eladkal commented on PR #71550: URL: https://github.com/apache/airflow/pull/71550#issuecomment-5328348457
Tested it. This is much faster but this makes the output not sorted so for manual reviews this is kinda of a pain. can you test out this version? It keeps sort with parallel fetch ``` #!/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. from __future__ import annotations import json import re import shlex import sys import tempfile import urllib.request from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING, Any from urllib.error import HTTPError, URLError from rich.console import Console from rich.syntax import Syntax from airflow_breeze.branch_defaults import DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH from airflow_breeze.global_constants import MOUNT_SELECTED from airflow_breeze.params.shell_params import ShellParams from airflow_breeze.utils.console import Output, console_print from airflow_breeze.utils.docker_command_utils import execute_command_in_shell from airflow_breeze.utils.github import download_constraints_file from airflow_breeze.utils.parallel import get_temp_file_name from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH from airflow_breeze.utils.shared_options import get_verbose console = Console(color_system="standard") if TYPE_CHECKING: from packaging.version 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: console_print( f"[yellow]Warning: Could not parse constraints generation date from: {date_str}[/]" ) return None return None def is_yanked_release(release_files: list[dict] | None) -> bool: """Return True if the release has files and all of them are yanked on PyPI.""" if not release_files: return False return all(f.get("yanked", False) for f in release_files) def is_valid_version(version_str: str, latest_version: Version) -> bool: """Check if the version string is a valid one. The version should not ve pre-release or dev release and should be below the latest version""" from packaging import version try: parsed_version = version.parse(version_str) return ( not parsed_version.is_prerelease and not parsed_version.is_devrelease and parsed_version <= latest_version ) except version.InvalidVersion: return False def count_versions_between(releases: dict[str, Any], current_version: str, latest_version: str): from packaging import version current = version.parse(current_version) latest = version.parse(latest_version) if current == latest: return 0 versions_between = [ v for v in releases.keys() if releases[v] and not is_yanked_release(releases[v]) and is_valid_version(version_str=v, latest_version=latest) and current < version.parse(v) <= latest ] return len(versions_between) def get_status_emoji(constraint_date, latest_date, is_latest_version, cooldown_days: int = 0): """Determine status emoji based on how outdated the package is. The ``cooldown_days`` value shifts the thresholds so that time a package spent in the cooldown window is not counted against its staleness — a package that was released just after the cooldown period should still be reported as "new" rather than immediately as "warning". All emojis used here (✅, 📢, 🔶, 🚨) are single Python chars with ~2 visual cells, so ljust produces consistent alignment without any offset workarounds. Returns a tuple of (formatted_status_string, status_category) where status_category is one of "ok", "new", "warning", "critical". """ col_target = 11 if is_latest_version: return "✅ OK".ljust(col_target), "ok" new_threshold = 5 + cooldown_days warning_threshold = 30 + cooldown_days 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 <= new_threshold: return f"📢 <{new_threshold}d".ljust(col_target), "new" if days_diff <= warning_threshold: return f"🔶 <{warning_threshold}d".ljust(col_target), "warning" return f"🚨 >{days_diff}d".ljust(col_target), "critical" except Exception: return "📢 N/A".ljust(col_target), "new" def get_days_stale(latest_release_date: str) -> str: """Return the number of days since the latest release if >365, else empty string.""" try: latest_release_dt = datetime.strptime(latest_release_date, "%Y-%m-%d") days_since = (datetime.now() - latest_release_dt).days if days_since > 365: return str(days_since) except Exception: pass return "" def get_max_package_length(packages: list[tuple[str, str]]) -> int: 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 if is_yanked_release(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_latest_version_with_cooldown(releases: dict[str, Any], cooldown_days: int) -> str | None: """Find the latest non-prerelease version whose release date is outside the cooldown period. Returns the version string, or None if no version qualifies. """ from packaging import version cutoff = datetime.now() - timedelta(days=cooldown_days) candidates: list[tuple[version.Version, str]] = [] for v, release_files in releases.items(): if not release_files: continue if is_yanked_release(release_files): continue try: parsed_v = version.parse(v) except version.InvalidVersion: continue if parsed_v.is_prerelease or parsed_v.is_devrelease: continue try: upload_time = datetime.fromisoformat( release_files[0]["upload_time_iso_8601"].replace("Z", "+00:00") ).replace(tzinfo=None) except (KeyError, IndexError, ValueError): continue if upload_time <= cutoff: candidates.append((parsed_v, v)) if not candidates: return None candidates.sort(key=lambda x: x[0], reverse=True) return candidates[0][1] def get_first_newer_release_date_str(releases, current_version): from packaging import version try: current = version.parse(current_version) # Filter and parse versions, excluding pre-releases, yanked, and invalid versions valid_versions = [] for v in releases: try: parsed_v = version.parse(v) if not parsed_v.is_prerelease and releases[v] and not is_yanked_release(releases[v]): valid_versions.append(parsed_v) except version.InvalidVersion: continue # Find newer versions newer_versions = [v for v in valid_versions if v > current] if not newer_versions: return None # Get the immediate next version first_newer_version = str(min(newer_versions)) upload_time_str = releases[first_newer_version][0]["upload_time_iso_8601"] return datetime.fromisoformat(upload_time_str.replace("Z", "+00:00")).strftime("%Y-%m-%d") except version.InvalidVersion as e: console_print( f"[yellow]Warning: Invalid version format for {current_version}. Skipping date check. Error: {str(e)}[/]" ) return None def constraints_version_check( python: str, airflow_constraints_mode: str, diff_mode: str, selected_packages: set[str] | None = None, explain_why: bool = False, github_token: str | None = None, github_repository: str | None = None, cooldown_days: int = 4, ): console_print(f"[bold cyan]Python version:[/] [white]{python}[/]") console_print(f"[bold cyan]Constraints mode:[/] [white]{airflow_constraints_mode}[/]") console_print(f"[bold cyan]Cooldown period:[/] [white]{cooldown_days} days[/]\n") with tempfile.TemporaryDirectory() as temp_dir: constraints_file = Path(temp_dir) / "constraints.txt" download_constraints_file( constraints_reference=DEFAULT_AIRFLOW_CONSTRAINTS_BRANCH, python_version=python, airflow_constraints_mode=airflow_constraints_mode, github_token=github_token, output_file=constraints_file, ) lines = constraints_file.read_text().splitlines() 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() if selected_packages: console_print("selected_packages:", selected_packages) packages = parse_packages_from_lines(lines, selected_packages) if not packages: console_print("[bold red]No matching packages found in constraints file.[/]") sys.exit(1) col_widths, format_str, headers, total_width = get_table_format(packages) print_table_header(format_str, headers, total_width) outdated_count, skipped_count, explanations, status_counts = process_packages( packages=packages, constraints_date=constraints_date, mode=diff_mode, explain_why=explain_why, col_widths=col_widths, format_str=format_str, python_version=python, airflow_constraints_mode=airflow_constraints_mode, github_repository=github_repository, cooldown_days=cooldown_days, ) print_table_footer( total_width=total_width, total_pkgs=len(packages), outdated_count=outdated_count, skipped_count=skipped_count, mode=diff_mode, status_counts=status_counts, cooldown_days=cooldown_days, ) 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]]: remaining_packages: set[str] = selected_packages.copy() if selected_packages else set() packages = [] for line_raw in lines: line = line_raw.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))) if pkg_name and selected_packages and pkg_name in selected_packages: remaining_packages.remove(pkg_name) if remaining_packages: console_print( f"[bold yellow]Warning:[/] [white]{len(remaining_packages)}[/] packages were selected but not found in constraints file: {', '.join(remaining_packages)}" ) 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": 12, "# Days Stale": 12, "# 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['# Days Stale']}}} | " f"{{:<{col_widths['# Versions Behind']}}} | " f"{{:<{col_widths['PyPI Link']}}}" ) headers = [ "Library Name", "Constraint Version", "Constraint Date", "Latest Version", "Latest Date", "📢 Status", "# Days Stale", "# 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, status_counts: dict[str, int], cooldown_days: int = 0, ): new_threshold = 5 + cooldown_days warning_threshold = 30 + cooldown_days console_print(f"[magenta]{'=' * total_width}[/]") console_print(f"[bold cyan]\nTotal packages checked:[/] [white]{total_pkgs}[/]") console_print(f" [green]✅ Up to date:[/] [white]{status_counts['ok']}[/]") console_print(f" [yellow]📢 New (<{new_threshold}d):[/] [white]{status_counts['new']}[/]") console_print(f" [magenta]🔶 Warning (<{warning_threshold}d):[/] [white]{status_counts['warning']}[/]") console_print(f" [red]🚨 Critical (>{warning_threshold}d):[/] [white]{status_counts['critical']}[/]") 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 update_pyproject_dependency(pyproject_path: Path, pkg: str, latest_version: str, python_version: str): lines = pyproject_path.read_text().splitlines() new_lines = [] in_deps = False dep_added = False dep_string = f" \"{pkg}=={latest_version}; python_version=='{python_version}'\"," 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, dep_string) dep_added = True in_deps = False if not dep_added: new_lines.append(dep_string) pyproject_path.write_text("\n".join(new_lines) + "\n") if get_verbose(): console_print( f"[cyan]Fixed {pkg} at {latest_version} in [white]{pyproject_path}[/] [dim](pyproject.toml)[/]" ) def process_packages( packages: list[tuple[str, str]], constraints_date: datetime | None, mode: str, explain_why: bool, col_widths: dict, format_str: str, python_version: str, airflow_constraints_mode: str, github_repository: str | None, cooldown_days: int = 4, ) -> tuple[int, int, list[str], dict[str, int]]: 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" outdated_count = 0 skipped_count = 0 explanations = [] status_counts: dict[str, int] = {"ok": 0, "new": 0, "warning": 0, "critical": 0} # Resolved lazily on the first package that needs an explanation, then shared by all of # them — see resolve_baseline_versions() for why one resolution is enough. baseline: tuple[str, dict[str, str]] | None = None for pkg, pinned_version in packages: try: data = fetch_pypi_data(pkg) releases = data["releases"] latest_version_with_cooldown = get_latest_version_with_cooldown(releases, cooldown_days) latest_version = latest_version_with_cooldown or data["info"]["version"] 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): status_category = 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, cooldown_days=cooldown_days, ) status_counts[status_category] += 1 if not is_latest_version: outdated_count += 1 else: skipped_count += 1 if explain_why and not is_latest_version: if baseline is None: baseline = resolve_baseline_versions( python_version=python_version, airflow_constraints_mode=airflow_constraints_mode, github_repository=github_repository, ) baseline_text, baseline_versions = baseline explanation = explain_package_upgrade( pkg=pkg, pinned_version=pinned_version, latest_version=latest_version, python_version=python_version, airflow_constraints_mode=airflow_constraints_mode, github_repository=github_repository, baseline_text=baseline_text, baseline_versions=baseline_versions, ) 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 return outdated_count, skipped_count, explanations, status_counts 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, cooldown_days: int = 0, ) -> str: first_newer_date_str = get_first_newer_release_date_str(releases, pinned_version) status, status_category = get_status_emoji( first_newer_date_str or constraint_release_date, datetime.now().strftime("%Y-%m-%d"), is_latest_version, cooldown_days=cooldown_days, ) days_stale_str = get_days_stale(latest_release_date) pypi_link = f"https://pypi.org/project/{pkg}/{latest_version}" if status_category == "ok": color = "green" elif status_category == "new": color = "yellow" elif status_category == "warning": color = "magenta" elif status_category == "critical": color = "red" else: color = "white" 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"]], days_stale_str, versions_behind_str, pypi_link, ) console_print(f"[{color}]{string_to_print}[/]") return status_category def parse_freeze(freeze_text: str) -> dict[str, str]: """Parse ``uv pip freeze`` output into a ``{canonical_name: version}`` mapping. Lines that are not simple ``name==version`` pins (editable installs, ``@`` URLs, log noise emitted by uv) are ignored. """ from packaging.utils import canonicalize_name versions: dict[str, str] = {} for line in freeze_text.splitlines(): match = re.match(r"^([A-Za-z0-9_.\-]+)==([\w.\-]+)$", line.strip()) if match: versions[str(canonicalize_name(match.group(1)))] = match.group(2) return versions def extract_uv_conflict(text: str) -> str: """Slice uv's resolver-conflict narrative out of a noisy command log. uv prints unsatisfiable resolutions as a block that starts with a line containing ``No solution found`` followed by ``Because ... we can conclude ...`` lines. The breeze shell wraps every command with an Airflow (re)install, so the conflict is buried in a lot of unrelated build/install output — this returns just the conflict block (with ANSI color codes stripped), or an empty string if no conflict was reported. """ ansi_re = re.compile(r"\x1b\[[0-9;]*m") clean = ansi_re.sub("", text) lines = clean.splitlines() for index, line in enumerate(lines): if "No solution found" in line: return "\n".join(lines[index:]).strip() return "" def find_downgrades( before: dict[str, str], after: dict[str, str], exclude: str ) -> list[tuple[str, str, str]]: """Return ``(name, before_version, after_version)`` for packages that went *down*. ``exclude`` is the canonical name of the package being explained (it is expected to go up, so it is never reported as a downgrade). """ from packaging import version downgrades: list[tuple[str, str, str]] = [] for name, before_version in before.items(): if name == exclude: continue after_version = after.get(name) if after_version is None: continue try: if version.parse(after_version) < version.parse(before_version): downgrades.append((name, before_version, after_version)) except version.InvalidVersion: continue return sorted(downgrades) @contextmanager def preserve_files(*paths: Path): """Restore the given files' contents on exit — ``uv sync`` rewrites ``uv.lock``.""" originals = {path: path.read_text() for path in paths} try: yield finally: for path, content in originals.items(): path.write_text(content) def get_additional_sync_args(airflow_constraints_mode: str) -> list[str]: if airflow_constraints_mode == "constraints-source-providers": # In case of source constraints we also need to add all development dependencies # to reflect exactly what is installed in the CI image by default. The ``ci-image`` # group aggregates dev/docs/docs-gen plus any hard-to-install provider extras # (see root pyproject.toml). return ["--group", "ci-image"] return [] # Marker echoed between ``uv sync`` and ``uv pip freeze`` so the freeze output can be # sliced out of the combined shell log. FREEZE_MARKER = "===BREEZE_RESOLVED_FREEZE===" def sync_and_freeze( *, python_version: str, airflow_constraints_mode: str, github_repository: str | None, title: str, ): """Resolve at --resolution highest and, in the *same* shell, freeze the result. Each ``execute_command_in_shell`` call is a fresh ``docker compose run --rm`` container, so running ``uv pip freeze`` as a separate call would not reliably see the environment the sync just populated. Chaining both in one ``bash -c`` keeps the freeze in the same shell/venv as the sync. ``&&`` ensures the freeze only runs when the sync succeeds and that a sync failure is still reflected in the return code. Returns ``(result, combined_output_text, {canonical_name: version})``. """ sync = shlex.join( [ "uv", "sync", "--all-packages", *get_additional_sync_args(airflow_constraints_mode), "--resolution", "highest", "--refresh", "--python", python_version, ] ) output = Output(title=title, file_name=get_temp_file_name()) result = execute_command_in_shell( ShellParams( github_repository=github_repository, python=python_version, mount_sources=MOUNT_SELECTED, ), project_name="breeze-constraints", command=shlex.join(["bash", "-c", f"{sync} && echo {FREEZE_MARKER} && uv pip freeze"]), output=output, signal_error=False, ) text = Path(output.file_name).read_text() versions = parse_freeze(text.split(FREEZE_MARKER, 1)[1]) if FREEZE_MARKER in text else {} return result, text, versions def resolve_baseline_versions( *, python_version: str, airflow_constraints_mode: str, github_repository: str | None, ) -> tuple[str, dict[str, str]]: """Resolve the unpinned workspace at --resolution highest, once. This is the resolution that actually generates the constraints, so it is the ground truth for "what would the constraints pick". It depends only on the workspace and the command's own arguments — never on which package is being explained — and every explanation restores ``pyproject.toml``/``uv.lock`` before the next one starts. So one resolution is enough for the whole run, and recomputing it per package would repeat an identical several-minute ``uv sync`` dozens of times. """ with preserve_files(AIRFLOW_ROOT_PATH / "pyproject.toml", AIRFLOW_ROOT_PATH / "uv.lock"): _, text, versions = sync_and_freeze( python_version=python_version, airflow_constraints_mode=airflow_constraints_mode, github_repository=github_repository, title="output_baseline", ) return text, versions def explain_package_upgrade( pkg: str, pinned_version: str, latest_version: str, python_version: str, airflow_constraints_mode: str, github_repository: str | None, baseline_text: str, baseline_versions: dict[str, str], ) -> str: explanation = ( f"[bold blue]\n--- Explaining for {pkg} (current: {pinned_version}, latest: {latest_version}) ---[/]" ) with preserve_files(AIRFLOW_ROOT_PATH / "pyproject.toml", AIRFLOW_ROOT_PATH / "uv.lock"): from packaging.utils import canonicalize_name airflow_pyproject = AIRFLOW_ROOT_PATH / "pyproject.toml" canonical_pkg = str(canonicalize_name(pkg)) baseline_version = baseline_versions.get(canonical_pkg) update_pyproject_dependency(airflow_pyproject, pkg, latest_version, python_version) if get_verbose(): syntax = Syntax( airflow_pyproject.read_text(), "toml", theme="monokai", line_numbers=True, word_wrap=False ) explanation += "\n" + str(syntax) after_result, after_text, after_versions = sync_and_freeze( python_version=python_version, airflow_constraints_mode=airflow_constraints_mode, github_repository=github_repository, title="output_after", ) # A zero exit code only proves that *some* valid resolution exists with the pin — not # that --resolution highest would ever select it. Inspect what was actually resolved: # if honouring the pin forced *other* packages to be downgraded, the unpinned highest # resolution (i.e. the constraints) keeps the package at its lower version, so this is # NOT a clean upgrade. resolved_version = after_versions.get(canonical_pkg) downgrades = find_downgrades(baseline_versions, after_versions, exclude=canonical_pkg) if after_result.returncode != 0: # Forcing the package to its latest version produced no valid resolution at all: # a genuine hard conflict. Surface uv's own conflict narrative from the sync log. explanation += ( f"\n[bold red]Package {pkg} CANNOT be upgraded to {latest_version}: " f"uv could not resolve the workspace with {pkg}=={latest_version} pinned " f"(hard conflict).[/]" ) conflict = extract_uv_conflict(after_text) if conflict: explanation += f"\n\n[bold yellow]Conflict as reported by uv:[/]\n{conflict}" elif not baseline_versions or not after_versions: # Without the resolved version lists we cannot tell a clean upgrade apart from one # that only works by downgrading other packages — never silently claim success. explanation += ( f"\n[bold yellow]uv sync succeeded but the resolved package versions could not " f"be read (empty freeze output), so the upgrade of {pkg} to {latest_version} " f"could not be classified.[/]" ) elif baseline_version == latest_version: explanation += ( f"\n[bold green]Package {pkg} already resolves to {latest_version} under " f"--resolution highest. The constraints file appears to be stale.[/]" ) elif resolved_version != latest_version: explanation += ( f"\n[bold yellow]uv sync succeeded but {pkg} still resolved to " f"{resolved_version or 'an unknown version'}, not {latest_version} — " f"the pin did not take effect, so this is not a real upgrade.[/]" ) elif downgrades: explanation += ( f"\n[bold yellow]Package {pkg} can reach {latest_version} only by DOWNGRADING " f"other packages, so --resolution highest keeps it at " f"{baseline_version or pinned_version}. Required downgrades:[/]" ) for name, before_version, after_version in downgrades: explanation += f"\n - {name}: {before_version} -> {after_version}" # Reproduce the conflict explicitly so uv's own resolver narrative is visible. # A fresh `uv pip compile` of just the package at its target version plus the # packages it would otherwise displace (held at their current versions) is a # contradiction, so uv fails and prints exactly why they cannot coexist. Running # it from scratch (rather than against the workspace) keeps the output to the # conflict itself, and we filter to uv's narrative regardless of shell noise. conflict_pins = [f"{pkg}=={latest_version}"] conflict_pins += [f"{name}=={before_version}" for name, before_version, _ in downgrades] printf_cmd = "printf '%s\\n' " + " ".join(shlex.quote(pin) for pin in conflict_pins) probe_output = Output(title="conflict_probe", file_name=get_temp_file_name()) execute_command_in_shell( ShellParams( github_repository=github_repository, python=python_version, mount_sources=MOUNT_SELECTED, ), project_name="breeze-constraints", command=shlex.join( [ "bash", "-c", f"{printf_cmd} | uv pip compile - --python {shlex.quote(python_version)}", ] ), output=probe_output, signal_error=False, ) conflict = extract_uv_conflict(Path(probe_output.file_name).read_text()) explanation += ( f"\n\n[bold yellow]Conflict as reported by uv " f"(uv pip compile {' '.join(conflict_pins)}):[/]\n" ) explanation += conflict or "[dim](uv did not emit a conflict narrative)[/]" else: explanation += ( f"\n[bold green]Package {pkg} can be upgraded from {pinned_version} to " f"{latest_version} without conflicts and without downgrading other packages.[/]" f"\n[dim]If this result is unexpected, run 'uv cache clean' and retry — a stale " f"uv cache can make breeze resolve against an out-of-date environment.[/]" ) if get_verbose(): # Full resolver logs of both phases — only when explicitly requested, since they # are very long (each is a complete uv sync plus freeze). explanation += ( f"\n\n[yellow]--- uv resolver output: phase 1, baseline (no pin) ---[/]\n{baseline_text}" f"\n[yellow]--- uv resolver output: phase 2, with {pkg}=={latest_version} pinned ---[/]" f"\n{after_text}" ) return explanation ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
