Coverage for paintmaskanimator/updater.py: 65%

83 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""In-app updater against the project's GitHub Releases. 

2 

3The latest published release is read from the public REST API at 

4:data:`~paintmaskanimator.constants.UPDATE_RELEASE_API_URL` -- drafts and 

5pre-releases are excluded by that endpoint -- and the installer for the running 

6platform is downloaded from the release's assets. No credentials are involved. 

7 

8Only the fields used here are read from the release:: 

9 

10 { 

11 "tag_name": "v0.6.6", 

12 "assets": [ 

13 {"name": "PaintMaskAnimator-Setup-0.6.6.exe", 

14 "browser_download_url": "https://github.com/.../PaintMaskAnimator-Setup-0.6.6.exe"}, 

15 {"name": "PaintMaskAnimator-0.6.6-macOS.dmg", 

16 "browser_download_url": "https://github.com/.../PaintMaskAnimator-0.6.6-macOS.dmg"} 

17 ] 

18 } 

19 

20The pure helpers (version parsing/compare, asset picking) are unit tested; the 

21network functions are thin wrappers over urllib. 

22""" 

23import json 

24import re 

25import sys 

26import urllib.error 

27import urllib.request 

28 

29from .constants import APP_VERSION, UPDATE_RELEASE_API_URL 

30from .i18n import tr 

31from .logging_setup import get_logger 

32 

33log = get_logger(__name__) 

34 

35_TIMEOUT = 15 

36 

37 

38def parse_version(text): 

39 """Parse a version string like 'v0.5.0' / '0.5' into a tuple of ints. 

40 

41 Non-numeric suffixes (e.g. '1.2.0-beta') are ignored for the numeric part. 

42 Returns an empty tuple if nothing numeric is found. 

43 """ 

44 if not text: 

45 return () 

46 text = str(text).strip().lstrip("vV") 

47 numbers = re.findall(r"\d+", text) 

48 return tuple(int(n) for n in numbers) 

49 

50 

51def is_newer(latest, current): 

52 """True if version string ``latest`` is strictly newer than ``current``.""" 

53 a = parse_version(latest) 

54 b = parse_version(current) 

55 if not a: 

56 return False 

57 length = max(len(a), len(b)) 

58 a += (0,) * (length - len(a)) 

59 b += (0,) * (length - len(b)) 

60 return a > b 

61 

62 

63def current_platform_key(): 

64 """The installer platform of the running app: windows / macos / linux.""" 

65 if sys.platform.startswith("win"): 

66 return "windows" 

67 if sys.platform == "darwin": 

68 return "macos" 

69 return "linux" 

70 

71 

72_INSTALLER_SUFFIX = {"windows": ".exe", "macos": ".dmg"} 

73 

74 

75def pick_installer_asset(release, platform_key=None): 

76 """The installer asset of *release* for ``platform_key``, or ``None``. 

77 

78 Returns ``{"name": ..., "url": ...}``. On Windows a ``Setup`` installer is 

79 preferred should a release ever carry more than one ``.exe``. 

80 """ 

81 suffix = _INSTALLER_SUFFIX.get(platform_key or current_platform_key()) 

82 assets = release.get("assets") 

83 if not suffix or not isinstance(assets, list): 

84 return None 

85 candidates = [ 

86 asset for asset in assets 

87 if isinstance(asset, dict) 

88 and str(asset.get("name", "")).lower().endswith(suffix) 

89 and asset.get("browser_download_url") 

90 ] 

91 if not candidates: 

92 return None 

93 chosen = next( 

94 (a for a in candidates if "setup" in str(a["name"]).lower()), 

95 candidates[0], 

96 ) 

97 return {"name": str(chosen["name"]), "url": str(chosen["browser_download_url"])} 

98 

99 

100def _request(url, accept): 

101 return urllib.request.Request( 

102 url, headers={"Accept": accept, "User-Agent": "PaintMaskAnimator-Updater"} 

103 ) 

104 

105 

106def fetch_latest_release(url=UPDATE_RELEASE_API_URL): 

107 """Fetch the latest release as a dict. Raises urllib errors on failure.""" 

108 request = _request(url, "application/vnd.github+json") 

109 with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: 

110 return json.loads(response.read().decode("utf-8")) 

111 

112 

113def download_asset(asset, dest_path, progress=None): 

114 """Download an installer asset to ``dest_path``. 

115 

116 ``progress`` (optional) is called with (downloaded_bytes, total_bytes). 

117 """ 

118 request = _request(asset["url"], "application/octet-stream") 

119 with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: 

120 total = int(response.headers.get("Content-Length", 0)) 

121 downloaded = 0 

122 with open(dest_path, "wb") as out: 

123 while True: 

124 chunk = response.read(65536) 

125 if not chunk: 

126 break 

127 out.write(chunk) 

128 downloaded += len(chunk) 

129 if progress: 

130 progress(downloaded, total) 

131 return dest_path 

132 

133 

134def check_for_update(release_url=UPDATE_RELEASE_API_URL, current_version=APP_VERSION): 

135 """Compare the latest release with ``current_version``. Returns a dict: 

136 

137 {"status": "up_to_date" | "update_available" | "error", 

138 "latest": <version>, "release": <json>, "asset": <asset|None>, 

139 "message": <str for error>} 

140 """ 

141 try: 

142 release = fetch_latest_release(release_url) 

143 except urllib.error.HTTPError as error: 

144 if error.code == 404: 

145 return {"status": "error", "message": tr("公開されているリリースが見つかりませんでした。")} 

146 if error.code in (403, 429): 

147 # Unauthenticated API calls are limited to 60 per hour per address. 

148 return { 

149 "status": "error", 

150 "message": tr("GitHubへの問い合わせが多すぎます。しばらくしてから再度お試しください。"), 

151 } 

152 return {"status": "error", "message": tr("HTTPエラー: {code}").format(code=error.code)} 

153 except urllib.error.URLError as error: 

154 return {"status": "error", "message": tr("ネットワークエラー: {reason}").format(reason=error.reason)} 

155 except (ValueError, OSError) as error: 

156 log.warning("update check failed: %s", error, exc_info=True) 

157 return {"status": "error", "message": str(error)} 

158 if not isinstance(release, dict): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 return {"status": "error", "message": tr("リリース情報の形式が不正です。")} 

160 

161 latest = str(release.get("tag_name") or "").lstrip("vV") 

162 if not is_newer(latest, current_version): 

163 return {"status": "up_to_date", "latest": latest, "release": release} 

164 return { 

165 "status": "update_available", 

166 "latest": latest, 

167 "release": release, 

168 "asset": pick_installer_asset(release), 

169 }