From 552690b5e8e74eefddd93d08de3ac8e87ba1ae20 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Mon, 14 Sep 2026 19:29:40 +0530 Subject: [PATCH 1/5] Add daily mssql-odbc compatibility pipeline --- .../mssql-odbc-daily-validation-pipeline.yml | 166 ++++++++++++++++++ eng/scripts/run-mssql-odbc-tests.sh | 158 +++++++++++++++++ eng/scripts/verify_mssql_odbc_provider.py | 31 ++++ 3 files changed, 355 insertions(+) create mode 100644 eng/pipelines/mssql-odbc-daily-validation-pipeline.yml create mode 100644 eng/scripts/run-mssql-odbc-tests.sh create mode 100644 eng/scripts/verify_mssql_odbc_provider.py diff --git a/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml b/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml new file mode 100644 index 000000000..aa248f124 --- /dev/null +++ b/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml @@ -0,0 +1,166 @@ +name: mssql-odbc-daily-$(Date:yyyyMMdd)$(Rev:.r) + +trigger: none +pr: none + +schedules: +- cron: '0 4 * * *' + displayName: 'Daily mssql-odbc validation' + branches: + include: + - main + always: true + +jobs: +- job: PytestOnMssqlOdbc + displayName: 'Complete mssql-python suite on mssql-odbc' + # Headroom over the serial step budgets (SQL setup 22m + pytest 130m + cleanup 3m) + # plus unbudgeted deps-install/build/install-core/verify/publish, so the always() + # publish and cleanup steps still run in the worst case. + timeoutInMinutes: 240 + pool: + vmImage: 'ubuntu-latest' + + variables: + testContainer: 'mssql-python-rust-odbc-$(Build.BuildId)' + sqlContainer: 'sqlserver-rust-odbc-$(Build.BuildId)' + + steps: + - checkout: self + clean: true + fetchDepth: 1 + displayName: 'Checkout latest GitHub main' + + - script: | + docker run -d --name $(testContainer) \ + -v $(Build.SourcesDirectory):/workspace \ + -w /workspace \ + --network bridge \ + ubuntu:24.04 \ + tail -f /dev/null + displayName: 'Create mssql-odbc test container' + + - script: | + python3 eng/scripts/setup_sql_container.py \ + --name "$(sqlContainer)" --owner "$(Build.BuildId).$(System.JobId)" \ + --image "mcr.microsoft.com/mssql/server:2022-latest" --database TestDB + displayName: 'Start SQL Server for mssql-odbc tests' + timeoutInMinutes: 22 + env: + DB_PASSWORD: $(DB_PASSWORD) + + - script: | + docker exec $(testContainer) bash -c ' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive TZ=UTC + ln -snf /usr/share/zoneinfo/$TZ /etc/localtime + echo $TZ > /etc/timezone + apt-get update + apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv python3-full python3-dev \ + cmake build-essential pybind11-dev unixodbc-dev curl ca-certificates \ + libssl3 libgssapi-krb5-2 libkrb5-3 + python3 -m venv /opt/venv + source /opt/venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + ' + displayName: 'Install mssql-odbc test dependencies' + + - script: | + docker exec $(testContainer) bash -c ' + set -euo pipefail + source /opt/venv/bin/activate + cd mssql_python/pybind + chmod +x build.sh + ./build.sh + ' + displayName: 'Build mssql-python native bindings' + + - template: steps/install-mssql-py-core.yml + parameters: + platform: container + containerName: $(testContainer) + venvActivate: 'source /opt/venv/bin/activate' + displaySuffix: ' for mssql-odbc tests' + + - script: | + set -euo pipefail + SQLSERVER_IP=$(docker inspect $(sqlContainer) --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') + export DB_CONNECTION_STRING="Server=$SQLSERVER_IP;Database=TestDB;Uid=SA;Pwd=$DB_PASSWORD;TrustServerCertificate=yes" + docker exec \ + -e MSSQL_PYTHON_NATIVE_PROVIDER=mssql-odbc \ + -e DB_CONNECTION_STRING \ + -e PYTHONPATH=/workspace \ + $(testContainer) bash -c ' + set -euo pipefail + source /opt/venv/bin/activate + python eng/scripts/verify_mssql_odbc_provider.py + ' + displayName: 'Verify packaged mssql-odbc selection, load, and query' + env: + DB_PASSWORD: $(DB_PASSWORD) + + - script: | + set -uo pipefail + SQLSERVER_IP=$(docker inspect $(sqlContainer) --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') + if [ -z "$SQLSERVER_IP" ]; then + echo "##[error]Could not resolve SQL Server container IP; SQL Server is unreachable (infrastructure failure)." + exit 1 + fi + export DB_CONNECTION_STRING="Server=$SQLSERVER_IP;Database=TestDB;Uid=SA;Pwd=$DB_PASSWORD;TrustServerCertificate=yes" + rc=0 + docker exec \ + -e MSSQL_PYTHON_NATIVE_PROVIDER=mssql-odbc \ + -e DB_CONNECTION_STRING \ + -e TEST_RESULTS_DIR=/workspace/test-results/mssql-odbc \ + -e PYTEST_FILE_TIMEOUT=10m \ + -e PYTEST_TOTAL_BUDGET=110m \ + -e PYTHONPATH=/workspace \ + $(testContainer) bash -c ' + source /opt/venv/bin/activate + chmod +x eng/scripts/run-mssql-odbc-tests.sh + eng/scripts/run-mssql-odbc-tests.sh + ' || rc=$? + + case "$rc" in + 0) + echo 'The complete pytest suite passed with mssql-odbc.' + ;; + 1) + echo '##vso[task.logissue type=warning]The complete pytest suite has expected compatibility failures with mssql-odbc; see published test results.' + echo '##vso[task.complete result=SucceededWithIssues;]' + ;; + *) + echo "##vso[task.logissue type=error]The mssql-odbc pytest harness failed (exit $rc)." + exit 1 + ;; + esac + exit 0 + displayName: 'Run complete pytest suite with mssql-odbc (advisory)' + timeoutInMinutes: 130 + env: + DB_PASSWORD: $(DB_PASSWORD) + + - task: PublishTestResults@2 + displayName: 'Publish mssql-odbc compatibility results' + condition: always() + continueOnError: true + inputs: + testResultsFormat: JUnit + testResultsFiles: 'test-results/mssql-odbc/*.xml' + searchFolder: '$(Build.SourcesDirectory)' + testRunTitle: 'mssql-python complete suite on mssql-odbc' + mergeTestResults: true + failTaskOnFailedTests: false + publishRunAttachments: true + + - script: | + python3 eng/scripts/setup_sql_container.py --cleanup \ + --name "$(sqlContainer)" --owner "$(Build.BuildId).$(System.JobId)" + docker rm -f $(testContainer) || true + displayName: 'Clean up mssql-odbc test containers' + condition: always() + timeoutInMinutes: 3 + env: + DB_PASSWORD: $(DB_PASSWORD) \ No newline at end of file diff --git a/eng/scripts/run-mssql-odbc-tests.sh b/eng/scripts/run-mssql-odbc-tests.sh new file mode 100644 index 000000000..e271b785e --- /dev/null +++ b/eng/scripts/run-mssql-odbc-tests.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Run every mssql-python test file in an isolated process with mssql-odbc +# selected. Functional failures are advisory, but crashes and timeouts still +# produce JUnit entries so the pipeline reports the complete result. + +set -uo pipefail + +RESULTS_DIR="${TEST_RESULTS_DIR:-test-results/mssql-odbc}" +FILE_TIMEOUT="${PYTEST_FILE_TIMEOUT:-10m}" +TOTAL_BUDGET="${PYTEST_TOTAL_BUDGET:-110m}" + +mkdir -p "$RESULTS_DIR" +rm -f "$RESULTS_DIR"/*.xml + +xml_escape() { + printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' +} + +write_stub() { + local name="$1" kind="$2" reason="$3" report="$4" + local safe_name safe_reason + safe_name="$(xml_escape "$name")" + safe_reason="$(xml_escape "$reason")" + if [ "$kind" = "skipped" ]; then + cat > "$report" < + + + + + + + +XML + else + cat > "$report" < + + + + The pytest process terminated before producing a report. + + + +XML + fi +} + +to_seconds() { + local value="$1" number="${1%[smh]}" + case "$value" in + *h) echo $((number * 3600)) ;; + *m) echo $((number * 60)) ;; + *s) echo "$number" ;; + *) echo "$value" ;; + esac +} + +mapfile -t TEST_FILES < <(find tests -name 'test_*.py' -type f | sort) +if [ "${#TEST_FILES[@]}" -eq 0 ] || ! python -m pytest --version >/dev/null 2>&1; then + echo "##[error]The pytest harness is not usable" + exit 2 +fi + +FILE_BUDGET_SECONDS="$(to_seconds "$FILE_TIMEOUT")" +TOTAL_BUDGET_SECONDS="$(to_seconds "$TOTAL_BUDGET")" +passed=0 +failed=0 +crashed=0 +timed_out=0 +skipped=0 + +echo "Running ${#TEST_FILES[@]} test files with MSSQL_PYTHON_NATIVE_PROVIDER=${MSSQL_PYTHON_NATIVE_PROVIDER:-unset}" + +for index in "${!TEST_FILES[@]}"; do + test_file="${TEST_FILES[$index]}" + name="${test_file#tests/}" + name="${name%.py}" + name="${name//\//_}" + report="$RESULTS_DIR/results-$name.xml" + remaining=$((TOTAL_BUDGET_SECONDS - SECONDS)) + + if [ "$remaining" -le 30 ]; then + for rest_index in $(seq "$index" $((${#TEST_FILES[@]} - 1))); do + rest_file="${TEST_FILES[$rest_index]}" + rest_name="${rest_file#tests/}" + rest_name="${rest_name%.py}" + rest_name="${rest_name//\//_}" + write_stub "$rest_name" skipped "Total test budget of $TOTAL_BUDGET exhausted" \ + "$RESULTS_DIR/results-$rest_name.xml" + skipped=$((skipped + 1)) + done + break + fi + + slice="$FILE_BUDGET_SECONDS" + if [ "$slice" -gt "$remaining" ]; then + slice="$remaining" + fi + + echo "##[group]$test_file" + timeout --kill-after=60s "${slice}s" \ + python -m pytest "$test_file" -v --junitxml="$report" \ + --capture=tee-sys --cache-clear + rc=$? + echo "##[endgroup]" + + case "$rc" in + 0) + passed=$((passed + 1)) + ;; + 1) + failed=$((failed + 1)) + ;; + 2|3|4) + echo "##[error]The pytest harness failed on $test_file (exit $rc)" + exit 2 + ;; + 5) + write_stub "$name" skipped "No tests collected" "$report" + skipped=$((skipped + 1)) + ;; + 124|137) + timed_out=$((timed_out + 1)) + ;; + 125|126|127) + echo "##[error]The pytest harness could not execute $test_file (exit $rc)" + exit 2 + ;; + *) + crashed=$((crashed + 1)) + ;; + esac + + if [ ! -s "$report" ]; then + if [ "$rc" -eq 0 ]; then + echo "##[error]Pytest reported success for $test_file but produced no JUnit results (harness failure)" + exit 2 + fi + write_stub "$name" error "Pytest exited $rc without producing JUnit" "$report" + fi +done + +echo "files: ${#TEST_FILES[@]} | passed: $passed | failed: $failed | crashed: $crashed | timed out: $timed_out | skipped: $skipped" + +if [ "$((passed + failed + crashed + timed_out))" -eq 0 ]; then + echo "##[error]No test file executed any tests" + exit 2 +fi + +# Intentional no-test files (pytest exit 5, e.g. stress files excluded by the +# "not stress" marker) count as skipped and must not mark the run SucceededWithIssues. +if [ "$failed" -gt 0 ] || [ "$crashed" -gt 0 ] || [ "$timed_out" -gt 0 ]; then + exit 1 +fi diff --git a/eng/scripts/verify_mssql_odbc_provider.py b/eng/scripts/verify_mssql_odbc_provider.py new file mode 100644 index 000000000..29bc67bad --- /dev/null +++ b/eng/scripts/verify_mssql_odbc_provider.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from pathlib import Path + +import mssql_python + + +def main(): + info = mssql_python.get_native_provider_info() + assert info["id"] == "mssql-odbc", info + assert info["package"] == "mssql_py_core", info + assert info["source"] == "environment", info + driver_path = Path(info["driver_path"]) + assert "mssqlodbc" in driver_path.name.lower(), info + assert driver_path.is_file(), info + + with mssql_python.connect(os.environ["DB_CONNECTION_STRING"]) as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + assert cursor.fetchone()[0] == 1 + + loaded_info = mssql_python.get_native_provider_info() + assert loaded_info["id"] == "mssql-odbc", loaded_info + assert loaded_info["frozen"] is True, loaded_info + print("MSSQL_ODBC_PREFLIGHT_OK", loaded_info) + + +if __name__ == "__main__": + main() From d08ae7e91a5ba42afc963d9a7ff6bf40c6e34928 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 18 Sep 2026 12:04:39 +0530 Subject: [PATCH 2/5] Fix scheduled mssql-odbc validation failures --- .../mssql-odbc-daily-validation-pipeline.yml | 29 ++++- eng/scripts/run-mssql-odbc-tests.sh | 68 +++++++--- eng/scripts/verify_mssql_odbc_provider.py | 2 +- eng/versions/mssql-python-rs-nuget.version | 2 +- tests/test_038_mssql_odbc_daily_validation.py | 120 ++++++++++++++++++ 5 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 tests/test_038_mssql_odbc_daily_validation.py diff --git a/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml b/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml index aa248f124..6a6712a64 100644 --- a/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml +++ b/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml @@ -85,10 +85,11 @@ jobs: displaySuffix: ' for mssql-odbc tests' - script: | - set -euo pipefail + set -uo pipefail SQLSERVER_IP=$(docker inspect $(sqlContainer) --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') export DB_CONNECTION_STRING="Server=$SQLSERVER_IP;Database=TestDB;Uid=SA;Pwd=$DB_PASSWORD;TrustServerCertificate=yes" - docker exec \ + rc=0 + output=$(docker exec \ -e MSSQL_PYTHON_NATIVE_PROVIDER=mssql-odbc \ -e DB_CONNECTION_STRING \ -e PYTHONPATH=/workspace \ @@ -96,7 +97,12 @@ jobs: set -euo pipefail source /opt/venv/bin/activate python eng/scripts/verify_mssql_odbc_provider.py - ' + ' 2>&1) || rc=$? + printf '%s\n' "$output" + if ! grep -q 'MSSQL_ODBC_PREFLIGHT_OK' <<< "$output"; then + echo "##[error]mssql-odbc provider preflight failed (exit $rc)." + exit 1 + fi displayName: 'Verify packaged mssql-odbc selection, load, and query' env: DB_PASSWORD: $(DB_PASSWORD) @@ -109,11 +115,14 @@ jobs: exit 1 fi export DB_CONNECTION_STRING="Server=$SQLSERVER_IP;Database=TestDB;Uid=SA;Pwd=$DB_PASSWORD;TrustServerCertificate=yes" + status_file="$(Build.SourcesDirectory)/test-results/mssql-odbc/runner.status" + rm -f "$status_file" rc=0 docker exec \ -e MSSQL_PYTHON_NATIVE_PROVIDER=mssql-odbc \ -e DB_CONNECTION_STRING \ -e TEST_RESULTS_DIR=/workspace/test-results/mssql-odbc \ + -e TEST_STATUS_FILE=/workspace/test-results/mssql-odbc/runner.status \ -e PYTEST_FILE_TIMEOUT=10m \ -e PYTEST_TOTAL_BUDGET=110m \ -e PYTHONPATH=/workspace \ @@ -123,11 +132,20 @@ jobs: eng/scripts/run-mssql-odbc-tests.sh ' || rc=$? + status=$(cat "$status_file" 2>/dev/null || true) case "$rc" in 0) + if [ "$status" != success ]; then + echo "##vso[task.logissue type=error]The pytest process returned success without a valid runner status." + exit 1 + fi echo 'The complete pytest suite passed with mssql-odbc.' ;; 1) + if [ "$status" != advisory ]; then + echo '##vso[task.logissue type=error]The test container failed before the pytest runner reported an advisory result.' + exit 1 + fi echo '##vso[task.logissue type=warning]The complete pytest suite has expected compatibility failures with mssql-odbc; see published test results.' echo '##vso[task.complete result=SucceededWithIssues;]' ;; @@ -145,7 +163,6 @@ jobs: - task: PublishTestResults@2 displayName: 'Publish mssql-odbc compatibility results' condition: always() - continueOnError: true inputs: testResultsFormat: JUnit testResultsFiles: 'test-results/mssql-odbc/*.xml' @@ -156,9 +173,11 @@ jobs: publishRunAttachments: true - script: | + cleanup_rc=0 python3 eng/scripts/setup_sql_container.py --cleanup \ - --name "$(sqlContainer)" --owner "$(Build.BuildId).$(System.JobId)" + --name "$(sqlContainer)" --owner "$(Build.BuildId).$(System.JobId)" || cleanup_rc=$? docker rm -f $(testContainer) || true + exit "$cleanup_rc" displayName: 'Clean up mssql-odbc test containers' condition: always() timeoutInMinutes: 3 diff --git a/eng/scripts/run-mssql-odbc-tests.sh b/eng/scripts/run-mssql-odbc-tests.sh index e271b785e..03eb07223 100644 --- a/eng/scripts/run-mssql-odbc-tests.sh +++ b/eng/scripts/run-mssql-odbc-tests.sh @@ -11,9 +11,30 @@ set -uo pipefail RESULTS_DIR="${TEST_RESULTS_DIR:-test-results/mssql-odbc}" FILE_TIMEOUT="${PYTEST_FILE_TIMEOUT:-10m}" TOTAL_BUDGET="${PYTEST_TOTAL_BUDGET:-110m}" +STATUS_FILE="${TEST_STATUS_FILE:-$RESULTS_DIR/runner.status}" +KILL_GRACE_SECONDS=10 -mkdir -p "$RESULTS_DIR" +if ! mkdir -p "$RESULTS_DIR"; then + echo "##[error]Could not create test results directory: $RESULTS_DIR" + exit 2 +fi rm -f "$RESULTS_DIR"/*.xml +rm -f "$STATUS_FILE" + +finish() { + local code="$1" status="$2" + printf '%s\n' "$status" > "$STATUS_FILE" || exit 2 + exit "$code" +} + +on_exit() { + local code=$? + if [ ! -s "$STATUS_FILE" ]; then + printf 'harness\n' > "$STATUS_FILE" 2>/dev/null || true + fi + return "$code" +} +trap on_exit EXIT xml_escape() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' @@ -59,10 +80,19 @@ to_seconds() { esac } +report_is_valid() { + python - "$1" <<'PY' +import sys +from xml.etree import ElementTree + +ElementTree.parse(sys.argv[1]) +PY +} + mapfile -t TEST_FILES < <(find tests -name 'test_*.py' -type f | sort) if [ "${#TEST_FILES[@]}" -eq 0 ] || ! python -m pytest --version >/dev/null 2>&1; then echo "##[error]The pytest harness is not usable" - exit 2 + finish 2 harness fi FILE_BUDGET_SECONDS="$(to_seconds "$FILE_TIMEOUT")" @@ -83,26 +113,27 @@ for index in "${!TEST_FILES[@]}"; do report="$RESULTS_DIR/results-$name.xml" remaining=$((TOTAL_BUDGET_SECONDS - SECONDS)) - if [ "$remaining" -le 30 ]; then + if [ "$remaining" -le "$KILL_GRACE_SECONDS" ]; then + remaining_files=$((${#TEST_FILES[@]} - index)) for rest_index in $(seq "$index" $((${#TEST_FILES[@]} - 1))); do rest_file="${TEST_FILES[$rest_index]}" rest_name="${rest_file#tests/}" rest_name="${rest_name%.py}" rest_name="${rest_name//\//_}" - write_stub "$rest_name" skipped "Total test budget of $TOTAL_BUDGET exhausted" \ + write_stub "$rest_name" error "Total test budget of $TOTAL_BUDGET exhausted before this file ran" \ "$RESULTS_DIR/results-$rest_name.xml" - skipped=$((skipped + 1)) done + timed_out=$((timed_out + remaining_files)) break fi slice="$FILE_BUDGET_SECONDS" - if [ "$slice" -gt "$remaining" ]; then - slice="$remaining" + if [ "$slice" -gt "$((remaining - KILL_GRACE_SECONDS))" ]; then + slice=$((remaining - KILL_GRACE_SECONDS)) fi echo "##[group]$test_file" - timeout --kill-after=60s "${slice}s" \ + timeout --kill-after="${KILL_GRACE_SECONDS}s" "${slice}s" \ python -m pytest "$test_file" -v --junitxml="$report" \ --capture=tee-sys --cache-clear rc=$? @@ -117,7 +148,7 @@ for index in "${!TEST_FILES[@]}"; do ;; 2|3|4) echo "##[error]The pytest harness failed on $test_file (exit $rc)" - exit 2 + finish 2 harness ;; 5) write_stub "$name" skipped "No tests collected" "$report" @@ -128,19 +159,23 @@ for index in "${!TEST_FILES[@]}"; do ;; 125|126|127) echo "##[error]The pytest harness could not execute $test_file (exit $rc)" - exit 2 + finish 2 harness ;; *) crashed=$((crashed + 1)) ;; esac - if [ ! -s "$report" ]; then + if [ ! -s "$report" ] || ! report_is_valid "$report" >/dev/null 2>&1; then if [ "$rc" -eq 0 ]; then - echo "##[error]Pytest reported success for $test_file but produced no JUnit results (harness failure)" - exit 2 + echo "##[error]Pytest reported success for $test_file but produced no valid JUnit results (harness failure)" + finish 2 harness fi - write_stub "$name" error "Pytest exited $rc without producing JUnit" "$report" + write_stub "$name" error "Pytest exited $rc without producing valid JUnit" "$report" + fi + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] || [ "$rc" -gt 127 ]; then + write_stub "${name}_process" error "Pytest process exited $rc after producing JUnit" \ + "$RESULTS_DIR/results-$name-process.xml" fi done @@ -148,11 +183,12 @@ echo "files: ${#TEST_FILES[@]} | passed: $passed | failed: $failed | crashed: $c if [ "$((passed + failed + crashed + timed_out))" -eq 0 ]; then echo "##[error]No test file executed any tests" - exit 2 + finish 2 harness fi # Intentional no-test files (pytest exit 5, e.g. stress files excluded by the # "not stress" marker) count as skipped and must not mark the run SucceededWithIssues. if [ "$failed" -gt 0 ] || [ "$crashed" -gt 0 ] || [ "$timed_out" -gt 0 ]; then - exit 1 + finish 1 advisory fi +finish 0 success diff --git a/eng/scripts/verify_mssql_odbc_provider.py b/eng/scripts/verify_mssql_odbc_provider.py index 29bc67bad..020743a5a 100644 --- a/eng/scripts/verify_mssql_odbc_provider.py +++ b/eng/scripts/verify_mssql_odbc_provider.py @@ -24,7 +24,7 @@ def main(): loaded_info = mssql_python.get_native_provider_info() assert loaded_info["id"] == "mssql-odbc", loaded_info assert loaded_info["frozen"] is True, loaded_info - print("MSSQL_ODBC_PREFLIGHT_OK", loaded_info) + print("MSSQL_ODBC_PREFLIGHT_OK", loaded_info, flush=True) if __name__ == "__main__": diff --git a/eng/versions/mssql-python-rs-nuget.version b/eng/versions/mssql-python-rs-nuget.version index eb603bd5c..6c6aa7cb0 100644 --- a/eng/versions/mssql-python-rs-nuget.version +++ b/eng/versions/mssql-python-rs-nuget.version @@ -1 +1 @@ -0.1.0-dev.20260914.174990 \ No newline at end of file +0.1.0 \ No newline at end of file diff --git a/tests/test_038_mssql_odbc_daily_validation.py b/tests/test_038_mssql_odbc_daily_validation.py new file mode 100644 index 000000000..adbc08db6 --- /dev/null +++ b/tests/test_038_mssql_odbc_daily_validation.py @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree + +ROOT = Path(__file__).parents[1] +RUNNER = ROOT / "eng" / "scripts" / "run-mssql-odbc-tests.sh" +PIPELINE = ROOT / "eng" / "pipelines" / "mssql-odbc-daily-validation-pipeline.yml" + + +@unittest.skipIf(os.name == "nt", "runner requires POSIX timeout and bash") +class RunnerTests(unittest.TestCase): + def run_runner(self, behavior: str, total_budget: str = "60s"): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work / "tests").mkdir() + (work / "tests" / "test_sample.py").write_text("pass\n", encoding="utf-8") + fake_bin = work / "bin" + fake_bin.mkdir() + fake_python = fake_bin / "python" + fake_python.write_text( + "#!/bin/sh\n" + f"real_python={sys.executable!r}\n" + 'if [ "$1" = "-" ]; then exec "$real_python" "$@"; fi\n' + "case \"$*\" in *'--version'*) exit 0 ;; esac\n" + "report=\n" + 'for arg in "$@"; do\n' + ' case "$arg" in --junitxml=*) report=${arg#--junitxml=} ;; esac\n' + "done\n" + f"case {behavior!r} in\n" + ' success) printf \'%s\\n\' \'\' > "$report"; exit 0 ;;\n' + " crash) printf '%s\\n' '' > \"$report\"; exit 139 ;;\n" + " missing) exit 0 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_python.chmod(0o755) + results = work / "results" + env = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "TEST_RESULTS_DIR": str(results), + "PYTEST_FILE_TIMEOUT": "30s", + "PYTEST_TOTAL_BUDGET": total_budget, + "MSSQL_PYTHON_NATIVE_PROVIDER": "mssql-odbc", + } + proc = subprocess.run( + ["bash", str(RUNNER)], + cwd=work, + env=env, + capture_output=True, + text=True, + check=False, + ) + status = (results / "runner.status").read_text(encoding="utf-8").strip() + reports = { + path.name: ElementTree.parse(path).getroot() for path in results.glob("*.xml") + } + return proc, status, reports + + def test_success_writes_authenticated_status(self): + proc, status, reports = self.run_runner("success") + + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(status, "success") + self.assertEqual(set(reports), {"results-test_sample.xml"}) + + def test_budget_exhaustion_is_advisory_not_success(self): + proc, status, reports = self.run_runner("success", total_budget="1s") + + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertEqual(status, "advisory") + self.assertEqual( + reports["results-test_sample.xml"].find(".//error").attrib["type"], "ProcessTerminated" + ) + + def test_crash_replaces_malformed_report_and_adds_process_result(self): + proc, status, reports = self.run_runner("crash") + + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertEqual(status, "advisory") + self.assertEqual( + set(reports), + {"results-test_sample.xml", "results-test_sample-process.xml"}, + ) + + def test_success_without_junit_is_blocking_harness_failure(self): + proc, status, reports = self.run_runner("missing") + + self.assertEqual(proc.returncode, 2, proc.stderr) + self.assertEqual(status, "harness") + self.assertEqual(reports, {}) + + +class PipelineContractTests(unittest.TestCase): + def test_pipeline_authenticates_advisory_status_and_keeps_publish_strict(self): + pipeline = PIPELINE.read_text(encoding="utf-8") + + self.assertIn('if [ "$status" != advisory ]', pipeline) + self.assertIn('if [ "$status" != success ]', pipeline) + self.assertNotIn("continueOnError: true", pipeline) + self.assertIn('exit "$cleanup_rc"', pipeline) + self.assertIn("grep -q 'MSSQL_ODBC_PREFLIGHT_OK'", pipeline) + + def test_stable_rs_transport_is_pinned(self): + version = (ROOT / "eng" / "versions" / "mssql-python-rs-nuget.version").read_text( + encoding="ascii" + ) + + self.assertEqual(version.strip(), "0.1.0") + + +if __name__ == "__main__": + unittest.main() From ec43ff1b3927859cdc0effa4114cf85df3ad94ed Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 18 Sep 2026 12:10:03 +0530 Subject: [PATCH 3/5] Harden scheduled validation preflight --- eng/scripts/run-mssql-odbc-tests.sh | 2 +- eng/scripts/verify_mssql_odbc_provider.py | 24 ++++++++++++------- tests/test_038_mssql_odbc_daily_validation.py | 22 +++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/eng/scripts/run-mssql-odbc-tests.sh b/eng/scripts/run-mssql-odbc-tests.sh index 03eb07223..0e97d7429 100644 --- a/eng/scripts/run-mssql-odbc-tests.sh +++ b/eng/scripts/run-mssql-odbc-tests.sh @@ -174,7 +174,7 @@ for index in "${!TEST_FILES[@]}"; do write_stub "$name" error "Pytest exited $rc without producing valid JUnit" "$report" fi if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] || [ "$rc" -gt 127 ]; then - write_stub "${name}_process" error "Pytest process exited $rc after producing JUnit" \ + write_stub "${name}_process" error "Pytest process exited $rc" \ "$RESULTS_DIR/results-$name-process.xml" fi done diff --git a/eng/scripts/verify_mssql_odbc_provider.py b/eng/scripts/verify_mssql_odbc_provider.py index 020743a5a..6158df104 100644 --- a/eng/scripts/verify_mssql_odbc_provider.py +++ b/eng/scripts/verify_mssql_odbc_provider.py @@ -7,23 +7,31 @@ import mssql_python +def require(condition, message, details): + if not condition: + raise RuntimeError(f"{message}: {details!r}") + + def main(): info = mssql_python.get_native_provider_info() - assert info["id"] == "mssql-odbc", info - assert info["package"] == "mssql_py_core", info - assert info["source"] == "environment", info + require(info.get("id") == "mssql-odbc", "Unexpected native provider", info) + require(info.get("package") == "mssql_py_core", "Unexpected provider package", info) + require(info.get("source") == "environment", "Unexpected provider source", info) driver_path = Path(info["driver_path"]) - assert "mssqlodbc" in driver_path.name.lower(), info - assert driver_path.is_file(), info + require("mssqlodbc" in driver_path.name.lower(), "Unexpected driver filename", info) + require(driver_path.is_file(), "Driver path does not exist", info) with mssql_python.connect(os.environ["DB_CONNECTION_STRING"]) as connection: with connection.cursor() as cursor: cursor.execute("SELECT 1") - assert cursor.fetchone()[0] == 1 + row = cursor.fetchone() + require( + row is not None and row[0] == 1, "Provider query returned an unexpected row", row + ) loaded_info = mssql_python.get_native_provider_info() - assert loaded_info["id"] == "mssql-odbc", loaded_info - assert loaded_info["frozen"] is True, loaded_info + require(loaded_info.get("id") == "mssql-odbc", "Loaded provider changed", loaded_info) + require(loaded_info.get("frozen") is True, "Loaded provider is not frozen", loaded_info) print("MSSQL_ODBC_PREFLIGHT_OK", loaded_info, flush=True) diff --git a/tests/test_038_mssql_odbc_daily_validation.py b/tests/test_038_mssql_odbc_daily_validation.py index adbc08db6..267f69e7f 100644 --- a/tests/test_038_mssql_odbc_daily_validation.py +++ b/tests/test_038_mssql_odbc_daily_validation.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).parents[1] RUNNER = ROOT / "eng" / "scripts" / "run-mssql-odbc-tests.sh" PIPELINE = ROOT / "eng" / "pipelines" / "mssql-odbc-daily-validation-pipeline.yml" +PREFLIGHT = ROOT / "eng" / "scripts" / "verify_mssql_odbc_provider.py" @unittest.skipIf(os.name == "nt", "runner requires POSIX timeout and bash") @@ -99,6 +100,27 @@ def test_success_without_junit_is_blocking_harness_failure(self): class PipelineContractTests(unittest.TestCase): + def test_optimized_preflight_rejects_wrong_provider(self): + with tempfile.TemporaryDirectory() as directory: + fake_module = Path(directory) / "mssql_python.py" + fake_module.write_text( + "def get_native_provider_info():\n" + " return {'id': 'msodbcsql18', 'package': 'wrong', 'source': 'default'}\n", + encoding="utf-8", + ) + env = {**os.environ, "PYTHONPATH": directory, "DB_CONNECTION_STRING": "unused"} + + proc = subprocess.run( + [sys.executable, "-O", str(PREFLIGHT)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + self.assertNotEqual(proc.returncode, 0) + self.assertNotIn("MSSQL_ODBC_PREFLIGHT_OK", proc.stdout) + def test_pipeline_authenticates_advisory_status_and_keeps_publish_strict(self): pipeline = PIPELINE.read_text(encoding="utf-8") From 085cf6cac7dbfc2e005642b33edb1ab707f74218 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 18 Sep 2026 14:21:18 +0530 Subject: [PATCH 4/5] Address scheduled validation review feedback --- eng/scripts/run-mssql-odbc-tests.sh | 5 +++++ eng/scripts/verify_mssql_odbc_provider.py | 4 +++- tests/test_038_mssql_odbc_daily_validation.py | 15 +++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/eng/scripts/run-mssql-odbc-tests.sh b/eng/scripts/run-mssql-odbc-tests.sh index 0e97d7429..1ceb7aa4b 100644 --- a/eng/scripts/run-mssql-odbc-tests.sh +++ b/eng/scripts/run-mssql-odbc-tests.sh @@ -36,6 +36,11 @@ on_exit() { } trap on_exit EXIT +if [ "${MSSQL_PYTHON_NATIVE_PROVIDER:-}" != "mssql-odbc" ]; then + echo "##[error]MSSQL_PYTHON_NATIVE_PROVIDER must be set to mssql-odbc" + finish 2 harness +fi + xml_escape() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' } diff --git a/eng/scripts/verify_mssql_odbc_provider.py b/eng/scripts/verify_mssql_odbc_provider.py index 6158df104..f2d4ff0a1 100644 --- a/eng/scripts/verify_mssql_odbc_provider.py +++ b/eng/scripts/verify_mssql_odbc_provider.py @@ -17,7 +17,9 @@ def main(): require(info.get("id") == "mssql-odbc", "Unexpected native provider", info) require(info.get("package") == "mssql_py_core", "Unexpected provider package", info) require(info.get("source") == "environment", "Unexpected provider source", info) - driver_path = Path(info["driver_path"]) + driver_path_value = info.get("driver_path") + require(driver_path_value, "Provider did not return a driver path", info) + driver_path = Path(driver_path_value) require("mssqlodbc" in driver_path.name.lower(), "Unexpected driver filename", info) require(driver_path.is_file(), "Driver path does not exist", info) diff --git a/tests/test_038_mssql_odbc_daily_validation.py b/tests/test_038_mssql_odbc_daily_validation.py index 267f69e7f..4231719d7 100644 --- a/tests/test_038_mssql_odbc_daily_validation.py +++ b/tests/test_038_mssql_odbc_daily_validation.py @@ -15,9 +15,9 @@ PREFLIGHT = ROOT / "eng" / "scripts" / "verify_mssql_odbc_provider.py" -@unittest.skipIf(os.name == "nt", "runner requires POSIX timeout and bash") +@unittest.skipUnless(sys.platform.startswith("linux"), "runner requires Linux GNU timeout and bash") class RunnerTests(unittest.TestCase): - def run_runner(self, behavior: str, total_budget: str = "60s"): + def run_runner(self, behavior: str, total_budget: str = "60s", provider: str = "mssql-odbc"): with tempfile.TemporaryDirectory() as directory: work = Path(directory) (work / "tests").mkdir() @@ -45,11 +45,11 @@ def run_runner(self, behavior: str, total_budget: str = "60s"): results = work / "results" env = { **os.environ, - "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}", "TEST_RESULTS_DIR": str(results), "PYTEST_FILE_TIMEOUT": "30s", "PYTEST_TOTAL_BUDGET": total_budget, - "MSSQL_PYTHON_NATIVE_PROVIDER": "mssql-odbc", + "MSSQL_PYTHON_NATIVE_PROVIDER": provider, } proc = subprocess.run( ["bash", str(RUNNER)], @@ -72,6 +72,13 @@ def test_success_writes_authenticated_status(self): self.assertEqual(status, "success") self.assertEqual(set(reports), {"results-test_sample.xml"}) + def test_wrong_provider_is_blocking_harness_failure(self): + proc, status, reports = self.run_runner("success", provider="msodbcsql18") + + self.assertEqual(proc.returncode, 2, proc.stderr) + self.assertEqual(status, "harness") + self.assertEqual(reports, {}) + def test_budget_exhaustion_is_advisory_not_success(self): proc, status, reports = self.run_runner("success", total_budget="1s") From b9a58ffbeb4f6a496b396227fc393b8a39031123 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 18 Sep 2026 14:40:07 +0530 Subject: [PATCH 5/5] Fix Alpine timeout compatibility --- eng/scripts/run-mssql-odbc-tests.sh | 2 +- tests/test_038_mssql_odbc_daily_validation.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/eng/scripts/run-mssql-odbc-tests.sh b/eng/scripts/run-mssql-odbc-tests.sh index 1ceb7aa4b..f59d6b4ac 100644 --- a/eng/scripts/run-mssql-odbc-tests.sh +++ b/eng/scripts/run-mssql-odbc-tests.sh @@ -138,7 +138,7 @@ for index in "${!TEST_FILES[@]}"; do fi echo "##[group]$test_file" - timeout --kill-after="${KILL_GRACE_SECONDS}s" "${slice}s" \ + timeout -k "${KILL_GRACE_SECONDS}s" "${slice}s" \ python -m pytest "$test_file" -v --junitxml="$report" \ --capture=tee-sys --cache-clear rc=$? diff --git a/tests/test_038_mssql_odbc_daily_validation.py b/tests/test_038_mssql_odbc_daily_validation.py index 4231719d7..b4a22b35c 100644 --- a/tests/test_038_mssql_odbc_daily_validation.py +++ b/tests/test_038_mssql_odbc_daily_validation.py @@ -107,6 +107,12 @@ def test_success_without_junit_is_blocking_harness_failure(self): class PipelineContractTests(unittest.TestCase): + def test_runner_uses_busybox_compatible_timeout_options(self): + runner = RUNNER.read_text(encoding="utf-8") + + self.assertIn('timeout -k "${KILL_GRACE_SECONDS}s"', runner) + self.assertNotIn("--kill-after", runner) + def test_optimized_preflight_rejects_wrong_provider(self): with tempfile.TemporaryDirectory() as directory: fake_module = Path(directory) / "mssql_python.py"