Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .governance/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@
"events/**",
"error/**",
"models/**",
"proto/**"
"proto/**",
".governance/manifest.json",
"weboql/**",
"tests/**"
]
},
"interfaces": {
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion project/ticket-001/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- **ID**: ticket-001
- **Owner**: antigravity
- **Status**: IN_PROGRESS
- **Status**: PLAN
- **Workflow state**: EDIT
- **Created**: 2026-09-19

Expand Down
21 changes: 21 additions & 0 deletions project/ticket-002/README.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions project/ticket-002/intent.json
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions tests/test_autoupdate.py
Original file line number Diff line number Diff line change
@@ -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
146 changes: 146 additions & 0 deletions weboql/autoupdate.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions weboql/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading