Coverage for src \ truenex_memory \ release \ update_check.py: 80%
49 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-19 10:21 +0200
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-19 10:21 +0200
1"""Manual update check support."""
3from __future__ import annotations
5from dataclasses import dataclass
6import json
7from typing import Callable
8from urllib.request import Request, urlopen
10from truenex_memory.release.manifest import DEFAULT_MANIFEST_URL, ReleaseManifest
11from truenex_memory.release.version import APP_VERSION
14Fetcher = Callable[[str], dict[str, object]]
17@dataclass(frozen=True)
18class UpdateCheckResult:
19 """Result of a local-first manual update check."""
21 current_version: str
22 latest_version: str
23 update_available: bool
24 force_update: bool
25 update_full: bool
26 requires_migration: bool
27 min_supported_version: str
28 channel: str
29 download_url: str | None
30 release_notes_url: str | None
31 manifest_url: str
33 def to_dict(self) -> dict[str, object]:
34 return {
35 "current_version": self.current_version,
36 "latest_version": self.latest_version,
37 "update_available": self.update_available,
38 "force_update": self.force_update,
39 "update_full": self.update_full,
40 "requires_migration": self.requires_migration,
41 "min_supported_version": self.min_supported_version,
42 "channel": self.channel,
43 "download_url": self.download_url,
44 "release_notes_url": self.release_notes_url,
45 "manifest_url": self.manifest_url,
46 }
49def check_for_updates(
50 *,
51 manifest_url: str = DEFAULT_MANIFEST_URL,
52 current_version: str = APP_VERSION,
53 fetcher: Fetcher | None = None,
54) -> UpdateCheckResult:
55 """Fetch a public manifest and compare it to the installed version.
57 This sends only an HTTP GET to the manifest URL. It does not send project
58 paths, indexed content, memory data, or machine identifiers.
59 """
61 payload = (fetcher or fetch_manifest)(manifest_url)
62 manifest = ReleaseManifest.from_dict(payload)
63 update_available = manifest.force_update or compare_versions(manifest.version, current_version) > 0
64 return UpdateCheckResult(
65 current_version=current_version,
66 latest_version=manifest.version,
67 update_available=update_available,
68 force_update=manifest.force_update,
69 update_full=manifest.update_full,
70 requires_migration=manifest.requires_migration,
71 min_supported_version=manifest.min_supported_version,
72 channel=manifest.channel,
73 download_url=manifest.download_url,
74 release_notes_url=manifest.release_notes_url,
75 manifest_url=manifest_url,
76 )
79def fetch_manifest(manifest_url: str) -> dict[str, object]:
80 """Read a JSON manifest from a public URL."""
82 request = Request(manifest_url, headers={"User-Agent": "truenex-memory-update-check"})
83 with urlopen(request, timeout=10) as response:
84 payload = json.loads(response.read().decode("utf-8"))
85 if not isinstance(payload, dict):
86 raise ValueError("release manifest must be a JSON object")
87 return payload
90def compare_versions(left: str, right: str) -> int:
91 """Compare SemVer-like versions, returning 1, 0 or -1."""
93 left_parts = _version_tuple(left)
94 right_parts = _version_tuple(right)
95 return (left_parts > right_parts) - (left_parts < right_parts)
98import re
100def _version_tuple(version: str) -> tuple[int, int, int]:
101 raw = version.strip().lstrip("v")
102 m = re.match(r"(\d+)\.(\d+)\.(\d+)", raw)
103 if not m:
104 raise ValueError(f"version must use MAJOR.MINOR.PATCH: {version!r}")
105 try:
106 return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
107 except ValueError as exc:
108 raise ValueError(f"version must use numeric MAJOR.MINOR.PATCH: {version!r}") from exc