diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index 542f1a185..54f341383 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -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 @@ -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() + + # Global registry for tracking active connections (using weak references) _active_connections = weakref.WeakSet() _connections_lock = threading.Lock() @@ -510,6 +525,9 @@ def _cleanup_connections(): # Module properties "lowercase", "native_uuid", + "odbc_provider", + # ODBC provider diagnostics + "get_odbc_provider_info", ] @@ -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__] diff --git a/mssql_python/connection.py b/mssql_python/connection.py index a618c0954..d4019a66a 100644 --- a/mssql_python/connection.py +++ b/mssql_python/connection.py @@ -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() + 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): diff --git a/mssql_python/mssql_python.pyi b/mssql_python/mssql_python.pyi index 5df22d203..b7d526796 100644 --- a/mssql_python/mssql_python.pyi +++ b/mssql_python/mssql_python.pyi @@ -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') # 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 diff --git a/mssql_python/odbc_provider.py b/mssql_python/odbc_provider.py new file mode 100644 index 000000000..a83d5e67e --- /dev/null +++ b/mssql_python/odbc_provider.py @@ -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() + 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: + 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, + "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 diff --git a/mssql_python/pooling.py b/mssql_python/pooling.py index 6543b07e3..d77c0e350 100644 --- a/mssql_python/pooling.py +++ b/mssql_python/pooling.py @@ -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) ddbc_bindings.enable_pooling(max_size, idle_timeout) cls._config["max_size"] = max_size cls._config["idle_timeout"] = idle_timeout diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index cd3a45fa6..d6d8f9e3b 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -971,6 +971,63 @@ std::string GetLastErrorMessage(); // verify the external package actually ships this platform's driver binary.) std::string GetDriverPathCpp(const std::string& moduleDir); +// ----------------------------------------------------------------------------- +// ODBC provider selection +// +// Two providers are supported: the classic Microsoft ODBC Driver 18 +// ("msodbcsql18", shipped by mssql_python_odbc) and the Rust driver +// ("mssql-odbc", shipped by mssql_python_rust_odbc). Python is the sole +// resolver (env var -> module property -> default) and pushes the chosen id +// here via set_odbc_provider() before the driver loads. The native side does +// not read the environment itself; if the push has not happened yet, it falls +// back to the hardcoded classic default. +// ----------------------------------------------------------------------------- +#include + +namespace { +constexpr const char* kProviderMsodbcsql18 = "msodbcsql18"; +constexpr const char* kProviderMssqlOdbc = "mssql-odbc"; + +std::mutex g_providerMutex; +std::string g_selectedProvider; // pushed from Python before load; "" = unset + +std::string NormalizeProviderId(const std::string& id) { + std::string out; + out.reserve(id.size()); + for (char c : id) { + if (std::isspace(static_cast(c))) { + continue; + } + out.push_back(static_cast(std::tolower(static_cast(c)))); + } + return out; +} +} // namespace + +void SetSelectedProvider(const std::string& id) { + std::lock_guard lock(g_providerMutex); + g_selectedProvider = NormalizeProviderId(id); +} + +// Effective provider id: the value pushed from Python, else the classic default. +// Python is the authoritative resolver (env var -> module property -> default) +// and pushes the result via set_odbc_provider() before the driver loads. +std::string GetSelectedProviderId() { + std::lock_guard lock(g_providerMutex); + if (g_selectedProvider == kProviderMssqlOdbc) { + return kProviderMssqlOdbc; + } + return kProviderMsodbcsql18; +} + +std::string ProviderPackageForId(const std::string& id) { + return (id == kProviderMssqlOdbc) ? "mssql_python_rust_odbc" : "mssql_python_odbc"; +} + +std::string ProviderDistForId(const std::string& id) { + return (id == kProviderMssqlOdbc) ? "mssql-python-rust-odbc" : "mssql-python-odbc"; +} + std::string GetOdbcLibsBaseDir() { namespace fs = std::filesystem; // This function calls into the Python C-API (py::module::import, attribute @@ -980,8 +1037,11 @@ std::string GetOdbcLibsBaseDir() { // dependency and keeps a future GIL-released caller from turning this into a // hard crash. py::gil_scoped_acquire gil; + const std::string providerId = GetSelectedProviderId(); + const std::string packageName = ProviderPackageForId(providerId); + const std::string distName = ProviderDistForId(providerId); try { - py::object module = py::module::import("mssql_python_odbc"); + py::object module = py::module::import(packageName.c_str()); py::object module_path = module.attr("__file__"); std::string module_file = module_path.cast(); @@ -1008,32 +1068,32 @@ std::string GetOdbcLibsBaseDir() { } #endif if (!externalComplete) { - LOG("GetOdbcLibsBaseDir: mssql_python_odbc present at '%s' but its ODBC driver " + LOG("GetOdbcLibsBaseDir: %s present at '%s' but its ODBC driver " "binaries are missing or incomplete for this platform", - parentDir.string().c_str()); + packageName.c_str(), parentDir.string().c_str()); ThrowStdException( - "The 'mssql-python-odbc' package is installed but its ODBC driver binaries " + "The '" + distName + "' package is installed but its ODBC driver binaries " "are missing or incomplete for this platform. Reinstall it with: " - "pip install --force-reinstall mssql-python-odbc"); + "pip install --force-reinstall " + distName); } - LOG("GetOdbcLibsBaseDir: Using external mssql_python_odbc package - directory='%s'", - parentDir.string().c_str()); + LOG("GetOdbcLibsBaseDir: Using external %s package - directory='%s'", + packageName.c_str(), parentDir.string().c_str()); return parentDir.string(); } catch (const py::error_already_set& e) { if (e.matches(PyExc_ModuleNotFoundError)) { // Phase 2: the standalone package is required. Turn the missing // dependency into a clear, actionable error instead of a fallback. - LOG("GetOdbcLibsBaseDir: required package mssql_python_odbc is not installed (%s)", - e.what()); + LOG("GetOdbcLibsBaseDir: required package %s is not installed (%s)", + packageName.c_str(), e.what()); ThrowStdException( - "The required 'mssql-python-odbc' package (which ships the ODBC driver " - "binaries) is not installed. Install it with: pip install mssql-python-odbc"); + "The required '" + distName + "' package (which ships the ODBC driver " + "binaries) is not installed. Install it with: pip install " + distName); } // A different import-time error means the package is installed but // broken; surface it instead of silently masking the real problem. - LOG("GetOdbcLibsBaseDir: importing mssql_python_odbc failed unexpectedly (%s); " + LOG("GetOdbcLibsBaseDir: importing %s failed unexpectedly (%s); " "re-raising", - e.what()); + packageName.c_str(), e.what()); throw; } } @@ -1122,6 +1182,25 @@ std::string GetDriverPathCpp(const std::string& moduleDir) { throw std::runtime_error("Unsupported architecture"); #endif + // Rust provider (mssql-odbc): ships as `mssql-odbc.{so,dylib,dll}` (no `lib` + // prefix on Linux/macOS) under an mssql-python-defined libs/ layout. + // mssql-python owns the provider wheel, so this layout is authoritative and + // finalized alongside that wheel build. + if (GetSelectedProviderId() == kProviderMssqlOdbc) { +#ifdef __linux__ + return (basePath / "libs" / "linux" / arch / "lib" / "mssql-odbc.so").string(); +#elif defined(__APPLE__) + return (basePath / "libs" / "macos" / arch / "lib" / "mssql-odbc.dylib").string(); +#elif defined(_WIN32) + { + std::string winArch = (arch == "x86_64") ? "x64" : arch; + return (basePath / "libs" / "windows" / winArch / "mssql-odbc.dll").string(); + } +#else + throw std::runtime_error("Unsupported platform"); +#endif + } + // Detect platform and set path #ifdef __linux__ if (fs::exists("/etc/alpine-release")) { @@ -1215,8 +1294,12 @@ DriverHandle LoadDriverOrThrowException() { LOG("LoadDriverOrThrowException: mssql-auth.dll not found at '%s' - " "Entra ID authentication will not be available", authDllPath.string().c_str()); - ThrowStdException("mssql-auth.dll not found. If you are using Entra " - "ID, please ensure it is present."); + // mssql-auth.dll ships with the classic driver; the Rust provider does + // not require it, so its absence is only fatal for msodbcsql18. + if (GetSelectedProviderId() != kProviderMssqlOdbc) { + ThrowStdException("mssql-auth.dll not found. If you are using Entra " + "ID, please ensure it is present."); + } } #endif @@ -6022,6 +6105,8 @@ PYBIND11_MODULE(ddbc_bindings, m) { // Expose the C++ functions to Python m.def("ThrowStdException", &ThrowStdException); m.def("GetDriverPathCpp", &GetDriverPathCpp, "Get the path to the ODBC driver"); + m.def("set_odbc_provider", &SetSelectedProvider, + "Select the ODBC provider ('msodbcsql18' or 'mssql-odbc') before the driver loads"); // Define parameter info class py::class_(m, "ParamInfo") diff --git a/tests/test_026_odbc_provider.py b/tests/test_026_odbc_provider.py new file mode 100644 index 000000000..2e75bf70a --- /dev/null +++ b/tests/test_026_odbc_provider.py @@ -0,0 +1,152 @@ +""" +Tests for ODBC provider selection (opt-in/opt-out). + +Covers the ``ProviderManager`` engine (precedence, normalization, fail-closed +validation, resolve-once freezing, post-freeze warning) and the public surface +(``mssql_python.odbc_provider`` property and ``get_odbc_provider_info()``). +""" + +import importlib +import sys + +import pytest + +import mssql_python +from mssql_python.odbc_provider import ( + ODBC_PROVIDER_ENV_VAR, + PROVIDER_MSODBCSQL18, + PROVIDER_MSSQL_ODBC, + ProviderManager, +) + + +@pytest.fixture(autouse=True) +def _reset_provider(monkeypatch): + """Clear provider state and the env var before and after each test.""" + monkeypatch.delenv(ODBC_PROVIDER_ENV_VAR, raising=False) + ProviderManager._reset_for_testing() + yield + ProviderManager._reset_for_testing() + + +def test_default_is_msodbcsql18(): + assert ProviderManager.effective() == PROVIDER_MSODBCSQL18 + assert ProviderManager.resolve() == PROVIDER_MSODBCSQL18 + + +def test_env_var_selects_provider(monkeypatch): + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, PROVIDER_MSSQL_ODBC) + assert ProviderManager.resolve() == PROVIDER_MSSQL_ODBC + + +def test_env_var_is_normalized(monkeypatch): + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, " MsSql-Odbc ") + assert ProviderManager.resolve() == PROVIDER_MSSQL_ODBC + + +def test_property_used_when_env_unset(): + ProviderManager.set_property(PROVIDER_MSSQL_ODBC) + assert ProviderManager.resolve() == PROVIDER_MSSQL_ODBC + + +def test_env_var_takes_precedence_over_property(monkeypatch): + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, PROVIDER_MSODBCSQL18) + ProviderManager.set_property(PROVIDER_MSSQL_ODBC) + assert ProviderManager.resolve() == PROVIDER_MSODBCSQL18 + + +def test_empty_env_var_falls_through_to_property(monkeypatch): + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, " ") + ProviderManager.set_property(PROVIDER_MSSQL_ODBC) + assert ProviderManager.resolve() == PROVIDER_MSSQL_ODBC + + +def test_invalid_property_fails_closed(): + with pytest.raises(ValueError): + ProviderManager.set_property("classic") + + +def test_invalid_env_var_fails_closed(monkeypatch): + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, "classic") + with pytest.raises(ValueError): + ProviderManager.resolve() + + +def test_resolve_freezes_selection(): + assert not ProviderManager.is_frozen() + ProviderManager.resolve() + assert ProviderManager.is_frozen() + # A second resolve is stable and does not re-read state. + assert ProviderManager.resolve() == PROVIDER_MSODBCSQL18 + + +def test_change_after_freeze_is_ignored_with_warning(): + ProviderManager.resolve() # freezes as default msodbcsql18 + with pytest.warns(RuntimeWarning): + ProviderManager.set_property(PROVIDER_MSSQL_ODBC) + assert ProviderManager.effective() == PROVIDER_MSODBCSQL18 + + +def test_same_value_after_freeze_does_not_warn(recwarn): + ProviderManager.resolve() + ProviderManager.set_property(PROVIDER_MSODBCSQL18) + assert len(recwarn) == 0 + + +def test_package_name_mapping(): + assert ProviderManager.package_name(PROVIDER_MSODBCSQL18) == "mssql_python_odbc" + assert ProviderManager.package_name(PROVIDER_MSSQL_ODBC) == "mssql_python_rust_odbc" + + +def test_get_info_before_and_after_resolve(monkeypatch): + info = ProviderManager.get_info() + assert info["id"] == PROVIDER_MSODBCSQL18 + assert info["package"] == "mssql_python_odbc" + assert info["frozen"] is False + + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, PROVIDER_MSSQL_ODBC) + ProviderManager.resolve() + info = ProviderManager.get_info() + assert info["id"] == PROVIDER_MSSQL_ODBC + assert info["package"] == "mssql_python_rust_odbc" + assert info["source"] == "environment" + assert info["frozen"] is True + + +def test_public_module_property_get_set(): + assert mssql_python.odbc_provider == PROVIDER_MSODBCSQL18 + mssql_python.odbc_provider = PROVIDER_MSSQL_ODBC + assert mssql_python.odbc_provider == PROVIDER_MSSQL_ODBC + + +def test_public_get_odbc_provider_info(): + info = mssql_python.get_odbc_provider_info() + assert info["id"] == PROVIDER_MSODBCSQL18 + assert info["frozen"] is False + + +def test_ensure_available_default_ok(): + # The default provider's package (mssql_python_odbc) ships with the driver. + assert ProviderManager.ensure_available() == PROVIDER_MSODBCSQL18 + + +def test_ensure_available_fails_closed_for_missing_provider(monkeypatch): + monkeypatch.setenv(ODBC_PROVIDER_ENV_VAR, PROVIDER_MSSQL_ODBC) + + # Force the provider package to appear absent so the test is deterministic + # regardless of what is installed in the environment. + real_import = importlib.import_module + + def fake_import(name, *args, **kwargs): + if name == "mssql_python_rust_odbc": + raise ModuleNotFoundError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr( + sys.modules[ProviderManager.__module__].importlib, "import_module", fake_import + ) + with pytest.raises(ImportError) as excinfo: + ProviderManager.ensure_available() + message = str(excinfo.value) + assert PROVIDER_MSSQL_ODBC in message + assert "mssql-python-rust-odbc" in message