Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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==<version>" --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
Expand Down
17 changes: 16 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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==<version>" --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
Expand Down
66 changes: 66 additions & 0 deletions src/gflow_cli/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <version>" 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 "
Expand Down
81 changes: 81 additions & 0 deletions tests/test_update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 <version>" 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:
Expand Down Expand Up @@ -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
17 changes: 16 additions & 1 deletion website/docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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==<version>" --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
Expand Down