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 d868c8c7144 Make breeze setup recover from a broken legacy uv tool 
install (#72059)
d868c8c7144 is described below

commit d868c8c714448eff8f020b9aa0d56772b0db7021
Author: rjgoyln <[email protected]>
AuthorDate: Fri Aug 28 04:32:28 2026 +0800

    Make breeze setup recover from a broken legacy uv tool install (#72059)
    
    `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.
---
 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 45742b43a97..5b63b90748b 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

Reply via email to