Coverage for src \ truenex_memory \ release \ manifest.py: 91%

43 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-19 10:21 +0200

1"""Release manifest parsing and validation.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Any 

7 

8 

9MANIFEST_VERSION = "1" 

10DEFAULT_MANIFEST_URL = ( 

11 "https://raw.githubusercontent.com/marcomnit/" 

12 "truenex-memory-releases/main/version.json" 

13) 

14VALID_CHANNELS = {"dev", "beta", "stable"} 

15 

16 

17@dataclass(frozen=True) 

18class ReleaseManifest: 

19 """Public update manifest read from the releases repository.""" 

20 

21 version: str 

22 channel: str = "dev" 

23 force_update: bool = False 

24 update_full: bool = False 

25 download_url: str | None = None 

26 release_notes_url: str | None = None 

27 requires_migration: bool = False 

28 min_supported_version: str = "0.1.0" 

29 manifest_version: str = MANIFEST_VERSION 

30 

31 @classmethod 

32 def from_dict(cls, payload: dict[str, Any]) -> "ReleaseManifest": 

33 """Validate and parse a release manifest payload.""" 

34 

35 version = _required_str(payload, "version") 

36 channel = str(payload.get("channel", "dev")) 

37 if channel not in VALID_CHANNELS: 

38 raise ValueError(f"unsupported release channel: {channel}") 

39 return cls( 

40 version=version, 

41 channel=channel, 

42 force_update=_bool(payload, "force_update", False), 

43 update_full=_bool(payload, "update_full", False), 

44 download_url=_optional_str(payload, "download_url"), 

45 release_notes_url=_optional_str(payload, "release_notes_url"), 

46 requires_migration=_bool(payload, "requires_migration", False), 

47 min_supported_version=str(payload.get("min_supported_version", "0.1.0")), 

48 manifest_version=str(payload.get("manifest_version", MANIFEST_VERSION)), 

49 ) 

50 

51 def to_dict(self) -> dict[str, Any]: 

52 """Return a JSON-friendly manifest dict.""" 

53 

54 return { 

55 "manifest_version": self.manifest_version, 

56 "version": self.version, 

57 "channel": self.channel, 

58 "force_update": self.force_update, 

59 "update_full": self.update_full, 

60 "download_url": self.download_url, 

61 "release_notes_url": self.release_notes_url, 

62 "requires_migration": self.requires_migration, 

63 "min_supported_version": self.min_supported_version, 

64 } 

65 

66 

67def _required_str(payload: dict[str, Any], key: str) -> str: 

68 value = payload.get(key) 

69 if not isinstance(value, str) or not value.strip(): 

70 raise ValueError(f"manifest field {key!r} must be a non-empty string") 

71 return value 

72 

73 

74def _optional_str(payload: dict[str, Any], key: str) -> str | None: 

75 value = payload.get(key) 

76 if value is None: 

77 return None 

78 if not isinstance(value, str) or not value.strip(): 

79 raise ValueError(f"manifest field {key!r} must be null or a non-empty string") 

80 return value 

81 

82 

83def _bool(payload: dict[str, Any], key: str, default: bool) -> bool: 

84 value = payload.get(key, default) 

85 if not isinstance(value, bool): 

86 raise ValueError(f"manifest field {key!r} must be boolean") 

87 return value