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

1"""Manual update check support.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6import json 

7from typing import Callable 

8from urllib.request import Request, urlopen 

9 

10from truenex_memory.release.manifest import DEFAULT_MANIFEST_URL, ReleaseManifest 

11from truenex_memory.release.version import APP_VERSION 

12 

13 

14Fetcher = Callable[[str], dict[str, object]] 

15 

16 

17@dataclass(frozen=True) 

18class UpdateCheckResult: 

19 """Result of a local-first manual update check.""" 

20 

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 

32 

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 } 

47 

48 

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. 

56 

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 """ 

60 

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 ) 

77 

78 

79def fetch_manifest(manifest_url: str) -> dict[str, object]: 

80 """Read a JSON manifest from a public URL.""" 

81 

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 

88 

89 

90def compare_versions(left: str, right: str) -> int: 

91 """Compare SemVer-like versions, returning 1, 0 or -1.""" 

92 

93 left_parts = _version_tuple(left) 

94 right_parts = _version_tuple(right) 

95 return (left_parts > right_parts) - (left_parts < right_parts) 

96 

97 

98import re 

99 

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