diff --git a/mssql_python/helpers.py b/mssql_python/helpers.py index 267ede75c..f15365c99 100644 --- a/mssql_python/helpers.py +++ b/mssql_python/helpers.py @@ -114,78 +114,6 @@ def add_driver_name_to_app_parameter(connection_string): return ";".join(modified_parameters) + ";" -def detect_linux_distro(): - """ - Detect Linux distribution for driver path selection. - - Returns: - str: Distribution name ('debian_ubuntu', 'rhel', 'alpine', etc.) - """ - import os - - distro_name = "debian_ubuntu" # default - - try: - if os.path.exists("/etc/os-release"): - with open("/etc/os-release", "r") as f: - content = f.read() - for line in content.split("\n"): - if line.startswith("ID="): - distro_id = line.split("=", 1)[1].strip('"\'') - if distro_id in ["ubuntu", "debian"]: - distro_name = "debian_ubuntu" - elif distro_id in ["rhel", "centos", "fedora"]: - distro_name = "rhel" - elif distro_id == "alpine": - distro_name = "alpine" - else: - distro_name = distro_id # use as-is - break - except Exception: - pass # use default - - return distro_name - -def get_driver_path(module_dir, architecture): - """ - Get the platform-specific ODBC driver path. - - Args: - module_dir (str): Base module directory - architecture (str): Target architecture (x64, arm64, x86, etc.) - - Returns: - str: Full path to the ODBC driver file - - Raises: - RuntimeError: If driver not found or unsupported platform - """ - - platform_name = platform.system().lower() - normalized_arch = normalize_architecture(platform_name, architecture) - - if platform_name == "windows": - driver_path = Path(module_dir) / "libs" / "windows" / normalized_arch / "msodbcsql18.dll" - - elif platform_name == "darwin": - driver_path = Path(module_dir) / "libs" / "macos" / normalized_arch / "lib" / "libmsodbcsql.18.dylib" - - elif platform_name == "linux": - distro_name = detect_linux_distro() - driver_path = Path(module_dir) / "libs" / "linux" / distro_name / normalized_arch / "lib" / "libmsodbcsql-18.5.so.1.1" - - else: - raise RuntimeError(f"Unsupported platform: {platform_name}") - - driver_path_str = str(driver_path) - - # Check if file exists - if not driver_path.exists(): - raise RuntimeError(f"ODBC driver not found at: {driver_path_str}") - - return driver_path_str - - def sanitize_connection_string(conn_str: str) -> str: """ Sanitize the connection string by removing sensitive information. diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 49c7c7af4..1b37b8f0f 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -637,20 +637,66 @@ std::string GetLastErrorMessage() { #endif } -// Function to call Python get_driver_path function -std::string GetDriverPathFromPython(const std::string& moduleDir, const std::string& architecture) { - try { - py::module_ helpers = py::module_::import("mssql_python.helpers"); - py::object get_driver_path = helpers.attr("get_driver_path"); - py::str result = get_driver_path(moduleDir, architecture); - return std::string(result); - } catch (const py::error_already_set& e) { - LOG("Python error in get_driver_path: {}", e.what()); - ThrowStdException("Failed to get driver path from Python: " + std::string(e.what())); - } catch (const std::exception& e) { - LOG("Error calling get_driver_path: {}", e.what()); - ThrowStdException("Failed to get driver path: " + std::string(e.what())); - } + +/* + * Resolve ODBC driver path in C++ to avoid circular import issues on Alpine. + * + * Background: + * On Alpine Linux, calling into Python during module initialization (via pybind11) + * causes a circular import due to musl's stricter dynamic loader behavior. + * + * Specifically, importing Python helpers from C++ triggered a re-import of the + * partially-initialized native module, which works on glibc (Ubuntu/macOS) but + * fails on musl-based systems like Alpine. + * + * By moving driver path resolution entirely into C++, we avoid any Python-layer + * dependencies during critical initialization, ensuring compatibility across + * all supported platforms. + */ +std::string GetDriverPathCpp(const std::string& moduleDir) { + namespace fs = std::filesystem; + fs::path basePath(moduleDir); + + std::string platform; + std::string arch; + + // Detect architecture + #if defined(__aarch64__) || defined(_M_ARM64) + arch = "arm64"; + #elif defined(__x86_64__) || defined(_M_X64) || defined(_M_AMD64) + arch = "x86_64"; // maps to "x64" on Windows + #else + throw std::runtime_error("Unsupported architecture"); + #endif + + // Detect platform and set path + #ifdef __linux__ + if (fs::exists("/etc/alpine-release")) { + platform = "alpine"; + } else if (fs::exists("/etc/redhat-release") || fs::exists("/etc/centos-release")) { + platform = "rhel"; + } else { + platform = "debian_ubuntu"; + } + + fs::path driverPath = basePath / "libs" / "linux" / platform / arch / "lib" / "libmsodbcsql-18.5.so.1.1"; + return driverPath.string(); + + #elif defined(__APPLE__) + platform = "macos"; + fs::path driverPath = basePath / "libs" / platform / arch / "lib" / "libmsodbcsql.18.dylib"; + return driverPath.string(); + + #elif defined(_WIN32) + platform = "windows"; + // Normalize x86_64 to x64 for Windows naming + if (arch == "x86_64") arch = "x64"; + fs::path driverPath = basePath / "libs" / platform / arch / "msodbcsql18.dll"; + return driverPath.string(); + + #else + throw std::runtime_error("Unsupported platform"); + #endif } DriverHandle LoadDriverOrThrowException() { @@ -662,8 +708,11 @@ DriverHandle LoadDriverOrThrowException() { std::string archStr = ARCHITECTURE; LOG("Architecture: {}", archStr); - // Use Python function to get the correct driver path for the platform - std::string driverPathStr = GetDriverPathFromPython(moduleDir, archStr); + // Use only C++ function for driver path resolution + // Not using Python function since it causes circular import issues on Alpine Linux + // and other platforms with strict module loading rules. + std::string driverPathStr = GetDriverPathCpp(moduleDir); + fs::path driverPath(driverPathStr); LOG("Driver path determined: {}", driverPath.string()); @@ -2448,7 +2497,7 @@ PYBIND11_MODULE(ddbc_bindings, m) { // Expose the C++ functions to Python m.def("ThrowStdException", &ThrowStdException); - m.def("get_driver_path", &GetDriverPathFromPython, "Get platform-specific ODBC driver path"); + m.def("GetDriverPathCpp", &GetDriverPathCpp, "Get the path to the ODBC driver"); // Define parameter info class py::class_(m, "ParamInfo") diff --git a/tests/test_000_dependencies.py b/tests/test_000_dependencies.py index 08d16fef5..f76a74134 100644 --- a/tests/test_000_dependencies.py +++ b/tests/test_000_dependencies.py @@ -9,6 +9,8 @@ import sys from pathlib import Path +from mssql_python.ddbc_bindings import normalize_architecture + class DependencyTester: """Helper class to test platform-specific dependencies.""" @@ -57,23 +59,26 @@ def _normalize_architecture(self): def _detect_linux_distro(self): """Detect Linux distribution for driver path selection.""" distro_name = "debian_ubuntu" # default - + ''' + #ifdef __linux__ + if (fs::exists("/etc/alpine-release")) { + platform = "alpine"; + } else if (fs::exists("/etc/redhat-release") || fs::exists("/etc/centos-release")) { + platform = "rhel"; + } else { + platform = "ubuntu"; + } + + fs::path driverPath = basePath / "libs" / "linux" / platform / arch / "lib" / "libmsodbcsql-18.5.so.1.1"; + return driverPath.string(); + ''' try: - if os.path.exists("/etc/os-release"): - with open("/etc/os-release", "r") as f: - content = f.read() - for line in content.split("\n"): - if line.startswith("ID="): - distro_id = line.split("=", 1)[1].strip('"\'') - if distro_id in ["ubuntu", "debian"]: - distro_name = "debian_ubuntu" - elif distro_id in ["rhel", "centos", "fedora"]: - distro_name = "rhel" - elif distro_id == "alpine": - distro_name = "alpine" - else: - distro_name = distro_id - break + if (Path("/etc/alpine-release").exists()): + distro_name = "alpine" + elif (Path("/etc/redhat-release").exists() or Path("/etc/centos-release").exists()): + distro_name = "rhel" + else: + distro_name = "debian_ubuntu" except Exception: pass # use default @@ -164,6 +169,30 @@ def get_expected_python_extension(self): return self.module_dir / extension_name + def get_expected_driver_path(self): + platform_name = platform.system().lower() + normalized_arch = normalize_architecture(platform_name, self.normalized_arch) + + if platform_name == "windows": + driver_path = Path(self.module_dir) / "libs" / "windows" / normalized_arch / "msodbcsql18.dll" + + elif platform_name == "darwin": + driver_path = Path(self.module_dir) / "libs" / "macos" / normalized_arch / "lib" / "libmsodbcsql.18.dylib" + + elif platform_name == "linux": + distro_name = self._detect_linux_distro() + driver_path = Path(self.module_dir) / "libs" / "linux" / distro_name / normalized_arch / "lib" / "libmsodbcsql-18.5.so.1.1" + + else: + raise RuntimeError(f"Unsupported platform: {platform_name}") + + driver_path_str = str(driver_path) + + # Check if file exists + if not driver_path.exists(): + raise RuntimeError(f"ODBC driver not found at: {driver_path_str}") + + return driver_path_str # Create global instance for use in tests dependency_tester = DependencyTester() @@ -314,21 +343,6 @@ def test_python_extension_imports(self): except Exception as e: pytest.fail(f"Failed to import or use ddbc_bindings: {e}") - - def test_helper_functions_work(self): - """Test that helper functions can detect platform correctly.""" - try: - from mssql_python.helpers import get_driver_path - - # Test that get_driver_path works for current platform - driver_path = get_driver_path(str(dependency_tester.module_dir), dependency_tester.normalized_arch) - - assert Path(driver_path).exists(), \ - f"Driver path returned by get_driver_path does not exist: {driver_path}" - - except Exception as e: - pytest.fail(f"Failed to use helper functions: {e}") - # Print platform information when tests are collected def pytest_runtest_setup(item): @@ -349,4 +363,21 @@ def test_ddbc_bindings_import(): import mssql_python.ddbc_bindings assert True, "ddbc_bindings module imported successfully." except ImportError as e: - pytest.fail(f"Failed to import ddbc_bindings: {e}") \ No newline at end of file + pytest.fail(f"Failed to import ddbc_bindings: {e}") + + + +def test_get_driver_path_from_ddbc_bindings(): + """Test the GetDriverPathCpp function from ddbc_bindings.""" + try: + import mssql_python.ddbc_bindings as ddbc + module_dir = dependency_tester.module_dir + + driver_path = ddbc.GetDriverPathCpp(str(module_dir)) + + # The driver path should be same as one returned by the Python function + expected_path = dependency_tester.get_expected_driver_path() + assert driver_path == str(expected_path), \ + f"Driver path mismatch: expected {expected_path}, got {driver_path}" + except Exception as e: + pytest.fail(f"Failed to call GetDriverPathCpp: {e}")