2121import json
2222import os
2323import sys
24+ from concurrent .futures import ThreadPoolExecutor
2425from dataclasses import dataclass
2526from datetime import datetime
2627from functools import cached_property
28+ from itertools import repeat
2729from pathlib import Path
2830from typing import TextIO
2931
3032import requests
3133from click import Choice
3234from in_container_utils import AIRFLOW_DIST_PATH , AIRFLOW_ROOT_PATH , click , console , run_command
35+ from packaging .specifiers import InvalidSpecifier , SpecifierSet
36+ from packaging .version import InvalidVersion , Version
3337
3438try :
3539 import tomllib
3943DEFAULT_BRANCH = os .environ .get ("DEFAULT_BRANCH" , "main" )
4044PYTHON_VERSION = os .environ .get ("PYTHON_MAJOR_MINOR_VERSION" , "3.10" )
4145GENERATED_PROVIDER_DEPENDENCIES_FILE = AIRFLOW_ROOT_PATH / "generated" / "provider_dependencies.json"
46+ PYPI_JSON_API_URL = "https://pypi.org/pypi/{distribution}/json"
47+ PYPI_LOOKUP_PARALLELISM = 8
4248
4349
4450def _read_version_from_pyproject (pyproject_path : Path ) -> str :
@@ -380,11 +386,19 @@ def check_providers_not_downgraded(config_params: ConfigParams) -> None:
380386 Fail generation if any released provider is downgraded compared to the latest constraints.
381387
382388 Released provider versions only ever move forward on PyPI, so a lower version in the freshly
383- generated constraints signals a resolution problem (a broken dependency forcing an old provider
384- back in) rather than an intended change. We stop here so it is caught instead of being published.
385- """
386- from packaging .version import InvalidVersion , Version
389+ generated constraints means the version we had pinned is no longer installable - yanked, or
390+ left without a file for the Python being resolved. We stop here so it is caught instead of
391+ being published.
387392
393+ A release candidate is exempt: it pins the wave being voted on, which sorts below the released
394+ versions a constraints branch may already carry, so the comparison says nothing there.
395+ """
396+ if config_params .allow_pre_releases :
397+ console .print (
398+ "[yellow]Pre-releases are allowed - skipping the provider downgrade check, a candidate "
399+ "sorts below the releases the constraints branch carries."
400+ )
401+ return
388402 if not config_params .latest_constraints_file .exists ():
389403 console .print ("[yellow]No previous constraints file downloaded - skipping provider downgrade check." )
390404 return
@@ -408,10 +422,10 @@ def check_providers_not_downgraded(config_params: ConfigParams) -> None:
408422 for provider , latest_version , current_version in sorted (downgraded ):
409423 console .print (f"[red] * { provider } : { latest_version } -> { current_version } " )
410424 console .print (
411- "[yellow]Released providers should never be downgraded. This usually means a broken "
412- "dependency version forced an older provider back in during resolution. Investigate the "
413- "diff above and, if needed, add an exclusion in the "
414- f"`additional_constraints_for_highest_resolution` list in [/] { __file__ } "
425+ "[yellow]Released providers should never be downgraded. The providers are pinned at the "
426+ "newest version PyPI serves for this Python, so a lower one means the version we had is "
427+ "gone - yanked, or no longer offering a file this Python can install. Investigate the "
428+ "diff above before publishing. [/]"
415429 )
416430 write_provider_downgrade_slack_message (config_params , downgraded )
417431 sys .exit (1 )
@@ -459,16 +473,82 @@ def get_all_active_provider_distributions(python_version: str | None = None) ->
459473 ]
460474
461475
462- def build_provider_pre_release_requirements (python_version : str ) -> list [str ]:
463- """Requirements that let only the providers resolve to a pre-release.
464-
465- uv considers a pre-release for a package only when some requirement for it mentions one, so a
466- pre-release lower bound on each provider confines the allowance to them. `--pre` would apply to
467- the whole resolution and could put a pre-release of any third-party dependency into the
468- constraints a release ships.
476+ def is_file_installable (pypi_file : dict , target_python : Version ) -> bool :
477+ """Whether a file PyPI lists for a release can be installed on ``target_python``."""
478+ if pypi_file .get ("yanked" ):
479+ return False
480+ requires_python = pypi_file .get ("requires_python" )
481+ if not requires_python :
482+ return True
483+ try :
484+ return target_python in SpecifierSet (requires_python )
485+ except InvalidSpecifier :
486+ return True
487+
488+
489+ def find_newest_version_in_pypi (
490+ distribution : str , python_version : str , allow_pre_releases : bool
491+ ) -> str | None :
492+ """Return the newest version of ``distribution`` PyPI can install for ``python_version``.
493+
494+ A pre-release is only ever considered when ``allow_pre_releases`` is set, and even then it wins
495+ only by sorting above every final release - so a provider with a candidate in the wave being
496+ voted on resolves to that candidate while one without keeps its last release. Versions PyPI can
497+ no longer serve for the Python being resolved - fully yanked, or excluded by ``requires_python``
498+ - are passed over, because pinning one leaves the resolution nothing to install. ``None``
499+ (nothing installable at all, e.g. a provider whose first release is still in this wave) leaves
500+ the distribution unpinned rather than pinned to a version that cannot be had.
469501 """
502+ response = requests .get (PYPI_JSON_API_URL .format (distribution = distribution ), timeout = 60 )
503+ if response .status_code == 404 :
504+ console .print (f"[yellow]{ distribution } is not published in PyPI - leaving it unpinned." )
505+ return None
506+ response .raise_for_status ()
507+ target_python = Version (python_version )
508+ newest_version : Version | None = None
509+ for version , files in response .json ().get ("releases" , {}).items ():
510+ try :
511+ parsed_version = Version (version )
512+ except InvalidVersion :
513+ continue
514+ if parsed_version .is_prerelease and not allow_pre_releases :
515+ continue
516+ if not any (is_file_installable (pypi_file , target_python ) for pypi_file in files ):
517+ continue
518+ if newest_version is None or parsed_version > newest_version :
519+ newest_version = parsed_version
520+ if newest_version is None :
521+ console .print (f"[yellow]{ distribution } has no installable version in PyPI - leaving it unpinned." )
522+ return None
523+ return str (newest_version )
524+
525+
526+ def build_pinned_provider_requirements (python_version : str , allow_pre_releases : bool ) -> list [str ]:
527+ """Exact pins for every active provider, at the newest version PyPI holds for it.
528+
529+ Naming the versions retrieved from PyPI leaves the providers nothing to resolve: the constraints
530+ pin what is actually published rather than whatever the resolver settles on. It is what makes a
531+ release candidate land on the wave being voted on, whose providers are published only as rc
532+ versions - handing uv a pre-release strategy instead left it free to answer with any version
533+ satisfying the lower bounds. It also keeps pre-releases confined to the providers: no
534+ third-party dependency can answer with one, because no requirement here mentions a pre-release
535+ of anything else.
536+ """
537+ distributions = get_all_active_provider_distributions (python_version )
538+ console .print (f"[bright_blue]Retrieving the newest PyPI version of { len (distributions )} providers." )
539+ with ThreadPoolExecutor (max_workers = PYPI_LOOKUP_PARALLELISM ) as executor :
540+ newest_versions = list (
541+ executor .map (
542+ find_newest_version_in_pypi ,
543+ distributions ,
544+ repeat (python_version ),
545+ repeat (allow_pre_releases ),
546+ )
547+ )
470548 return [
471- f"{ distribution } >=0.0.0rc0" for distribution in get_all_active_provider_distributions (python_version )
549+ f"{ distribution } =={ version } "
550+ for distribution , version in zip (distributions , newest_versions )
551+ if version is not None
472552 ]
473553
474554
@@ -532,17 +612,13 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
532612 # that the resolver will not downgrade the provider.
533613 # * opentelemetry-exporter-prometheus>=0.47b0 — this package only ever publishes beta versions
534614 # (airflow-core requires ``>=0.47b0`` and released constraints already pin a beta, e.g.
535- # ``==0.65b0``). For a release candidate the resolution runs with ``--prerelease explicit``,
536- # which permits a pre-release only for a package some requirement marks as such and drops the
537- # if-necessary fallback; without this entry the package cannot resolve and generation fails
538- # with "No solution found". Keeping it here (rather than the provider pre-release list) marks
539- # it as an always-allowed pre-release across every resolution, matching how it already ships.
615+ # ``==0.65b0``). Marking it as a pre-release here rather than relying on uv's if-necessary
616+ # fallback keeps the choice deliberate and identical across every resolution.
540617 # * opentelemetry-semantic-conventions>=0.48b0 — same story: a beta-only package, pulled in as a
541- # hard dependency of opentelemetry-sdk (via opentelemetry-exporter-otlp and shared/observability),
542- # so the ``--prerelease explicit`` resolution needs the same explicit mark. The floor only marks
543- # it as a pre-release and must stay at the version paired with our ``opentelemetry-*>=1.27.0``
544- # floor — opentelemetry-sdk exact-pins the semantic conventions version it ships with, so a
545- # higher floor here would conflict with any sdk older than that pairing.
618+ # hard dependency of opentelemetry-sdk (via opentelemetry-exporter-otlp and shared/observability).
619+ # The floor only marks it as a pre-release and must stay at the version paired with our
620+ # ``opentelemetry-*>=1.27.0`` floor — opentelemetry-sdk exact-pins the semantic conventions
621+ # version it ships with, so a higher floor here would conflict with any sdk older than that pairing.
546622 #
547623 # These two are the only pre-releases in the constraints we tag, and removing the need for the
548624 # exception is tracked at https://github.com/apache/airflow/issues/71176
@@ -554,27 +630,16 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
554630 "opentelemetry-semantic-conventions>=0.48b0" ,
555631 ]
556632
557- # Constraints cut for a release candidate have to pin the candidates themselves - the providers
558- # for that wave exist on PyPI only as rcN versions, and uv will not resolve to a pre-release
559- # unless asked. The final release regenerates these without it, so a released constraints file
560- # can never carry an rc pin.
561- #
562- # Scoped to the providers rather than passing `--pre`, which applies to the whole resolution
563- # and would let any dependency answer with a pre-release - putting, say, a beta of a
564- # third-party library into the constraints a release ships. uv has no per-package pre-release
565- # flag, so the scoping is expressed the way it does support: `explicit` permits a pre-release
566- # only for a package some requirement marks as such, and the rc lower bound below is that mark.
567- # `explicit` rather than the default `if-necessary-or-explicit` also drops the fallback that
568- # would otherwise let an unmarked package resolve to a pre-release when no final satisfies.
569- pre_release_requirements : list [str ] = []
570- pre_release_strategy : list [str ] = []
571- if config_params .allow_pre_releases :
572- pre_release_requirements = build_provider_pre_release_requirements (config_params .python )
573- pre_release_strategy = ["--prerelease" , "explicit" ]
574- console .print (
575- f"[bright_blue]Allowing pre-releases for { len (pre_release_requirements )} provider "
576- "distributions - no other package can resolve to one."
577- )
633+ # Every run pins the providers at the versions PyPI holds when it is generated. Only a run for
634+ # a release candidate considers the rc versions of the wave being voted on; a final regenerates
635+ # these against the published releases, so a released constraints file can never carry an rc pin.
636+ pinned_provider_requirements = build_pinned_provider_requirements (
637+ config_params .python , config_params .allow_pre_releases
638+ )
639+ console .print (
640+ f"[bright_blue]Pinning { len (pinned_provider_requirements )} provider distributions to the "
641+ f"{ 'newest' if config_params .allow_pre_releases else 'newest final' } versions in PyPI."
642+ )
578643
579644 result = run_command (
580645 cmd = [
@@ -589,8 +654,7 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
589654 f"apache-airflow-task-sdk=={ AIRFLOW_TASK_SDK_VERSION } " ,
590655 "./airflow-ctl" ,
591656 * additional_constraints_for_highest_resolution ,
592- * pre_release_requirements ,
593- * pre_release_strategy ,
657+ * pinned_provider_requirements ,
594658 "--reinstall" , # We need to pull the provider distributions from PyPI or dist, not the local ones
595659 "--resolution" ,
596660 "highest" ,
@@ -603,7 +667,9 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
603667 if result .returncode != 0 :
604668 console .print (
605669 "[red]Failed to install airflow with PyPI providers with highest resolution.[/]\n "
606- "[yellow]Please check the output above for details. One of they ways how to resolve it, in "
670+ "[yellow]Please check the output above for details. The providers are pinned at the newest "
671+ "versions PyPI serves, so two of them requiring incompatible dependencies fails here rather "
672+ "than quietly settling on an older provider. One of they ways how to resolve it, in "
607673 "case it is caused by a specific broken dependency version, is to exclude it above in the "
608674 f"`additional_constraints_for_highest_resolution` list in [/] { __file__ } "
609675 )
@@ -693,8 +759,9 @@ def generate_constraints_no_providers(config_params: ConfigParams) -> None:
693759 "--allow-pre-releases" ,
694760 is_flag = True ,
695761 default = False ,
696- help = "Allow pre-release versions of Airflow and providers to be pinned. Used when constraints "
697- "are generated for a release candidate, whose providers are only on PyPI as rc versions." ,
762+ help = "Let the provider pins use pre-release versions when those are newer than any final "
763+ "release. Used when constraints are generated for a release candidate, whose providers are "
764+ "only on PyPI as rc versions." ,
698765 envvar = "ALLOW_PRE_RELEASES" ,
699766)
700767def generate_constraints (
0 commit comments