diff --git a/.github/workflows/conda-audit.yml b/.github/workflows/conda-audit.yml index b1a2abe76..f733a3c59 100644 --- a/.github/workflows/conda-audit.yml +++ b/.github/workflows/conda-audit.yml @@ -3,7 +3,7 @@ # # The OneBranch conda-build pipeline is `trigger: none` / `pr: none`, so its blocking # audit never runs on a PR. This lightweight GitHub Actions job builds ONE real -# linux-64 conda package from the SHIPPED PyPI wheels and runs +# linux-64 conda package from the current published PyPI wheels and runs # python -m eng.conda_tools elf on it, so a regression in the $ORIGIN climb, # the declared conda deps (krb5/libtool/openssl), or the expected DT_NEEDED set fails # the PR automatically -- the full-agent runtime masking cannot hide it. @@ -11,7 +11,9 @@ # build.sh ALWAYS stamps the relative $ORIGIN climb onto the vendored Linux ODBC .so # (they are not code-signed, so the patch breaks no signature; an already-baked climb # is a byte-for-byte no-op). This gate builds one real linux-64 conda package from the -# shipped PyPI wheels and audits the resulting climb (see conda/mssql-python/build.sh). +# published wheels and audits the resulting climb (see conda/mssql-python/build.sh). +# It is a published CPython 3.11 packaging control, not current-source RS/3.15 +# qualification; source-only tests above it remain current-checkout and blocking. # # PR triggers are conda-specific: general package/version changes and shared wheel # build infrastructure do not run this gate unless the PR also changes a path below. @@ -72,15 +74,15 @@ jobs: tests/test_038_conda_archive_limits.py \ tests/test_027_conda_release_metadata.py -q - - name: Fetch the shipped linux wheels from PyPI + - name: Fetch the published CPython 3.11 smoke wheels from PyPI id: public-wheels working-directory: ${{ github.workspace }} run: | set -euo pipefail - # Exact maintained release versions and PyPI digests, never an older/latest fallback. - # Fetch RS only if binding METADATA requires it, using its distribution (not NuGet) pin. - # Embedded-core public wheels are historical controls, not current-source RS proof. + # Select PyPI's current binding and its exact ODBC/RS dependencies, not source pins. + # Verify PyPI hashes and actual METADATA/WHEEL/RECORD; no older-release fallback. python -m eng.conda_tools fetch-wheels \ + --published-smoke \ --wheel-dir "$PWD/wheels" --requirements-file "$PWD/wheel-inputs.txt" \ --python-tag cp311 --conda-subdir linux-64 echo "Downloaded:"; ls -1 wheels @@ -91,10 +93,9 @@ jobs: RS_REQUIRED: ${{ steps.public-wheels.outputs.rsRequired }} run: | set -euo pipefail - # The verified public dependency/ownership facts select the actual input set. rsArgs=() if [[ "$RS_REQUIRED" == "true" ]]; then - rsArgs=(--rs-wheel-dir "$PWD/wheels" --rs-version-file "$PWD/eng/versions/mssql-python-rs.version") + rsArgs=(--rs-wheel-dir "$PWD/wheels") elif [[ "$RS_REQUIRED" != "false" ]]; then echo "Missing verified public wheel profile"; exit 1 fi diff --git a/OneBranchPipelines/conda-build-pipeline.yml b/OneBranchPipelines/conda-build-pipeline.yml index d1c8fa239..fc5598478 100644 --- a/OneBranchPipelines/conda-build-pipeline.yml +++ b/OneBranchPipelines/conda-build-pipeline.yml @@ -35,12 +35,11 @@ parameters: - 'Official' - 'NonOfficial' default: 'NonOfficial' - # Python versions to build conda packages for (Windows loops these; the POSIX legs - # auto-detect the same set from the downloaded wheels). + # Ordinary CPython versions passed explicitly to the Windows and POSIX build legs. - name: pythonVersions displayName: 'Python versions (comma-separated)' type: string - default: '3.10,3.11,3.12,3.13,3.14' + default: '3.10,3.11,3.12,3.13,3.14,3.15' # H1: enable the minimal-base ldd reachability gate (CONDA_ASSERT_PREFIX_REACHABLE). # It fails CLOSED if the driver binds a system (or absent) krb5/gssapi/libltdl, so it # is only valid on a leg with NO system copies of those libs -- set true ONLY when @@ -198,14 +197,14 @@ extends: # internally (so a broken/wrong-arch package is never staged), AND win-arm64 is in # the release gate's requiredSubdirs -- so a MISSING win-arm64 blocks the RELEASE. # Net: a win-arm64 failure isolates to win-arm64 (win-64 still ships) yet still - # blocks the publish via the gate. DELIBERATELY a FIXED set (3.12-3.14), INDEPENDENT + # blocks the publish via the gate. DELIBERATELY a FIXED set (3.12-3.15), INDEPENDENT # of the pythonVersions parameter: neither defaults nor conda-forge ships a # win-arm64 python for 3.10/3.11, so following the param would queue an unbuildable # 3.10/3.11 arm leg. Its own bld tree + subdir-scoped verify env keep it from # colliding with win-64 on this shared agent. - template: /OneBranchPipelines/steps/conda-build-validate-step.yml@self parameters: - pythonVersion: '3.12,3.13,3.14' + pythonVersion: '3.12,3.13,3.14,3.15' condaSubdir: 'win-arm64' condaTargetSubdir: 'win-arm64' odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' diff --git a/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml b/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml index 72d4512c3..498f09829 100644 --- a/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml +++ b/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml @@ -12,15 +12,15 @@ # Expected packages (validated conda subdirs). The self-contained mssql-python # package (which vendors the ODBC payload) is emitted per-Python by each build leg; # there is NO separate companion package: -# win-64 : 5 py x mssql-python = 5 -# win-arm64 : 3 py x mssql-python (py3.12-3.14, cross-built on x64) = 3 -# osx-64 : 5 py x mssql-python (Intel Mac, native) = 5 -# osx-arm64 : 5 py x mssql-python (Intel Mac, cross-built) = 5 -# linux-64 : 5 py x mssql-python (glibc x86_64 host, native) = 5 -# linux-aarch64 : 5 py x mssql-python (x86_64 host + QEMU, best-effort) = 5 +# win-64 : 6 py x mssql-python (py3.10-3.15) = 6 +# win-arm64 : 4 py x mssql-python (py3.12-3.15, cross-built on x64) = 4 +# osx-64 : 6 py x mssql-python (Intel Mac, native) = 6 +# osx-arm64 : 6 py x mssql-python (Intel Mac, cross-built) = 6 +# linux-64 : 6 py x mssql-python (glibc x86_64 host, native) = 6 +# linux-aarch64 : 6 py x mssql-python (x86_64 host + QEMU, best-effort) = 6 # ------------------------------------------------------------------------------ -# TOTAL (PyPI parity minus musllinux) = 28 -# win-arm64 is cross-built for py3.12-3.14 only (py3.10/3.11 have no win-arm64 deps +# TOTAL (ordinary CPython, no musllinux or free-threaded variants) = 34 +# win-arm64 is cross-built for py3.12-3.15 only (py3.10/3.11 have no win-arm64 deps # on Anaconda defaults); its runtime import is skipped on the x64 host, so its arch # is enforced by the PE audit (eng.conda_tools pe). musllinux (no conda # musl subdir) is intentionally NOT conda-built. This job is BEST-EFFORT and never @@ -117,7 +117,7 @@ jobs: n=$(find "$d" -type f \( -name '*.conda' -o -name '*.tar.bz2' \) | wc -l) printf ' %-14s %s\n' "$sub" "$n" done - echo "Total conda package count: $PKG_COUNT (full PyPI-parity set = 28)" + echo "Total conda package count: $PKG_COUNT (full ordinary-CPython set = 34)" # BEST-EFFORT: never exit non-zero — an empty set emits a warning; a partial set # remains visible in the counts above. A conda hiccup must not block the wheel diff --git a/OneBranchPipelines/steps/conda-build-validate-step.yml b/OneBranchPipelines/steps/conda-build-validate-step.yml index 0aa094509..5dea08c3f 100644 --- a/OneBranchPipelines/steps/conda-build-validate-step.yml +++ b/OneBranchPipelines/steps/conda-build-validate-step.yml @@ -27,7 +27,7 @@ parameters: # Python version(s) to build, comma-separated X.Y (e.g. '3.13' or '3.12,3.13,3.14'); passed # straight to the orchestrator's --python-versions. win-64 follows the pythonVersions - # parameter; win-arm64 uses a fixed 3.12-3.14 set (the singular name is kept for callers). + # parameter; win-arm64 uses a fixed 3.12-3.15 set (the singular name is kept for callers). - name: pythonVersion type: string # Conda subdir for this leg's package metadata + staging: 'win-64' (native) or 'win-arm64' diff --git a/OneBranchPipelines/steps/conda-publish-step.yml b/OneBranchPipelines/steps/conda-publish-step.yml index af084951d..9c406726a 100644 --- a/OneBranchPipelines/steps/conda-publish-step.yml +++ b/OneBranchPipelines/steps/conda-publish-step.yml @@ -68,7 +68,7 @@ parameters: # against this job's independently downloaded artifact before upload. - name: pythonVersions type: string - default: '3.10,3.11,3.12,3.13,3.14' + default: '3.10,3.11,3.12,3.13,3.14,3.15' # The publisher hash lock targets CPython 3.12 / Windows x64; other runtimes reject. - name: pythonVersion type: string diff --git a/OneBranchPipelines/steps/conda-release-step.yml b/OneBranchPipelines/steps/conda-release-step.yml index 4a8386ff0..3e3cce161 100644 --- a/OneBranchPipelines/steps/conda-release-step.yml +++ b/OneBranchPipelines/steps/conda-release-step.yml @@ -35,7 +35,7 @@ # dependency solve PLUS a PE machine-type assert on the vendored arm64 .pyd/.dll, so a # broken or wrong-arch package is never STAGED. That build leg is continueOnError (so a # win-arm64 hiccup can't nuke the shared win-64 publish), and THIS release gate is where -# win-arm64 is enforced: a missing win-arm64 fails the gate. Its matrix is 3.12-3.14 (the +# win-arm64 is enforced: a missing win-arm64 fails the gate. Its matrix is 3.12-3.15 (the # gate's per-subdir override), since Anaconda `defaults` has no win-arm64 python for # 3.10/3.11. musllinux has no conda subdir at all (conda Linux is glibc-only), so absent. # @@ -64,7 +64,7 @@ parameters: # subdir. The gate reads each binding's pyXY build tag from info/index.json. - name: pythonVersions type: string - default: '3.10,3.11,3.12,3.13,3.14' + default: '3.10,3.11,3.12,3.13,3.14,3.15' # Optional EXACT expected versions. When set, the gate asserts every package's # info/index.json version matches; when empty it still enforces one-version-per- # package consistency plus the subdir / matrix / pairing checks. diff --git a/conda/README.md b/conda/README.md index 89071b988..efbd5fb92 100644 --- a/conda/README.md +++ b/conda/README.md @@ -57,8 +57,15 @@ The `conda-audit` PR gate triggers only for `conda/`, `eng/conda_tools/`, Conda- OneBranch pipelines/steps/jobs, their native/probe/archive/provenance and release tests, and the workflow itself. General package metadata/version changes and shared wheel-build infrastructure do not trigger it on their own; mixed PRs with a Conda change still do. -This scope does not change the unit-test runner, source-version wheel selection, or -failure handling when a required wheel is unpublished. +Source-only tests remain current-checkout and blocking. The real Linux CPython 3.11 +build/audit uses `fetch-wheels --published-smoke`: PyPI's current published binding +and its exact ODBC dependency (plus RS when required). No smoke versions or hashes +are hardcoded; downloads are verified against PyPI's SHA256 values and actual wheel +metadata/ownership. Source release bumps do not select unpublished smoke inputs. +The selected versions are logged and hash-required requirements are generated per run. +Missing wheels, metadata/dependency or hash mismatches and build/audit failures still +fail the gate; there is no skip or older-release fallback. This published packaging +control does not qualify current-source RS or Python 3.15. Run the source-only trigger regressions without a native build, SQL Server, or PyYAML: @@ -112,8 +119,9 @@ macOS retains its external Homebrew/MacPorts OpenSSL prerequisite for encryption Conda OpenSSL alone does not satisfy the driver's system-path lookup. The release goal is the matching PyPI release's public API and supported feature -behavior on all 28 ordinary CPython variants: 3.10-3.14 on win-64, linux-64, -linux-aarch64, osx-64 and osx-arm64; 3.12-3.14 on win-arm64. No silent removal of +behavior on all 34 ordinary CPython variants: 3.10-3.15 on win-64, linux-64, +linux-aarch64, osx-64 and osx-arm64; 3.12-3.15 on win-arm64. Free-threaded CPython +(including `cp315t`) and musllinux are not Conda targets. No silent removal of required native functionality is acceptable. Optional features require their corresponding dependencies; Windows ARM64 PyArrow availability remains a blocker to qualifying those features, not permission to drop them. @@ -125,7 +133,7 @@ load do not establish those results. Applicable OS, certificate and authenticati configuration remain external prerequisites. Use only organizationally approved channels and handle applicable terms separately; -the Windows ARM64 dependency profile includes Anaconda `defaults`. Before unattended +the Windows ARM64 3.12-3.14 dependency profile includes Anaconda `defaults`. Before unattended Windows ARM64 builds, agent owners must use approved provisioning to put a disposable Conda installation on `PATH`, with applicable channel terms handled for the job's execution identity. The orchestrator's automatic Miniforge installation does not @@ -134,6 +142,25 @@ or verification solve stops with Conda's diagnostic; provision the prerequisite running again. Automatic acceptance, including an inherited `CONDA_PLUGINS_AUTO_ACCEPT_TOS` opt-in, remains disabled. +Windows ARM64 3.15 uses `microsoft`, then `conda-forge`, for both build and verification +(after the local verification channel), without `defaults`. Older targets retain their +existing channel policies and the ARM64 3.12 manual ABI pin. The blocking full dependency +solve remains; every 3.15 package must pass the `python_abi 3.15.* *_cp315` native audit. + +### Python 3.15 packaging + +Conda-forge's Windows Python 3.15 declares `lib/python/site-packages` instead of +`Lib/site-packages` ([layout change](https://conda-forge.org/blog/2026/09/03/python-315/)). +The Windows recipe uses the target's `SP_DIR` for binding/RS/ODBC extraction, with +matching native and installed-metadata checks. Windows legs containing 3.15 use +**conda-build 26.9.1** for all requested variants: its +[26.9.0 fix](https://github.com/conda/conda-build/releases/tag/26.9.0) reads +`python_site_packages_path` from the target Python record, including cross builds. +25.x computes the old Windows path even for 3.15. Other legs retain `conda-build<26`; +the publishing toolchain is unchanged. The existing RS 0.3.0 stable-ABI selection and +ODBC `py3-none` wheels need no version change. PyArrow stays optional; no speculative +`libpython` dependency or change to the Unix OS floors is introduced. + Run this existing build workflow only in a disposable isolated installation: shared-environment ownership hardening is outside this change. Publication/provenance tooling is included alongside this native/tooling foundation; see the @@ -262,17 +289,19 @@ members. This detects unowned or cross-owned additions; it cannot recover file o after an overwrite of an already owned path, which is why the raw pre-extraction gate is required. -`fetch-wheels` uses exact current maintained versions, hash-required binary-only PyPI +By default, `fetch-wheels` uses exact current maintained versions, hash-required binary-only PyPI downloads, and actual wheel metadata/ownership. Pip selects for its executing interpreter; the requested Python/platform must match the binding, ODBC and selected RS wheel tags before the command accepts the inputs. These arguments validate the download, rather than enabling cross-target pip resolution. An authentic published embedded-core binding is explicitly reported as a **historical published packaging control, not current-source RS qualification**. An RS-dependent published binding requires its exact -provider and source pin; unavailable releases, malformed declarations, or mismatches +provider and selected pin; unavailable releases, malformed declarations, or mismatches fail with no older-version or TLS fallback. Public availability does not gate the recorded ADO artifact path. Neither a public input check nor static archive checks establish installed SQL/runtime qualification. +Only the explicit `--published-smoke` option selects the published inputs above; +production, provenance, preflight and release/publish version selection are unchanged. The driver-load probe's sole implementation is `eng/conda_tools/driver_load_probe.py`. It separates native connection execution, diff --git a/conda/mssql-python/bld.bat b/conda/mssql-python/bld.bat index 22efc9fcc..b09a3ddfc 100644 --- a/conda/mssql-python/bld.bat +++ b/conda/mssql-python/bld.bat @@ -4,7 +4,13 @@ REM vendor the ODBC Driver 18 payload inside it. conda-build exports PKG_NAME / REM PKG_VERSION / CONDA_PY; the pipeline exports WHEELS_DIR + MSSQL_ODBC_VERSION. setlocal enabledelayedexpansion -set "SP=%PREFIX%\Lib\site-packages" +REM conda-build 26.9+ reads the target Python's python_site_packages_path (CFEP-27). +REM Python 3.15 uses lib\python\site-packages; older Windows targets use Lib\site-packages. +if not defined SP_DIR ( + echo ERROR: conda-build must supply the target Python site-packages in SP_DIR. + exit /b 1 +) +set "SP=%SP_DIR%" if not exist "%SP%" mkdir "%SP%" set "PKG_UNDERSCORE=%PKG_NAME:-=_%" diff --git a/conda/mssql-python/meta.yaml b/conda/mssql-python/meta.yaml index 375231a1e..800fa579a 100644 --- a/conda/mssql-python/meta.yaml +++ b/conda/mssql-python/meta.yaml @@ -36,7 +36,7 @@ requirements: # --python matrix here, so each build emits the matching py3XX package. A range such # as `python >=3.10` collapses ALL --python builds into ONE cp package (build # string "py>=310") whose python_abi is pinned to 3.14, so `conda create python=3.10 - # mssql-python` can't solve. The >=3.10 floor is enforced implicitly: only py310-py314 + # mssql-python` can't solve. The >=3.10 floor is enforced implicitly: only py310-py315 # are built and each package's python_abi pins its exact minor. - python - pip diff --git a/eng/conda_tools/build.py b/eng/conda_tools/build.py index 0c4bb84f6..f68392cb0 100644 --- a/eng/conda_tools/build.py +++ b/eng/conda_tools/build.py @@ -302,10 +302,10 @@ def build_packages( croot = os.path.join(os.path.dirname(os.path.abspath(bld)), "croot") if os.path.isdir(croot): shutil.rmtree(croot) - channels = ["microsoft", "conda-forge"] - if target_subdir == "win-arm64": - channels.insert(0, "defaults") for py in pyvers: + channels = ["microsoft", "conda-forge"] + if target_subdir == "win-arm64" and py != "3.15": + channels.insert(0, "defaults") environment._log( f"=== [py {py}] build mssql-python (self-contained: vendors the ODBC payload) ===" ) @@ -471,8 +471,8 @@ def execute(args: argparse.Namespace) -> int: conda = environment.find_or_install_conda(output_dir) environment._log(f"Using conda: {conda}") environment.run([conda, "--version"], what="conda --version") - builder = environment.create_builder_env(conda) pyvers = detect_pythons(links, python_versions) + builder = environment.create_builder_env(conda, target, pyvers) cross_build = bool(args.conda_target_subdir) env = environment.build_env(mssql_ver, odbc_ver, links, args.conda_target_subdir, rs_ver) diff --git a/eng/conda_tools/contracts.py b/eng/conda_tools/contracts.py index da84d6bed..155a4a417 100644 --- a/eng/conda_tools/contracts.py +++ b/eng/conda_tools/contracts.py @@ -418,6 +418,18 @@ def validate_rs_ownership( return errors +def site_packages_root(python_tag: str, subdir: str) -> str: + """Canonical archive root for the supported ordinary CPython target, not the host.""" + if not re.fullmatch(r"cp3\d+", python_tag) or subdir not in _RS_PLATFORMS: + raise ValueError(f"Unsupported Conda Python/platform target: {python_tag} {subdir}") + minor = int(python_tag[3:]) + if subdir.startswith("win-"): + prefix = "lib/python" if minor >= 15 else "Lib" + else: + prefix = f"lib/python3.{minor}" + return f"{prefix}/site-packages/" + + def validate_native_contract(names: Iterable[str], index: dict[str, Any]) -> list[str]: """Require a target binding, core extension and initializer; platform audits check headers. @@ -436,8 +448,7 @@ def validate_native_contract(names: Iterable[str], index: dict[str, Any]) -> lis subdir = index["subdir"] windows = subdir.startswith("win-") suffix = "pyd" if windows else "so" - prefix = "Lib" if windows else f"lib/python{abi[1]}" - root = f"{prefix}/site-packages/" + root = site_packages_root(f"cp{abi[2]}", subdir) names = [name.replace("\\", "/") for name in names] bindings = [ name diff --git a/eng/conda_tools/environment.py b/eng/conda_tools/environment.py index fad5c18c7..1268ddd0f 100644 --- a/eng/conda_tools/environment.py +++ b/eng/conda_tools/environment.py @@ -139,14 +139,21 @@ def _sha256(path: str) -> str: return h.hexdigest() -def create_builder_env(conda: str) -> str: - """conda-build<26: 26.7.0 crashes in the local packaging phase; the 25.x series is stable. +def create_builder_env(conda: str, target_subdir: str, pyvers: list[str]) -> str: + """Keep the stable 25.x builder except where Windows 3.15 needs CFEP-27 support. + 26.9.1 reads the target Python's recorded site-packages path; 25.x computes Lib. + Pin that opt-in rather than reopening the range containing the 26.7.0 packaging crash. A DEDICATED env (not `install -n base`) because a hosted runner's base may be pinned to a python no conda-build<26 supports (e.g. 3.14), making a base install UNSOLVABLE; a fresh env lets conda pick a supported python. conda-forge only (--override-channels) avoids the defaults ToS; zstandard rides along so the RUNPATH audit reads .conda payloads here.""" env_name = "conda_builder" - _log(f"=== creating dedicated conda-build env ({env_name}: conda-build<26) ===") + builder_spec = ( + "conda-build=26.9.1" + if target_subdir.startswith("win-") and "3.15" in pyvers + else "conda-build<26" + ) + _log(f"=== creating dedicated conda-build env ({env_name}: {builder_spec}) ===") # Idempotent: a reused agent may already have this env; a pre-existing env makes # `conda create` fail. Remove first (best-effort). run_ok([conda, "env", "remove", "-y", "-n", env_name]) @@ -160,7 +167,7 @@ def create_builder_env(conda: str) -> str: "-c", "conda-forge", "--override-channels", - "conda-build<26", + builder_spec, "zstandard", ], what=f"conda create {env_name}", diff --git a/eng/conda_tools/inputs.py b/eng/conda_tools/inputs.py index af7f3d93d..5cc43380b 100644 --- a/eng/conda_tools/inputs.py +++ b/eng/conda_tools/inputs.py @@ -176,7 +176,7 @@ def fetch_wheels( else: if source_versions.get("mssql-python-rs") != rs_version: raise ValueError( - "Published binding RS requirement differs from the maintained source pin." + "Published binding RS requirement differs from the selected pin." ) versions["mssql-python-rs"] = rs_version hashes.update( @@ -213,11 +213,7 @@ def validate_installed_inputs( if len(names) != len(set(names)): raise ValueError(f"{path}: duplicate payload members") metadata = {} - root = ( - "Lib/site-packages/" - if subdir.startswith("win-") - else (f"lib/python{python_tag[2]}.{python_tag[3:]}/site-packages/") - ) + root = contracts.site_packages_root(python_tag, subdir) for facts, present, owned, prefix in archive.installed_metadata(names, files): name = contracts.canonical_distribution_name(facts["name"]) if name in metadata: @@ -282,6 +278,11 @@ def validate_installed_inputs( def add_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--wheel-dir", type=Path, required=True) parser.add_argument("--requirements-file", type=Path, required=True) + parser.add_argument( + "--published-smoke", + action="store_true", + help="Use PyPI's current published binding and its exact dependencies for the PR smoke.", + ) parser.add_argument( "--python-tag", required=True, @@ -296,7 +297,12 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: def fetch_cli(args: argparse.Namespace) -> int: try: - source = read_release_versions(lambda path: Path(path).read_text(encoding="utf-8")) + if args.published_smoke: + if (args.python_tag, args.conda_subdir) != ("cp311", "linux-64"): + raise ValueError("The published smoke requires cp311 linux-64.") + source = read_published_versions() + else: + source = read_release_versions(lambda path: Path(path).read_text(encoding="utf-8")) versions = fetch_wheels( source, args.wheel_dir, args.requirements_file, args.python_tag, args.conda_subdir ) @@ -316,6 +322,32 @@ def fetch_cli(args: argparse.Namespace) -> int: return 0 +def read_published_versions() -> dict[str, str]: + """Select PyPI's current binding release, never independently mix latest components.""" + try: + with urlopen("https://pypi.org/pypi/mssql-python/json", timeout=60) as response: + release = json.load(response) + except URLError as error: + raise ValueError( + "Cannot select the published binding from PyPI; no fallback is allowed." + ) from error + info = release.get("info") if isinstance(release, dict) else None + if not isinstance(info, dict) or info.get("name") != "mssql-python": + raise ValueError("Invalid published binding identity.") + requirements = info.get("requires_dist") + if not isinstance(requirements, list) or any( + not isinstance(item, str) for item in requirements + ): + raise ValueError("Invalid published binding dependencies.") + versions = { + "mssql-python": info.get("version"), + "mssql-python-odbc": contracts.exact_dependency_pin(requirements, "mssql-python-odbc"), + } + if rs_version := contracts.exact_dependency_pin(requirements, "mssql-python-rs"): + versions["mssql-python-rs"] = rs_version + return parse_release_versions(json.dumps(versions)) + + def read_release_versions(read_source: Callable[[str], str]) -> dict[str, str]: """Read the literals maintained by the existing wheel release process, without imports.""" setup_source = read_source("setup.py") diff --git a/eng/conda_tools/release.py b/eng/conda_tools/release.py index 65202a583..bf7ab52da 100644 --- a/eng/conda_tools/release.py +++ b/eng/conda_tools/release.py @@ -19,7 +19,7 @@ Regex, parentheses, epochs, local/dev/post versions and other syntax fail closed. * the (required-subdir x Python) matrix is complete -- every required platform ships a package for every expected Python, honoring any per-subdir Python - override (e.g. win-arm64 ships only 3.12-3.14). + override (e.g. win-arm64 ships only 3.12-3.15). Exit code 0 = release-ready; non-zero = a violation was found (blocks publish). """ @@ -54,10 +54,8 @@ ">=": operator.ge, } -# Some subdirs legitimately ship a REDUCED Python matrix. win-arm64's conda -# dependencies (cryptography, pyodbc) are published on Anaconda `defaults` only -# for Python 3.12+, so 3.10/3.11 cannot be built there -- expect 3.12-3.14 only. -_DEFAULT_SUBDIR_PYTHONS = "win-arm64=3.12,3.13,3.14" +# win-arm64 retains its reduced matrix; its supported interpreters start at 3.12. +_DEFAULT_SUBDIR_PYTHONS = "win-arm64=3.12,3.13,3.14,3.15" @dataclass(frozen=True) @@ -197,7 +195,7 @@ def validate( ``python`` (``X.Y`` or ``''``). ``subdir_pythons`` maps a subdir to the Python versions expected FOR THAT - subdir, overriding ``expected_pythons`` (e.g. win-arm64 ships only 3.12-3.14). + subdir, overriding ``expected_pythons`` (e.g. win-arm64 ships only 3.12-3.15). """ errors: list[str] = [] expected_versions = expected_versions or {} @@ -388,11 +386,11 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: "--allowed-subdirs", default="win-64,win-arm64,osx-64,osx-arm64,linux-64,linux-aarch64", ) - parser.add_argument("--pythons", default="3.10,3.11,3.12,3.13,3.14") + parser.add_argument("--pythons", default="3.10,3.11,3.12,3.13,3.14,3.15") parser.add_argument( "--subdir-pythons", default=_DEFAULT_SUBDIR_PYTHONS, - help="Per-subdir Python overrides, e.g. 'win-arm64=3.12,3.13,3.14'.", + help="Per-subdir Python overrides, e.g. 'win-arm64=3.12,3.13,3.14,3.15'.", ) parser.add_argument("--mssql-python-version", default=None) parser.add_argument( diff --git a/eng/conda_tools/verify.py b/eng/conda_tools/verify.py index ce1887a0a..83c1348cc 100644 --- a/eng/conda_tools/verify.py +++ b/eng/conda_tools/verify.py @@ -131,6 +131,8 @@ def _verify_impl( # (no link / post-link / arm64 exec) -- a pure "installable?" check. win-arm64 deps # span microsoft (noarch azure-identity/msal) + Anaconda defaults, so no # --strict-channel-priority. Pin the freshly built version so no channel can shadow it. + # Python 3.15 is supplied by conda-forge; retain the old channels for 3.12-3.14. + python_channel = "conda-forge" if py == "3.15" else "defaults" environment.run( [ conda, @@ -143,7 +145,7 @@ def _verify_impl( "-c", "microsoft", "-c", - "defaults", + python_channel, "--override-channels", f"python={py}", f"mssql-python={mssql_ver}", @@ -163,7 +165,7 @@ def _verify_impl( "-c", "microsoft", "-c", - "defaults", + python_channel, "--override-channels", f"python={py}", f"mssql-python={mssql_ver}", diff --git a/tests/test_027_conda_release_metadata.py b/tests/test_027_conda_release_metadata.py index 185ca6b97..7d599683f 100644 --- a/tests/test_027_conda_release_metadata.py +++ b/tests/test_027_conda_release_metadata.py @@ -506,10 +506,31 @@ def test_default_subdir_pythons_reduces_win_arm64(): # The shipped CLI default carries the win-arm64 reduction so the pipeline needs # no extra flag; parsing it yields the expected mapping. assert vcr._parse_subdir_pythons(vcr._DEFAULT_SUBDIR_PYTHONS) == { - "win-arm64": ["3.12", "3.13", "3.14"] + "win-arm64": ["3.12", "3.13", "3.14", "3.15"] } +def test_conda_python_defaults_are_synchronized(): + args = cli.parser().parse_args(["validate", "--root", "unused"]) + assert args.pythons == "3.10,3.11,3.12,3.13,3.14,3.15" + assert args.subdir_pythons == "win-arm64=3.12,3.13,3.14,3.15" + for path in ( + "OneBranchPipelines/conda-build-pipeline.yml", + "OneBranchPipelines/steps/conda-release-step.yml", + "OneBranchPipelines/steps/conda-publish-step.yml", + ): + source = (_ROOT / path).read_text(encoding="utf-8") + parameter = source.split("- name: pythonVersions\n", 1)[1].split("\n - name:", 1)[0] + assert f"default: '{args.pythons}'" in parameter, path + build_source = (_ROOT / "OneBranchPipelines/conda-build-pipeline.yml").read_text( + encoding="utf-8" + ) + assert ( + f"pythonVersion: '{args.subdir_pythons.split('=')[1]}'\n" + " condaSubdir: 'win-arm64'" + ) in build_source + + def test_parse_subdir_pythons(): assert vcr._parse_subdir_pythons("") == {} assert vcr._parse_subdir_pythons("a=3.10;b=3.11,3.12") == { @@ -1514,9 +1535,18 @@ def test_metadata_agreement_across_full_archive_matrix(tmp_path, capsys, build, index.update(build=build, depends=depends) _write_release_archive(tmp_path, folder=subdir, **index) assert len(list(tmp_path.rglob("*.tar.bz2"))) == 28 - assert _metadata_main(["--root", str(tmp_path), "--mssql-python-version", _MP_VER]) == bool( - error - ) + assert _metadata_main( + [ + "--root", + str(tmp_path), + "--mssql-python-version", + _MP_VER, + "--pythons", + ",".join(_PYTHONS), + "--subdir-pythons", + "win-arm64=3.12,3.13,3.14", + ] + ) == bool(error) output = capsys.readouterr() assert error in output.err if error else "metadata-validated" in output.out @@ -1895,22 +1925,27 @@ def read(path, *args, **kwargs): "count, policy, message", [ (0, [], "no conda packages"), - (25, [], "win-arm64"), - (28, [], ""), - (28, ["--mssql-python-version", "wrong"], "!= expected"), - (28, ["--required-subdirs", ""], "must not be empty"), - (28, ["--required-subdirs", " , "], "must not be empty"), - (28, ["--allowed-subdirs", ""], "must not be empty"), - (28, ["--allowed-subdirs", " , "], "must not be empty"), - (28, ["--pythons", ""], "must not be empty"), - (28, ["--pythons", " , "], "must not be empty"), - (28, ["--allowed-subdirs", "invalid"], "not in allowed set"), + (33, [], "INCOMPLETE"), + (34, [], ""), + (34, ["--mssql-python-version", "wrong"], "!= expected"), + (34, ["--required-subdirs", ""], "must not be empty"), + (34, ["--required-subdirs", " , "], "must not be empty"), + (34, ["--allowed-subdirs", ""], "must not be empty"), + (34, ["--allowed-subdirs", " , "], "must not be empty"), + (34, ["--pythons", ""], "must not be empty"), + (34, ["--pythons", " , "], "must not be empty"), + (34, ["--allowed-subdirs", "invalid"], "not in allowed set"), ], ) def test_metadata_cli_enforces_full_default_release_policy( tmp_path, monkeypatch, capsys, count, policy, message ): - packages = _healthy_set() + [_binding("win-arm64", py) for py in _PYTHONS[2:]] + packages = ( + _healthy_set() + + [_binding("win-arm64", py) for py in _PYTHONS[2:]] + + [_binding(subdir, "3.15") for subdir in _ALLOWED] + ) + assert len(packages) == 34 monkeypatch.setattr(vcr, "collect_packages", lambda _root: packages[:count]) assert _metadata_main(["--root", str(tmp_path), *policy]) == (1 if message else 0) output = capsys.readouterr() @@ -1945,10 +1980,18 @@ def test_metadata_cli_rejects_distinct_builds_in_one_matrix_cell(tmp_path, capsy build=f"py{py.replace('.', '')}_0", depends=[f"python {py}.*"], ) - assert _metadata_main(["--root", str(tmp_path)]) == 0 + policy = [ + "--root", + str(tmp_path), + "--pythons", + ",".join(_PYTHONS), + "--subdir-pythons", + "win-arm64=3.12,3.13,3.14", + ] + assert _metadata_main(policy) == 0 _write_release_archive(tmp_path, build="py312_1") assert len(list(tmp_path.rglob("*.tar.bz2"))) == 29 - assert _metadata_main(["--root", str(tmp_path)]) == 1 + assert _metadata_main(policy) == 1 assert "DUPLICATE" in capsys.readouterr().err diff --git a/tests/test_030_pe_machine_assert.py b/tests/test_030_pe_machine_assert.py index 8f4a11efd..9f2b77852 100644 --- a/tests/test_030_pe_machine_assert.py +++ b/tests/test_030_pe_machine_assert.py @@ -186,7 +186,14 @@ def test_binding_rs_requirement_accepts_metadata_spacing(spacing): @pytest.mark.skipif(sys.platform != "win32", reason="Windows recipe requires cmd.exe") -@pytest.mark.parametrize("cross_build", [False, True]) +@pytest.mark.parametrize( + "cross_build,tag", + [ + (False, f"{sys.version_info.major}{sys.version_info.minor}"), + (True, f"{sys.version_info.major}{sys.version_info.minor}"), + (True, "315"), + ], +) @pytest.mark.parametrize( ("rs_owned", "state"), [ @@ -196,12 +203,13 @@ def test_binding_rs_requirement_accepts_metadata_spacing(spacing): ] + [(True, "missing-private-library")], ) -def test_windows_recipe_requires_core_on_both_install_paths(tmp_path, cross_build, rs_owned, state): +def test_windows_recipe_requires_core_on_both_install_paths( + tmp_path, cross_build, tag, rs_owned, state +): wheels = tmp_path / "wheels" wheels.mkdir() prefix = tmp_path / "prefix" - site_packages = prefix / "Lib" / "site-packages" - tag = f"{sys.version_info.major}{sys.version_info.minor}" + site_packages = prefix / ("lib/python" if int(tag) >= 315 else "Lib") / "site-packages" arch = sysconfig.get_platform().replace("-", "_") machine = _ARM64 if arch == "win_arm64" else _AMD64 core = f"mssql_py_core/mssql_py_core.cp{tag}-{arch}.pyd" @@ -256,6 +264,7 @@ def test_windows_recipe_requires_core_on_both_install_paths(tmp_path, cross_buil env = dict( os.environ, PREFIX=str(prefix), + SP_DIR=str(site_packages), PYTHON=str(tmp_path / "nonexecutable-python") if cross_build else sys.executable, PKG_NAME="mssql-python", PKG_VERSION="1.13.0", @@ -298,9 +307,12 @@ def test_windows_recipe_requires_core_on_both_install_paths(tmp_path, cross_buil ) in output -def _make_conda(tmp_path, subdir, payload, depends=("python_abi 3.12.* *_cp312",)): +def _make_conda( + tmp_path, subdir, payload, depends=("python_abi 3.12.* *_cp312",), python_tag="cp312" +): """Build a minimal .conda (info-*.tar.zst + pkg-*.tar.zst) with the given payload files.""" - name = "mssql-python-1.13.0-py312_0" + build = f"py{python_tag[2:]}_0" + name = f"mssql-python-1.13.0-{build}" pkg_buf = io.BytesIO() with tarfile.open(fileobj=pkg_buf, mode="w") as tf: @@ -312,7 +324,7 @@ def _make_conda(tmp_path, subdir, payload, depends=("python_abi 3.12.* *_cp312", index = { "name": "mssql-python", "version": "1.13.0", - "build": "py312_0", + "build": build, "subdir": subdir, "depends": depends, } @@ -330,6 +342,21 @@ def _make_conda(tmp_path, subdir, payload, depends=("python_abi 3.12.* *_cp312", return str(conda_path) +@pytest.mark.parametrize( + "root", + ["Lib/site-packages/", "lib/python3.15/site-packages/", "nested/lib/python/site-packages/"], +) +def test_cp315_native_layout_rejects_other_windows_roots(root): + names = [ + root + "mssql_python/ddbc_bindings.cp315-win_amd64.pyd", + root + "mssql_py_core/__init__.py", + root + "mssql_py_core/mssql_py_core.pyd", + ] + assert contracts.validate_native_contract( + names, {"subdir": "win-64", "depends": ["python_abi 3.15.* *_cp315"]} + ) + + @pytest.mark.skipif(not _zstd_available(), reason="no zstandard backend available") def test_malformed_dependencies_are_reported(tmp_path): errors = audit.audit_package( @@ -435,7 +462,8 @@ def test_win_arm64_binaries_require_binding_metadata(tmp_path, metadata_state): "wrong-abi", ], ) -def test_required_core_contract(tmp_path, state): +@pytest.mark.parametrize("python_tag", ["cp312", "cp315"]) +def test_required_core_contract(tmp_path, state, python_tag): pin = "python_abi 3.12.* *_cp312" # The observed defaults CP312 host supplied only the Python range, not an ABI export. depends = ["vc14_runtime", "python >=3.12,<3.13.0a0", "azure-identity >=1.12.0"] @@ -464,8 +492,19 @@ def test_required_core_contract(tmp_path, state): payload[prefix + "RECORD"] = "".join( f"{name.removeprefix('Lib/site-packages/')},,\n" for name in [*payload, prefix + "RECORD"] ).encode() + if python_tag == "cp315": + payload = { + name.replace("Lib/site-packages/", "lib/python/site-packages/").replace( + "cp312", "cp315" + ): data.replace(b"cp312", b"cp315") + for name, data in payload.items() + } + depends = [ + dep.replace("3.12", "3.15").replace("3.13", "3.16").replace("cp312", "cp315") + for dep in depends + ] errors = audit.audit_package( - _make_conda(tmp_path, "win-arm64", payload, depends=depends), "pe" + _make_conda(tmp_path, "win-arm64", payload, depends=depends, python_tag=python_tag), "pe" ).violations if state in ("valid", "abi3"): assert errors == [] diff --git a/tests/test_034_conda_verify_cwd.py b/tests/test_034_conda_verify_cwd.py index ec01f2b9d..befe385c4 100644 --- a/tests/test_034_conda_verify_cwd.py +++ b/tests/test_034_conda_verify_cwd.py @@ -352,7 +352,7 @@ def test_main_routes_native_effective_subdir_without_cross_target(tmp_path, monk monkeypatch.setattr(build, "gather_wheels", lambda *_args: ("1.2.3", "18.6.2", None)) monkeypatch.setattr(environment, "find_or_install_conda", lambda _output_dir: "conda") monkeypatch.setattr(environment, "run", lambda *_args, **_kwargs: None) - monkeypatch.setattr(environment, "create_builder_env", lambda _conda: "conda_builder") + monkeypatch.setattr(environment, "create_builder_env", lambda *_args: "conda_builder") monkeypatch.setattr(build, "detect_pythons", lambda *_args: ["3.13"]) def _build_env(_mssql_ver, _odbc_ver, _links, cross_target_subdir, _rs_ver): @@ -856,6 +856,20 @@ def test_binding_selection_requires_the_requested_normal_abi(abi): assert not contracts.binding_wheel_matches_target(wheel, "win-64", ["3.13"]) +@pytest.mark.parametrize("subdir,platform", contracts._RS_PLATFORMS.items()) +def test_cp315_selects_ordinary_binding_and_existing_rs_abi3(tmp_path, subdir, platform): + binding = f"mssql_python-1.2.3-cp315-cp315-{platform}.whl" + assert contracts.binding_wheel_matches_target(binding, subdir, ["3.15"]) + assert not contracts.binding_wheel_matches_target( + binding.replace("-cp315-cp315-", "-cp315-cp315t-"), subdir, ["3.15"] + ) + selected = tmp_path / f"mssql_python_rs-0.3.0-cp310-abi3-{platform}.whl" + selected.touch() + (tmp_path / f"mssql_python_rs-0.3.0-cp315-cp315t-{platform}.whl").touch() + (tmp_path / f"mssql_python_rs-0.3.0-cp316-abi3-{platform}.whl").touch() + assert build._select_rs_wheel(str(tmp_path), "0.3.0", "cp315", subdir) == str(selected) + + @pytest.mark.parametrize( "state", ["valid", "missing-rs", "mismatched-rs", "odbc-core", "odbc-target"] ) @@ -1134,17 +1148,20 @@ def test_malformed_record_is_an_explicit_error(record): @pytest.mark.parametrize( - ("target_subdir", "expected_channels"), + ("target_subdir", "python_version", "expected_channels"), [ - ("", ["microsoft", "conda-forge"]), - ("win-64", ["microsoft", "conda-forge"]), - ("osx-arm64", ["microsoft", "conda-forge"]), - ("linux-aarch64", ["microsoft", "conda-forge"]), - ("win-arm64", ["defaults", "microsoft", "conda-forge"]), + ("", "3.13", ["microsoft", "conda-forge"]), + ("win-64", "3.13", ["microsoft", "conda-forge"]), + ("osx-arm64", "3.13", ["microsoft", "conda-forge"]), + ("linux-aarch64", "3.13", ["microsoft", "conda-forge"]), + ("win-arm64", "3.12", ["defaults", "microsoft", "conda-forge"]), + ("win-arm64", "3.13", ["defaults", "microsoft", "conda-forge"]), + ("win-arm64", "3.14", ["defaults", "microsoft", "conda-forge"]), + ("win-arm64", "3.15", ["microsoft", "conda-forge"]), ], ) -def test_conda_build_uses_only_explicit_channels( - target_subdir, expected_channels, tmp_path, monkeypatch +def test_conda_build_and_verify_use_only_explicit_channels( + target_subdir, python_version, expected_channels, tmp_path, monkeypatch ): calls = [] croot = tmp_path / "croot" @@ -1160,7 +1177,7 @@ def _capture_run(command, **_kwargs): "conda", "conda_builder", str(tmp_path / "recipe"), - ["3.13"], + [python_version], str(tmp_path / "conda-bld"), target_subdir, {}, @@ -1174,6 +1191,52 @@ def _capture_run(command, **_kwargs): assert [command[index + 1] for index, arg in enumerate(command) if arg == "-c"] == ( expected_channels ) + monkeypatch.setattr(environment, "run_ok", lambda *_args, **_kwargs: 0) + monkeypatch.setattr(environment, "run_capture", lambda *_args, **_kwargs: (0, "")) + verify._verify_impl( + "conda", + "local-channel", + str(tmp_path), + [python_version], + "1.2.3", + target_subdir, + True, + {}, + ) + creates = [cmd for cmd in calls if cmd[1:2] == ["create"]] + assert len(creates) == (2 if target_subdir == "win-arm64" else 1) + verify_channels = ( + ["microsoft", "defaults"] + if target_subdir == "win-arm64" and python_version != "3.15" + else ["microsoft", "conda-forge"] + ) + for command in creates: + assert [command[i + 1] for i, arg in enumerate(command) if arg == "-c"] == [ + "local-channel", + *verify_channels, + ] + + +@pytest.mark.parametrize( + "target,pyvers,spec", + [ + ("win-64", ["3.10", "3.14"], "conda-build<26"), + ("win-arm64", ["3.12", "3.14"], "conda-build<26"), + ("win-64", ["3.10", "3.15"], "conda-build=26.9.1"), + ("win-arm64", ["3.12", "3.15"], "conda-build=26.9.1"), + ("linux-64", ["3.15"], "conda-build<26"), + ("linux-aarch64", ["3.15"], "conda-build<26"), + ("osx-64", ["3.15"], "conda-build<26"), + ("osx-arm64", ["3.15"], "conda-build<26"), + ], +) +def test_builder_upgrade_is_limited_to_windows_315(target, pyvers, spec, monkeypatch): + calls = [] + monkeypatch.setattr(environment, "run_ok", lambda *_args, **_kwargs: 0) + monkeypatch.setattr(environment, "run", lambda cmd, **_kwargs: calls.append(cmd)) + assert environment.create_builder_env("conda", target, pyvers) == "conda_builder" + assert len(calls) == 1 + assert [arg for arg in calls[0] if arg.startswith("conda-build")] == [spec] @pytest.mark.parametrize("state", ["native", "pure-python", "foreign"]) diff --git a/tests/test_036_conda_provenance.py b/tests/test_036_conda_provenance.py index 459fce977..445b13cbc 100644 --- a/tests/test_036_conda_provenance.py +++ b/tests/test_036_conda_provenance.py @@ -58,7 +58,7 @@ def release_sources(): } -def _component_files(name, version, rs=None): +def _component_files(name, version, rs=None, python_tag="cp313"): prefix = f"{name.replace('-', '_')}-{version}.dist-info/" metadata = f"Metadata-Version: 2.4\nName: {name}\nVersion: {version}\n" files = {} @@ -71,14 +71,23 @@ def _component_files(name, version, rs=None): files.update( { "mssql_py_core/__init__.py": b"", - "mssql_py_core/mssql_py_core.cp313-win_amd64.pyd": b"native fixture", + f"mssql_py_core/mssql_py_core.{python_tag}-win_amd64.pyd": b"native fixture", } ) if name == "mssql-python-rs": files.update( {path: b"private native fixture" for path in contracts.rs_private_libraries("win-64")} ) - tag = "py3-none-win_amd64" if name == "mssql-python-odbc" else "cp313-cp313-win_amd64" + tag = ( + "py3-none-win_amd64" + if name == "mssql-python-odbc" + else f"{python_tag}-{python_tag}-win_amd64" + ) + if name == "mssql-python-rs" and python_tag == "cp315": + tag = "cp310-abi3-win_amd64" + files["mssql_py_core/mssql_py_core.pyd"] = files.pop( + f"mssql_py_core/mssql_py_core.{python_tag}-win_amd64.pyd" + ) files[prefix + "METADATA"] = metadata.encode() files[prefix + "WHEEL"] = f"Wheel-Version: 1.0\nTag: {tag}\n".encode() files[prefix + "RECORD"] = "".join( @@ -339,6 +348,7 @@ def download(argv, *, check): "missing-record", "missing-wheel", "wrong-root", + "other-python-root", "duplicate-metadata", "unowned-core", "unrecorded-core", @@ -378,19 +388,23 @@ def download(argv, *, check): ), ], ) -def test_source_bound_release_components(tmp_path, monkeypatch, capsys, problem): +@pytest.mark.parametrize("python_version", ["3.13", "3.15"]) +def test_source_bound_release_components(tmp_path, monkeypatch, capsys, problem, python_version): + python_tag = "cp" + python_version.replace(".", "") + minor = int(python_version.split(".")[1]) with_rs = problem not in {"legacy", "new-source-legacy"} and not problem.startswith("legacy-") versions = {"mssql-python": "1.15.0", "mssql-python-odbc": "18.6.2.1"} if with_rs: versions["mssql-python-rs"] = "0.2.0" files = {} for name, version in versions.items(): - files.update(_component_files(name, version, "0.2.0" if with_rs else None)) + files.update(_component_files(name, version, "0.2.0" if with_rs else None, python_tag)) if problem == "new-source-legacy": versions["mssql-python-rs"] = "0.2.0" rs_prefix = "mssql_python_rs-0.2.0.dist-info/" binding_key = "mssql_python-1.15.0.dist-info/METADATA" - selected = "mssql_python_rs-0.2.0-cp313-cp313-win_amd64.whl" + rs_tag = "cp310-abi3" if python_tag == "cp315" else "cp313-cp313" + selected = f"mssql_python_rs-0.2.0-{rs_tag}-win_amd64.whl" receipt = { "distribution_version": "0.2.0", "transport_version": "0.2.0-dev.transport", @@ -464,21 +478,27 @@ def test_source_bound_release_components(tmp_path, monkeypatch, capsys, problem) elif problem == "wrong-wheel-tags": files[rs_prefix + "WHEEL"] = b"Tag: cp310-cp310-win_amd64\n" elif problem == "wrong-wheel-target": - wrong = selected.replace("313", "312") + wrong = "mssql_python_rs-0.2.0-cp316-cp316-win_amd64.whl" receipt["wheel_sha256"] = {wrong: "b" * 64} files[rs_prefix + "conda-wheel-source.txt"] = (wrong + "\n").encode() - files[rs_prefix + "WHEEL"] = b"Tag: cp312-cp312-win_amd64\n" + files[rs_prefix + "WHEEL"] = b"Tag: cp316-cp316-win_amd64\n" root = tmp_path / "conda" - package = root / "win-64" / "mssql-python-1.15.0-py313_0.tar.bz2" + package = root / "win-64" / f"mssql-python-1.15.0-py{python_tag[2:]}_0.tar.bz2" package.parent.mkdir(parents=True) index = { "name": "mssql-python", "version": "1.15.0", "subdir": "win-64", - "build": "py313_0", - "depends": ["python >=3.13,<3.14.0a0", "python_abi 3.13.* *_cp313"], + "build": f"py{python_tag[2:]}_0", + "depends": [ + f"python >={python_version},<3.{minor + 1}.0a0", + f"python_abi {python_version}.* *_{python_tag}", + ], } - prefix = "wrong/site-packages/" if problem == "wrong-root" else "Lib/site-packages/" + canonical_root = "lib/python/site-packages/" if python_tag == "cp315" else "Lib/site-packages/" + prefix = "wrong/site-packages/" if problem == "wrong-root" else canonical_root + if problem == "other-python-root": + prefix = "Lib/site-packages/" if python_tag == "cp315" else "lib/python/site-packages/" members = [ ("info/index.json", json.dumps(index).encode()), *((prefix + key, value) for key, value in files.items()), @@ -486,7 +506,7 @@ def test_source_bound_release_components(tmp_path, monkeypatch, capsys, problem) if problem == "duplicate-payload": members.append(members[-1]) elif problem == "core-root-alias": - members.append(("LIB/SITE-PACKAGES/mssql_py_core/unrecorded.py", b"")) + members.append((canonical_root.upper() + "mssql_py_core/unrecorded.py", b"")) with tarfile.open(package, "w:bz2") as contents: for name, data in members: entry = tarfile.TarInfo(name) @@ -515,7 +535,7 @@ def test_source_bound_release_components(tmp_path, monkeypatch, capsys, problem) "--allowed-subdirs", "win-64", "--pythons", - "3.13", + python_version, "--mssql-python-version", "0.0.0" if problem == "mismatched-binding-override" else "1.15.0", "--release-versions", @@ -526,6 +546,83 @@ def test_source_bound_release_components(tmp_path, monkeypatch, capsys, problem) ), capsys.readouterr() +@pytest.mark.parametrize( + "profile", + [ + "source", + "smoke", + "smoke-rs", + "smoke-unavailable", + "smoke-bad-dependency", + "smoke-wrong-target", + ], +) +def test_published_smoke_is_explicit_and_independent_of_unpublished_source( + tmp_path, monkeypatch, capsys, profile +): + source = {"mssql-python": "1.16.0", "mssql-python-odbc": "18.6.2.2"} + published = {"mssql-python": "1.15.0", "mssql-python-odbc": "18.6.2.1"} + if profile == "smoke-rs": + published["mssql-python-rs"] = "0.3.0" + reads, requests, selected = [], [], [] + monkeypatch.setattr(inputs, "read_release_versions", lambda read: reads.append(True) or source) + + def pypi(url, *, timeout): + assert url == "https://pypi.org/pypi/mssql-python/json" and timeout == 60 + requests.append(url) + if profile == "smoke-unavailable": + raise HTTPError(url, 404, "not published", {}, None) + requirements = [ + f"{name}=={version}" for name, version in published.items() if name != "mssql-python" + ] + if profile == "smoke-bad-dependency": + requirements = ["mssql-python-odbc>=18.6.2.1"] + return io.BytesIO( + json.dumps( + { + "info": { + "name": "mssql-python", + "version": published["mssql-python"], + "requires_dist": requirements, + } + } + ).encode() + ) + + def fetch(versions, *args): + selected.append(versions) + return versions + + monkeypatch.setattr(inputs, "urlopen", pypi) + monkeypatch.setattr(inputs, "fetch_wheels", fetch) + output = tmp_path / "outputs" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + result = cli.main( + [ + "fetch-wheels", + "--wheel-dir", + str(tmp_path), + "--requirements-file", + str(tmp_path / "pins"), + "--python-tag", + "cp315" if profile == "smoke-wrong-target" else "cp311", + "--conda-subdir", + "linux-64", + *([] if profile == "source" else ["--published-smoke"]), + ] + ) + assert reads == ([True] if profile == "source" else []) + assert len(requests) == (0 if profile in {"source", "smoke-wrong-target"} else 1) + failed = profile in {"smoke-unavailable", "smoke-bad-dependency", "smoke-wrong-target"} + assert selected == ([] if failed else [source if profile == "source" else published]) + assert result == int(failed) + assert output.exists() == (not failed) + if failed: + assert "ERROR:" in capsys.readouterr().err + else: + assert output.read_text() == f"rsRequired={str(profile == 'smoke-rs').lower()}\n" + + @pytest.mark.parametrize("with_rs", [False, True]) def test_public_cli_reports_qualified_profile_without_importing_runtime( tmp_path, monkeypatch, capsys, with_rs