diff --git a/CHANGELOG.md b/CHANGELOG.md index 9183c13..3bcbf19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added +- **`proofpath update`** installs the latest release from PyPI, and + **`proofpath update --check`** only says whether there is one. In the TUI they are + `/update` and `/update --check`. Running the command is the consent, so nothing + asks. uv tool, pipx and pip installs are each updated with their own tool; pip + falls back to `uv pip` when it fails and uv is on PATH. An editable install is + never touched: the command names its source directory and says to update it with + `git pull` and `uv sync`. +- **The installed extras are kept.** They are written into the install spec + (`proofpath[browser]`), never with a version pin. A plain `uv tool upgrade` was + measured to drop packages added to the tool's environment after it was installed, + which would have removed the browser wheels you had consented to. +- **The new version is read back from a fresh process.** An update the resolver held + back below the latest version is a failure that names the version it installed. + On Windows a failed command adds "close proofpath and run the command above", + since a running `proofpath.exe` can be locked. +- **Temporary environments are left alone.** In a `uvx` or `pipx run` environment + nothing runs. The command says that `uvx proofpath@latest` or + `pipx run --no-cache proofpath` already fetches the newest. +- **Commands paste back on every platform.** Each command is logged as `$ `; the + one printed for running by hand has no `$`. On Windows both use `cmd.exe` quoting, + and so does the browser install's log. + The version check after the update runs the interpreter isolated (`-I`), so a + checkout in the working directory cannot answer for the install. +- **In the TUI, an update and a run never overlap.** A bare `/update` is refused while + a run or a mirrored verb is still going, because the install replaces the code they + are executing; `/update --check` is not refused. While an update is installing, + every `/check` and mirrored verb is refused until it finishes, including a second + `/update`. +- Exit codes: `0` for up to date, updated, ahead of PyPI, editable, temporary, or + (with `--check`) an update available. `1` means the check or the update failed. + `permissions.network` is read by the fetch ladder's own rule: `deny`, and `ask` + without a terminal, send nothing to PyPI and fail rather than report "up to date". + `ask` in a terminal goes ahead with a note. + +### Changed +- **At 80x24 the `/` list now caps.** `/update` makes eleven verbs and 80x24 has + room for ten rows. The last two, `/quit` and `/update`, sit behind `... 2 more`. + `update` is last on purpose: the banner's hint line already names `/quit`, and + `/help` stays visible. One more row, at 80x25, shows them all. +- `packaging` is a declared dependency. It was already installed through other + packages; `update` now imports it directly. + ## [0.4.10] - 2026-10-01 An opt-in, larger NLI model for scientific sources, chosen by a measured bake-off and diff --git a/README.md b/README.md index 4e6ad21..2b522e1 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,18 @@ Both themes open with the wordmark: `rich` in block glyphs through five crimson `plain` in ASCII. Under it, or beside it on a wide terminal, the version and the hint; a rule closes the banner. +**Updating.** `proofpath update` installs the latest release from PyPI, and +`proofpath update --check` only says whether there is one (`/update` and +`/update --check` in the TUI). It updates uv tool, pipx and pip installs with their +own tool and keeps the extras you have installed, such as `browser`, by naming them +in the install spec. An editable checkout is never touched: update it with `git pull` +and `uv sync`. A temporary `uvx` or `pipx run` environment is not updated either: +`uvx proofpath@latest` or `pipx run --no-cache proofpath` already fetches the newest. +Known limits: `uv tool install --upgrade` does not keep the options stored in uv's +tool receipt, such as a pinned `--python`, and it drops packages added with `--with`. +`pipx install --force` drops packages added with `pipx inject`, the `pip_args` the +install was made with, and a custom `--python`. Reinstall those by hand afterwards. + Bare `proofpath` opens the terminal UI ([a recorded session in both themes](docs/eval/2026-09-15-tui-v2-live.md), with SVG screenshots of a real run on v0.4.1, before the wordmark, in [`rich`](docs/eval/tui-raven-rich.svg) and [`plain`](docs/eval/tui-raven-plain.svg)). Each run is one panel in its own @@ -49,7 +61,7 @@ coverage; the docked footer draws it as a bar and never scrolls away. Paste a path, a URL or a claim and it runs — a post of several lines pastes whole: the bar holds it behind a one-line summary, `Enter` checks it, `Esc` drops it. Every -one-shot verb is a slash command (`/check`, `/resolve`, `/fetch`, `/config`, `/cache`), +one-shot verb is a slash command (`/check`, `/resolve`, `/fetch`, `/config`, `/cache`, `/update`), runs can be started while others are in flight and stopped with `/cancel #n` — a stopped run keeps what it had decided. Type `/` and every command is listed above the bar with what it wants; `Tab` completes, `↑`/`↓` pick. `/config` opens the settings as diff --git a/docs/superpowers/OPEN-ITEMS.md b/docs/superpowers/OPEN-ITEMS.md index 4cb3975..299764d 100644 --- a/docs/superpowers/OPEN-ITEMS.md +++ b/docs/superpowers/OPEN-ITEMS.md @@ -610,3 +610,19 @@ five packages. The user's decisions: | 20.10 | Eval honesty | add a `--judge` flag to `scripts/eval_averitec.py`; do one run with the browser allowed; show the 0.404 search score in the README "Measured" table | | 20.11 | NLI bake-off result | **Winner: `large-fever` (MoritzLaurer DeBERTa-v3-large mnli-fever-anli-ling-wanli) × k=2 × `max`.** SciFact dev macro-F1 0.697, against 0.580 for the `base` × k=1 × `max` baseline and 0.608 for the best `base` row. Accuracy 0.697. AVeriTeC 3-way 0.438, or 0.475 on the readable subset, against a 0.708 majority baseline. Cost: 643 MB and 54 ms/pair, against 244 MB and 19 ms/pair. Full report: `docs/eval/2026-10-01-nli-bakeoff.md`. Next: (a) and (b) are settled by the accurate-NLI design (`docs/superpowers/specs/2026-10-01-accurate-nli-design.md`), which adds the winner as an opt-in "accurate" profile behind a consent download (spec §7.1); (c) stays open. (a) **settled** — rule-decided rows are left out of the tier walk (the numeric layer fired 4–6 times on train with 0 correct and pinned `high` at 1.000000); the accurate cuts are `high` 0.999142. (b) **settled** — the `medium` cut still sits on `decide`, and the report now says so itself (`NO_LOW_TIER`, default runs included). (c) **still open** — on AVeriTeC the winner is below `base` × k=2 (0.475 against 0.537), and `Supported` is still 2/19; the README recommends the profile for scientific sources, not for news | | 20.12 | Accurate-NLI follow-ups | (1) A shared `browser.read_terminal_answer` helper: `model_gate.ask_model_terminal` repeats `browser.ask_terminal`'s prompt loop and imports the private `_CHOICE_ANSWERS` to do it. (2) The `-q` `nli` line carries the fallback reason but not the advice. (3) Ctrl-C and `/stop` do not interrupt a running 643 MB download. (4) An "installed" but corrupt accurate model fails the run instead of falling back. (5) A multilingual NLI model is still deferred (out of scope in the accurate-NLI design); non-English claims still need the judge to translate | + +## 21. `proofpath update` — 2026-10-05 + +The user asked for an update command like `claude update`. They picked the recommended +option on three of the four questions. On the fourth they chose "every install method" +over "uv tool and pipx only". Design: `docs/superpowers/specs/2026-10-05-update-command-design.md`. + +- **Where:** CLI `proofpath update [--check]` and TUI `/update`, through one + `proofpath.commands` function. No check runs at startup. +- **Which installs:** uv tool, pipx and pip are updated. An editable install is never + touched; it is told how to update itself. +- **Extras:** the extras that are installed go into the install spec + (`proofpath[browser]`). Measured the same day: `uv tool upgrade` removes packages added + to the tool environment with pip, and the browser extra is installed that way on + consent. +- **Behaviour:** running the command updates straight away; `--check` only reports. diff --git a/docs/superpowers/specs/2026-10-05-update-command-design.md b/docs/superpowers/specs/2026-10-05-update-command-design.md new file mode 100644 index 0000000..c95b9a4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-update-command-design.md @@ -0,0 +1,144 @@ +# `proofpath update` — design (2026-10-05) + +The user asked for an update command like `claude update`. This spec records what they +decided and how it works. OPEN-ITEMS §21 points here. + +## 1. Decisions (user, 2026-10-05) + +| # | Question | Decision | +|---|---|---| +| 1 | Where | `proofpath update [--check]` in the CLI and `/update` in the TUI. Both call one function in `proofpath.commands`. There is no update check at startup. | +| 2 | Which installs | uv tool, pipx and pip installs are all updated. An editable (development) install is never touched: the command says how to update it. | +| 3 | Extras | The extras that are installed are written into the install spec (`proofpath[browser]`). That way the tool's own record keeps them, and a later plain `uv tool upgrade` keeps them too. | +| 4 | Behaviour | Running the command is the consent: it updates straight away. `--check` only reports. | + +### Why extras need handling (measured 2026-10-05) + +On consent, the browser extra is installed with `pip install` into the running +environment (`browser.install_commands`). A spike in an isolated `UV_TOOL_DIR` ran +three steps: it installed a tool, added `six` to its environment with +`uv pip install --python`, then ran `uv tool upgrade`. **`six` was gone after the +upgrade.** A plain `uv tool upgrade proofpath` would therefore remove the browser +wheels the user had consented to. The next blocked page would ask, and download, all +over again. The browser binary is not affected: it lives in the `ms-playwright` cache, +outside the environment. Neither is the accurate NLI model, which sits in the cache +directory. + +## 2. Behaviour + +1. **Current version.** `proofpath.__version__`. +2. **Latest version.** `GET https://pypi.org/pypi/proofpath/json` with `httpx` and a + 10 s timeout, reading `info.version`. The request carries no contact address and no + key. `permissions.network` is decided by `fetch.network_permission`, the fetch + ladder's own rule, not a copy of it. `deny` sends nothing, and neither does `ask` + without a terminal (product rule 4): the command says why. `ask` with a terminal + goes ahead, and a note in the result says so. The CLI passes `is_interactive()`; + the TUI passes `True`. Running the command is consent to *install* (decision 4), + but it is not an answer to `ask` where no terminal could have been asked. A refused + or failed request fails rather than reporting "up to date": absence of evidence is + not evidence (product rule 2). +3. **Comparison.** `packaging.version.Version`. Add `packaging` to the declared + dependencies: it is already installed through other packages, but the code imports + it directly. There are three outcomes: + - up to date + - an update is available + - this build is newer than PyPI's (a development build). In that case nothing is run. +4. **Install detection**, in this order: + 1. *editable*: the distribution's `direct_url.json` has `dir_info.editable: true` + 2. *ephemeral*: a temporary environment, judged from `sys.prefix` alone. A `uvx` / + `uv tool run` environment lives in uv's cache, so an `archive-v0` path component + means uv. `pipx install` keeps every venv in `/venvs/`, and + `pipx run` keeps its venvs in pipx's cache instead. That cache is + `~/Library/Caches/pipx/` on macOS and `...\AppData\Local\pipx\pipx\Cache\` + on Windows; with `PIPX_HOME` set it is `$PIPX_HOME/.cache/`. Both kinds + write `pipx_metadata.json`. So a prefix with `pipx_metadata.json` whose parent + directory is not named `venvs` is a `pipx run` venv. This check comes before + *pipx*. + 3. *uv tool*: `Path(sys.prefix) / "uv-receipt.toml"` exists + 4. *pipx*: `Path(sys.prefix) / "pipx_metadata.json"` exists + 5. *pip*: anything else, run as `sys.executable` +5. **Extras detection.** + - `browser`: `scrapling` and `patchright` are both importable (`find_spec`). + - `gpu`: `torch` and `sentence_transformers` are both importable. + - For a uv tool install, the extras already listed for `proofpath` in the receipt's + `requirements` are added to these (their union). + - `dev` is never carried over. +6. **The command.** The spec is `proofpath[a,b]`, or `proofpath` with no extras. It is + **never version-pinned**: a pin would freeze the uv receipt or pipx metadata, and a + later upgrade would not move. + - uv tool: `uv tool install --upgrade `. Needs `uv` on PATH. + - pipx: `pipx install --force `. Needs `pipx` on PATH. + - pip: ` -m pip install --upgrade `. When that fails and `uv` + is on PATH, it falls back to `uv pip install --python --upgrade `, + the same fallback `browser.install` uses. + - editable: nothing runs. The message names the source directory and says to update + it with `git pull` and `uv sync`. + - ephemeral: nothing runs. Updating a cached environment in place would be undone + when the cache is pruned. The message says this is a temporary environment and + that `uvx proofpath@latest` (uv) or `pipx run --no-cache proofpath` (pipx) already + fetches the newest. `pipx run` reuses its cached venv for days unless told not to, + hence `--no-cache`. + - A required tool that is missing is a failure. The message prints the command to + run by hand. +7. **Running it.** The runner is injected, as in `browser.install`. Output is captured, + never inherited: the TUI owns the terminal. Each command that runs is logged as + `$ `. The command meant for pasting has no `$`: what `--check` would run, or + what to run by hand after a failure. Both are joined by `shell.display_command`: + `subprocess.list2cmdline` on Windows, `shlex.join` elsewhere. `shlex.join` would + wrap `proofpath[gpu]` in single quotes, and `cmd.exe` keeps them. `shell` is a + leaf module that `browser.install` logs through as well, so the browser install's + log pastes on Windows too. +8. **Verification.** After a zero exit, the installed version is read in a fresh + process: + - uv tool and pipx: ` -I -c "import importlib.metadata as m; print(m.version('proofpath'))"` + - pip: `sys.executable`, with the same `-I` + + `-I` runs the interpreter isolated: no `PYTHON*` variables, no working directory on + `sys.path`. A proofpath checkout the user is standing in cannot answer for the + install. + + The code imported in the running process is still the old version and cannot answer + this. If the new version is not the latest (for example, the resolver held it back), + the result is a failure that says which version was installed. +9. **Windows.** A running `proofpath.exe` can be locked while it is being replaced. A + failed command on Windows adds this line: "close proofpath and run the command + above". CI cannot test this live, so a unit test covers the message. +10. **Exit codes.** `0` means up to date, updated, or (with `--check`) an update is + available. It also means ahead of PyPI, editable, and ephemeral: in those three + cases nothing went wrong, and there was nothing for this command to run. `1` means + the check or the update failed. Usage errors keep typer's `2`. +11. **TUI.** `/update` and `/update --check` are mirrored verbs: in `MIRRORED` and + `VERBS`, and in the `/help` list. They run on the mirror worker like `/fetch`. + After a successful update the block says "restart proofpath to use vX". + Nothing prompts. A bare `/update` is refused while anything is still running: + an unfinished run, or a mirrored verb whose worker is still out. Its install + replaces the venv under the code those are executing, so the block says so in one + line. `/update --check` installs nothing and is never refused. The guard works + the other way too. While a bare `/update` is installing, `/check` (implicit or + typed) and every mirrored verb are refused with "an update is installing; wait + for it to finish". That includes a second `/update` and `/update --check`. + `update` is last + in `VERBS`: at 80x24 the `/` list holds ten of the eleven rows. The cap then hides + `/quit`, which the banner's hint line names anyway, and `/update`. It never + hides `/help`. + +## 3. Tests (no network) + +- PyPI responses come from `respx`. Install kinds are faked with tmp dirs and + monkeypatched `sys.prefix`/`sys.executable`. Extras use monkeypatched `find_spec`. + The runner is a fake that records commands. +- Each install kind builds the right command. Extras are carried over, including the + union with the receipt, and `dev` never is. The spec is never pinned. +- An editable install never runs anything. `network=deny` sends no request. +- A PyPI failure exits `1` with the reason. An up-to-date or ahead build runs nothing. + `--check` runs nothing. +- When a command fails, the output says so and prints the command, with the Windows + line on Windows. When verification sees an old version, that is a failure. +- The CLI is tested through `CliRunner`. In the TUI, `/update --check` renders in a + command block. + +## 4. Out of scope + +- An update check at startup (declined, decision 1). +- Keeping uv receipt options such as a pinned `--python` across `uv tool install + --upgrade`. This is a known limit, written in the README. diff --git a/pyproject.toml b/pyproject.toml index 73ee57d..74bf8c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,9 @@ dependencies = [ "numpy>=1.26", "platformdirs>=4.3", "orjson>=3.11", + # Pulled in by other packages already; declared because update.py compares + # versions with it directly. + "packaging>=24.0", "tomli>=2.0; python_version < '3.11'", ] diff --git a/src/proofpath/browser.py b/src/proofpath/browser.py index c28ecdd..3b721b3 100644 --- a/src/proofpath/browser.py +++ b/src/proofpath/browser.py @@ -15,7 +15,6 @@ import importlib.util import os -import shlex import shutil import subprocess import sys @@ -26,6 +25,7 @@ import typer from proofpath.config import ConfigError, Decision, Permission, resolve_permission, set_value +from proofpath.shell import run_logged Answer = Literal["once", "always", "no", "never"] PROMPT_CHOICES = ("y", "a", "n", "never") @@ -185,29 +185,6 @@ def install_commands() -> list[list[str]]: ] -def _run_logged( - cmd: list[str], - log: list[str], - run: Callable[..., subprocess.CompletedProcess[str]], -) -> subprocess.CompletedProcess[str]: - # Quoted, not merely spaced: the log is what the user is shown when an install - # fails, and a line they can paste back into a shell says more than one they - # cannot. The -c program and the pip spec are each one argument. - log.append(f"$ {shlex.join(cmd)}") - try: - result = run(cmd, capture_output=True, text=True, check=False) - except OSError as exc: - # e.g. the interpreter/binary named in cmd does not exist on this machine. - log.append(f"exit ? ({type(exc).__name__}: {exc})") - return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=str(exc)) - log.append(f"exit {result.returncode}") - if result.returncode != 0: - stderr_lines = (result.stderr or "").strip().splitlines() - if stderr_lines: - log.append(stderr_lines[-1]) - return result - - def install( log: list[str], *, @@ -217,16 +194,16 @@ def install( fail. ``pip`` missing in a uv-managed venv falls back to ``uv pip install`` only when ``uv`` is on PATH; otherwise the install fails outright.""" pip_cmd, uv_cmd, module_cmd = install_commands() - result = _run_logged(pip_cmd, log, run) + result = run_logged(pip_cmd, log, run) if result.returncode != 0: if not shutil.which("uv"): return False - result = _run_logged(uv_cmd, log, run) + result = run_logged(uv_cmd, log, run) if result.returncode != 0: return False # No retry behind this one: see ``install_commands``. A failed browser download # is reported with its log, not paid for twice. - return _run_logged(module_cmd, log, run).returncode == 0 + return run_logged(module_cmd, log, run).returncode == 0 def fetch_with_browser(url: str, *, timeout: float = 60.0) -> tuple[int, bytes, str]: diff --git a/src/proofpath/cli.py b/src/proofpath/cli.py index 125c0ec..1724526 100644 --- a/src/proofpath/cli.py +++ b/src/proofpath/cli.py @@ -811,5 +811,50 @@ def _write_file(out: ui.Ui, path: Path | None, text: str) -> None: raise _fail(out, exc) from exc +@app.command("update") +def update_cmd( + ctx: typer.Context, + check: Annotated[ + bool, typer.Option("--check", help="Only say whether a newer release exists.") + ] = False, +) -> None: + """Update proofpath to the latest release on PyPI, keeping its extras.""" + out = _ui(ctx) + result = lib.update_install( + config=_load_config(out), + interactive=cfg.is_interactive(), # rule 4: ``network = ask`` needs a terminal + check_only=check, + ) + _print_update(out, result) + if not result.ok: + # Update spec section 2.10: a failed check or update is 1, so a script can + # tell it from "up to date"; usage errors keep typer's 2. + raise typer.Exit(EXIT_FINDINGS) + + +def _print_update(out: ui.Ui, result: lib.UpdateResult) -> None: + ui.kv(out, "current", result.current) + ui.kv(out, "latest", result.latest or "—") + install = result.install + if install is not None: + where = install.source if install.source is not None else install.prefix + ui.kv(out, "install", f"{install.name} ({where})") + if install.updatable: + ui.kv(out, "extras", ", ".join(result.extras) or "none") + for line in result.log: + ui.kv(out, "run", line) + if result.ok: + ui.kv(out, "state", result.summary) + else: + ui.error(out, result.summary) + if result.command and result.state != "updated": + # What would run, or what to run by hand: one line a shell takes back as is. + ui.kv(out, "command", lib.display_command(result.command)) + if result.state == "available": + ui.note(out, "proofpath update installs it") + for note in result.notes: + ui.kv(out, "note", note) + + if __name__ == "__main__": # pragma: no cover sys.exit(app()) diff --git a/src/proofpath/commands.py b/src/proofpath/commands.py index 2706502..3c51c4b 100644 --- a/src/proofpath/commands.py +++ b/src/proofpath/commands.py @@ -30,11 +30,14 @@ from proofpath import judge as judge_mod from proofpath import oa as oa_mod from proofpath import resolve as resolve_mod +from proofpath import update as update_mod from proofpath.browser import Answer, ConsentGate from proofpath.cache import Cache, Cleared, SourceDetail, SourceSummary from proofpath.config import Config, JudgeConfig from proofpath.paths import config_path from proofpath.polite import PoliteClient, ProviderError, user_agent +from proofpath.shell import display_command +from proofpath.update import UpdateResult #: ``judge.provider`` is not one setting: switching provider without its model, base #: URL and key variable would leave three fields pointed at the old one. @@ -313,6 +316,23 @@ def cache_file() -> Path: return db.path +# --- update ------------------------------------------------------------------------- + + +def update_install(*, config: Config, interactive: bool, check_only: bool = False) -> UpdateResult: + """Compare this build with PyPI's latest and, unless ``check_only``, update to it, + keeping the installed extras (docs/superpowers/specs/2026-10-05-update-command- + design.md). + + Nothing asks: running the verb is the consent to install, and ``check_only`` runs + nothing. ``interactive`` is required because it decides what + ``permissions.network = ask`` means (rule 4): the CLI passes + ``config.is_interactive()``, the TUI passes ``True``. Every failure, PyPI's + included, is reported in the result rather than raised. + """ + return update_mod.update(config, interactive=interactive, check_only=check_only) + + __all__ = [ "JUDGE_PRESET_FIELDS", "Answer", @@ -324,6 +344,7 @@ def cache_file() -> Path: "PromptFn", "Resolved", "TargetError", + "UpdateResult", "cache_clear", "cache_detail", "cache_file", @@ -332,6 +353,8 @@ def cache_file() -> Path: "config_check", "config_set", "config_view", + "display_command", "fetch_target", "resolve_reference", + "update_install", ] diff --git a/src/proofpath/shell.py b/src/proofpath/shell.py new file mode 100644 index 0000000..592f8bb --- /dev/null +++ b/src/proofpath/shell.py @@ -0,0 +1,96 @@ +"""Logged subprocess commands, shown the way this platform's shell takes them back. + +A leaf: it imports nothing from the package, so ``browser`` (the browser install) and +``update`` (the self-update) can both run their commands through it without a cycle +between them. Both log every command as ``$ cmd`` with its exit code, and both may +have to tell the user to run one by hand, so the line has to paste: ``cmd.exe`` +quoting on Windows, POSIX quoting elsewhere. +""" + +from __future__ import annotations + +import shlex +import subprocess +import sys +from collections.abc import Callable, Sequence + + +def on_windows() -> bool: + """The platform question, asked through one hook a test can patch on any runner.""" + return sys.platform == "win32" + + +# Characters ``cmd.exe`` or PowerShell act on outside double quotes. ``list2cmdline`` +# quotes only for whitespace, so ``scrapling[fetchers]>=0.4.15`` came out bare and, +# pasted back, redirected pip's output to a file named ``=0.4.15``. PowerShell also +# reads ``proofpath[browser,gpu]`` as an array (``,``) and ends a statement at ``;``. +_WINDOWS_SPECIAL = frozenset("<>|&^(),;") + + +def _windows_quoted(arg: str) -> str: + """``arg`` in double quotes by the MS C runtime's rule, as ``list2cmdline`` quotes + one: a backslash run is doubled before a quote (escaped or closing) and kept as is + anywhere else, so a trailing backslash cannot swallow the closing quote.""" + out = ['"'] + backslashes = 0 + for char in arg: + if char == "\\": + backslashes += 1 + continue + if char == '"': + out.append("\\" * (backslashes * 2 + 1) + '"') + else: + out.append("\\" * backslashes + char) + backslashes = 0 + out.append("\\" * (backslashes * 2) + '"') + return "".join(out) + + +def _windows_arg(arg: str) -> str: + if _WINDOWS_SPECIAL.intersection(arg): + return _windows_quoted(arg) + return subprocess.list2cmdline([arg]) + + +def display_command(cmd: Sequence[str]) -> str: + """``cmd`` as one line this platform's shell takes back as is. ``shlex.join`` + would wrap ``proofpath[gpu]`` in single quotes, which ``cmd.exe`` keeps as part + of the argument. On Windows each argument is quoted as ``list2cmdline`` quotes it + (``subprocess``'s own rule there), and also whenever it holds a character the + shell would act on.""" + if on_windows(): + return " ".join(_windows_arg(arg) for arg in cmd) + return shlex.join(cmd) + + +def run_logged( + cmd: list[str], + log: list[str], + run: Callable[..., subprocess.CompletedProcess[str]], + *, + show: Callable[[Sequence[str]], str] = display_command, +) -> subprocess.CompletedProcess[str]: + """Run one command with its output captured, and log it as ``$ cmd``, its exit + code and, on failure, the last line of its stderr. + + Captured, never inherited: the TUI owns the terminal while these run. + """ + # Quoted, not merely spaced: the log is what the user is shown when an install + # fails, and a line they can paste back into a shell says more than one they + # cannot. The -c program and the pip spec are each one argument. + log.append(f"$ {show(cmd)}") + try: + result = run(cmd, capture_output=True, text=True, check=False) + except OSError as exc: + # e.g. the interpreter/binary named in cmd does not exist on this machine. + log.append(f"exit ? ({type(exc).__name__}: {exc})") + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=str(exc)) + log.append(f"exit {result.returncode}") + if result.returncode != 0: + stderr_lines = (result.stderr or "").strip().splitlines() + if stderr_lines: + log.append(stderr_lines[-1]) + return result + + +__all__ = ["display_command", "on_windows", "run_logged"] diff --git a/src/proofpath/tui/app.py b/src/proofpath/tui/app.py index 7946256..8ef225d 100644 --- a/src/proofpath/tui/app.py +++ b/src/proofpath/tui/app.py @@ -15,7 +15,7 @@ worker thread block on a question the event loop is drawing. The mirrored verbs (spec section 13.3) hold no more logic than the rest: ``/resolve``, -``/fetch``, ``/config`` and ``/cache`` call :mod:`proofpath.commands`, the same +``/fetch``, ``/config``, ``/cache`` and ``/update`` call :mod:`proofpath.commands`, the same functions ``cli.py`` calls, on a worker thread, and :mod:`proofpath.tui.verbs` only turns what comes back into lines. ``/config`` on its own is the one exception: it opens the settings panel of :mod:`proofpath.tui.widgets.config_panel`, which writes @@ -96,7 +96,19 @@ DEFAULT_PLACEHOLDER = "paste a file path, a URL, or a claim" #: The CLI verbs the TUI mirrors (spec section 13.3): each one calls the same #: :mod:`proofpath.commands` function ``cli.py`` calls, on a worker thread. -MIRRORED = ("resolve", "fetch", "config", "cache") +MIRRORED = ("resolve", "fetch", "config", "cache", "update") +#: What a bare ``/update`` says while anything is still running. Its install replaces +#: the venv under the code those runs are executing (update spec section 2.11), so it +#: waits for them; ``/update --check`` installs nothing and is never refused. +UPDATE_WHILE_RUNNING = ( + "not while a run is active: /update replaces the code it runs on" + " -- let it finish or /cancel it first" +) +#: The other direction: what anything that runs this install's code says while a bare +#: ``/update`` is replacing it -- a run, a mirrored verb, or a second ``/update``. +UPDATE_INSTALLING = "an update is installing; wait for it to finish" +#: The verbs that guard holds back: ``/check`` and every mirrored verb. +HELD_BY_UPDATE = ("check", *MIRRORED) #: What ``/summarize`` says when no judge can be built from the config and the #: environment. It is one call over a finished report (spec section 11.1), but it is #: still a call, so it needs a provider and a key like every other one. @@ -347,6 +359,8 @@ def __init__( #: One map, so ``/allow`` answers the newest question without a second lookup. self.pending: dict[int, Future[Answer]] = {} self._command_blocks: dict[int, CommandBlock] = {} + #: True while a bare ``/update`` is on its worker (``UPDATE_INSTALLING``). + self._installing = False self._owner = 0 # the negative half of the id space, for ``/fetch`` #: Set the moment ``_quit`` starts. A question asked after that is answered #: ``no`` without ever being drawn: the app is on its way out, and a worker @@ -829,6 +843,11 @@ async def _run_line(self, line: str) -> None: await self._dispatch(parsed) async def _dispatch(self, command: commands.Command) -> None: + if self._installing and command.verb in HELD_BY_UPDATE: + # Checked before anything starts: the venv is being replaced under this + # process, and a run started now would load code from it mid-swap. + await self._refuse(command, UPDATE_INSTALLING) + return if command.verb == "check": self._check(command.arg) elif command.verb == "cancel": @@ -843,9 +862,26 @@ async def _dispatch(self, command: commands.Command) -> None: await self._quit() elif command.verb == "config" and not command.arg: await self._open_config_panel() + elif _installs(command) and self._anything_running(): + await self._refuse(command, UPDATE_WHILE_RUNNING) elif command.verb in MIRRORED: self._mirror(command) + def _anything_running(self) -> bool: + """A run that has not finished, or a mirrored verb whose worker is still out: + ``_command_blocks`` holds one exactly that long, and ``/fetch`` runs this + install's code as much as a ``/check`` does.""" + runs = self._scheduler.runs if self._scheduler is not None else () + return any(run.state not in TERMINAL for run in runs) or bool(self._command_blocks) + + async def _refuse(self, command: commands.Command, reason: str) -> None: + """Answer a verb in its own block without running it: one error line.""" + block = CommandBlock(f"/{command.verb} {command.arg}".strip(), self._out, self._theme) + # Awaited: the block's line container exists only once it has been composed. + await self.query_one(RunLog).mount(block) + block.show([error_line(self._out, reason)]) + self._scroll_log() + # --- the mirrored verbs ---------------------------------------------------------- def _mirror(self, command: commands.Command) -> None: @@ -867,6 +903,8 @@ def _mirror(self, command: commands.Command) -> None: self._owner -= 1 owner = self._owner self._command_blocks[owner] = block + if _installs(command): + self._installing = True self.query_one(RunLog).mount(block) self._scroll_log() self.run_worker(self._run_verb(command, block, owner), name=line, group="mirror") @@ -888,6 +926,8 @@ async def _run_verb(self, command: commands.Command, block: CommandBlock, owner: finally: self._settle_prompt(owner, "no") self._command_blocks.pop(owner, None) + if _installs(command): + self._installing = False block.show(lines) if command.verb == "config" and command.arg.split()[:1] == ["set"]: # The file on disk has just changed; the session must not keep deciding by @@ -1195,6 +1235,11 @@ async def _quit(self) -> None: self.exit() +def _installs(command: commands.Command) -> bool: + """A bare ``/update``: the one mirrored verb that replaces this install.""" + return command.verb == "update" and not command.arg + + def run(config: Config, out: ui.Ui) -> None: """Open the TUI. The bare ``proofpath`` invocation calls exactly this. diff --git a/src/proofpath/tui/commands.py b/src/proofpath/tui/commands.py index 0f58f07..b8cbc38 100644 --- a/src/proofpath/tui/commands.py +++ b/src/proofpath/tui/commands.py @@ -1,8 +1,8 @@ """Slash commands, and the awaiting mode a bare verb enters (spec section 13.1). Spec section 13.3's mirror rule: every CLI verb exists in the TUI as the -same-named slash command, so ``/check``, ``/resolve``, ``/fetch``, ``/config`` and -``/cache`` are the CLI's verbs and the rest are TUI-only. +same-named slash command, so ``/check``, ``/resolve``, ``/fetch``, ``/config``, +``/cache`` and ``/update`` are the CLI's verbs and the rest are TUI-only. The parser is deliberately dumb. It splits a line into a verb and the rest and says whether the verb is still waiting for an argument; it never judges the @@ -27,6 +27,10 @@ "cancel", "help", "quit", + # Last on purpose: at 80x24 the ``/`` list holds ten of these eleven rows, and the + # cap then hides ``/quit`` (which the banner's hint line names anyway) and this + # one, never ``/help``. + "update", ) #: Verbs that open the awaiting mode when typed alone, with the bar's placeholder. NEEDS_ARGUMENT = { @@ -52,6 +56,9 @@ "fetch": "read one URL, DOI or arXiv id", "config": "settings panel, or show/set/check", "cache": "list or drop cached sources", + # ``/update`` alone updates at once (running it is the consent, update spec + # decision 4), so it is not in NEEDS_ARGUMENT: ``--check`` is its one option. + "update": "update from PyPI, or --check only", "allow": "answer the permission question", "summarize": "a paragraph over the last run", "cancel": "stop a run", diff --git a/src/proofpath/tui/verbs.py b/src/proofpath/tui/verbs.py index 2f14245..290dc90 100644 --- a/src/proofpath/tui/verbs.py +++ b/src/proofpath/tui/verbs.py @@ -57,6 +57,8 @@ def verb_lines( return _fetch_lines(out, fetched) if verb == "config": return _config_lines(out, arg) + if verb == "update": + return _update_lines(out, arg, config) return _cache_lines(out, arg) @@ -242,6 +244,36 @@ def _judge_lines(out: ui.Ui, checked: library.JudgeCheck) -> list[Text]: return lines +def _update_lines(out: ui.Ui, arg: str, config: Config) -> list[Text]: + """``/update`` and ``/update --check``: what ``proofpath update`` prints, plus the + restart line only a running TUI needs (update spec section 2.11). Nothing prompts.""" + if arg not in ("", "--check"): + return [error_line(out, f"/update {arg}: try /update or /update --check")] + # The TUI is a terminal, so ``permissions.network = ask`` is let through with a + # note rather than read as a no-TTY deny (rule 4), as ``/fetch`` does. + result = library.update_install(config=config, interactive=True, check_only=arg == "--check") + lines = [_kv(out, "current", result.current), _kv(out, "latest", result.latest or "—")] + install = result.install + if install is not None: + where = install.source if install.source is not None else install.prefix + lines.append(_kv(out, "install", f"{install.name} ({where})")) + if install.updatable: + lines.append(_kv(out, "extras", ", ".join(result.extras) or "none")) + lines.extend(_kv(out, "run", line) for line in result.log) + lines.append( + _kv(out, "state", result.summary) if result.ok else error_line(out, result.summary) + ) + if result.command and result.state != "updated": + lines.append(_kv(out, "command", library.display_command(result.command))) + if result.state == "available": + lines.append(_kv(out, "note", "/update installs it")) + if result.state == "updated": + # This process still runs the code it started with; the new one is on disk. + lines.append(_kv(out, "note", f"restart proofpath to use v{result.installed}")) + lines.extend(_kv(out, "note", note) for note in result.notes) + return lines + + def _cache_lines(out: ui.Ui, arg: str) -> list[Text]: """``/cache``, ``/cache path|ls|show ID|clear [--expired]`` — the CLI group.""" sub, *rest = arg.split() or [""] diff --git a/src/proofpath/update.py b/src/proofpath/update.py new file mode 100644 index 0000000..ea3e939 --- /dev/null +++ b/src/proofpath/update.py @@ -0,0 +1,491 @@ +"""``proofpath update``: replace this install with PyPI's latest release. + +docs/superpowers/specs/2026-10-05-update-command-design.md is the source of truth; +the section numbers below are that spec's. + +Three things shape this module. Running the command is the consent (decision 4), so +nothing here asks: ``--check`` only reports, and the plain verb updates straight away. +The installed extras are written into the install spec (decision 3), because a bare +``uv tool upgrade`` was measured to drop packages added to the tool's environment +after it was installed -- the browser wheels the user consented to among them. And the +result is read back from a fresh process (section 2.8): the code imported here is the +old version and cannot say what the command installed. + +Like :mod:`proofpath.commands`, nothing here prints, exits or reads a terminal. The +runner is injected, as in ``browser.install``, so a test never installs anything. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from importlib import metadata +from importlib.util import find_spec +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlparse +from urllib.request import url2pathname + +import httpx +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from proofpath import __version__, shell +from proofpath.config import Config +from proofpath.fetch import network_permission +from proofpath.polite import user_agent + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised only on 3.10 + import tomli as tomllib # type: ignore[import-not-found] + +DIST = "proofpath" +PYPI_URL = f"https://pypi.org/pypi/{DIST}/json" +PYPI_TIMEOUT = 10.0 +#: Run by the venv's own interpreter after the update (section 2.8), under ``-I``: an +#: isolated interpreter ignores ``PYTHON*`` variables and the working directory, so a +#: proofpath checkout the user happens to stand in cannot answer for the install. +VERSION_PROBE = f"import importlib.metadata as m; print(m.version({DIST!r}))" +#: Each extra the update carries over, and the modules that import only when it is +#: installed. All of them must import: ``scrapling`` alone is a base dependency. +EXTRA_MODULES: dict[str, tuple[str, ...]] = { + "browser": ("scrapling", "patchright"), + "gpu": ("torch", "sentence_transformers"), +} +#: The development extra is a checkout's business, never a user install's. +NEVER_CARRIED = frozenset({"dev"}) +#: Section 2.9: a running ``proofpath.exe`` can be locked while it is being replaced. +WINDOWS_NOTE = "close proofpath and run the command above" +EDITABLE_HINT = "update it with git pull and uv sync" +#: Section 2.4: what fetches the newest release again for each temporary environment. +#: ``uvx name@latest`` refreshes uv's cached copy; ``pipx run`` reuses its cached venv +#: for days unless told not to, hence ``--no-cache``. +UVX_RERUN = f"uvx {DIST}@latest" +PIPX_RUN_RERUN = f"pipx run --no-cache {DIST}" + +InstallKind = Literal["editable", "ephemeral", "uv", "pipx", "pip"] +UpdateState = Literal[ + "up to date", "available", "ahead", "editable", "ephemeral", "updated", "failed" +] +Runner = Callable[..., subprocess.CompletedProcess[str]] + +_KIND_NAMES: dict[InstallKind, str] = { + "editable": "editable", + "ephemeral": "temporary environment", + "uv": "uv tool", + "pipx": "pipx", + "pip": "pip", +} + + +class UpdateError(RuntimeError): + """PyPI could not say which version is the latest. The message is the reason.""" + + +@dataclass(frozen=True) +class Install: + """How this copy of proofpath was installed, and so how it is updated. + + ``python`` is the interpreter that reads the installed version back afterwards: + the venv's own for a uv tool or pipx install, ``sys.executable`` otherwise. + ``source`` is the checkout an editable install points at, when it can be read; + ``rerun`` is the command that fetches the newest release for an ephemeral one. + """ + + kind: InstallKind + prefix: Path + python: Path + source: Path | None = None + rerun: str = "" + + @property + def updatable(self) -> bool: + """False for the two kinds this command never runs anything for.""" + return self.kind not in ("editable", "ephemeral") + + @property + def name(self) -> str: + return _KIND_NAMES[self.kind] + + +@dataclass(frozen=True) +class UpdateResult: + """What ``update`` found and did. ``latest`` is ``None`` when PyPI was not asked + or did not answer, which is never the same thing as "up to date" (rule 2). + + ``command`` is what ran, or what would run; on a failure it is the command to run + by hand. ``log`` holds each command as ``$ cmd`` with its exit code. + """ + + current: str + latest: str | None + state: UpdateState + install: Install | None = None + extras: tuple[str, ...] = () + command: tuple[str, ...] = () + log: tuple[str, ...] = () + installed: str | None = None + detail: str = "" + notes: tuple[str, ...] = () + + @property + def ok(self) -> bool: + """Every state but ``failed``: a caller's exit code is 0 or 1 by this alone.""" + return self.state != "failed" + + @property + def summary(self) -> str: + """The one sentence both front ends print for the state.""" + if self.state == "up to date": + return "up to date" + if self.state == "available": + return f"update available: {self.current} → {self.latest}" + if self.state == "ahead": + return ( + f"this build ({self.current}) is newer than PyPI's {self.latest}:" + " a development build, nothing to do" + ) + if self.state == "editable": + source = self.install.source if self.install is not None else None + where = f" from {source}" if source is not None else "" + return ( + f"{self.latest} is out, but this is an editable install{where}: " + f"not touched, {EDITABLE_HINT}" + ) + if self.state == "ephemeral": + rerun = self.install.rerun if self.install is not None else "" + return ( + f"{self.latest} is out, but this is a temporary environment;" + f" `{rerun}` already fetches the newest" + ) + if self.state == "updated": + return f"updated to {self.installed}" + return self.detail + + +def update( + config: Config, + *, + interactive: bool = False, + check_only: bool = False, + run: Runner | None = None, +) -> UpdateResult: + """Compare this build with PyPI's latest and, unless ``check_only``, update to it. + + ``interactive`` is the caller's answer to "is there a terminal": it decides what + ``permissions.network = ask`` means, exactly as for the fetch ladder (rule 4), and + defaults to the safe "no". Only an available update goes on to look at the + install: an up-to-date or ahead build has nothing to run, and an editable or + ephemeral one is never touched. Every failure is reported in the result, never + raised. ``run`` defaults to ``subprocess.run``, looked up at call time. + """ + run = subprocess.run if run is None else run + current = __version__ + # The fetcher's own rule, not a copy of it: ``deny`` and a no-TTY ``ask`` send + # nothing and say why; an ``ask`` with a terminal goes ahead with a note, since + # typing the command is the answer that terminal would have been asked for. + allowed, note = network_permission(config, interactive=interactive) + if not allowed: + return UpdateResult(current, None, "failed", detail=f"PyPI was not asked; {note}") + notes = (note,) if note else () + try: + latest = latest_version() + except UpdateError as exc: + return UpdateResult(current, None, "failed", detail=str(exc), notes=notes) + + if Version(latest) == Version(current): + return UpdateResult(current, latest, "up to date", notes=notes) + if Version(latest) < Version(current): + return UpdateResult(current, latest, "ahead", notes=notes) + + install = detect_install() + if not install.updatable: + held: UpdateState = "editable" if install.kind == "editable" else "ephemeral" + return UpdateResult(current, latest, held, install=install, notes=notes) + extras = detect_extras(install) + commands = update_commands(install, install_spec(extras)) + ran = commands[0] + log: list[str] = [] + + def outcome( + state: UpdateState, + *, + detail: str = "", + installed: str | None = None, + windows: bool = False, + ) -> UpdateResult: + # Every state from here on has an install, its extras and a command: the one + # that ran, would run, or is the one to run by hand. + return UpdateResult( + current, + latest, + state, + install=install, + extras=extras, + command=tuple(ran), + log=tuple(log), + installed=installed, + detail=detail, + notes=(*notes, WINDOWS_NOTE) if windows else notes, + ) + + if check_only: + return outcome("available") + if install.kind in ("uv", "pipx") and shutil.which(ran[0]) is None: + return outcome("failed", detail=f"{ran[0]} is not on PATH: run the command below by hand") + + result = shell.run_logged(ran, log, run) + if result.returncode != 0 and len(commands) > 1 and shutil.which("uv"): + # The pip install of a uv-made venv has no pip; ``browser.install`` falls back + # the same way, and only when uv is there to fall back on. + ran = commands[1] + result = shell.run_logged(ran, log, run) + if result.returncode != 0: + return outcome( + "failed", + detail=f"the update command failed (exit {result.returncode}):" + " run the command below by hand", + windows=shell.on_windows(), + ) + + installed = read_installed(install, log, run) + if installed is None: + return outcome( + "failed", detail="the update ran, but the installed version could not be read" + ) + if Version(installed) < Version(latest): + # The resolver held it back (a pin elsewhere, an incompatible Python): the + # command passed and the version did not move to the latest. + return outcome( + "failed", + detail=f"the update installed {installed}, not PyPI's latest {latest}", + installed=installed, + ) + return outcome("updated", installed=installed) + + +def latest_version() -> str: + """PyPI's ``info.version`` for proofpath. Raises :class:`UpdateError` with the reason. + + The request carries no contact address and no key (section 2.2): the user agent + is the bare one ``polite`` builds without an address. + """ + client = httpx.Client(headers={"User-Agent": user_agent()}, timeout=PYPI_TIMEOUT) + try: + response = client.get(PYPI_URL) + except httpx.HTTPError as exc: + raise UpdateError(f"PyPI did not answer: {type(exc).__name__}: {exc}") from exc + finally: + client.close() + if response.status_code != 200: + raise UpdateError(f"PyPI did not answer: HTTP {response.status_code}") + try: + version = response.json()["info"]["version"] + except (ValueError, KeyError, TypeError) as exc: + raise UpdateError("PyPI's answer had no version") from exc + if not isinstance(version, str) or not version: + raise UpdateError("PyPI's answer had no version") + try: + Version(version) + except InvalidVersion as exc: + raise UpdateError(f"PyPI's latest version {version!r} is not a version") from exc + return version + + +def direct_url() -> dict[str, Any] | None: + """PEP 610's ``direct_url.json`` for the installed distribution, or ``None``. + + Only an install from a URL or a local path has one; a plain index install does not. + """ + try: + text = metadata.distribution(DIST).read_text("direct_url.json") + except metadata.PackageNotFoundError: + return None + if not text: + return None + try: + data = json.loads(text) + except ValueError: + return None + return data if isinstance(data, dict) else None + + +def detect_install() -> Install: + """Section 2.4, in its order: editable, ephemeral, uv tool, pipx, and pip for + anything else. + + The two kinds the command never touches come first, whatever else their prefix + happens to hold: a ``pipx run`` venv carries pipx's metadata like an installed one. + """ + prefix = Path(sys.prefix) + executable = Path(sys.executable) + record = direct_url() + if record is not None and _is_editable(record): + return Install("editable", prefix, executable, _source_dir(record)) + rerun = ephemeral_rerun(prefix) + if rerun: + return Install("ephemeral", prefix, executable, rerun=rerun) + if (prefix / "uv-receipt.toml").exists(): + return Install("uv", prefix, venv_python(prefix)) + if (prefix / "pipx_metadata.json").exists(): + return Install("pipx", prefix, venv_python(prefix)) + return Install("pip", prefix, executable) + + +def ephemeral_rerun(prefix: Path) -> str: + """The command that fetches the newest release, when ``prefix`` is a temporary + environment; ``""`` when it is not. Judged from the prefix alone (section 2.4): + + - ``uvx`` / ``uv tool run`` builds its environment in uv's cache, under an + ``archive-v0`` directory, so that component in the prefix means uv's cache. + - ``pipx install`` keeps every venv in ``/venvs/``; ``pipx run`` + keeps its venvs in pipx's cache instead, wherever the platform puts that + (``~/Library/Caches/pipx`` on macOS, ``...\\pipx\\Cache`` on Windows, + ``$PIPX_HOME/.cache`` when that is set). Both write ``pipx_metadata.json``, so + pipx's metadata outside a ``venvs`` directory means ``pipx run``. + + Updating either in place would be undone the next time the cache is pruned, and a + ``pipx install`` into pipx's cache would not be the environment that runs next. + """ + if "archive-v0" in prefix.parts: + return UVX_RERUN + if (prefix / "pipx_metadata.json").exists() and prefix.parent.name.casefold() != "venvs": + return PIPX_RUN_RERUN + return "" + + +def detect_extras(install: Install) -> tuple[str, ...]: + """The extras to write into the spec, sorted (section 2.5). + + An extra counts when all its modules import. For a uv tool install the extras its + receipt already records are added: they are what ``uv tool install`` was asked + for, whether or not they import right now. ``dev`` is never carried over. + """ + found = { + extra + for extra, modules in EXTRA_MODULES.items() + if all(_importable(module) for module in modules) + } + if install.kind == "uv": + found |= receipt_extras(install.prefix / "uv-receipt.toml") + return tuple(sorted(found - NEVER_CARRIED)) + + +def receipt_extras(path: Path) -> set[str]: + """The extras ``uv-receipt.toml`` lists for proofpath; empty when it cannot be read. + + An unreadable receipt costs nothing worse than the importable extras alone, which + already cover every extra an installed module can prove. + """ + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return set() + tool = data.get("tool") + requirements = tool.get("requirements") if isinstance(tool, dict) else None + found: set[str] = set() + for requirement in requirements if isinstance(requirements, list) else (): + if not isinstance(requirement, dict): + continue + if canonicalize_name(str(requirement.get("name", ""))) != DIST: + continue + extras = requirement.get("extras") + if isinstance(extras, list): + found.update(str(extra) for extra in extras) + return found + + +def install_spec(extras: tuple[str, ...]) -> str: + """``proofpath[a,b]``, or ``proofpath``. Never version-pinned (section 2.6): a pin + would freeze the uv receipt or the pipx metadata, and a later upgrade would not move.""" + return f"{DIST}[{','.join(extras)}]" if extras else DIST + + +def update_commands(install: Install, spec: str) -> list[list[str]]: + """The commands that update ``install``, in the order they are tried. Kept as data + so each install kind's command is testable without a subprocess. + + Only pip has a second one: ``uv pip install`` into the same interpreter, the + fallback ``browser.install`` uses. An editable install has none. + """ + if install.kind == "uv": + return [["uv", "tool", "install", "--upgrade", spec]] + if install.kind == "pipx": + return [["pipx", "install", "--force", spec]] + if install.kind == "pip": + python = str(install.python) + return [ + [python, "-m", "pip", "install", "--upgrade", spec], + ["uv", "pip", "install", "--python", python, "--upgrade", spec], + ] + return [] + + +def read_installed(install: Install, log: list[str], run: Runner) -> str | None: + """The version a fresh interpreter sees after the update, or ``None``.""" + probe = [str(install.python), "-I", "-c", VERSION_PROBE] + result = shell.run_logged(probe, log, run) + lines = (result.stdout or "").strip().splitlines() + if result.returncode != 0 or not lines: + return None + text = lines[-1].strip() + try: + Version(text) + except InvalidVersion: + return None + return text + + +def venv_python(prefix: Path) -> Path: + """The interpreter inside the venv at ``prefix``, by this platform's layout.""" + return prefix / "Scripts" / "python.exe" if shell.on_windows() else prefix / "bin" / "python" + + +def _importable(module: str) -> bool: + try: + return find_spec(module) is not None + except (ImportError, ValueError): + # A module already imported with no spec raises ValueError; a broken parent + # package raises ImportError. Neither proves the extra is installed. + return False + + +def _is_editable(record: dict[str, Any]) -> bool: + dir_info = record.get("dir_info") + return isinstance(dir_info, dict) and dir_info.get("editable") is True + + +def _source_dir(record: dict[str, Any]) -> Path | None: + url = record.get("url") + if not isinstance(url, str): + return None + parsed = urlparse(url) + if parsed.scheme != "file": + return None + return Path(url2pathname(parsed.path)) + + +__all__ = [ + "EDITABLE_HINT", + "PYPI_URL", + "VERSION_PROBE", + "WINDOWS_NOTE", + "Install", + "InstallKind", + "UpdateError", + "UpdateResult", + "UpdateState", + "detect_extras", + "detect_install", + "direct_url", + "ephemeral_rerun", + "install_spec", + "latest_version", + "update", + "update_commands", +] diff --git a/tests/test_browser.py b/tests/test_browser.py index 14a0b76..9ed8b73 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -6,7 +6,6 @@ from __future__ import annotations -import shlex import subprocess from pathlib import Path @@ -16,6 +15,7 @@ from proofpath import browser as bw from proofpath import fetch as fx +from proofpath import shell from proofpath.config import Config, FetchConfig, load_config @@ -313,7 +313,7 @@ def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: result = bw.install(log, run=run) assert result is True assert calls == bw.install_commands() - assert log[0] == f"$ {shlex.join(bw.install_commands()[0])}" + assert log[0] == f"$ {shell.display_command(bw.install_commands()[0])}" assert log[1] == "exit 1" assert log[2] == "No module named pip" for kwargs in call_kwargs: @@ -330,7 +330,11 @@ def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: result = bw.install(log, run=run) assert result is False # Only the pip command was attempted: no uv, no scrapling install step. - assert log == [f"$ {shlex.join(bw.install_commands()[0])}", "exit 1", "No module named pip"] + assert log == [ + f"$ {shell.display_command(bw.install_commands()[0])}", + "exit 1", + "No module named pip", + ] def test_install_pip_success_skips_uv(monkeypatch: pytest.MonkeyPatch) -> None: @@ -358,7 +362,7 @@ def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: log: list[str] = [] result = bw.install(log, run=run) assert result is False - assert log[0] == f"$ {shlex.join(bw.install_commands()[0])}" + assert log[0] == f"$ {shell.display_command(bw.install_commands()[0])}" assert log[1] == "exit ? (FileNotFoundError: no such file or directory: 'pip')" @@ -498,9 +502,11 @@ def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: assert calls == [pip_cmd, module_cmd] -def test_the_install_log_can_be_pasted_back_into_a_shell() -> None: +def test_the_install_log_can_be_pasted_back_into_a_shell(monkeypatch: pytest.MonkeyPatch) -> None: """The log is what a user is shown when an install fails; a line they cannot - re-run tells them less than one they can.""" + re-run tells them less than one they can. POSIX quoting, pinned: the Windows + form is the test below.""" + monkeypatch.setattr(shell, "on_windows", lambda: False) log: list[str] = [] bw.install( log, @@ -509,6 +515,22 @@ def test_the_install_log_can_be_pasted_back_into_a_shell() -> None: _pip_cmd, _uv_cmd, module_cmd = bw.install_commands() # The -c program holds spaces and a semicolon, and the pip spec holds brackets # and a `>`: both are one argument and both must come back quoted as one. - assert log[-2] == f"$ {shlex.join(module_cmd)}" + assert log[-2] == f"$ {shell.display_command(module_cmd)}" assert f"'{bw.SCRAPLING_CLI}'" in log[-2] assert "'scrapling[fetchers]>=0.4.15'" in log[0] + + +def test_the_install_log_can_be_pasted_back_into_cmd_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """On Windows the pip spec's `>` is inside double quotes, where neither cmd.exe nor + PowerShell reads it as a redirect, and nothing is in single quotes.""" + monkeypatch.setattr(shell, "on_windows", lambda: True) + log: list[str] = [] + bw.install( + log, + run=lambda cmd, **kwargs: subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""), + ) + assert '"scrapling[fetchers]>=0.4.15"' in log[0] + assert f'"{bw.SCRAPLING_CLI}"' in log[-2] + assert "'" not in log[0] diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..2c67eed --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,68 @@ +"""``shell.display_command``: a logged command has to paste back into this platform's +shell. The expectations are literal strings, never the function's own output.""" + +from __future__ import annotations + +import pytest + +from proofpath import shell + +PIP = ["python", "-m", "pip", "install", "--upgrade", "scrapling[fetchers]>=0.4.15"] + + +@pytest.fixture +def windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shell, "on_windows", lambda: True) + + +@pytest.fixture +def posix(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shell, "on_windows", lambda: False) + + +@pytest.mark.usefixtures("windows") +def test_a_redirect_character_is_quoted_on_windows() -> None: + # Bare, the `>` would send pip's output to a file named `=0.4.15`. + assert shell.display_command(PIP) == ( + 'python -m pip install --upgrade "scrapling[fetchers]>=0.4.15"' + ) + + +@pytest.mark.usefixtures("windows") +def test_an_extras_spec_stays_bare_on_windows() -> None: + # Single quotes would become part of the argument in cmd.exe. + assert shell.display_command(["uv", "tool", "install", "--upgrade", "proofpath[browser]"]) == ( + "uv tool install --upgrade proofpath[browser]" + ) + + +@pytest.mark.usefixtures("windows") +def test_two_extras_are_quoted_for_powershell() -> None: + # Bare, PowerShell would read `browser,gpu` as an array and pass two arguments. + assert shell.display_command( + ["uv", "tool", "install", "--upgrade", "proofpath[browser,gpu]"] + ) == ('uv tool install --upgrade "proofpath[browser,gpu]"') + + +@pytest.mark.usefixtures("windows") +def test_a_path_with_spaces_is_quoted_on_windows() -> None: + assert shell.display_command([r"C:\Program Files\py\python.exe", "-V"]) == ( + r'"C:\Program Files\py\python.exe" -V' + ) + + +@pytest.mark.usefixtures("windows") +def test_a_trailing_backslash_cannot_swallow_the_closing_quote() -> None: + assert shell.display_command(["a&b\\"]) == '"a&b\\\\"' + + +@pytest.mark.usefixtures("windows") +def test_an_inner_quote_is_escaped_on_windows() -> None: + assert shell.display_command(['say "hi" & go']) == '"say \\"hi\\" & go"' + + +@pytest.mark.usefixtures("posix") +def test_posix_quoting_is_shlex() -> None: + assert shell.display_command(PIP) == ( + "python -m pip install --upgrade 'scrapling[fetchers]>=0.4.15'" + ) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index a4d1f30..b335a7d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -25,6 +25,7 @@ from proofpath import __version__, browser, ui from proofpath import commands as library +from proofpath import update as update_mod from proofpath import verify as verify_mod from proofpath.browser import ConsentGate from proofpath.config import Config, ConfigError, JudgeConfig, Permissions, load_config @@ -45,6 +46,8 @@ AWAITING_VERBS, CLEARED_NOT_SUMMARIZABLE, HINT, + UPDATE_INSTALLING, + UPDATE_WHILE_RUNNING, Banner, CommandBlock, CoverageFooter, @@ -77,6 +80,9 @@ from proofpath.verify import Engine SIZE = (80, 24) +#: The shortest 80-column terminal where ``/`` lists every verb with nothing capped. +#: Eleven verbs since ``/update`` (update spec section 2.11): 80x24 holds ten rows. +ALL_VERBS_FIT = (80, 25) HOST = "sciencedirect.com" @@ -1707,6 +1713,85 @@ async def test_an_unknown_subcommand_is_reported_not_guessed() -> None: assert "nope" in text and "try path, ls, show or clear" in text +def an_update(state: str, **fields: Any) -> update_mod.UpdateResult: + values: dict[str, Any] = { + "current": "1.0.0", + "latest": "1.1.0", + "state": state, + "install": update_mod.Install("uv", Path("/venv"), Path("/venv/bin/python")), + "command": ("uv", "tool", "install", "--upgrade", "proofpath"), + } + values.update(fields) + return update_mod.UpdateResult(**values) + + +async def test_update_check_renders_in_a_command_block(monkeypatch: pytest.MonkeyPatch) -> None: + """``/update --check`` is a mirrored verb (update spec section 2.11): the same + library function ``proofpath update --check`` calls, drawn in its own block.""" + asked: list[tuple[bool, bool]] = [] + + def stub(*, config: Config, interactive: bool, check_only: bool = False) -> Any: + asked.append((interactive, check_only)) + return an_update("available") + + monkeypatch.setattr(library, "update_install", stub) + app, _ = build_app() + async with app.run_test(size=SIZE) as pilot: + await submit(pilot, "/update --check") + text = await lines_of(app, pilot) + assert app.query_one(CommandBlock) + # The TUI is a terminal: ``permissions.network = ask`` is not a no-TTY deny here. + assert asked == [(True, True)] + assert "update available: 1.0.0 → 1.1.0" in text + assert "uv tool install --upgrade proofpath" in text + assert "/update installs it" in text + + +async def test_update_says_to_restart_after_it_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + updated = an_update("updated", installed="1.1.0", log=("$ uv tool", "exit 0")) + monkeypatch.setattr(library, "update_install", lambda **kwargs: updated) + app, _ = build_app() + async with app.run_test(size=SIZE) as pilot: + await submit(pilot, "/update") + text = await lines_of(app, pilot) + assert "updated to 1.1.0" in text + assert "restart proofpath to use v1.1.0" in text + + +async def test_update_takes_only_check(monkeypatch: pytest.MonkeyPatch) -> None: + called: list[bool] = [] + monkeypatch.setattr(library, "update_install", lambda **kwargs: called.append(True)) + app, _ = build_app() + async with app.run_test(size=SIZE) as pilot: + await submit(pilot, "/update now") + text = await lines_of(app, pilot) + assert called == [] # an unknown argument installs nothing + assert "/update now" in text and "try /update or /update --check" in text + + +async def test_update_is_refused_while_a_run_is_active_but_check_is_not( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The update replaces the venv under the code a live run is executing.""" + asked: list[bool] = [] + + def stub(*, config: Config, interactive: bool, check_only: bool = False) -> Any: + asked.append(check_only) + return an_update("available") + + monkeypatch.setattr(library, "update_install", stub) + app, schedulers = build_app() + async with app.run_test(size=SIZE) as pilot: + await submit(pilot, "/check one.pdf") + assert schedulers[0].runs[0].state == "queued" + await submit(pilot, "/update") + text = await lines_of(app, pilot) + assert asked == [] # refused before the library was asked anything + assert UPDATE_WHILE_RUNNING in text + await submit(pilot, "/update --check") + await until(pilot, lambda: asked == [True], "the check to run beside the live run") + + async def test_help_lists_every_verb_with_what_it_wants() -> None: app, _ = build_app() async with app.run_test(size=SIZE) as pilot: @@ -2311,7 +2396,7 @@ async def test_tab_on_a_recalled_slash_line_brings_the_list_back() -> None: async def test_the_list_never_covers_the_log_and_the_bottom_grows_by_its_rows() -> None: app, _ = build_app() - async with app.run_test(size=SIZE) as pilot: + async with app.run_test(size=ALL_VERBS_FIT) as pilot: bottom = app.query_one("#bottom") log = app.query_one(RunLog) suggestions = app.query_one(Suggestions) @@ -2334,8 +2419,8 @@ async def test_the_list_never_covers_the_log_and_the_bottom_grows_by_its_rows() await pilot.press("backspace") await pilot.pause() # ``/`` alone lists every verb, and with the shortened ``/summarize`` - # description (item 1) none of the ten rows wraps at eighty columns: at - # 80x24 they all fit, so there is nothing to cap and no indicator. + # description (item 1) none of the rows wraps at eighty columns: at + # ALL_VERBS_FIT they all fit, so there is nothing to cap and no indicator. banner = app.query_one("#banner").region.height assert len(suggestions.held) == len(commands.VERBS) assert suggestions.window == (0, len(commands.VERBS)) @@ -2346,7 +2431,7 @@ async def test_the_list_never_covers_the_log_and_the_bottom_grows_by_its_rows() assert footer.region.bottom == suggestions.region.y assert suggestions.region.bottom == frame.region.y assert not app.screen.show_vertical_scrollbar - assert bottom.region.width == SIZE[0] + assert bottom.region.width == ALL_VERBS_FIT[0] # A shorter terminal is what forces the cap now (not a wrapped row): the # banner, the footer (3) and the bar (1) plus one row of log leave fewer @@ -2372,7 +2457,7 @@ async def test_the_list_never_covers_the_log_and_the_bottom_grows_by_its_rows() assert not app.screen.show_vertical_scrollbar assert bottom.region.width == 80 - # The window slides with the selection: ``up`` wraps to ``/quit``, off the + # The window slides with the selection: ``up`` wraps to the last verb, off the # bottom of the first window, and the top gives way to an indicator instead. await pilot.press("up") await pilot.pause() @@ -2382,11 +2467,11 @@ async def test_the_list_never_covers_the_log_and_the_bottom_grows_by_its_rows() assert len(suggestions.drawn) == limit assert suggestions.drawn[0] == f"... {first} more" assert suggestions.drawn[1:] == suggestions.held[first:last] - assert suggestions.drawn[-1].endswith(commands.DESCRIPTIONS["quit"]) + assert suggestions.drawn[-1].endswith(commands.DESCRIPTIONS[commands.VERBS[-1]]) assert bottom.region.height == 4 + limit # Taller again: the window and the indicator both give the rows back. - await pilot.resize_terminal(*SIZE) + await pilot.resize_terminal(*ALL_VERBS_FIT) await pilot.pause() assert suggestions.window == (0, len(commands.VERBS)) assert suggestions.drawn == suggestions.held @@ -2403,10 +2488,10 @@ async def test_the_list_never_covers_the_log_and_the_bottom_grows_by_its_rows() async def test_the_more_indicator_marks_only_the_rows_the_cap_hides() -> None: app, _ = build_app() - async with app.run_test(size=SIZE) as pilot: + async with app.run_test(size=ALL_VERBS_FIT) as pilot: suggestions = app.query_one(Suggestions) await type_into(pilot, "/") - # At SIZE (80x24) all ten rows fit, nothing is hidden: no indicator either end. + # At ALL_VERBS_FIT every row fits, nothing is hidden: no indicator either end. assert suggestions.window == (0, len(commands.VERBS)) assert suggestions.drawn == suggestions.held assert not any(row.startswith("...") for row in suggestions.drawn) @@ -2421,7 +2506,7 @@ async def test_the_more_indicator_marks_only_the_rows_the_cap_hides() -> None: assert suggestions.drawn[-1] == f"... {len(commands.VERBS) - last} more" assert suggestions.drawn[0] != suggestions.drawn[-1] # only the one end hides - # ``up`` wraps the selection to ``/quit``, off the bottom of that window: + # ``up`` wraps the selection to the last verb, off the bottom of that window: # the window slides down and the *top* indicator takes the first drawn line. await pilot.press("up") await pilot.pause() @@ -2429,15 +2514,117 @@ async def test_the_more_indicator_marks_only_the_rows_the_cap_hides() -> None: assert first > 0 assert last == len(commands.VERBS) assert suggestions.drawn[0] == f"... {first} more" - assert suggestions.drawn[-1].endswith(commands.DESCRIPTIONS["quit"]) + assert suggestions.drawn[-1].endswith(commands.DESCRIPTIONS[commands.VERBS[-1]]) - # Back to SIZE, everything fits again and the indicator goes. - await pilot.resize_terminal(*SIZE) + # Back to ALL_VERBS_FIT, everything fits again and the indicator goes. + await pilot.resize_terminal(*ALL_VERBS_FIT) await pilot.pause() assert suggestions.window == (0, len(commands.VERBS)) assert not any(row.startswith("...") for row in suggestions.drawn) +async def test_update_is_refused_while_a_mirrored_verb_is_still_out( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A ``/resolve`` on its worker runs this install's code as much as a run does.""" + release = threading.Event() + updates: list[bool] = [] + + def slow(reference: str, *, config: Config) -> library.Resolved: + release.wait(10) + return a_resolved() + + monkeypatch.setattr(library, "resolve_reference", slow) + monkeypatch.setattr(library, "update_install", lambda **kwargs: updates.append(True)) + app, _ = build_app() + async with app.run_test(size=SIZE) as pilot: + try: + await submit(pilot, "/resolve Jumper 2021") + await submit(pilot, "/update") + text = await lines_of(app, pilot) + assert UPDATE_WHILE_RUNNING in text + assert updates == [] + finally: + release.set() + # The ``/resolve`` worker finishes while the app is still up: leaving the block + # with it still out closed the screen under it, and on a slow runner it then + # wrote to a ``RunLog`` that was gone (Windows CI, 2026-10-05). + await until( + pilot, + lambda: any("RESOLVED" in line.render().plain for line in app.query(KvLine)), + "the /resolve to finish", + ) + + +def _refused_by_the_update(app: ProofpathApp) -> int: + return sum(UPDATE_INSTALLING in line.render().plain for line in app.query(KvLine)) + + +async def test_nothing_starts_while_an_update_is_installing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The guard's other direction: the venv is being replaced under this process, so + no run, no mirrored verb and no second ``/update`` may start until it is done.""" + release = threading.Event() + calls: list[str] = [] + + def installing(*, config: Config, interactive: bool, check_only: bool = False) -> Any: + calls.append("update --check" if check_only else "update") + release.wait(10) + return an_update("updated", installed="1.1.0") + + monkeypatch.setattr(library, "update_install", installing) + monkeypatch.setattr(library, "resolve_reference", lambda *a, **k: calls.append("resolve")) + monkeypatch.setattr(library, "fetch_target", lambda *a, **k: calls.append("fetch")) + monkeypatch.setattr(library, "config_view", lambda: calls.append("config")) + monkeypatch.setattr(library, "cache_overview", lambda: calls.append("cache")) + held = [ + "/check one.pdf", + "one.pdf", # the implicit /check + "/resolve Jumper 2021", + "/fetch https://x.test/p.html", + "/config show", + "/cache", + "/update", + "/update --check", + ] + app, schedulers = build_app() + async with app.run_test(size=SIZE) as pilot: + try: + await submit(pilot, "/update") + await until(pilot, lambda: calls == ["update"], "the install to start") + for line in held: + await submit(pilot, line) + await until( + pilot, lambda: _refused_by_the_update(app) == len(held), "every line refused" + ) + assert calls == ["update"] + assert schedulers[0].submitted == [] + finally: + release.set() + await until( + pilot, + lambda: any("restart proofpath" in line.render().plain for line in app.query(KvLine)), + "the install to finish", + ) + await submit(pilot, "/check one.pdf") + assert [target for target, _ in schedulers[0].submitted] == ["one.pdf"] + + +async def test_at_80x24_the_list_caps_only_quit_and_update() -> None: + """Eleven verbs, ten rows: the cap hides the last two, ``/quit`` (the banner's hint + line names it anyway) and ``/update``, and never ``/help``.""" + app, _ = build_app() + async with app.run_test(size=SIZE) as pilot: + suggestions = app.query_one(Suggestions) + await type_into(pilot, "/") + assert commands.VERBS[-2:] == ("quit", "update") + assert suggestions.window == (0, len(commands.VERBS) - 2) + assert suggestions.drawn[-1] == "... 2 more" + assert any(row[2:].startswith("/help") for row in suggestions.drawn) + assert not any(row[2:].startswith(("/quit", "/update")) for row in suggestions.drawn) + + async def test_the_indicator_is_dropped_when_the_cap_leaves_room_for_only_the_row() -> None: """At ``limit == 1`` there is room for the selected row and nothing else: an ``... n more`` indicator would already overrun the cap on its own, so neither diff --git a/tests/test_tui_commands.py b/tests/test_tui_commands.py index 370b269..d1bb4cc 100644 --- a/tests/test_tui_commands.py +++ b/tests/test_tui_commands.py @@ -99,7 +99,7 @@ def test_parse(line, expected): def test_verbs_mirror_the_cli_verbs(): # Spec section 13.3's TUI mirror rule: every CLI verb exists as a slash command. - for verb in ("check", "resolve", "fetch", "config", "cache"): + for verb in ("check", "resolve", "fetch", "config", "cache", "update"): assert verb in VERBS diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..153d00a --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,609 @@ +"""``proofpath update``: the version check, the install kinds, the command, the +verification (docs/superpowers/specs/2026-10-05-update-command-design.md). + +Nothing here touches the network or installs anything. PyPI answers through +``respx``, the install kind is a tmp directory standing in for ``sys.prefix``, the +extras are a faked ``find_spec`` and every command goes to a runner that records it. +""" + +from __future__ import annotations + +import json +import shlex +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest +import respx + +from proofpath import shell, update +from proofpath.config import Config, Contact, Permissions + +CURRENT = "1.0.0" +NEWER = "1.1.0" +PYPI = update.PYPI_URL + + +class FakeRun: + """Stands in for ``subprocess.run``: records each command, answers from a script. + + Each scripted answer is ``(returncode, stdout, stderr)``; once the script runs out + every further command succeeds and prints nothing. + """ + + def __init__(self, *answers: tuple[int, str, str]) -> None: + self.answers = list(answers) + self.calls: list[list[str]] = [] + self.kwargs: list[dict[str, Any]] = [] + + def __call__(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + self.calls.append(list(cmd)) + self.kwargs.append(kwargs) + code, stdout, stderr = self.answers.pop(0) if self.answers else (0, "", "") + return subprocess.CompletedProcess(cmd, code, stdout=stdout, stderr=stderr) + + +def ok(version: str = NEWER) -> tuple[tuple[int, str, str], tuple[int, str, str]]: + """The install command succeeds, then the fresh process reports ``version``.""" + return (0, "", ""), (0, f"{version}\n", "") + + +@pytest.fixture +def env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A plain pip install of version ``CURRENT``, no extras, every tool on PATH. + + Each test turns it into the install it needs by writing a marker file into the + prefix it returns, or by patching one of the hooks below. + """ + # Inside a ``venvs`` directory, as a ``pipx install`` venv is: the ``pipx`` marker + # below then means an installed pipx venv, not a ``pipx run`` one. + prefix = tmp_path / "venvs" / "proofpath" + (prefix / "bin").mkdir(parents=True) + monkeypatch.setattr(sys, "prefix", str(prefix)) + monkeypatch.setattr(sys, "executable", str(prefix / "bin" / "python3")) + monkeypatch.setattr(update, "__version__", CURRENT) + monkeypatch.setattr(update, "direct_url", lambda: None) + monkeypatch.setattr(update, "find_spec", lambda name: None) + monkeypatch.setattr(update.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(shell, "on_windows", lambda: False) + return prefix + + +@pytest.fixture +def pypi() -> Iterator[respx.MockRouter]: + with respx.mock(assert_all_called=False) as router: + yield router + + +def latest(router: respx.MockRouter, version: str = NEWER) -> respx.Route: + return router.get(PYPI).mock( + return_value=httpx.Response(200, json={"info": {"version": version}}) + ) + + +def venv_python(prefix: Path) -> str: + # ``env`` pins ``shell.on_windows`` to False, and the venv layout follows that hook, so + # this is the POSIX layout on every runner, Windows included. + return str(prefix / "bin" / "python") + + +def uv_tool(prefix: Path, *extras: str) -> None: + requirement = '{ name = "proofpath"' + if extras: + requirement += ", extras = [" + ", ".join(f'"{e}"' for e in extras) + "]" + requirement += " }" + (prefix / "uv-receipt.toml").write_text( + f"[tool]\nrequirements = [{requirement}]\n", encoding="utf-8" + ) + + +def pipx(prefix: Path) -> None: + (prefix / "pipx_metadata.json").write_text("{}", encoding="utf-8") + + +def with_extras(monkeypatch: pytest.MonkeyPatch, *modules: str) -> None: + present = set(modules) + monkeypatch.setattr(update, "find_spec", lambda name: object() if name in present else None) + + +# --- the version check --------------------------------------------------------------- + + +def test_up_to_date_runs_nothing(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi, CURRENT) + run = FakeRun() + result = update.update(Config(), run=run) + assert result.state == "up to date" + assert result.ok + assert (result.current, result.latest) == (CURRENT, CURRENT) + assert run.calls == [] + + +def test_a_build_ahead_of_pypi_runs_nothing(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi, "0.9.0") + run = FakeRun() + result = update.update(Config(), run=run) + assert result.state == "ahead" + assert result.ok + assert "development build" in result.summary + assert run.calls == [] + + +def test_versions_compare_as_versions_not_strings(env: Path, pypi: respx.MockRouter) -> None: + """``1.10.0`` is newer than ``1.9.0``, although it sorts before it as text.""" + latest(pypi, "1.10.0") + with pytest.MonkeyPatch.context() as mp: + mp.setattr(update, "__version__", "1.9.0") + result = update.update(Config(), check_only=True, run=FakeRun()) + assert result.state == "available" + + +def test_check_reports_an_update_and_runs_nothing(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi) + run = FakeRun() + result = update.update(Config(), check_only=True, run=run) + assert result.state == "available" + assert result.ok + assert result.latest == NEWER + assert run.calls == [] + # What would run is still worked out, so ``--check`` can show it. + assert result.command == (sys.executable, "-m", "pip", "install", "--upgrade", "proofpath") + + +def test_the_request_carries_no_contact_address(env: Path, pypi: respx.MockRouter) -> None: + route = latest(pypi, CURRENT) + config = Config(contact=Contact(email="someone@example.org")) + update.update(config, run=FakeRun()) + assert route.called + request = route.calls.last.request + assert "someone@example.org" not in str(request.url) + assert all("someone@example.org" not in value for value in request.headers.values()) + + +def test_network_ask_without_a_terminal_is_deny_and_says_so( + env: Path, pypi: respx.MockRouter +) -> None: + """Rule 4: no TTY, no question, and ``ask`` is ``deny`` -- reported, not silent.""" + route = latest(pypi) + run = FakeRun() + config = Config(permissions=Permissions(network="ask")) + result = update.update(config, interactive=False, run=run) + assert not route.called + assert result.state == "failed" + assert result.latest is None + assert "no interactive terminal" in result.summary + assert "permissions.network allow" in result.summary + assert run.calls == [] + + +def test_network_ask_with_a_terminal_goes_ahead_with_a_note( + env: Path, pypi: respx.MockRouter +) -> None: + route = latest(pypi, CURRENT) + config = Config(permissions=Permissions(network="ask")) + result = update.update(config, interactive=True, run=FakeRun()) + assert route.called + assert result.state == "up to date" + assert result.notes == ("network permission is 'ask'; allowed for this run",) + + +def test_network_deny_sends_nothing(env: Path, pypi: respx.MockRouter) -> None: + route = latest(pypi) + run = FakeRun() + config = Config(permissions=Permissions(network="deny")) + result = update.update(config, interactive=True, run=run) + assert not route.called + assert result.state == "failed" + assert not result.ok + # Never "up to date": nobody was asked (rule 2). + assert result.latest is None + assert "permissions.network" in result.summary + assert run.calls == [] + + +@pytest.mark.parametrize( + ("response", "reason"), + [ + (httpx.Response(503), "HTTP 503"), + (httpx.Response(200, json={"info": {}}), "no version"), + (httpx.Response(200, text="not json"), "no version"), + (httpx.Response(200, json={"info": {"version": "not a version"}}), "not a version"), + ], +) +def test_a_pypi_failure_is_a_failure_with_its_reason( + env: Path, pypi: respx.MockRouter, response: httpx.Response, reason: str +) -> None: + pypi.get(PYPI).mock(return_value=response) + run = FakeRun() + result = update.update(Config(), run=run) + assert result.state == "failed" + assert reason in result.summary + assert run.calls == [] + + +def test_an_unreachable_pypi_is_a_failure(env: Path, pypi: respx.MockRouter) -> None: + pypi.get(PYPI).mock(side_effect=httpx.ConnectError("no route to host")) + result = update.update(Config(), run=FakeRun()) + assert result.state == "failed" + assert "ConnectError" in result.summary + + +# --- install detection and the command ---------------------------------------------- + + +def test_a_uv_tool_install_is_upgraded_through_uv(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi) + uv_tool(env) + run = FakeRun(*ok()) + result = update.update(Config(), run=run) + assert result.install is not None and result.install.kind == "uv" + assert run.calls == [ + ["uv", "tool", "install", "--upgrade", "proofpath"], + [venv_python(env), "-I", "-c", update.VERSION_PROBE], + ] + assert result.state == "updated" + assert result.installed == NEWER + assert result.ok + + +def test_extras_are_the_union_of_the_importable_ones_and_the_receipt( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + uv_tool(env, "gpu", "dev") + with_extras(monkeypatch, "scrapling", "patchright") + run = FakeRun(*ok()) + result = update.update(Config(), run=run) + assert result.extras == ("browser", "gpu") + # ``dev`` is never carried over, even when the receipt names it. + assert run.calls[0] == ["uv", "tool", "install", "--upgrade", "proofpath[browser,gpu]"] + + +@pytest.mark.parametrize( + ("modules", "extras"), + [ + (("scrapling",), ()), # the browser extra needs patchright too + (("scrapling", "patchright"), ("browser",)), + (("torch",), ()), # the gpu extra needs sentence_transformers too + (("torch", "sentence_transformers"), ("gpu",)), + ], +) +def test_an_extra_counts_only_when_all_its_modules_import( + env: Path, + pypi: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + modules: tuple[str, ...], + extras: tuple[str, ...], +) -> None: + latest(pypi) + with_extras(monkeypatch, *modules) + result = update.update(Config(), check_only=True, run=FakeRun()) + assert result.extras == extras + + +def test_a_pipx_install_is_reinstalled_through_pipx( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + pipx(env) + with_extras(monkeypatch, "scrapling", "patchright") + run = FakeRun(*ok()) + result = update.update(Config(), run=run) + assert result.install is not None and result.install.kind == "pipx" + assert run.calls == [ + ["pipx", "install", "--force", "proofpath[browser]"], + [venv_python(env), "-I", "-c", update.VERSION_PROBE], + ] + assert result.state == "updated" + + +def test_a_pip_install_is_upgraded_with_its_own_interpreter( + env: Path, pypi: respx.MockRouter +) -> None: + latest(pypi) + run = FakeRun(*ok()) + result = update.update(Config(), run=run) + assert result.install is not None and result.install.kind == "pip" + assert run.calls == [ + [sys.executable, "-m", "pip", "install", "--upgrade", "proofpath"], + [sys.executable, "-I", "-c", update.VERSION_PROBE], + ] + assert result.state == "updated" + + +def test_pip_falls_back_to_uv_pip_when_pip_fails(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi) + run = FakeRun((1, "", "No module named pip"), *ok()) + result = update.update(Config(), run=run) + assert run.calls[1] == [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + "proofpath", + ] + assert run.calls[2] == [sys.executable, "-I", "-c", update.VERSION_PROBE] + assert result.state == "updated" + + +def test_pip_without_uv_to_fall_back_on_fails( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + monkeypatch.setattr(update.shutil, "which", lambda name: None) + run = FakeRun((1, "", "No module named pip")) + result = update.update(Config(), run=run) + assert len(run.calls) == 1 + assert result.state == "failed" + assert result.command == (sys.executable, "-m", "pip", "install", "--upgrade", "proofpath") + + +def test_the_spec_is_never_pinned( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + """A pin would freeze the uv receipt or pipx metadata at this version.""" + latest(pypi) + uv_tool(env, "browser") + with_extras(monkeypatch, "torch", "sentence_transformers") + run = FakeRun(*ok()) + update.update(Config(), run=run) + spec = run.calls[0][-1] + assert spec == "proofpath[browser,gpu]" + assert not any(marker in spec for marker in ("=", "<", ">", "~", NEWER)) + + +def test_an_editable_install_is_never_touched( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + latest(pypi) + uv_tool(env) # editable wins over every other marker + source = tmp_path / "proofpath-src" + monkeypatch.setattr( + update, + "direct_url", + lambda: {"url": source.as_uri(), "dir_info": {"editable": True}}, + ) + run = FakeRun() + result = update.update(Config(), run=run) + assert run.calls == [] + assert result.state == "editable" + assert result.install is not None and result.install.kind == "editable" + assert result.install.source == source + assert str(source) in result.summary + assert "git pull" in result.summary and "uv sync" in result.summary + + +@pytest.mark.parametrize( + ("parts", "rerun"), + [ + # ``uvx`` / ``uv tool run``: uv's cache, ``/archive-v0/``. + ((".cache", "uv", "archive-v0", "Xa1b2"), "uvx proofpath@latest"), + # ``pipx run``: pipx's cache, ``$PIPX_HOME/.cache/``. + (("pipx", ".cache", "0f9e8d"), "pipx run --no-cache proofpath"), + ], +) +def test_an_ephemeral_environment_runs_nothing( + env: Path, + pypi: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + parts: tuple[str, ...], + rerun: str, +) -> None: + latest(pypi) + prefix = tmp_path.joinpath(*parts) + prefix.mkdir(parents=True) + pipx(prefix) # a ``pipx run`` venv has pipx's metadata too; ephemeral wins + monkeypatch.setattr(sys, "prefix", str(prefix)) + run = FakeRun() + for check_only in (False, True): + result = update.update(Config(), check_only=check_only, run=run) + assert result.state == "ephemeral" + assert result.ok + assert result.install is not None and result.install.kind == "ephemeral" + assert "temporary environment" in result.summary + assert f"`{rerun}`" in result.summary + assert result.command == () + assert run.calls == [] + + +@pytest.mark.parametrize( + ("parts", "kind"), + [ + # ``pipx run`` venvs live in pipx's cache: platformdirs' user cache when + # PIPX_HOME is unset, or ``$PIPX_HOME/.cache``. Never in a ``venvs`` directory. + (("Library", "Caches", "pipx", "0f9e8d"), "ephemeral"), # macOS + (("AppData", "Local", "pipx", "pipx", "Cache", "0f9e8d"), "ephemeral"), # Windows + ((".local", "pipx", ".cache", "0f9e8d"), "ephemeral"), # Linux, PIPX_HOME + # ``pipx install`` venvs live in ``/venvs/``. + ((".local", "pipx", "venvs", "proofpath"), "pipx"), + (("Library", "Application Support", "pipx", "venvs", "proofpath"), "pipx"), + (("AppData", "Local", "pipx", "pipx", "venvs", "proofpath"), "pipx"), + ], +) +def test_a_pipx_run_venv_is_told_apart_from_a_pipx_install( + env: Path, + pypi: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + parts: tuple[str, ...], + kind: str, +) -> None: + latest(pypi) + prefix = tmp_path.joinpath(*parts) + prefix.mkdir(parents=True) + pipx(prefix) + monkeypatch.setattr(sys, "prefix", str(prefix)) + run = FakeRun() + result = update.update(Config(), run=run) + assert result.install is not None and result.install.kind == kind + if kind == "ephemeral": + assert run.calls == [] # never installed for good into pipx's cache + assert "pipx run --no-cache proofpath" in result.summary + + +def test_a_non_editable_direct_url_is_not_editable( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + """A wheel installed from a local path has a ``direct_url.json`` too.""" + latest(pypi) + monkeypatch.setattr(update, "direct_url", lambda: {"url": "file:///w.whl", "archive_info": {}}) + result = update.update(Config(), check_only=True, run=FakeRun()) + assert result.install is not None and result.install.kind == "pip" + + +def test_direct_url_reads_pep_610_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + class Dist: + def read_text(self, name: str) -> str | None: + assert name == "direct_url.json" + return json.dumps({"url": "file:///src", "dir_info": {"editable": True}}) + + monkeypatch.setattr(update.metadata, "distribution", lambda name: Dist()) + assert update.direct_url() == {"url": "file:///src", "dir_info": {"editable": True}} + + def missing(name: str) -> Any: + raise update.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(update.metadata, "distribution", missing) + assert update.direct_url() is None + + +@pytest.mark.parametrize(("marker", "tool"), [(uv_tool, "uv"), (pipx, "pipx")]) +def test_a_missing_tool_is_a_failure_that_prints_the_command( + env: Path, + pypi: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + marker: Any, + tool: str, +) -> None: + latest(pypi) + marker(env) + monkeypatch.setattr(update.shutil, "which", lambda name: None) + run = FakeRun() + result = update.update(Config(), run=run) + assert run.calls == [] + assert result.state == "failed" + assert f"{tool} is not on PATH" in result.summary + assert result.command[0] == tool + + +# --- running it --------------------------------------------------------------------- + + +def test_output_is_captured_never_inherited(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi) + uv_tool(env) + run = FakeRun(*ok()) + update.update(Config(), run=run) + assert all(kwargs.get("capture_output") is True for kwargs in run.kwargs) + + +def test_every_command_is_logged_so_it_can_be_pasted_back( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + uv_tool(env) + with_extras(monkeypatch, "scrapling", "patchright") + result = update.update(Config(), run=FakeRun(*ok())) + assert result.log[0] == "$ uv tool install --upgrade 'proofpath[browser]'" + assert result.log[1] == "exit 0" + + +def test_a_failed_command_says_so_and_prints_it(env: Path, pypi: respx.MockRouter) -> None: + latest(pypi) + uv_tool(env) + run = FakeRun((2, "", "error: resolution failed")) + result = update.update(Config(), run=run) + assert len(run.calls) == 1 # nothing to verify + assert result.state == "failed" + assert "exit 2" in result.summary + assert "error: resolution failed" in result.log + assert result.command == ("uv", "tool", "install", "--upgrade", "proofpath") + assert update.WINDOWS_NOTE not in result.notes + + +def test_a_failed_command_on_windows_adds_the_lock_line( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + uv_tool(env) + monkeypatch.setattr(shell, "on_windows", lambda: True) + result = update.update(Config(), run=FakeRun((1, "", "Access is denied."))) + assert result.state == "failed" + assert update.WINDOWS_NOTE in result.notes + assert update.WINDOWS_NOTE == "close proofpath and run the command above" + + +def test_a_success_on_windows_adds_no_lock_line( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + uv_tool(env) + monkeypatch.setattr(shell, "on_windows", lambda: True) + result = update.update(Config(), run=FakeRun(*ok())) + assert result.state == "updated" + assert result.notes == () + + +def test_commands_are_shown_the_way_this_platform_pastes_them( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cmd = ["C:\\Program Files\\Python\\python.exe", "-m", "pip", "install", "proofpath[browser]"] + monkeypatch.setattr(shell, "on_windows", lambda: False) + assert shell.display_command(cmd) == shlex.join(cmd) + monkeypatch.setattr(shell, "on_windows", lambda: True) + assert shell.display_command(cmd) == subprocess.list2cmdline(cmd) + assert shell.display_command(cmd).startswith('"C:\\Program Files\\Python\\python.exe" -m') + + +def test_the_log_on_windows_is_pasteable_into_cmd( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + uv_tool(env) + with_extras(monkeypatch, "scrapling", "patchright") + monkeypatch.setattr(shell, "on_windows", lambda: True) + result = update.update(Config(), run=FakeRun(*ok())) + # ``shlex.join`` would wrap the spec in single quotes, which cmd.exe keeps. + assert result.log[0] == "$ uv tool install --upgrade proofpath[browser]" + + +def test_a_windows_venv_is_verified_with_its_scripts_python( + env: Path, pypi: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + latest(pypi) + uv_tool(env) + monkeypatch.setattr(shell, "on_windows", lambda: True) + run = FakeRun(*ok()) + result = update.update(Config(), run=run) + assert run.calls[1] == [str(env / "Scripts" / "python.exe"), "-I", "-c", update.VERSION_PROBE] + assert result.state == "updated" + + +def test_verification_that_sees_an_old_version_is_a_failure( + env: Path, pypi: respx.MockRouter +) -> None: + """The resolver held the update back: the command passed, the version did not move.""" + latest(pypi) + uv_tool(env) + result = update.update(Config(), run=FakeRun(*ok(CURRENT))) + assert result.state == "failed" + assert result.installed == CURRENT + assert CURRENT in result.summary and NEWER in result.summary + + +def test_verification_that_cannot_read_a_version_is_a_failure( + env: Path, pypi: respx.MockRouter +) -> None: + latest(pypi) + uv_tool(env) + result = update.update(Config(), run=FakeRun((0, "", ""), (1, "", "ModuleNotFoundError"))) + assert result.state == "failed" + assert result.installed is None + assert "installed version" in result.summary diff --git a/tests/test_update_cli.py b/tests/test_update_cli.py new file mode 100644 index 0000000..fa26152 --- /dev/null +++ b/tests/test_update_cli.py @@ -0,0 +1,185 @@ +"""``proofpath update [--check]``: wiring, output, exit codes (update spec section 2.10). + +The ``--check`` paths run end to end with PyPI answering through ``respx``: nothing +is installed by a check, so nothing has to be stubbed below the library. The paths +that would install stub ``commands.update_install`` instead; what the command does +is ``test_update.py``'s business, and this file only checks how the CLI says it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +from proofpath import commands as lib +from proofpath import update +from proofpath.cli import app +from proofpath.config import Config, set_value + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A plain pip install of 1.0.0 under a fresh config dir.""" + monkeypatch.setenv("PROOFPATH_CONFIG_DIR", str(tmp_path / "conf")) + prefix = tmp_path / "venv" + prefix.mkdir() + monkeypatch.setattr(sys, "prefix", str(prefix)) + monkeypatch.setattr(update, "__version__", "1.0.0") + monkeypatch.setattr(update, "direct_url", lambda: None) + monkeypatch.setattr(update, "find_spec", lambda name: None) + return prefix + + +def pypi(version: str) -> respx.Route: + return respx.get(update.PYPI_URL).mock( + return_value=httpx.Response(200, json={"info": {"version": version}}) + ) + + +@respx.mock +def test_check_when_up_to_date_exits_zero() -> None: + pypi("1.0.0") + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 0, result.output + assert "current 1.0.0" in result.stdout + assert "latest 1.0.0" in result.stdout + assert "state up to date" in result.stdout + + +@respx.mock +def test_check_with_an_update_available_exits_zero_and_shows_the_command() -> None: + pypi("1.1.0") + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 0, result.output + assert "latest 1.1.0" in result.stdout + assert "update available: 1.0.0 → 1.1.0" in result.stdout + assert "install pip" in result.stdout + assert "extras none" in result.stdout + assert "-m pip install --upgrade proofpath" in result.stdout + assert "proofpath update installs it" in result.stdout + + +@respx.mock +def test_a_pypi_failure_exits_one_with_the_reason() -> None: + respx.get(update.PYPI_URL).mock(return_value=httpx.Response(503)) + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 1 + assert "HTTP 503" in result.stderr + assert "up to date" not in result.output + + +@respx.mock(assert_all_called=False) +def test_network_deny_sends_nothing_and_exits_one() -> None: + route = pypi("1.1.0") + set_value("permissions.network", "deny") + result = runner.invoke(app, ["update"]) + assert result.exit_code == 1 + assert not route.called + assert "permissions.network" in result.stderr + + +@respx.mock(assert_all_called=False) +def test_network_ask_without_a_terminal_sends_nothing_and_exits_one() -> None: + """Rule 4: CliRunner is no TTY, so ``ask`` is ``deny``, and the CLI says why.""" + route = pypi("1.1.0") + set_value("permissions.network", "ask") + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 1 + assert not route.called + assert "no interactive terminal" in result.stderr + + +@respx.mock +def test_an_editable_install_exits_zero_and_runs_nothing( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Update spec section 2.10: editable is not a failure, it is updated by hand.""" + pypi("1.1.0") + source = tmp_path / "checkout" + monkeypatch.setattr( + update, "direct_url", lambda: {"url": source.as_uri(), "dir_info": {"editable": True}} + ) + + def never(*args: object, **kwargs: object) -> None: + raise AssertionError("an editable install must never run anything") + + monkeypatch.setattr(update.subprocess, "run", never) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0, result.output + assert "install editable" in result.stdout + assert "git pull and uv sync" in result.stdout + assert "command" not in result.stdout + + +@respx.mock +def test_an_ephemeral_environment_exits_zero( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + pypi("1.1.0") + prefix = tmp_path / "uv-cache" / "archive-v0" / "abc123" + prefix.mkdir(parents=True) + monkeypatch.setattr(sys, "prefix", str(prefix)) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0, result.output + assert "temporary environment" in result.stdout + assert "uvx proofpath@latest" in result.stdout + + +def test_a_successful_update_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None: + asked: list[bool] = [] + + def stub(*, config: Config, interactive: bool, check_only: bool = False) -> update.UpdateResult: + asked.append(check_only) + return update.UpdateResult( + current="1.0.0", + latest="1.1.0", + state="updated", + install=update.Install("uv", Path("/venv"), Path("/venv/bin/python")), + extras=("browser",), + command=("uv", "tool", "install", "--upgrade", "proofpath[browser]"), + log=("$ uv tool install --upgrade 'proofpath[browser]'", "exit 0"), + installed="1.1.0", + ) + + monkeypatch.setattr(lib, "update_install", stub) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0, result.output + assert asked == [False] + assert "install uv tool" in result.stdout + assert "extras browser" in result.stdout + assert "run $ uv tool install --upgrade 'proofpath[browser]'" in result.stdout + assert "state updated to 1.1.0" in result.stdout + + +def test_a_failed_update_exits_one_and_prints_the_command( + monkeypatch: pytest.MonkeyPatch, +) -> None: + failed = update.UpdateResult( + current="1.0.0", + latest="1.1.0", + state="failed", + install=update.Install("uv", Path("/venv"), Path("/venv/bin/python")), + command=("uv", "tool", "install", "--upgrade", "proofpath"), + log=("$ uv tool install --upgrade proofpath", "exit 1", "Access is denied."), + detail="the update command failed (exit 1)", + notes=(update.WINDOWS_NOTE,), + ) + monkeypatch.setattr(lib, "update_install", lambda **kwargs: failed) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 1 + assert "the update command failed (exit 1)" in result.stderr + assert "command uv tool install --upgrade proofpath" in result.stdout + assert "Access is denied." in result.stdout + assert f"note {update.WINDOWS_NOTE}" in result.stdout + + +def test_an_unknown_option_is_a_usage_error() -> None: + result = runner.invoke(app, ["update", "--nope"]) + assert result.exit_code == 2 diff --git a/uv.lock b/uv.lock index 9b00d8f..81617e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2508,6 +2508,7 @@ dependencies = [ { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "onnxruntime", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "orjson" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "protego" }, { name = "pymupdf" }, @@ -2552,6 +2553,7 @@ requires-dist = [ { name = "numpy", specifier = ">=1.26" }, { name = "onnxruntime", specifier = ">=1.20" }, { name = "orjson", specifier = ">=3.11" }, + { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.3" }, { name = "protego", specifier = ">=0.3" }, { name = "pymupdf", specifier = ">=1.25" },