Coverage for app/services/version_service.py: 100%
64 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1"""
2Version-check service — GitHub Pages versions list, AppSetting cache, background thread.
4Kept in services/ so both init.py (thread start) and config/routes.py
5(force-refresh endpoint) can import from here without creating a cycle.
6"""
8from typing import Any
9from urllib.parse import urlparse
11_VERSION_CHECK_HOST = "e2jk.github.io"
12_VERSIONS_JSON_URL = f"https://{_VERSION_CHECK_HOST}/OpenHangar/versions.json"
15def fetch_versions() -> list[str]:
16 """Fetch the ordered list of all published versions from GitHub Pages. Returns [] on error."""
17 import json
18 import urllib.error
19 import urllib.request
21 class _StrictRedirect(urllib.request.HTTPRedirectHandler):
22 """Block any redirect that leaves the allowed host."""
24 def redirect_request(
25 self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
26 ) -> Any:
27 host = urlparse(newurl).netloc
28 if host != _VERSION_CHECK_HOST:
29 raise urllib.error.URLError(
30 f"version-check redirect to {host!r} blocked"
31 )
32 return super().redirect_request(req, fp, code, msg, headers, newurl)
34 opener = urllib.request.build_opener(_StrictRedirect)
35 req = urllib.request.Request(
36 _VERSIONS_JSON_URL,
37 headers={"User-Agent": "OpenHangar-version-check"},
38 )
39 try:
40 with opener.open(req, timeout=10) as resp: # nosec B310
41 data = json.loads(resp.read())
42 return data if isinstance(data, list) else []
43 except Exception:
44 return []
47def fetch_latest_version() -> str | None:
48 """Return the most recent published version, or None on error."""
49 versions = fetch_versions()
50 return versions[0] if versions else None
53def upsert_app_setting(db_session: Any, key: str, value: str) -> None:
54 from models import AppSetting # pyright: ignore[reportMissingImports]
56 setting = db_session.get(AppSetting, key)
57 if setting:
58 setting.value = value
59 else:
60 db_session.add(AppSetting(key=key, value=value))
63def run_version_check(app: Any) -> None:
64 """Check GitHub Pages for the versions list and cache results in AppSetting."""
65 import json
66 from datetime import datetime, timedelta, timezone
68 from models import AppSetting, db # pyright: ignore[reportMissingImports]
70 with app.app_context():
71 last = db.session.get(AppSetting, "version_last_checked_at")
72 if last and last.value:
73 try:
74 if datetime.now(timezone.utc) - datetime.fromisoformat(
75 last.value
76 ) < timedelta(hours=23):
77 return
78 except ValueError:
79 pass # malformed stored timestamp — proceed with the check
81 versions = fetch_versions()
82 upsert_app_setting(
83 db.session,
84 "version_last_checked_at",
85 datetime.now(timezone.utc).isoformat(),
86 )
87 if versions:
88 upsert_app_setting(db.session, "latest_version", versions[0])
89 upsert_app_setting(db.session, "all_versions", json.dumps(versions))
90 db.session.commit()
93def version_check_loop(app: Any, _sleep_fn: Any = None) -> None:
94 """Daemon thread body — random startup delay then every 24 h."""
95 import random
96 import time as _time
98 sleep = _sleep_fn if _sleep_fn is not None else _time.sleep
99 sleep(random.randint(0, 6 * 3600))
100 while True:
101 try:
102 run_version_check(app)
103 except Exception:
104 app.logger.exception("Version check failed; will retry in 24 h")
105 sleep(24 * 3600)
108def start_version_check_thread(app: Any) -> None:
109 import threading
111 t = threading.Thread(
112 target=version_check_loop,
113 args=(app,),
114 daemon=True,
115 name="version-check",
116 )
117 t.start()