From 595e15cc3fe45a17519141c7c2f9536001a1c472 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 16 Sep 2026 20:11:27 +0100 Subject: [PATCH] fix(update): detect a half-replaced install instead of reporting its version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package manager replaces files in place, so an interrupted upgrade leaves a venv whose metadata reads perfectly and whose imports are dead. `gflow update` only ever re-read the version, so it called that state "still 0.69.0" and sent the user to a plain `uv tool upgrade` — which cannot repair it: it reads the same intact metadata, finds it current and changes nothing. Measured on Windows (#848): an aborted native-dependency swap left every command dying on `AttributeError: module 'greenlet' has no attribute 'greenlet'`, and only `uv tool install "gflow-cli==" --force --reinstall` completed. After the manager runs, import gflow-cli in a fresh isolated interpreter before looking at any version. `-I` so a `gflow_cli` directory in the caller's CWD cannot answer for the venv; a probe that could not run at all is never reported as breakage. Refs #848 --- CHANGELOG.md | 19 ++++++++ docs/USAGE.md | 17 +++++++- src/gflow_cli/update_check.py | 66 ++++++++++++++++++++++++++++ tests/test_update_check.py | 81 +++++++++++++++++++++++++++++++++++ website/docs/USAGE.md | 17 +++++++- 5 files changed, 198 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2563b3d0c..24c735ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`gflow update` could report a version for an install that no longer starts.** A package + manager replaces files in place, so an interrupted upgrade leaves a venv whose *metadata* + reads perfectly and whose *imports* are dead. `gflow update` only ever re-read the version, + so it called that state *"still 0.69.0"* and sent the user to a plain `uv tool upgrade` — + which cannot repair it, because it reads the same intact metadata, finds it current and + changes nothing. Measured on Windows: an aborted native-dependency swap left every command + dying on `AttributeError: module 'greenlet' has no attribute 'greenlet'`, and only + `uv tool install "gflow-cli==" --force --reinstall` completed. + + After the manager runs, `gflow update` now imports gflow-cli in a fresh isolated + interpreter *before* it looks at any version, and a failed import is reported as an + unusable install (exit 11) quoting that interpreter's own last line, with the forced + reinstall as the remediation. The probe is isolated (`-I`) so a `gflow_cli` directory in + the caller's working directory cannot answer on the venv's behalf, and a probe that could + not run at all is never reported as breakage. + ([#848](https://github.com/ffroliva/gflow-cli/issues/848)) + ## [0.77.0] — 2026-09-16 ### Fixed diff --git a/docs/USAGE.md b/docs/USAGE.md index 6d740cf6a..c41f2569d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1488,12 +1488,27 @@ be reached; after an upgrade `latest` is the version the venv actually reports; installed (`git pull`, reinstall from the checkout); `uv` / `pipx` detected but not on `PATH`, or a plain venv with no `pip` module (a `uv venv`): the message carries the exact command to run yourself; -- **after the manager ran** — the venv still reports the old version (a receipt +- **after the manager ran** — first, the venv can no longer import gflow-cli at + all (see below); then, the venv still reports the old version (a receipt pinned to one version makes `uv tool upgrade` a silent no-op, for instance), or the version could not be re-read at all. The manager's own output is above the error. One carve-out: when PyPI was unreachable *and* the manager exited 0 *and* nothing changed, the manager simply found nothing newer — exit 0. +**A half-replaced environment is detected, not reported as a version.** Managers +replace files in place, so an interrupted run can leave a venv whose *metadata* +reads perfectly and whose *imports* are dead — measured on Windows in +[#848](https://github.com/ffroliva/gflow-cli/issues/848), where an aborted native +dependency swap left every command dying on `AttributeError: module 'greenlet' +has no attribute 'greenlet'` while the version still read `0.69.0`. After the +manager runs, `gflow update` therefore imports gflow-cli in a fresh isolated +interpreter before it looks at any version. If that import fails the command +exits 11 saying the install is unusable and quoting the interpreter's own last +line, and the remediation is a **forced reinstall** +(`uv tool install "gflow-cli==" --force --reinstall`, or the pipx / pip +equivalent) — a plain `upgrade` cannot repair that state, because it reads the +metadata, finds it current and changes nothing. + The venv is the truth, not the manager's exit code: after the manager runs, `gflow update` re-reads the installed gflow-cli version from a fresh interpreter and reports *that*. **Windows:** the `gflow.exe` launcher you are running holds diff --git a/src/gflow_cli/update_check.py b/src/gflow_cli/update_check.py index 4f1688bd3..9657ac9ae 100644 --- a/src/gflow_cli/update_check.py +++ b/src/gflow_cli/update_check.py @@ -244,6 +244,58 @@ def _version_in_venv(dist: str) -> str | None: return result.stdout.strip() or None +_IMPORT_PROBE = "import gflow_cli.cli" + + +def _import_failure() -> str | None: + """Why a fresh interpreter can no longer import gflow-cli, or None if it can. + + ``_version_in_venv`` cannot see a half-replaced environment: it reads + ``*.dist-info`` and never touches the modules the next command imports. + #848 measured exactly that gap — an interrupted native-dependency + replacement left every command dying on ``AttributeError: module + 'greenlet' has no attribute 'greenlet'`` while the reported version stayed + perfectly readable, so `gflow update` called it "still 0.69.0" and sent the + user to a plain `uv tool upgrade` that could not repair it. + + ``-I`` so a ``gflow_cli`` directory in the user's CWD — or on PYTHONPATH — + cannot answer on the venv's behalf. A probe that could not run at all + returns None: an environment we failed to ask about must never be reported + as a broken one. + """ + try: + result = subprocess.run( # noqa: S603 — fixed argv + [sys.executable, "-I", "-c", _IMPORT_PROBE], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode == 0: + return None + lines = [line for line in result.stderr.strip().splitlines() if line.strip()] + return lines[-1] if lines else f"exit code {result.returncode}" + + +def _reinstall_hint(inst: Installer, version: str | None) -> str: + """The command that repairs a half-replaced environment. + + A plain ``upgrade`` does not: it reads the metadata, finds it current and + exits 0 having changed nothing — measured in #848, where only a forced + reinstall completed.""" + pin = f"gflow-cli=={version}" if version else "gflow-cli" + repair = { + "uv": f'uv tool install "{pin}" --force --reinstall', + "pipx": f"pipx install --force {pin}", + }.get(inst.name, f"{sys.executable} -m pip install --force-reinstall {pin}") + return ( + "Close every running gflow process (including any `gflow serve` / MCP server), " + f"then run `{repair}`." + ) + + def run_update(*, check: bool) -> UpdateReport: """Upgrade gflow-cli in place, or with ``check`` only report. @@ -305,6 +357,20 @@ def run_update(*, check: bool) -> UpdateReport: # stale launcher keeps working: it only points at the venv's python. after = _version_in_venv("gflow-cli") log.info("update.command_finished", installer=inst.name, returncode=returncode, after=after) + # Before any version arithmetic: is the environment still usable at all? + # The manager replaces files in place, so an interrupted run leaves a venv + # whose metadata reads fine and whose imports do not (#848). That state is + # worse than not upgrading, and the version-based branches below cannot + # see it — they would report "still " for an install that no + # longer starts, and recommend a command that cannot repair it. + broken = _import_failure() + if broken is not None: + log.warning("update.environment_broken", installer=inst.name, returncode=returncode) + raise ConfigurationError( + f"`{shown}` left this gflow-cli install unusable: a fresh interpreter can no " + f"longer import it ({broken})", + remediation_hint=_reinstall_hint(inst, latest or after), + ) if after is None: raise ConfigurationError( f"`{shown}` exited {returncode}, and the installed version could not be re-read " diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 7d8bdde50..ee3ec4776 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -331,6 +331,8 @@ def _run(command: tuple[str, ...]) -> int: # The venv after the manager ran: gflow-cli moved to 999.0.0, Playwright stable. versions = {"gflow-cli": "999.0.0", "playwright": "1.59.0"} monkeypatch.setattr(uc, "_version_in_venv", lambda dist: versions[dist]) + # Healthy by default, and stubbed so no test spawns the real probe interpreter. + monkeypatch.setattr(uc, "_import_failure", lambda: None) return calls @@ -414,6 +416,39 @@ def test_manager_exit_0_but_version_unchanged_is_configuration_error( with pytest.raises(ConfigurationError, match=f"still {__version__}"): uc.run_update(check=False) + def test_broken_environment_is_reported_even_when_the_version_moved( + self, uv_install: list[tuple[str, ...]], monkeypatch: pytest.MonkeyPatch + ) -> None: + """#848: metadata says 999.0.0, imports say otherwise. The version moved, + so every existing branch would have called this a successful upgrade.""" + from gflow_cli.errors import ConfigurationError + + monkeypatch.setattr(uc, "fetch_latest", lambda timeout: "999.0.0") + monkeypatch.setattr( + uc, + "_import_failure", + lambda: "AttributeError: module 'greenlet' has no attribute 'greenlet'", + ) + with pytest.raises(ConfigurationError, match="unusable") as excinfo: + uc.run_update(check=False) + assert "greenlet" in str(excinfo.value) + # The repair command, not the `upgrade` that cannot fix a half-replaced venv. + assert "--force --reinstall" in (excinfo.value.remediation_hint or "") + + def test_broken_environment_outranks_the_unchanged_version_message( + self, uv_install: list[tuple[str, ...]], monkeypatch: pytest.MonkeyPatch + ) -> None: + """The #848 shape exactly: manager failed, version unchanged, venv dead. + "still " plus "run the upgrade yourself" was the wrong advice.""" + from gflow_cli.errors import ConfigurationError + + monkeypatch.setattr(uc, "fetch_latest", lambda timeout: "999.0.0") + monkeypatch.setattr(uc, "_run_command", lambda cmd: 2) + monkeypatch.setattr(uc, "_version_in_venv", lambda dist: __version__) + monkeypatch.setattr(uc, "_import_failure", lambda: "ImportError: DLL load failed") + with pytest.raises(ConfigurationError, match="unusable"): + uc.run_update(check=False) + def test_manager_failure_after_install_is_upgraded_with_note( self, uv_install: list[tuple[str, ...]], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -520,3 +555,49 @@ def test_probe_failure_after_run_is_reported_as_unverified( monkeypatch.setattr(uc, "_version_in_venv", lambda dist: None) with pytest.raises(ConfigurationError, match="could not be re-read"): uc.run_update(check=False) + + +class TestImportFailure: + """The probe itself — a real subprocess, because a stubbed one proves nothing + about whether `-I` can still see the venv it is asked about.""" + + def test_healthy_environment_probes_clean(self) -> None: + assert uc._import_failure() is None + + def test_broken_import_returns_the_last_stderr_line( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(uc, "_IMPORT_PROBE", "import gflow_cli_definitely_not_installed") + reason = uc._import_failure() + assert reason is not None + assert "ModuleNotFoundError" in reason + + def test_unrunnable_probe_is_not_a_broken_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An environment we could not ask about is never reported as broken.""" + + def _boom(*args: object, **kwargs: object) -> object: + raise OSError("no fork for you") + + monkeypatch.setattr(uc.subprocess, "run", _boom) + assert uc._import_failure() is None + + +class TestReinstallHint: + @pytest.mark.parametrize( + ("name", "expected"), + [ + ("uv", 'uv tool install "gflow-cli==9.9.9" --force --reinstall'), + ("pipx", "pipx install --force gflow-cli==9.9.9"), + ("pip", "-m pip install --force-reinstall gflow-cli==9.9.9"), + ], + ) + def test_repair_command_per_installer(self, name: str, expected: str) -> None: + hint = uc._reinstall_hint(uc.Installer(name, ("x",)), "9.9.9") + assert expected in hint + assert "Close every running gflow process" in hint + + def test_unknown_version_falls_back_to_unpinned(self) -> None: + hint = uc._reinstall_hint(uc.Installer("uv", ("x",)), None) + assert '"gflow-cli" --force --reinstall' in hint diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 4c3b8e14e..6bafd7809 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -1488,12 +1488,27 @@ be reached; after an upgrade `latest` is the version the venv actually reports; installed (`git pull`, reinstall from the checkout); `uv` / `pipx` detected but not on `PATH`, or a plain venv with no `pip` module (a `uv venv`): the message carries the exact command to run yourself; -- **after the manager ran** — the venv still reports the old version (a receipt +- **after the manager ran** — first, the venv can no longer import gflow-cli at + all (see below); then, the venv still reports the old version (a receipt pinned to one version makes `uv tool upgrade` a silent no-op, for instance), or the version could not be re-read at all. The manager's own output is above the error. One carve-out: when PyPI was unreachable *and* the manager exited 0 *and* nothing changed, the manager simply found nothing newer — exit 0. +**A half-replaced environment is detected, not reported as a version.** Managers +replace files in place, so an interrupted run can leave a venv whose *metadata* +reads perfectly and whose *imports* are dead — measured on Windows in +[#848](https://github.com/ffroliva/gflow-cli/issues/848), where an aborted native +dependency swap left every command dying on `AttributeError: module 'greenlet' +has no attribute 'greenlet'` while the version still read `0.69.0`. After the +manager runs, `gflow update` therefore imports gflow-cli in a fresh isolated +interpreter before it looks at any version. If that import fails the command +exits 11 saying the install is unusable and quoting the interpreter's own last +line, and the remediation is a **forced reinstall** +(`uv tool install "gflow-cli==" --force --reinstall`, or the pipx / pip +equivalent) — a plain `upgrade` cannot repair that state, because it reads the +metadata, finds it current and changes nothing. + The venv is the truth, not the manager's exit code: after the manager runs, `gflow update` re-reads the installed gflow-cli version from a fresh interpreter and reports *that*. **Windows:** the `gflow.exe` launcher you are running holds