Coverage for paintmaskanimator/updater.py: 65%
83 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« 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.
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.
8Only the fields used here are read from the release::
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 }
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
29from .constants import APP_VERSION, UPDATE_RELEASE_API_URL
30from .i18n import tr
31from .logging_setup import get_logger
33log = get_logger(__name__)
35_TIMEOUT = 15
38def parse_version(text):
39 """Parse a version string like 'v0.5.0' / '0.5' into a tuple of ints.
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)
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
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"
72_INSTALLER_SUFFIX = {"windows": ".exe", "macos": ".dmg"}
75def pick_installer_asset(release, platform_key=None):
76 """The installer asset of *release* for ``platform_key``, or ``None``.
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"])}
100def _request(url, accept):
101 return urllib.request.Request(
102 url, headers={"Accept": accept, "User-Agent": "PaintMaskAnimator-Updater"}
103 )
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"))
113def download_asset(asset, dest_path, progress=None):
114 """Download an installer asset to ``dest_path``.
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
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:
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("リリース情報の形式が不正です。")}
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 }