synodic-client 0.0.1.dev74__tar.gz → 0.0.1.dev75__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.dev75}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/debug.py +27 -46
  4. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/qt.py +6 -23
  5. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/__init__.py +11 -17
  6. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/action_card.py +26 -35
  7. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/install.py +6 -23
  8. synodic_client-0.0.1.dev75/synodic_client/application/screen/install_workers.py +152 -0
  9. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/schema.py +2 -9
  10. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/screen.py +6 -63
  11. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/tool_update_controller.py +40 -83
  12. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/update_controller.py +7 -11
  13. synodic_client-0.0.1.dev75/synodic_client/application/workers.py +20 -0
  14. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/config.py +6 -9
  15. synodic_client-0.0.1.dev75/synodic_client/cli/debug.py +187 -0
  16. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/tool.py +1 -3
  17. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/operations/__init__.py +47 -3
  18. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/operations/config.py +21 -0
  19. synodic_client-0.0.1.dev75/synodic_client/operations/install.py +272 -0
  20. synodic_client-0.0.1.dev75/synodic_client/operations/project.py +270 -0
  21. synodic_client-0.0.1.dev75/synodic_client/operations/schema.py +321 -0
  22. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/operations/tool.py +52 -43
  23. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/resolution.py +5 -70
  24. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/subprocess_patch.py +6 -1
  25. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/updater.py +1 -1
  26. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_action_card.py +46 -33
  27. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_gather_packages.py +11 -11
  28. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_install_preview.py +12 -12
  29. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_tray_window_show.py +6 -5
  30. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_update_controller.py +14 -13
  31. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_cli.py +69 -18
  32. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/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.dev75}/LICENSE.md +0 -0
  40. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/README.md +0 -0
  41. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/__main__.py +0 -0
  43. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/bootstrap.py +0 -0
  45. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/config_store.py +0 -0
  46. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/data.py +0 -0
  47. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/icon.py +0 -0
  48. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/init.py +0 -0
  49. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/instance.py +0 -0
  50. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/package_state.py +0 -0
  51. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/schema.py +0 -0
  52. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/card.py +0 -0
  53. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/log_panel.py +0 -0
  54. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/plugin_row.py +0 -0
  55. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/projects.py +0 -0
  56. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/settings.py +0 -0
  57. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/sidebar.py +0 -0
  58. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/spinner.py +0 -0
  59. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/tray.py +0 -0
  60. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/screen/update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/theme.py +0 -0
  62. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/update_model.py +0 -0
  63. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/application/uri.py +0 -0
  64. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/__init__.py +0 -0
  65. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/context.py +0 -0
  66. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/output.py +0 -0
  67. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/project.py +0 -0
  68. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/cli/update.py +0 -0
  69. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/client.py +0 -0
  70. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/config.py +0 -0
  71. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/logging.py +0 -0
  72. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/operations/bootstrap.py +0 -0
  73. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/operations/update.py +0 -0
  74. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/protocol.py +0 -0
  75. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/py.typed +0 -0
  76. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/schema.py +0 -0
  77. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/synodic_client/startup.py +0 -0
  78. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/__init__.py +0 -0
  79. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/conftest.py +0 -0
  80. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/__init__.py +0 -0
  81. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/operations/__init__.py +0 -0
  82. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/operations/test_config.py +0 -0
  83. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/operations/test_install.py +0 -0
  84. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/operations/test_project.py +0 -0
  85. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/operations/test_tool.py +0 -0
  86. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/operations/test_update.py +0 -0
  87. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/__init__.py +0 -0
  88. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/conftest.py +0 -0
  89. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_log_panel.py +0 -0
  90. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_logging.py +0 -0
  91. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_preview_model.py +0 -0
  92. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_settings.py +0 -0
  93. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_sidebar.py +0 -0
  94. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_update_banner.py +0 -0
  95. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/qt/test_update_feedback.py +0 -0
  96. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_client_updater.py +0 -0
  97. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_client_version.py +0 -0
  98. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_config.py +0 -0
  99. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_examples.py +0 -0
  100. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_init.py +0 -0
  101. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_install.py +0 -0
  102. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_resolution.py +0 -0
  103. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_updater.py +0 -0
  104. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/test_uri.py +0 -0
  105. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/windows/__init__.py +0 -0
  106. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/windows/conftest.py +0 -0
  107. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/tests/unit/windows/test_protocol.py +0 -0
  108. {synodic_client-0.0.1.dev74 → synodic_client-0.0.1.dev75}/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.dev75
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.dev75"
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:
@@ -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
@@ -657,7 +640,7 @@ class SetupPreviewWidget(QWidget):
657
640
  action = m.preview.actions[row]
658
641
  card = self._card_list.get_card(action)
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:
@@ -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
@@ -11,13 +11,11 @@ from porringer.backend.builder import Builder
11
11
  from porringer.core.plugin_schema.plugin_manager import PluginManager
12
12
  from porringer.core.plugin_schema.project_environment import ProjectEnvironment
13
13
  from porringer.schema import (
14
- ActionCompletedEvent,
15
14
  ManifestDirectory,
16
15
  ManifestParsedEvent,
17
16
  PluginInfo,
18
17
  SetupAction,
19
18
  SetupParameters,
20
- SkipReason,
21
19
  SyncStrategy,
22
20
  )
23
21
  from porringer.schema.plugin import PluginKind, RuntimePackageResult
@@ -1045,14 +1043,10 @@ class ToolsView(QWidget):
1045
1043
  """
1046
1044
  actions: list[SetupAction] = []
1047
1045
  try:
1046
+ from synodic_client.operations.project import find_manifest
1047
+
1048
1048
  path = Path(directory.path)
1049
- filenames = self._porringer.sync.manifest_filenames()
1050
- manifest_path: Path | None = None
1051
- for fname in filenames:
1052
- candidate = path / fname
1053
- if candidate.exists():
1054
- manifest_path = candidate
1055
- break
1049
+ manifest_path = find_manifest(self._porringer, path)
1056
1050
 
1057
1051
  if manifest_path is None:
1058
1052
  return actions
@@ -1177,19 +1171,10 @@ class ToolsView(QWidget):
1177
1171
  if self._coordinator is not None:
1178
1172
  return await self._check_updates_via_coordinator()
1179
1173
 
1180
- # Legacy per-directory fallback
1181
- available: dict[str, dict[str, str]] = {}
1182
-
1183
- async def _check_one(directory: ManifestDirectory) -> None:
1184
- partial = await self._check_directory_updates(directory)
1185
- for installer, packages in partial.items():
1186
- available.setdefault(installer, {}).update(packages)
1187
-
1188
- async with asyncio.TaskGroup() as tg:
1189
- for d in directories:
1190
- tg.create_task(_check_one(d))
1174
+ # Legacy per-directory fallback — delegate to the operations layer
1175
+ from synodic_client.operations.tool import check_tool_updates
1191
1176
 
1192
- return available
1177
+ return await check_tool_updates(self._porringer, directories)
1193
1178
 
1194
1179
  async def _check_updates_via_coordinator(self) -> dict[str, dict[str, str]]:
1195
1180
  """Use the coordinator's ``check_updates`` for efficient detection.
@@ -1225,48 +1210,6 @@ class ToolsView(QWidget):
1225
1210
 
1226
1211
  return available
1227
1212
 
1228
- async def _check_directory_updates(
1229
- self,
1230
- directory: ManifestDirectory,
1231
- ) -> dict[str, dict[str, str]]:
1232
- """Check a single directory for available updates (dry-run).
1233
-
1234
- Legacy fallback used when no coordinator is available.
1235
- """
1236
- available: dict[str, dict[str, str]] = {}
1237
- try:
1238
- path = Path(directory.path)
1239
- filenames = self._porringer.sync.manifest_filenames()
1240
- manifest_path: Path | None = None
1241
- for fname in filenames:
1242
- candidate = path / fname
1243
- if candidate.exists():
1244
- manifest_path = candidate
1245
- break
1246
-
1247
- if manifest_path is None:
1248
- return available
1249
-
1250
- params = SetupParameters(
1251
- paths=[str(manifest_path)],
1252
- dry_run=True,
1253
- project_directory=path,
1254
- )
1255
- async for event in self._porringer.sync.execute_stream(params):
1256
- if isinstance(event, ActionCompletedEvent) and event.result.skip_reason == SkipReason.UPDATE_AVAILABLE:
1257
- action = event.result.action
1258
- if action.installer and action.package:
1259
- pkg_name = str(action.package.name)
1260
- latest = event.result.available_version or ''
1261
- available.setdefault(action.installer, {})[pkg_name] = latest
1262
- except Exception:
1263
- logger.debug(
1264
- 'Could not detect updates for %s',
1265
- directory.path,
1266
- exc_info=True,
1267
- )
1268
- return available
1269
-
1270
1213
  async def _deferred_update_check(self) -> None:
1271
1214
  """Run update detection in the background, then patch the widget tree.
1272
1215