synodic-client 0.0.1.dev74__tar.gz → 0.0.1.dev76__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.dev74 → synodic_client-0.0.1.dev76}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/debug.py +27 -46
  4. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/qt.py +6 -23
  5. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/__init__.py +11 -17
  6. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/action_card.py +45 -35
  7. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/install.py +19 -31
  8. synodic_client-0.0.1.dev76/synodic_client/application/screen/install_workers.py +152 -0
  9. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/schema.py +2 -9
  10. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/screen.py +6 -63
  11. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/tool_update_controller.py +40 -83
  12. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/update_controller.py +7 -11
  13. synodic_client-0.0.1.dev76/synodic_client/application/workers.py +20 -0
  14. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/config.py +6 -9
  15. synodic_client-0.0.1.dev76/synodic_client/cli/debug.py +187 -0
  16. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/tool.py +1 -3
  17. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/operations/__init__.py +47 -3
  18. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/operations/config.py +21 -0
  19. synodic_client-0.0.1.dev76/synodic_client/operations/install.py +272 -0
  20. synodic_client-0.0.1.dev76/synodic_client/operations/project.py +270 -0
  21. synodic_client-0.0.1.dev76/synodic_client/operations/schema.py +332 -0
  22. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/operations/tool.py +52 -43
  23. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/resolution.py +5 -70
  24. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/subprocess_patch.py +6 -1
  25. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/updater.py +1 -1
  26. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_action_card.py +46 -33
  27. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_gather_packages.py +11 -11
  28. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_install_preview.py +12 -12
  29. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_tray_window_show.py +6 -5
  30. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_update_controller.py +14 -13
  31. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_cli.py +69 -18
  32. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_workers.py +21 -23
  33. synodic_client-0.0.1.dev74/synodic_client/application/screen/install_workers.py +0 -251
  34. synodic_client-0.0.1.dev74/synodic_client/application/workers.py +0 -214
  35. synodic_client-0.0.1.dev74/synodic_client/cli/debug.py +0 -85
  36. synodic_client-0.0.1.dev74/synodic_client/operations/install.py +0 -150
  37. synodic_client-0.0.1.dev74/synodic_client/operations/project.py +0 -87
  38. synodic_client-0.0.1.dev74/synodic_client/operations/schema.py +0 -180
  39. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/LICENSE.md +0 -0
  40. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/README.md +0 -0
  41. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/__main__.py +0 -0
  43. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/bootstrap.py +0 -0
  45. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/config_store.py +0 -0
  46. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/data.py +0 -0
  47. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/icon.py +0 -0
  48. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/init.py +0 -0
  49. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/instance.py +0 -0
  50. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/package_state.py +0 -0
  51. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/schema.py +0 -0
  52. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/card.py +0 -0
  53. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/log_panel.py +0 -0
  54. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/plugin_row.py +0 -0
  55. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/projects.py +0 -0
  56. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/settings.py +0 -0
  57. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/sidebar.py +0 -0
  58. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/spinner.py +0 -0
  59. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/tray.py +0 -0
  60. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/screen/update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/theme.py +0 -0
  62. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/update_model.py +0 -0
  63. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/application/uri.py +0 -0
  64. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/__init__.py +0 -0
  65. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/context.py +0 -0
  66. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/output.py +0 -0
  67. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/project.py +0 -0
  68. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/cli/update.py +0 -0
  69. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/client.py +0 -0
  70. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/config.py +0 -0
  71. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/logging.py +0 -0
  72. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/operations/bootstrap.py +0 -0
  73. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/operations/update.py +0 -0
  74. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/protocol.py +0 -0
  75. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/py.typed +0 -0
  76. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/schema.py +0 -0
  77. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/synodic_client/startup.py +0 -0
  78. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/__init__.py +0 -0
  79. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/conftest.py +0 -0
  80. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/__init__.py +0 -0
  81. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/operations/__init__.py +0 -0
  82. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/operations/test_config.py +0 -0
  83. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/operations/test_install.py +0 -0
  84. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/operations/test_project.py +0 -0
  85. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/operations/test_tool.py +0 -0
  86. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/operations/test_update.py +0 -0
  87. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/__init__.py +0 -0
  88. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/conftest.py +0 -0
  89. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_log_panel.py +0 -0
  90. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_logging.py +0 -0
  91. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_preview_model.py +0 -0
  92. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_settings.py +0 -0
  93. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_sidebar.py +0 -0
  94. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_update_banner.py +0 -0
  95. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/qt/test_update_feedback.py +0 -0
  96. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_client_updater.py +0 -0
  97. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_client_version.py +0 -0
  98. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_config.py +0 -0
  99. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_examples.py +0 -0
  100. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_init.py +0 -0
  101. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_install.py +0 -0
  102. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_resolution.py +0 -0
  103. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_updater.py +0 -0
  104. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/test_uri.py +0 -0
  105. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/windows/__init__.py +0 -0
  106. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/windows/conftest.py +0 -0
  107. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/tests/unit/windows/test_protocol.py +0 -0
  108. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev76}/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.dev74
3
+ Version: 0.0.1.dev76
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.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev74"
18
+ version = "0.0.1.dev76"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import TYPE_CHECKING
18
18
 
19
19
  from synodic_client.config import is_dev_mode
20
+ from synodic_client.operations.schema import DEBUG_ACTIONS
20
21
 
21
22
  if TYPE_CHECKING:
22
23
  from porringer.api import API
@@ -32,19 +33,7 @@ if TYPE_CHECKING:
32
33
 
33
34
  logger = logging.getLogger(__name__)
34
35
 
35
- _ACTIONS: dict[str, str] = {
36
- 'check_update': 'Trigger a self-update check.',
37
- 'tool_update': 'Run tool/package updates for all plugins.',
38
- 'refresh_data': 'Mark cached data as stale (next refresh re-fetches).',
39
- 'show_main': 'Show and raise the main window.',
40
- 'show_settings': 'Show the settings window.',
41
- 'apply_update': 'Apply a downloaded update and restart.',
42
- 'list_projects': 'List cached project directories with validation status.',
43
- 'add_project': 'Add a directory to the project cache. Arg: <path>',
44
- 'remove_project': 'Remove a directory from the project cache. Arg: <path>',
45
- 'project_status': 'Dump per-action preview status. Arg (optional): <path>',
46
- 'select_project': 'Select a project in the sidebar. Arg: <path>',
47
- }
36
+ _ACTIONS = DEBUG_ACTIONS
48
37
 
49
38
 
50
39
  @dataclasses.dataclass
@@ -164,49 +153,39 @@ class DebugHandler:
164
153
 
165
154
  def _handle_list_projects(self) -> str:
166
155
  """List all cached project directories with validation status."""
167
- from synodic_client.operations.project import list_projects
156
+ from synodic_client.operations.project import run_project_action
168
157
 
169
- projects = list_projects(self._s.porringer)
170
- return json.dumps({'projects': [dataclasses.asdict(p) for p in projects]})
158
+ return json.dumps(run_project_action('list_projects', None, self._s.porringer))
171
159
 
172
160
  def _handle_add_project(self, arg: str | None) -> str:
173
161
  """Add a directory to the porringer cache."""
174
- if not arg:
175
- return json.dumps({'error': 'add_project requires a path argument'})
176
-
177
- from synodic_client.operations.project import add_project
162
+ from synodic_client.operations.project import run_project_action
178
163
 
179
- try:
180
- add_project(self._s.porringer, arg)
181
- except (NotADirectoryError, ValueError) as exc:
182
- return json.dumps({'error': str(exc)})
164
+ result = run_project_action('add_project', arg, self._s.porringer)
183
165
 
184
- if self._s.coordinator is not None:
185
- self._s.coordinator.invalidate()
186
-
187
- projects_view = self._s.main_window._projects_view
188
- if projects_view is not None:
189
- projects_view.refresh()
166
+ if 'error' not in result:
167
+ if self._s.coordinator is not None:
168
+ self._s.coordinator.invalidate()
169
+ projects_view = self._s.main_window._projects_view
170
+ if projects_view is not None:
171
+ projects_view.refresh()
190
172
 
191
- return json.dumps({'ok': True, 'action': 'add_project', 'path': arg})
173
+ return json.dumps(result)
192
174
 
193
175
  def _handle_remove_project(self, arg: str | None) -> str:
194
176
  """Remove a directory from the porringer cache."""
195
- if not arg:
196
- return json.dumps({'error': 'remove_project requires a path argument'})
177
+ from synodic_client.operations.project import run_project_action
197
178
 
198
- from synodic_client.operations.project import remove_project
179
+ result = run_project_action('remove_project', arg, self._s.porringer)
199
180
 
200
- remove_project(self._s.porringer, arg)
201
-
202
- if self._s.coordinator is not None:
203
- self._s.coordinator.invalidate()
204
-
205
- projects_view = self._s.main_window._projects_view
206
- if projects_view is not None:
207
- projects_view.refresh()
181
+ if 'error' not in result:
182
+ if self._s.coordinator is not None:
183
+ self._s.coordinator.invalidate()
184
+ projects_view = self._s.main_window._projects_view
185
+ if projects_view is not None:
186
+ projects_view.refresh()
208
187
 
209
- return json.dumps({'ok': True, 'action': 'remove_project', 'path': arg})
188
+ return json.dumps(result)
210
189
 
211
190
  def _handle_project_status(self, arg: str | None) -> str:
212
191
  """Dump per-action preview status for a project."""
@@ -243,9 +222,11 @@ class DebugHandler:
243
222
  entry['installer'] = act.installer
244
223
  actions.append(entry)
245
224
 
246
- needed = sum(1 for s in model.action_states if s.status == 'Needed')
247
- satisfied = sum(1 for s in model.action_states if '\u2713' in s.status)
248
- pending = sum(1 for s in model.action_states if s.status == 'Pending')
225
+ from synodic_client.operations.schema import classify_status
226
+
227
+ needed = sum(1 for s in model.action_states if classify_status(s.status) == 'needed')
228
+ satisfied = sum(1 for s in model.action_states if classify_status(s.status) == 'satisfied')
229
+ pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending')
249
230
  upgradable = len(model.upgradable_keys)
250
231
 
251
232
  return json.dumps({
@@ -13,7 +13,6 @@ from collections.abc import Callable
13
13
 
14
14
  import qasync
15
15
  from porringer.api import API
16
- from porringer.schema import LocalConfiguration
17
16
  from PySide6.QtCore import QEvent, QObject, Qt, QTimer
18
17
  from PySide6.QtWidgets import QApplication, QWidget
19
18
 
@@ -29,12 +28,9 @@ from synodic_client.application.uri import parse_uri
29
28
  from synodic_client.client import Client
30
29
  from synodic_client.config import set_dev_mode
31
30
  from synodic_client.logging import configure_logging, log_path, set_debug_level
31
+ from synodic_client.operations.bootstrap import init_services
32
32
  from synodic_client.protocol import extract_uri_from_args
33
- from synodic_client.resolution import (
34
- ResolvedConfig,
35
- resolve_config,
36
- resolve_update_config,
37
- )
33
+ from synodic_client.resolution import ResolvedConfig
38
34
  from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
39
35
  from synodic_client.updater import initialize_velopack
40
36
 
@@ -42,27 +38,14 @@ from synodic_client.updater import initialize_velopack
42
38
  def _init_services(logger: logging.Logger) -> tuple[Client, API, ResolvedConfig]:
43
39
  """Create and configure core services.
44
40
 
41
+ Delegates to :func:`~synodic_client.operations.bootstrap.init_services`
42
+ and adds GUI-specific debug logging.
43
+
45
44
  Returns:
46
45
  A (Client, porringer API, resolved config) tuple.
47
46
  """
48
- config = resolve_config()
49
- client = Client()
50
-
51
- local_config = LocalConfiguration()
52
- porringer = API(local_config)
53
-
54
- update_config = resolve_update_config(config)
55
- client.initialize_updater(update_config)
47
+ client, porringer, config = init_services()
56
48
 
57
- cached_dirs = porringer.cache.list_directories()
58
-
59
- logger.info(
60
- 'Synodic Client v%s started (channel: %s, source: %s, cached_projects: %d)',
61
- client.version,
62
- update_config.channel.name,
63
- update_config.repo_url,
64
- len(cached_dirs),
65
- )
66
49
  logger.debug(
67
50
  'Resolved config: update_source=%s update_channel=%s auto_update=%dm tool_update=%dm '
68
51
  'auto_apply=%s auto_start=%s debug_logging=%s prerelease_packages=%s plugin_auto_update=%s',
@@ -9,9 +9,19 @@ from __future__ import annotations
9
9
  import re
10
10
  from datetime import UTC, datetime
11
11
 
12
- from porringer.schema import SetupAction, SetupActionResult, SkipReason
12
+ from porringer.schema import SetupAction, SetupActionResult
13
13
  from porringer.schema.plugin import PluginKind
14
14
 
15
+ from synodic_client.operations.schema import (
16
+ SKIP_REASON_LABELS as SKIP_REASON_LABELS,
17
+ )
18
+ from synodic_client.operations.schema import (
19
+ resolve_action_status as resolve_action_status,
20
+ )
21
+ from synodic_client.operations.schema import (
22
+ skip_reason_label as skip_reason_label,
23
+ )
24
+
15
25
  _SECONDS_PER_MINUTE = 60
16
26
  _MINUTES_PER_HOUR = 60
17
27
  _HOURS_PER_DAY = 24
@@ -48,22 +58,6 @@ def plugin_kind_group_label(kind: PluginKind) -> str:
48
58
  return PLUGIN_KIND_GROUP_LABELS.get(kind, kind.name.replace('_', ' ').title())
49
59
 
50
60
 
51
- SKIP_REASON_LABELS: dict[SkipReason, str] = {
52
- SkipReason.ALREADY_INSTALLED: 'Already installed',
53
- SkipReason.NOT_INSTALLED: 'Not installed',
54
- SkipReason.ALREADY_LATEST: 'Already latest',
55
- SkipReason.NO_PROJECT_DIRECTORY: 'No project directory',
56
- SkipReason.UPDATE_AVAILABLE: 'Update available',
57
- }
58
-
59
-
60
- def skip_reason_label(reason: SkipReason | None) -> str:
61
- """Return a human-readable label for a skip reason."""
62
- if reason is None:
63
- return 'Skipped'
64
- return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
65
-
66
-
67
61
  def format_cli_command(
68
62
  action: SetupAction,
69
63
  *,
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import logging
15
15
 
16
16
  from porringer.backend.command.core.action_builder import PHASE_ORDER
17
- from porringer.schema import SetupAction, SetupActionResult, SkipReason
17
+ from porringer.schema import SetupAction, SetupActionResult
18
18
  from porringer.schema.plugin import PluginKind
19
19
  from PySide6.QtCore import Qt, QTimer, Signal
20
20
  from PySide6.QtGui import QColor
@@ -443,56 +443,47 @@ class ActionCard(QFrame):
443
443
  # Public API — dry-run check result
444
444
  # ------------------------------------------------------------------
445
445
 
446
- def set_check_result(self, result: SetupActionResult) -> None:
446
+ def set_check_result(self, result: SetupActionResult, status: str) -> None:
447
447
  """Update the card with a dry-run check result.
448
448
 
449
- Handles these cases:
450
-
451
- * **Skipped (update available)** amber "Update available" badge.
452
- * **Skipped (other)** — muted satisfied badge.
453
- * **Failed** — red "Failed" badge with diagnostic tooltip.
454
- * **Bare command** (kind=None, success=True) — keeps "Pending".
455
- * **Project sync** (kind=PROJECT, success=True) — "Ready".
456
- * **Needed** — default blue badge.
449
+ The *status* string is pre-resolved by the operations layer via
450
+ :func:`resolve_action_status`. This method maps it to the
451
+ appropriate style and updates the version / tooltip columns.
457
452
 
458
453
  Args:
459
454
  result: The action check result from the preview worker.
455
+ status: Pre-resolved human-readable status label.
460
456
  """
461
457
  if self._is_skeleton:
462
458
  return
463
459
 
464
460
  self._stop_spinner()
465
461
 
466
- if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE:
467
- label = skip_reason_label(result.skip_reason)
468
- self._status_label.setText(label)
469
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_UPDATE)
470
- elif result.skipped:
471
- label = '\u2713 ' + skip_reason_label(result.skip_reason)
472
- self._status_label.setText(label)
473
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
474
- elif not result.success:
475
- label = 'Failed'
476
- self._status_label.setText(label)
477
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED)
462
+ # Status-to-style mapping
463
+ _STATUS_STYLES: dict[str, str] = {
464
+ 'Update available': ACTION_CARD_STATUS_UPDATE,
465
+ 'Failed': ACTION_CARD_STATUS_FAILED,
466
+ 'Pending': ACTION_CARD_STATUS_PENDING,
467
+ 'Ready': ACTION_CARD_STATUS_SATISFIED,
468
+ 'Needed': ACTION_CARD_STATUS_NEEDED,
469
+ }
470
+
471
+ style = _STATUS_STYLES.get(status, ACTION_CARD_STATUS_SATISFIED)
472
+ display = status
473
+
474
+ # Satisfied (skipped) statuses get a checkmark prefix
475
+ if result.skipped and status not in _STATUS_STYLES:
476
+ display = f'\u2713 {status}'
477
+
478
+ if not result.success and status == 'Failed':
478
479
  logger.warning(
479
480
  'Dry-run check failed for %s: %s',
480
481
  self._action.description if self._action else '(unknown)',
481
482
  result.message or 'unknown error',
482
483
  )
483
- elif self._action is not None and self._action.kind is None:
484
- # Bare command — porringer returns success=True; keep Pending.
485
- label = 'Pending'
486
- self._status_label.setText(label)
487
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING)
488
- elif self._action is not None and self._action.kind == PluginKind.PROJECT:
489
- label = 'Ready'
490
- self._status_label.setText(label)
491
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
492
- else:
493
- label = 'Needed'
494
- self._status_label.setText(label)
495
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED)
484
+
485
+ self._status_label.setText(display)
486
+ self._status_label.setStyleSheet(style)
496
487
 
497
488
  # Surface diagnostic detail (e.g. SCM URL mismatch) as a tooltip
498
489
  if result.message:
@@ -641,6 +632,7 @@ class ActionCardList(QWidget):
641
632
 
642
633
  self._cards: list[ActionCard] = []
643
634
  self._action_map: dict[SetupAction, ActionCard] = {}
635
+ self._index_map: dict[int, ActionCard] = {}
644
636
 
645
637
  # ------------------------------------------------------------------
646
638
  # Skeleton loading
@@ -692,6 +684,14 @@ class ActionCardList(QWidget):
692
684
  self._cards.append(card)
693
685
  self._action_map[act] = card
694
686
 
687
+ # Build original-index → card mapping so callers can look up by
688
+ # the action index porringer emits, which is independent of the
689
+ # display sort order.
690
+ for original_index, act in enumerate(actions):
691
+ card = self._action_map.get(act)
692
+ if card is not None:
693
+ self._index_map[original_index] = card
694
+
695
695
  # ------------------------------------------------------------------
696
696
  # Card lookup
697
697
  # ------------------------------------------------------------------
@@ -706,6 +706,15 @@ class ActionCardList(QWidget):
706
706
  """Return the number of cards (including skeletons)."""
707
707
  return len(self._cards)
708
708
 
709
+ def card_for_action_index(self, action_index: int) -> ActionCard | None:
710
+ """Return the card for the given original action index.
711
+
712
+ The index corresponds to the action's position in the unsorted
713
+ list passed to :meth:`populate`, matching the indices emitted
714
+ by porringer's ``ActionCompletedEvent``.
715
+ """
716
+ return self._index_map.get(action_index)
717
+
709
718
  def get_card(self, action: SetupAction) -> ActionCard | None:
710
719
  """Look up the card for a given action.
711
720
 
@@ -736,3 +745,4 @@ class ActionCardList(QWidget):
736
745
  card.deleteLater()
737
746
  self._cards.clear()
738
747
  self._action_map.clear()
748
+ self._index_map.clear()
@@ -27,7 +27,6 @@ from porringer.schema import (
27
27
  SubActionProgress,
28
28
  SyncStrategy,
29
29
  )
30
- from porringer.schema.plugin import PluginKind
31
30
  from PySide6.QtCore import Qt, QTimer, Signal
32
31
  from PySide6.QtGui import QShowEvent
33
32
  from PySide6.QtWidgets import (
@@ -45,7 +44,6 @@ from PySide6.QtWidgets import (
45
44
  )
46
45
 
47
46
  from synodic_client.application.package_state import PackageStateStore
48
- from synodic_client.application.screen import skip_reason_label
49
47
  from synodic_client.application.screen.action_card import ActionCardList
50
48
  from synodic_client.application.screen.card import CardFrame
51
49
  from synodic_client.application.screen.install_workers import run_install, run_preview
@@ -615,28 +613,13 @@ class SetupPreviewWidget(QWidget):
615
613
  if preview.metadata:
616
614
  self.metadata_ready.emit(preview)
617
615
 
618
- def _on_action_checked(self, row: int, result: SetupActionResult) -> None:
616
+ def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None:
619
617
  """Update the model and action card with a dry-run result."""
620
618
  m = self._model
621
- if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE:
622
- label = skip_reason_label(result.skip_reason)
623
- if 0 <= row < len(m.action_states):
624
- m.upgradable_keys.add(m.action_states[row].action)
625
- elif result.skipped:
626
- label = skip_reason_label(result.skip_reason)
627
- elif not result.success:
628
- label = 'Failed'
629
- else:
630
- # Bare commands (kind=None) and PROJECT actions always return
631
- # success=True from porringer's dry_run_action. Don't
632
- # overwrite their initial status to "Needed".
633
- action = m.action_states[row].action if 0 <= row < len(m.action_states) else None
634
- if action is not None and action.kind is None:
635
- label = 'Pending'
636
- elif action is not None and action.kind == PluginKind.PROJECT:
637
- label = 'Ready'
638
- else:
639
- label = 'Needed'
619
+ label = status
620
+
621
+ if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE and 0 <= row < len(m.action_states):
622
+ m.upgradable_keys.add(m.action_states[row].action)
640
623
 
641
624
  if 0 <= row < len(m.action_states):
642
625
  m.action_states[row].status = label
@@ -655,9 +638,9 @@ class SetupPreviewWidget(QWidget):
655
638
  # Update the card widget
656
639
  if m.preview and 0 <= row < len(m.preview.actions):
657
640
  action = m.preview.actions[row]
658
- card = self._card_list.get_card(action)
641
+ card = self._card_list.card_for_action_index(row)
659
642
  if card is not None:
660
- card.set_check_result(result)
643
+ card.set_check_result(result, status)
661
644
 
662
645
  # Record in shared store so ToolsView can reflect the update
663
646
  if self._package_store is not None and action.installer and action.package:
@@ -696,15 +679,20 @@ class SetupPreviewWidget(QWidget):
696
679
  finalized,
697
680
  )
698
681
 
699
- # Compute summary
682
+ # Compute summary using the shared operations-layer classifier
683
+ from collections import Counter
684
+
685
+ from synodic_client.operations.schema import classify_status
686
+
700
687
  total = len(m.action_states)
701
- needed = sum(1 for s in m.action_states if s.status == 'Needed')
688
+ counts = Counter(classify_status(s.status) for s in m.action_states)
689
+ needed = counts.get('needed', 0)
690
+ satisfied = counts.get('satisfied', 0)
691
+ pending = counts.get('pending', 0)
692
+ ready = counts.get('ready', 0)
693
+ unavailable = counts.get('unavailable', 0)
694
+ failed = counts.get('failed', 0)
702
695
  upgradable = len(m.upgradable_keys)
703
- unavailable = sum(1 for s in m.action_states if s.status == 'Not installed')
704
- failed = sum(1 for s in m.action_states if s.status == 'Failed')
705
- pending = sum(1 for s in m.action_states if s.status == 'Pending')
706
- ready = sum(1 for s in m.action_states if s.status == 'Ready')
707
- satisfied = total - needed - upgradable - unavailable - failed - pending - ready
708
696
 
709
697
  parts: list[str] = []
710
698
  _counts: list[tuple[int, str]] = [
@@ -0,0 +1,152 @@
1
+ """Async worker coroutines for install and preview operations.
2
+
3
+ Contains ``run_install``, ``run_preview``, and supporting helpers that
4
+ stream porringer events back to the GUI via callbacks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ from porringer.api import API
14
+ from porringer.backend.command.core.discovery import DiscoveredPlugins
15
+ from porringer.schema import (
16
+ ActionCompletedEvent,
17
+ ActionStartedEvent,
18
+ ManifestLoadedEvent,
19
+ SetupAction,
20
+ SetupActionResult,
21
+ SetupParameters,
22
+ SetupResults,
23
+ SubActionProgressEvent,
24
+ )
25
+
26
+ from synodic_client.application.screen.schema import (
27
+ InstallCallbacks,
28
+ InstallConfig,
29
+ PreviewCallbacks,
30
+ PreviewConfig,
31
+ )
32
+ from synodic_client.application.uri import safe_rmtree
33
+ from synodic_client.operations.install import preview_manifest_stream
34
+ from synodic_client.operations.schema import (
35
+ PreviewActionChecked,
36
+ PreviewManifestParsed,
37
+ PreviewPluginsQueried,
38
+ PreviewReady,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # run_install — execute setup actions via porringer
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ async def run_install(
50
+ porringer: API,
51
+ manifest_path: Path,
52
+ config: InstallConfig | None = None,
53
+ callbacks: InstallCallbacks | None = None,
54
+ *,
55
+ plugins: DiscoveredPlugins | None = None,
56
+ ) -> SetupResults:
57
+ """Execute setup actions via porringer and stream progress.
58
+
59
+ Runs on the caller's event loop (typically the qasync main-thread
60
+ loop). Callbacks are invoked between ``await`` points so the GUI
61
+ stays responsive without cross-thread signalling.
62
+ """
63
+ cfg = config or InstallConfig()
64
+ cb = callbacks or InstallCallbacks()
65
+ params = SetupParameters(
66
+ paths=[manifest_path],
67
+ project_directory=cfg.project_directory,
68
+ strategy=cfg.strategy,
69
+ prerelease_packages=cfg.prerelease_packages,
70
+ )
71
+ actions: list[SetupAction] = []
72
+ collected: list[SetupActionResult] = []
73
+ manifest_result: SetupResults | None = None
74
+
75
+ async for event in porringer.sync.execute_stream(params, plugins=plugins):
76
+ if isinstance(event, ManifestLoadedEvent):
77
+ manifest_result = event.manifest
78
+ actions = list(event.manifest.actions)
79
+
80
+ elif isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
81
+ cb.on_action_started(event.action)
82
+
83
+ elif isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
84
+ cb.on_sub_progress(event.action, event.sub_action)
85
+
86
+ elif isinstance(event, ActionCompletedEvent):
87
+ collected.append(event.result)
88
+ if cb.on_progress is not None:
89
+ cb.on_progress(event.action, event.result)
90
+
91
+ return SetupResults(
92
+ actions=actions,
93
+ results=collected,
94
+ manifest_path=manifest_result.manifest_path if manifest_result else None,
95
+ metadata=manifest_result.metadata if manifest_result else None,
96
+ )
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # run_preview — dry-run preview of a manifest
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ async def run_preview(
105
+ porringer: API,
106
+ url: str,
107
+ *,
108
+ config: PreviewConfig | None = None,
109
+ callbacks: PreviewCallbacks | None = None,
110
+ plugins: DiscoveredPlugins | None = None,
111
+ ) -> None:
112
+ """Download a manifest and perform a dry-run preview.
113
+
114
+ Delegates to :func:`preview_manifest_stream` in the operations
115
+ layer, then routes each :data:`PreviewEvent` to the appropriate
116
+ callback.
117
+ """
118
+ logger.info('run_preview starting for: %s', url)
119
+ cb = callbacks or PreviewCallbacks()
120
+ cfg = config or PreviewConfig()
121
+ temp_dir: str | None = None
122
+ try:
123
+ async for event in preview_manifest_stream(
124
+ porringer,
125
+ url,
126
+ project_directory=cfg.project_directory,
127
+ prerelease_packages=cfg.prerelease_packages,
128
+ discovered=plugins,
129
+ ):
130
+ if isinstance(event, PreviewManifestParsed):
131
+ temp_dir = event.temp_dir or None
132
+ if cb.on_manifest_parsed is not None:
133
+ cb.on_manifest_parsed(event.manifest, event.manifest_path, event.temp_dir)
134
+
135
+ elif isinstance(event, PreviewPluginsQueried) and cb.on_plugins_queried is not None:
136
+ cb.on_plugins_queried(event.availability, event.capabilities)
137
+
138
+ elif isinstance(event, PreviewReady):
139
+ if cb.on_preview_ready is not None:
140
+ cb.on_preview_ready(event.manifest, event.manifest_path, event.temp_dir)
141
+
142
+ elif isinstance(event, PreviewActionChecked) and cb.on_action_checked is not None:
143
+ cb.on_action_checked(event.index, event.result, event.status)
144
+
145
+ except asyncio.CancelledError:
146
+ if temp_dir:
147
+ safe_rmtree(temp_dir)
148
+ raise
149
+ except Exception:
150
+ if temp_dir:
151
+ safe_rmtree(temp_dir)
152
+ raise
@@ -323,8 +323,8 @@ class PreviewCallbacks:
323
323
  on_preview_ready: Callable[[SetupResults, str, str], None] | None = None
324
324
  """``(SetupResults, manifest_path, temp_dir)`` — CLI commands resolved."""
325
325
 
326
- on_action_checked: Callable[[int, SetupActionResult], None] | None = None
327
- """``(row_index, SetupActionResult)`` — per-action dry-run result."""
326
+ on_action_checked: Callable[[int, SetupActionResult, str], None] | None = None
327
+ """``(row_index, SetupActionResult, status)`` — per-action dry-run result with resolved status."""
328
328
 
329
329
 
330
330
  @dataclass(frozen=True, slots=True)
@@ -333,10 +333,3 @@ class PreviewConfig:
333
333
 
334
334
  project_directory: Path | None = None
335
335
  prerelease_packages: set[str] | None = None
336
-
337
-
338
- @dataclass(slots=True)
339
- class _DispatchState:
340
- """Mutable accumulator for :func:`_dispatch_preview_event`."""
341
-
342
- got_parsed: bool = False