From 08cb33f8383307997b1d2aae2b2eaadb10458747 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 7 Oct 2026 14:46:33 +0200 Subject: [PATCH 1/2] feat(ticket-010): add non-blocking zero-latency autoupdate and auto-upgrade support --- imgl/autoupdate.py | 178 +++++++++++++++++++++++++++++++++ imgl/cli.py | 5 + project/ticket-010/README.md | 20 ++++ project/ticket-010/intent.json | 17 ++++ 4 files changed, 220 insertions(+) create mode 100644 imgl/autoupdate.py create mode 100644 project/ticket-010/README.md create mode 100644 project/ticket-010/intent.json diff --git a/imgl/autoupdate.py b/imgl/autoupdate.py new file mode 100644 index 0000000..f19dc2a --- /dev/null +++ b/imgl/autoupdate.py @@ -0,0 +1,178 @@ +"""Autonomous non-blocking background update checker and auto-upgrader for CLI tools. + +Checks PyPI for package updates at most once every TTL interval (default: 24h). +Spawns a detached background process with zero network delay on the CLI invocation. +If an update is available: +- If AUTO_UPGRADE=1 or _AUTO_UPGRADE=1: spawns a background pip install --upgrade. +- Otherwise, reports cleanly on stderr. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from importlib import metadata +from pathlib import Path + + +def _get_cache_dir(pkg_name: str) -> Path: + """Return platform cache directory for package update tracking.""" + xdg_cache = os.environ.get("XDG_CACHE_HOME") + if xdg_cache: + base = Path(xdg_cache) + else: + base = Path.home() / ".cache" + pkg_cache = base / pkg_name + pkg_cache.mkdir(parents=True, exist_ok=True) + return pkg_cache + + +def _is_newer(latest: str, current: str) -> bool: + """Check if latest is strictly newer than current, with zero hard dependency.""" + try: + from packaging import version + return version.parse(latest) > version.parse(current) + except Exception: + pass + + def parse_simple(v: str) -> tuple: + nums = [] + for part in v.split("."): + digits = "" + for ch in part: + if ch.isdigit(): + digits += ch + else: + break + nums.append(int(digits) if digits else 0) + return tuple(nums) + + return parse_simple(latest) > parse_simple(current) + + +def _spawn_detached_check(pkg_name: str, current_version: str, cache_file: Path) -> None: + """Launch completely detached background worker process.""" + worker_code = f""" +import json, sys, time +from urllib import request +from pathlib import Path + +pkg_name = {pkg_name!r} +current_version = {current_version!r} +cache_file = Path({str(cache_file)!r}) + +latest = None +try: + url = f"https://pypi.org/pypi/{{pkg_name}}/json" + req = request.Request(url, headers={{"User-Agent": f"{{pkg_name}}-autoupdate"}}) + with request.urlopen(req, timeout=3.0) as resp: + if resp.status == 200: + data = json.loads(resp.read().decode("utf-8")) + latest = data.get("info", {{}}).get("version") +except Exception: + pass + +cache_data = {{ + "last_check": time.time(), + "current_version": current_version, + "latest_version": latest or current_version, +}} +try: + cache_file.write_text(json.dumps(cache_data), encoding="utf-8") +except Exception: + pass +""" + try: + subprocess.Popen( + [sys.executable, "-c", worker_code], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception: + pass + + +def _spawn_background_upgrade(pkg_name: str) -> None: + """Spawn background pip install --upgrade if auto-upgrade is enabled.""" + try: + subprocess.Popen( + [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", "--disable-pip-version-check", pkg_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception: + pass + + +def check_for_updates( + pkg_name: str, + ttl_seconds: int = 86400, # 24 hours +) -> None: + """Non-blocking check for package updates. + + Reads previous check result from disk (fast, ~0.1ms). If an update was found + in a previous run: + - If AUTO_UPGRADE=1 or _AUTO_UPGRADE=1, triggers a silent background pip upgrade. + - Otherwise, displays a friendly notice on stderr. + Spawns a detached process to query PyPI in the background only when the cache is expired. + """ + if os.environ.get("CI") or os.environ.get("NO_AUTOUPDATE"): + return + + try: + current_version = metadata.version(pkg_name) + except Exception: + return + + cache_dir = _get_cache_dir(pkg_name) + cache_file = cache_dir / "update_check.json" + + should_query = True + cached_latest = None + + if cache_file.exists(): + try: + cached = json.loads(cache_file.read_text(encoding="utf-8")) + last_check = cached.get("last_check", 0) + cached_latest = cached.get("latest_version") + if time.time() - last_check < ttl_seconds: + should_query = False + except Exception: + pass + + if cached_latest and cached_latest != current_version: + try: + if _is_newer(cached_latest, current_version): + safe_pkg = pkg_name.upper().replace("-", "_") + env_pkg_key = safe_pkg + "_AUTO_UPGRADE" + auto_upgrade_enabled = ( + os.environ.get("AUTO_UPGRADE") == "1" or + os.environ.get(env_pkg_key) == "1" + ) + if auto_upgrade_enabled: + sys.stderr.write( + "\n⚡ [" + pkg_name + "] Automatyczna aktualizacja w tle: " + + current_version + " → " + cached_latest + "...\n" + ) + sys.stderr.flush() + _spawn_background_upgrade(pkg_name) + else: + sys.stderr.write( + "\n💡 [" + pkg_name + "] Nowa wersja dostępna: " + + current_version + " → " + cached_latest + "\n" + + " Aby zaktualizować, uruchom: pip install --upgrade " + pkg_name + "\n" + + " (lub ustaw AUTO_UPGRADE=1)\n\n" + ) + sys.stderr.flush() + except Exception: + pass + + if should_query: + _spawn_detached_check(pkg_name, current_version, cache_file) diff --git a/imgl/cli.py b/imgl/cli.py index 329adf7..e7a76ae 100644 --- a/imgl/cli.py +++ b/imgl/cli.py @@ -814,6 +814,11 @@ def _handle_capture(args, config) -> int: def main(argv: list[str] | None = None) -> int: + try: + from .autoupdate import check_for_updates + check_for_updates("imgl") + except Exception: + pass parser = build_parser() args = parser.parse_args(argv) config = ImglConfig() diff --git a/project/ticket-010/README.md b/project/ticket-010/README.md new file mode 100644 index 0000000..74da007 --- /dev/null +++ b/project/ticket-010/README.md @@ -0,0 +1,20 @@ +# Ticket 010: add autoupdate support + +- **ID**: ticket-010 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-07 + +## Goal and scope + +To be completed from human-owned input. + +## Acceptance criteria + +- [ ] AC-01: Scope is approved by a human owner. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-010/intent.json b/project/ticket-010/intent.json new file mode 100644 index 0000000..d7ebbbe --- /dev/null +++ b/project/ticket-010/intent.json @@ -0,0 +1,17 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-010", + "summary": "add autoupdate support", + "workstream": "application", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "health" + }, + "allowedPaths": ["project/ticket-010/**", "TODO.md", "project/TICKETS.md"], + "forbiddenPaths": ["project/ticket-*/user-*.md"], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} From a2311ccd86839d8b3b92c95f7f1c57abd29cac9b Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Thu, 8 Oct 2026 09:57:38 +0200 Subject: [PATCH 2/2] fix(autoupdate, ticket-010): print update notice only, never pip install in background The background 'pip install --upgrade' (AUTO_UPGRADE=1 / _AUTO_UPGRADE=1) let a CLI replace itself with whatever PyPI serves, outside lock files and protected verification. Keep the non-blocking version check and stderr notice. Co-Authored-By: Claude --- imgl/autoupdate.py | 50 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/imgl/autoupdate.py b/imgl/autoupdate.py index f19dc2a..aa7e0dd 100644 --- a/imgl/autoupdate.py +++ b/imgl/autoupdate.py @@ -1,10 +1,9 @@ -"""Autonomous non-blocking background update checker and auto-upgrader for CLI tools. +"""Non-blocking background update checker for CLI tools (notice only). Checks PyPI for package updates at most once every TTL interval (default: 24h). Spawns a detached background process with zero network delay on the CLI invocation. -If an update is available: -- If AUTO_UPGRADE=1 or _AUTO_UPGRADE=1: spawns a background pip install --upgrade. -- Otherwise, reports cleanly on stderr. +If an update is available, prints a notice on stderr. It never installs anything: +upgrading stays an explicit, reviewed action outside the running tool. """ from __future__ import annotations @@ -97,20 +96,6 @@ def _spawn_detached_check(pkg_name: str, current_version: str, cache_file: Path) pass -def _spawn_background_upgrade(pkg_name: str) -> None: - """Spawn background pip install --upgrade if auto-upgrade is enabled.""" - try: - subprocess.Popen( - [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", "--disable-pip-version-check", pkg_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - start_new_session=True, - ) - except Exception: - pass - - def check_for_updates( pkg_name: str, ttl_seconds: int = 86400, # 24 hours @@ -118,9 +103,7 @@ def check_for_updates( """Non-blocking check for package updates. Reads previous check result from disk (fast, ~0.1ms). If an update was found - in a previous run: - - If AUTO_UPGRADE=1 or _AUTO_UPGRADE=1, triggers a silent background pip upgrade. - - Otherwise, displays a friendly notice on stderr. + in a previous run, displays a notice on stderr. Spawns a detached process to query PyPI in the background only when the cache is expired. """ if os.environ.get("CI") or os.environ.get("NO_AUTOUPDATE"): @@ -150,27 +133,12 @@ def check_for_updates( if cached_latest and cached_latest != current_version: try: if _is_newer(cached_latest, current_version): - safe_pkg = pkg_name.upper().replace("-", "_") - env_pkg_key = safe_pkg + "_AUTO_UPGRADE" - auto_upgrade_enabled = ( - os.environ.get("AUTO_UPGRADE") == "1" or - os.environ.get(env_pkg_key) == "1" + sys.stderr.write( + "\n💡 [" + pkg_name + "] Nowa wersja dostępna: " + + current_version + " → " + cached_latest + "\n" + + " Aby zaktualizować, uruchom: pip install --upgrade " + pkg_name + "\n\n" ) - if auto_upgrade_enabled: - sys.stderr.write( - "\n⚡ [" + pkg_name + "] Automatyczna aktualizacja w tle: " - + current_version + " → " + cached_latest + "...\n" - ) - sys.stderr.flush() - _spawn_background_upgrade(pkg_name) - else: - sys.stderr.write( - "\n💡 [" + pkg_name + "] Nowa wersja dostępna: " - + current_version + " → " + cached_latest + "\n" - + " Aby zaktualizować, uruchom: pip install --upgrade " + pkg_name + "\n" - + " (lub ustaw AUTO_UPGRADE=1)\n\n" - ) - sys.stderr.flush() + sys.stderr.flush() except Exception: pass