diff --git a/.gitignore b/.gitignore index 080fc9e5..cd64adb9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,13 @@ build/ **/build/ mssql_python.egg-info/ +# ODBC package (mssql-python-odbc): transition-period build artifacts. +# During Phase 2, setup_odbc.py copies the current platform's driver binaries +# from mssql_python/libs into this tree so a wheel can be built locally. The +# release pipeline populates these per-platform; do not commit the copies. +mssql_python_odbc/libs/ +mssql_python_odbc.egg-info/ + # Python bytecode __pycache__/ *.py[cod] diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b6e571..e6720691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), connection. The callback is invoked by mssql-tds mid-handshake (FedAuth workflow 0x02) so the tenant id can be resolved from the server-supplied STS URL. Requires `mssql-py-core` 0.1.5+. Partial fix for #534. +- **Standalone `mssql-python-odbc` package (PRs #663, #664):** the Microsoft + ODBC Driver 18 for SQL Server binaries are now also published as a separate, + platform-specific `mssql-python-odbc` package. When it is installed, the + native driver loader resolves the driver from it; when it is absent or + incomplete, mssql-python transparently falls back to its own bundled `libs/`. + This is a non-breaking step toward decoupling driver-binary updates from + mssql-python releases; a future major version will make the dependency + explicit and drop the bundled binaries. ### Changed - Improved error handling in the connection module. diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index be33ff7e..a97a47bc 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -772,6 +772,9 @@ jobs: - script: | # Install ODBC driver in the container + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-$(distroName) bash -c " export DEBIAN_FRONTEND=noninteractive @@ -833,6 +836,9 @@ jobs: - script: | # Uninstall ODBC Driver before running tests + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-$(distroName) bash -c " export DEBIAN_FRONTEND=noninteractive apt-get remove --purge -y msodbcsql18 mssql-tools18 unixodbc-dev @@ -843,7 +849,7 @@ jobs: odbcinst -u -d -n 'ODBC Driver 18 for SQL Server' || true echo 'Uninstalled ODBC Driver and cleaned up libraries' echo 'Verifying x86_64 debian_ubuntu driver library signatures:' - ldd mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libmsodbcsql-18.6.so.2.1 + ldd mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libmsodbcsql-${DRIVER_MAJOR_MINOR}.so.2.1 " displayName: 'Uninstall ODBC Driver before running tests in $(distroName) container' @@ -1070,32 +1076,41 @@ jobs: # Install dependencies in the ARM64 container if [ "$(distroName)" = "Ubuntu" ]; then docker exec test-container-$(distroName)-$(archName) bash -c " + set -euo pipefail export DEBIAN_FRONTEND=noninteractive export TZ=UTC ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone - apt-get update && + apt-get update apt-get install -y python3 python3-pip python3-venv python3-full cmake curl wget gnupg software-properties-common build-essential python3-dev pybind11-dev # Verify architecture uname -m dpkg --print-architecture + python3 --version + cmake --version " else # Debian ARM64 docker exec test-container-$(distroName)-$(archName) bash -c " + set -euo pipefail export DEBIAN_FRONTEND=noninteractive export TZ=UTC ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone - apt-get update && + apt-get update apt-get install -y python3 python3-pip python3-venv python3-full cmake curl wget gnupg software-properties-common build-essential python3-dev pybind11-dev # Verify architecture uname -m dpkg --print-architecture + python3 --version + cmake --version " fi displayName: 'Install basic dependencies in $(distroName) ARM64 container' - script: | # Install ODBC driver in the ARM64 container + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-$(distroName)-$(archName) bash -c " export DEBIAN_FRONTEND=noninteractive @@ -1126,9 +1141,14 @@ jobs: - script: | # Install Python dependencies in the ARM64 container using virtual environment docker exec test-container-$(distroName)-$(archName) bash -c " + set -euo pipefail # Create a virtual environment python3 -m venv /opt/venv source /opt/venv/bin/activate + + # Ensure the expected runtime tools are available before build/test steps + command -v python >/dev/null 2>&1 + command -v cmake >/dev/null 2>&1 # Install dependencies in the virtual environment python -m pip install --upgrade pip @@ -1142,7 +1162,14 @@ jobs: - script: | # Build pybind bindings in the ARM64 container docker exec test-container-$(distroName)-$(archName) bash -c " + set -euo pipefail + if [ ! -f /opt/venv/bin/activate ]; then + echo '/opt/venv/bin/activate is missing. Python dependency setup did not complete.' + exit 1 + fi source /opt/venv/bin/activate + command -v python >/dev/null 2>&1 + command -v cmake >/dev/null 2>&1 cd mssql_python/pybind chmod +x build.sh ./build.sh @@ -1158,6 +1185,9 @@ jobs: - script: | # Uninstall ODBC Driver before running tests + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-$(distroName)-$(archName) bash -c " export DEBIAN_FRONTEND=noninteractive apt-get remove --purge -y msodbcsql18 mssql-tools18 unixodbc-dev @@ -1168,7 +1198,7 @@ jobs: odbcinst -u -d -n 'ODBC Driver 11 for SQL Server' || true echo 'Uninstalled ODBC Driver and cleaned up libraries' echo 'Verifying arm64 debian_ubuntu driver library signatures:' - ldd mssql_python/libs/linux/debian_ubuntu/arm64/lib/libmsodbcsql-18.6.so.2.1 + ldd mssql_python/libs/linux/debian_ubuntu/arm64/lib/libmsodbcsql-${DRIVER_MAJOR_MINOR}.so.2.1 " displayName: 'Uninstall ODBC Driver before running tests in $(distroName) ARM64 container' @@ -1268,6 +1298,9 @@ jobs: - script: | # Install dependencies in the RHEL 9 container + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-rhel9 bash -c " # Enable CodeReady Builder repository for additional packages dnf update -y @@ -1372,6 +1405,9 @@ jobs: - script: | # Uninstall ODBC Driver before running tests + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-rhel9 bash -c " dnf remove -y msodbcsql18 mssql-tools18 unixODBC-devel rm -f /usr/bin/sqlcmd @@ -1381,7 +1417,7 @@ jobs: odbcinst -u -d -n 'ODBC Driver 11 for SQL Server' || true echo 'Uninstalled ODBC Driver and cleaned up libraries' echo 'Verifying x86_64 rhel driver library signatures:' - ldd mssql_python/libs/linux/rhel/x86_64/lib/libmsodbcsql-18.6.so.2.1 + ldd mssql_python/libs/linux/rhel/x86_64/lib/libmsodbcsql-${DRIVER_MAJOR_MINOR}.so.2.1 " displayName: 'Uninstall ODBC Driver before running tests in RHEL 9 container' @@ -1489,6 +1525,9 @@ jobs: - script: | # Install dependencies in the RHEL 9 ARM64 container + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-rhel9-arm64 bash -c " # Enable CodeReady Builder repository for additional packages dnf update -y @@ -1597,6 +1636,9 @@ jobs: - script: | # Uninstall ODBC Driver before running tests + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-rhel9-arm64 bash -c " dnf remove -y msodbcsql18 mssql-tools18 unixODBC-devel rm -f /usr/bin/sqlcmd @@ -1606,7 +1648,7 @@ jobs: odbcinst -u -d -n 'ODBC Driver 18 for SQL Server' || true echo 'Uninstalled ODBC Driver and cleaned up libraries' echo 'Verifying arm64 rhel driver library signatures:' - ldd mssql_python/libs/linux/rhel/arm64/lib/libmsodbcsql-18.6.so.2.1 + ldd mssql_python/libs/linux/rhel/arm64/lib/libmsodbcsql-${DRIVER_MAJOR_MINOR}.so.2.1 " displayName: 'Uninstall ODBC Driver before running tests in RHEL 9 ARM64 container' @@ -1750,6 +1792,9 @@ jobs: - script: | # Install ODBC driver in the Alpine x86_64 container + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-alpine bash -c " # Detect architecture for ODBC driver download case \$(uname -m) in @@ -1830,6 +1875,9 @@ jobs: - script: | # Uninstall ODBC Driver before running tests to use bundled libraries + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-alpine bash -c " # Remove system ODBC installation apk del msodbcsql18 mssql-tools18 unixodbc-dev || echo 'ODBC packages not installed via apk' @@ -1840,7 +1888,7 @@ jobs: odbcinst -u -d -n 'ODBC Driver 18 for SQL Server' || true echo 'Uninstalled system ODBC Driver and cleaned up libraries' echo 'Verifying x86_64 alpine driver library signatures:' - ldd mssql_python/libs/linux/alpine/x86_64/lib/libmsodbcsql-18.6.so.2.1 || echo 'Driver library not found' + ldd mssql_python/libs/linux/alpine/x86_64/lib/libmsodbcsql-${DRIVER_MAJOR_MINOR}.so.2.1 || echo 'Driver library not found' " displayName: 'Uninstall system ODBC Driver before running tests in Alpine x86_64 container' @@ -2000,6 +2048,9 @@ jobs: - script: | # Install ODBC driver in the Alpine ARM64 container + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-alpine-arm64 bash -c " # Detect architecture for ODBC driver download case \$(uname -m) in @@ -2081,6 +2132,9 @@ jobs: - script: | # Uninstall ODBC Driver before running tests to use bundled libraries + DRIVER_VERSION=$(awk -F= '/^__version__/ {gsub(/[[:space:]"]/, "", $2); print $2; exit}' mssql_python_odbc/__init__.py) + DRIVER_MAJOR_MINOR=$(echo "$DRIVER_VERSION" | cut -d. -f1,2) + if [ -z "$DRIVER_MAJOR_MINOR" ]; then echo 'Error: failed to parse mssql_python_odbc.__version__'; exit 1; fi docker exec test-container-alpine-arm64 bash -c " # Remove system ODBC installation apk del msodbcsql18 mssql-tools18 unixodbc-dev || echo 'ODBC packages not installed via apk' @@ -2091,7 +2145,7 @@ jobs: odbcinst -u -d -n 'ODBC Driver 18 for SQL Server' || true echo 'Uninstalled system ODBC Driver and cleaned up libraries' echo 'Verifying arm64 alpine driver library signatures:' - ldd mssql_python/libs/linux/alpine/arm64/lib/libmsodbcsql-18.6.so.2.1 || echo 'Driver library not found' + ldd mssql_python/libs/linux/alpine/arm64/lib/libmsodbcsql-${DRIVER_MAJOR_MINOR}.so.2.1 || echo 'Driver library not found' " displayName: 'Uninstall system ODBC Driver before running tests in Alpine ARM64 container' diff --git a/mssql_python/pybind/CMakeLists.txt b/mssql_python/pybind/CMakeLists.txt index d1835b1a..d5b8c1be 100644 --- a/mssql_python/pybind/CMakeLists.txt +++ b/mssql_python/pybind/CMakeLists.txt @@ -73,6 +73,36 @@ endif() add_definitions(-DARCHITECTURE="${ARCHITECTURE}") add_definitions(-DPLATFORM_NAME="${PLATFORM_NAME}") +# --- msodbcsql driver version (single source of truth) ----------------------- +# The ODBC driver filename embeds the msodbcsql version (e.g. +# libmsodbcsql-18.6.so.2.1 on Linux, libmsodbcsql.18.dylib on macOS, +# msodbcsql18.dll on Windows). Derive that version from +# mssql_python_odbc.__version__ -- the single source of truth for the driver +# version -- and inject it into GetDriverPathCpp() via -D defines, so the native +# resolver and the Python package can never drift. The literal is read straight +# from the file (not by importing the package) to keep configure hermetic and +# independent of whatever packages the build interpreter happens to have. +set(MSODBCSQL_VERSION_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/../../mssql_python_odbc/__init__.py") +if(NOT EXISTS "${MSODBCSQL_VERSION_SOURCE}") + message(FATAL_ERROR + "Cannot resolve the msodbcsql driver version: ${MSODBCSQL_VERSION_SOURCE} not found. " + "The native driver filename is derived from mssql_python_odbc.__version__; " + "build from a full repository checkout.") +endif() +file(STRINGS "${MSODBCSQL_VERSION_SOURCE}" _msodbcsql_version_line REGEX "^__version__") +string(REGEX REPLACE ".*['\"]([0-9]+\\.[0-9]+\\.[0-9]+)['\"].*" "\\1" + MSODBCSQL_VERSION "${_msodbcsql_version_line}") +if(NOT MSODBCSQL_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") + message(FATAL_ERROR + "Failed to parse __version__ from ${MSODBCSQL_VERSION_SOURCE} " + "(got '${MSODBCSQL_VERSION}').") +endif() +string(REGEX MATCH "^[0-9]+" MSODBCSQL_VERSION_MAJOR "${MSODBCSQL_VERSION}") +string(REGEX MATCH "^[0-9]+\\.[0-9]+" MSODBCSQL_VERSION_MAJOR_MINOR "${MSODBCSQL_VERSION}") +message(STATUS "msodbcsql driver version (from mssql_python_odbc.__version__): ${MSODBCSQL_VERSION} -> major=${MSODBCSQL_VERSION_MAJOR}, major.minor=${MSODBCSQL_VERSION_MAJOR_MINOR}") +add_definitions(-DMSODBCSQL_VERSION_MAJOR="${MSODBCSQL_VERSION_MAJOR}") +add_definitions(-DMSODBCSQL_VERSION_MAJOR_MINOR="${MSODBCSQL_VERSION_MAJOR_MINOR}") + # For macOS, always set universal build if(APPLE) set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "Build architectures for macOS" FORCE) diff --git a/mssql_python/pybind/build.sh b/mssql_python/pybind/build.sh index 98c5cb6c..2afec206 100755 --- a/mssql_python/pybind/build.sh +++ b/mssql_python/pybind/build.sh @@ -181,7 +181,7 @@ fi # TODO: Linux-specific: use patchelf to set RPATH of the driver .so file # Currently added Driver SO files right now are already patched -# patchelf --set-rpath '$ORIGIN' libmsodbcsql-18.6.so.2.1 +# patchelf --set-rpath '$ORIGIN' libmsodbcsql-..so.2.1 # This command sets the RPATH of the specified .so file to the directory containing the file (similar to Windows) # Needed since libodbcinst.so.2 is located in the same directory and needs to be resolved diff --git a/mssql_python/pybind/configure_dylibs.sh b/mssql_python/pybind/configure_dylibs.sh index 00ec8502..6022d977 100755 --- a/mssql_python/pybind/configure_dylibs.sh +++ b/mssql_python/pybind/configure_dylibs.sh @@ -6,13 +6,28 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ODBC_VERSION_FILE="$PROJECT_DIR/../mssql_python_odbc/__init__.py" +if [ ! -f "$ODBC_VERSION_FILE" ]; then + echo "Error: SSOT version file not found: $ODBC_VERSION_FILE" + exit 1 +fi + +DRIVER_MAJOR=$(sed -nE "s/^__version__[[:space:]]*=[[:space:]]*['\"]([0-9]+)\.[0-9]+\.[0-9]+['\"].*/\1/p" "$ODBC_VERSION_FILE" | head -1) +if [ -z "$DRIVER_MAJOR" ]; then + echo "Error: failed to parse __version__ from $ODBC_VERSION_FILE" + exit 1 +fi + +DRIVER_DYLIB_NAME="libmsodbcsql.${DRIVER_MAJOR}.dylib" + # The universal2 wheel bundles a per-arch copy of the driver dylibs, so every # bundled arch must be configured, not just the build host's ($(uname -m)). # install_name_tool and codesign both work cross-arch. Fixing only the host arch # is what shipped the broken arm64 driver in issue #656. for ARCH in arm64 x86_64; do LIB_DIR="$PROJECT_DIR/libs/macos/$ARCH/lib" -LIBMSODBCSQL_PATH="$LIB_DIR/libmsodbcsql.18.dylib" +LIBMSODBCSQL_PATH="$LIB_DIR/$DRIVER_DYLIB_NAME" +LIBMSODBCSQL_NAME="$DRIVER_DYLIB_NAME" LIBODBCINST_PATH="$LIB_DIR/libodbcinst.2.dylib" LIBLTDL_PATH="$LIB_DIR/libltdl.7.dylib" @@ -23,7 +38,7 @@ if [ ! -d "$LIB_DIR" ]; then fi if [ ! -f "$LIBMSODBCSQL_PATH" ]; then - echo "Error: libmsodbcsql.18.dylib not found at: $LIBMSODBCSQL_PATH" + echo "Error: $DRIVER_DYLIB_NAME not found at: $LIBMSODBCSQL_PATH" exit 1 fi @@ -42,7 +57,7 @@ fi echo "Configuring dylibs in: $LIB_DIR" # Get the existing library paths which are linked to the dylibs -echo "Reading dependencies from libmsodbcsql.18.dylib..." +echo "Reading dependencies from $LIBMSODBCSQL_NAME..." OTOOL_LIST=$(otool -L "$LIBMSODBCSQL_PATH") OLD_LIBODBCINST_PATH="" @@ -68,12 +83,12 @@ done <<< "$OTOOL_LIST" # Configure the library paths if dependencies were found if [ -n "$OLD_LIBODBCINST_PATH" ]; then - echo "Fixing libmsodbcsql.18.dylib dependency on libodbcinst.2.dylib..." + echo "Fixing $LIBMSODBCSQL_NAME dependency on libodbcinst.2.dylib..." echo " Changing: $OLD_LIBODBCINST_PATH" echo " To: @loader_path/libodbcinst.2.dylib" install_name_tool -change "$OLD_LIBODBCINST_PATH" "@loader_path/libodbcinst.2.dylib" "$LIBMSODBCSQL_PATH" else - echo "Warning: libodbcinst dependency not found in libmsodbcsql.18.dylib" + echo "Warning: libodbcinst dependency not found in $LIBMSODBCSQL_NAME" fi if [ -n "$OLD_LIBLTDL_PATH" ] && [ -f "$LIBLTDL_PATH" ]; then @@ -89,8 +104,8 @@ fi # First set the IDs of the libraries using @loader_path echo "Setting library IDs with @loader_path..." -echo "Setting ID for libmsodbcsql.18.dylib..." -install_name_tool -id "@loader_path/libmsodbcsql.18.dylib" "$LIBMSODBCSQL_PATH" +echo "Setting ID for $LIBMSODBCSQL_NAME..." +install_name_tool -id "@loader_path/$LIBMSODBCSQL_NAME" "$LIBMSODBCSQL_PATH" echo "Setting ID for libodbcinst.2.dylib..." install_name_tool -id "@loader_path/libodbcinst.2.dylib" "$LIBODBCINST_PATH" @@ -100,7 +115,7 @@ if [ -f "$LIBLTDL_PATH" ]; then install_name_tool -id "@loader_path/libltdl.7.dylib" "$LIBLTDL_PATH" fi -echo "Codesigning libmsodbcsql.18.dylib..." +echo "Codesigning $LIBMSODBCSQL_NAME..." codesign -s - -f "$LIBMSODBCSQL_PATH" 2>/dev/null echo "Codesigning libodbcinst.2.dylib..." diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 3cb00814..7119e36e 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -1125,6 +1125,90 @@ std::string GetModuleDirectory() { return parentDir.string(); } +// Resolve the base directory that contains the ODBC driver `libs/` tree. +// +// Post-split, the driver binaries ship in the standalone `mssql_python_odbc` +// package (a pure-data sibling with no native extension). We import it and use +// its directory as the base that `GetDriverPathCpp` (and the Windows +// `mssql-auth.dll` lookup) append `libs` to. +// +// During the Phase-2 transition we fall back to the bundled `mssql_python` +// directory when the external package is not installed, so a wheel that still +// bundles `libs/` keeps working. Importing `mssql_python_odbc` here is +// Alpine/musl-safe precisely because it is a separate pure package: it cannot +// trigger the partially-initialized-module circular import that motivated +// resolving these paths in C++ in the first place. +// +// (`GetDriverPathCpp` is defined further below; forward-declared here so we can +// verify the external package actually ships this platform's driver binary.) +std::string GetDriverPathCpp(const std::string& moduleDir); + +std::string GetOdbcLibsBaseDir() { + namespace fs = std::filesystem; + // This function calls into the Python C-API (py::module::import, attribute + // access, casts), so it must run with the GIL held. It is a no-op when the + // GIL is already held — which it is at the sole current call site, during + // module initialization — but acquiring it here self-documents the C-API + // dependency and keeps a future GIL-released caller from turning this into a + // hard crash. + py::gil_scoped_acquire gil; + try { + py::object module = py::module::import("mssql_python_odbc"); + py::object module_path = module.attr("__file__"); + std::string module_file = module_path.cast(); + + fs::path parentDir = fs::path(module_file).parent_path(); + + // Only treat the external package as authoritative if it actually ships + // a COMPLETE set of this platform's driver binaries. In a source/dev + // checkout (and in CI) the package is importable from the repo root but + // its `libs/` tree is gitignored and either absent or only partially + // populated; in that case fall back to the bundled `mssql_python` libs + // so driver resolution points at a real, complete installation. + // + // "Complete" means the ODBC driver itself and, on Windows, the + // co-located `mssql-auth.dll` that LoadDriverOrThrowException loads + // unconditionally. Verifying both here keeps this resolver's notion of a + // usable base dir consistent with what the loader below actually needs, + // so we never select a directory that would later make the loader throw + // (e.g. a dir that has msodbcsql18.dll but is missing mssql-auth.dll). + std::error_code ec; + fs::path externalDriver(GetDriverPathCpp(parentDir.string())); + bool externalComplete = fs::exists(externalDriver, ec); +#ifdef _WIN32 + if (externalComplete) { + fs::path externalAuthDll = externalDriver.parent_path() / "mssql-auth.dll"; + externalComplete = fs::exists(externalAuthDll, ec); + } +#endif + if (externalComplete) { + LOG("GetOdbcLibsBaseDir: Using external mssql_python_odbc package - directory='%s'", + parentDir.string().c_str()); + return parentDir.string(); + } + LOG("GetOdbcLibsBaseDir: mssql_python_odbc present at '%s' but its libs are missing or " + "incomplete; falling back to bundled libs in mssql_python", + parentDir.string().c_str()); + return GetModuleDirectory(); + } catch (const py::error_already_set& e) { + if (e.matches(PyExc_ModuleNotFoundError)) { + // Expected in Phase 2 when the standalone package is not installed. + // pybind11 has already fetched and cleared the CPython error + // indicator, so re-importing `mssql_python` below is safe. + LOG("GetOdbcLibsBaseDir: mssql_python_odbc not installed (%s); " + "falling back to bundled libs in mssql_python", + e.what()); + return GetModuleDirectory(); + } + // 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); " + "re-raising", + e.what()); + throw; + } +} + // Platform-agnostic function to load the driver dynamic library DriverHandle LoadDriverLibrary(const std::string& driverPath) { LOG("LoadDriverLibrary: Attempting to load ODBC driver from path='%s'", driverPath.c_str()); @@ -1188,6 +1272,12 @@ std::string GetLastErrorMessage() { * all supported platforms. */ std::string GetDriverPathCpp(const std::string& moduleDir) { +#if !defined(MSODBCSQL_VERSION_MAJOR) || !defined(MSODBCSQL_VERSION_MAJOR_MINOR) +#error \ + "MSODBCSQL_VERSION_MAJOR / MSODBCSQL_VERSION_MAJOR_MINOR must be defined at build time. " \ + "They are derived from mssql_python_odbc.__version__ in CMakeLists.txt so the driver " \ + "filename can never drift from the packaged driver version." +#endif namespace fs = std::filesystem; fs::path basePath(moduleDir); @@ -1215,13 +1305,22 @@ std::string GetDriverPathCpp(const std::string& moduleDir) { platform = "debian_ubuntu"; // Default to debian_ubuntu for other distros } - fs::path driverPath = - basePath / "libs" / "linux" / platform / arch / "lib" / "libmsodbcsql-18.6.so.2.1"; + // The msodbcsql version embedded in the driver filename is injected at build + // time from mssql_python_odbc.__version__ (the single source of truth for the + // driver version) via the MSODBCSQL_VERSION_* macros defined in + // CMakeLists.txt. This keeps the native resolver and the Python package + // version from ever drifting: GetOdbcLibsBaseDir() calls fs::exists() on this + // exact path to decide whether the external package is "complete", so a stale + // name would silently fall back to the bundled libs. (The ".so.2.1" suffix is + // the driver's ELF soname, which is independent of the product version.) + fs::path driverPath = basePath / "libs" / "linux" / platform / arch / "lib" / + ("libmsodbcsql-" MSODBCSQL_VERSION_MAJOR_MINOR ".so.2.1"); return driverPath.string(); #elif defined(__APPLE__) platform = "macos"; - fs::path driverPath = basePath / "libs" / platform / arch / "lib" / "libmsodbcsql.18.dylib"; + fs::path driverPath = basePath / "libs" / platform / arch / "lib" / + ("libmsodbcsql." MSODBCSQL_VERSION_MAJOR ".dylib"); return driverPath.string(); #elif defined(_WIN32) @@ -1229,7 +1328,8 @@ std::string GetDriverPathCpp(const std::string& moduleDir) { // Normalize x86_64 to x64 for Windows naming if (arch == "x86_64") arch = "x64"; - fs::path driverPath = basePath / "libs" / platform / arch / "msodbcsql18.dll"; + fs::path driverPath = + basePath / "libs" / platform / arch / ("msodbcsql" MSODBCSQL_VERSION_MAJOR ".dll"); return driverPath.string(); #else @@ -1240,8 +1340,12 @@ std::string GetDriverPathCpp(const std::string& moduleDir) { DriverHandle LoadDriverOrThrowException() { namespace fs = std::filesystem; - std::string moduleDir = GetModuleDirectory(); - LOG("LoadDriverOrThrowException: Module directory resolved to '%s'", moduleDir.c_str()); + // Resolve the base dir from the standalone `mssql_python_odbc` package + // (falls back to the bundled `mssql_python` libs during the transition). + // Both the driver path and the Windows `mssql-auth.dll` path below are + // derived from this directory. + std::string moduleDir = GetOdbcLibsBaseDir(); + LOG("LoadDriverOrThrowException: ODBC libs base directory resolved to '%s'", moduleDir.c_str()); std::string archStr = ARCHITECTURE; LOG("LoadDriverOrThrowException: Architecture detected as '%s'", archStr.c_str()); diff --git a/mssql_python_odbc/__init__.py b/mssql_python_odbc/__init__.py new file mode 100644 index 00000000..8355021a --- /dev/null +++ b/mssql_python_odbc/__init__.py @@ -0,0 +1,42 @@ +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. + +mssql_python_odbc — Microsoft ODBC Driver 18 for SQL Server binaries. + +Internal implementation package for ``mssql-python``. It ships the +platform-specific ODBC driver binaries (``msodbcsql18``) and their supporting +libraries so that ``mssql-python`` does not have to bundle them in its own +wheel. It is not meant for direct consumption — install ``mssql-python`` +instead, which depends on this package. + +Driver-path resolution lives entirely in the native +``mssql_python.ddbc_bindings`` extension (``GetOdbcLibsBaseDir`` / +``GetDriverPathCpp``): it imports this package purely for its ``__file__`` and +appends ``libs///...`` itself. Keeping a single (C++) resolver +avoids a second copy of the arch/distro/filename logic that could silently +drift out of sync — see the drift guard in ``tests/test_000_dependencies.py``. +""" + +import os + +__all__ = ["get_libs_dir", "__version__"] + +# Version tracks the bundled Microsoft ODBC Driver 18 for SQL Server release and +# is the single source of truth for the driver version. ``setup_odbc.py`` reads +# it for the wheel version, and the native build (``mssql_python/pybind/ +# CMakeLists.txt``) derives the driver filename version from it and injects it +# into ``GetDriverPathCpp`` -- so the C++ resolver and this package can never +# drift. Bump this one value to move to a new driver release. +__version__ = "18.6.2" + + +def get_libs_dir() -> str: + """Return the absolute path to this package's ``libs/`` directory. + + This is the root under which the platform-specific ODBC binaries live + (``libs///...``). The parent of this path (the package + directory) is the base the native loader appends ``libs`` to when resolving + the driver. + """ + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "libs") diff --git a/setup_odbc.py b/setup_odbc.py new file mode 100644 index 00000000..7e87cc73 --- /dev/null +++ b/setup_odbc.py @@ -0,0 +1,271 @@ +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. + +Build script for the ``mssql-python-odbc`` package. + +This packages the Microsoft ODBC Driver 18 for SQL Server binaries into a +standalone, platform-specific wheel that ``mssql-python`` depends on. Build it +with:: + + python setup_odbc.py bdist_wheel + +During the transition period the driver binaries still live under +``mssql_python/libs/``. This script copies the current platform's subtree into +``mssql_python_odbc/libs/`` so a wheel can be produced locally. The release +pipeline populates ``libs/`` per-platform and is the source of truth for the +full release matrix. +""" + +import os +import re +import shutil +import sys +from pathlib import Path + +import setuptools +from setuptools import setup +from setuptools.dist import Distribution +from wheel.bdist_wheel import bdist_wheel + +PROJECT_ROOT = Path(__file__).resolve().parent +PACKAGE_NAME = "mssql_python_odbc" +PACKAGE_DIR = PROJECT_ROOT / PACKAGE_NAME +BUNDLED_LIBS_ROOT = PROJECT_ROOT / "mssql_python" / "libs" + +# The driver binaries are packaged via the recursive ``libs/**/*`` glob in +# ``package_data`` below, which enumerates files from the copied +# ``mssql_python_odbc/libs/`` subtree on disk (not from version control). +# Support for ``**`` recursive globs in ``package_data`` landed in setuptools +# 62.3.0; with an older setuptools the glob silently matches nothing and the +# wheel ships WITHOUT any driver binaries. Fail fast instead of producing a +# broken-but-quiet wheel. +MIN_SETUPTOOLS = (62, 3, 0) + + +def _read_odbc_version() -> str: + """Return ``__version__`` from ``mssql_python_odbc/__init__.py``. + + Single source of truth for the package version: the value is defined once in + the package's ``__init__.py`` and read here (by regex, without importing the + package) so the wheel version can never drift from what the package reports + at runtime. + """ + init_file = PACKAGE_DIR / "__init__.py" + text = init_file.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) + if not match: + raise SystemExit(f"Could not find __version__ in {init_file}") + return match.group(1) + + +def _require_min_setuptools() -> None: + raw = setuptools.__version__ + parts = tuple(int(m) for m in re.findall(r"\d+", raw)[:3]) + parts += (0,) * (3 - len(parts)) + if parts < MIN_SETUPTOOLS: + raise SystemExit( + "setup_odbc.py requires setuptools >= " + f"{'.'.join(map(str, MIN_SETUPTOOLS))} to package the ODBC driver " + "binaries via the recursive 'libs/**/*' glob; found setuptools " + f"{raw}. Upgrade with:\n" + ' python -m pip install --upgrade "setuptools>=62.3.0"' + ) + + +class BinaryDistribution(Distribution): + """Force a platform-specific wheel (the package ships native binaries).""" + + def has_ext_modules(self): + return True + + +def get_platform_info(): + """Get platform-specific architecture and platform tag information. + + Kept in sync with ``setup.py`` so the ODBC wheel carries the same platform + tags as the main ``mssql-python`` wheel. + """ + if sys.platform.startswith("win"): + arch = os.environ.get("ARCHITECTURE", "x64") + if isinstance(arch, str): + arch = arch.strip("\"'") + if arch in ["x86", "win32"]: + return "x86", "win32" + elif arch == "arm64": + return "arm64", "win_arm64" + else: + return "x64", "win_amd64" + + elif sys.platform.startswith("darwin"): + return "universal2", "macosx_15_0_universal2" + + elif sys.platform.startswith("linux"): + import platform + + target_arch = os.environ.get("targetArch", platform.machine()) + libc_name, _ = platform.libc_ver() + is_musl = libc_name == "" or "musl" in libc_name.lower() + manylinux_tag = os.environ.get("MANYLINUX_TAG", "manylinux_2_28") + + if target_arch == "x86_64": + return "x86_64", "musllinux_1_2_x86_64" if is_musl else f"{manylinux_tag}_x86_64" + elif target_arch in ["aarch64", "arm64"]: + return "aarch64", "musllinux_1_2_aarch64" if is_musl else f"{manylinux_tag}_aarch64" + else: + raise OSError( + f"Unsupported architecture '{target_arch}' for Linux; " + f"expected 'x86_64' or 'aarch64'." + ) + + raise OSError(f"Unsupported platform: {sys.platform!r}") + + +def _libs_arch(build_arch: str) -> str: + """Map the build arch from ``get_platform_info`` to the ``libs/`` dir name. + + ``libs/`` uses ``x64``/``x86``/``arm64`` on Windows and + ``x86_64``/``arm64`` on Linux/macOS. + """ + if sys.platform.startswith("win"): + return build_arch # already x64 / x86 / arm64 + if build_arch in ("x86_64", "amd64"): + return "x86_64" + if build_arch in ("aarch64", "arm64"): + return "arm64" + return build_arch + + +def _copytree(src: Path, dst: Path) -> None: + if src.is_dir(): + shutil.copytree(src, dst, dirs_exist_ok=True) + print(f" Copied {src} -> {dst}") + + +def sync_libs() -> None: + """Copy the current platform's ODBC libs into ``mssql_python_odbc/libs/``. + + Convenience for local/transition builds while ``mssql_python`` still bundles + the binaries. If ``mssql_python/libs`` is absent (e.g. the pipeline places + binaries directly into the package), this is a no-op. + """ + target_root = PACKAGE_DIR / "libs" + if not BUNDLED_LIBS_ROOT.is_dir(): + print(f"sync_libs: bundled libs not found at {BUNDLED_LIBS_ROOT}; skipping copy") + return + + build_arch, _ = get_platform_info() + arch = _libs_arch(build_arch) + + # Always carry the licensing files. + _copytree(BUNDLED_LIBS_ROOT / "LICENSING", target_root / "LICENSING") + + if sys.platform.startswith("win"): + _copytree(BUNDLED_LIBS_ROOT / "windows" / arch, target_root / "windows" / arch) + + elif sys.platform.startswith("darwin"): + # universal2 wheel serves both architectures. + for mac_arch in ("arm64", "x86_64"): + _copytree(BUNDLED_LIBS_ROOT / "macos" / mac_arch, target_root / "macos" / mac_arch) + + elif sys.platform.startswith("linux"): + # A single Linux wheel serves all distro families for its libc/arch; + # the driver is selected at runtime via /etc/*-release detection. + for distro in ("alpine", "debian_ubuntu", "rhel", "suse"): + _copytree( + BUNDLED_LIBS_ROOT / "linux" / distro / arch, + target_root / "linux" / distro / arch, + ) + + +class CustomBdistWheel(bdist_wheel): + """Force a platform-specific but Python-agnostic tag and sync libs. + + The package ships only pre-built ODBC driver binaries (data), not a compiled + Python extension, so one ``py3-none-`` wheel serves every supported + Python version (3.10+). The wheel stays platform-specific + (``root_is_pure = False``) because the binaries differ per OS/arch/libc, but + the interpreter/ABI tags are forced to ``py3``/``none``. Without this the + ``BinaryDistribution`` (``has_ext_modules`` -> True) would produce a + ``cp3XX``-specific tag, forcing a needless per-Python-version build matrix. + """ + + def finalize_options(self): + bdist_wheel.finalize_options(self) + arch, platform_tag = get_platform_info() + self.plat_name = platform_tag + # Platform-specific (ships native binaries) but not tied to a CPython ABI. + self.root_is_pure = False + print(f"Setting wheel platform tag to: {self.plat_name} (arch: {arch})") + + def get_tag(self): + # Preserve the platform tag from the base implementation but relabel the + # interpreter/ABI tags as Python-agnostic ("py3"/"none"). + _python, _abi, plat = bdist_wheel.get_tag(self) + return "py3", "none", plat + + def run(self): + sync_libs() + bdist_wheel.run(self) + + +_require_min_setuptools() + +setup( + name="mssql-python-odbc", + version=_read_odbc_version(), + description=( + "Internal implementation package for mssql-python: Microsoft ODBC " + "Driver 18 for SQL Server binaries. Not intended for direct use." + ), + long_description=( + "Internal implementation package not meant for direct consumption. " + "Install `mssql-python`, which depends on this package." + ), + long_description_content_type="text/plain", + author="Microsoft Corporation", + author_email="mssql-python@microsoft.com", + url="https://github.com/microsoft/mssql-python", + license="MIT", + packages=[PACKAGE_NAME], + # vcredist / VC++ runtime (deliberate divergence from the main wheel's SHAPE, + # not a behavior change): unlike setup.py we do NOT exclude + # ``libs//vcredist/``. This is intentional and consistent with what the + # main wheel actually ships: + # * The main mssql-python wheel still ships the VC++ runtime + # (``msvcp140.dll``) -- build.bat copies it out of + # ``libs/windows//vcredist/`` to the package root next to the + # compiled ``ddbc_bindings`` extension, and setup.py then excludes the + # now-duplicate ``vcredist/`` folder. That exclusion is DE-DUPLICATION, + # not a licensing/size-driven removal of the runtime. + # * This package is pure driver-binary data with no compiled extension, so + # there is no package-root ``.pyd`` to relocate the runtime beside. We + # therefore keep ``msvcp140.dll`` in its original ``vcredist/`` folder + # (with its license text) so the driver binaries and the runtime they + # were built against travel together and the wheel stays self-contained. + # This does not create a Phase-2 runtime skew: the driver's ``msvcp140.dll`` + # dependency is satisfied in-process by the mssql-python extension module + # (built ``/MD``, which loads ``msvcp140.dll`` from beside the ``.pyd``), so + # the external-package and bundled-libs paths behave identically. This copy + # is completeness + attribution, which is also why GetOdbcLibsBaseDir()'s + # completeness check does not need to verify vcredist. + package_data={ + PACKAGE_NAME: [ + "libs/*", + "libs/**/*", + ], + }, + include_package_data=True, + python_requires=">=3.10", + classifiers=[ + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + ], + zip_safe=False, + distclass=BinaryDistribution, + cmdclass={ + "bdist_wheel": CustomBdistWheel, + }, +) diff --git a/tests/test_000_dependencies.py b/tests/test_000_dependencies.py index b8e0f55d..f38ba06e 100644 --- a/tests/test_000_dependencies.py +++ b/tests/test_000_dependencies.py @@ -6,6 +6,7 @@ import pytest import platform import os +import re import shutil import subprocess import sys @@ -66,23 +67,12 @@ def _normalize_architecture(self): return arch_lower 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 if (fs::exists("/etc/SuSE-release") || fs::exists("/etc/SUSE-brand")) { - platform = "suse"; - } else { - platform = "debian_ubuntu"; - } - - fs::path driverPath = basePath / "libs" / "linux" / platform / arch / "lib" / "libmsodbcsql-18.6.so.2.1"; - return driverPath.string(); + """Detect Linux distribution for driver path selection. + + Mirrors the ``/etc/*-release`` probing in the native ``GetDriverPathCpp`` + so the expected paths agree with what the C++ resolver looks for. """ + distro_name = "debian_ubuntu" # default try: if Path("/etc/alpine-release").exists(): distro_name = "alpine" @@ -97,6 +87,58 @@ def _detect_linux_distro(self): return distro_name + def _driver_version_parts(self): + """Return ``(major, minor)`` of the bundled msodbcsql driver. + + Derived from ``mssql_python_odbc.__version__`` -- the single source of + truth for the driver version. If that package is not installed, fall + back to the native resolver's own filename so the expected paths still + track whatever the compiled extension actually looks for (rather than a + hardcoded literal that could silently drift on a version bump). + + Only a missing package triggers the fallback: an installed-but-malformed + ``__version__`` is surfaced (not swallowed) so a corrupt package fails + loudly instead of silently masking behind the native resolver. + """ + try: + import mssql_python_odbc + except ImportError: + import mssql_python.ddbc_bindings as ddbc + + name = os.path.basename(ddbc.GetDriverPathCpp("base")) + match = re.search(r"(\d+)(?:\.(\d+))?", name) + if match is None: + raise AssertionError( + f"Could not derive driver version from resolved filename {name!r}" + ) + return match.group(1), (match.group(2) or "") + + parts = mssql_python_odbc.__version__.split(".") + if len(parts) < 2: + raise AssertionError( + "mssql_python_odbc.__version__ is malformed " + f"(expected 'major.minor[.patch]', got {mssql_python_odbc.__version__!r})" + ) + return parts[0], parts[1] + + def _driver_filename(self): + """Return the msodbcsql driver filename for the current platform. + + Built from the version parts (see :meth:`_driver_version_parts`) so it + stays in lockstep with the native resolver's ``GetDriverPathCpp``. + """ + major, minor = self._driver_version_parts() + if self.platform_name == "windows": + return f"msodbcsql{major}.dll" + if self.platform_name == "darwin": + return f"libmsodbcsql.{major}.dylib" + if not minor: + raise AssertionError( + f"Linux driver filename needs a minor version but none was derived (major={major!r})" + ) + # Linux embeds the major.minor plus the ELF soname suffix. + return f"libmsodbcsql-{major}.{minor}.so.2.1" + def get_expected_dependencies(self): """Get expected dependencies for the current platform and architecture.""" if self.platform_name == "windows": @@ -113,7 +155,7 @@ def _get_windows_dependencies(self): base_path = self.module_dir / "libs" / "windows" / self.normalized_arch dependencies = [ - base_path / "msodbcsql18.dll", + base_path / self._driver_filename(), base_path / "msodbcdiag18.dll", base_path / "mssql-auth.dll", base_path / "vcredist" / "msvcp140.dll", @@ -130,7 +172,7 @@ def _get_macos_dependencies(self): base_path = self.module_dir / "libs" / "macos" / arch / "lib" dependencies.extend( [ - base_path / "libmsodbcsql.18.dylib", + base_path / self._driver_filename(), base_path / "libodbcinst.2.dylib", ] ) @@ -151,7 +193,7 @@ def _get_linux_dependencies(self): base_path = self.module_dir / "libs" / "linux" / distro_name / runtime_arch / "lib" dependencies = [ - base_path / "libmsodbcsql-18.6.so.2.1", + base_path / self._driver_filename(), base_path / "libodbcinst.so.2", ] @@ -190,7 +232,11 @@ def get_expected_driver_path(self): if platform_name == "windows": driver_path = ( - Path(self.module_dir) / "libs" / "windows" / normalized_arch / "msodbcsql18.dll" + Path(self.module_dir) + / "libs" + / "windows" + / normalized_arch + / self._driver_filename() ) elif platform_name == "darwin": @@ -200,7 +246,7 @@ def get_expected_driver_path(self): / "macos" / normalized_arch / "lib" - / "libmsodbcsql.18.dylib" + / self._driver_filename() ) elif platform_name == "linux": @@ -212,7 +258,7 @@ def get_expected_driver_path(self): / distro_name / normalized_arch / "lib" - / "libmsodbcsql-18.6.so.2.1" + / self._driver_filename() ) else: @@ -341,7 +387,7 @@ def test_macos_universal_dependencies(self): for arch in ["arm64", "x86_64"]: base_path = dependency_tester.module_dir / "libs" / "macos" / arch / "lib" - msodbcsql_path = base_path / "libmsodbcsql.18.dylib" + msodbcsql_path = base_path / dependency_tester._driver_filename() libodbcinst_path = base_path / "libodbcinst.2.dylib" assert msodbcsql_path.exists(), f"macOS {arch} ODBC driver not found: {msodbcsql_path}" @@ -768,3 +814,75 @@ def test_ddbc_bindings_no_module_found_error(): assert python_version in expected_error assert architecture in expected_error assert extension in expected_error + + +class TestOdbcPackageSplit: + """Coverage for the standalone ``mssql-python-odbc`` driver package. + + The ODBC driver binaries are being split out of ``mssql-python`` into the + ``mssql-python-odbc`` package. Driver-path resolution has a single owner -- + the native C++ resolver (``GetDriverPathCpp``); the Python package only + ships the binaries and exposes ``__version__``. These tests therefore use + the native resolver rather than a duplicate Python path builder. + """ + + def test_odbc_package_ships_driver_for_platform(self): + """The odbc package (if installed) ships this platform's driver binary. + + Skips cleanly when ``mssql-python-odbc`` is not installed or its + ``libs/`` tree has not been synced (fresh source checkout / CI before + the binaries are placed). When ``libs/`` is present it MUST contain this + platform's driver, otherwise the package is broken. + """ + odbc = pytest.importorskip("mssql_python_odbc") + + libs_dir = odbc.get_libs_dir() + if not os.path.isdir(libs_dir): + pytest.skip( + "mssql_python_odbc/libs not present (fresh checkout or binaries " + "not built/synced for this platform)" + ) + + import mssql_python.ddbc_bindings as ddbc + + package_dir = os.path.dirname(os.path.abspath(odbc.__file__)) + driver_path = ddbc.GetDriverPathCpp(package_dir) + + assert os.path.isfile(driver_path), ( + "mssql-python-odbc ships a libs/ tree but no driver for this " + f"platform at: {driver_path}" + ) + + def test_cpp_driver_filename_matches_odbc_version(self): + """Guard against C++/Python version drift. + + The driver version in ``GetDriverPathCpp``'s resolved filename (e.g. + ``libmsodbcsql-18.6.so.2.1`` on Linux) is injected at build time from + ``mssql_python_odbc.__version__`` -- the single source of truth -- via + the ``MSODBCSQL_VERSION_*`` macros in ``CMakeLists.txt``, so a fresh + build cannot drift. This test additionally catches a *stale* compiled + extension being tested against updated sources (the filename baked into + the binary would then disagree with the current ``__version__``). Each + platform's CI validates its own compiled filename. + """ + odbc = pytest.importorskip("mssql_python_odbc") + + import mssql_python.ddbc_bindings as ddbc + + # GetDriverPathCpp only builds a path string; the base dir need not + # exist. It resolves for the host platform via compile-time #ifdefs. + filename = os.path.basename(ddbc.GetDriverPathCpp("base")) + major, minor = odbc.__version__.split(".")[:2] + + if platform.system().lower() == "linux": + # e.g. __version__ "18.6.2" -> "libmsodbcsql-18.6.so..." + assert ( + f"-{major}.{minor}.so" in filename + ), f"Linux driver filename {filename!r} disagrees with odbc __version__ {odbc.__version__!r}" + else: + # Windows (msodbcsql18.dll) / macOS (libmsodbcsql.18.dylib) key off + # the major version only. Match the exact digit run (word boundary) + # so e.g. major "18" does not spuriously match "msodbcsql182.dll". + assert re.search( + rf"(?