Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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_odbc_provider_info() -> dict:
"""Return the selected ODBC 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()

Comment thread
gargsaumya marked this conversation as resolved.

# 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",
"odbc_provider",
# ODBC provider diagnostics
"get_odbc_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 odbc_provider(self) -> str:
"""Get the 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_ODBC_PROVIDER`` environment
variable takes precedence over this property.
"""
return ProviderManager.effective()

@odbc_provider.setter
def odbc_provider(self, value: Optional[str]) -> None:
"""Set the 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
7 changes: 7 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 @@ -368,6 +369,12 @@ def __init__(
>>> # Return native uuid.UUID objects instead of strings
>>> conn = ms.connect("Server=myserver;Database=mydb", native_uuid=True)
"""
# Resolve and freeze the ODBC provider before the native driver loads,
# then hand the selection to the native loader so it imports the matching
# provider package.
_provider = ProviderManager.ensure_available()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this resolves and freezes the provider before the native_uuid type validation just below, so a call that is about to raise TypeError still freezes the selection as a side effect. Moving the two lines after the argument validation is free and keeps the freeze tied to a connection attempt that actually proceeds.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this provider resolution happens after the native extension has already attempted to load the classic driver. Importing mssql_python loads the extension, whose PYBIND11_MODULE calls DriverLoader::loadDriver() at ddbc_bindings.cpp:6302. That call is protected by std::call_once, so it permanently records either the classic driver handle or the classic load error before this line can push mssql-odbc.

I reproduced this by selecting mssql-odbc in a fresh process with stand-in provider packages; connect() still surfaced the mssql-python-odbc completeness error. Please remove the import-time loadDriver() block and rely on the existing lazy load from the native connection path, so this push really runs first. A subprocess test should select Rust with incomplete stand-in packages and assert that the native error names mssql-python-rust-odbc, not the classic distribution.

ddbc_bindings.set_odbc_provider(_provider)

# Store per-connection native_uuid override.
# None means "use module-level mssql_python.native_uuid".
if native_uuid is not None and not isinstance(native_uuid, bool):
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
odbc_provider: str # Selects the ODBC provider ('msodbcsql18' or 'mssql-odbc')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this only half-closes the earlier review point about Optional.

ProviderManager.set_property() and the module setter at __init__.py:614-617 both accept Optional[str] (documented as "or None to clear"), so mssql_python.odbc_provider = None works at runtime but is a type error for anyone consuming the stubs. A module-level .pyi variable can't express asymmetric get/set types, so it has to be one or the other: either annotate Optional[str] here, or drop None support from the public setter.

I'd lean toward dropping it — clearing the selection has no test, and given the freeze semantics there's a narrow window in which it does anything at all.


# 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_odbc_provider_info() -> Dict[str, object]: ...
def get_info_constants() -> Dict[str, int]: ...

# Logging Functions
Expand Down
177 changes: 177 additions & 0 deletions mssql_python/odbc_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
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_ODBC_PROVIDER`` environment variable, the ``mssql_python.odbc_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

ODBC_PROVIDER_ENV_VAR = "MSSQL_PYTHON_ODBC_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(ODBC_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."""
with cls._lock:
if cls._resolved is not None:
return cls._resolved
provider, _ = cls._compute()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: _compute() raises ValueError on an unrecognized env var, and that propagates out of effective() — so the diagnostics can't diagnose the one misconfiguration they exist for. Verified by running the module standalone with MSSQL_PYTHON_ODBC_PROVIDER=bogus-value:

get_info()    RAISED: ValueError Unknown ODBC provider 'bogus-value'. Valid providers are: msodbcsql18, mssql-odbc.
effective()   RAISED: ValueError Unknown ODBC provider 'bogus-value'. Valid providers are: msodbcsql18, mssql-odbc.

Failing closed at resolve() / ensure_available() is right and I'm not suggesting changing that. The problem is the read-only paths that share _compute():

  • get_odbc_provider_info() raises instead of reporting the bad value.
  • effective() also backs the getter for mssql_python.odbc_provider (__init__.py:605-613), so plain attribute access raises. And since "odbc_provider" is in __all__ (__init__.py:528), from mssql_python import * would raise ValueError at import — before any connection is attempted, from a line that has nothing to do with providers.

Consider having the non-freezing paths report the raw invalid value (e.g. an error key in get_info, and the default from the getter) and keep the hard failure at resolve time, where it is actionable.

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:
"""Resolve and freeze the provider, verifying its package is installed.

Called before the native driver loads. Fails closed with an actionable
error if the selected provider's package is missing, rather than
silently loading a different provider.
"""
provider = cls.resolve()
package = _PACKAGE_BY_PROVIDER[provider]
try:
importlib.import_module(package)
except ImportError as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this rewrites every ImportError as “the provider package is not installed.” If the package is present but its initialization or a transitive dependency fails, the actionable underlying error is hidden behind an incorrect installation hint.

Catch ModuleNotFoundError and translate it only when exc.name == package; otherwise re-raise the original exception. The test double should construct ModuleNotFoundError(..., name=name) so it matches real import behavior.

except ModuleNotFoundError as exc:
    if exc.name != package:
        raise
    dist = _DIST_BY_PROVIDER[provider]
    ...

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 provider

@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."""
provider = cls._resolved if cls._resolved is not None else cls.effective()
return {
"id": provider,
"package": _PACKAGE_BY_PROVIDER[provider],
"source": cls._source,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: source is always None until the provider freezes, so the diagnostic is blank in exactly the window you'd call it.

_compute() returns the source, but get_info reads cls._source, which only resolve() ever assigns. Confirmed by running the module standalone with MSSQL_PYTHON_ODBC_PROVIDER=mssql-odbc set:

BEFORE resolve: {'id': 'mssql-odbc', 'package': 'mssql_python_rust_odbc', 'source': None,          'frozen': False}
AFTER  resolve: {'id': 'mssql-odbc', 'package': 'mssql_python_rust_odbc', 'source': 'environment', 'frozen': True}

Reporting the id but not where it came from is the opposite of useful when someone is trying to work out why they're getting a provider they didn't expect — which is the pre-connect case.

if cls._resolved is not None:
    provider, source = cls._resolved, cls._source
else:
    provider, source = cls._compute()

test_get_info_before_and_after_resolve (tests/test_026_odbc_provider.py:97-109) asserts id, package and frozen before resolve but skips source, which is why this passes today.

"frozen": cls._resolved is not None,
}

@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"{ODBC_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
6 changes: 6 additions & 0 deletions mssql_python/pooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from mssql_python import ddbc_bindings
from mssql_python.logging import logger
from mssql_python.odbc_provider import ProviderManager


class PoolingManager:
Expand Down Expand Up @@ -62,6 +63,11 @@ def enable(cls, max_size: int = 100, idle_timeout: int = 600) -> None:
max_size,
idle_timeout,
)
# Enabling pooling loads the native driver; resolve and push the
# ODBC provider first so an explicit pooling() before any connect
# still honors the selection (mirrors Connection.__init__).
_provider = ProviderManager.ensure_available()
ddbc_bindings.set_odbc_provider(_provider)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the comment above is factually wrong, and the code has a side effect that works against the feature this PR adds.

enable_pooling does not load the native driver. It is ddbc_bindings.cpp:6068-6078ConnectionPoolManager::configure() (pybind/connection/connection_pool.cpp:486-493, pure state assignment) plus setAccepting(true) (:525-528). There is no loadDriver() call anywhere in pybind/connection/*.cpp except the Connection constructor at connection.cpp:25.

So the hook protects nothing, but it does two user-visible things:

  • mssql_python.pooling() now freezes the provider. ms.pooling(max_size=50) followed by ms.odbc_provider = "mssql-odbc" silently becomes a no-op plus a RuntimeWarning. That is exactly the opt-in route this PR exists to add, and pooling() is a natural first line in a startup block — so the ordering trap is easy to hit and gives no error, just a warning.
  • pooling() can now raise ImportError. A configuration call that previously could only raise ValueError on bad parameters now fails when the selected provider's wheel is absent.

Deleting lines 69-70 loses nothing. On the auto-enable path, Connection.__init__ already pushed at connection.py:376 before reaching PoolingManager.enable() at line 737; on the explicit path, the next connect() pushes before the driver loads either way.

If you'd rather keep it as defence-in-depth against a future enable_pooling that does load the driver, use the non-freezing accessor:

ddbc_bindings.set_odbc_provider(ProviderManager.effective())

Either way the comment needs correcting — as written it will stop the next reader from removing this.

ddbc_bindings.enable_pooling(max_size, idle_timeout)
cls._config["max_size"] = max_size
cls._config["idle_timeout"] = idle_timeout
Expand Down
Loading
Loading