synodic-client 0.0.1.dev82__tar.gz → 0.0.1.dev83__tar.gz

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.
Files changed (106) hide show
  1. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/schema.py +6 -0
  4. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/updater.py +92 -5
  5. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_updater.py +267 -0
  6. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/LICENSE.md +0 -0
  7. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/README.md +0 -0
  8. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/__init__.py +0 -0
  9. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/__main__.py +0 -0
  10. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/__init__.py +0 -0
  11. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/bootstrap.py +0 -0
  12. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/config_store.py +0 -0
  13. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/data.py +0 -0
  14. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/debug.py +0 -0
  15. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/icon.py +0 -0
  16. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/init.py +0 -0
  17. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/instance.py +0 -0
  18. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/package_state.py +0 -0
  19. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/qt.py +0 -0
  20. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/schema.py +0 -0
  21. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/action_card.py +0 -0
  23. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/card.py +0 -0
  24. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/install.py +0 -0
  25. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/install_workers.py +0 -0
  26. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/log_panel.py +0 -0
  27. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/plugin_row.py +0 -0
  28. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/projects.py +0 -0
  29. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/schema.py +0 -0
  30. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/screen.py +0 -0
  31. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/settings.py +0 -0
  32. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/sidebar.py +0 -0
  33. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/spinner.py +0 -0
  34. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/tool_update_controller.py +0 -0
  35. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/tray.py +0 -0
  36. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/update_banner.py +0 -0
  37. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/screen/wsl.py +0 -0
  38. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/theme.py +0 -0
  39. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/update_controller.py +0 -0
  40. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/update_model.py +0 -0
  41. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/application/uri.py +0 -0
  42. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/__init__.py +0 -0
  43. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/config.py +0 -0
  44. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/context.py +0 -0
  45. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/debug.py +0 -0
  46. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/install.py +0 -0
  47. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/output.py +0 -0
  48. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/project.py +0 -0
  49. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/tool.py +0 -0
  50. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/cli/update.py +0 -0
  51. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/client.py +0 -0
  52. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/config.py +0 -0
  53. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/logging.py +0 -0
  54. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/bootstrap.py +0 -0
  56. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/config.py +0 -0
  57. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/install.py +0 -0
  58. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/project.py +0 -0
  59. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/schema.py +0 -0
  60. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/tool.py +0 -0
  61. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/operations/update.py +0 -0
  62. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/protocol.py +0 -0
  63. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/py.typed +0 -0
  64. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/resolution.py +0 -0
  65. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/startup.py +0 -0
  66. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/synodic_client/subprocess_patch.py +0 -0
  67. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/__init__.py +0 -0
  68. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/conftest.py +0 -0
  69. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/__init__.py +0 -0
  70. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/__init__.py +0 -0
  71. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/test_config.py +0 -0
  72. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/test_install_plan.py +0 -0
  74. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/test_project.py +0 -0
  75. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/test_tool.py +0 -0
  76. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/operations/test_update.py +0 -0
  77. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/__init__.py +0 -0
  78. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/conftest.py +0 -0
  79. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_action_card.py +0 -0
  80. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_gather_packages.py +0 -0
  81. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_install_preview.py +0 -0
  82. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_log_panel.py +0 -0
  83. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_logging.py +0 -0
  84. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_package_state.py +0 -0
  85. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_preview_model.py +0 -0
  86. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_settings.py +0 -0
  87. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_sidebar.py +0 -0
  88. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_tray_window_show.py +0 -0
  89. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_update_banner.py +0 -0
  90. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_update_controller.py +0 -0
  91. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/qt/test_update_feedback.py +0 -0
  92. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_bootstrap.py +0 -0
  93. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_cli.py +0 -0
  94. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_client_updater.py +0 -0
  95. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_client_version.py +0 -0
  96. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_config.py +0 -0
  97. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_examples.py +0 -0
  98. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_init.py +0 -0
  99. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_install.py +0 -0
  100. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_resolution.py +0 -0
  101. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_uri.py +0 -0
  102. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/test_workers.py +0 -0
  103. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/windows/__init__.py +0 -0
  104. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/windows/conftest.py +0 -0
  105. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/windows/test_protocol.py +0 -0
  106. {synodic_client-0.0.1.dev82 → synodic_client-0.0.1.dev83}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev82
3
+ Version: 0.0.1.dev83
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1535.dev45597",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev82"
18
+ version = "0.0.1.dev83"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -151,6 +151,12 @@ class UpdateInfo:
151
151
  # Internal: Velopack update info for download/apply
152
152
  _velopack_info: Any = field(default=None, repr=False)
153
153
 
154
+ # Internal: True when the update was discovered via the manifest
155
+ # fallback rather than the Velopack SDK. The download path uses
156
+ # this to route to a direct HTTP download instead of the SDK's
157
+ # GithubSource (which cannot find prerelease assets).
158
+ _used_manifest_fallback: bool = field(default=False, repr=False)
159
+
154
160
 
155
161
  # Default interval for automatic update checks (minutes)
156
162
  DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES = 5
@@ -8,11 +8,13 @@ For non-installed (development) environments, updates are not supported.
8
8
  """
9
9
 
10
10
  import contextlib
11
+ import hashlib
11
12
  import json
12
13
  import logging
13
14
  import sys
14
15
  import urllib.request
15
16
  from collections.abc import Callable
17
+ from pathlib import Path
16
18
  from typing import Any
17
19
 
18
20
  import velopack
@@ -175,12 +177,14 @@ class Updater:
175
177
  error='Not installed via Velopack',
176
178
  )
177
179
 
180
+ used_fallback = False
178
181
  try:
179
182
  velopack_info = manager.check_for_updates()
180
183
  except Exception as sdk_err:
181
184
  if '404' in str(sdk_err):
182
185
  logger.debug('SDK check failed with 404, trying manifest fallback: %s', sdk_err)
183
186
  velopack_info = self._check_manifest_fallback()
187
+ used_fallback = velopack_info is not None
184
188
  else:
185
189
  raise
186
190
 
@@ -188,6 +192,7 @@ class Updater:
188
192
  # SDK returned no update; try the manual manifest fallback
189
193
  # in case the SDK's GithubSource skipped prerelease entries.
190
194
  velopack_info = self._check_manifest_fallback()
195
+ used_fallback = velopack_info is not None
191
196
 
192
197
  if velopack_info is not None:
193
198
  latest = Version(velopack_info.TargetFullRelease.Version)
@@ -197,6 +202,7 @@ class Updater:
197
202
  current_version=self._current_version,
198
203
  latest_version=latest,
199
204
  _velopack_info=velopack_info,
205
+ _used_manifest_fallback=used_fallback,
200
206
  )
201
207
  # Only advance to UPDATE_AVAILABLE if we haven't already
202
208
  # moved past it. A periodic re-check that discovers the
@@ -310,6 +316,85 @@ class Updater:
310
316
  IsDowngrade=False,
311
317
  )
312
318
 
319
+ def _download_direct(
320
+ self,
321
+ velopack_info: Any,
322
+ progress_callback: Callable[[int], None] | None = None,
323
+ ) -> None:
324
+ """Download the update package directly via HTTP.
325
+
326
+ Used when the update was discovered via ``_check_manifest_fallback``
327
+ instead of the Velopack SDK. The SDK's ``GithubSource`` cannot
328
+ download assets from prerelease GitHub Releases, so this method
329
+ fetches the ``.nupkg`` from the known GitHub Release asset URL
330
+ and places it in the Velopack packages directory where the SDK's
331
+ apply step expects to find it.
332
+
333
+ Args:
334
+ velopack_info: A ``velopack.UpdateInfo`` whose
335
+ ``TargetFullRelease`` describes the package to download.
336
+ progress_callback: Optional callback for percentage progress
337
+ (0–100).
338
+
339
+ Raises:
340
+ RuntimeError: If the packages directory cannot be determined,
341
+ the download fails, or the checksum does not match.
342
+ """
343
+ asset = velopack_info.TargetFullRelease
344
+ asset_base = github_release_asset_url(self._config.repo_url, self._config.channel)
345
+ download_url = f'{asset_base}/{asset.FileName}'
346
+
347
+ # Velopack stores packages under ``{root}/packages/`` where
348
+ # ``{root}`` is the parent of the ``current/`` directory that
349
+ # contains the running executable.
350
+ packages_dir = Path(sys.executable).resolve().parent.parent / 'packages'
351
+ packages_dir.mkdir(parents=True, exist_ok=True)
352
+
353
+ target_file = packages_dir / asset.FileName
354
+ if target_file.exists():
355
+ logger.info('Package already exists, skipping download: %s', target_file)
356
+ return
357
+
358
+ partial_file = target_file.with_suffix('.partial')
359
+
360
+ logger.info('Direct download: %s -> %s', download_url, partial_file)
361
+
362
+ req = urllib.request.Request(download_url, headers={'User-Agent': 'synodic-client'})
363
+ with urllib.request.urlopen(req, timeout=300) as resp: # noqa: S310 — URL from known repo
364
+ total = int(resp.headers.get('Content-Length', 0))
365
+ sha256_hash = hashlib.sha256()
366
+ downloaded = 0
367
+
368
+ with partial_file.open('wb') as f:
369
+ while True:
370
+ chunk = resp.read(256 * 1024)
371
+ if not chunk:
372
+ break
373
+ f.write(chunk)
374
+ sha256_hash.update(chunk)
375
+ downloaded += len(chunk)
376
+ if progress_callback is not None and total > 0:
377
+ progress_callback(int(downloaded * 100 / total))
378
+
379
+ # Verify checksum — prefer SHA256, fall back to SHA1.
380
+ if asset.SHA256:
381
+ actual = sha256_hash.hexdigest()
382
+ if not actual.lower() == asset.SHA256.lower():
383
+ partial_file.unlink(missing_ok=True)
384
+ raise RuntimeError(
385
+ f'SHA256 mismatch for {asset.FileName}: expected {asset.SHA256}, got {actual}'
386
+ )
387
+ elif asset.SHA1:
388
+ actual_sha1 = hashlib.sha1(partial_file.read_bytes()).hexdigest() # noqa: S324 — verifying known digest
389
+ if not actual_sha1.lower() == asset.SHA1.lower():
390
+ partial_file.unlink(missing_ok=True)
391
+ raise RuntimeError(
392
+ f'SHA1 mismatch for {asset.FileName}: expected {asset.SHA1}, got {actual_sha1}'
393
+ )
394
+
395
+ partial_file.rename(target_file)
396
+ logger.info('Direct download complete: %s', target_file)
397
+
313
398
  def download_update(self, progress_callback: Callable[[int], None] | None = None) -> bool:
314
399
  """Download the update.
315
400
 
@@ -334,11 +419,13 @@ class Updater:
334
419
  logger.info('Starting update download for %s', self._update_info._velopack_info)
335
420
 
336
421
  try:
337
- manager = self._get_velopack_manager()
338
- if manager is None:
339
- raise RuntimeError('Velopack manager not available')
340
-
341
- manager.download_updates(self._update_info._velopack_info, progress_callback)
422
+ if self._update_info._used_manifest_fallback:
423
+ self._download_direct(self._update_info._velopack_info, progress_callback)
424
+ else:
425
+ manager = self._get_velopack_manager()
426
+ if manager is None:
427
+ raise RuntimeError('Velopack manager not available')
428
+ manager.download_updates(self._update_info._velopack_info, progress_callback)
342
429
 
343
430
  self._state = UpdateState.DOWNLOADED
344
431
  logger.info('Update downloaded successfully')
@@ -1,5 +1,8 @@
1
1
  """Tests for the self-update functionality using Velopack."""
2
2
 
3
+ import io
4
+ import json
5
+ from pathlib import Path
3
6
  from unittest.mock import MagicMock, PropertyMock, patch
4
7
 
5
8
  import pytest
@@ -597,3 +600,267 @@ class TestPep440ToSemver:
597
600
  """SemVer-style pre-release input is normalised via PEP 440."""
598
601
  # packaging.version.Version normalises '0.1.0-dev.5' to '0.1.0.dev5'
599
602
  assert pep440_to_semver('0.1.0-dev.5') == '0.1.0-dev.5'
603
+
604
+
605
+ # ---------------------------------------------------------------------------
606
+ # Realistic dev-channel manifest payload (mirrors the real GitHub Release).
607
+ # The ``dev`` tag on GitHub is marked ``"prerelease": true``, which
608
+ # Velopack's GithubSource filters out (it hard-codes prerelease=false).
609
+ # ---------------------------------------------------------------------------
610
+
611
+ _DEV_MANIFEST: dict[str, object] = {
612
+ 'Assets': [
613
+ {
614
+ 'PackageId': 'synodic',
615
+ 'Version': '0.1.0-dev.83',
616
+ 'Type': 'Full',
617
+ 'FileName': 'synodic-0.1.0-dev.83-dev-win-full.nupkg',
618
+ 'SHA1': 'aabbccdd',
619
+ 'SHA256': '07badc6414dc5d87b009a7ecaa4ee446febe0d15275aa86465a9720762ceab80',
620
+ 'Size': 66358200,
621
+ },
622
+ {
623
+ 'PackageId': 'synodic',
624
+ 'Version': '0.1.0-dev.83',
625
+ 'Type': 'Delta',
626
+ 'FileName': 'synodic-0.1.0-dev.83-dev-win-delta.nupkg',
627
+ 'SHA1': '11223344',
628
+ 'SHA256': '5305031a791e0de45f517eda0d2cf827951ec603380ba740fe58fbac4b6246cd',
629
+ 'Size': 15560939,
630
+ },
631
+ {
632
+ 'PackageId': 'synodic',
633
+ 'Version': '0.1.0-dev.80',
634
+ 'Type': 'Full',
635
+ 'FileName': 'synodic-0.1.0-dev.80-dev-win-full.nupkg',
636
+ 'SHA1': 'deadbeef',
637
+ 'SHA256': '09bc2032a6a374e1d722cb3c42d1e586a9113c7fd6877721ad5e1ecb611dbbc3',
638
+ 'Size': 65808335,
639
+ },
640
+ ],
641
+ }
642
+
643
+
644
+ def _make_urlopen_response(data: dict[str, object]) -> MagicMock:
645
+ """Build a mock ``urlopen`` return value that reads as JSON."""
646
+ body = json.dumps(data).encode()
647
+ resp = MagicMock()
648
+ resp.read.return_value = body
649
+ resp.__enter__ = lambda s: s
650
+ resp.__exit__ = MagicMock(return_value=False)
651
+ resp.headers = {'Content-Length': str(len(body))}
652
+ return resp
653
+
654
+
655
+ @pytest.fixture
656
+ def dev_updater() -> Updater:
657
+ """Create an Updater on the dev channel at version 0.1.0-dev.80."""
658
+ config = UpdateConfig(
659
+ repo_url=GITHUB_REPO_URL,
660
+ channel=UpdateChannel.DEVELOPMENT,
661
+ )
662
+ u = Updater(current_version=Version('0.1.0.dev80'), config=config)
663
+ u._velopack_manager = None
664
+ u._velopack_not_installed = False
665
+ return u
666
+
667
+
668
+ class TestDevChannelGithubPrerelease:
669
+ """Regression tests: dev channel uses a GitHub prerelease that Velopack's
670
+ GithubSource silently ignores (``prerelease=false``).
671
+
672
+ The check path already has ``_check_manifest_fallback``. These tests
673
+ verify that the *download* path also works when the update was discovered
674
+ via the fallback.
675
+ """
676
+
677
+ @staticmethod
678
+ def test_check_finds_update_via_manifest_fallback(dev_updater: Updater) -> None:
679
+ """check_for_update discovers dev.83 via manifest fallback when SDK returns None."""
680
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
681
+ # SDK's GithubSource filters prereleases → returns None
682
+ mock_manager.check_for_updates.return_value = None
683
+
684
+ manifest_resp = _make_urlopen_response(_DEV_MANIFEST)
685
+
686
+ with (
687
+ patch.object(dev_updater, '_get_velopack_manager', return_value=mock_manager),
688
+ patch('synodic_client.updater.urllib.request.urlopen', return_value=manifest_resp),
689
+ ):
690
+ info = dev_updater.check_for_update()
691
+
692
+ assert info.available is True
693
+ assert info.latest_version == Version('0.1.0.dev83')
694
+ assert dev_updater.state == UpdateState.UPDATE_AVAILABLE
695
+
696
+ @staticmethod
697
+ def test_check_sets_manifest_fallback_flag(dev_updater: Updater) -> None:
698
+ """check_for_update sets _used_manifest_fallback when fallback discovered the update."""
699
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
700
+ mock_manager.check_for_updates.return_value = None
701
+
702
+ manifest_resp = _make_urlopen_response(_DEV_MANIFEST)
703
+
704
+ with (
705
+ patch.object(dev_updater, '_get_velopack_manager', return_value=mock_manager),
706
+ patch('synodic_client.updater.urllib.request.urlopen', return_value=manifest_resp),
707
+ ):
708
+ info = dev_updater.check_for_update()
709
+
710
+ assert info._used_manifest_fallback is True
711
+
712
+ @staticmethod
713
+ def test_check_sdk_success_does_not_set_fallback_flag(dev_updater: Updater) -> None:
714
+ """_used_manifest_fallback stays False when the SDK itself found the update."""
715
+ mock_target = MagicMock(spec=velopack.VelopackAsset)
716
+ mock_target.Version = '0.1.0-dev.83'
717
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
718
+ mock_velopack_info.TargetFullRelease = mock_target
719
+
720
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
721
+ mock_manager.check_for_updates.return_value = mock_velopack_info
722
+
723
+ with patch.object(dev_updater, '_get_velopack_manager', return_value=mock_manager):
724
+ info = dev_updater.check_for_update()
725
+
726
+ assert info.available is True
727
+ assert info._used_manifest_fallback is False
728
+
729
+ @staticmethod
730
+ def test_download_succeeds_after_manifest_fallback(dev_updater: Updater) -> None:
731
+ """download_update routes to _download_direct when the manifest fallback was used."""
732
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
733
+ mock_manager.check_for_updates.return_value = None
734
+
735
+ manifest_resp = _make_urlopen_response(_DEV_MANIFEST)
736
+
737
+ with (
738
+ patch.object(dev_updater, '_get_velopack_manager', return_value=mock_manager),
739
+ patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True),
740
+ patch('synodic_client.updater.urllib.request.urlopen', return_value=manifest_resp),
741
+ ):
742
+ info = dev_updater.check_for_update()
743
+ assert info.available is True
744
+ assert info._used_manifest_fallback is True
745
+
746
+ # Now mock the direct download — _download_direct is called instead of
747
+ # manager.download_updates, so the SDK never touches GithubSource.
748
+ with (
749
+ patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True),
750
+ patch.object(dev_updater, '_download_direct') as mock_direct,
751
+ ):
752
+ result = dev_updater.download_update()
753
+
754
+ assert result is True
755
+ assert dev_updater.state == UpdateState.DOWNLOADED
756
+ mock_direct.assert_called_once_with(info._velopack_info, None)
757
+ # The SDK's download_updates should NOT have been called
758
+ mock_manager.download_updates.assert_not_called()
759
+
760
+ @staticmethod
761
+ def test_sdk_download_used_when_sdk_found_update(dev_updater: Updater) -> None:
762
+ """download_update uses the SDK when the update was found without the fallback."""
763
+ mock_target = MagicMock(spec=velopack.VelopackAsset)
764
+ mock_target.Version = '0.1.0-dev.83'
765
+ mock_velopack_info = MagicMock(spec=velopack.UpdateInfo)
766
+ mock_velopack_info.TargetFullRelease = mock_target
767
+
768
+ mock_manager = MagicMock(spec=velopack.UpdateManager)
769
+ mock_manager.check_for_updates.return_value = mock_velopack_info
770
+
771
+ with (
772
+ patch.object(dev_updater, '_get_velopack_manager', return_value=mock_manager),
773
+ patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True),
774
+ ):
775
+ info = dev_updater.check_for_update()
776
+ assert info._used_manifest_fallback is False
777
+
778
+ result = dev_updater.download_update()
779
+
780
+ assert result is True
781
+ assert dev_updater.state == UpdateState.DOWNLOADED
782
+ mock_manager.download_updates.assert_called_once_with(mock_velopack_info, None)
783
+
784
+ @staticmethod
785
+ def test_download_direct_constructs_correct_url(dev_updater: Updater, tmp_path: Path) -> None:
786
+ """_download_direct fetches from the correct GitHub release asset URL."""
787
+ mock_velopack_info = MagicMock()
788
+ mock_velopack_info.TargetFullRelease.FileName = 'synodic-0.1.0-dev.83-dev-win-full.nupkg'
789
+ mock_velopack_info.TargetFullRelease.SHA256 = ''
790
+ mock_velopack_info.TargetFullRelease.SHA1 = ''
791
+ mock_velopack_info.TargetFullRelease.Size = 0
792
+
793
+ nupkg_content = b'fake-nupkg-content'
794
+ resp = MagicMock()
795
+ resp.read.side_effect = [nupkg_content, b'']
796
+ resp.__enter__ = lambda s: s
797
+ resp.__exit__ = MagicMock(return_value=False)
798
+ resp.headers = {'Content-Length': str(len(nupkg_content))}
799
+
800
+ with (
801
+ patch('synodic_client.updater.sys') as mock_sys,
802
+ patch('synodic_client.updater.urllib.request.urlopen', return_value=resp) as mock_urlopen,
803
+ ):
804
+ mock_sys.executable = str(tmp_path / 'current' / 'synodic.exe')
805
+ (tmp_path / 'current').mkdir()
806
+
807
+ dev_updater._download_direct(mock_velopack_info)
808
+
809
+ # Verify the URL
810
+ call_args = mock_urlopen.call_args
811
+ req = call_args[0][0]
812
+ expected_url = (
813
+ f'{GITHUB_REPO_URL}/releases/download/dev'
814
+ '/synodic-0.1.0-dev.83-dev-win-full.nupkg'
815
+ )
816
+ assert req.full_url == expected_url
817
+
818
+ # Verify the file was written
819
+ target_file = tmp_path / 'packages' / 'synodic-0.1.0-dev.83-dev-win-full.nupkg'
820
+ assert target_file.exists()
821
+ assert target_file.read_bytes() == nupkg_content
822
+
823
+ @staticmethod
824
+ def test_download_direct_sha256_mismatch(dev_updater: Updater, tmp_path: Path) -> None:
825
+ """_download_direct raises on SHA256 mismatch and cleans up the partial."""
826
+ mock_velopack_info = MagicMock()
827
+ mock_velopack_info.TargetFullRelease.FileName = 'test.nupkg'
828
+ mock_velopack_info.TargetFullRelease.SHA256 = 'wrong_hash'
829
+ mock_velopack_info.TargetFullRelease.SHA1 = ''
830
+ mock_velopack_info.TargetFullRelease.Size = 0
831
+
832
+ resp = MagicMock()
833
+ resp.read.side_effect = [b'some content', b'']
834
+ resp.__enter__ = lambda s: s
835
+ resp.__exit__ = MagicMock(return_value=False)
836
+ resp.headers = {'Content-Length': '12'}
837
+
838
+ with (
839
+ patch('synodic_client.updater.sys') as mock_sys,
840
+ patch('synodic_client.updater.urllib.request.urlopen', return_value=resp),
841
+ pytest.raises(RuntimeError, match='SHA256 mismatch'),
842
+ ):
843
+ mock_sys.executable = str(tmp_path / 'current' / 'synodic.exe')
844
+ (tmp_path / 'current').mkdir()
845
+
846
+ dev_updater._download_direct(mock_velopack_info)
847
+
848
+ # Partial file should be cleaned up
849
+ assert not (tmp_path / 'packages' / 'test.nupkg.partial').exists()
850
+ assert not (tmp_path / 'packages' / 'test.nupkg').exists()
851
+
852
+ @staticmethod
853
+ def test_download_direct_skips_existing(dev_updater: Updater, tmp_path: Path) -> None:
854
+ """_download_direct skips download if the package already exists on disk."""
855
+ mock_velopack_info = MagicMock()
856
+ mock_velopack_info.TargetFullRelease.FileName = 'already-there.nupkg'
857
+
858
+ with patch('synodic_client.updater.sys') as mock_sys:
859
+ mock_sys.executable = str(tmp_path / 'current' / 'synodic.exe')
860
+ (tmp_path / 'current').mkdir()
861
+ packages = tmp_path / 'packages'
862
+ packages.mkdir()
863
+ (packages / 'already-there.nupkg').write_bytes(b'existing')
864
+
865
+ # Should not hit network at all
866
+ dev_updater._download_direct(mock_velopack_info)