This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-0-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-0-test by this push:
new 84403162801 Backport e455329 v3 0 test (#52494)
84403162801 is described below
commit 844031628015c2cc975233b332d475166c572030
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Jun 29 22:08:52 2025 +0200
Backport e455329 v3 0 test (#52494)
* Make the dependency script executable (#52493)
(cherry picked from commit e455329ca352b31c866f859dfa3446110166153d)
* [v3-0-test] Make the dependency script executable (#52493)
(cherry picked from commit e455329ca352b31c866f859dfa3446110166153d)
Co-authored-by: Jarek Potiuk <[email protected]>
---
dev/constraints-updated-version-check.py | 531 +++++++++++++++++++++++++++++++
1 file changed, 531 insertions(+)
diff --git a/dev/constraints-updated-version-check.py
b/dev/constraints-updated-version-check.py
new file mode 100755
index 00000000000..bf68c9b067a
--- /dev/null
+++ b/dev/constraints-updated-version-check.py
@@ -0,0 +1,531 @@
+#!/usr/bin/env python3
+# 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.11"
+# dependencies = [
+# "rich",
+# "rich-click",
+# "packaging",
+# ]
+# ///
+from __future__ import annotations
+
+import json
+import os
+import re
+import urllib.request
+from contextlib import contextmanager
+from datetime import datetime
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+
+import rich_click as click
+from packaging import version
+from rich.console import Console
+from rich.syntax import Syntax
+
+console = Console(color_system="standard")
+
+
+def parse_constraints_generation_date(lines):
+ for line in lines[:5]:
+ if "automatically generated on" in line:
+ date_str = line.split("generated on")[-1].strip()
+ try:
+ return datetime.fromisoformat(date_str).replace(tzinfo=None)
+ except ValueError:
+ console.print(
+ f"[yellow]Warning: Could not parse constraints generation
date from: {date_str}[/]"
+ )
+ return None
+ return None
+
+
+def get_constraints_file(python_version):
+ url =
f"https://raw.githubusercontent.com/apache/airflow/refs/heads/constraints-main/constraints-{python_version}.txt"
+ try:
+ response = urllib.request.urlopen(url)
+ return response.read().decode("utf-8").splitlines()
+ except HTTPError as e:
+ if e.code == 404:
+ console.print(f"[bold red]Error: Constraints file for Python
{python_version} not found.[/]")
+ console.print(f"[bold red]URL: {url}[/]")
+ console.print("[bold red]Please check if the Python version is
correct and the file exists.[/]")
+ else:
+ console.print(f"[bold red]HTTP Error: {e.code} - {e.reason}[/]")
+ exit(1)
+ except URLError as e:
+ console.print(f"[bold red]Error connecting to GitHub: {e.reason}[/]")
+ exit(1)
+ except Exception as e:
+ console.print(f"[bold red]Unexpected error: {str(e)}[/]")
+ exit(1)
+
+
+def count_versions_between(releases, current_version, latest_version):
+ try:
+ current = version.parse(current_version)
+ latest = version.parse(latest_version)
+
+ if current == latest:
+ return 0
+
+ valid_versions = [
+ v
+ for v in releases.keys()
+ if releases[v] and not version.parse(v).is_prerelease and current
< version.parse(v) <= latest
+ ]
+
+ return max(len(valid_versions), 1) if current < latest else 0
+ except Exception:
+ return 0
+
+
+def get_status_emoji(constraint_date, latest_date, is_latest_version):
+ """Determine status emoji based on how outdated the package is"""
+ if is_latest_version:
+ return "✅ OK " # Package is up to date (15 chars padding)
+
+ try:
+ constraint_dt = datetime.strptime(constraint_date, "%Y-%m-%d")
+ latest_dt = datetime.strptime(latest_date, "%Y-%m-%d")
+ days_diff = (latest_dt - constraint_dt).days
+
+ if days_diff <= 5:
+ return "📢 <5d "
+ if days_diff <= 30:
+ return "⚠ <30d "
+ return f"🚨 >{days_diff}d".ljust(15)
+ except Exception:
+ return "📢 N/A "
+
+
+def get_max_package_length(packages):
+ return max(len(pkg) for pkg, _ in packages)
+
+
+def should_show_package(releases, latest_version, constraints_date, mode,
is_latest_version):
+ if mode == "full":
+ return True
+ if mode == "diff-all":
+ return not is_latest_version
+ # diff-constraints
+ if is_latest_version:
+ return False
+
+ if not constraints_date:
+ return True
+
+ for version_info in releases.values():
+ if not version_info:
+ continue
+ try:
+ release_date = datetime.fromisoformat(
+ version_info[0]["upload_time_iso_8601"].replace("Z", "+00:00")
+ ).replace(tzinfo=None)
+ if release_date > constraints_date:
+ return False
+ except (KeyError, IndexError, ValueError):
+ continue
+
+ return True
+
+
+def get_first_newer_release_date_str(releases, current_version):
+ current = version.parse(current_version)
+ newer_versions = [
+ version.parse(v)
+ for v in releases
+ if version.parse(v) > current and releases[v] and not
version.parse(v).is_prerelease
+ ]
+ if not newer_versions:
+ return None
+
+ first_newer_version = min(newer_versions)
+ upload_time_str =
releases[str(first_newer_version)][0]["upload_time_iso_8601"]
+ return datetime.fromisoformat(upload_time_str.replace("Z",
"+00:00")).strftime("%Y-%m-%d")
+
+
+def main(
+ python_version: str,
+ mode: str,
+ selected_packages: set[str] | None = None,
+ explain_why: bool = False,
+ verbose: bool = False,
+):
+ lines = get_constraints_file(python_version)
+ constraints_date = parse_constraints_generation_date(lines)
+ if constraints_date:
+ console.print(
+ f"[bold cyan]Constraints file generation date:[/]
[white]{constraints_date.strftime('%Y-%m-%d %H:%M:%S')}[/]"
+ )
+ console.print()
+
+ packages = parse_packages_from_lines(lines, selected_packages)
+ col_widths, format_str, headers, total_width = get_table_format(packages)
+ print_table_header(format_str, headers, total_width)
+
+ outdated_count, skipped_count, explanations = process_packages(
+ packages, constraints_date, mode, explain_why, verbose, col_widths,
format_str
+ )
+
+ print_table_footer(total_width, len(packages), outdated_count,
skipped_count, mode)
+ if explain_why and explanations:
+ print_explanations(explanations)
+
+
+def parse_packages_from_lines(lines: list[str], selected_packages: set[str] |
None) -> list[tuple[str, str]]:
+ packages = []
+ for line in lines:
+ line = line.strip()
+ if line and not line.startswith("#") and "@" not in line:
+ match = re.match(r"^([a-zA-Z0-9_.\-]+)==([\w.\-]+)$", line)
+ if match:
+ pkg_name = match.group(1)
+ if not selected_packages or (pkg_name in selected_packages):
+ packages.append((pkg_name, match.group(2)))
+ return packages
+
+
+def get_table_format(packages: list[tuple[str, str]]):
+ max_pkg_length = get_max_package_length(packages)
+ col_widths = {
+ "Library Name": max(35, max_pkg_length),
+ "Constraint Version": 18,
+ "Constraint Date": 15,
+ "Latest Version": 15,
+ "Latest Date": 12,
+ "📢 Status": 17,
+ "# Versions Behind": 19,
+ "PyPI Link": 60,
+ }
+ format_str = (
+ f"{{:<{col_widths['Library Name']}}} | "
+ f"{{:<{col_widths['Constraint Version']}}} | "
+ f"{{:<{col_widths['Constraint Date']}}} | "
+ f"{{:<{col_widths['Latest Version']}}} | "
+ f"{{:<{col_widths['Latest Date']}}} | "
+ f"{{:<{col_widths['📢 Status']}}} | "
+ f"{{:<{col_widths['# Versions Behind']}}} | "
+ f"{{:<{col_widths['PyPI Link']}}}"
+ )
+ headers = [
+ "Library Name",
+ "Constraint Version",
+ "Constraint Date",
+ "Latest Version",
+ "Latest Date",
+ "📢 Status",
+ "# Versions Behind",
+ "PyPI Link",
+ ]
+ total_width = sum(col_widths.values()) + (len(col_widths) - 1) * 3
+ return col_widths, format_str, headers, total_width
+
+
+def print_table_header(format_str: str, headers: list[str], total_width: int):
+ console.print(f"[bold magenta]{format_str.format(*headers)}[/]")
+ console.print(f"[magenta]{'=' * total_width}[/]")
+
+
+def print_table_footer(total_width: int, total_pkgs: int, outdated_count: int,
skipped_count: int, mode: str):
+ console.print(f"[magenta]{'=' * total_width}[/]")
+ console.print(f"[bold cyan]\nTotal packages checked:[/]
[white]{total_pkgs}[/]")
+ console.print(f"[bold yellow]Outdated packages found:[/]
[white]{outdated_count}[/]")
+ if mode == "diff-constraints":
+ console.print(
+ f"[bold blue]Skipped packages (updated after constraints
generation):[/] [white]{skipped_count}[/]"
+ )
+
+
+def print_explanations(explanations: list[str]):
+ console.print("\n[bold magenta]Upgrade Explanations:[/]")
+ for explanation in explanations:
+ console.print(explanation)
+
+
+def process_packages(
+ packages: list[tuple[str, str]],
+ constraints_date: datetime | None,
+ mode: str,
+ explain_why: bool,
+ verbose: bool,
+ col_widths: dict,
+ format_str: str,
+) -> tuple[int, int, list[str]]:
+ import subprocess
+ import tempfile
+ from contextlib import contextmanager
+ from pathlib import Path
+
+ @contextmanager
+ def preserve_pyproject_file(pyproject_path: Path):
+ original_content = pyproject_path.read_text()
+ try:
+ yield
+ finally:
+ pyproject_path.write_text(original_content)
+
+ def run_uv_sync(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess:
+ env = os.environ.copy()
+ env.pop("VIRTUAL_ENV", None)
+ if verbose:
+ console.print(f"[cyan]Running command:[/] [white]{'
'.join(cmd)}[/] [dim]in {cwd}[/]")
+ result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True,
text=True)
+ return result
+
+ def fetch_pypi_data(pkg: str) -> dict:
+ pypi_url = f"https://pypi.org/pypi/{pkg}/json"
+ with urllib.request.urlopen(pypi_url) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+
+ def get_release_dates(releases: dict, version: str) -> str:
+ if releases.get(version):
+ return (
+
datetime.fromisoformat(releases[version][0]["upload_time_iso_8601"].replace("Z",
"+00:00"))
+ .replace(tzinfo=None)
+ .strftime("%Y-%m-%d")
+ )
+ return "N/A"
+
+ def update_pyproject_dependency(pyproject_path: Path, pkg: str,
latest_version: str):
+ lines = pyproject_path.read_text().splitlines()
+ new_lines = []
+ in_deps = False
+ dep_added = False
+ for line in lines:
+ new_lines.append(line)
+ if line.strip() == "dependencies = [":
+ in_deps = True
+ elif in_deps and line.strip().startswith("]") and not dep_added:
+ new_lines.insert(-1, f' "{pkg}=={latest_version}",')
+ dep_added = True
+ in_deps = False
+ if not dep_added:
+ new_lines.append(f' "{pkg}=={latest_version}",')
+ pyproject_path.write_text("\n".join(new_lines) + "\n")
+
+ airflow_pyproject = Path(__file__).parent.parent / "pyproject.toml"
+ airflow_pyproject = airflow_pyproject.resolve()
+ repo_root = airflow_pyproject.parent
+
+ outdated_count = 0
+ skipped_count = 0
+ explanations = []
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir_path = Path(temp_dir)
+ for pkg, pinned_version in packages:
+ try:
+ data = fetch_pypi_data(pkg)
+ latest_version = data["info"]["version"]
+ releases = data["releases"]
+ latest_release_date = get_release_dates(releases,
latest_version)
+ constraint_release_date = get_release_dates(releases,
pinned_version)
+ is_latest_version = pinned_version == latest_version
+ versions_behind = count_versions_between(releases,
pinned_version, latest_version)
+ versions_behind_str = str(versions_behind) if versions_behind
> 0 else ""
+ if should_show_package(releases, latest_version,
constraints_date, mode, is_latest_version):
+ print_package_table_row(
+ pkg=pkg,
+ pinned_version=pinned_version,
+ constraint_release_date=constraint_release_date,
+ latest_version=latest_version,
+ latest_release_date=latest_release_date,
+ releases=releases,
+ col_widths=col_widths,
+ format_str=format_str,
+ is_latest_version=is_latest_version,
+ versions_behind_str=versions_behind_str,
+ )
+ if not is_latest_version:
+ outdated_count += 1
+ else:
+ skipped_count += 1
+
+ if explain_why and not is_latest_version:
+ explanation = explain_package_upgrade(
+ pkg,
+ pinned_version,
+ latest_version,
+ airflow_pyproject,
+ repo_root,
+ temp_dir_path,
+ run_uv_sync,
+ update_pyproject_dependency,
+ verbose,
+ )
+ explanations.append(explanation)
+ except HTTPError as e:
+ console.print(f"[bold red]Error fetching {pkg} from PyPI: HTTP
{e.code}[/]")
+ continue
+ except URLError as e:
+ console.print(f"[bold red]Error fetching {pkg} from PyPI:
{e.reason}[/]")
+ continue
+ except Exception as e:
+ console.print(f"[bold red]Error processing {pkg}: {str(e)}[/]")
+ continue
+ return outdated_count, skipped_count, explanations
+
+
+def print_package_table_row(
+ pkg: str,
+ pinned_version: str,
+ constraint_release_date: str,
+ latest_version: str,
+ latest_release_date: str,
+ releases: dict,
+ col_widths: dict,
+ format_str: str,
+ is_latest_version: bool,
+ versions_behind_str: str,
+):
+ first_newer_date_str = get_first_newer_release_date_str(releases,
pinned_version)
+ status = get_status_emoji(
+ first_newer_date_str or constraint_release_date,
+ datetime.now().strftime("%Y-%m-%d"),
+ is_latest_version,
+ )
+ pypi_link = f"https://pypi.org/project/{pkg}/{latest_version}"
+ color = (
+ "green"
+ if is_latest_version
+ else ("yellow" if status.startswith("📢") or status.startswith("⚠")
else "red")
+ )
+ string_to_print = format_str.format(
+ pkg,
+ pinned_version[: col_widths["Constraint Version"]],
+ constraint_release_date[: col_widths["Constraint Date"]],
+ latest_version[: col_widths["Latest Version"]],
+ latest_release_date[: col_widths["Latest Date"]],
+ status[: col_widths["📢 Status"]],
+ versions_behind_str,
+ pypi_link,
+ )
+ console.print(f"[{color}]{string_to_print}[/]")
+
+
+def explain_package_upgrade(
+ pkg: str,
+ pinned_version: str,
+ latest_version: str,
+ airflow_pyproject: Path,
+ repo_root: Path,
+ temp_dir_path: Path,
+ run_uv_sync,
+ update_pyproject_dependency,
+ verbose: bool,
+) -> str:
+ explanation = (
+ f"[bold blue]\n--- Explaining for {pkg} (current: {pinned_version},
latest: {latest_version}) ---[/]"
+ )
+
+ @contextmanager
+ def preserve_pyproject_file(pyproject_path: Path):
+ original_content = pyproject_path.read_text()
+ try:
+ yield
+ finally:
+ pyproject_path.write_text(original_content)
+
+ with preserve_pyproject_file(airflow_pyproject):
+ before_result = run_uv_sync(
+ [
+ "uv",
+ "sync",
+ "--all-packages",
+ "--resolution",
+ "highest",
+ "--refresh",
+ ],
+ cwd=repo_root,
+ )
+ (temp_dir_path / "uv_sync_before.txt").write_text(before_result.stdout
+ before_result.stderr)
+ update_pyproject_dependency(airflow_pyproject, pkg, latest_version)
+ if verbose:
+ syntax = Syntax(
+ airflow_pyproject.read_text(), "toml", theme="monokai",
line_numbers=True, word_wrap=False
+ )
+ explanation += "\n" + str(syntax)
+ after_result = run_uv_sync(
+ [
+ "uv",
+ "sync",
+ "--all-packages",
+ "--resolution",
+ "highest",
+ "--refresh",
+ ],
+ cwd=repo_root,
+ )
+ (temp_dir_path / "uv_sync_after.txt").write_text(after_result.stdout +
after_result.stderr)
+ if after_result.returncode == 0:
+ explanation += f"\n[bold yellow]Package {pkg} can be upgraded from
{pinned_version} to {latest_version} without conflicts.[/]."
+ else:
+ explanation += f"\n[yellow]uv sync output for
{pkg}=={latest_version}:[/]\n"
+ explanation += after_result.stdout + after_result.stderr
+ return explanation
+
+
[email protected]()
[email protected](
+ "--python-version",
+ required=False,
+ default="3.10",
+ help="Python version to check constraints for (e.g., 3.12)",
+)
[email protected](
+ "--mode",
+ type=click.Choice(["full", "diff-constraints", "diff-all"]),
+ default="diff-constraints",
+ show_default=True,
+ help="Operation mode: full, diff-constraints, or diff-all.",
+)
[email protected](
+ "--selected-packages",
+ required=False,
+ default=None,
+ help="Comma-separated list of package names to check (e.g.,
'requests,flask'). If not set, all packages are checked.",
+)
[email protected](
+ "--explain-why",
+ is_flag=True,
+ default=False,
+ help="For each selected package, attempts to upgrade to the latest version
and explains why it cannot be upgraded.",
+)
[email protected](
+ "--verbose",
+ is_flag=True,
+ default=False,
+ help="Print the temporary pyproject.toml file used for each package when
running --explain-why.",
+)
+def cli(
+ python_version: str,
+ mode: str,
+ selected_packages: str | None,
+ explain_why: bool,
+ verbose: bool,
+):
+ selected_packages_set = None
+ if selected_packages:
+ selected_packages_set = set(pkg.strip() for pkg in
selected_packages.split(",") if pkg.strip())
+ main(python_version, mode, selected_packages_set, explain_why, verbose)
+
+
+if __name__ == "__main__":
+ cli()