This is an automated email from the ASF dual-hosted git repository.
eladkal 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 2d8b40f3ae4 Surface conflict message for constraints-version-check
(#68671)
2d8b40f3ae4 is described below
commit 2d8b40f3ae4abbf1874d1850ff1c9479fc7a4999
Author: Elad Kalif <[email protected]>
AuthorDate: Mon Jun 22 17:46:52 2026 +0300
Surface conflict message for constraints-version-check (#68671)
* Surface conflict message for constraints-version-check
---
.../utils/constraints_version_check.py | 227 +++++++++++++++++----
1 file changed, 192 insertions(+), 35 deletions(-)
diff --git a/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
b/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
index ced0564d855..16e0cdb51a4 100755
--- a/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
+++ b/dev/breeze/src/airflow_breeze/utils/constraints_version_check.py
@@ -570,6 +570,65 @@ def print_package_table_row(
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)
+
+
def explain_package_upgrade(
pkg: str,
pinned_version: str,
@@ -601,16 +660,31 @@ def explain_package_upgrade(
preserve_pyproject_file(AIRFLOW_ROOT_PATH / "pyproject.toml") as
airflow_pyproject,
preserve_pyproject_file(AIRFLOW_ROOT_PATH / "uv.lock"),
):
+ from packaging.utils import canonicalize_name
+
+ canonical_pkg = str(canonicalize_name(pkg))
+
shell_params = ShellParams(
github_repository=github_repository,
python=python_version,
mount_sources=MOUNT_SELECTED,
)
- output_before = Output(title="output_before",
file_name=get_temp_file_name())
- execute_command_in_shell(
- shell_params,
- project_name="breeze-constraints",
- command=shlex.join(
+
+ # 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(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",
@@ -622,40 +696,123 @@ def explain_package_upgrade(
"--python",
python_version,
]
- ),
- output=output_before,
- signal_error=False,
- )
+ )
+ output = Output(title=title, file_name=get_temp_file_name())
+ result = execute_command_in_shell(
+ shell_params,
+ 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
+
+ # Baseline: resolve the workspace at --resolution highest *without*
any pin and
+ # record what version that resolution naturally selects for the
package. This is
+ # the resolution that actually generates the constraints, so it is the
ground truth
+ # for "what would the constraints pick".
+ _, before_text, before_versions = sync_and_freeze("output_before")
+ baseline_version = before_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)
- output_after = Output(title="output_after",
file_name=get_temp_file_name())
- after_result = execute_command_in_shell(
- shell_params,
- project_name="breeze-constraints",
- command=shlex.join(
- [
- "uv",
- "sync",
- "--all-packages",
- "--resolution",
- "highest",
- "--refresh",
- "--python",
- python_version,
- ],
- ),
- output=output_after,
- signal_error=False,
- )
- if after_result.returncode == 0:
- explanation += f"\n[bold yellow]Package {pkg} can be upgraded from
{pinned_version} to {latest_version} without conflicts.[/]."
- if airflow_constraints_mode == "constraints-source-providers":
- explanation += Path(output_after.file_name).read_text()
- if after_result.returncode != 0 or get_verbose():
- explanation += f"\n[yellow]uv sync output for
{pkg}=={latest_version}:[/]\n"
- explanation += Path(output_after.file_name).read_text()
+ after_result, after_text, after_versions =
sync_and_freeze("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(before_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 before_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(
+ shell_params,
+ 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{before_text}"
+ f"\n[yellow]--- uv resolver output: phase 2, with
{pkg}=={latest_version} pinned ---[/]"
+ f"\n{after_text}"
+ )
return explanation