-
Notifications
You must be signed in to change notification settings - Fork 55
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
Open
gargsaumya
wants to merge
40
commits into
main
Choose a base branch
from
saumya/rust-odbc-optin
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
7f5a785
Add opt-in/opt-out ODBC provider selection (msodbcsql18 / mssql-odbc)
gargsaumya 0696e5b
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya b2bdc27
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya e9372ba
Address PR review: drop banned getenv, fix provider-aware logs, add t…
gargsaumya 1088ba8
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya 42f49b2
Apply black formatting (line-length=100)
gargsaumya 6aab9dd
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya f854f8d
Fix stale comment: native side has no env-var fallback
gargsaumya 094dc2f
Fix Rust provider driver filenames to mssql-odbc.{dll,so,dylib}
gargsaumya 5d42bfa
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya 8ac75ec
FIX: address PR review on ODBC provider selection
gargsaumya 41703fa
REFACTOR: rename odbc_provider -> native_provider public surface
gargsaumya 254b5e3
STYLE: align user-facing messages/docstrings to native provider vocab…
gargsaumya fb9d271
FIX: correct Rust driver artifact filename to mssqlodbc (no hyphen)
gargsaumya 5a1b366
FIX: use non-connecting Server=test in provider regression test (DevS…
gargsaumya 08b2aae
FIX: set name= on test_025 missing-package ModuleNotFoundError
gargsaumya 0e5506a
CHORE: keep faulthandler active through shutdown to capture CI crash …
gargsaumya 9f88bd1
FIX: force DriverLoader singleton construction at import to preserve …
gargsaumya 2de01cc
REVERT: roll back review-comment fixes (8ac75eca..9f88bd1b) that intr…
gargsaumya 1d09109
FIX: defer ODBC driver load without disturbing DriverLoader singleton…
gargsaumya 608c5f6
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya 4804580
FIX: address remaining PR review comments on provider selection
gargsaumya 49264a7
FIX: address remaining low-risk PR review nits on provider selection
gargsaumya f5d4563
FIX: require mssql-auth.dll for both ODBC providers on Windows
gargsaumya 1b782ee
FIX: rename set_odbc_provider to _set_odbc_provider, guard against po…
gargsaumya 19ceb73
FIX: finalize Rust provider paths, scope mssql-auth.dll to classic pr…
gargsaumya 8049922
Fix Rust provider path and loading docs
gargsaumya 1c6459c
Format provider path test with Black
gargsaumya 464f53d
Fix default provider repush after native load
gargsaumya 54ceef1
Avoid localhost in provider regression test
gargsaumya cf0f62d
fix: use _Py_IsFinalizing() for GIL-free shutdown detection on Python…
gargsaumya b7cbeed
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya 54eb190
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya 05e49d5
Address ODBC provider diagnostics feedback
gargsaumya 03880cf
Merge remote-tracking branch 'origin/main' into saumya/rust-odbc-optin
Copilot 3c91190
Fix provider path assertions across platforms
gargsaumya ccaba00
Resolve mssql-odbc provider from mssql_py_core (mssql-python-rs)
gargsaumya 26d4811
Merge branch 'main' into saumya/rust-odbc-optin
gargsaumya 9c251f1
Fix provider selection race during driver load
gargsaumya 407a05d
Add mssql-odbc provider connection smoke tests
gargsaumya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| """ | ||
| 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 | ||
| inside ``mssql_py_core`` / the ``mssql-python-rs`` wheel). 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_py_core", | ||
| } | ||
|
|
||
| # 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-rs", | ||
| } | ||
|
|
||
|
|
||
| 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 | ||
|
gargsaumya marked this conversation as resolved.
|
||
| env_value = os.environ.get(NATIVE_PROVIDER_ENV_VAR) | ||
| if canonical is not None and env_value and env_value.strip(): | ||
| try: | ||
| env_provider = _normalize(env_value) | ||
| except ValueError: | ||
| # Preserve the existing fail-closed error at connection time. | ||
| return | ||
| if canonical != env_provider: | ||
| cls._warn_env_override(canonical, env_provider) | ||
|
|
||
| @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) | ||
|
gargsaumya marked this conversation as resolved.
|
||
| except ModuleNotFoundError as exc: | ||
|
gargsaumya marked this conversation as resolved.
|
||
| 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]: | ||
|
gargsaumya marked this conversation as resolved.
|
||
| """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) | ||
| frozen = cls._resolved is not None | ||
|
|
||
| version = None | ||
| driver_path = None | ||
| package = _PACKAGE_BY_PROVIDER[provider] | ||
| try: | ||
| provider_module = importlib.import_module(package) | ||
| version = getattr(provider_module, "__version__", None) | ||
| module_file = getattr(provider_module, "__file__", None) | ||
| if module_file: | ||
| from mssql_python import ddbc_bindings | ||
|
|
||
| driver_path = ddbc_bindings._get_odbc_driver_path( | ||
| os.path.dirname(os.path.abspath(module_file)), provider | ||
| ) | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
| # Diagnostics must remain safe even for a broken provider package. | ||
| pass | ||
|
|
||
| info: Dict[str, object] = { | ||
| "id": provider, | ||
| "package": package, | ||
| "version": version, | ||
| "driver_path": driver_path, | ||
| "source": source, | ||
| "frozen": frozen, | ||
| } | ||
| if error is not None: | ||
| info["error"] = error | ||
| return info | ||
|
|
||
| @classmethod | ||
| def _warn_env_override(cls, requested: str, effective: str) -> None: | ||
| message = ( | ||
| f"ODBC provider property was set to '{requested}', but " | ||
| f"{NATIVE_PROVIDER_ENV_VAR} selects '{effective}' and takes precedence." | ||
| ) | ||
| logger.warning(message) | ||
| warnings.warn(message, RuntimeWarning, stacklevel=3) | ||
|
|
||
| @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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.