qBitrr2 5.4.3__py3-none-any.whl → 5.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qBitrr/auto_update.py CHANGED
@@ -1,17 +1,149 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import platform
4
5
  import subprocess
5
6
  import sys
6
7
  import threading
7
8
  from datetime import datetime
8
9
  from pathlib import Path
9
- from typing import Callable
10
+ from typing import Any, Callable
10
11
 
12
+ import requests
11
13
  from croniter import croniter
12
14
  from croniter.croniter import CroniterBadCronError
13
15
 
14
16
 
17
+ def get_installation_type() -> str:
18
+ """Detect how qBitrr is installed.
19
+
20
+ Returns:
21
+ "binary" - PyInstaller frozen executable
22
+ "git" - Git repository installation
23
+ "pip" - PyPI package installation
24
+ """
25
+ # Check if running as PyInstaller binary
26
+ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
27
+ return "binary"
28
+
29
+ # Check for git repository
30
+ repo_root = Path(__file__).resolve().parent.parent
31
+ git_dir = repo_root / ".git"
32
+ if git_dir.exists():
33
+ return "git"
34
+
35
+ # Default to pip installation
36
+ return "pip"
37
+
38
+
39
+ def get_binary_asset_pattern() -> str:
40
+ """Get the asset filename pattern for the current platform.
41
+
42
+ Returns:
43
+ Partial filename to match against release assets
44
+ Examples: "ubuntu-latest-x64", "windows-latest-x64", "macOS-latest-arm64"
45
+
46
+ Note: The release workflow only builds these platforms:
47
+ - ubuntu-latest-x64
48
+ - macOS-latest-arm64
49
+ - windows-latest-x64
50
+ Other platforms (Linux ARM, macOS Intel, Windows ARM) are not built.
51
+ """
52
+ system = platform.system()
53
+ machine = platform.machine()
54
+
55
+ # Map platform to GitHub runner names (matching build workflow)
56
+ if system == "Linux":
57
+ os_part = "ubuntu-latest"
58
+ # Note: Only x64 is built for Linux (arm64 excluded from workflow)
59
+ arch_part = "x64" if machine in ("x86_64", "AMD64") else "arm64"
60
+ elif system == "Darwin": # macOS
61
+ os_part = "macOS-latest"
62
+ # Note: Only arm64 is built for macOS (x64/Intel excluded from workflow)
63
+ arch_part = "arm64" if machine == "arm64" else "x64"
64
+ elif system == "Windows":
65
+ os_part = "windows-latest"
66
+ # Note: Only x64 is built for Windows (arm64 excluded from workflow)
67
+ arch_part = "x64" if machine in ("x86_64", "AMD64") else "arm64"
68
+ else:
69
+ raise RuntimeError(f"Unsupported platform: {system} {machine}")
70
+
71
+ return f"{os_part}-{arch_part}"
72
+
73
+
74
+ def get_binary_download_url(release_tag: str, logger: logging.Logger) -> dict[str, Any]:
75
+ """Get the download URL for the binary asset matching current platform.
76
+
77
+ Args:
78
+ release_tag: GitHub release tag (e.g., "v5.4.3")
79
+ logger: Logger instance
80
+
81
+ Returns:
82
+ Dict with 'url', 'name', 'size' if found, or 'error' if not found
83
+ """
84
+ try:
85
+ # Get asset pattern for current platform
86
+ asset_pattern = get_binary_asset_pattern()
87
+ logger.debug("Looking for binary asset matching: %s", asset_pattern)
88
+
89
+ # Fetch release details with assets
90
+ repo = "Feramance/qBitrr"
91
+ url = f"https://api.github.com/repos/{repo}/releases/tags/{release_tag}"
92
+ response = requests.get(url, timeout=30)
93
+ response.raise_for_status()
94
+ release_data = response.json()
95
+
96
+ # Find matching asset
97
+ assets = release_data.get("assets", [])
98
+ for asset in assets:
99
+ name = asset.get("name", "")
100
+ if asset_pattern in name:
101
+ return {
102
+ "url": asset["browser_download_url"],
103
+ "name": name,
104
+ "size": asset.get("size", 0),
105
+ "error": None,
106
+ }
107
+
108
+ # No matching asset found
109
+ available = [a.get("name") for a in assets]
110
+ logger.error(
111
+ "No binary asset found for platform %s in release %s",
112
+ asset_pattern,
113
+ release_tag,
114
+ )
115
+ logger.debug("Available assets: %s", available)
116
+
117
+ # Provide helpful error message
118
+ system = platform.system()
119
+ machine = platform.machine()
120
+ unsupported_platforms = [
121
+ "ubuntu-latest-arm64",
122
+ "macOS-latest-x64",
123
+ "windows-latest-arm64",
124
+ ]
125
+
126
+ error_msg = f"No binary available for {system} {machine}"
127
+ if asset_pattern in unsupported_platforms:
128
+ error_msg += f" (platform {asset_pattern} is not built by release workflow)"
129
+
130
+ return {
131
+ "url": None,
132
+ "name": None,
133
+ "size": None,
134
+ "error": error_msg,
135
+ }
136
+
137
+ except Exception as exc:
138
+ logger.error("Failed to fetch binary asset info: %s", exc)
139
+ return {
140
+ "url": None,
141
+ "name": None,
142
+ "size": None,
143
+ "error": str(exc),
144
+ }
145
+
146
+
15
147
  class AutoUpdater:
16
148
  """Background worker that executes a callback on a cron schedule."""
17
149
 
@@ -82,16 +214,126 @@ class AutoUpdater:
82
214
  self._logger.info("Auto update completed")
83
215
 
84
216
 
85
- def perform_self_update(logger: logging.Logger) -> bool:
86
- """Attempt to update qBitrr in-place using git or pip.
217
+ def verify_update_success(expected_version: str, logger: logging.Logger) -> bool:
218
+ """Verify that the installed version matches the expected version.
219
+
220
+ Args:
221
+ expected_version: Expected version string (e.g., "5.4.3")
222
+ logger: Logger instance for output
87
223
 
88
- Returns True when the update command completed successfully, False otherwise.
224
+ Returns:
225
+ True if version matches, False otherwise
89
226
  """
227
+ try:
228
+ # Re-import bundled_data to get fresh version
229
+ pass
90
230
 
91
- repo_root = Path(__file__).resolve().parent.parent
92
- git_dir = repo_root / ".git"
93
- if git_dir.exists():
94
- logger.debug("Detected git repository at %s", repo_root)
231
+ # Remove cached module
232
+ if "qBitrr.bundled_data" in sys.modules:
233
+ del sys.modules["qBitrr.bundled_data"]
234
+
235
+ # Re-import
236
+ from qBitrr import bundled_data
237
+ from qBitrr.versioning import normalize_version
238
+
239
+ current = normalize_version(bundled_data.version)
240
+ expected = normalize_version(expected_version)
241
+
242
+ if current == expected:
243
+ logger.info("Update verified: version %s installed successfully", current)
244
+ return True
245
+ logger.warning(
246
+ "Version mismatch after update: expected %s, got %s",
247
+ expected,
248
+ current,
249
+ )
250
+ return False
251
+
252
+ except Exception as exc:
253
+ logger.error("Failed to verify update: %s", exc)
254
+ return False
255
+
256
+
257
+ def perform_self_update(logger: logging.Logger, target_version: str | None = None) -> bool:
258
+ """Attempt to update qBitrr in-place using appropriate method for installation type.
259
+
260
+ Args:
261
+ logger: Logger instance for output
262
+ target_version: Optional specific version to update to (e.g., "5.4.3")
263
+
264
+ Returns:
265
+ True when the update command completed successfully, False otherwise.
266
+ """
267
+
268
+ # Detect installation type
269
+ install_type = get_installation_type()
270
+ logger.debug("Installation type detected: %s", install_type)
271
+
272
+ # BINARY INSTALLATION - Cannot auto-update
273
+ if install_type == "binary":
274
+ logger.info("Binary installation detected - manual update required")
275
+ if target_version:
276
+ logger.info(
277
+ "Update available: v%s",
278
+ target_version if target_version.startswith("v") else f"v{target_version}",
279
+ )
280
+ logger.info("Download from: https://github.com/Feramance/qBitrr/releases/latest")
281
+ logger.info("Instructions:")
282
+ logger.info(" 1. Download the binary for your platform")
283
+ logger.info(" 2. Extract the archive")
284
+ logger.info(" 3. Replace current executable with new binary")
285
+ logger.info(" 4. Restart qBitrr")
286
+ return False # Binary updates require manual intervention
287
+
288
+ # GIT INSTALLATION
289
+ elif install_type == "git":
290
+ repo_root = Path(__file__).resolve().parent.parent
291
+ repo_root / ".git"
292
+ logger.debug("Git repository detected at %s", repo_root)
293
+
294
+ if target_version:
295
+ # Strict version: checkout specific tag
296
+ tag = target_version if target_version.startswith("v") else f"v{target_version}"
297
+
298
+ try:
299
+ logger.debug("Fetching tags from remote")
300
+ subprocess.run(
301
+ ["git", "fetch", "--tags", "--force"],
302
+ cwd=str(repo_root),
303
+ capture_output=True,
304
+ text=True,
305
+ check=True,
306
+ )
307
+
308
+ result = subprocess.run(
309
+ ["git", "rev-parse", tag],
310
+ cwd=str(repo_root),
311
+ capture_output=True,
312
+ text=True,
313
+ )
314
+ if result.returncode != 0:
315
+ logger.error("Tag %s not found in repository", tag)
316
+ logger.warning("Falling back to git pull")
317
+ else:
318
+ result = subprocess.run(
319
+ ["git", "checkout", tag],
320
+ cwd=str(repo_root),
321
+ capture_output=True,
322
+ text=True,
323
+ check=True,
324
+ )
325
+ stdout = (result.stdout or "").strip()
326
+ if stdout:
327
+ logger.info("git checkout output:\n%s", stdout)
328
+ logger.info("Checked out tag %s", tag)
329
+ return True
330
+
331
+ except subprocess.CalledProcessError as exc:
332
+ stderr = (exc.stderr or "").strip()
333
+ logger.error("Failed to checkout tag %s: %s", tag, stderr or exc)
334
+ logger.warning("Falling back to git pull")
335
+
336
+ # Default: git pull
95
337
  try:
96
338
  result = subprocess.run(
97
339
  ["git", "pull", "--ff-only"],
@@ -109,20 +351,32 @@ def perform_self_update(logger: logging.Logger) -> bool:
109
351
  logger.error("Failed to update repository via git: %s", stderr or exc)
110
352
  return False
111
353
 
112
- package = "qBitrr2"
113
- logger.debug("Fallback to pip upgrade for package %s", package)
114
- try:
115
- result = subprocess.run(
116
- [sys.executable, "-m", "pip", "install", "--upgrade", package],
117
- capture_output=True,
118
- text=True,
119
- check=True,
120
- )
121
- stdout = (result.stdout or "").strip()
122
- if stdout:
123
- logger.info("pip upgrade output:\n%s", stdout)
124
- return True
125
- except subprocess.CalledProcessError as exc:
126
- stderr = (exc.stderr or "").strip()
127
- logger.error("Failed to upgrade package via pip: %s", stderr or exc)
128
- return False
354
+ # PIP INSTALLATION
355
+ elif install_type == "pip":
356
+ logger.debug("PyPI installation detected")
357
+
358
+ package = "qBitrr2"
359
+ if target_version:
360
+ # Strict version: install exact version
361
+ version = target_version[1:] if target_version.startswith("v") else target_version
362
+ package = f"{package}=={version}"
363
+
364
+ logger.debug("Upgrading package: %s", package)
365
+ try:
366
+ result = subprocess.run(
367
+ [sys.executable, "-m", "pip", "install", "--upgrade", package],
368
+ capture_output=True,
369
+ text=True,
370
+ check=True,
371
+ )
372
+ stdout = (result.stdout or "").strip()
373
+ if stdout:
374
+ logger.info("pip upgrade output:\n%s", stdout)
375
+ return True
376
+ except subprocess.CalledProcessError as exc:
377
+ stderr = (exc.stderr or "").strip()
378
+ logger.error("Failed to upgrade package via pip: %s", stderr or exc)
379
+ return False
380
+
381
+ logger.error("Unknown installation type: %s", install_type)
382
+ return False
qBitrr/bundled_data.py CHANGED
@@ -1,5 +1,5 @@
1
- version = "5.4.3"
2
- git_hash = "eb7a9ae5"
1
+ version = "5.4.4"
2
+ git_hash = "478d48fd"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
qBitrr/main.py CHANGED
@@ -180,27 +180,66 @@ class qBitManager:
180
180
  self.logger.error("Auto update could not be scheduled; leaving it disabled")
181
181
 
182
182
  def _perform_auto_update(self) -> None:
183
+ """Check for updates and apply if available."""
183
184
  self.logger.notice("Checking for updates...")
185
+
186
+ # Fetch latest release info from GitHub
184
187
  release_info = fetch_latest_release()
188
+
185
189
  if release_info.get("error"):
186
190
  self.logger.error("Auto update skipped: %s", release_info["error"])
187
191
  return
188
- target_version = release_info.get("raw_tag") or release_info.get("normalized")
192
+
193
+ # Use normalized version for comparison, raw tag for display
194
+ target_version = release_info.get("normalized")
195
+ raw_tag = release_info.get("raw_tag")
196
+
189
197
  if not release_info.get("update_available"):
190
198
  if target_version:
191
199
  self.logger.info(
192
200
  "Auto update skipped: already running the latest release (%s).",
193
- target_version,
201
+ raw_tag or target_version,
194
202
  )
195
203
  else:
196
204
  self.logger.info("Auto update skipped: no new release detected.")
197
205
  return
198
206
 
199
- self.logger.notice("Updating from %s to %s", patched_version, target_version or "latest")
200
- updated = perform_self_update(self.logger)
207
+ # Detect installation type
208
+ from qBitrr.auto_update import get_installation_type
209
+
210
+ install_type = get_installation_type()
211
+
212
+ self.logger.notice(
213
+ "Update available: %s -> %s (installation: %s)",
214
+ patched_version,
215
+ raw_tag or target_version,
216
+ install_type,
217
+ )
218
+
219
+ # Perform the update with specific version
220
+ updated = perform_self_update(self.logger, target_version=target_version)
221
+
201
222
  if not updated:
202
- self.logger.error("Auto update failed; manual intervention may be required.")
223
+ if install_type == "binary":
224
+ # Binary installations require manual update, this is expected
225
+ self.logger.info("Manual update required for binary installation")
226
+ else:
227
+ self.logger.error("Auto update failed; manual intervention may be required.")
203
228
  return
229
+
230
+ # Verify update success (git/pip only)
231
+ if target_version and install_type != "binary":
232
+ from qBitrr.auto_update import verify_update_success
233
+
234
+ if verify_update_success(target_version, self.logger):
235
+ self.logger.notice("Update verified successfully")
236
+ else:
237
+ self.logger.warning(
238
+ "Update completed but version verification failed. "
239
+ "The system may not be running the expected version."
240
+ )
241
+ # Continue with restart anyway (Phase 1 approach)
242
+
204
243
  self.logger.notice("Update applied successfully; restarting to load the new version.")
205
244
  self.request_restart()
206
245