Skip to content

Commit fb76d9c

Browse files
committed
feat(cli): add daily PyPI update-check notice
- best-effort update notice on stderr, throttled once/day via ~/.webskrap cache - stdlib only (urllib + importlib.metadata), no new dependency - skips on WEBSKRAP_NO_UPDATE_CHECK, CI, and non-tty; never touches stdout - bump version 0.5.7
1 parent f2e2cd5 commit fb76d9c

5 files changed

Lines changed: 136 additions & 2 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,11 @@ webskrap fetch https://amiunique.org/fr/fingerprint \
389389

390390
Use repeated `--launch-arg=...` options for advanced browser flags.
391391

392+
The CLI checks PyPI once a day (best-effort) and prints an "update available"
393+
notice to stderr when a newer `webskrap` is released. Set
394+
`WEBSKRAP_NO_UPDATE_CHECK=1` to disable it; it is also skipped under `CI` and when
395+
stderr is not a TTY.
396+
392397
## MCP server
393398

394399
WebSkrap ships an optional Model Context Protocol server so MCP clients (Claude

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "webskrap"
7-
version = "0.5.6"
7+
version = "0.5.7"
88
description = "A Playwright-based Python scraping framework with coherent browser profiles and session controls."
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/webskrap/cli.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import asyncio
44
import json
5+
import os
56
import subprocess
67
import sys
8+
import time
9+
import urllib.request
10+
from importlib import metadata
711
from pathlib import Path
812
from typing import Annotated, Literal
913

@@ -32,6 +36,69 @@
3236
(sys.executable, "-m", "playwright", "install", "chromium"),
3337
(sys.executable, "-m", "patchright", "install", "chromium"),
3438
)
39+
UPDATE_CHECK_URL = "https://pypi.org/pypi/webskrap/json"
40+
UPDATE_CHECK_INTERVAL = 86_400 # once per day
41+
UPDATE_CHECK_CACHE = Path.home() / ".webskrap" / "update-check.json"
42+
# ponytail: ~/.webskrap not XDG/APPDATA-aware; swap to platformdirs if that matters
43+
44+
45+
def _is_newer(latest: str, current: str) -> bool:
46+
# ponytail: naive X.Y.Z compare; swap to packaging.version if pre-release tags ever ship
47+
try:
48+
return tuple(map(int, latest.split("."))) > tuple(map(int, current.split(".")))
49+
except ValueError:
50+
return False
51+
52+
53+
def _check_for_update() -> None:
54+
"""Best-effort 'update available' notice. Never raises, never touches stdout."""
55+
try:
56+
if (
57+
os.environ.get("WEBSKRAP_NO_UPDATE_CHECK")
58+
or os.environ.get("CI")
59+
or not sys.stderr.isatty()
60+
):
61+
return
62+
63+
current = metadata.version("webskrap")
64+
latest: str | None = None
65+
66+
try:
67+
cached = json.loads(UPDATE_CHECK_CACHE.read_text())
68+
if time.time() - cached["checked_at"] < UPDATE_CHECK_INTERVAL:
69+
latest = cached["latest"]
70+
except Exception:
71+
latest = None
72+
73+
if latest is None:
74+
fetched: str | None = None
75+
try:
76+
with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=2) as response:
77+
fetched = json.load(response)["info"]["version"]
78+
except Exception:
79+
fetched = None
80+
# Stamp the attempt either way so a PyPI outage can't cause hammering.
81+
latest = fetched or current
82+
try:
83+
UPDATE_CHECK_CACHE.parent.mkdir(parents=True, exist_ok=True)
84+
UPDATE_CHECK_CACHE.write_text(
85+
json.dumps({"checked_at": time.time(), "latest": latest})
86+
)
87+
except Exception:
88+
pass
89+
90+
if _is_newer(latest, current):
91+
Console(stderr=True, highlight=False).print(
92+
f"[yellow]webskrap {latest} available[/] (you have {current}) — "
93+
"upgrade: [bold]pip install -U webskrap[/]"
94+
)
95+
except Exception:
96+
return
97+
98+
99+
@app.callback()
100+
def _main() -> None:
101+
_check_for_update()
35102

36103

37104
@app.command("install")

tests/test_cli.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,65 @@ def fake_run(command: tuple[str, ...], **_kwargs: Any) -> subprocess.CompletedPr
210210
assert "patchright" in result.output
211211
assert "install" in result.output
212212
assert "chromium" in result.output
213+
214+
215+
class _FakeResponse:
216+
def __init__(self, version: str) -> None:
217+
self._version = version
218+
219+
def __enter__(self) -> _FakeResponse:
220+
return self
221+
222+
def __exit__(self, *_: object) -> None:
223+
return None
224+
225+
def read(self) -> bytes:
226+
return json.dumps({"info": {"version": self._version}}).encode()
227+
228+
229+
def test_is_newer_truth_table() -> None:
230+
assert cli._is_newer("0.6.0", "0.5.6") is True
231+
assert cli._is_newer("0.5.7", "0.5.6") is True
232+
assert cli._is_newer("0.5.6", "0.5.6") is False
233+
assert cli._is_newer("0.5.5", "0.5.6") is False
234+
assert cli._is_newer("1.0.0rc1", "0.5.6") is False # malformed -> False
235+
236+
237+
def _enable_update_check(monkeypatch: Any, tmp_path: Any) -> None:
238+
monkeypatch.delenv("WEBSKRAP_NO_UPDATE_CHECK", raising=False)
239+
monkeypatch.delenv("CI", raising=False)
240+
monkeypatch.setattr(cli.sys.stderr, "isatty", lambda: True, raising=False)
241+
monkeypatch.setattr(cli, "UPDATE_CHECK_CACHE", tmp_path / "update-check.json")
242+
monkeypatch.setattr(cli.metadata, "version", lambda _name: "0.5.6")
243+
244+
245+
def test_update_check_opt_out_skips_network(monkeypatch: Any, tmp_path: Any) -> None:
246+
_enable_update_check(monkeypatch, tmp_path)
247+
monkeypatch.setenv("WEBSKRAP_NO_UPDATE_CHECK", "1")
248+
249+
def boom(*_a: Any, **_k: Any) -> Any:
250+
raise AssertionError("network must not be called")
251+
252+
monkeypatch.setattr(cli.urllib.request, "urlopen", boom)
253+
cli._check_for_update() # no raise = pass
254+
255+
256+
def test_update_check_notifies_and_caches(monkeypatch: Any, tmp_path: Any, capsys: Any) -> None:
257+
_enable_update_check(monkeypatch, tmp_path)
258+
calls = {"n": 0}
259+
260+
def fake_urlopen(*_a: Any, **_k: Any) -> _FakeResponse:
261+
calls["n"] += 1
262+
return _FakeResponse("0.9.9")
263+
264+
monkeypatch.setattr(cli.urllib.request, "urlopen", fake_urlopen)
265+
266+
cli._check_for_update()
267+
err = capsys.readouterr().err
268+
assert "0.9.9 available" in err
269+
assert "0.5.6" in err
270+
assert (tmp_path / "update-check.json").exists()
271+
272+
# Fresh cache -> no second network call.
273+
cli._check_for_update()
274+
assert calls["n"] == 1

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)