This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch pin-constraints-providers-to-pypi-versions in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 2dceda458fcbfcae2ad52e71493920b485c33b60 Author: Jarek Potiuk <[email protected]> AuthorDate: Sat Aug 8 16:00:46 2026 +0800 Pin providers in constraints to the versions published in PyPI Constraints cut for a release candidate had to land on the wave being voted on, whose providers exist in PyPI only as rc versions. Asking uv for a pre-release strategy left it free to answer with any version satisfying the lower bounds, so the pins were neither the candidate nor the last release, and what a candidate shipped depended on how the resolution happened to go. Retrieving the versions from PyPI and naming them leaves nothing to resolve: the constraints pin what is actually published. A pre-release is only ever considered when the run allows it, and even then it has to sort above every final release, so a provider without a candidate in the wave keeps its release and a released constraints file can never carry an rc pin. A candidate is exempt from the downgrade check - it sorts below the releases the constraints branch already carries, so the comparison says nothing there. Re-running the workflow for the same candidate now replaces that candidate's branch and tag instead of pushing onto them, so the two always describe the run that produced them and a wave can be re-cut. --- .github/workflows/release-constraints.yml | 30 +++- .../airflow_breeze/utils/release_constraints.py | 11 +- scripts/in_container/run_generate_constraints.py | 171 ++++++++++++++------- .../in_container/test_run_generate_constraints.py | 164 +++++++++++++++++--- 4 files changed, 295 insertions(+), 81 deletions(-) diff --git a/.github/workflows/release-constraints.yml b/.github/workflows/release-constraints.yml index 94506af6f01..be898f91fce 100644 --- a/.github/workflows/release-constraints.yml +++ b/.github/workflows/release-constraints.yml @@ -20,10 +20,11 @@ # # The stage is derived from the version, so the two cannot be mismatched by hand: # -# * a candidate (`3.1.3rc1`) resolves with pre-releases allowed - the providers of the wave -# being voted on exist on PyPI only as rc versions - and lands on a branch of its own, so a -# candidate never moves the branch every other build reads; -# * a final (`3.1.3`) resolves without them, against the providers now published as finals, and +# * a candidate (`3.1.3rc1`) pins the providers at the versions PyPI holds - the wave being +# voted on exists there only as rc versions - and lands on a branch of its own, so a candidate +# never moves the branch every other build reads. Re-running it for the same candidate +# replaces that branch and its tag, so the two always describe the run that produced them; +# * a final (`3.1.3`) pins the providers at their released versions, ignoring any candidate, and # commits onto `constraints-X-Y` itself, which is what makes the released constraints the # baseline everything downstream reads. # @@ -203,6 +204,7 @@ jobs: VERSION: ${{ inputs.version }} CONSTRAINTS_BRANCH: ${{ needs.build-info.outputs.constraints-branch }} TARGET_BRANCH: ${{ needs.build-info.outputs.target-branch }} + ALLOW_PRE_RELEASES: ${{ needs.build-info.outputs.allow-pre-releases }} steps: - name: "Cleanup repo" shell: bash @@ -226,6 +228,26 @@ jobs: with: pattern: constraints-* path: ./files + # A candidate's branch and tag belong to that candidate alone, so re-running for the same + # rc replaces them rather than adding to them: the branch would otherwise already hold the + # previous run's constraints (making the push a non-fast-forward) and the tag already exist. + # A final never gets this - it commits onto the shared constraints-X-Y branch, whose history + # every other build reads. + - name: "Delete the previous ${{ needs.build-info.outputs.target-branch }} branch and tag" + if: needs.build-info.outputs.allow-pre-releases == 'true' + working-directory: "constraints" + shell: bash + run: | + if git ls-remote --exit-code origin "refs/heads/${TARGET_BRANCH}" > /dev/null; then + echo "Deleting the existing '${TARGET_BRANCH}' branch." + git push origin --delete "refs/heads/${TARGET_BRANCH}" + fi + if git ls-remote --exit-code origin "refs/tags/constraints-${VERSION}" > /dev/null; then + echo "Deleting the existing 'constraints-${VERSION}' tag." + git push origin --delete "refs/tags/constraints-${VERSION}" + fi + git tag --delete "constraints-${VERSION}" > /dev/null 2>&1 || true + git branch --delete --force "${TARGET_BRANCH}" > /dev/null 2>&1 || true - name: "Switch to ${{ needs.build-info.outputs.target-branch }}" working-directory: "constraints" shell: bash diff --git a/dev/breeze/src/airflow_breeze/utils/release_constraints.py b/dev/breeze/src/airflow_breeze/utils/release_constraints.py index 466ab689120..13ad8a79d9e 100644 --- a/dev/breeze/src/airflow_breeze/utils/release_constraints.py +++ b/dev/breeze/src/airflow_breeze/utils/release_constraints.py @@ -39,11 +39,12 @@ APACHE_AIRFLOW_REPO = "apache/airflow" def publish_constraints(*, version: str, ref: str, workflow_branch: str = "main") -> None: """Resolve, publish and tag the constraints belonging to ``version``. - ``version`` alone decides what happens: a candidate (``3.1.3rc1``) resolves with pre-releases - allowed - the providers of the wave being voted on are on PyPI only as rc versions - and lands - on a branch of its own, leaving the branch every other build reads where it was. A final - (``3.1.3``) resolves without them and commits onto ``constraints-X-Y``, which is what makes the - released constraints the baseline everything downstream reads. + ``version`` alone decides what happens: a candidate (``3.1.3rc1``) pins the providers at the + versions PyPI holds - the wave being voted on is there only as rc versions - and lands on a + branch of its own, leaving the branch every other build reads where it was. Re-running it for + the same candidate replaces that branch and its tag, so a wave can be re-cut. A final + (``3.1.3``) resolves against the published releases and commits onto ``constraints-X-Y``, which + is what makes the released constraints the baseline everything downstream reads. """ stage = "candidate" if "rc" in version else "final" if not confirm_action( diff --git a/scripts/in_container/run_generate_constraints.py b/scripts/in_container/run_generate_constraints.py index c1cca95e2c2..6bd75777253 100755 --- a/scripts/in_container/run_generate_constraints.py +++ b/scripts/in_container/run_generate_constraints.py @@ -21,15 +21,19 @@ import ast import json import os import sys +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime from functools import cached_property +from itertools import repeat from pathlib import Path from typing import TextIO import requests from click import Choice from in_container_utils import AIRFLOW_DIST_PATH, AIRFLOW_ROOT_PATH, click, console, run_command +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version try: import tomllib @@ -39,6 +43,8 @@ except ImportError: DEFAULT_BRANCH = os.environ.get("DEFAULT_BRANCH", "main") PYTHON_VERSION = os.environ.get("PYTHON_MAJOR_MINOR_VERSION", "3.10") GENERATED_PROVIDER_DEPENDENCIES_FILE = AIRFLOW_ROOT_PATH / "generated" / "provider_dependencies.json" +PYPI_JSON_API_URL = "https://pypi.org/pypi/{distribution}/json" +PYPI_LOOKUP_PARALLELISM = 8 def _read_version_from_pyproject(pyproject_path: Path) -> str: @@ -380,11 +386,19 @@ def check_providers_not_downgraded(config_params: ConfigParams) -> None: Fail generation if any released provider is downgraded compared to the latest constraints. Released provider versions only ever move forward on PyPI, so a lower version in the freshly - generated constraints signals a resolution problem (a broken dependency forcing an old provider - back in) rather than an intended change. We stop here so it is caught instead of being published. - """ - from packaging.version import InvalidVersion, Version + generated constraints means the version we had pinned is no longer installable - yanked, or + left without a file for the Python being resolved. We stop here so it is caught instead of + being published. + A release candidate is exempt: it pins the wave being voted on, which sorts below the released + versions a constraints branch may already carry, so the comparison says nothing there. + """ + if config_params.allow_pre_releases: + console.print( + "[yellow]Pre-releases are allowed - skipping the provider downgrade check, a candidate " + "sorts below the releases the constraints branch carries." + ) + return if not config_params.latest_constraints_file.exists(): console.print("[yellow]No previous constraints file downloaded - skipping provider downgrade check.") return @@ -408,10 +422,10 @@ def check_providers_not_downgraded(config_params: ConfigParams) -> None: for provider, latest_version, current_version in sorted(downgraded): console.print(f"[red] * {provider}: {latest_version} -> {current_version}") console.print( - "[yellow]Released providers should never be downgraded. This usually means a broken " - "dependency version forced an older provider back in during resolution. Investigate the " - "diff above and, if needed, add an exclusion in the " - f"`additional_constraints_for_highest_resolution` list in [/] {__file__}" + "[yellow]Released providers should never be downgraded. The providers are pinned at the " + "newest version PyPI serves for this Python, so a lower one means the version we had is " + "gone - yanked, or no longer offering a file this Python can install. Investigate the " + "diff above before publishing.[/]" ) write_provider_downgrade_slack_message(config_params, downgraded) sys.exit(1) @@ -459,16 +473,82 @@ def get_all_active_provider_distributions(python_version: str | None = None) -> ] -def build_provider_pre_release_requirements(python_version: str) -> list[str]: - """Requirements that let only the providers resolve to a pre-release. - - uv considers a pre-release for a package only when some requirement for it mentions one, so a - pre-release lower bound on each provider confines the allowance to them. `--pre` would apply to - the whole resolution and could put a pre-release of any third-party dependency into the - constraints a release ships. +def is_file_installable(pypi_file: dict, target_python: Version) -> bool: + """Whether a file PyPI lists for a release can be installed on ``target_python``.""" + if pypi_file.get("yanked"): + return False + requires_python = pypi_file.get("requires_python") + if not requires_python: + return True + try: + return target_python in SpecifierSet(requires_python) + except InvalidSpecifier: + return True + + +def find_newest_version_in_pypi( + distribution: str, python_version: str, allow_pre_releases: bool +) -> str | None: + """Return the newest version of ``distribution`` PyPI can install for ``python_version``. + + A pre-release is only ever considered when ``allow_pre_releases`` is set, and even then it wins + only by sorting above every final release - so a provider with a candidate in the wave being + voted on resolves to that candidate while one without keeps its last release. Versions PyPI can + no longer serve for the Python being resolved - fully yanked, or excluded by ``requires_python`` + - are passed over, because pinning one leaves the resolution nothing to install. ``None`` + (nothing installable at all, e.g. a provider whose first release is still in this wave) leaves + the distribution unpinned rather than pinned to a version that cannot be had. """ + response = requests.get(PYPI_JSON_API_URL.format(distribution=distribution), timeout=60) + if response.status_code == 404: + console.print(f"[yellow]{distribution} is not published in PyPI - leaving it unpinned.") + return None + response.raise_for_status() + target_python = Version(python_version) + newest_version: Version | None = None + for version, files in response.json().get("releases", {}).items(): + try: + parsed_version = Version(version) + except InvalidVersion: + continue + if parsed_version.is_prerelease and not allow_pre_releases: + continue + if not any(is_file_installable(pypi_file, target_python) for pypi_file in files): + continue + if newest_version is None or parsed_version > newest_version: + newest_version = parsed_version + if newest_version is None: + console.print(f"[yellow]{distribution} has no installable version in PyPI - leaving it unpinned.") + return None + return str(newest_version) + + +def build_pinned_provider_requirements(python_version: str, allow_pre_releases: bool) -> list[str]: + """Exact pins for every active provider, at the newest version PyPI holds for it. + + Naming the versions retrieved from PyPI leaves the providers nothing to resolve: the constraints + pin what is actually published rather than whatever the resolver settles on. It is what makes a + release candidate land on the wave being voted on, whose providers are published only as rc + versions - handing uv a pre-release strategy instead left it free to answer with any version + satisfying the lower bounds. It also keeps pre-releases confined to the providers: no + third-party dependency can answer with one, because no requirement here mentions a pre-release + of anything else. + """ + distributions = get_all_active_provider_distributions(python_version) + console.print(f"[bright_blue]Retrieving the newest PyPI version of {len(distributions)} providers.") + with ThreadPoolExecutor(max_workers=PYPI_LOOKUP_PARALLELISM) as executor: + newest_versions = list( + executor.map( + find_newest_version_in_pypi, + distributions, + repeat(python_version), + repeat(allow_pre_releases), + ) + ) return [ - f"{distribution}>=0.0.0rc0" for distribution in get_all_active_provider_distributions(python_version) + f"{distribution}=={version}" + for distribution, version in zip(distributions, newest_versions) + if version is not None ] @@ -532,17 +612,13 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None: # that the resolver will not downgrade the provider. # * opentelemetry-exporter-prometheus>=0.47b0 — this package only ever publishes beta versions # (airflow-core requires ``>=0.47b0`` and released constraints already pin a beta, e.g. - # ``==0.65b0``). For a release candidate the resolution runs with ``--prerelease explicit``, - # which permits a pre-release only for a package some requirement marks as such and drops the - # if-necessary fallback; without this entry the package cannot resolve and generation fails - # with "No solution found". Keeping it here (rather than the provider pre-release list) marks - # it as an always-allowed pre-release across every resolution, matching how it already ships. + # ``==0.65b0``). Marking it as a pre-release here rather than relying on uv's if-necessary + # fallback keeps the choice deliberate and identical across every resolution. # * opentelemetry-semantic-conventions>=0.48b0 — same story: a beta-only package, pulled in as a - # hard dependency of opentelemetry-sdk (via opentelemetry-exporter-otlp and shared/observability), - # so the ``--prerelease explicit`` resolution needs the same explicit mark. The floor only marks - # it as a pre-release and must stay at the version paired with our ``opentelemetry-*>=1.27.0`` - # floor — opentelemetry-sdk exact-pins the semantic conventions version it ships with, so a - # higher floor here would conflict with any sdk older than that pairing. + # hard dependency of opentelemetry-sdk (via opentelemetry-exporter-otlp and shared/observability). + # The floor only marks it as a pre-release and must stay at the version paired with our + # ``opentelemetry-*>=1.27.0`` floor — opentelemetry-sdk exact-pins the semantic conventions + # version it ships with, so a higher floor here would conflict with any sdk older than that pairing. # # These two are the only pre-releases in the constraints we tag, and removing the need for the # exception is tracked at https://github.com/apache/airflow/issues/71176 @@ -554,27 +630,16 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None: "opentelemetry-semantic-conventions>=0.48b0", ] - # Constraints cut for a release candidate have to pin the candidates themselves - the providers - # for that wave exist on PyPI only as rcN versions, and uv will not resolve to a pre-release - # unless asked. The final release regenerates these without it, so a released constraints file - # can never carry an rc pin. - # - # Scoped to the providers rather than passing `--pre`, which applies to the whole resolution - # and would let any dependency answer with a pre-release - putting, say, a beta of a - # third-party library into the constraints a release ships. uv has no per-package pre-release - # flag, so the scoping is expressed the way it does support: `explicit` permits a pre-release - # only for a package some requirement marks as such, and the rc lower bound below is that mark. - # `explicit` rather than the default `if-necessary-or-explicit` also drops the fallback that - # would otherwise let an unmarked package resolve to a pre-release when no final satisfies. - pre_release_requirements: list[str] = [] - pre_release_strategy: list[str] = [] - if config_params.allow_pre_releases: - pre_release_requirements = build_provider_pre_release_requirements(config_params.python) - pre_release_strategy = ["--prerelease", "explicit"] - console.print( - f"[bright_blue]Allowing pre-releases for {len(pre_release_requirements)} provider " - "distributions - no other package can resolve to one." - ) + # Every run pins the providers at the versions PyPI holds when it is generated. Only a run for + # a release candidate considers the rc versions of the wave being voted on; a final regenerates + # these against the published releases, so a released constraints file can never carry an rc pin. + pinned_provider_requirements = build_pinned_provider_requirements( + config_params.python, config_params.allow_pre_releases + ) + console.print( + f"[bright_blue]Pinning {len(pinned_provider_requirements)} provider distributions to the " + f"{'newest' if config_params.allow_pre_releases else 'newest final'} versions in PyPI." + ) result = run_command( cmd=[ @@ -589,8 +654,7 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None: f"apache-airflow-task-sdk=={AIRFLOW_TASK_SDK_VERSION}", "./airflow-ctl", *additional_constraints_for_highest_resolution, - *pre_release_requirements, - *pre_release_strategy, + *pinned_provider_requirements, "--reinstall", # We need to pull the provider distributions from PyPI or dist, not the local ones "--resolution", "highest", @@ -603,7 +667,9 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None: if result.returncode != 0: console.print( "[red]Failed to install airflow with PyPI providers with highest resolution.[/]\n" - "[yellow]Please check the output above for details. One of they ways how to resolve it, in " + "[yellow]Please check the output above for details. The providers are pinned at the newest " + "versions PyPI serves, so two of them requiring incompatible dependencies fails here rather " + "than quietly settling on an older provider. One of they ways how to resolve it, in " "case it is caused by a specific broken dependency version, is to exclude it above in the " f"`additional_constraints_for_highest_resolution` list in [/] {__file__}" ) @@ -693,8 +759,9 @@ ALLOWED_CONSTRAINTS_MODES = ["constraints", "constraints-source-providers", "con "--allow-pre-releases", is_flag=True, default=False, - help="Allow pre-release versions of Airflow and providers to be pinned. Used when constraints " - "are generated for a release candidate, whose providers are only on PyPI as rc versions.", + help="Let the provider pins use pre-release versions when those are newer than any final " + "release. Used when constraints are generated for a release candidate, whose providers are " + "only on PyPI as rc versions.", envvar="ALLOW_PRE_RELEASES", ) def generate_constraints( diff --git a/scripts/tests/in_container/test_run_generate_constraints.py b/scripts/tests/in_container/test_run_generate_constraints.py index 88af9e84102..99c7f4c5ee9 100644 --- a/scripts/tests/in_container/test_run_generate_constraints.py +++ b/scripts/tests/in_container/test_run_generate_constraints.py @@ -24,15 +24,38 @@ import pytest import run_generate_constraints as m -def _config(latest: Path, current: Path, constraints_dir: Path | None = None) -> SimpleNamespace: +def _config( + latest: Path, + current: Path, + constraints_dir: Path | None = None, + allow_pre_releases: bool = False, +) -> SimpleNamespace: return SimpleNamespace( latest_constraints_file=latest, current_constraints_file=current, constraints_dir=constraints_dir if constraints_dir is not None else latest.parent, python="3.10", + allow_pre_releases=allow_pre_releases, ) +def _file(*, yanked: bool = False, requires_python: str | None = ">=3.10") -> dict: + return {"yanked": yanked, "requires_python": requires_python} + + +class _FakeResponse: + def __init__(self, payload: dict, status_code: int = 200): + self._payload = payload + self.status_code = status_code + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise AssertionError(f"Unexpected status {self.status_code}") + + class TestReadProviderVersionsFromConstraints: def test_extracts_only_providers_and_strips_markers(self, tmp_path): constraints = tmp_path / "constraints.txt" @@ -82,6 +105,16 @@ class TestCheckProvidersNotDowngraded: payload = json.loads((tmp_path / "provider-downgrade-slack-message.json").read_text()) assert payload["channel"] == "some-other-channel" + def test_skips_the_check_for_a_release_candidate(self, tmp_path): + latest = tmp_path / "latest.txt" + current = tmp_path / "current.txt" + latest.write_text("apache-airflow-providers-amazon==8.0.0\n") + current.write_text("apache-airflow-providers-amazon==7.5.0\n") + + m.check_providers_not_downgraded(_config(latest, current, allow_pre_releases=True)) + + assert not (tmp_path / "provider-downgrade-slack-message.json").exists() + def test_passes_when_no_provider_is_downgraded(self, tmp_path): latest = tmp_path / "latest.txt" current = tmp_path / "current.txt" @@ -111,40 +144,131 @@ class TestCheckProvidersNotDowngraded: m.check_providers_not_downgraded(_config(latest, current)) -class TestBuildProviderPreReleaseRequirements: - """Pre-releases are allowed for the providers only, never for the whole resolution.""" +class TestFindNewestVersionInPypi: + @pytest.mark.parametrize( + "releases, allow_pre_releases, expected", + [ + pytest.param( + {"1.0.0": [_file()], "1.1.0": [_file()]}, + False, + "1.1.0", + id="newest-final-release", + ), + pytest.param( + {"1.1.0": [_file()], "1.2.0rc1": [_file()]}, + True, + "1.2.0rc1", + id="candidate-newer-than-the-release-wins-when-allowed", + ), + pytest.param( + {"1.1.0": [_file()], "1.2.0rc1": [_file()]}, + False, + "1.1.0", + id="candidate-is-ignored-when-not-allowed", + ), + pytest.param( + {"1.1.0rc1": [_file()], "1.1.0": [_file()]}, + True, + "1.1.0", + id="superseded-candidate-loses-to-its-release", + ), + pytest.param( + {"1.0.0": [_file()], "1.1.0": [_file(yanked=True)]}, + False, + "1.0.0", + id="yanked-version-is-passed-over", + ), + pytest.param( + {"1.0.0": [_file()], "1.1.0": []}, + False, + "1.0.0", + id="version-with-no-files-is-passed-over", + ), + pytest.param( + {"1.0.0": [_file()], "1.1.0": [_file(requires_python=">=3.12")]}, + False, + "1.0.0", + id="version-excluded-by-requires-python-is-passed-over", + ), + pytest.param( + {"1.0.0": [_file()], "not-a-version": [_file()]}, + False, + "1.0.0", + id="unparseable-version-is-passed-over", + ), + pytest.param( + {"1.0.0": [_file(requires_python=None)]}, + False, + "1.0.0", + id="file-without-requires-python-is-installable", + ), + pytest.param( + {"1.0.0": [_file(requires_python="not-a-specifier")]}, + False, + "1.0.0", + id="file-with-broken-requires-python-is-not-excluded", + ), + pytest.param({"1.0.0": [_file(yanked=True)]}, False, None, id="nothing-installable-is-unpinned"), + pytest.param({"1.0.0rc1": [_file()]}, False, None, id="only-a-candidate-is-unpinned"), + ], + ) + def test_picks_the_newest_installable_version(self, monkeypatch, releases, allow_pre_releases, expected): + monkeypatch.setattr(m.requests, "get", lambda url, timeout: _FakeResponse({"releases": releases})) + + newest = m.find_newest_version_in_pypi("apache-airflow-providers-amazon", "3.10", allow_pre_releases) + + assert newest == expected + + def test_unpublished_distribution_is_unpinned(self, monkeypatch): + monkeypatch.setattr(m.requests, "get", lambda url, timeout: _FakeResponse({}, status_code=404)) + + assert m.find_newest_version_in_pypi("apache-airflow-providers-brand-new", "3.10", True) is None + - def test_every_requirement_names_a_provider_and_permits_a_pre_release(self, monkeypatch): +class TestBuildPinnedProviderRequirements: + """Providers are pinned at what PyPI holds rather than left for the resolution to pick.""" + + def test_every_provider_is_pinned_to_its_newest_pypi_version(self, monkeypatch): monkeypatch.setattr( m, "get_all_active_provider_distributions", lambda python_version=None: [ "apache-airflow-providers-amazon", "apache-airflow-providers-cncf-kubernetes", + "apache-airflow-providers-brand-new", ], ) + newest = { + "apache-airflow-providers-amazon": "9.0.0rc1", + "apache-airflow-providers-cncf-kubernetes": "10.20.0", + "apache-airflow-providers-brand-new": None, + } + monkeypatch.setattr( + m, "find_newest_version_in_pypi", lambda dist, python_version, allow_pre_releases: newest[dist] + ) - requirements = m.build_provider_pre_release_requirements("3.10") - - assert requirements == [ - "apache-airflow-providers-amazon>=0.0.0rc0", - "apache-airflow-providers-cncf-kubernetes>=0.0.0rc0", + assert m.build_pinned_provider_requirements("3.10", True) == [ + "apache-airflow-providers-amazon==9.0.0rc1", + "apache-airflow-providers-cncf-kubernetes==10.20.0", ] - # The rc lower bound is what marks the package as explicit to uv; without it the - # requirement would not permit a pre-release at all. - assert all(requirement.endswith(">=0.0.0rc0") for requirement in requirements) - assert all(requirement.startswith("apache-airflow-providers-") for requirement in requirements) - def test_the_python_version_is_passed_through(self, monkeypatch): - """Providers excluded on a Python version must not be named for it.""" - seen: list[str | None] = [] + def test_the_python_version_and_the_pre_release_choice_reach_the_lookup(self, monkeypatch): + """Providers excluded on a Python version must not be named, nor pinned to a version it cannot install.""" + seen_for_distributions: list[str | None] = [] + seen_for_lookup: list[tuple[str, bool]] = [] def fake_distributions(python_version=None): - seen.append(python_version) - return [] + seen_for_distributions.append(python_version) + return ["apache-airflow-providers-amazon"] + + def fake_lookup(distribution, python_version, allow_pre_releases): + seen_for_lookup.append((python_version, allow_pre_releases)) + return "9.0.0" monkeypatch.setattr(m, "get_all_active_provider_distributions", fake_distributions) + monkeypatch.setattr(m, "find_newest_version_in_pypi", fake_lookup) - m.build_provider_pre_release_requirements("3.14") + m.build_pinned_provider_requirements("3.14", False) - assert seen == ["3.14"] + assert seen_for_distributions == ["3.14"] + assert seen_for_lookup == [("3.14", False)]
