Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7f5a785
Add opt-in/opt-out ODBC provider selection (msodbcsql18 / mssql-odbc)
gargsaumya Aug 26, 2026
0696e5b
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya Aug 26, 2026
b2bdc27
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya Aug 27, 2026
e9372ba
Address PR review: drop banned getenv, fix provider-aware logs, add t…
gargsaumya Aug 27, 2026
1088ba8
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya Aug 27, 2026
42f49b2
Apply black formatting (line-length=100)
gargsaumya Aug 27, 2026
6aab9dd
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya Aug 27, 2026
f854f8d
Fix stale comment: native side has no env-var fallback
gargsaumya Aug 27, 2026
094dc2f
Fix Rust provider driver filenames to mssql-odbc.{dll,so,dylib}
gargsaumya Aug 27, 2026
5d42bfa
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya Aug 28, 2026
8ac75ec
FIX: address PR review on ODBC provider selection
gargsaumya Aug 31, 2026
41703fa
REFACTOR: rename odbc_provider -> native_provider public surface
gargsaumya Aug 31, 2026
254b5e3
STYLE: align user-facing messages/docstrings to native provider vocab…
gargsaumya Aug 31, 2026
fb9d271
FIX: correct Rust driver artifact filename to mssqlodbc (no hyphen)
gargsaumya Aug 31, 2026
5a1b366
FIX: use non-connecting Server=test in provider regression test (DevS…
gargsaumya Aug 31, 2026
08b2aae
FIX: set name= on test_025 missing-package ModuleNotFoundError
gargsaumya Aug 31, 2026
0e5506a
CHORE: keep faulthandler active through shutdown to capture CI crash …
gargsaumya Aug 31, 2026
9f88bd1
FIX: force DriverLoader singleton construction at import to preserve …
gargsaumya Sep 1, 2026
2de01cc
REVERT: roll back review-comment fixes (8ac75eca..9f88bd1b) that intr…
gargsaumya Sep 1, 2026
1d09109
FIX: defer ODBC driver load without disturbing DriverLoader singleton…
gargsaumya Sep 1, 2026
608c5f6
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya Sep 1, 2026
4804580
FIX: address remaining PR review comments on provider selection
gargsaumya Sep 1, 2026
49264a7
FIX: address remaining low-risk PR review nits on provider selection
gargsaumya Sep 1, 2026
f5d4563
FIX: require mssql-auth.dll for both ODBC providers on Windows
gargsaumya Sep 1, 2026
1b782ee
FIX: rename set_odbc_provider to _set_odbc_provider, guard against po…
gargsaumya Sep 1, 2026
19ceb73
FIX: finalize Rust provider paths, scope mssql-auth.dll to classic pr…
gargsaumya Sep 1, 2026
8049922
Fix Rust provider path and loading docs
gargsaumya Sep 1, 2026
1c6459c
Format provider path test with Black
gargsaumya Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
This is a non-breaking step toward decoupling driver-binary updates from
mssql-python releases; a future major version will make the dependency
explicit and drop the bundled binaries.
- **Opt-in/opt-out native provider selection:** a new `mssql_python.native_provider`
module property (and `MSSQL_PYTHON_NATIVE_PROVIDER` environment variable, which
takes precedence) lets a caller select which native ODBC provider is loaded:
the default `"msodbcsql18"` (Microsoft ODBC Driver 18, unchanged behavior) or
the opt-in `"mssql-odbc"` (a Rust-based driver, shipped separately as
`mssql-python-rust-odbc`). The selection must be made before the first
connection; it resolves and freezes then, and a later change is ignored with
a `RuntimeWarning`. Call `mssql_python.get_native_provider_info()` for
diagnostics (selected id, package, source, and whether it's frozen). This PR
does not change the default provider or ship any Rust driver binaries.

### Changed
- Connection strings and string connection parameters that contain a NUL
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ The driver is compatible with all the Python versions >= 3.10
> - Import name: `mssql_python_odbc`
> - Current version: **18.6.2.1**
>
> `mssql-python` depends on `mssql-python-odbc==18.6.2.1` and loads the ODBC driver binaries from it at import time. `pip install mssql-python` transparently pulls the companion package alongside it — no separate install step is required.
> `mssql-python` depends on `mssql-python-odbc==18.6.2.1`. The ODBC driver is loaded lazily when the first connection is created. `pip install mssql-python` transparently pulls the companion package alongside it — no separate install step is required.
>
> Starting with v1.13.0, the bundled `libs/` fallback that shipped in v1.12.0 has been removed. `mssql-python` will fail to import if `mssql-python-odbc` is not installed. If you install `mssql-python` from a private index or with `--no-deps`, make sure `mssql-python-odbc==18.6.2.1` is installed alongside it.
> Starting with v1.13.0, the bundled `libs/` fallback that shipped in v1.12.0 has been removed. Creating a connection will fail if `mssql-python-odbc` is not installed. If you install `mssql-python` from a private index or with `--no-deps`, make sure `mssql-python-odbc==18.6.2.1` is installed alongside it.
>
> ### ODBC Provider Selection (opt-in)
> `mssql-python` also supports selecting an alternate native ODBC provider before the first connection, via the `mssql_python.native_provider` module property or the `MSSQL_PYTHON_NATIVE_PROVIDER` environment variable (which takes precedence). The default, `"msodbcsql18"`, is unchanged; opting into `"mssql-odbc"` requires the separate `mssql-python-rust-odbc` package. Call `mssql_python.get_native_provider_info()` to check the selected provider and its source. This is plumbing only today — no Rust driver binaries are published yet.

## Installation

Expand Down
33 changes: 33 additions & 0 deletions mssql_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import threading
import types
import weakref
from typing import Optional

# Import settings from helpers module
from .helpers import Settings, get_settings, _settings, _settings_lock
Expand Down Expand Up @@ -75,6 +76,20 @@
# Pooling
from .pooling import PoolingManager

# ODBC provider selection
from .odbc_provider import ProviderManager


def get_native_provider_info() -> dict:
"""Return the selected native provider for diagnostics.

Reports the provider ``id``, the ``package`` that ships its native binaries,
the selection ``source`` (once resolved), and whether the choice is
``frozen`` (loaded and no longer changeable).
"""
return ProviderManager.get_info()


# Global registry for tracking active connections (using weak references)
_active_connections = weakref.WeakSet()
_connections_lock = threading.Lock()
Expand Down Expand Up @@ -510,6 +525,9 @@ def _cleanup_connections():
# Module properties
"lowercase",
"native_uuid",
"native_provider",
# Native provider diagnostics
"get_native_provider_info",
]


Expand Down Expand Up @@ -583,6 +601,21 @@ def native_uuid(self, value: bool) -> None:
with _settings_lock:
_settings.native_uuid = value

@property
def native_provider(self) -> str:
"""Get the native ODBC provider that will be (or was) loaded.

Honored only when set before the first connection; a later change is
ignored with a warning. The ``MSSQL_PYTHON_NATIVE_PROVIDER`` environment
variable takes precedence over this property.
"""
return ProviderManager.effective()

@native_provider.setter
def native_provider(self, value: Optional[str]) -> None:
"""Set the native ODBC provider selection (or None to clear)."""
ProviderManager.set_property(value)


# Replace the current module with our custom module class
old_module: types.ModuleType = sys.modules[__name__]
Expand Down
11 changes: 11 additions & 0 deletions mssql_python/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from mssql_python.logging import logger
from mssql_python import ddbc_bindings
from mssql_python.pooling import PoolingManager
from mssql_python.odbc_provider import ProviderManager
from mssql_python.exceptions import (
Warning, # pylint: disable=redefined-builtin
Error,
Expand Down Expand Up @@ -730,6 +731,16 @@ def _token_factory():
PoolingManager.enable()
self._pooling = PoolingManager.is_enabled()

# Resolve and freeze the ODBC provider, then hand the selection to the
# native loader so it imports the matching provider package. Done here —
# after every Python-side validation and token acquisition has succeeded
# and immediately before the native driver loads — so a call that fails
# earlier never freezes the selection as a side effect; a later, corrected
# connection can then still choose a different provider without a process
# restart.
_provider = ProviderManager.ensure_available()
ddbc_bindings._set_odbc_provider(_provider)

try:
self._conn = ddbc_bindings.Connection(
self.connection_str,
Expand Down
2 changes: 2 additions & 0 deletions mssql_python/mssql_python.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ threadsafety: int # 1
# Module Settings - Properties that can be get/set at module level
lowercase: bool # Controls column name case behavior
native_uuid: bool # Controls UUID type handling
native_provider: Optional[str] # Selects the native ODBC provider ('msodbcsql18' or 'mssql-odbc')

# Settings Class
class Settings:
Expand All @@ -43,6 +44,7 @@ def get_settings() -> Settings: ...
def setDecimalSeparator(separator: str) -> None: ...
def getDecimalSeparator() -> str: ...
def pooling(max_size: int = 100, idle_timeout: int = 600, enabled: bool = True) -> None: ...
def get_native_provider_info() -> Dict[str, object]: ...
def get_info_constants() -> Dict[str, int]: ...

# Logging Functions
Expand Down
211 changes: 211 additions & 0 deletions mssql_python/odbc_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""
Copyright (c) Microsoft Corporation.
Licensed under the MIT license.
Selects which ODBC provider (native driver package) mssql-python loads.

Two providers are supported: ``msodbcsql18`` (the Microsoft ODBC Driver 18,
shipped by ``mssql_python_odbc``) and ``mssql-odbc`` (the Rust driver, shipped
by ``mssql_python_rust_odbc``). Selection is process-wide and resolved exactly
once, before the native driver loads, from — in precedence order — the
``MSSQL_PYTHON_NATIVE_PROVIDER`` environment variable, the ``mssql_python.native_provider``
module property, then the release default. An unknown value fails closed rather
than falling back.
"""

import os
import threading
import warnings
import importlib
from typing import Dict, Optional, Tuple

from mssql_python.logging import logger

NATIVE_PROVIDER_ENV_VAR = "MSSQL_PYTHON_NATIVE_PROVIDER"

# Customer-facing provider identifiers.
PROVIDER_MSODBCSQL18 = "msodbcsql18"
PROVIDER_MSSQL_ODBC = "mssql-odbc"

# Phase 1 default. Phase 2 flips this to PROVIDER_MSSQL_ODBC via a documented release.
_DEFAULT_PROVIDER = PROVIDER_MSODBCSQL18

# Provider -> import package that ships its native binaries.
_PACKAGE_BY_PROVIDER: Dict[str, str] = {
PROVIDER_MSODBCSQL18: "mssql_python_odbc",
PROVIDER_MSSQL_ODBC: "mssql_python_rust_odbc",
}

# Provider -> the pip distribution that installs its package (for error hints).
_DIST_BY_PROVIDER: Dict[str, str] = {
PROVIDER_MSODBCSQL18: "mssql-python-odbc",
PROVIDER_MSSQL_ODBC: "mssql-python-rust-odbc",
}


def _normalize(value: str) -> str:
"""Return the canonical provider id for ``value`` or raise ``ValueError``.

An unrecognized selection is rejected so a typo fails closed instead of
silently loading the default provider.
"""
canonical = value.strip().lower()
if canonical not in _PACKAGE_BY_PROVIDER:
valid = ", ".join(sorted(_PACKAGE_BY_PROVIDER))
raise ValueError(f"Unknown ODBC provider {value!r}. Valid providers are: {valid}.")
return canonical


class ProviderManager:
"""Process-wide, resolve-once selector for the ODBC provider.

The selection freezes when :meth:`resolve` first runs (at native driver
load). A later change to the module property is ignored with a warning,
mirroring the connection-pool configuration model.
"""

_lock: threading.Lock = threading.Lock()
_property_value: Optional[str] = None
_resolved: Optional[str] = None
_source: Optional[str] = None

@classmethod
def _compute(cls) -> Tuple[str, str]:
"""Apply precedence env var -> module property -> default (lock-free)."""
env_value = os.environ.get(NATIVE_PROVIDER_ENV_VAR)
if env_value and env_value.strip():
return _normalize(env_value), "environment"
if cls._property_value is not None:
return cls._property_value, "property"
return _DEFAULT_PROVIDER, "default"

@classmethod
def set_property(cls, value: Optional[str]) -> None:
"""Set the module-property selection.

Accepts a provider id or ``None`` to clear. A change after the provider
has been resolved is ignored with a warning; the env var still takes
precedence over this value when both are set.
"""
with cls._lock:
canonical = _normalize(value) if value is not None else None
if cls._resolved is not None:
if canonical != cls._resolved:
cls._warn_frozen()
return
cls._property_value = canonical

@classmethod
def resolve(cls) -> str:
"""Resolve and freeze the provider, returning its canonical id."""
with cls._lock:
if cls._resolved is None:
cls._resolved, cls._source = cls._compute()
logger.info(
"ODBC provider resolved to '%s' (source=%s)",
cls._resolved,
cls._source,
)
return cls._resolved

@classmethod
def effective(cls) -> str:
"""Return the provider that would be used, without freezing it.

Reports the release default for an invalid selection (e.g. a mistyped
env var) rather than raising - this backs the public getter and
diagnostics, which must stay safe to read at any time. The hard
failure for a bad selection surfaces at :meth:`resolve`/
:meth:`ensure_available` instead.
"""
with cls._lock:
if cls._resolved is not None:
return cls._resolved
try:
provider, _ = cls._compute()
except ValueError:
return _DEFAULT_PROVIDER
return provider

@classmethod
def package_name(cls, provider: Optional[str] = None) -> str:
"""Return the import package that ships ``provider``'s native binaries."""
provider = provider or cls.effective()
return _PACKAGE_BY_PROVIDER[provider]

@classmethod
def ensure_available(cls) -> str:
"""Verify the selected provider's package is installed, then freeze it.

Called before the native driver loads. Fails closed with an actionable
error if the package is missing. The selection is only frozen (via
:meth:`resolve`) once the package has been confirmed importable, so a
failed check here does not permanently lock in a provider that never
actually loaded - a later call can still select a different, installed
provider instead of requiring a process restart.
"""
provider = cls.effective()
package = _PACKAGE_BY_PROVIDER[provider]
try:
importlib.import_module(package)
except ModuleNotFoundError as exc:
if exc.name != package:
# A transitive dependency of an installed package is missing,
# or the package is broken - don't mask it as "not installed".
raise
dist = _DIST_BY_PROVIDER[provider]
raise ImportError(
f"The '{provider}' ODBC provider is selected but its package "
f"'{package}' is not installed. Install it with: pip install {dist}"
) from exc
return cls.resolve()

@classmethod
def is_frozen(cls) -> bool:
"""Whether the provider has been resolved and can no longer change."""
return cls._resolved is not None

@classmethod
def get_info(cls) -> Dict[str, object]:
"""Report the selected provider for diagnostics.

Never raises: an invalid selection is reported via the ``error`` key
(with ``id`` falling back to the default) instead of propagating, so
this stays safe to call at any time, including before a provider is
chosen or resolvable.
"""
with cls._lock:
if cls._resolved is not None:
provider, source, error = cls._resolved, cls._source, None
else:
try:
provider, source = cls._compute()
error = None
except ValueError as exc:
provider, source, error = _DEFAULT_PROVIDER, None, str(exc)
info: Dict[str, object] = {
"id": provider,
"package": _PACKAGE_BY_PROVIDER[provider],
"source": source,
"frozen": cls._resolved is not None,
}
if error is not None:
info["error"] = error
return info

@classmethod
def _warn_frozen(cls) -> None:
message = (
f"ODBC provider is already loaded as '{cls._resolved}'; ignoring the "
f"change. Select a provider before the first connection, or set the "
f"{NATIVE_PROVIDER_ENV_VAR} environment variable."
)
logger.warning(message)
warnings.warn(message, RuntimeWarning, stacklevel=3)

@classmethod
def _reset_for_testing(cls) -> None:
"""Reset selection state - for testing purposes only."""
with cls._lock:
cls._property_value = None
cls._resolved = None
cls._source = None
4 changes: 4 additions & 0 deletions mssql_python/pooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def enable(cls, max_size: int = 100, idle_timeout: int = 600) -> None:
max_size,
idle_timeout,
)
# enable_pooling() only configures the connection-pool manager; it
# does not load the native driver, so it must not resolve/freeze
# the ODBC provider (an explicit pooling() before selecting a
# provider would otherwise lock in the default prematurely).
ddbc_bindings.enable_pooling(max_size, idle_timeout)
cls._config["max_size"] = max_size
cls._config["idle_timeout"] = idle_timeout
Expand Down
Loading
Loading