feat(cli): add contextzip update / cz update self-update command

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.
This commit is contained in:
akadeepesh
2026-09-20 11:56:37 +05:30
parent 6ac6b85070
commit 20490a75e8
2 changed files with 250 additions and 0 deletions
+85
View File
@@ -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)
# ---------------------------------------------------------------------------
+165
View File
@@ -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}")