synodic-client 0.0.1.dev39__tar.gz → 0.0.1.dev40__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 (69) hide show
  1. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/pyproject.toml +4 -4
  3. synodic_client-0.0.1.dev40/synodic_client/_version.py +1 -0
  4. synodic_client-0.0.1.dev40/synodic_client/application/data.py +192 -0
  5. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/__init__.py +1 -0
  6. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/install.py +15 -5
  7. synodic_client-0.0.1.dev40/synodic_client/application/screen/screen.py +2115 -0
  8. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/spinner.py +35 -8
  9. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/tray.py +224 -19
  10. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/theme.py +159 -13
  11. synodic_client-0.0.1.dev40/synodic_client/application/workers.py +164 -0
  12. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/config.py +12 -4
  13. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/resolution.py +90 -2
  14. synodic_client-0.0.1.dev40/tests/unit/qt/test_gather_packages.py +755 -0
  15. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_install_preview.py +19 -18
  16. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_logging.py +2 -2
  17. synodic_client-0.0.1.dev40/tests/unit/qt/test_tray_window_show.py +69 -0
  18. synodic_client-0.0.1.dev40/tests/unit/qt/test_update_feedback.py +393 -0
  19. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_config.py +12 -0
  20. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_resolution.py +86 -0
  21. synodic_client-0.0.1.dev40/tests/unit/test_workers.py +55 -0
  22. synodic_client-0.0.1.dev39/synodic_client/_version.py +0 -1
  23. synodic_client-0.0.1.dev39/synodic_client/application/screen/screen.py +0 -810
  24. synodic_client-0.0.1.dev39/synodic_client/application/workers.py +0 -100
  25. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/LICENSE.md +0 -0
  26. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/README.md +0 -0
  27. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/__main__.py +0 -0
  29. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/__init__.py +0 -0
  30. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/bootstrap.py +0 -0
  31. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/icon.py +0 -0
  32. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/instance.py +0 -0
  33. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/qt.py +0 -0
  34. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/action_card.py +0 -0
  35. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/card.py +0 -0
  36. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/log_panel.py +0 -0
  37. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/settings.py +0 -0
  38. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/sidebar.py +0 -0
  39. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/update_banner.py +0 -0
  40. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/application/uri.py +0 -0
  41. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/cli.py +0 -0
  42. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/client.py +0 -0
  43. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/logging.py +0 -0
  44. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/protocol.py +0 -0
  45. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/py.typed +0 -0
  46. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/startup.py +0 -0
  47. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/synodic_client/updater.py +0 -0
  48. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/__init__.py +0 -0
  49. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/conftest.py +0 -0
  50. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/__init__.py +0 -0
  51. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/__init__.py +0 -0
  52. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/conftest.py +0 -0
  53. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_action_card.py +0 -0
  54. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_log_panel.py +0 -0
  55. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_preview_model.py +0 -0
  56. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_settings.py +0 -0
  57. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_sidebar.py +0 -0
  58. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_update_banner.py +0 -0
  59. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_cli.py +0 -0
  60. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_client_updater.py +0 -0
  61. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_client_version.py +0 -0
  62. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_examples.py +0 -0
  63. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_install.py +0 -0
  64. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_updater.py +0 -0
  65. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/test_uri.py +0 -0
  66. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/windows/__init__.py +0 -0
  67. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/windows/conftest.py +0 -0
  68. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/tests/unit/windows/test_protocol.py +0 -0
  69. {synodic_client-0.0.1.dev39 → synodic_client-0.0.1.dev40}/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.dev39
3
+ Version: 0.0.1.dev40
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
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev56
11
+ Requires-Dist: porringer>=0.2.1.dev71
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev56",
13
+ "porringer>=0.2.1.dev71",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev39"
18
+ version = "0.0.1.dev40"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -36,7 +36,7 @@ build = [
36
36
  ]
37
37
  lint = [
38
38
  "ruff>=0.15.4",
39
- "pyrefly>=0.54.0",
39
+ "pyrefly>=0.55.0",
40
40
  ]
41
41
  test = [
42
42
  "pytest>=9.0.2",
@@ -109,7 +109,7 @@ write_template = "__version__ = '{}'\n"
109
109
  allow-prereleases = true
110
110
 
111
111
  [tool.pdm.scripts]
112
- analyze = "ruff check synodic_client tests"
112
+ analyze = "ruff check"
113
113
  format = "ruff format"
114
114
  test = "pytest --cov=synodic_client --verbose tests"
115
115
  type-check = "pyrefly check"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev40'
@@ -0,0 +1,192 @@
1
+ """Shared data coordinator for the Synodic Client application.
2
+
3
+ Centralises porringer API calls so that plugin discovery, directory
4
+ listing, and runtime context resolution happen once per refresh cycle
5
+ and the results are reused by every consumer (ToolsView, ProjectsView,
6
+ TrayScreen, install workers).
7
+
8
+ The coordinator follows an *invalidate-on-mutation* strategy: callers
9
+ that modify state (install, uninstall, add/remove directory) call
10
+ :meth:`invalidate` to force the next :meth:`refresh` to re-fetch.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+
19
+ from porringer.api import API
20
+ from porringer.backend.command.core.discovery import DiscoveredPlugins
21
+ from porringer.core.plugin_schema.plugin_manager import PluginManager
22
+ from porringer.schema import (
23
+ CheckParameters,
24
+ CheckResult,
25
+ DirectoryValidationResult,
26
+ ManifestDirectory,
27
+ PluginInfo,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class Snapshot:
35
+ """Immutable bundle of data produced by a single refresh cycle.
36
+
37
+ All fields are populated by :meth:`DataCoordinator.refresh` and
38
+ remain stable until the next refresh.
39
+ """
40
+
41
+ plugins: list[PluginInfo] = field(default_factory=list)
42
+ """All discovered plugins with install status and version info."""
43
+
44
+ directories: list[ManifestDirectory] = field(default_factory=list)
45
+ """Cached project directories (un-validated)."""
46
+
47
+ validated_directories: list[DirectoryValidationResult] = field(default_factory=list)
48
+ """Cached directories with ``exists`` / ``has_manifest`` validation."""
49
+
50
+ discovered: DiscoveredPlugins | None = None
51
+ """Full plugin discovery result including runtime context."""
52
+
53
+ plugin_managers: dict[str, PluginManager] = field(default_factory=dict)
54
+ """Project-environment plugins implementing the ``PluginManager`` protocol."""
55
+
56
+
57
+ class DataCoordinator:
58
+ """Single source of truth for porringer data across the application.
59
+
60
+ Usage::
61
+
62
+ coordinator = DataCoordinator(porringer)
63
+ snapshot = await coordinator.refresh() # first load
64
+ # … later, after an install …
65
+ coordinator.invalidate()
66
+ snapshot = await coordinator.refresh() # re-fetches everything
67
+
68
+ The coordinator caches the most recent :class:`Snapshot` so that
69
+ synchronous property access (``coordinator.snapshot``) is available
70
+ between refresh cycles.
71
+ """
72
+
73
+ def __init__(self, porringer: API) -> None:
74
+ """Initialize the coordinator with a porringer API instance."""
75
+ self._porringer = porringer
76
+ self._snapshot: Snapshot = Snapshot()
77
+ self._stale = True
78
+ self._refresh_lock = asyncio.Lock()
79
+
80
+ # -- Public API --------------------------------------------------------
81
+
82
+ @property
83
+ def snapshot(self) -> Snapshot:
84
+ """Return the most recent snapshot (may be empty before first refresh)."""
85
+ return self._snapshot
86
+
87
+ @property
88
+ def discovered_plugins(self) -> DiscoveredPlugins | None:
89
+ """Shortcut to the current ``DiscoveredPlugins`` instance."""
90
+ return self._snapshot.discovered
91
+
92
+ def invalidate(self) -> None:
93
+ """Mark the cached data as stale.
94
+
95
+ The next call to :meth:`refresh` will re-fetch everything from
96
+ porringer. This is a lightweight O(1) flag-flip.
97
+ """
98
+ self._stale = True
99
+
100
+ async def refresh(self, *, force: bool = False) -> Snapshot:
101
+ """Fetch fresh data from porringer if stale (or *force* is set).
102
+
103
+ Multiple concurrent callers are coalesced via an ``asyncio.Lock``
104
+ so that only one discovery + listing round-trip runs at a time.
105
+
106
+ Returns:
107
+ The populated :class:`Snapshot`.
108
+ """
109
+ if not self._stale and not force:
110
+ return self._snapshot
111
+
112
+ async with self._refresh_lock:
113
+ # Double-check after acquiring the lock — another coroutine
114
+ # may have already refreshed while we were waiting.
115
+ if not self._stale and not force:
116
+ return self._snapshot
117
+
118
+ self._snapshot = await self._fetch()
119
+ self._stale = False
120
+ return self._snapshot
121
+
122
+ async def check_updates(
123
+ self,
124
+ plugins: list[str] | None = None,
125
+ ) -> list[CheckResult]:
126
+ """Run update detection using the cached ``DiscoveredPlugins``.
127
+
128
+ Args:
129
+ plugins: Optional include-set of plugin names. ``None``
130
+ means all plugins.
131
+
132
+ Returns:
133
+ A list of :class:`CheckResult` per plugin.
134
+ """
135
+ params = CheckParameters(plugins=plugins)
136
+ return await self._porringer.sync.check_updates(
137
+ params,
138
+ plugins=self._snapshot.discovered,
139
+ )
140
+
141
+ # -- Internals ---------------------------------------------------------
142
+
143
+ async def _fetch(self) -> Snapshot:
144
+ """Run the full discovery + listing pipeline.
145
+
146
+ 1. ``API.discover_plugins()`` — plugin entry-points + runtime
147
+ context in one shot.
148
+ 2. ``PluginCommands.list()`` — installed status + versions,
149
+ passing the already-discovered ``DiscoveredPlugins``.
150
+ 3. ``cache.list_directories(validate=True, check_manifest=True)``
151
+ — directory listing with validation baked in.
152
+ 4. Filter ``project_environments`` for ``PluginManager`` instances.
153
+
154
+ All blocking calls are dispatched via ``asyncio.to_thread``.
155
+ """
156
+ loop = asyncio.get_running_loop()
157
+
158
+ # Step 1: discover all plugins + resolve runtime context
159
+ discovered = await API.discover_plugins()
160
+
161
+ # Step 2 + 3 in parallel: plugin list + validated directories
162
+ plugins_task = asyncio.create_task(
163
+ self._porringer.plugin.list(plugins=discovered),
164
+ )
165
+ dirs_future = loop.run_in_executor(
166
+ None,
167
+ lambda: self._porringer.cache.list_directories(
168
+ validate=True,
169
+ check_manifest=True,
170
+ ),
171
+ )
172
+
173
+ plugins = await plugins_task
174
+ validated = await dirs_future
175
+
176
+ # Step 4: extract PluginManager instances from project_environments
177
+ managers: dict[str, PluginManager] = {}
178
+ for _name, env in discovered.project_environments.items():
179
+ if isinstance(env, PluginManager) and env.is_available():
180
+ managers[env.tool_name()] = env
181
+
182
+ # Derive the un-validated directory list for callers that only
183
+ # need path + name (e.g. _gather_packages).
184
+ directories = [r.directory for r in validated]
185
+
186
+ return Snapshot(
187
+ plugins=plugins,
188
+ directories=directories,
189
+ validated_directories=validated,
190
+ discovered=discovered,
191
+ plugin_managers=managers,
192
+ )
@@ -43,6 +43,7 @@ def plugin_kind_group_label(kind: PluginKind) -> str:
43
43
 
44
44
  SKIP_REASON_LABELS: dict[SkipReason, str] = {
45
45
  SkipReason.ALREADY_INSTALLED: 'Already installed',
46
+ SkipReason.NOT_INSTALLED: 'Not installed',
46
47
  SkipReason.ALREADY_LATEST: 'Already latest',
47
48
  SkipReason.NO_PROJECT_DIRECTORY: 'No project directory',
48
49
  SkipReason.UPDATE_AVAILABLE: 'Update available',
@@ -24,6 +24,7 @@ from urllib.parse import urlparse
24
24
  from urllib.request import url2pathname
25
25
 
26
26
  from porringer.api import API
27
+ from porringer.backend.command.core.discovery import DiscoveredPlugins
27
28
  from porringer.schema import (
28
29
  DownloadParameters,
29
30
  ProgressEvent,
@@ -230,6 +231,8 @@ async def run_install(
230
231
  manifest_path: Path,
231
232
  config: InstallConfig | None = None,
232
233
  callbacks: InstallCallbacks | None = None,
234
+ *,
235
+ plugins: DiscoveredPlugins | None = None,
233
236
  ) -> SetupResults:
234
237
  """Execute setup actions via porringer and stream progress.
235
238
 
@@ -243,6 +246,8 @@ async def run_install(
243
246
  config: Optional execution parameters (directory, strategy,
244
247
  prerelease overrides).
245
248
  callbacks: Optional progress callbacks.
249
+ plugins: Pre-discovered plugins to pass through to porringer,
250
+ avoiding redundant discovery.
246
251
 
247
252
  Returns:
248
253
  Aggregated :class:`SetupResults`.
@@ -259,7 +264,7 @@ async def run_install(
259
264
  collected: list[SetupActionResult] = []
260
265
  manifest_result: SetupResults | None = None
261
266
 
262
- async for event in porringer.sync.execute_stream(params):
267
+ async for event in porringer.sync.execute_stream(params, plugins=plugins):
263
268
  if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest:
264
269
  manifest_result = event.manifest
265
270
  actions = list(event.manifest.actions)
@@ -275,7 +280,7 @@ async def run_install(
275
280
  ):
276
281
  cb.on_sub_progress(event.action, event.sub_action)
277
282
 
278
- if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result:
283
+ if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action:
279
284
  collected.append(event.result)
280
285
  if cb.on_progress is not None:
281
286
  cb.on_progress(event.action, event.result)
@@ -340,6 +345,7 @@ class SetupPreviewWidget(QWidget):
340
345
  self._porringer = porringer
341
346
  self._show_close = show_close
342
347
  self._config = config
348
+ self._discovered_plugins: DiscoveredPlugins | None = None
343
349
 
344
350
  self._model = PreviewModel()
345
351
  self._task: asyncio.Task[None] | None = None
@@ -705,6 +711,7 @@ class SetupPreviewWidget(QWidget):
705
711
  on_preview_ready=self._on_preview_resolved,
706
712
  on_action_checked=self._on_action_checked,
707
713
  ),
714
+ plugins=self._discovered_plugins,
708
715
  )
709
716
  self._on_preview_finished()
710
717
  except asyncio.CancelledError:
@@ -729,6 +736,7 @@ class SetupPreviewWidget(QWidget):
729
736
  on_sub_progress=self._on_sub_progress,
730
737
  on_progress=self._on_action_progress,
731
738
  ),
739
+ plugins=self._discovered_plugins,
732
740
  )
733
741
  self._on_install_finished(results)
734
742
  except asyncio.CancelledError:
@@ -1259,8 +1267,7 @@ async def _resolve_manifest_path(url: str) -> tuple[Path, str | None]:
1259
1267
  dest = Path(temp_dir) / 'porringer.json'
1260
1268
 
1261
1269
  params = DownloadParameters(url=url, destination=dest, timeout=3)
1262
- loop = asyncio.get_running_loop()
1263
- result = await loop.run_in_executor(None, API.download, params)
1270
+ result = await API.download(params)
1264
1271
 
1265
1272
  if not result.success:
1266
1273
  _safe_rmtree(temp_dir)
@@ -1314,6 +1321,7 @@ async def run_preview(
1314
1321
  *,
1315
1322
  config: PreviewConfig | None = None,
1316
1323
  callbacks: PreviewCallbacks | None = None,
1324
+ plugins: DiscoveredPlugins | None = None,
1317
1325
  ) -> None:
1318
1326
  """Download a manifest and perform a dry-run preview.
1319
1327
 
@@ -1331,6 +1339,8 @@ async def run_preview(
1331
1339
  url: Manifest URL or local path.
1332
1340
  config: Optional preview configuration.
1333
1341
  callbacks: Optional preview callbacks.
1342
+ plugins: Pre-discovered plugins to pass through to porringer,
1343
+ avoiding redundant discovery.
1334
1344
  """
1335
1345
  logger.info('run_preview starting for: %s', url)
1336
1346
  temp_dir: str | None = None
@@ -1351,7 +1361,7 @@ async def run_preview(
1351
1361
  temp_dir_str = temp_dir or ''
1352
1362
  manifest_path_str = str(manifest_path)
1353
1363
 
1354
- async for event in porringer.sync.execute_stream(setup_params):
1364
+ async for event in porringer.sync.execute_stream(setup_params, plugins=plugins):
1355
1365
  _dispatch_preview_event(
1356
1366
  event,
1357
1367
  manifest_path_str,