From 2b72732d0d64c7b8236fcae529066525f25736ba Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 2 Jul 2026 14:12:01 +0530 Subject: [PATCH 1/7] ci(macos): uninstall system unixODBC before tests to validate bundled ODBC libs (#656) --- eng/pipelines/pr-validation-pipeline.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index 8cc7ea8e..503df57a 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -543,6 +543,26 @@ jobs: parameters: platform: unix + - script: | + # Uninstall system unixODBC before running tests. + # This ensures the bundled ODBC driver-manager dylibs are used instead of any + # Homebrew-provided unixODBC, proving the wheel is self-contained. + echo "Removing Homebrew unixODBC / msodbcsql18 to force use of bundled ODBC libraries..." + brew uninstall --ignore-dependencies unixodbc 2>/dev/null || echo "unixODBC not installed via Homebrew" + brew uninstall --ignore-dependencies msodbcsql18 2>/dev/null || echo "msodbcsql18 not installed via Homebrew" + + # Remove any leftover driver-manager dylibs from the standard Homebrew locations + # (arm64 -> /opt/homebrew/lib, x86_64 -> /usr/local/lib) + rm -f /opt/homebrew/lib/libodbcinst.2.dylib /opt/homebrew/lib/libodbc.2.dylib + rm -f /usr/local/lib/libodbcinst.2.dylib /usr/local/lib/libodbc.2.dylib + echo "Removed system unixODBC libraries" + + # Verify the bundled driver's dependencies (macOS equivalent of ldd is otool -L) + ARCH=$(uname -m) + echo "Verifying $ARCH macOS driver library dependencies:" + otool -L "mssql_python/libs/macos/$ARCH/lib/libmsodbcsql.18.dylib" || echo 'Driver library not found' + displayName: 'Uninstall system unixODBC before running tests on macOS' + - script: | echo "Build successful, running tests now" python -m pytest -v --junitxml=test-results.xml --cov=. --cov-report=xml --capture=tee-sys --cache-clear From 33d20eb2d8dd55233688df9fb0371f4002e60cbc Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 2 Jul 2026 21:36:44 +0530 Subject: [PATCH 2/7] Replace macOS pipeline cleanup with packaging test for dylib paths Revert the 'uninstall system unixODBC' step (it only ran on the x86_64 runner, whose dylib is already correct, and it broke the benchmark by removing unixODBC that pyodbc needs). Add test_macos_dylibs_have_no_absolute_dependency_paths in test_000_dependencies.py. It runs 'otool -L' on every bundled dylib in both libs/macos/arm64 and libs/macos/x86_64 and fails if a sibling bundled library is referenced via an absolute path instead of @loader_path/@rpath. Because otool reads Mach-O load commands of any architecture, the x86_64 macOS CI lane now catches the arm64-only packaging bug behind GitHub #656. --- eng/pipelines/pr-validation-pipeline.yml | 20 -------- tests/test_000_dependencies.py | 60 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index 503df57a..8cc7ea8e 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -543,26 +543,6 @@ jobs: parameters: platform: unix - - script: | - # Uninstall system unixODBC before running tests. - # This ensures the bundled ODBC driver-manager dylibs are used instead of any - # Homebrew-provided unixODBC, proving the wheel is self-contained. - echo "Removing Homebrew unixODBC / msodbcsql18 to force use of bundled ODBC libraries..." - brew uninstall --ignore-dependencies unixodbc 2>/dev/null || echo "unixODBC not installed via Homebrew" - brew uninstall --ignore-dependencies msodbcsql18 2>/dev/null || echo "msodbcsql18 not installed via Homebrew" - - # Remove any leftover driver-manager dylibs from the standard Homebrew locations - # (arm64 -> /opt/homebrew/lib, x86_64 -> /usr/local/lib) - rm -f /opt/homebrew/lib/libodbcinst.2.dylib /opt/homebrew/lib/libodbc.2.dylib - rm -f /usr/local/lib/libodbcinst.2.dylib /usr/local/lib/libodbc.2.dylib - echo "Removed system unixODBC libraries" - - # Verify the bundled driver's dependencies (macOS equivalent of ldd is otool -L) - ARCH=$(uname -m) - echo "Verifying $ARCH macOS driver library dependencies:" - otool -L "mssql_python/libs/macos/$ARCH/lib/libmsodbcsql.18.dylib" || echo 'Driver library not found' - displayName: 'Uninstall system unixODBC before running tests on macOS' - - script: | echo "Build successful, running tests now" python -m pytest -v --junitxml=test-results.xml --cov=. --cov-report=xml --capture=tee-sys --cache-clear diff --git a/tests/test_000_dependencies.py b/tests/test_000_dependencies.py index 5c50c10c..09b6aa5b 100644 --- a/tests/test_000_dependencies.py +++ b/tests/test_000_dependencies.py @@ -6,6 +6,8 @@ import pytest import platform import os +import shutil +import subprocess import sys from pathlib import Path @@ -347,6 +349,64 @@ def test_macos_universal_dependencies(self): libodbcinst_path.exists() ), f"macOS {arch} ODBC installer library not found: {libodbcinst_path}" + @pytest.mark.skipif(dependency_tester.platform_name != "darwin", reason="macOS-specific test") + def test_macos_dylibs_have_no_absolute_dependency_paths(self): + """Ensure bundled macOS dylibs reference their sibling libraries relocatably. + + Guards against GitHub issue #656: on a fresh Apple Silicon Mac (without + ``brew install unixodbc``) loading the driver fails because the arm64 + ``libmsodbcsql.18.dylib`` hardcodes ``/opt/homebrew/lib/libodbcinst.2.dylib`` + instead of using ``@loader_path``. + + ``otool -L`` reads the Mach-O load commands of any architecture regardless + of the host, so this inspects BOTH the arm64 and x86_64 dylibs even when + running on a single-architecture runner (e.g. x86_64 CI). That lets an + x86_64 build machine catch the arm64-only packaging bug. + """ + otool = shutil.which("otool") + if otool is None: + pytest.skip("otool not available on this system") + + problems = [] + for arch in ["arm64", "x86_64"]: + lib_dir = dependency_tester.module_dir / "libs" / "macos" / arch / "lib" + if not lib_dir.exists(): + continue + + # Libraries we ship in this directory. A dependency on any of these + # must be relocatable (@loader_path/@rpath), never an absolute path. + bundled_names = {p.name for p in lib_dir.glob("*.dylib")} + + for dylib in sorted(lib_dir.glob("*.dylib")): + result = subprocess.run( + [otool, "-L", str(dylib)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + problems.append(f"{arch}/{dylib.name}: otool failed: {result.stderr.strip()}") + continue + + # First line of `otool -L` output is the file itself; the rest are deps. + for line in result.stdout.splitlines()[1:]: + dep = line.strip().split(" (compatibility")[0].strip() + if not dep: + continue + + # A sibling bundled library must be referenced relocatably. + if os.path.basename(dep) in bundled_names and dep.startswith("/"): + problems.append( + f"{arch}/{dylib.name} references bundled " + f"'{os.path.basename(dep)}' via absolute path '{dep}' " + f"(expected @loader_path/@rpath)" + ) + + assert not problems, ( + "macOS dylibs contain hardcoded absolute dependency paths that will " + "break on machines without Homebrew (see GitHub issue #656):\n " + + "\n ".join(problems) + ) + @pytest.mark.skipif(dependency_tester.platform_name != "linux", reason="Linux-specific test") def test_linux_distribution_dependencies(self): """Test that Linux builds include distribution-specific dependencies.""" From cf9704503516f84d187ca391b8d2befcf7ab8096 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 2 Jul 2026 22:47:07 +0530 Subject: [PATCH 3/7] Refine macOS packaging test to walk the actual driver load chain Verified against the shipped binaries that: - arm64/libmsodbcsql.18.dylib hardcodes /opt/homebrew/lib/libodbcinst.2.dylib (the GH #656 break); x86_64 gets rewritten to @loader_path at build time because configure_dylibs.sh only fixes ARCH=\ on the Intel runner. - ddbc_bindings dlopens libmsodbcsql.18.dylib DIRECTLY, so libodbc.2.dylib is off the runtime load path (and is never touched by configure_dylibs.sh). The previous test scanned every dylib, so it would have flagged the dead libodbc.2.dylib and could never go green even after the real fix. Replace it with test_macos_driver_load_chain_is_relocatable, which starts at the driver and follows only its bundled sibling deps -- matching what dlopen resolves at runtime, ignoring external openssl, and catching the arm64 bug from an x86_64 runner via otool. --- tests/test_000_dependencies.py | 117 ++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 39 deletions(-) diff --git a/tests/test_000_dependencies.py b/tests/test_000_dependencies.py index 09b6aa5b..c33af400 100644 --- a/tests/test_000_dependencies.py +++ b/tests/test_000_dependencies.py @@ -350,60 +350,99 @@ def test_macos_universal_dependencies(self): ), f"macOS {arch} ODBC installer library not found: {libodbcinst_path}" @pytest.mark.skipif(dependency_tester.platform_name != "darwin", reason="macOS-specific test") - def test_macos_dylibs_have_no_absolute_dependency_paths(self): - """Ensure bundled macOS dylibs reference their sibling libraries relocatably. - - Guards against GitHub issue #656: on a fresh Apple Silicon Mac (without - ``brew install unixodbc``) loading the driver fails because the arm64 - ``libmsodbcsql.18.dylib`` hardcodes ``/opt/homebrew/lib/libodbcinst.2.dylib`` - instead of using ``@loader_path``. - - ``otool -L`` reads the Mach-O load commands of any architecture regardless - of the host, so this inspects BOTH the arm64 and x86_64 dylibs even when - running on a single-architecture runner (e.g. x86_64 CI). That lets an - x86_64 build machine catch the arm64-only packaging bug. + def test_macos_driver_load_chain_is_relocatable(self): + """Ensure the macOS driver's bundled load chain contains no absolute paths. + + Guards against GitHub issue #656. On a fresh Apple Silicon Mac (without + ``brew install unixodbc``) importing mssql_python fails because the arm64 + ``libmsodbcsql.18.dylib`` shipped in the wheel still hardcodes + ``/opt/homebrew/lib/libodbcinst.2.dylib`` instead of + ``@loader_path/libodbcinst.2.dylib``. + + Root cause: ``pybind/configure_dylibs.sh`` only rewrites the dylibs for + the *build host* architecture (``ARCH=$(uname -m)``). macOS wheels are + universal2 but built on an x86_64 runner, so only the x86_64 driver gets + relocated; the arm64 driver ships with Homebrew-absolute dependencies. + + At runtime ``ddbc_bindings`` ``dlopen``s ``libmsodbcsql.18.dylib`` + DIRECTLY (it does not go through the unixODBC driver manager), so this + test walks exactly that load graph: starting from the driver and + following only its *bundled* sibling dependencies. Any bundled sibling + reached via an absolute path would fail to load on a machine without + Homebrew. + + ``otool -L`` reads Mach-O load commands of any architecture regardless of + the host, so inspecting BOTH the arm64 and x86_64 drivers lets a single + x86_64 CI runner catch the arm64-only packaging bug. Non-bundled + dependencies (system libs under /usr/lib or /System, and the documented + external openssl prerequisite) are intentionally ignored. """ otool = shutil.which("otool") if otool is None: pytest.skip("otool not available on this system") + def direct_bundled_deps(dylib_path, bundled_names): + """Return (absolute_hits, relocatable_hits) for bundled sibling deps. + + The first line of ``otool -L`` output is the file being inspected and + the second is the library's own install id (LC_ID_DYLIB); both are + self-references and are skipped by ignoring deps whose basename equals + the file's own name. + """ + result = subprocess.run( + [otool, "-L", str(dylib_path)], capture_output=True, text=True + ) + if result.returncode != 0: + return None, None + + absolute_hits, relocatable_hits = [], [] + for line in result.stdout.splitlines()[1:]: + dep = line.strip().split(" (compatibility")[0].strip() + if not dep: + continue + base = os.path.basename(dep) + if base == dylib_path.name or base not in bundled_names: + continue # self-reference or a non-bundled/external dependency + if dep.startswith("/"): + absolute_hits.append((base, dep)) + else: + relocatable_hits.append(base) + return absolute_hits, relocatable_hits + problems = [] for arch in ["arm64", "x86_64"]: lib_dir = dependency_tester.module_dir / "libs" / "macos" / arch / "lib" - if not lib_dir.exists(): + driver = lib_dir / "libmsodbcsql.18.dylib" + if not driver.exists(): continue - # Libraries we ship in this directory. A dependency on any of these - # must be relocatable (@loader_path/@rpath), never an absolute path. - bundled_names = {p.name for p in lib_dir.glob("*.dylib")} - - for dylib in sorted(lib_dir.glob("*.dylib")): - result = subprocess.run( - [otool, "-L", str(dylib)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - problems.append(f"{arch}/{dylib.name}: otool failed: {result.stderr.strip()}") + bundled = {p.name: p for p in lib_dir.glob("*.dylib")} + + # Breadth-first walk of the driver's bundled load chain. + visited, queue = set(), [driver] + while queue: + current = queue.pop() + if current.name in visited: continue + visited.add(current.name) - # First line of `otool -L` output is the file itself; the rest are deps. - for line in result.stdout.splitlines()[1:]: - dep = line.strip().split(" (compatibility")[0].strip() - if not dep: - continue + absolute_hits, relocatable_hits = direct_bundled_deps(current, bundled) + if absolute_hits is None or relocatable_hits is None: + problems.append(f"{arch}/{current.name}: otool failed to inspect the library") + continue - # A sibling bundled library must be referenced relocatably. - if os.path.basename(dep) in bundled_names and dep.startswith("/"): - problems.append( - f"{arch}/{dylib.name} references bundled " - f"'{os.path.basename(dep)}' via absolute path '{dep}' " - f"(expected @loader_path/@rpath)" - ) + for base, dep in absolute_hits: + problems.append( + f"{arch}/{current.name} loads bundled '{base}' via absolute " + f"path '{dep}' (expected @loader_path); this breaks on " + f"machines without Homebrew" + ) + for base in relocatable_hits: + queue.append(bundled[base]) assert not problems, ( - "macOS dylibs contain hardcoded absolute dependency paths that will " - "break on machines without Homebrew (see GitHub issue #656):\n " + "macOS driver load chain contains hardcoded absolute dependency paths " + "that break on machines without Homebrew (see GitHub issue #656):\n " + "\n ".join(problems) ) From a95202924e33bf38c41320b078fe18ee8d88d5af Mon Sep 17 00:00:00 2001 From: Gaurav Sharma <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:56:53 +0530 Subject: [PATCH 4/7] CHORE: trust mssql-release tap so macOS benchmark installs the odbc driver newer Homebrew (6.x) refuses to load formulae from third-party taps unless trusted, so brew install msodbcsql18 in the macOS benchmark step failed with "Refusing to load formula ... from untrusted tap". the driver never installed, so every pyodbc connection failed with "[unixODBC][Driver Manager]Can't open lib 'ODBC Driver 18 for SQL Server' : file not found" and the benchmark's pyodbc column came out all N/A. the step has continueOnError so the job stayed green. add brew trust microsoft/mssql-release between the tap and install. verified locally: forcing HOMEBREW_REQUIRE_TAP_TRUST=1 reproduces the error, brew trust clears it, and msodbcsql18 resolves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/pr-validation-pipeline.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index 8cc7ea8e..cad13bc5 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -615,6 +615,8 @@ jobs: - script: | echo "Installing ODBC Driver 18 for pyodbc..." brew tap microsoft/mssql-release https://github.com/Microsoft/homebrew-mssql-release + # Newer Homebrew refuses to load formulae from third-party taps unless the tap is trusted + brew trust microsoft/mssql-release || echo "brew trust failed; attempting install anyway" HOMEBREW_ACCEPT_EULA=Y brew install msodbcsql18 || echo "ODBC Driver 18 install failed — pyodbc benchmarks will be skipped" pip install pyodbc echo "Running performance benchmarks..." From 9d989a2f40285cd77b9cb5262292ad96afb3e161 Mon Sep 17 00:00:00 2001 From: Gaurav Sharma <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:08:27 +0530 Subject: [PATCH 5/7] STYLE: black-format subprocess.run call in test_000_dependencies black (line-length 100) collapses the multi-line subprocess.run into one 99-char line. fixes the Python Linting check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_000_dependencies.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_000_dependencies.py b/tests/test_000_dependencies.py index c33af400..0e1f679d 100644 --- a/tests/test_000_dependencies.py +++ b/tests/test_000_dependencies.py @@ -389,9 +389,7 @@ def direct_bundled_deps(dylib_path, bundled_names): self-references and are skipped by ignoring deps whose basename equals the file's own name. """ - result = subprocess.run( - [otool, "-L", str(dylib_path)], capture_output=True, text=True - ) + result = subprocess.run([otool, "-L", str(dylib_path)], capture_output=True, text=True) if result.returncode != 0: return None, None From 986953a7ec7640d270ef8db4ff8b0dd271ee4ee6 Mon Sep 17 00:00:00 2001 From: Gaurav Sharma <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:19:59 +0530 Subject: [PATCH 6/7] CHORE: uninstall system ODBC before macOS tests to validate bundled driver the macOS pytest job had no step to remove system/Homebrew unixODBC and msodbcsql18 before running tests, unlike every Linux job. without it, mssql-python could load a system driver manager from the default dyld path and pass, hiding a broken bundle. the otool load-chain test is a static check and does not guarantee the bundled driver manager is the one loaded at runtime. add an uninstall step to PytestOnMacOS mirroring the Linux jobs: remove Homebrew msodbcsql18/mssql-tools18/unixodbc, delete leftover libodbc/libodbcinst dylibs and odbcinst config from both Homebrew prefixes, then otool -L the bundled driver to confirm it is intact. every command is guarded so it is a no-op when nothing is installed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/pr-validation-pipeline.yml | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index cad13bc5..be33ff7e 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -543,6 +543,36 @@ jobs: parameters: platform: unix + - script: | + # Uninstall any system/Homebrew ODBC before tests so pytest exercises the + # driver and driver manager bundled in the wheel, not a system copy. Mirrors + # the Linux jobs. Every command is guarded so this is a no-op when nothing is + # installed (the hosted runner may or may not ship unixODBC). + echo "Removing Homebrew ODBC packages (if installed)..." + brew uninstall --ignore-dependencies --force msodbcsql18 mssql-tools18 unixodbc 2>/dev/null \ + || echo " no Homebrew ODBC packages to remove" + + echo "Removing leftover driver-manager dylibs and config from Homebrew prefixes..." + for prefix in /opt/homebrew /usr/local; do + rm -f "$prefix"/lib/libodbc.*.dylib "$prefix"/lib/libodbcinst.*.dylib \ + "$prefix"/lib/libodbc.dylib "$prefix"/lib/libodbcinst.dylib + rm -rf "$prefix"/opt/msodbcsql18 "$prefix"/opt/unixodbc + rm -f "$prefix"/etc/odbcinst.ini "$prefix"/etc/odbc.ini + done + rm -f /etc/odbcinst.ini /etc/odbc.ini "$HOME/.odbcinst.ini" "$HOME/.odbc.ini" + + echo "Confirming no system unixODBC driver manager remains on the default search path:" + if ls /opt/homebrew/lib/libodbc.*.dylib /usr/local/lib/libodbc.*.dylib 2>/dev/null; then + echo " WARNING: a system libodbc is still present" + else + echo " none found (good)" + fi + + echo "Verifying the bundled x86_64 driver load chain is intact:" + otool -L mssql_python/libs/macos/x86_64/lib/libmsodbcsql.18.dylib \ + || echo " bundled driver not found" + displayName: 'Uninstall system ODBC Driver before running tests on macOS' + - script: | echo "Build successful, running tests now" python -m pytest -v --junitxml=test-results.xml --cov=. --cov-report=xml --capture=tee-sys --cache-clear From e81cbc7de49e3614fadc9c7778bd454cf52a3467 Mon Sep 17 00:00:00 2001 From: Gaurav Sharma <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:43:36 +0530 Subject: [PATCH 7/7] CHORE: fail loudly if no macOS driver found, fix load-chain walk comment the relocatability test silently continued when a driver dylib was missing, so if the packaging layout ever changed the test would pass without validating anything. now it records which arches were checked and asserts at least one bundled driver was found. also corrects the walk comment: queue.pop() is LIFO, so it is depth-first, not breadth-first (order does not affect the result). addresses the two review comments on the test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_000_dependencies.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_000_dependencies.py b/tests/test_000_dependencies.py index 0e1f679d..b8e0f55d 100644 --- a/tests/test_000_dependencies.py +++ b/tests/test_000_dependencies.py @@ -408,15 +408,19 @@ def direct_bundled_deps(dylib_path, bundled_names): return absolute_hits, relocatable_hits problems = [] + checked_arches = [] for arch in ["arm64", "x86_64"]: lib_dir = dependency_tester.module_dir / "libs" / "macos" / arch / "lib" driver = lib_dir / "libmsodbcsql.18.dylib" if not driver.exists(): continue + checked_arches.append(arch) bundled = {p.name: p for p in lib_dir.glob("*.dylib")} - # Breadth-first walk of the driver's bundled load chain. + # Depth-first walk of the driver's bundled load chain (queue.pop() is + # LIFO). Order does not matter here: we visit every reachable bundled + # dylib and collect all absolute-path problems regardless of traversal. visited, queue = set(), [driver] while queue: current = queue.pop() @@ -438,6 +442,14 @@ def direct_bundled_deps(dylib_path, bundled_names): for base in relocatable_hits: queue.append(bundled[base]) + # Fail loudly instead of passing vacuously if no bundled driver was found + # to validate (e.g. the packaging layout changed). + assert checked_arches, ( + "no bundled macOS driver found to validate under " + "libs/macos/{arm64,x86_64}/lib/libmsodbcsql.18.dylib " + "(packaging layout may have changed)" + ) + assert not problems, ( "macOS driver load chain contains hardcoded absolute dependency paths " "that break on machines without Homebrew (see GitHub issue #656):\n "