synodic-client 0.0.1.dev76__tar.gz → 0.0.1.dev77__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.
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/PKG-INFO +3 -3
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/pyproject.toml +4 -4
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/bootstrap.py +1 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/config_store.py +4 -3
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/debug.py +1 -1
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/package_state.py +23 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/action_card.py +38 -1
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/install.py +193 -83
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/install_workers.py +71 -16
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/projects.py +30 -12
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/schema.py +21 -10
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/screen.py +71 -26
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/tool_update_controller.py +8 -7
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/theme.py +16 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/__init__.py +2 -0
- synodic_client-0.0.1.dev77/synodic_client/cli/install.py +213 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/output.py +5 -3
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/tool.py +4 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/__init__.py +14 -1
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/config.py +23 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/install.py +180 -15
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/schema.py +210 -2
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/tool.py +31 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_config.py +37 -1
- synodic_client-0.0.1.dev77/tests/unit/operations/test_install.py +424 -0
- synodic_client-0.0.1.dev77/tests/unit/operations/test_install_plan.py +207 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_tool.py +21 -0
- synodic_client-0.0.1.dev77/tests/unit/qt/test_package_state.py +63 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_preview_model.py +61 -15
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_cli.py +107 -0
- synodic_client-0.0.1.dev76/tests/unit/operations/test_install.py +0 -221
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/README.md +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/qt.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/schema.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/plugin_row.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/settings.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/tray.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/update_controller.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/update_model.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/config.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/context.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/debug.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/project.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/cli/update.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/project.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/operations/update.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/schema.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/subprocess_patch.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/operations/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_project.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/operations/test_update.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_gather_packages.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_settings.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_update_controller.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/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.
|
|
3
|
+
Version: 0.0.1.dev77
|
|
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,9 +8,9 @@ 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.
|
|
11
|
+
Requires-Dist: porringer>=0.2.1.dev86
|
|
12
12
|
Requires-Dist: qasync>=0.28.0
|
|
13
|
-
Requires-Dist: velopack>=0.0.
|
|
13
|
+
Requires-Dist: velopack>=0.0.1521.dev61717
|
|
14
14
|
Requires-Dist: typer>=0.24.1
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
|
|
@@ -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.
|
|
13
|
+
"porringer>=0.2.1.dev86",
|
|
14
14
|
"qasync>=0.28.0",
|
|
15
|
-
"velopack>=0.0.
|
|
15
|
+
"velopack>=0.0.1521.dev61717",
|
|
16
16
|
"typer>=0.24.1",
|
|
17
17
|
]
|
|
18
|
-
version = "0.0.1.
|
|
18
|
+
version = "0.0.1.dev77"
|
|
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.6",
|
|
39
|
-
"pyrefly>=0.
|
|
39
|
+
"pyrefly>=0.57.0",
|
|
40
40
|
]
|
|
41
41
|
test = [
|
|
42
42
|
"pytest>=9.0.2",
|
{synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/bootstrap.py
RENAMED
|
@@ -55,6 +55,7 @@ def bootstrap() -> None:
|
|
|
55
55
|
run_startup_preamble(sys.executable)
|
|
56
56
|
|
|
57
57
|
# Heavy imports happen here — PySide6, porringer, etc.
|
|
58
|
+
|
|
58
59
|
from synodic_client.application.qt import application
|
|
59
60
|
|
|
60
61
|
application(uri=extract_uri_from_args(), dev_mode=dev_mode, debug=debug)
|
{synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/config_store.py
RENAMED
|
@@ -10,7 +10,8 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from PySide6.QtCore import QObject, Signal
|
|
12
12
|
|
|
13
|
-
from synodic_client.
|
|
13
|
+
from synodic_client.operations.config import get_config, update_config
|
|
14
|
+
from synodic_client.schema import ResolvedConfig
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class ConfigStore(QObject):
|
|
@@ -33,7 +34,7 @@ class ConfigStore(QObject):
|
|
|
33
34
|
def __init__(self, config: ResolvedConfig | None = None, parent: QObject | None = None) -> None:
|
|
34
35
|
"""Create a new store, optionally seeded with *config*."""
|
|
35
36
|
super().__init__(parent)
|
|
36
|
-
self._config = config if config is not None else
|
|
37
|
+
self._config = config if config is not None else get_config()
|
|
37
38
|
|
|
38
39
|
@property
|
|
39
40
|
def config(self) -> ResolvedConfig:
|
|
@@ -52,7 +53,7 @@ class ConfigStore(QObject):
|
|
|
52
53
|
Returns:
|
|
53
54
|
The fresh :class:`ResolvedConfig`.
|
|
54
55
|
"""
|
|
55
|
-
self._config =
|
|
56
|
+
self._config = update_config(**changes)
|
|
56
57
|
self.changed.emit(self._config)
|
|
57
58
|
return self._config
|
|
58
59
|
|
{synodic_client-0.0.1.dev76 → synodic_client-0.0.1.dev77}/synodic_client/application/debug.py
RENAMED
|
@@ -227,7 +227,7 @@ class DebugHandler:
|
|
|
227
227
|
needed = sum(1 for s in model.action_states if classify_status(s.status) == 'needed')
|
|
228
228
|
satisfied = sum(1 for s in model.action_states if classify_status(s.status) == 'satisfied')
|
|
229
229
|
pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending')
|
|
230
|
-
upgradable =
|
|
230
|
+
upgradable = sum(1 for s in model.action_states if s.status == 'Update available')
|
|
231
231
|
|
|
232
232
|
return json.dumps({
|
|
233
233
|
'path': str(target),
|
|
@@ -123,3 +123,26 @@ class PackageStateStore(QObject):
|
|
|
123
123
|
def clear(self) -> None:
|
|
124
124
|
"""Remove all recorded state."""
|
|
125
125
|
self._data.clear()
|
|
126
|
+
|
|
127
|
+
def record_updates_completed(
|
|
128
|
+
self,
|
|
129
|
+
signal_key: str,
|
|
130
|
+
version_map: dict[str, tuple[str, str]],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Mark packages as updated, clearing stale ``has_update`` flags.
|
|
133
|
+
|
|
134
|
+
Called after a successful tool update run. For each entry in
|
|
135
|
+
*version_map* (``{package_name: (old_version, new_version)}``),
|
|
136
|
+
the corresponding :class:`PackageState` is updated to reflect
|
|
137
|
+
the new installed version and ``has_update`` is cleared.
|
|
138
|
+
"""
|
|
139
|
+
changed = False
|
|
140
|
+
bucket = self._data.get(signal_key, {})
|
|
141
|
+
for pkg_name, (_, new_ver) in version_map.items():
|
|
142
|
+
existing = bucket.get(pkg_name)
|
|
143
|
+
if existing is not None:
|
|
144
|
+
existing.installed_version = new_ver
|
|
145
|
+
existing.has_update = False
|
|
146
|
+
changed = True
|
|
147
|
+
if changed:
|
|
148
|
+
self.state_changed.emit()
|
|
@@ -17,11 +17,12 @@ from porringer.backend.command.core.action_builder import PHASE_ORDER
|
|
|
17
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
|
-
from PySide6.QtGui import QColor
|
|
20
|
+
from PySide6.QtGui import QColor, QCursor, QMouseEvent
|
|
21
21
|
from PySide6.QtWidgets import (
|
|
22
22
|
QApplication,
|
|
23
23
|
QCheckBox,
|
|
24
24
|
QFrame,
|
|
25
|
+
QGraphicsOpacityEffect,
|
|
25
26
|
QHBoxLayout,
|
|
26
27
|
QLabel,
|
|
27
28
|
QToolButton,
|
|
@@ -57,6 +58,7 @@ from synodic_client.application.theme import (
|
|
|
57
58
|
ACTION_CARD_STATUS_UPDATE,
|
|
58
59
|
ACTION_CARD_STYLE,
|
|
59
60
|
ACTION_CARD_TYPE_BADGE_STYLE,
|
|
61
|
+
ACTION_CARD_UPDATE_AVAILABLE_STYLE,
|
|
60
62
|
ACTION_CARD_VERSION_STYLE,
|
|
61
63
|
COPY_BTN_SIZE,
|
|
62
64
|
COPY_BTN_STYLE,
|
|
@@ -119,6 +121,10 @@ class ActionCard(QFrame):
|
|
|
119
121
|
"""Emitted with ``(package_name, checked)`` when the user toggles the
|
|
120
122
|
per-row pre-release checkbox."""
|
|
121
123
|
|
|
124
|
+
navigate_to_tool = Signal(str, str)
|
|
125
|
+
"""Emitted with ``(installer, package_name)`` when the user clicks an
|
|
126
|
+
'Update available' card to navigate to the Tools view."""
|
|
127
|
+
|
|
122
128
|
def __init__(
|
|
123
129
|
self,
|
|
124
130
|
parent: QWidget | None = None,
|
|
@@ -491,6 +497,10 @@ class ActionCard(QFrame):
|
|
|
491
497
|
else:
|
|
492
498
|
self._status_label.setToolTip('')
|
|
493
499
|
|
|
500
|
+
# "Update available" — fade the card and make it clickable
|
|
501
|
+
if status == 'Update available':
|
|
502
|
+
self._apply_update_available_style()
|
|
503
|
+
|
|
494
504
|
# CLI command — update with resolved cli_command from result
|
|
495
505
|
assert self._action is not None
|
|
496
506
|
cmd_text = format_cli_command(self._action, result=result, suppress_description=True)
|
|
@@ -524,6 +534,15 @@ class ActionCard(QFrame):
|
|
|
524
534
|
self._version_label.setText(f'\u2192 {result.available_version}')
|
|
525
535
|
self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: grey;')
|
|
526
536
|
|
|
537
|
+
def _apply_update_available_style(self) -> None:
|
|
538
|
+
"""Fade the card and make it clickable for 'Update available' status."""
|
|
539
|
+
self.setStyleSheet(ACTION_CARD_UPDATE_AVAILABLE_STYLE)
|
|
540
|
+
opacity = QGraphicsOpacityEffect(self)
|
|
541
|
+
opacity.setOpacity(0.55)
|
|
542
|
+
self.setGraphicsEffect(opacity)
|
|
543
|
+
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
|
544
|
+
self.setToolTip('Manage this update in the Tools view')
|
|
545
|
+
|
|
527
546
|
def finalize_checking(self) -> None:
|
|
528
547
|
"""Resolve a still-pending 'Checking\u2026' status to 'Needed'.
|
|
529
548
|
|
|
@@ -605,6 +624,16 @@ class ActionCard(QFrame):
|
|
|
605
624
|
"""Return whether the card shows an 'Update available' status."""
|
|
606
625
|
return self.status_text() == 'Update available'
|
|
607
626
|
|
|
627
|
+
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
628
|
+
"""Navigate to Tools view when clicking an 'Update available' card."""
|
|
629
|
+
if self.is_update_available() and self._action is not None:
|
|
630
|
+
installer = self._action.installer or ''
|
|
631
|
+
package = str(self._action.package.name) if self._action.package else ''
|
|
632
|
+
if installer and package:
|
|
633
|
+
self.navigate_to_tool.emit(installer, package)
|
|
634
|
+
return
|
|
635
|
+
super().mousePressEvent(event)
|
|
636
|
+
|
|
608
637
|
|
|
609
638
|
# ---------------------------------------------------------------------------
|
|
610
639
|
# ActionCardList — card container
|
|
@@ -621,6 +650,13 @@ class ActionCardList(QWidget):
|
|
|
621
650
|
prerelease_toggled = Signal(str, bool)
|
|
622
651
|
"""Forwarded from child :class:`ActionCard` widgets."""
|
|
623
652
|
|
|
653
|
+
navigate_to_tool = Signal(str, str)
|
|
654
|
+
"""Forwarded from child :class:`ActionCard` widgets.
|
|
655
|
+
|
|
656
|
+
Emitted with ``(installer, package_name)`` when the user clicks an
|
|
657
|
+
'Update available' card.
|
|
658
|
+
"""
|
|
659
|
+
|
|
624
660
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
625
661
|
"""Initialise the card list."""
|
|
626
662
|
super().__init__(parent)
|
|
@@ -680,6 +716,7 @@ class ActionCardList(QWidget):
|
|
|
680
716
|
prerelease_overrides=prerelease_overrides,
|
|
681
717
|
)
|
|
682
718
|
card.prerelease_toggled.connect(self.prerelease_toggled.emit)
|
|
719
|
+
card.navigate_to_tool.connect(self.navigate_to_tool.emit)
|
|
683
720
|
self._layout.insertWidget(self._layout.count() - 1, card)
|
|
684
721
|
self._cards.append(card)
|
|
685
722
|
self._action_map[act] = card
|
|
@@ -23,9 +23,7 @@ from porringer.schema import (
|
|
|
23
23
|
SetupAction,
|
|
24
24
|
SetupActionResult,
|
|
25
25
|
SetupResults,
|
|
26
|
-
SkipReason,
|
|
27
26
|
SubActionProgress,
|
|
28
|
-
SyncStrategy,
|
|
29
27
|
)
|
|
30
28
|
from PySide6.QtCore import Qt, QTimer, Signal
|
|
31
29
|
from PySide6.QtGui import QShowEvent
|
|
@@ -46,7 +44,7 @@ from PySide6.QtWidgets import (
|
|
|
46
44
|
from synodic_client.application.package_state import PackageStateStore
|
|
47
45
|
from synodic_client.application.screen.action_card import ActionCardList
|
|
48
46
|
from synodic_client.application.screen.card import CardFrame
|
|
49
|
-
from synodic_client.application.screen.install_workers import run_install, run_preview
|
|
47
|
+
from synodic_client.application.screen.install_workers import run_install, run_post_sync, run_preview
|
|
50
48
|
from synodic_client.application.screen.log_panel import ExecutionLogPanel
|
|
51
49
|
from synodic_client.application.screen.schema import (
|
|
52
50
|
ActionState,
|
|
@@ -106,6 +104,10 @@ class SetupPreviewWidget(QWidget):
|
|
|
106
104
|
#: Emitted whenever the lifecycle phase changes.
|
|
107
105
|
phase_changed = Signal(object) # PreviewPhase
|
|
108
106
|
|
|
107
|
+
#: Emitted with ``(installer, package_name)`` when the user clicks an
|
|
108
|
+
#: 'Update available' card to navigate to the Tools view.
|
|
109
|
+
navigate_to_tool_requested = Signal(str, str)
|
|
110
|
+
|
|
109
111
|
def __init__(
|
|
110
112
|
self,
|
|
111
113
|
porringer: API,
|
|
@@ -134,6 +136,7 @@ class SetupPreviewWidget(QWidget):
|
|
|
134
136
|
|
|
135
137
|
self._model = PreviewModel()
|
|
136
138
|
self._task: asyncio.Task[None] | None = None
|
|
139
|
+
self._install_results: SetupResults | None = None
|
|
137
140
|
|
|
138
141
|
# Debounce timer for per-row pre-release checkbox changes
|
|
139
142
|
self._prerelease_debounce = QTimer(self)
|
|
@@ -183,6 +186,7 @@ class SetupPreviewWidget(QWidget):
|
|
|
183
186
|
|
|
184
187
|
self._card_list = ActionCardList()
|
|
185
188
|
self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
|
|
189
|
+
self._card_list.navigate_to_tool.connect(self.navigate_to_tool_requested.emit)
|
|
186
190
|
scroll_layout.addWidget(self._card_list)
|
|
187
191
|
|
|
188
192
|
self._log_panel = ExecutionLogPanel()
|
|
@@ -249,6 +253,13 @@ class SetupPreviewWidget(QWidget):
|
|
|
249
253
|
button_bar = QHBoxLayout()
|
|
250
254
|
button_bar.addStretch()
|
|
251
255
|
|
|
256
|
+
self._run_commands_btn = QPushButton('Run Commands')
|
|
257
|
+
self._run_commands_btn.setToolTip('Execute post-sync commands from the manifest')
|
|
258
|
+
self._run_commands_btn.setEnabled(False)
|
|
259
|
+
self._run_commands_btn.hide()
|
|
260
|
+
self._run_commands_btn.clicked.connect(self._on_run_commands)
|
|
261
|
+
button_bar.addWidget(self._run_commands_btn)
|
|
262
|
+
|
|
252
263
|
self._install_btn = QPushButton('Install')
|
|
253
264
|
self._install_btn.setEnabled(False)
|
|
254
265
|
self._install_btn.clicked.connect(self._on_install)
|
|
@@ -370,6 +381,9 @@ class SetupPreviewWidget(QWidget):
|
|
|
370
381
|
self._status_label.setText('')
|
|
371
382
|
self._status_label.setStyleSheet('')
|
|
372
383
|
self._install_btn.setEnabled(False)
|
|
384
|
+
self._run_commands_btn.setEnabled(False)
|
|
385
|
+
self._run_commands_btn.hide()
|
|
386
|
+
self._install_results = None
|
|
373
387
|
self._log_panel.clear()
|
|
374
388
|
self._log_panel.hide()
|
|
375
389
|
|
|
@@ -517,6 +531,7 @@ class SetupPreviewWidget(QWidget):
|
|
|
517
531
|
on_progress=self._on_action_progress,
|
|
518
532
|
),
|
|
519
533
|
plugins=self._discovered_plugins,
|
|
534
|
+
exclude_post_sync=self._model.has_post_sync,
|
|
520
535
|
)
|
|
521
536
|
self._on_install_finished(results)
|
|
522
537
|
except asyncio.CancelledError:
|
|
@@ -614,20 +629,24 @@ class SetupPreviewWidget(QWidget):
|
|
|
614
629
|
self.metadata_ready.emit(preview)
|
|
615
630
|
|
|
616
631
|
def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None:
|
|
617
|
-
"""Update the model and action card with a dry-run result.
|
|
618
|
-
|
|
619
|
-
|
|
632
|
+
"""Update the model and action card with a dry-run result.
|
|
633
|
+
|
|
634
|
+
This callback performs only two things:
|
|
635
|
+
1. Update the ``ActionState.status`` in the model.
|
|
636
|
+
2. Update the ``ActionCard`` widget visually.
|
|
620
637
|
|
|
621
|
-
|
|
622
|
-
|
|
638
|
+
Cross-component side effects (PackageStateStore writes) are
|
|
639
|
+
deferred to :meth:`_on_preview_finished` for one-way data flow.
|
|
640
|
+
"""
|
|
641
|
+
m = self._model
|
|
623
642
|
|
|
624
643
|
if 0 <= row < len(m.action_states):
|
|
625
|
-
m.action_states[row].status =
|
|
644
|
+
m.action_states[row].status = status
|
|
626
645
|
|
|
627
646
|
logger.debug(
|
|
628
647
|
'Action checked [%d]: status=%s success=%s skipped=%s skip_reason=%s installed=%s available=%s',
|
|
629
648
|
row,
|
|
630
|
-
|
|
649
|
+
status,
|
|
631
650
|
result.success,
|
|
632
651
|
result.skipped,
|
|
633
652
|
result.skip_reason,
|
|
@@ -637,21 +656,10 @@ class SetupPreviewWidget(QWidget):
|
|
|
637
656
|
|
|
638
657
|
# Update the card widget
|
|
639
658
|
if m.preview and 0 <= row < len(m.preview.actions):
|
|
640
|
-
action = m.preview.actions[row]
|
|
641
659
|
card = self._card_list.card_for_action_index(row)
|
|
642
660
|
if card is not None:
|
|
643
661
|
card.set_check_result(result, status)
|
|
644
662
|
|
|
645
|
-
# Record in shared store so ToolsView can reflect the update
|
|
646
|
-
if self._package_store is not None and action.installer and action.package:
|
|
647
|
-
self._package_store.record_action_result(
|
|
648
|
-
action.installer,
|
|
649
|
-
str(action.package.name),
|
|
650
|
-
installed_version=result.installed_version or '',
|
|
651
|
-
available_version=result.available_version or '',
|
|
652
|
-
has_update=result.skip_reason == SkipReason.UPDATE_AVAILABLE,
|
|
653
|
-
)
|
|
654
|
-
|
|
655
663
|
# Update phase text
|
|
656
664
|
m.checked_count += 1
|
|
657
665
|
total = len(m.action_states)
|
|
@@ -660,7 +668,15 @@ class SetupPreviewWidget(QWidget):
|
|
|
660
668
|
)
|
|
661
669
|
|
|
662
670
|
def _on_preview_finished(self) -> None:
|
|
663
|
-
"""Finalize the preview after the dry-run check completes.
|
|
671
|
+
"""Finalize the preview after the dry-run check completes.
|
|
672
|
+
|
|
673
|
+
Computes the :class:`InstallPlan` via the operations layer,
|
|
674
|
+
batch-writes to :class:`PackageStateStore`, and updates all
|
|
675
|
+
button states. This is the single point where preview results
|
|
676
|
+
are materialised into actionable decisions.
|
|
677
|
+
"""
|
|
678
|
+
from synodic_client.operations.schema import ActionCheckResult, compute_install_plan
|
|
679
|
+
|
|
664
680
|
m = self._model
|
|
665
681
|
if not m.action_states:
|
|
666
682
|
return
|
|
@@ -679,55 +695,60 @@ class SetupPreviewWidget(QWidget):
|
|
|
679
695
|
finalized,
|
|
680
696
|
)
|
|
681
697
|
|
|
682
|
-
#
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
698
|
+
# Build check results for the plan computation
|
|
699
|
+
check_results: list[ActionCheckResult] = []
|
|
700
|
+
for i, state in enumerate(m.action_states):
|
|
701
|
+
# We need the dry-run result — reconstruct a minimal one from the status
|
|
702
|
+
# The actual result was already applied to the card; here we use the
|
|
703
|
+
# status string which is the canonical output of resolve_action_status.
|
|
704
|
+
check_results.append(
|
|
705
|
+
ActionCheckResult(
|
|
706
|
+
index=i,
|
|
707
|
+
action=state.action,
|
|
708
|
+
result=SetupActionResult(action=state.action, success=True),
|
|
709
|
+
status=state.status,
|
|
710
|
+
),
|
|
711
|
+
)
|
|
686
712
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if count:
|
|
709
|
-
parts.append(f'{count} {label}')
|
|
710
|
-
|
|
711
|
-
actionable = needed + upgradable
|
|
712
|
-
if actionable == 0 and unavailable == 0 and failed == 0:
|
|
713
|
-
self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.')
|
|
714
|
-
self._install_btn.setEnabled(False)
|
|
713
|
+
plan = compute_install_plan(check_results)
|
|
714
|
+
m.install_plan = plan
|
|
715
|
+
|
|
716
|
+
# Batch-write to PackageStateStore (one-way, after plan is computed)
|
|
717
|
+
if self._package_store is not None and m.preview is not None:
|
|
718
|
+
for state in m.action_states:
|
|
719
|
+
action = state.action
|
|
720
|
+
if action.installer and action.package:
|
|
721
|
+
self._package_store.record_action_result(
|
|
722
|
+
action.installer,
|
|
723
|
+
str(action.package.name),
|
|
724
|
+
installed_version='',
|
|
725
|
+
available_version='',
|
|
726
|
+
has_update=state.status == 'Update available',
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# Update UI from the plan
|
|
730
|
+
self._status_label.setText(plan.summary)
|
|
731
|
+
self._install_btn.setEnabled(plan.install_enabled)
|
|
732
|
+
if not plan.install_enabled:
|
|
733
|
+
self._install_btn.setToolTip('No packages to install')
|
|
715
734
|
else:
|
|
716
|
-
self.
|
|
735
|
+
self._install_btn.setToolTip('')
|
|
736
|
+
|
|
737
|
+
# Show/enable the Run Commands button if post-sync exists
|
|
738
|
+
if plan.has_post_sync:
|
|
739
|
+
self._run_commands_btn.show()
|
|
740
|
+
self._run_commands_btn.setEnabled(True)
|
|
717
741
|
|
|
718
742
|
self._set_phase(PreviewPhase.READY)
|
|
719
743
|
|
|
720
744
|
logger.info(
|
|
721
|
-
'Preview complete: %d total, %d
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
pending,
|
|
729
|
-
unavailable,
|
|
730
|
-
failed,
|
|
745
|
+
'Preview complete: %d total, %d to install, %d satisfied, %d upgradable, %d post-sync, install_enabled=%s',
|
|
746
|
+
len(m.action_states),
|
|
747
|
+
len(plan.install_indices),
|
|
748
|
+
len(plan.satisfied_indices),
|
|
749
|
+
len(plan.upgradable_indices),
|
|
750
|
+
len(plan.post_sync_indices),
|
|
751
|
+
plan.install_enabled,
|
|
731
752
|
)
|
|
732
753
|
|
|
733
754
|
def _on_preview_error(self, message: str) -> None:
|
|
@@ -774,14 +795,20 @@ class SetupPreviewWidget(QWidget):
|
|
|
774
795
|
# --- Install execution ---
|
|
775
796
|
|
|
776
797
|
def _on_install(self) -> None:
|
|
777
|
-
"""Handle the Install button click.
|
|
798
|
+
"""Handle the Install button click.
|
|
799
|
+
|
|
800
|
+
Uses the pre-computed :class:`InstallPlan` to determine the
|
|
801
|
+
sync strategy. Post-sync commands are excluded from this
|
|
802
|
+
execution — they are handled by :meth:`_on_run_commands`.
|
|
803
|
+
"""
|
|
778
804
|
m = self._model
|
|
779
|
-
if m.manifest_path is None:
|
|
805
|
+
if m.manifest_path is None or m.install_plan is None:
|
|
780
806
|
return
|
|
781
807
|
|
|
782
808
|
self._prerelease_debounce.stop()
|
|
783
809
|
self._set_phase(PreviewPhase.INSTALLING)
|
|
784
810
|
self._install_btn.setEnabled(False)
|
|
811
|
+
self._run_commands_btn.setEnabled(False)
|
|
785
812
|
self._close_btn.setEnabled(False)
|
|
786
813
|
m.completed_count = 0
|
|
787
814
|
|
|
@@ -791,9 +818,8 @@ class SetupPreviewWidget(QWidget):
|
|
|
791
818
|
self._log_panel.clear()
|
|
792
819
|
self._log_panel.show()
|
|
793
820
|
|
|
794
|
-
#
|
|
795
|
-
|
|
796
|
-
strategy = SyncStrategy.LATEST if m.upgradable_keys else SyncStrategy.MINIMAL
|
|
821
|
+
# Strategy and post-sync exclusion come from the plan
|
|
822
|
+
strategy = m.install_plan.strategy
|
|
797
823
|
|
|
798
824
|
self._task = asyncio.create_task(
|
|
799
825
|
self._run_install_task(
|
|
@@ -852,25 +878,39 @@ class SetupPreviewWidget(QWidget):
|
|
|
852
878
|
self._task.cancel()
|
|
853
879
|
|
|
854
880
|
def _on_install_finished(self, results: SetupResults) -> None:
|
|
855
|
-
"""Handle install completion.
|
|
856
|
-
self._set_phase(PreviewPhase.DONE)
|
|
881
|
+
"""Handle install completion.
|
|
857
882
|
|
|
858
|
-
|
|
859
|
-
|
|
883
|
+
If the plan includes post-sync commands and no actions failed,
|
|
884
|
+
automatically triggers :meth:`_on_run_commands`.
|
|
885
|
+
"""
|
|
886
|
+
from synodic_client.operations.schema import format_install_summary
|
|
887
|
+
|
|
888
|
+
m = self._model
|
|
889
|
+
pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0
|
|
860
890
|
failed = sum(1 for r in results.results if not r.success)
|
|
861
891
|
|
|
862
|
-
|
|
863
|
-
if
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
892
|
+
# Auto-run post-sync if install succeeded and post-sync exists
|
|
893
|
+
if m.has_post_sync and failed == 0:
|
|
894
|
+
summary = format_install_summary(
|
|
895
|
+
install_results=list(results.results),
|
|
896
|
+
pre_skipped_count=pre_skipped,
|
|
897
|
+
)
|
|
898
|
+
self._status_label.setText(f'{summary}. Running post-sync commands\u2026')
|
|
899
|
+
self._run_commands_btn.setEnabled(False)
|
|
900
|
+
self._task = asyncio.create_task(self._run_post_sync_task())
|
|
901
|
+
self._install_results = results # Stash for final summary
|
|
902
|
+
return
|
|
869
903
|
|
|
870
|
-
|
|
871
|
-
|
|
904
|
+
self._set_phase(PreviewPhase.DONE)
|
|
905
|
+
summary = format_install_summary(
|
|
906
|
+
install_results=list(results.results),
|
|
907
|
+
pre_skipped_count=pre_skipped,
|
|
908
|
+
)
|
|
909
|
+
self._status_label.setText(summary)
|
|
872
910
|
self._install_btn.setEnabled(False)
|
|
873
911
|
self._close_btn.setEnabled(True)
|
|
912
|
+
if m.has_post_sync:
|
|
913
|
+
self._run_commands_btn.setEnabled(True)
|
|
874
914
|
self.install_finished.emit(results)
|
|
875
915
|
|
|
876
916
|
def _on_install_error(self, message: str) -> None:
|
|
@@ -880,6 +920,76 @@ class SetupPreviewWidget(QWidget):
|
|
|
880
920
|
self._install_btn.setEnabled(True)
|
|
881
921
|
self._close_btn.setEnabled(True)
|
|
882
922
|
|
|
923
|
+
# --- Post-sync execution ---
|
|
924
|
+
|
|
925
|
+
def _on_run_commands(self) -> None:
|
|
926
|
+
"""Handle the Run Commands button click."""
|
|
927
|
+
m = self._model
|
|
928
|
+
if m.manifest_path is None:
|
|
929
|
+
return
|
|
930
|
+
|
|
931
|
+
self._run_commands_btn.setEnabled(False)
|
|
932
|
+
self._install_btn.setEnabled(False)
|
|
933
|
+
self._close_btn.setEnabled(False)
|
|
934
|
+
|
|
935
|
+
self._status_label.setText('Running post-sync commands\u2026')
|
|
936
|
+
|
|
937
|
+
if not self._log_panel.isVisible():
|
|
938
|
+
self._log_panel.clear()
|
|
939
|
+
self._log_panel.show()
|
|
940
|
+
|
|
941
|
+
self._install_results = None
|
|
942
|
+
self._task = asyncio.create_task(self._run_post_sync_task())
|
|
943
|
+
|
|
944
|
+
async def _run_post_sync_task(self) -> None:
|
|
945
|
+
"""Run the post-sync coroutine and route completion/errors."""
|
|
946
|
+
assert self._model.manifest_path is not None
|
|
947
|
+
try:
|
|
948
|
+
results = await run_post_sync(
|
|
949
|
+
self._porringer,
|
|
950
|
+
self._model.manifest_path,
|
|
951
|
+
project_directory=self._model.project_directory,
|
|
952
|
+
callbacks=InstallCallbacks(
|
|
953
|
+
on_action_started=self._on_action_started,
|
|
954
|
+
on_sub_progress=self._on_sub_progress,
|
|
955
|
+
on_progress=self._on_action_progress,
|
|
956
|
+
),
|
|
957
|
+
plugins=self._discovered_plugins,
|
|
958
|
+
)
|
|
959
|
+
self._on_post_sync_finished(results)
|
|
960
|
+
except asyncio.CancelledError:
|
|
961
|
+
self._on_post_sync_finished(SetupResults(actions=[]))
|
|
962
|
+
except Exception as exc:
|
|
963
|
+
logger.exception('Post-sync execution failed')
|
|
964
|
+
self._on_install_error(f'Post-sync failed: {exc}')
|
|
965
|
+
|
|
966
|
+
def _on_post_sync_finished(self, results: SetupResults) -> None:
|
|
967
|
+
"""Handle post-sync completion."""
|
|
968
|
+
from synodic_client.operations.schema import format_install_summary
|
|
969
|
+
|
|
970
|
+
m = self._model
|
|
971
|
+
m.post_sync_completed = True
|
|
972
|
+
m.post_sync_results = list(results.results)
|
|
973
|
+
|
|
974
|
+
self._set_phase(PreviewPhase.DONE)
|
|
975
|
+
|
|
976
|
+
pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0
|
|
977
|
+
|
|
978
|
+
# If we have stashed install results (auto-run path), use combined summary
|
|
979
|
+
install_results = (
|
|
980
|
+
list(self._install_results.results) if hasattr(self, '_install_results') and self._install_results else None
|
|
981
|
+
)
|
|
982
|
+
summary = format_install_summary(
|
|
983
|
+
install_results=install_results,
|
|
984
|
+
post_sync_results=m.post_sync_results,
|
|
985
|
+
pre_skipped_count=pre_skipped,
|
|
986
|
+
)
|
|
987
|
+
self._status_label.setText(summary)
|
|
988
|
+
self._install_btn.setEnabled(False)
|
|
989
|
+
self._run_commands_btn.setEnabled(False)
|
|
990
|
+
self._close_btn.setEnabled(True)
|
|
991
|
+
self.install_finished.emit(results)
|
|
992
|
+
|
|
883
993
|
|
|
884
994
|
# ---------------------------------------------------------------------------
|
|
885
995
|
# InstallPreviewWindow — standalone URI-based install window
|