This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new f1222224665 [v3-3-test] Make breeze setup recover from a broken legacy
uv tool install (#72059) (#72175)
f1222224665 is described below
commit f122222466591f9dbf0912a69e1223e46c6b6371
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Aug 27 22:56:20 2026 +0200
[v3-3-test] Make breeze setup recover from a broken legacy uv tool install
(#72059) (#72175)
`uv tool list` leaves out a tool whose environment is corrupted, so a
leftover
`uv tool install` of breeze went unnoticed by the legacy-install check even
though it still owned ~/.local/bin/breeze. Setup stopped instead at the
generic
"not the shim managed by this script" message, which says nothing about the
`uv tool uninstall` step that actually resolves it.
Uninstalling such a tool cannot retract its entry point either — uv has
already
lost the metadata recording it — so a dangling symlink survives that
neither -e
nor -f sees, and the following run wrote through the dead link into the tool
directory that was no longer there.
Both halves of the migration to the uvx shim described in ADR 0017 now
complete
without manual cleanup.
(cherry picked from commit d868c8c714448eff8f020b9aa0d56772b0db7021)
Co-authored-by: rjgoyln <[email protected]>
---
dev/breeze/src/airflow_breeze/utils/path_utils.py | 16 +++
dev/breeze/tests/test_shim_version_check.py | 123 ++++++++++++++++++++++
scripts/tools/setup_breeze | 31 +++++-
3 files changed, 169 insertions(+), 1 deletion(-)
diff --git a/dev/breeze/src/airflow_breeze/utils/path_utils.py
b/dev/breeze/src/airflow_breeze/utils/path_utils.py
index 533e741c61e..716d7c2c7cc 100644
--- a/dev/breeze/src/airflow_breeze/utils/path_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/path_utils.py
@@ -260,6 +260,18 @@ def warn_if_shim_outdated(airflow_sources: Path,
shim_text: str | None = None) -
return True
+def has_uv_breeze_tool_dir() -> bool:
+ """Tell whether uv owns a tool directory for breeze."""
+ try:
+ result = subprocess.run(["uv", "tool", "dir"], text=True,
capture_output=True, check=False)
+ except FileNotFoundError:
+ return False
+ if result.returncode != 0:
+ return False
+ tool_dir = result.stdout.strip()
+ return bool(tool_dir) and (Path(tool_dir) /
"apache-airflow-breeze").is_dir()
+
+
def detect_legacy_global_breeze_install() -> str | None:
"""
Detect a legacy global breeze install superseded by the shim (ADR 0017).
@@ -273,6 +285,10 @@ def detect_legacy_global_breeze_install() -> str | None:
return "uv"
except FileNotFoundError:
pass
+ # `uv tool list` drops a tool whose environment is corrupted, so an
install the listing above
+ # cannot see may still be there, owning ~/.local/bin/breeze.
+ if has_uv_breeze_tool_dir():
+ return "uv"
try:
result = subprocess.run(["pipx", "list", "--short"], text=True,
capture_output=True, check=False)
if result.returncode == 0 and "apache-airflow-breeze" in result.stdout:
diff --git a/dev/breeze/tests/test_shim_version_check.py
b/dev/breeze/tests/test_shim_version_check.py
index b027955a1d4..b2c41836cc0 100644
--- a/dev/breeze/tests/test_shim_version_check.py
+++ b/dev/breeze/tests/test_shim_version_check.py
@@ -28,6 +28,7 @@ from airflow_breeze.utils.path_utils import (
_parse_shim_version,
detect_legacy_global_breeze_install,
get_expected_shim_version,
+ has_uv_breeze_tool_dir,
warn_if_breeze_launcher_outdated,
warn_if_shim_outdated,
)
@@ -210,3 +211,125 @@ def
test_launcher_check_silent_without_shim_or_legacy(tmp_path, monkeypatch, cap
with
mock.patch("airflow_breeze.utils.path_utils.detect_legacy_global_breeze_install",
return_value=None):
assert warn_if_breeze_launcher_outdated(sources) is False
assert capsys.readouterr().out == ""
+
+
[email protected](
+ ("tool_dir_stdout", "returncode", "create_tool_dir", "expected"),
+ [
+ pytest.param("{tool_dir}\n", 0, True, True,
id="breeze-tool-dir-present"),
+ pytest.param("{tool_dir}\n", 0, False, False, id="no-breeze-tool-dir"),
+ pytest.param("{tool_dir}\n", 2, True, False, id="uv-tool-dir-failed"),
+ pytest.param("\n", 0, True, False, id="uv-reported-no-tool-dir"),
+ ],
+)
+def test_has_uv_breeze_tool_dir(tmp_path, tool_dir_stdout, returncode,
create_tool_dir, expected):
+ if create_tool_dir:
+ (tmp_path / "apache-airflow-breeze").mkdir()
+ with mock.patch("airflow_breeze.utils.path_utils.subprocess.run") as run:
+ run.return_value =
_run_result(tool_dir_stdout.format(tool_dir=tmp_path), returncode=returncode)
+ assert has_uv_breeze_tool_dir() is expected
+
+
+def test_has_uv_breeze_tool_dir_without_uv():
+ with mock.patch("airflow_breeze.utils.path_utils.subprocess.run",
side_effect=FileNotFoundError):
+ assert has_uv_breeze_tool_dir() is False
+
+
+def test_detect_legacy_global_breeze_install_corrupted_uv_tool(tmp_path):
+ (tmp_path / "apache-airflow-breeze").mkdir()
+
+ def fake_run(cmd, *args, **kwargs):
+ if cmd[:3] == ["uv", "tool", "dir"]:
+ return _run_result(f"{tmp_path}\n")
+ return _run_result("prek v0.3.6\n- prek\n")
+
+ with mock.patch("airflow_breeze.utils.path_utils.subprocess.run",
side_effect=fake_run):
+ assert detect_legacy_global_breeze_install() == "uv"
+
+
+SETUP_BREEZE_SCRIPT = ACTUAL_AIRFLOW_SOURCES / "scripts" / "tools" /
"setup_breeze"
+
+
+def _write_executable(path: Path, body: str) -> None:
+ path.write_text(body)
+ path.chmod(0o755)
+
+
+def _sandbox_env(
+ tmp_path: Path, *, breeze_tool_installed: bool, entry_point_installed:
bool = False
+) -> dict[str, str]:
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ tool_dir = tmp_path / "uv-tools"
+ tool_dir.mkdir()
+ home = tmp_path / "home"
+ home.mkdir()
+ breeze_tool = tool_dir / "apache-airflow-breeze"
+ if breeze_tool_installed:
+ (breeze_tool / "bin").mkdir(parents=True)
+ _write_executable(breeze_tool / "bin" / "breeze", "#!/usr/bin/env
bash\nexit 0\n")
+ if entry_point_installed:
+ shim_path = home / ".local" / "bin" / "breeze"
+ shim_path.parent.mkdir(parents=True)
+ shim_path.symlink_to(breeze_tool / "bin" / "breeze")
+ # Reproduces uv's handling of a tool whose environment is corrupted: the
warning goes to
+ # stderr and the tool is left out of the listing that setup_breeze greps.
+ _write_executable(
+ bin_dir / "uv",
+ "#!/usr/bin/env bash\n"
+ 'if [[ "$1 $2" == "tool list" ]]; then\n'
+ ' echo "warning: Ignoring malformed tool apache-airflow-breeze" >&2\n'
+ ' echo "prek v0.3.6"\n'
+ " exit 0\n"
+ "fi\n"
+ 'if [[ "$1 $2" == "tool dir" ]]; then\n'
+ f' echo "{tool_dir}"\n'
+ " exit 0\n"
+ "fi\n"
+ "exit 0\n",
+ )
+ _write_executable(bin_dir / "pipx", "#!/usr/bin/env bash\nexit 0\n")
+ return {"PATH": f"{bin_dir}:/usr/bin:/bin", "HOME": str(home), "ANSWER":
"y"}
+
+
+def _run_setup_breeze(env: dict[str, str]) -> subprocess.CompletedProcess:
+ return subprocess.run([str(SETUP_BREEZE_SCRIPT)], env=env, text=True,
capture_output=True, check=False)
+
+
[email protected]("entry_point_installed", [True, False])
+def test_setup_breeze_reports_uv_tool_install_missing_from_listing(tmp_path,
entry_point_installed):
+ result = _run_setup_breeze(
+ _sandbox_env(tmp_path, breeze_tool_installed=True,
entry_point_installed=entry_point_installed)
+ )
+ assert result.returncode == 1
+ assert "A legacy global breeze install was detected." in result.stdout
+ assert "uv tool uninstall apache-airflow-breeze" in result.stdout
+
+
+def test_setup_breeze_replaces_entry_point_left_by_uv_tool_uninstall(tmp_path):
+ # An entry point with no tool directory under it is the dangling symlink
uv leaves behind.
+ env = _sandbox_env(tmp_path, breeze_tool_installed=False,
entry_point_installed=True)
+ result = _run_setup_breeze(env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ shim = Path(env["HOME"]) / ".local" / "bin" / "breeze"
+ assert not shim.is_symlink()
+ assert BREEZE_SHIM_MARKER in shim.read_text()
+
+
+def test_setup_breeze_refuses_a_broken_symlink_uv_does_not_own(tmp_path):
+ env = _sandbox_env(tmp_path, breeze_tool_installed=False,
entry_point_installed=True)
+ shim = Path(env["HOME"]) / ".local" / "bin" / "breeze"
+ shim.unlink()
+ shim.symlink_to(tmp_path / "elsewhere" / "breeze")
+ result = _run_setup_breeze(env)
+ assert result.returncode == 1
+ assert "is a broken symlink to" in result.stdout
+ assert shim.is_symlink()
+
+
+def test_setup_breeze_installs_shim_when_uv_owns_no_breeze_tool(tmp_path):
+ env = _sandbox_env(tmp_path, breeze_tool_installed=False)
+ result = _run_setup_breeze(env)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "A legacy global breeze install was detected." not in result.stdout
+ assert BREEZE_SHIM_MARKER in (Path(env["HOME"]) / ".local" / "bin" /
"breeze").read_text()
diff --git a/scripts/tools/setup_breeze b/scripts/tools/setup_breeze
index 8ea3a8f2ab2..a89f1257251 100755
--- a/scripts/tools/setup_breeze
+++ b/scripts/tools/setup_breeze
@@ -125,9 +125,18 @@ function fail_on_legacy_global_install() {
# and our shim want to live at ~/.local/bin/breeze. Refuse to proceed until
# the user removes the legacy install — silent overwrite would corrupt uv's
# tool state and confuse later upgrades.
- local legacy_uv=0 legacy_pipx=0
+ local legacy_uv=0 legacy_pipx=0 uv_tool_dir=""
if uv tool list 2>/dev/null | grep -q '^apache-airflow-breeze\b'; then
legacy_uv=1
+ else
+ # A tool whose environment is corrupted is dropped from `uv tool list`
output entirely
+ # (uv only mentions it in a stderr warning), while its directory and
the breeze entry
+ # point it owns both survive. Ask uv where its tools live so such an
install is still
+ # reported here, instead of surfacing later as an unexplained refusal
to install.
+ uv_tool_dir=$(uv tool dir 2>/dev/null) || uv_tool_dir=""
+ if [[ -n "${uv_tool_dir}" && -d "${uv_tool_dir}/apache-airflow-breeze"
]]; then
+ legacy_uv=1
+ fi
fi
if command -v pipx >/dev/null 2>&1 && pipx list --short 2>/dev/null | grep
-q '^apache-airflow-breeze\b'; then
legacy_pipx=1
@@ -167,6 +176,26 @@ function check_shim_dir_on_path() {
function install_breeze_shim() {
mkdir -p "${SHIM_DIR}"
+ # A dangling symlink is invisible to the checks below (-e and -f both
follow symlinks) and
+ # would send our write to the link's target instead of ${SHIM_PATH}.
+ if [[ -L "${SHIM_PATH}" && ! -e "${SHIM_PATH}" ]]; then
+ local link_target uv_tool_dir=""
+ link_target=$(readlink "${SHIM_PATH}")
+ uv_tool_dir=$(uv tool dir 2>/dev/null) || uv_tool_dir=""
+ if [[ -n "${uv_tool_dir}" && "${link_target}" == "${uv_tool_dir}/"*
]]; then
+ # `uv tool uninstall` cannot retract an entry point once it has
lost the tool
+ # metadata recording it, so a corrupted install leaves this behind
for us to reclaim.
+ rm -f "${SHIM_PATH}"
+ echo "${COLOR_YELLOW}Removed the dangling ${SHIM_PATH} left behind
by uv.${COLOR_RESET}"
+ else
+ echo
+ echo "${COLOR_RED}${SHIM_PATH} is a broken symlink to
${link_target}.${COLOR_RESET}"
+ echo "${COLOR_YELLOW}Inspect it; if it is safe to replace, remove
it and re-run this script.${COLOR_RESET}"
+ echo
+ exit 1
+ fi
+ fi
+
# If something exists at SHIM_PATH that we did not write, do not overwrite
it
# silently — it could be the user's own script.
if [[ -e "${SHIM_PATH}" ]] && ! grep -qF "${SHIM_MARKER}" "${SHIM_PATH}";
then