This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new c81fb5c8681 [v3-4-test] Add script to remove constraints for 
unsupported Python versions (#75333) (#76405)
c81fb5c8681 is described below

commit c81fb5c868124e2a6f6268375c31c7182a1c33fc
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Nov 6 09:50:11 2026 +0000

    [v3-4-test] Add script to remove constraints for unsupported Python 
versions (#75333) (#76405)
    
    Several constraints-<VERSION> tags ship constraints files for Python
    versions the release does not support, for example 3.13 for Airflow
    3.0.x. Following the documented install command with such a file pins
    packages that have no wheels for that interpreter, so users hit a
    grpcio source build failure instead of a clear missing-file error.
    (cherry picked from commit 5afb353fd087343f142a49d64ed796f51f12b710)
    
    
    Generated-by: Claude Opus 5
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 dev/remove_unsupported_python_constraints.py | 300 +++++++++++++++++++++++++++
 1 file changed, 300 insertions(+)

diff --git a/dev/remove_unsupported_python_constraints.py 
b/dev/remove_unsupported_python_constraints.py
new file mode 100755
index 00000000000..aac28e5014d
--- /dev/null
+++ b/dev/remove_unsupported_python_constraints.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+#   "packaging>=24.0",
+#   "rich-click>=1.7.0",
+# ]
+# ///
+"""
+Remove constraint files for Python versions that an Airflow release does not 
support.
+
+Some ``constraints-<VERSION>`` tags carry ``constraints-3.X.txt`` files for a 
Python version the
+release's ``requires-python`` excludes (for example 3.13 for Airflow 3.0.x). 
Those files pin
+packages that have no wheels for that interpreter, so following the documented 
install command
+ends in a source build failure instead of a clear "file not found".
+
+For every affected tag the script commits the removal on top of the tagged 
commit and re-creates
+the tag there. Release branches (``constraints-X-Y``) get the removal 
committed on their tip.
+Nothing is pushed unless ``--push`` is passed; without ``--apply`` it only 
reports. Every run,
+dry run included, fetches tags from the remote with ``--tags --force``, 
overwriting local tags that differ.
+
+Findings based only on the ``Programming Language`` classifiers are reported 
but skipped by ``--apply``
+and ``--push`` unless ``--include-classifier-only`` is passed, because 
classifiers can lag behind
+the Python versions a release actually supports (Airflow 2.3.0 supports 3.10 
but does not list it).
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+import sys
+from dataclasses import dataclass
+
+import rich_click as click
+from packaging.specifiers import SpecifierSet
+from packaging.version import InvalidVersion, Version
+from rich.console import Console
+from rich.table import Table
+
+console = Console()
+
+METADATA_FILES = ["airflow-core/pyproject.toml", "pyproject.toml", 
"setup.cfg", "setup.py"]
+CONSTRAINT_FILE_RE = 
re.compile(r"^constraints(?:-no-providers|-source-providers)?-(3\.\d+)\.txt$")
+CLASSIFIER_RE = re.compile(r"Programming Language :: Python :: (3\.\d+)")
+REQUIRES_PYTHON_RE = 
re.compile(r"""(?:requires-python|python_requires)\s*=\s*["']?([^"'\n]+)""")
+# Probed to tell a capped requires-python (">=3.10,<3.14") from an open one 
("~=3.6").
+PROBE_PYTHON_VERSIONS = [Version(f"3.{minor}") for minor in range(6, 20)]
+
+
+@dataclass
+class Finding:
+    ref: str
+    airflow_version: str
+    unsupported_pythons: list[str]
+    files: list[str]
+    reason: str
+    classifier_only: bool
+
+
+def run_git(*args: str, check: bool = True, env: dict[str, str] | None = None) 
-> str:
+    try:
+        return subprocess.run(["git", *args], check=check, 
capture_output=True, text=True, env=env).stdout
+    except subprocess.CalledProcessError as e:
+        sys.stderr.write(e.stderr)
+        raise
+
+
+def read_file_at(ref: str, path: str) -> str | None:
+    result = subprocess.run(["git", "show", f"{ref}:{path}"], check=False, 
capture_output=True, text=True)
+    return result.stdout if result.returncode == 0 else None
+
+
+def is_minor_allowed(specifier: SpecifierSet, minor: Version) -> bool:
+    """
+    Check whether a Python minor version is allowed, judged by its latest 
patch release.
+
+    A patch-level floor such as ``>=3.8.1`` still supports 3.8, while 
``!=3.13`` is meant to exclude
+    the whole minor even though PEP 440 only excludes 3.13.0 with it.
+    """
+    for spec in specifier:
+        if spec.operator == "!=" and 
Version(spec.version.removesuffix(".*")).release[:2] == minor.release:
+            return False
+    return specifier.contains(Version(f"{minor}.99"), prereleases=True)
+
+
+def find_supported_pythons(release_ref: str) -> tuple[set[str], str, bool] | 
None:
+    """Return supported Pythons, where that was read from, and whether only 
classifiers say so."""
+    for path in METADATA_FILES:
+        content = read_file_at(release_ref, path)
+        if not content:
+            continue
+        classifiers = sorted(set(CLASSIFIER_RE.findall(content)), key=Version)
+        if not classifiers:
+            continue
+        match = REQUIRES_PYTHON_RE.search(content)
+        if not match:
+            return set(classifiers), f"{path} classifiers", True
+        specifier = SpecifierSet(match.group(1).strip().rstrip(",").replace(" 
", ""))
+        allowed = [v for v in PROBE_PYTHON_VERSIONS if 
is_minor_allowed(specifier, v)]
+        is_capped = bool(allowed) and any(
+            not is_minor_allowed(specifier, v) for v in PROBE_PYTHON_VERSIONS 
if v > allowed[0]
+        )
+        if is_capped:
+            # Classifiers lag behind requires-python (3.1.x supports 3.13 but 
does not list it),
+            # so a capped specifier is authoritative.
+            return {str(v) for v in allowed}, f"{path} requires-python 
{specifier}", False
+        # Open "~=3.6"-style specifiers say nothing about newer Pythons; fall 
back to classifiers.
+        supported = {c for c in classifiers if is_minor_allowed(specifier, 
Version(c))}
+        return supported, f"{path} classifiers within requires-python 
{specifier}", True
+    return None
+
+
+def list_constraint_files(ref: str) -> dict[str, list[str]]:
+    files_by_python: dict[str, list[str]] = {}
+    for name in run_git("ls-tree", "--name-only", ref).splitlines():
+        if match := CONSTRAINT_FILE_RE.match(name):
+            files_by_python.setdefault(match.group(1), []).append(name)
+    return files_by_python
+
+
+def find_release_tags(include_prereleases: bool) -> list[tuple[str, str]]:
+    """Return (constraints tag, Airflow release tag) pairs for 2.x and 
later."""
+    pairs = []
+    for tag in run_git("tag", "-l", "constraints-*").split():
+        version_str = tag.removeprefix("constraints-")
+        try:
+            version = Version(version_str)
+        except InvalidVersion:
+            continue
+        if version.major < 2 or (version.is_prerelease and not 
include_prereleases):
+            continue
+        if run_git("rev-parse", "-q", "--verify", 
f"refs/tags/{version_str}^{{commit}}", check=False):
+            pairs.append((tag, version_str))
+    return sorted(pairs, key=lambda pair: Version(pair[1]))
+
+
+def find_branch_release(branch: str, release_tags: list[tuple[str, str]]) -> 
str | None:
+    """Map ``constraints-X-Y`` to the newest final release of the X.Y line."""
+    major_minor = branch.removeprefix("constraints-").replace("-", ".")
+    candidates = [
+        v
+        for _, v in release_tags
+        if re.fullmatch(rf"{re.escape(major_minor)}\.\d+", v) and not 
Version(v).is_prerelease
+    ]
+    return max(candidates, key=Version) if candidates else None
+
+
+def analyze(ref: str, release_ref: str) -> Finding | None:
+    supported = find_supported_pythons(release_ref)
+    if supported is None:
+        console.print(f"[yellow]Could not determine supported Pythons for 
{release_ref}, skipping {ref}")
+        return None
+    supported_pythons, reason, classifier_only = supported
+    files_by_python = list_constraint_files(ref)
+    unsupported = sorted((p for p in files_by_python if p not in 
supported_pythons), key=Version)
+    if not unsupported:
+        return None
+    return Finding(
+        ref=ref,
+        airflow_version=release_ref,
+        unsupported_pythons=unsupported,
+        files=sorted(f for p in unsupported for f in files_by_python[p]),
+        reason=reason,
+        classifier_only=classifier_only,
+    )
+
+
+def build_commit_message(finding: Finding) -> str:
+    pythons = ", ".join(finding.unsupported_pythons)
+    return (
+        f"Remove constraints for Python {pythons} unsupported by Airflow 
{finding.airflow_version}\n\n"
+        f"Supported Python versions taken from {finding.reason}. The removed 
files pinned packages\n"
+        f"without wheels for those interpreters, so installs failed while 
building them from source."
+    )
+
+
+def commit_removal(base: str, finding: Finding) -> str:
+    """Commit the removal on top of ``base`` using a scratch index, leaving 
the worktree alone."""
+    index_file = run_git("rev-parse", "--git-path", 
"remove-constraints-index").strip()
+    env = {**os.environ, "GIT_INDEX_FILE": index_file}
+    run_git("read-tree", f"{base}^{{tree}}", env=env)
+    run_git("rm", "--cached", "--quiet", *finding.files, env=env)
+    tree = run_git("write-tree", env=env).strip()
+    return run_git("commit-tree", tree, "-p", base, "-m", 
build_commit_message(finding), env=env).strip()
+
+
[email protected]()
[email protected]("--remote", default="upstream", show_default=True, help="Remote 
holding the constraints refs.")
[email protected]("--include-prereleases", is_flag=True, help="Also fix beta and 
rc constraints tags.")
[email protected]("--apply", "do_apply", is_flag=True, help="Create the fix 
commits and move local refs.")
[email protected]("--push", is_flag=True, help="Force-push the rewritten tags and 
branches (implies --apply).")
[email protected](
+    "--include-classifier-only",
+    is_flag=True,
+    help="Also apply or push findings based only on classifiers, which may be 
incomplete.",
+)
[email protected]("--sign/--no-sign", default=True, show_default=True, 
help="GPG-sign re-created tags.")
+def main(
+    remote: str,
+    include_prereleases: bool,
+    do_apply: bool,
+    push: bool,
+    include_classifier_only: bool,
+    sign: bool,
+):
+    run_git(
+        "fetch", remote, "--tags", "--force", 
f"+refs/heads/constraints-*:refs/remotes/{remote}/constraints-*"
+    )
+    release_tags = find_release_tags(include_prereleases)
+
+    findings: list[tuple[Finding, str]] = []
+    for constraints_tag, release in release_tags:
+        if finding := analyze(constraints_tag, release):
+            findings.append((finding, "tag"))
+    branches = run_git("for-each-ref", "--format=%(refname:short)", 
f"refs/remotes/{remote}/constraints-*")
+    for remote_branch in branches.split():
+        branch = remote_branch.removeprefix(f"{remote}/")
+        if not re.fullmatch(r"constraints-\d+-\d+", branch):
+            continue
+        if (branch_release := find_branch_release(branch, release_tags)) and (
+            finding := analyze(remote_branch, branch_release)
+        ):
+            findings.append((finding, "branch"))
+
+    table = Table(title="Constraints for unsupported Python versions")
+    for column in ("Ref", "Kind", "Airflow", "Unsupported Python", "Supported 
per"):
+        table.add_column(column)
+    for finding, kind in findings:
+        table.add_row(
+            finding.ref,
+            kind,
+            finding.airflow_version,
+            ", ".join(finding.unsupported_pythons),
+            finding.reason + (" (classifier-only)" if finding.classifier_only 
else ""),
+        )
+    console.print(table)
+    if not findings:
+        return
+    if not (do_apply or push):
+        console.print("\nDry run. Re-run with [bold]--apply[/] to fix locally, 
[bold]--push[/] to publish.")
+        return
+
+    if not include_classifier_only:
+        skipped = sum(finding.classifier_only for finding, _ in findings)
+        findings = [(finding, kind) for finding, kind in findings if not 
finding.classifier_only]
+        if skipped:
+            console.print(
+                f"\nSkipping {skipped} classifier-only finding(s); "
+                "pass [bold]--include-classifier-only[/] to include them."
+            )
+        if not findings:
+            return
+
+    push_refspecs = []
+    for finding, kind in findings:
+        if kind == "tag":
+            tag = finding.ref
+            base = run_git("rev-parse", f"{tag}^{{commit}}").strip()
+            message = run_git("tag", "-l", "--format=%(contents:subject)", 
tag).strip()
+            new_commit = commit_removal(base, finding)
+            run_git("tag", "-f", "-s" if sign else "-a", tag, new_commit, 
"-m", message)
+            push_refspecs.append(f"+refs/tags/{tag}:refs/tags/{tag}")
+        else:
+            branch = finding.ref.removeprefix(f"{remote}/")
+            base = run_git("rev-parse", finding.ref).strip()
+            new_commit = commit_removal(base, finding)
+            # Fast-forward on top of the branch tip, so no force is needed.
+            push_refspecs.append(f"{new_commit}:refs/heads/{branch}")
+        console.print(
+            f"[green]{finding.ref}[/]: {base[:10]} -> {new_commit[:10]} 
(removed {len(finding.files)} files)"
+        )
+
+    if not push:
+        console.print("\nLocal refs prepared. To publish, run:")
+        # Plain print: rich wraps long lines, and pasting a wrapped command 
runs each line separately.
+        print(" \\\n    ".join([f"git push {remote}", *push_refspecs]))
+        return
+    subprocess.run(["git", "push", remote, *push_refspecs], check=True)
+
+
+if __name__ == "__main__":
+    sys.exit(main())

Reply via email to