1010* The HTTP fetch runs on a worker thread via :class:`QThreadPool`; the
1111 main-thread slot is only invoked if a strictly-newer version is found.
1212* The user can always trigger a check from a menu / button with
13- ``force=True``.
13+ ``force=True`` (even when automatic checks are disabled in preferences).
14+ * Among all published (non-draft) releases, only the **highest** version
15+ greater than the running build is considered (PEP 440 ordering, including
16+ alpha / beta / rc). GitHub's ``/releases/latest`` endpoint is not used
17+ because it omits pre-releases.
1418
1519The release repository is hardcoded to the canonical upstream; change
1620:data:`DEFAULT_RELEASES_REPO` if the project moves.
2529from collections .abc import Callable
2630from dataclasses import dataclass
2731from datetime import UTC , datetime , timedelta
32+ from typing import Any , cast
2833
2934from packaging .version import InvalidVersion , Version
3035from PySide6 .QtCore import QObject , QRunnable , QSettings , QThreadPool , Signal
3944DEFAULT_COOLDOWN = timedelta (hours = 24 )
4045DEFAULT_TIMEOUT_SECONDS = 10
4146_LAST_CHECK_KEY = "updater/last_check_iso"
47+ _AUTO_CHECK_KEY = "updater/auto_check_enabled"
48+ _RELEASES_PAGE_SIZE = 100
49+ _MAX_RELEASE_PAGES = 5
4250
4351
4452@dataclass (frozen = True )
@@ -50,6 +58,9 @@ class ReleaseInfo:
5058 html_url : str
5159 published_at : str
5260 body : str
61+ #: ``True`` if GitHub marked the release as pre-release or the tag parses
62+ #: as a PEP 440 pre-release (alpha / beta / rc).
63+ is_prerelease : bool
5364
5465
5566def _parse_version (tag : str ) -> Version | None :
@@ -66,6 +77,21 @@ def _parse_version(tag: str) -> Version | None:
6677 return None
6778
6879
80+ def _coerce_bool (value : object , default : bool ) -> bool :
81+ if isinstance (value , bool ):
82+ return value
83+ if isinstance (value , str ):
84+ s = value .lower ().strip ()
85+ if s in ("false" , "0" , "no" , "" ):
86+ return False
87+ if s in ("true" , "1" , "yes" ):
88+ return True
89+ return default
90+ if value is None :
91+ return default
92+ return bool (value )
93+
94+
6995class _CheckSignals (QObject ):
7096 """Qt signals for a check worker.
7197
@@ -96,7 +122,7 @@ def __init__(
96122
97123 def run (self ) -> None :
98124 try :
99- latest = self ._fetch_latest_release ()
125+ latest = self ._fetch_newest_applicable_release ()
100126 except Exception as exc :
101127 logger .info ("Update check failed: %s" , exc )
102128 self ._signals .failed .emit (str (exc ))
@@ -108,42 +134,94 @@ def run(self) -> None:
108134
109135 self ._signals .update_available .emit (latest )
110136
111- def _fetch_latest_release (self ) -> ReleaseInfo | None :
112- url = f"https://api.github.com/repos/{ self ._repo } /releases/latest"
113- request = urllib .request .Request (
137+ def _request (self , url : str ) -> urllib .request .Request :
138+ return urllib .request .Request (
114139 url ,
115140 headers = {
116141 "Accept" : "application/vnd.github+json" ,
117142 "User-Agent" : f"DBSAnnotator/{ self ._current_version } " ,
118143 },
119144 )
145+
146+ def _urlopen_json (self , url : str ) -> object :
147+ request = self ._request (url )
120148 with urllib .request .urlopen (request , timeout = self ._timeout ) as response :
121- payload = json .loads (response .read ().decode ("utf-8" ))
149+ return json .loads (response .read ().decode ("utf-8" ))
122150
123- tag = str (payload .get ("tag_name" , "" ))
124- if not tag :
151+ def _fetch_releases_page (self , page : int ) -> list [dict ] | None :
152+ url = (
153+ f"https://api.github.com/repos/{ self ._repo } /releases"
154+ f"?per_page={ _RELEASES_PAGE_SIZE } &page={ page } "
155+ )
156+ try :
157+ payload = self ._urlopen_json (url )
158+ except urllib .error .HTTPError as exc :
159+ if exc .code == 404 :
160+ logger .debug (
161+ "No GitHub releases list for %s (HTTP %s); treat as no update" ,
162+ self ._repo ,
163+ exc .code ,
164+ )
165+ return None
166+ raise
167+ if not isinstance (payload , list ):
168+ return []
169+ return cast (list [dict [str , Any ]], payload )
170+
171+ def _fetch_all_releases (self ) -> list [dict ]:
172+ merged : list [dict ] = []
173+ for page in range (1 , _MAX_RELEASE_PAGES + 1 ):
174+ batch = self ._fetch_releases_page (page )
175+ if batch is None :
176+ return []
177+ merged .extend (batch )
178+ if len (batch ) < _RELEASES_PAGE_SIZE :
179+ break
180+ return merged
181+
182+ def _fetch_newest_applicable_release (self ) -> ReleaseInfo | None :
183+ """Return single newest published release with version *>* local."""
184+ payloads = self ._fetch_all_releases ()
185+ if not payloads :
125186 return None
126187
127- remote = _parse_version (tag )
128188 local = _parse_version (self ._current_version )
129- if remote is None or local is None :
189+ if local is None :
130190 logger .debug (
131- "Skipping update comparison for tag=%r current=%r" ,
132- tag ,
191+ "Skipping update comparison; local version not PEP 440: %r" ,
133192 self ._current_version ,
134193 )
135194 return None
136- if payload .get ("prerelease" ):
137- return None
138- if remote <= local :
195+
196+ best_remote : Version | None = None
197+ best_payload : dict | None = None
198+
199+ for payload in payloads :
200+ if payload .get ("draft" ):
201+ continue
202+ tag = str (payload .get ("tag_name" , "" ))
203+ if not tag :
204+ continue
205+ remote = _parse_version (tag )
206+ if remote is None or remote <= local :
207+ continue
208+ if best_remote is None or remote > best_remote :
209+ best_remote = remote
210+ best_payload = payload
211+
212+ if best_remote is None or best_payload is None :
139213 return None
140214
215+ gh_prerelease = bool (best_payload .get ("prerelease" ))
216+ is_prerelease = gh_prerelease or best_remote .is_prerelease
217+
141218 return ReleaseInfo (
142- version = str (remote ),
143- tag_name = tag ,
144- html_url = str (payload .get ("html_url" , "" )),
145- published_at = str (payload .get ("published_at" , "" )),
146- body = str (payload .get ("body" , "" )),
219+ version = str (best_remote ),
220+ tag_name = str (best_payload .get ("tag_name" , "" )),
221+ html_url = str (best_payload .get ("html_url" , "" )),
222+ published_at = str (best_payload .get ("published_at" , "" )),
223+ body = str (best_payload .get ("body" , "" )),
224+ is_prerelease = is_prerelease ,
147225 )
148226
149227
@@ -153,6 +231,8 @@ class UpdateChecker(QObject):
153231 Create one of these on the main thread (typically owned by the main
154232 window) and call :meth:`check_async`. A ``check_async(force=True)`` call
155233 bypasses the cooldown -- wire it to a "Check for updates" menu action.
234+ Automatic checks respect :meth:`auto_update_checks_enabled` (stored in
235+ ``QSettings`` under :data:`_AUTO_CHECK_KEY`).
156236 """
157237
158238 update_available = Signal (object )
@@ -178,6 +258,16 @@ def __init__(
178258 self ._signals .up_to_date .connect (self ._on_up_to_date )
179259 self ._signals .failed .connect (self ._on_failed )
180260
261+ def auto_update_checks_enabled (self ) -> bool :
262+ """Whether startup / periodic background checks are allowed."""
263+ raw = self ._settings .value (_AUTO_CHECK_KEY , True )
264+ return _coerce_bool (raw , True )
265+
266+ def set_auto_update_checks_enabled (self , enabled : bool ) -> None :
267+ """Persist preference for automatic update checks."""
268+ self ._settings .setValue (_AUTO_CHECK_KEY , enabled )
269+ self ._settings .sync ()
270+
181271 def _on_update_available (self , release : ReleaseInfo ) -> None :
182272 self ._record_check_time ()
183273 self .update_available .emit (release )
@@ -200,13 +290,16 @@ def check_async(
200290 """Schedule a background check.
201291
202292 Args:
203- force: If True, bypass the cooldown.
293+ force: If True, bypass the cooldown and automatic-check opt-out .
204294 now: Injectable clock, only for tests.
205295
206296 Returns:
207297 True if a check was scheduled; False if the cooldown suppressed
208- it.
298+ it, automatic checks are disabled, or (when not forced) opt-out
299+ applies.
209300 """
301+ if not force and not self .auto_update_checks_enabled ():
302+ return False
210303 if not force and not self ._cooldown_elapsed (now ()):
211304 return False
212305
0 commit comments