synodic-client 0.0.1.dev38__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.dev38 → synodic_client-0.0.1.dev40}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/pyproject.toml +5 -5
  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.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/__init__.py +1 -0
  6. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/install.py +324 -255
  7. synodic_client-0.0.1.dev40/synodic_client/application/screen/screen.py +2115 -0
  8. synodic_client-0.0.1.dev40/synodic_client/application/screen/sidebar.py +330 -0
  9. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/spinner.py +35 -8
  10. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/tray.py +260 -43
  11. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/theme.py +251 -14
  12. synodic_client-0.0.1.dev40/synodic_client/application/workers.py +164 -0
  13. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/client.py +0 -11
  14. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/config.py +12 -4
  15. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/resolution.py +90 -2
  16. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/updater.py +24 -53
  17. synodic_client-0.0.1.dev40/tests/unit/qt/test_gather_packages.py +755 -0
  18. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_install_preview.py +198 -161
  19. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_log_panel.py +44 -28
  20. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_logging.py +2 -2
  21. synodic_client-0.0.1.dev40/tests/unit/qt/test_sidebar.py +308 -0
  22. synodic_client-0.0.1.dev40/tests/unit/qt/test_tray_window_show.py +69 -0
  23. synodic_client-0.0.1.dev40/tests/unit/qt/test_update_feedback.py +393 -0
  24. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_client_updater.py +0 -7
  25. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_config.py +12 -0
  26. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_resolution.py +86 -0
  27. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_updater.py +34 -24
  28. synodic_client-0.0.1.dev40/tests/unit/test_workers.py +55 -0
  29. synodic_client-0.0.1.dev38/synodic_client/_version.py +0 -1
  30. synodic_client-0.0.1.dev38/synodic_client/application/screen/screen.py +0 -843
  31. synodic_client-0.0.1.dev38/synodic_client/application/workers.py +0 -112
  32. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/LICENSE.md +0 -0
  33. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/README.md +0 -0
  34. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/__init__.py +0 -0
  35. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/__main__.py +0 -0
  36. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/__init__.py +0 -0
  37. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/bootstrap.py +0 -0
  38. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/icon.py +0 -0
  39. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/instance.py +0 -0
  40. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/qt.py +0 -0
  41. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/action_card.py +0 -0
  42. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/card.py +0 -0
  43. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/log_panel.py +0 -0
  44. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/settings.py +0 -0
  45. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/screen/update_banner.py +0 -0
  46. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/application/uri.py +0 -0
  47. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/cli.py +0 -0
  48. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/logging.py +0 -0
  49. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/protocol.py +0 -0
  50. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/py.typed +0 -0
  51. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/synodic_client/startup.py +0 -0
  52. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/conftest.py +0 -0
  54. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/conftest.py +0 -0
  57. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_action_card.py +0 -0
  58. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_preview_model.py +0 -0
  59. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_settings.py +0 -0
  60. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/qt/test_update_banner.py +0 -0
  61. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_cli.py +0 -0
  62. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_client_version.py +0 -0
  63. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_examples.py +0 -0
  64. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_install.py +0 -0
  65. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/test_uri.py +0 -0
  66. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/windows/__init__.py +0 -0
  67. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/windows/conftest.py +0 -0
  68. {synodic_client-0.0.1.dev38 → synodic_client-0.0.1.dev40}/tests/unit/windows/test_protocol.py +0 -0
  69. {synodic_client-0.0.1.dev38 → 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.dev38
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.dev54
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.dev54",
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.dev38"
18
+ version = "0.0.1.dev40"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -35,8 +35,8 @@ build = [
35
35
  "pyinstaller>=6.19.0",
36
36
  ]
37
37
  lint = [
38
- "ruff>=0.15.3",
39
- "pyrefly>=0.54.0",
38
+ "ruff>=0.15.4",
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',