Skip to content
Merged
32 changes: 32 additions & 0 deletions eng/pipelines/pr-validation-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -615,6 +645,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"
Comment thread
bewithgaurav marked this conversation as resolved.
pip install pyodbc
echo "Running performance benchmarks..."
Expand Down
109 changes: 109 additions & 0 deletions tests/test_000_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import pytest
import platform
import os
import shutil
import subprocess
import sys
from pathlib import Path

Expand Down Expand Up @@ -347,6 +349,113 @@ 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_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 = []
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")}

# 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()
if current.name in visited:
continue
visited.add(current.name)

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

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])

# 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 "
+ "\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."""
Expand Down
Loading