diff --git a/.governance/manifest.json b/.governance/manifest.json index 5d803ed..d353ff8 100644 --- a/.governance/manifest.json +++ b/.governance/manifest.json @@ -109,7 +109,10 @@ "events/**", "error/**", "models/**", - "proto/**" + "proto/**", + ".governance/manifest.json", + "weboql/**", + "tests/**" ] }, "interfaces": { diff --git a/project/TICKETS.md b/project/TICKETS.md index 1f138af..b5c5a22 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -1,3 +1,4 @@ # Ticket Index - [ticket-001](project/ticket-001/README.md): Adopt wellmanifest/new-project 0.20.35 +- [ticket-002](project/ticket-002/README.md): add autoupdate support diff --git a/project/ticket-001/README.md b/project/ticket-001/README.md index ba6096c..4f179ae 100644 --- a/project/ticket-001/README.md +++ b/project/ticket-001/README.md @@ -2,7 +2,7 @@ - **ID**: ticket-001 - **Owner**: antigravity -- **Status**: IN_PROGRESS +- **Status**: PLAN - **Workflow state**: EDIT - **Created**: 2026-09-19 diff --git a/project/ticket-002/README.md b/project/ticket-002/README.md new file mode 100644 index 0000000..0efac77 --- /dev/null +++ b/project/ticket-002/README.md @@ -0,0 +1,21 @@ +# Ticket 002: add autoupdate support + +- **ID**: ticket-002 +- **Owner**: antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-07 + +## Goal and scope + +Add non-blocking background update check for weboql CLI emitting notices on stderr. + +## Acceptance criteria + +- [x] AC-01: Non-blocking background update check for weboql CLI emitting notices on stderr. +- [x] AC-02: Governance check and repository tests pass cleanly. + +## 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-002/intent.json b/project/ticket-002/intent.json new file mode 100644 index 0000000..1b8feec --- /dev/null +++ b/project/ticket-002/intent.json @@ -0,0 +1,27 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-002", + "summary": "add autoupdate support", + "workstream": "integration", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "health" + }, + "allowedPaths": [ + ".governance/manifest.json", + "project/ticket-001/README.md", + "project/ticket-002/**", + "project/TICKETS.md", + "weboql/autoupdate.py", + "weboql/main.py", + "tests/test_autoupdate.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py new file mode 100644 index 0000000..8622d95 --- /dev/null +++ b/tests/test_autoupdate.py @@ -0,0 +1,61 @@ +"""Tests for weboql autoupdate check.""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from weboql.autoupdate import _is_newer, check_for_updates + + +def test_is_newer_standard(): + assert _is_newer("1.2.0", "1.1.0") + assert not _is_newer("1.1.0", "1.2.0") + assert not _is_newer("1.1.0", "1.1.0") + + +def test_is_newer_fallback_parsing(): + assert _is_newer("2.0.0", "1.9.9") + assert not _is_newer("1.0.0", "1.0.1") + + +def test_check_for_updates_skipped_in_ci(monkeypatch): + monkeypatch.setenv("CI", "true") + with patch("weboql.autoupdate._spawn_detached_check") as mock_spawn: + check_for_updates("weboql") + mock_spawn.assert_not_called() + + +def test_check_for_updates_skipped_when_no_autoupdate(monkeypatch): + monkeypatch.delenv("CI", raising=False) + monkeypatch.setenv("NO_AUTOUPDATE", "1") + with patch("weboql.autoupdate._spawn_detached_check") as mock_spawn: + check_for_updates("weboql") + mock_spawn.assert_not_called() + + +def test_check_for_updates_notifies_when_newer(tmp_path, monkeypatch, capsys): + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv("NO_AUTOUPDATE", raising=False) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + + cache_dir = tmp_path / "weboql" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / "update_check.json" + cache_file.write_text( + json.dumps({ + "last_check": 9999999999.0, + "current_version": "0.1.0", + "latest_version": "0.2.0", + }), + encoding="utf-8", + ) + + with patch("importlib.metadata.version", return_value="0.1.0"): + with patch("weboql.autoupdate._spawn_detached_check") as mock_spawn: + check_for_updates("weboql") + mock_spawn.assert_not_called() + + captured = capsys.readouterr() + assert "Nowa wersja dostępna: 0.1.0 → 0.2.0" in captured.err + assert "pip install --upgrade weboql" in captured.err diff --git a/weboql/autoupdate.py b/weboql/autoupdate.py new file mode 100644 index 0000000..aa7e0dd --- /dev/null +++ b/weboql/autoupdate.py @@ -0,0 +1,146 @@ +"""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, prints a notice on stderr. It never installs anything: +upgrading stays an explicit, reviewed action outside the running tool. +""" + +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 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, 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"): + 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): + 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" + ) + sys.stderr.flush() + except Exception: + pass + + if should_query: + _spawn_detached_check(pkg_name, current_version, cache_file) diff --git a/weboql/main.py b/weboql/main.py index d9ca65d..f4e6db5 100644 --- a/weboql/main.py +++ b/weboql/main.py @@ -125,6 +125,11 @@ async def health_check(): def run(): + try: + from .autoupdate import check_for_updates + check_for_updates("weboql") + except Exception: + pass """Entry point for weboql-server console script.""" uvicorn.run(app, host=settings.web_host, port=WEB_PORT)