-
Notifications
You must be signed in to change notification settings - Fork 54
FEAT: Add opt-in/opt-out ODBC provider selection (msodbcsql18 / mssql-odbc) #730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7f5a785
0696e5b
b2bdc27
e9372ba
1088ba8
42f49b2
6aab9dd
f854f8d
094dc2f
5d42bfa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this resolves and freezes the provider before the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I reproduced this by selecting |
||
| 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): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: this only half-closes the earlier review point about
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: | ||
|
|
@@ -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 | ||
|
|
||
| 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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Failing closed at
Consider having the non-freezing paths report the raw invalid value (e.g. an |
||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: this rewrites every Catch 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion:
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()
|
||
| "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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
So the hook protects nothing, but it does two user-visible things:
Deleting lines 69-70 loses nothing. On the auto-enable path, If you'd rather keep it as defence-in-depth against a future 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.