synodic-client 0.0.1.dev77__tar.gz → 0.0.1.dev79__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 (108) hide show
  1. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/bootstrap.py +16 -3
  4. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/schema.py +0 -33
  5. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/action_card.py +9 -9
  6. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/install.py +29 -18
  7. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/install_workers.py +31 -77
  8. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/plugin_row.py +84 -28
  9. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/projects.py +7 -10
  10. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/schema.py +20 -18
  11. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/screen.py +34 -0
  12. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/tool_update_controller.py +70 -49
  13. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/theme.py +3 -0
  14. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/update_controller.py +10 -3
  15. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/update_model.py +15 -8
  16. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/install.py +33 -36
  17. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/update.py +8 -1
  18. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/config.py +12 -12
  19. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/install.py +94 -2
  20. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/schema.py +0 -3
  21. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/tool.py +82 -30
  22. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/resolution.py +178 -211
  23. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/updater.py +102 -15
  24. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_tool.py +5 -63
  25. synodic_client-0.0.1.dev79/tests/unit/qt/conftest.py +125 -0
  26. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_action_card.py +112 -173
  27. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_gather_packages.py +46 -79
  28. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_install_preview.py +32 -25
  29. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_log_panel.py +47 -91
  30. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_preview_model.py +14 -47
  31. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_settings.py +30 -58
  32. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_sidebar.py +17 -32
  33. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_tray_window_show.py +3 -20
  34. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_update_controller.py +28 -40
  35. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_update_feedback.py +78 -11
  36. synodic_client-0.0.1.dev79/tests/unit/test_bootstrap.py +79 -0
  37. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_cli.py +28 -1
  38. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_resolution.py +30 -11
  39. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_updater.py +55 -156
  40. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_workers.py +72 -1
  41. synodic_client-0.0.1.dev79/tests/unit/windows/conftest.py +19 -0
  42. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/windows/test_protocol.py +5 -7
  43. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/windows/test_startup.py +17 -43
  44. synodic_client-0.0.1.dev77/synodic_client/application/workers.py +0 -20
  45. synodic_client-0.0.1.dev77/tests/unit/qt/conftest.py +0 -24
  46. synodic_client-0.0.1.dev77/tests/unit/windows/conftest.py +0 -9
  47. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/LICENSE.md +0 -0
  48. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/README.md +0 -0
  49. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/__main__.py +0 -0
  51. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/__init__.py +0 -0
  52. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/config_store.py +0 -0
  53. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/data.py +0 -0
  54. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/debug.py +0 -0
  55. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/icon.py +0 -0
  56. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/init.py +0 -0
  57. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/instance.py +0 -0
  58. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/package_state.py +0 -0
  59. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/qt.py +0 -0
  60. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/__init__.py +0 -0
  61. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/card.py +0 -0
  62. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/log_panel.py +0 -0
  63. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/settings.py +0 -0
  64. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/sidebar.py +0 -0
  65. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/spinner.py +0 -0
  66. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/tray.py +0 -0
  67. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/update_banner.py +0 -0
  68. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/application/uri.py +0 -0
  69. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/__init__.py +0 -0
  70. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/config.py +0 -0
  71. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/context.py +0 -0
  72. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/debug.py +0 -0
  73. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/output.py +0 -0
  74. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/project.py +0 -0
  75. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/cli/tool.py +0 -0
  76. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/client.py +0 -0
  77. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/config.py +0 -0
  78. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/logging.py +0 -0
  79. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/__init__.py +0 -0
  80. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/bootstrap.py +0 -0
  81. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/project.py +0 -0
  82. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/operations/update.py +0 -0
  83. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/protocol.py +0 -0
  84. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/py.typed +0 -0
  85. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/schema.py +0 -0
  86. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/startup.py +0 -0
  87. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/synodic_client/subprocess_patch.py +0 -0
  88. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/__init__.py +0 -0
  89. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/conftest.py +0 -0
  90. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/__init__.py +0 -0
  91. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/__init__.py +0 -0
  92. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_config.py +0 -0
  93. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_install.py +0 -0
  94. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_install_plan.py +0 -0
  95. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_project.py +0 -0
  96. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_update.py +0 -0
  97. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/__init__.py +0 -0
  98. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_logging.py +0 -0
  99. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_package_state.py +0 -0
  100. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_update_banner.py +0 -0
  101. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_client_updater.py +0 -0
  102. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_client_version.py +0 -0
  103. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_config.py +0 -0
  104. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_examples.py +0 -0
  105. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_init.py +0 -0
  106. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_install.py +0 -0
  107. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/test_uri.py +0 -0
  108. {synodic_client-0.0.1.dev77 → synodic_client-0.0.1.dev79}/tests/unit/windows/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev77
3
+ Version: 0.0.1.dev79
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.1521.dev61717",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev77"
18
+ version = "0.0.1.dev79"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -8,9 +8,11 @@ timeouts (15–30 s) and must complete before the process is killed.
8
8
  Import order matters:
9
9
  1. stdlib + config (pure-Python, fast)
10
10
  2. configure_logging() — now Qt-free
11
- 3. initialize_velopack() — hooks run with logging active
12
- 4. run_startup_preamble() protocol, config seed, auto-startup
13
- 5. import qt.application PySide6 / porringer loaded here
11
+ 3. sync_startup() — refresh Windows auto-startup registry **before**
12
+ Velopack, which may exit the process during post-update hooks
13
+ 4. initialize_velopack()hooks run with logging active
14
+ 5. run_startup_preamble() — protocol, config seed, auto-startup
15
+ 6. import qt.application — PySide6 / porringer loaded here
14
16
  """
15
17
 
16
18
  import logging
@@ -47,6 +49,17 @@ def bootstrap() -> None:
47
49
  logger = logging.getLogger(__name__)
48
50
  logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)
49
51
 
52
+ # Refresh the Windows auto-startup registry entry BEFORE Velopack
53
+ # initialisation. App.run() may exit the current process during
54
+ # post-update lifecycle hooks, so sync_startup must run first to
55
+ # ensure the registry path stays current after an update.
56
+ if not dev_mode:
57
+ from synodic_client.resolution import resolve_config
58
+ from synodic_client.startup import sync_startup
59
+
60
+ config = resolve_config()
61
+ sync_startup(sys.executable, auto_start=config.auto_start)
62
+
50
63
  initialize_velopack()
51
64
 
52
65
  if not dev_mode:
@@ -43,36 +43,3 @@ class Snapshot:
43
43
 
44
44
  plugin_capabilities: dict[str, frozenset[PluginCapability]] = field(default_factory=dict)
45
45
  """Protocol capabilities reported for each discovered plugin."""
46
-
47
-
48
- @dataclass(slots=True)
49
- class ToolUpdateResult:
50
- """Summary of a tool-update run across cached manifests."""
51
-
52
- manifests_processed: int = 0
53
- updated: int = 0
54
- already_latest: int = 0
55
- failed: int = 0
56
- updated_packages: set[str] = field(default_factory=set)
57
- """Package names that were successfully upgraded."""
58
-
59
-
60
- @dataclass(frozen=True, slots=True)
61
- class UpdateTarget:
62
- """Identifies the scope of a manual tool update.
63
-
64
- Passed to the shared completion handler so it can clear the correct
65
- updating state and derive timestamp keys. ``None`` (the default in
66
- the handler) means the update was periodic / automatic.
67
-
68
- When *package* is empty the update targeted an entire plugin;
69
- otherwise it targeted one specific package within the plugin.
70
- *plugin* always carries the signal key (possibly composite
71
- ``"plugin:tag"``).
72
- """
73
-
74
- plugin: str
75
- """Signal key for the plugin (may be composite ``"name:tag"``)."""
76
-
77
- package: str = ''
78
- """Package name, or empty when the whole plugin was updated."""
@@ -79,6 +79,15 @@ _SPINNER_INTERVAL = 50
79
79
  #: display order always matches the order actions actually execute.
80
80
  _KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}
81
81
 
82
+ #: Mapping of resolved status label → stylesheet for dry-run badge styling.
83
+ _STATUS_STYLES: dict[str, str] = {
84
+ 'Update available': ACTION_CARD_STATUS_UPDATE,
85
+ 'Failed': ACTION_CARD_STATUS_FAILED,
86
+ 'Pending': ACTION_CARD_STATUS_PENDING,
87
+ 'Ready': ACTION_CARD_STATUS_SATISFIED,
88
+ 'Needed': ACTION_CARD_STATUS_NEEDED,
89
+ }
90
+
82
91
 
83
92
  def action_sort_key(action: SetupAction) -> int:
84
93
  """Return a sort key that groups cards by execution phase.
@@ -465,15 +474,6 @@ class ActionCard(QFrame):
465
474
 
466
475
  self._stop_spinner()
467
476
 
468
- # Status-to-style mapping
469
- _STATUS_STYLES: dict[str, str] = {
470
- 'Update available': ACTION_CARD_STATUS_UPDATE,
471
- 'Failed': ACTION_CARD_STATUS_FAILED,
472
- 'Pending': ACTION_CARD_STATUS_PENDING,
473
- 'Ready': ACTION_CARD_STATUS_SATISFIED,
474
- 'Needed': ACTION_CARD_STATUS_NEEDED,
475
- }
476
-
477
477
  style = _STATUS_STYLES.get(status, ACTION_CARD_STATUS_SATISFIED)
478
478
  display = status
479
479
 
@@ -50,7 +50,6 @@ from synodic_client.application.screen.schema import (
50
50
  ActionState,
51
51
  InstallCallbacks,
52
52
  InstallConfig,
53
- PreviewCallbacks,
54
53
  PreviewConfig,
55
54
  PreviewModel,
56
55
  PreviewPhase,
@@ -499,12 +498,7 @@ class SetupPreviewWidget(QWidget):
499
498
  project_directory=project_directory,
500
499
  prerelease_packages=prerelease_packages,
501
500
  ),
502
- callbacks=PreviewCallbacks(
503
- on_manifest_parsed=self._on_manifest_parsed,
504
- on_plugins_queried=self._on_plugins_queried,
505
- on_preview_ready=self._on_preview_resolved,
506
- on_action_checked=self._on_action_checked,
507
- ),
501
+ on_event=self._on_preview_event,
508
502
  plugins=self._discovered_plugins,
509
503
  )
510
504
  self._on_preview_finished()
@@ -540,6 +534,26 @@ class SetupPreviewWidget(QWidget):
540
534
  logger.exception('Install execution failed')
541
535
  self._on_install_error(str(exc))
542
536
 
537
+ # --- Preview event dispatcher ---
538
+
539
+ def _on_preview_event(self, event: object) -> None:
540
+ """Route a :data:`PreviewEvent` to the appropriate handler."""
541
+ from synodic_client.operations.schema import (
542
+ PreviewActionChecked,
543
+ PreviewManifestParsed,
544
+ PreviewPluginsQueried,
545
+ PreviewReady,
546
+ )
547
+
548
+ if isinstance(event, PreviewManifestParsed):
549
+ self._on_manifest_parsed(event.manifest, event.manifest_path, event.temp_dir)
550
+ elif isinstance(event, PreviewPluginsQueried):
551
+ self._on_plugins_queried(event.availability, event.capabilities)
552
+ elif isinstance(event, PreviewReady):
553
+ self._on_preview_resolved(event.manifest, event.manifest_path, event.temp_dir)
554
+ elif isinstance(event, PreviewActionChecked):
555
+ self._on_action_checked(event.index, event.result, event.status)
556
+
543
557
  # --- Preview callbacks (wired by load()) ---
544
558
 
545
559
  def _on_manifest_parsed(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None:
@@ -577,9 +591,6 @@ class SetupPreviewWidget(QWidget):
577
591
 
578
592
  self._show_metadata(preview)
579
593
 
580
- if preview.metadata:
581
- self.metadata_ready.emit(preview)
582
-
583
594
  if not preview.actions:
584
595
  self._card_list.clear()
585
596
  self._status_label.setText('No actions to perform — the manifest is empty.')
@@ -618,16 +629,13 @@ class SetupPreviewWidget(QWidget):
618
629
 
619
630
  Called after ``MANIFEST_LOADED`` — cards are already visible
620
631
  from the earlier ``_on_manifest_parsed`` handler. This updates
621
- the temp-dir reference and emits metadata.
632
+ the temp-dir reference.
622
633
  """
623
634
  if self._model.preview is None:
624
635
  return
625
636
 
626
637
  self._model.temp_dir = temp_dir_path
627
638
 
628
- if preview.metadata:
629
- self.metadata_ready.emit(preview)
630
-
631
639
  def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None:
632
640
  """Update the model and action card with a dry-run result.
633
641
 
@@ -976,9 +984,7 @@ class SetupPreviewWidget(QWidget):
976
984
  pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0
977
985
 
978
986
  # If we have stashed install results (auto-run path), use combined summary
979
- install_results = (
980
- list(self._install_results.results) if hasattr(self, '_install_results') and self._install_results else None
981
- )
987
+ install_results = list(self._install_results.results) if self._install_results else None
982
988
  summary = format_install_summary(
983
989
  install_results=install_results,
984
990
  post_sync_results=m.post_sync_results,
@@ -988,7 +994,12 @@ class SetupPreviewWidget(QWidget):
988
994
  self._install_btn.setEnabled(False)
989
995
  self._run_commands_btn.setEnabled(False)
990
996
  self._close_btn.setEnabled(True)
991
- self.install_finished.emit(results)
997
+
998
+ # Only emit when coming from the auto-run path (install_finished
999
+ # was not yet emitted). On the manual "Run Commands" path the
1000
+ # signal was already emitted by _on_install_finished.
1001
+ if self._install_results is not None:
1002
+ self.install_finished.emit(results)
992
1003
 
993
1004
 
994
1005
  # ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import asyncio
12
12
  import logging
13
+ from collections.abc import Callable
13
14
  from pathlib import Path
14
15
 
15
16
  from porringer.api import API
@@ -17,9 +18,6 @@ from porringer.backend.command.core.discovery import DiscoveredPlugins
17
18
  from porringer.schema import (
18
19
  ActionCompletedEvent,
19
20
  ActionStartedEvent,
20
- ManifestLoadedEvent,
21
- SetupAction,
22
- SetupActionResult,
23
21
  SetupResults,
24
22
  SubActionProgressEvent,
25
23
  )
@@ -27,16 +25,13 @@ from porringer.schema import (
27
25
  from synodic_client.application.screen.schema import (
28
26
  InstallCallbacks,
29
27
  InstallConfig,
30
- PreviewCallbacks,
31
28
  PreviewConfig,
32
29
  )
33
30
  from synodic_client.application.uri import safe_rmtree
34
- from synodic_client.operations.install import execute_install, execute_post_sync, preview_manifest_stream
31
+ from synodic_client.operations.install import collect_install, collect_post_sync, preview_manifest_stream
35
32
  from synodic_client.operations.schema import (
36
- PreviewActionChecked,
33
+ PreviewEvent,
37
34
  PreviewManifestParsed,
38
- PreviewPluginsQueried,
39
- PreviewReady,
40
35
  )
41
36
 
42
37
  logger = logging.getLogger(__name__)
@@ -58,16 +53,21 @@ async def run_install(
58
53
  ) -> SetupResults:
59
54
  """Execute setup actions via the operations layer and stream progress.
60
55
 
61
- Delegates to :func:`~synodic_client.operations.install.execute_install`
62
- and routes the tagged ``(stage, event)`` stream to GUI callbacks.
56
+ Delegates to :func:`~synodic_client.operations.install.collect_install`
57
+ and routes progress events to GUI callbacks.
63
58
  """
64
59
  cfg = config or InstallConfig()
65
60
  cb = callbacks or InstallCallbacks()
66
- actions: list[SetupAction] = []
67
- collected: list[SetupActionResult] = []
68
- manifest_result: SetupResults | None = None
69
61
 
70
- async for stage, event in execute_install(
62
+ def _on_progress(stage: str, event: object) -> None:
63
+ if stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
64
+ cb.on_action_started(event.action)
65
+ elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
66
+ cb.on_sub_progress(event.action, event.sub_action)
67
+ elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent) and cb.on_progress is not None:
68
+ cb.on_progress(event.action, event.result)
69
+
70
+ return await collect_install(
71
71
  porringer,
72
72
  manifest_path,
73
73
  project_directory=cfg.project_directory,
@@ -75,27 +75,7 @@ async def run_install(
75
75
  prerelease_packages=cfg.prerelease_packages,
76
76
  discovered=plugins,
77
77
  exclude_post_sync=exclude_post_sync,
78
- ):
79
- if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent):
80
- manifest_result = event.manifest
81
- actions = list(event.manifest.actions)
82
-
83
- elif stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
84
- cb.on_action_started(event.action)
85
-
86
- elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
87
- cb.on_sub_progress(event.action, event.sub_action)
88
-
89
- elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent):
90
- collected.append(event.result)
91
- if cb.on_progress is not None:
92
- cb.on_progress(event.action, event.result)
93
-
94
- return SetupResults(
95
- actions=actions,
96
- results=collected,
97
- manifest_path=manifest_result.manifest_path if manifest_result else None,
98
- metadata=manifest_result.metadata if manifest_result else None,
78
+ on_progress=_on_progress,
99
79
  )
100
80
 
101
81
 
@@ -114,40 +94,25 @@ async def run_post_sync(
114
94
  ) -> SetupResults:
115
95
  """Execute only the post-sync commands from a manifest.
116
96
 
117
- Delegates to :func:`~synodic_client.operations.install.execute_post_sync`
118
- and routes events to GUI callbacks.
97
+ Delegates to :func:`~synodic_client.operations.install.collect_post_sync`
98
+ and routes progress events to GUI callbacks.
119
99
  """
120
100
  cb = callbacks or InstallCallbacks()
121
- actions: list[SetupAction] = []
122
- collected: list[SetupActionResult] = []
123
- manifest_result: SetupResults | None = None
124
-
125
- async for stage, event in execute_post_sync(
126
- porringer,
127
- manifest_path,
128
- project_directory=project_directory,
129
- discovered=plugins,
130
- ):
131
- if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent):
132
- manifest_result = event.manifest
133
- actions = list(event.manifest.actions)
134
101
 
135
- elif stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
102
+ def _on_progress(stage: str, event: object) -> None:
103
+ if stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
136
104
  cb.on_action_started(event.action)
137
-
138
105
  elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
139
106
  cb.on_sub_progress(event.action, event.sub_action)
107
+ elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent) and cb.on_progress is not None:
108
+ cb.on_progress(event.action, event.result)
140
109
 
141
- elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent):
142
- collected.append(event.result)
143
- if cb.on_progress is not None:
144
- cb.on_progress(event.action, event.result)
145
-
146
- return SetupResults(
147
- actions=actions,
148
- results=collected,
149
- manifest_path=manifest_result.manifest_path if manifest_result else None,
150
- metadata=manifest_result.metadata if manifest_result else None,
110
+ return await collect_post_sync(
111
+ porringer,
112
+ manifest_path,
113
+ project_directory=project_directory,
114
+ discovered=plugins,
115
+ on_progress=_on_progress,
151
116
  )
152
117
 
153
118
 
@@ -161,17 +126,15 @@ async def run_preview(
161
126
  url: str,
162
127
  *,
163
128
  config: PreviewConfig | None = None,
164
- callbacks: PreviewCallbacks | None = None,
129
+ on_event: Callable[[PreviewEvent], object] | None = None,
165
130
  plugins: DiscoveredPlugins | None = None,
166
131
  ) -> None:
167
132
  """Download a manifest and perform a dry-run preview.
168
133
 
169
134
  Delegates to :func:`preview_manifest_stream` in the operations
170
- layer, then routes each :data:`PreviewEvent` to the appropriate
171
- callback.
135
+ layer, then yields each :data:`PreviewEvent` to *on_event*.
172
136
  """
173
137
  logger.info('run_preview starting for: %s', url)
174
- cb = callbacks or PreviewCallbacks()
175
138
  cfg = config or PreviewConfig()
176
139
  temp_dir: str | None = None
177
140
  try:
@@ -184,18 +147,9 @@ async def run_preview(
184
147
  ):
185
148
  if isinstance(event, PreviewManifestParsed):
186
149
  temp_dir = event.temp_dir or None
187
- if cb.on_manifest_parsed is not None:
188
- cb.on_manifest_parsed(event.manifest, event.manifest_path, event.temp_dir)
189
-
190
- elif isinstance(event, PreviewPluginsQueried) and cb.on_plugins_queried is not None:
191
- cb.on_plugins_queried(event.availability, event.capabilities)
192
-
193
- elif isinstance(event, PreviewReady):
194
- if cb.on_preview_ready is not None:
195
- cb.on_preview_ready(event.manifest, event.manifest_path, event.temp_dir)
196
150
 
197
- elif isinstance(event, PreviewActionChecked) and cb.on_action_checked is not None:
198
- cb.on_action_checked(event.index, event.result, event.status)
151
+ if on_event is not None:
152
+ on_event(event)
199
153
 
200
154
  except asyncio.CancelledError:
201
155
  if temp_dir:
@@ -7,6 +7,8 @@ package rows, project child rows, and filter chips.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from enum import Enum
11
+
10
12
  from porringer.schema import PluginInfo
11
13
  from porringer.schema.plugin import PluginCapability, PluginKind
12
14
  from PySide6.QtCore import Qt, QTimer, Signal
@@ -40,6 +42,7 @@ from synodic_client.application.theme import (
40
42
  PLUGIN_ROW_PROJECT_TAG_TRANSITIVE_STYLE,
41
43
  PLUGIN_ROW_REMOVE_STYLE,
42
44
  PLUGIN_ROW_STATUS_MIN_WIDTH,
45
+ PLUGIN_ROW_STATUS_PENDING_STYLE,
43
46
  PLUGIN_ROW_STATUS_STYLE,
44
47
  PLUGIN_ROW_STYLE,
45
48
  PLUGIN_ROW_TIMESTAMP_MIN_WIDTH,
@@ -59,6 +62,16 @@ from synodic_client.application.theme import (
59
62
  PROJECT_CHILD_VERSION_STYLE,
60
63
  )
61
64
 
65
+
66
+ class RowPhase(Enum):
67
+ """Mutually exclusive visual state for a :class:`PluginRow`."""
68
+
69
+ IDLE = 'idle'
70
+ CHECKING = 'checking'
71
+ PENDING = 'pending'
72
+ UPDATING = 'updating'
73
+
74
+
62
75
  # Row-spinner dimensions
63
76
  _ROW_SPINNER_SIZE = 12
64
77
  _ROW_SPINNER_PEN = 2
@@ -249,15 +262,18 @@ class PluginProviderHeader(QFrame):
249
262
  update_btn.setToolTip('Not installed \u2014 cannot update')
250
263
 
251
264
  def set_updating(self, updating: bool) -> None:
252
- """Toggle the button between *Updating…* and *Update* states."""
253
- if self._update_btn is None:
254
- return
265
+ """Toggle between *Updating…* (with spinner) and *Update* states."""
255
266
  if updating:
256
- self._update_btn.setText('Updating\u2026')
257
- self._update_btn.setEnabled(False)
267
+ if self._checking_spinner is not None:
268
+ self._checking_spinner.start()
269
+ if self._update_btn is not None:
270
+ self._update_btn.hide()
258
271
  else:
259
- self._update_btn.setText('Update')
260
- self._update_btn.setEnabled(True)
272
+ if self._checking_spinner is not None:
273
+ self._checking_spinner.stop()
274
+ if self._update_btn is not None:
275
+ self._update_btn.setText('Update')
276
+ self._update_btn.setEnabled(True)
261
277
 
262
278
  def set_checking(self, checking: bool) -> None:
263
279
  """Show or hide the inline checking spinner."""
@@ -331,7 +347,7 @@ class PluginRow(QFrame):
331
347
  self._signal_key = f'{data.plugin_name}:{data.runtime_tag}' if data.runtime_tag else data.plugin_name
332
348
  self._update_btn: QPushButton | None = None
333
349
  self._remove_btn: QPushButton | None = None
334
- self._checking_spinner: _RowSpinner | None = None
350
+ self._row_spinner: _RowSpinner | None = None
335
351
  self._host_label: QLabel | None = None
336
352
  self._project_paths: list[str] = list(data.project_paths)
337
353
  self._project_labels: list[str] = [p.project_label for p in data.project_instances]
@@ -386,6 +402,11 @@ class PluginRow(QFrame):
386
402
  if data.show_toggle:
387
403
  self._build_toggle(layout, data)
388
404
 
405
+ # Inline spinner — always created so checking, pending, and
406
+ # updating flows can use it regardless of whether the toggle is shown.
407
+ self._row_spinner = _RowSpinner(self)
408
+ layout.addWidget(self._row_spinner)
409
+
389
410
  # Update button — always created for alignment, hidden when no update
390
411
  self._build_update_button(layout, data)
391
412
 
@@ -421,7 +442,7 @@ class PluginRow(QFrame):
421
442
  self._build_remove_button(layout, data)
422
443
 
423
444
  def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None:
424
- """Add the auto-update toggle and inline checking spinner."""
445
+ """Add the auto-update toggle button."""
425
446
  toggle_btn = QPushButton('Auto')
426
447
  toggle_btn.setCheckable(True)
427
448
  toggle_btn.setChecked(data.auto_update)
@@ -436,9 +457,6 @@ class PluginRow(QFrame):
436
457
  )
437
458
  layout.addWidget(toggle_btn)
438
459
 
439
- self._checking_spinner = _RowSpinner(self)
440
- layout.addWidget(self._checking_spinner)
441
-
442
460
  def _build_update_button(self, layout: QHBoxLayout, data: PluginRowData) -> None:
443
461
  """Add the per-package update button (always created, visibility toggled)."""
444
462
  update_btn = QPushButton('Update')
@@ -483,27 +501,65 @@ class PluginRow(QFrame):
483
501
  self._remove_btn = remove_btn
484
502
  layout.addWidget(remove_btn)
485
503
 
486
- def set_updating(self, updating: bool) -> None:
487
- """Toggle the button between *Updating…* and *Update* states."""
488
- if self._update_btn is None:
489
- return
490
- if updating:
491
- self._update_btn.setText('Updating\u2026')
492
- self._update_btn.setEnabled(False)
504
+ def set_phase(self, phase: RowPhase) -> None:
505
+ """Transition the row to a mutually exclusive visual *phase*.
506
+
507
+ * ``IDLE`` — spinner stopped, status hidden, button restored.
508
+ * ``CHECKING`` — spinner running, button hidden.
509
+ * ``PENDING`` — spinner stopped, *Pending* text shown, button hidden.
510
+ * ``UPDATING`` — spinner running, status hidden, button hidden.
511
+ """
512
+ self._apply_phase_reset()
513
+
514
+ if phase == RowPhase.IDLE:
515
+ self._apply_phase_idle()
516
+ elif phase == RowPhase.PENDING:
517
+ self._apply_phase_pending()
493
518
  else:
519
+ # CHECKING and UPDATING both start spinner and hide update btn
520
+ self._apply_phase_spinner()
521
+
522
+ def _apply_phase_reset(self) -> None:
523
+ """Stop spinner and hide status — common entry for every transition."""
524
+ if self._row_spinner is not None:
525
+ self._row_spinner.stop()
526
+ if self._update_status_label is not None:
527
+ self._update_status_label.hide()
528
+
529
+ def _apply_phase_idle(self) -> None:
530
+ """Restore the update button to its default label and state."""
531
+ if self._update_btn is not None:
494
532
  self._update_btn.setText('Update')
495
533
  self._update_btn.setEnabled(True)
496
534
 
535
+ def _apply_phase_pending(self) -> None:
536
+ """Show *Pending* text and hide the update button."""
537
+ if self._update_status_label is not None:
538
+ self._update_status_label.setText('Pending')
539
+ self._update_status_label.setStyleSheet(PLUGIN_ROW_STATUS_PENDING_STYLE)
540
+ self._update_status_label.show()
541
+ if self._update_btn is not None:
542
+ self._update_btn.hide()
543
+
544
+ def _apply_phase_spinner(self) -> None:
545
+ """Start the spinner and hide the update button."""
546
+ if self._row_spinner is not None:
547
+ self._row_spinner.start()
548
+ if self._update_btn is not None:
549
+ self._update_btn.hide()
550
+
551
+ # Convenience aliases for backward-compatible call sites
552
+ def set_updating(self, updating: bool) -> None:
553
+ """Toggle between updating and idle states."""
554
+ self.set_phase(RowPhase.UPDATING if updating else RowPhase.IDLE)
555
+
497
556
  def set_checking(self, checking: bool) -> None:
498
- """Show or hide the inline checking spinner."""
499
- if self._checking_spinner is None:
500
- return
501
- if checking:
502
- self._checking_spinner.start()
503
- if self._update_btn is not None:
504
- self._update_btn.hide()
505
- else:
506
- self._checking_spinner.stop()
557
+ """Toggle between checking and idle states."""
558
+ self.set_phase(RowPhase.CHECKING if checking else RowPhase.IDLE)
559
+
560
+ def set_pending(self, pending: bool) -> None:
561
+ """Toggle between pending and idle states."""
562
+ self.set_phase(RowPhase.PENDING if pending else RowPhase.IDLE)
507
563
 
508
564
  def set_removing(self, removing: bool) -> None:
509
565
  """Toggle the remove button between *Removing…* and *×* states."""
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
9
9
 
10
10
  from porringer.api import API
11
11
  from porringer.backend.command.core.discovery import DiscoveredPlugins
12
+ from porringer.schema import DirectoryValidationResult, ManifestDirectory
12
13
  from PySide6.QtCore import Qt, Signal
13
14
  from PySide6.QtWidgets import (
14
15
  QFileDialog,
@@ -138,15 +139,11 @@ class ProjectsView(QWidget):
138
139
  # Convert ProjectInfo list to the same shape as validated_directories
139
140
  results = []
140
141
  for p in projects:
141
- result = type(
142
- '_Result',
143
- (),
144
- {
145
- 'directory': type('_Dir', (), {'path': p.path, 'name': p.name})(),
146
- 'exists': p.exists,
147
- 'has_manifest': p.has_manifest,
148
- },
149
- )()
142
+ result = DirectoryValidationResult(
143
+ directory=ManifestDirectory(path=Path(p.path), name=p.name),
144
+ exists=p.exists,
145
+ has_manifest=p.has_manifest,
146
+ )
150
147
  results.append(result)
151
148
  discovered = None
152
149
 
@@ -154,7 +151,7 @@ class ProjectsView(QWidget):
154
151
  current_paths: set[Path] = set()
155
152
  for result in results:
156
153
  d = result.directory
157
- valid = bool(result.exists and result.has_manifest is not False)
154
+ valid = bool(result.exists and result.has_manifest)
158
155
  path = Path(d.path)
159
156
  directories.append((path, d.name or '', valid))
160
157
  current_paths.add(path)