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

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 148a1c3222d Add a script to report outdated versions in constraints 
(#52406)
148a1c3222d is described below

commit 148a1c3222dd443a8e03938970d8498031db9cfc
Author: Elad Kalif <[email protected]>
AuthorDate: Sat Jun 28 23:21:15 2025 +0300

    Add a script to report outdated versions in constraints (#52406)
---
 dev/constraints-updated-version-check.py | 322 +++++++++++++++++++++++++++++++
 1 file changed, 322 insertions(+)

diff --git a/dev/constraints-updated-version-check.py 
b/dev/constraints-updated-version-check.py
new file mode 100644
index 00000000000..d46c4886e32
--- /dev/null
+++ b/dev/constraints-updated-version-check.py
@@ -0,0 +1,322 @@
+# 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.
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import urllib.request
+from datetime import datetime
+from urllib.error import HTTPError, URLError
+
+from packaging import version
+
+
+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:
+                print(f"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:
+            print(f"Error: Constraints file for Python {python_version} not 
found.")
+            print(f"URL: {url}")
+            print("Please check if the Python version is correct and the file 
exists.")
+        else:
+            print(f"HTTP Error: {e.code} - {e.reason}")
+        exit(1)
+    except URLError as e:
+        print(f"Error connecting to GitHub: {e.reason}")
+        exit(1)
+    except Exception as e:
+        print(f"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():
+    parser = argparse.ArgumentParser(
+        description="""
+Python Package Version Checker for Airflow Constraints
+
+This script checks Python package versions against the Airflow constraints 
file and reports:
+- Current constrained version vs latest available version
+- Release dates for both versions
+- Status indicator showing how outdated the package is
+- Number of versions between constrained and latest version
+- Direct PyPI link to the package
+
+Status Indicators:
+✅ OK          - Package is up to date
+📢 <5d         - Less than 5 days behind latest version
+⚠ <30d         - Between 5-30 days behind latest version
+🚨 >Xd         - More than X days behind latest version (X = actual days)
+        """,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+
+    parser.add_argument(
+        "--python-version", required=True, help="Python version to check 
constraints for (e.g., 3.12)"
+    )
+    parser.add_argument(
+        "--mode",
+        choices=["full", "diff-constraints", "diff-all"],
+        default="diff-constraints",
+        help="""
+Operation modes:
+  full            : Show all packages, including up-to-date ones
+  diff-constraints: (Default) Show only outdated packages with updates
+                   before constraints generation
+  diff-all       : Show all outdated packages regardless of update timing
+                       """,
+    )
+
+    args = parser.parse_args()
+
+    lines = get_constraints_file(args.python_version)
+
+    constraints_date = parse_constraints_generation_date(lines)
+    if constraints_date:
+        print(f"Constraints file generation date: 
{constraints_date.strftime('%Y-%m-%d %H:%M:%S')}")
+        print()
+
+    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:
+                packages.append((match.group(1), match.group(2)))
+
+    max_pkg_length = get_max_package_length(packages)
+
+    col_widths = {
+        "Library Name": max(35, max_pkg_length),
+        "Constraint Version": 18,
+        "Constraint Date": 12,
+        "Latest Version": 15,
+        "Latest Date": 12,
+        "Status": 15,
+        "# Versions Behind": 16,
+        "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"{{:>15}} | "
+        f"{{:<{col_widths['PyPI Link']}}}"
+    )
+
+    headers = [
+        "Library Name",
+        "Constraint Version",
+        "Constraint Date",
+        "Latest Version",
+        "Latest Date",
+        "Status",
+        "# Versions Behind",
+        "PyPI Link",
+    ]
+
+    print(format_str.format(*headers))
+    total_width = sum(col_widths.values()) + (len(col_widths) - 1) * 3
+    print("=" * total_width)
+
+    outdated_count = 0
+    skipped_count = 0
+
+    for pkg, pinned_version in packages:
+        try:
+            pypi_url = f"https://pypi.org/pypi/{pkg}/json";
+            with urllib.request.urlopen(pypi_url) as resp:
+                data = json.loads(resp.read().decode("utf-8"))
+                latest_version = data["info"]["version"]
+                releases = data["releases"]
+
+                latest_release_date = "N/A"
+                constraint_release_date = "N/A"
+
+                if releases.get(latest_version):
+                    latest_release_date = (
+                        datetime.fromisoformat(
+                            
releases[latest_version][0]["upload_time_iso_8601"].replace("Z", "+00:00")
+                        )
+                        .replace(tzinfo=None)
+                        .strftime("%Y-%m-%d")
+                    )
+
+                if releases.get(pinned_version):
+                    constraint_release_date = (
+                        datetime.fromisoformat(
+                            
releases[pinned_version][0]["upload_time_iso_8601"].replace("Z", "+00:00")
+                        )
+                        .replace(tzinfo=None)
+                        .strftime("%Y-%m-%d")
+                    )
+
+                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, args.mode, 
is_latest_version
+                ):
+                    # Use the first newer release date instead of latest 
version date
+                    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.utcnow().strftime("%Y-%m-%d"),  # noqa: TID251
+                        is_latest_version,
+                    )
+                    pypi_link = 
f"https://pypi.org/project/{pkg}/{latest_version}";
+
+                    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,
+                        )
+                    )
+                    if not is_latest_version:
+                        outdated_count += 1
+                    else:
+                        skipped_count += 1
+
+        except HTTPError as e:
+            print(f"Error fetching {pkg} from PyPI: HTTP {e.code}")
+            continue
+        except URLError as e:
+            print(f"Error fetching {pkg} from PyPI: {e.reason}")
+            continue
+        except Exception as e:
+            print(f"Error processing {pkg}: {str(e)}")
+            continue
+
+    print("=" * total_width)
+    print(f"\nTotal packages checked: {len(packages)}")
+    print(f"Outdated packages found: {outdated_count}")
+    if args.mode == "diff-constraints":
+        print(f"Skipped packages (updated after constraints generation): 
{skipped_count}")
+
+
+if __name__ == "__main__":
+    main()

Reply via email to