From 20490a75e8a4d1b65b6a28dd68d25e4dceea9e59 Mon Sep 17 00:00:00 2001 From: akadeepesh Date: Sun, 20 Sep 2026 11:56:37 +0530 Subject: [PATCH] feat(cli): add `contextzip update` / `cz update` self-update command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries PyPI for the latest published version and compares it against the running __version__. When a newer release exists, re-invokes whichever installer matches how contextzip was installed (pip, pipx, or uv tool), always against the currently running interpreter rather than a bare 'pip' off PATH. cz update # check, then confirm before updating cz update --yes # update immediately if available cz update --check # report versions only, install nothing Never raises on network failure — a bad connection or PyPI hiccup degrades to a clear 'couldn't check' message instead of a traceback. --- contextzip/cli.py | 85 ++++++++++++++++++++++ contextzip/updater.py | 165 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 contextzip/updater.py diff --git a/contextzip/cli.py b/contextzip/cli.py index 9afa555..c48f62c 100644 --- a/contextzip/cli.py +++ b/contextzip/cli.py @@ -74,6 +74,7 @@ from contextzip.cli_ai import ( ) from contextzip.cli_onboard import onboard_api_key from contextzip import cleanup as cleanup_mod +from contextzip import updater as updater_mod console = Console() @@ -688,6 +689,90 @@ def cmd_config( console.print() +# --------------------------------------------------------------------------- +# Subcommand: update +# --------------------------------------------------------------------------- + + +@main.command("update") +@click.option( + "--check", + "check_only", + is_flag=True, + default=False, + help="Only check for a newer version — don't install anything.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip the confirmation prompt and update immediately if available.", +) +def cmd_update(check_only: bool, yes: bool) -> None: + """ + Check PyPI for a newer contextzip release and update in place. + + \b + EXAMPLES + contextzip update # check, then confirm before updating + contextzip update --yes # update immediately if one is available + contextzip update --check # only report the current/latest version + cz update # same command, short alias + + \b + HOW IT WORKS + contextzip cuts frequent releases, so this beats remembering + `pip install --upgrade contextzip` by hand. It detects whether this + install came from pip, pipx, or uv tool and re-invokes the matching + one, upgrading the same interpreter that's currently running — + never a possibly-different "pip" found on PATH. + """ + console.print() + with console.status("[cyan]Checking for updates…[/]", spinner="dots"): + check = updater_mod.check_latest_version() + + if check.latest is None: + err(f"Could not check for updates: {check.error}") + info(f"You're on {check.current}. Try again later, or update manually:") + info(" pip install --upgrade contextzip") + console.print() + raise SystemExit(1) + + if not check.update_available: + ok(f"You're up to date — contextzip {check.current}") + console.print() + return + + info(f"Update available: {check.current} → {check.latest}") + + if check_only: + info("Run contextzip update to install it.") + console.print() + return + + if not yes: + proceed = click.confirm(f" Update to {check.latest} now?", default=True) + if not proceed: + info("Not updating.") + console.print() + return + + with console.status(f"[cyan]Updating to {check.latest}…[/]", spinner="dots"): + result = updater_mod.run_update() + + console.print() + if result.ok: + ok(f"Updated to contextzip {check.latest}", f"via {result.method}") + info("Restart any running contextzip/watch sessions to pick it up.") + else: + err(f"Update via {result.method} failed") + if result.output: + console.print(f"[dim]{result.output}[/]") + info("You can also update manually: pip install --upgrade contextzip") + console.print() + + # Core execution logic (shared by main command + all subcommands) # --------------------------------------------------------------------------- diff --git a/contextzip/updater.py b/contextzip/updater.py new file mode 100644 index 0000000..b5994cc --- /dev/null +++ b/contextzip/updater.py @@ -0,0 +1,165 @@ +""" +updater.py — Self-update support for `contextzip update` / `cz update`. + +contextzip cuts frequent releases, so re-running +`pip install --upgrade contextzip` by hand every time gets old fast. +This module: + + 1. Asks PyPI's JSON API for the latest published version. + 2. Compares it against the version actually running (contextzip.__version__). + 3. If newer, re-invokes pip (or pipx, if that's how contextzip was + installed) to upgrade in place. + +Never raises on network failure — a broken connection or PyPI hiccup +should degrade to "couldn't check right now", not crash the CLI. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from dataclasses import dataclass + +from contextzip import __version__ as installed_version + +PYPI_URL = "https://pypi.org/pypi/contextzip/json" +_TIMEOUT_SECS = 5 + + +# --------------------------------------------------------------------------- +# Version comparison (no `packaging` dependency) +# --------------------------------------------------------------------------- + + +def _parse_version(v: str) -> tuple[int, ...]: + """ + Turn "1.2.3" into (1, 2, 3) for comparison. Ignores any pre-release / + build suffix after the numeric core (e.g. "1.2.3rc1" -> (1, 2, 3)) — + good enough for "is there a newer release" without pulling in the + `packaging` library just for this. + """ + core = v.strip().lstrip("v") + parts: list[int] = [] + for chunk in core.split("."): + digits = "" + for ch in chunk: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + return tuple(parts) if parts else (0,) + + +def is_newer(latest: str, current: str) -> bool: + """True if *latest* is a strictly newer version than *current*.""" + a, b = _parse_version(latest), _parse_version(current) + length = max(len(a), len(b)) + a = a + (0,) * (length - len(a)) + b = b + (0,) * (length - len(b)) + return a > b + + +# --------------------------------------------------------------------------- +# PyPI lookup +# --------------------------------------------------------------------------- + + +@dataclass +class VersionCheck: + current: str + latest: str | None # None if the lookup failed + error: str | None = None + + @property + def update_available(self) -> bool: + return bool(self.latest) and is_newer(self.latest, self.current) + + +def check_latest_version(timeout: float = _TIMEOUT_SECS) -> VersionCheck: + """ + Query PyPI for the latest published contextzip version. + + Uses httpx (already a hard dependency) but never lets a network + problem escape as an exception — callers always get a VersionCheck + back, with `.error` set on failure. + """ + try: + import httpx + + resp = httpx.get(PYPI_URL, timeout=timeout) + resp.raise_for_status() + data = resp.json() + latest = data.get("info", {}).get("version") + if not latest: + return VersionCheck( + current=installed_version, latest=None, error="Malformed PyPI response" + ) + return VersionCheck(current=installed_version, latest=latest) + except Exception as exc: # noqa: BLE001 — any network/parsing failure + return VersionCheck( + current=installed_version, latest=None, error=f"{type(exc).__name__}: {exc}" + ) + + +# --------------------------------------------------------------------------- +# Install-method detection +# --------------------------------------------------------------------------- + + +def _installed_via_pipx() -> bool: + """ + Best-effort guess at whether this contextzip was installed with pipx + rather than plain pip — pipx installs live under a `pipx/venvs/...` + path, which shows up in the interpreter's own executable path. + """ + exe = sys.executable.replace("\\", "/").lower() + return "pipx" in exe + + +def _installed_via_uv_tool() -> bool: + exe = sys.executable.replace("\\", "/").lower() + return "/uv/tools/" in exe or "\\uv\\tools\\" in sys.executable.lower() + + +@dataclass +class UpdateResult: + ok: bool + method: str + output: str + + +def run_update(timeout: float = 120) -> UpdateResult: + """ + Upgrade the installed contextzip package to the latest PyPI release. + + Picks the tool that matches how contextzip was installed: + - pipx, if this interpreter lives inside a pipx venv and the pipx + executable is on PATH + - uv tool, if this interpreter lives inside a uv tool install + - otherwise `python -m pip install --upgrade contextzip`, using + the *currently running* interpreter's pip — never a bare "pip" + off PATH, which could silently target the wrong Python. + """ + if _installed_via_pipx() and shutil.which("pipx"): + cmd = ["pipx", "upgrade", "contextzip"] + method = "pipx" + elif _installed_via_uv_tool() and shutil.which("uv"): + cmd = ["uv", "tool", "upgrade", "contextzip"] + method = "uv tool" + else: + cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "contextzip"] + method = "pip" + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + output = (proc.stdout or "") + (proc.stderr or "") + return UpdateResult(ok=proc.returncode == 0, method=method, output=output.strip()) + except Exception as exc: # noqa: BLE001 + return UpdateResult(ok=False, method=method, output=f"{type(exc).__name__}: {exc}")