synodic-client 0.0.1.dev71__tar.gz → 0.0.1.dev72__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.dev71 → synodic_client-0.0.1.dev72}/PKG-INFO +2 -2
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/pyproject.toml +6 -14
- synodic_client-0.0.1.dev72/synodic_client/application/bootstrap.py +63 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/data.py +18 -0
- synodic_client-0.0.1.dev72/synodic_client/application/debug.py +271 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/instance.py +60 -2
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/qt.py +114 -26
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/schema.py +4 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/__init__.py +13 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/action_card.py +37 -4
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/install.py +57 -16
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/install_workers.py +17 -23
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/plugin_row.py +8 -2
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/schema.py +4 -2
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/screen.py +16 -11
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/settings.py +1 -1
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/tool_update_controller.py +13 -7
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/tray.py +34 -16
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/update_controller.py +12 -9
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/update_model.py +1 -1
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/workers.py +4 -2
- synodic_client-0.0.1.dev72/synodic_client/cli/__init__.py +74 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/config.py +92 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/context.py +29 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/debug.py +85 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/output.py +60 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/project.py +77 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/tool.py +93 -0
- synodic_client-0.0.1.dev72/synodic_client/cli/update.py +81 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/logging.py +5 -4
- synodic_client-0.0.1.dev72/synodic_client/operations/__init__.py +84 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/bootstrap.py +48 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/config.py +67 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/install.py +150 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/project.py +87 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/schema.py +180 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/tool.py +388 -0
- synodic_client-0.0.1.dev72/synodic_client/operations/update.py +104 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/protocol.py +1 -1
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/schema.py +1 -1
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/subprocess_patch.py +2 -3
- synodic_client-0.0.1.dev72/tests/unit/operations/__init__.py +1 -0
- synodic_client-0.0.1.dev72/tests/unit/operations/test_config.py +77 -0
- synodic_client-0.0.1.dev72/tests/unit/operations/test_install.py +221 -0
- synodic_client-0.0.1.dev72/tests/unit/operations/test_project.py +85 -0
- synodic_client-0.0.1.dev72/tests/unit/operations/test_tool.py +231 -0
- synodic_client-0.0.1.dev72/tests/unit/operations/test_update.py +147 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_action_card.py +145 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_gather_packages.py +1 -2
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_install_preview.py +48 -51
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_log_panel.py +21 -29
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_logging.py +1 -1
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_update_controller.py +4 -2
- synodic_client-0.0.1.dev72/tests/unit/test_cli.py +337 -0
- synodic_client-0.0.1.dev71/synodic_client/application/bootstrap.py +0 -57
- synodic_client-0.0.1.dev71/synodic_client/cli.py +0 -46
- synodic_client-0.0.1.dev71/tests/unit/test_cli.py +0 -56
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/README.md +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/config_store.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/package_state.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/projects.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/theme.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_settings.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/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.dev72
|
|
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.
|
|
11
|
+
Requires-Dist: porringer>=0.2.1.dev84
|
|
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.
|
|
13
|
+
"porringer>=0.2.1.dev84",
|
|
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.
|
|
18
|
+
version = "0.0.1.dev72"
|
|
19
19
|
|
|
20
20
|
[project.license]
|
|
21
21
|
text = "LGPL-3.0-or-later"
|
|
@@ -35,7 +35,7 @@ build = [
|
|
|
35
35
|
"pyinstaller>=6.19.0",
|
|
36
36
|
]
|
|
37
37
|
lint = [
|
|
38
|
-
"ruff>=0.15.
|
|
38
|
+
"ruff>=0.15.6",
|
|
39
39
|
"pyrefly>=0.56.0",
|
|
40
40
|
]
|
|
41
41
|
test = [
|
|
@@ -62,6 +62,8 @@ ignore = [
|
|
|
62
62
|
"E111",
|
|
63
63
|
"E114",
|
|
64
64
|
"E117",
|
|
65
|
+
"PLC0415",
|
|
66
|
+
"PLR0913",
|
|
65
67
|
]
|
|
66
68
|
select = [
|
|
67
69
|
"D",
|
|
@@ -75,14 +77,6 @@ select = [
|
|
|
75
77
|
"PT",
|
|
76
78
|
]
|
|
77
79
|
|
|
78
|
-
[tool.ruff.lint.per-file-ignores]
|
|
79
|
-
"synodic_client/application/bootstrap.py" = [
|
|
80
|
-
"E402",
|
|
81
|
-
]
|
|
82
|
-
"synodic_client/cli.py" = [
|
|
83
|
-
"PLC0415",
|
|
84
|
-
]
|
|
85
|
-
|
|
86
80
|
[tool.ruff.lint.pydocstyle]
|
|
87
81
|
convention = "google"
|
|
88
82
|
|
|
@@ -108,14 +102,12 @@ allow-prereleases = true
|
|
|
108
102
|
|
|
109
103
|
[tool.pdm.scripts]
|
|
110
104
|
analyze = "ruff check"
|
|
105
|
+
dev = "synodic-c --dev"
|
|
111
106
|
format = "ruff format"
|
|
112
107
|
test = "pytest --cov=synodic_client --verbose tests"
|
|
113
108
|
type-check = "pyrefly check"
|
|
114
109
|
serve = "zensical serve"
|
|
115
110
|
|
|
116
|
-
[tool.pdm.scripts.dev]
|
|
117
|
-
call = "tool.scripts.dev:main"
|
|
118
|
-
|
|
119
111
|
[tool.pdm.scripts.lint]
|
|
120
112
|
composite = [
|
|
121
113
|
"analyze",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Bootstrap entry point for PyInstaller builds.
|
|
2
|
+
|
|
3
|
+
Runs the lightweight startup preamble — logging, Velopack hooks, and
|
|
4
|
+
protocol registration — **before** importing heavy modules (PySide6,
|
|
5
|
+
porringer). Velopack's install/uninstall/update hooks have strict
|
|
6
|
+
timeouts (15–30 s) and must complete before the process is killed.
|
|
7
|
+
|
|
8
|
+
Import order matters:
|
|
9
|
+
1. stdlib + config (pure-Python, fast)
|
|
10
|
+
2. configure_logging() — now Qt-free
|
|
11
|
+
3. initialize_velopack() — hooks run with logging active
|
|
12
|
+
4. run_startup_preamble() — protocol, config seed, auto-startup
|
|
13
|
+
5. import qt.application — PySide6 / porringer loaded here
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
import traceback
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bootstrap() -> None:
|
|
22
|
+
"""Execute the ordered bootstrap sequence."""
|
|
23
|
+
try:
|
|
24
|
+
from synodic_client.config import set_dev_mode
|
|
25
|
+
from synodic_client.logging import configure_logging
|
|
26
|
+
from synodic_client.protocol import extract_uri_from_args
|
|
27
|
+
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
|
|
28
|
+
from synodic_client.updater import initialize_velopack
|
|
29
|
+
except Exception:
|
|
30
|
+
# Last-resort crash log when imports fail before logging is configured.
|
|
31
|
+
import os
|
|
32
|
+
|
|
33
|
+
_fallback = os.path.join(os.environ.get('LOCALAPPDATA', '.'), 'Synodic', 'logs', 'bootstrap-crash.log')
|
|
34
|
+
os.makedirs(os.path.dirname(_fallback), exist_ok=True)
|
|
35
|
+
with open(_fallback, 'a', encoding='utf-8') as _f:
|
|
36
|
+
_f.write(traceback.format_exc())
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
# Parse flags early so logging uses the right filename and level.
|
|
40
|
+
dev_mode = '--dev' in sys.argv[1:]
|
|
41
|
+
debug = '--debug' in sys.argv[1:]
|
|
42
|
+
set_dev_mode(dev_mode)
|
|
43
|
+
_apply_subprocess_patch()
|
|
44
|
+
|
|
45
|
+
configure_logging(debug=debug)
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)
|
|
49
|
+
|
|
50
|
+
initialize_velopack()
|
|
51
|
+
|
|
52
|
+
if not dev_mode:
|
|
53
|
+
from synodic_client.application.init import run_startup_preamble
|
|
54
|
+
|
|
55
|
+
run_startup_preamble(sys.executable)
|
|
56
|
+
|
|
57
|
+
# Heavy imports happen here — PySide6, porringer, etc.
|
|
58
|
+
from synodic_client.application.qt import application
|
|
59
|
+
|
|
60
|
+
application(uri=extract_uri_from_args(), dev_mode=dev_mode, debug=debug)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
bootstrap()
|
{synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/data.py
RENAMED
|
@@ -64,6 +64,11 @@ class DataCoordinator:
|
|
|
64
64
|
"""Shortcut to the current ``DiscoveredPlugins`` instance."""
|
|
65
65
|
return self._snapshot.discovered
|
|
66
66
|
|
|
67
|
+
@property
|
|
68
|
+
def is_stale(self) -> bool:
|
|
69
|
+
"""Whether the cached data needs refreshing."""
|
|
70
|
+
return self._stale
|
|
71
|
+
|
|
67
72
|
def invalidate(self) -> None:
|
|
68
73
|
"""Mark the cached data as stale.
|
|
69
74
|
|
|
@@ -173,14 +178,27 @@ class DataCoordinator:
|
|
|
173
178
|
if isinstance(env, PluginManager) and env.is_available():
|
|
174
179
|
managers[env.tool_name()] = env
|
|
175
180
|
|
|
181
|
+
# Step 5: collect protocol capabilities for each plugin
|
|
182
|
+
capabilities: dict[str, frozenset] = {
|
|
183
|
+
plugin.name: frozenset(discovered.capabilities(plugin.name)) for plugin in plugins
|
|
184
|
+
}
|
|
185
|
+
|
|
176
186
|
# Derive the un-validated directory list for callers that only
|
|
177
187
|
# need path + name (e.g. _gather_packages).
|
|
178
188
|
directories = [r.directory for r in validated]
|
|
179
189
|
|
|
190
|
+
logger.info(
|
|
191
|
+
'Discovery complete: %d plugin(s), %d directory(ies), %d plugin manager(s)',
|
|
192
|
+
len(plugins),
|
|
193
|
+
len(directories),
|
|
194
|
+
len(managers),
|
|
195
|
+
)
|
|
196
|
+
|
|
180
197
|
return Snapshot(
|
|
181
198
|
plugins=plugins,
|
|
182
199
|
directories=directories,
|
|
183
200
|
validated_directories=validated,
|
|
184
201
|
discovered=discovered,
|
|
185
202
|
plugin_managers=managers,
|
|
203
|
+
plugin_capabilities=capabilities,
|
|
186
204
|
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Debug automation interface for agent and developer interaction.
|
|
2
|
+
|
|
3
|
+
Provides a command handler that serialises application domain state and
|
|
4
|
+
dispatches deterministic actions via the existing controller/service
|
|
5
|
+
layer. Commands arrive over the :class:`SingleInstance` IPC socket and
|
|
6
|
+
responses are returned as JSON strings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from synodic_client.config import is_dev_mode
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from porringer.api import API
|
|
23
|
+
|
|
24
|
+
from synodic_client.application.config_store import ConfigStore
|
|
25
|
+
from synodic_client.application.data import DataCoordinator
|
|
26
|
+
from synodic_client.application.screen.screen import MainWindow
|
|
27
|
+
from synodic_client.application.screen.settings import SettingsWindow
|
|
28
|
+
from synodic_client.application.screen.tool_update_controller import ToolUpdateOrchestrator
|
|
29
|
+
from synodic_client.application.update_controller import UpdateController
|
|
30
|
+
from synodic_client.application.update_model import UpdateModel
|
|
31
|
+
from synodic_client.client import Client
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
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
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass
|
|
51
|
+
class DebugServices:
|
|
52
|
+
"""Bundle of service references for the debug handler."""
|
|
53
|
+
|
|
54
|
+
client: Client
|
|
55
|
+
porringer: API
|
|
56
|
+
coordinator: DataCoordinator | None
|
|
57
|
+
config_store: ConfigStore
|
|
58
|
+
update_controller: UpdateController
|
|
59
|
+
update_model: UpdateModel
|
|
60
|
+
tool_orchestrator: ToolUpdateOrchestrator
|
|
61
|
+
main_window: MainWindow
|
|
62
|
+
settings_window: SettingsWindow
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DebugHandler:
|
|
66
|
+
"""Routes debug IPC commands to the application service layer."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, services: DebugServices) -> None:
|
|
69
|
+
"""Initialize the debug handler with service references."""
|
|
70
|
+
self._s = services
|
|
71
|
+
|
|
72
|
+
def handle(self, command: str) -> str:
|
|
73
|
+
"""Dispatch a debug command and return a JSON response string."""
|
|
74
|
+
logger.debug('Debug command received: %s', command)
|
|
75
|
+
|
|
76
|
+
if command == 'state':
|
|
77
|
+
return self._handle_state()
|
|
78
|
+
if command == 'actions':
|
|
79
|
+
return self._handle_actions()
|
|
80
|
+
if command.startswith('action:'):
|
|
81
|
+
remainder = command[len('action:') :]
|
|
82
|
+
name, _, arg = remainder.partition(':')
|
|
83
|
+
return self._handle_action(name, arg or None)
|
|
84
|
+
|
|
85
|
+
return json.dumps({'error': f'unknown command: {command}'})
|
|
86
|
+
|
|
87
|
+
def _handle_state(self) -> str:
|
|
88
|
+
"""Serialise current application domain state."""
|
|
89
|
+
config = self._s.config_store.config
|
|
90
|
+
coordinator = self._s.coordinator
|
|
91
|
+
model = self._s.update_model
|
|
92
|
+
|
|
93
|
+
snapshot = coordinator.snapshot if coordinator is not None else None
|
|
94
|
+
data_stale = coordinator.is_stale if coordinator is not None else None
|
|
95
|
+
|
|
96
|
+
state = {
|
|
97
|
+
'app': {
|
|
98
|
+
'version': str(self._s.client.version),
|
|
99
|
+
'dev_mode': is_dev_mode(),
|
|
100
|
+
'frozen': getattr(sys, 'frozen', False),
|
|
101
|
+
'platform': sys.platform,
|
|
102
|
+
},
|
|
103
|
+
'config': dataclasses.asdict(config),
|
|
104
|
+
'update': {
|
|
105
|
+
'phase': model.phase.name,
|
|
106
|
+
'version': model.version,
|
|
107
|
+
'error_message': model.error_message,
|
|
108
|
+
},
|
|
109
|
+
'data': {
|
|
110
|
+
'plugin_count': len(snapshot.plugins) if snapshot else 0,
|
|
111
|
+
'directory_count': len(snapshot.directories) if snapshot else 0,
|
|
112
|
+
'manager_count': len(snapshot.plugin_managers) if snapshot else 0,
|
|
113
|
+
'stale': data_stale,
|
|
114
|
+
},
|
|
115
|
+
'windows': {
|
|
116
|
+
'main_visible': self._s.main_window.isVisible(),
|
|
117
|
+
'settings_visible': self._s.settings_window.isVisible(),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
return json.dumps(state, default=str)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _handle_actions() -> str:
|
|
124
|
+
"""Return a list of available action names with descriptions."""
|
|
125
|
+
return json.dumps({'actions': _ACTIONS})
|
|
126
|
+
|
|
127
|
+
def _handle_action(self, name: str, arg: str | None = None) -> str:
|
|
128
|
+
"""Dispatch a named action to the appropriate controller."""
|
|
129
|
+
if name not in _ACTIONS:
|
|
130
|
+
return json.dumps({'error': f'unknown action: {name}', 'available': list(_ACTIONS)})
|
|
131
|
+
|
|
132
|
+
# Actions that return their own JSON response
|
|
133
|
+
_project_dispatch: dict[str, Callable[[str | None], str]] = {
|
|
134
|
+
'list_projects': lambda _: self._handle_list_projects(),
|
|
135
|
+
'add_project': self._handle_add_project,
|
|
136
|
+
'remove_project': self._handle_remove_project,
|
|
137
|
+
'project_status': self._handle_project_status,
|
|
138
|
+
'select_project': self._handle_select_project,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if name in _project_dispatch:
|
|
142
|
+
return _project_dispatch[name](arg)
|
|
143
|
+
|
|
144
|
+
# Fire-and-forget actions
|
|
145
|
+
if name == 'check_update':
|
|
146
|
+
self._s.update_controller.check_now(silent=False)
|
|
147
|
+
elif name == 'tool_update':
|
|
148
|
+
self._s.tool_orchestrator.on_tool_update()
|
|
149
|
+
elif name == 'refresh_data':
|
|
150
|
+
if self._s.coordinator is not None:
|
|
151
|
+
self._s.coordinator.invalidate()
|
|
152
|
+
elif name == 'show_main':
|
|
153
|
+
self._s.main_window.show()
|
|
154
|
+
self._s.main_window.raise_()
|
|
155
|
+
self._s.main_window.activateWindow()
|
|
156
|
+
elif name == 'show_settings':
|
|
157
|
+
self._s.settings_window.show()
|
|
158
|
+
elif name == 'apply_update':
|
|
159
|
+
self._s.update_controller.request_apply()
|
|
160
|
+
|
|
161
|
+
return json.dumps({'ok': True, 'action': name})
|
|
162
|
+
|
|
163
|
+
# -- Project management actions ----------------------------------------
|
|
164
|
+
|
|
165
|
+
def _handle_list_projects(self) -> str:
|
|
166
|
+
"""List all cached project directories with validation status."""
|
|
167
|
+
from synodic_client.operations.project import list_projects
|
|
168
|
+
|
|
169
|
+
projects = list_projects(self._s.porringer)
|
|
170
|
+
return json.dumps({'projects': [dataclasses.asdict(p) for p in projects]})
|
|
171
|
+
|
|
172
|
+
def _handle_add_project(self, arg: str | None) -> str:
|
|
173
|
+
"""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
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
add_project(self._s.porringer, arg)
|
|
181
|
+
except (NotADirectoryError, ValueError) as exc:
|
|
182
|
+
return json.dumps({'error': str(exc)})
|
|
183
|
+
|
|
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()
|
|
190
|
+
|
|
191
|
+
return json.dumps({'ok': True, 'action': 'add_project', 'path': arg})
|
|
192
|
+
|
|
193
|
+
def _handle_remove_project(self, arg: str | None) -> str:
|
|
194
|
+
"""Remove a directory from the porringer cache."""
|
|
195
|
+
if not arg:
|
|
196
|
+
return json.dumps({'error': 'remove_project requires a path argument'})
|
|
197
|
+
|
|
198
|
+
from synodic_client.operations.project import remove_project
|
|
199
|
+
|
|
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()
|
|
208
|
+
|
|
209
|
+
return json.dumps({'ok': True, 'action': 'remove_project', 'path': arg})
|
|
210
|
+
|
|
211
|
+
def _handle_project_status(self, arg: str | None) -> str:
|
|
212
|
+
"""Dump per-action preview status for a project."""
|
|
213
|
+
projects_view = self._s.main_window._projects_view
|
|
214
|
+
if projects_view is None:
|
|
215
|
+
return json.dumps({'error': 'projects view not initialised — run show_main first'})
|
|
216
|
+
|
|
217
|
+
if arg:
|
|
218
|
+
target = Path(arg)
|
|
219
|
+
else:
|
|
220
|
+
target = projects_view._sidebar.selected_path
|
|
221
|
+
if target is None:
|
|
222
|
+
return json.dumps({'error': 'no project selected and no path argument provided'})
|
|
223
|
+
|
|
224
|
+
widget = projects_view._widgets.get(target)
|
|
225
|
+
if widget is None:
|
|
226
|
+
available = [str(p) for p in projects_view._widgets]
|
|
227
|
+
return json.dumps({'error': f'no widget for path: {target}', 'available_paths': available})
|
|
228
|
+
|
|
229
|
+
model = widget.model
|
|
230
|
+
actions = []
|
|
231
|
+
for state in model.action_states:
|
|
232
|
+
act = state.action
|
|
233
|
+
entry: dict[str, object] = {
|
|
234
|
+
'description': act.description,
|
|
235
|
+
'kind': act.kind.name if act.kind else None,
|
|
236
|
+
'status': state.status,
|
|
237
|
+
}
|
|
238
|
+
if act.package is not None:
|
|
239
|
+
entry['package'] = act.package.name
|
|
240
|
+
if act.package.constraint:
|
|
241
|
+
entry['constraint'] = act.package.constraint
|
|
242
|
+
if act.installer:
|
|
243
|
+
entry['installer'] = act.installer
|
|
244
|
+
actions.append(entry)
|
|
245
|
+
|
|
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')
|
|
249
|
+
upgradable = len(model.upgradable_keys)
|
|
250
|
+
|
|
251
|
+
return json.dumps({
|
|
252
|
+
'path': str(target),
|
|
253
|
+
'phase': model.phase.name,
|
|
254
|
+
'action_count': len(model.action_states),
|
|
255
|
+
'checked_count': model.checked_count,
|
|
256
|
+
'actions': actions,
|
|
257
|
+
'summary': {
|
|
258
|
+
'needed': needed,
|
|
259
|
+
'satisfied': satisfied,
|
|
260
|
+
'pending': pending,
|
|
261
|
+
'upgradable': upgradable,
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
def _handle_select_project(self, arg: str | None) -> str:
|
|
266
|
+
"""Select a project in the sidebar."""
|
|
267
|
+
if not arg:
|
|
268
|
+
return json.dumps({'error': 'select_project requires a path argument'})
|
|
269
|
+
|
|
270
|
+
self._s.main_window._navigate_to_project(arg)
|
|
271
|
+
return json.dumps({'ok': True, 'action': 'select_project', 'path': arg})
|
{synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev72}/synodic_client/application/instance.py
RENAMED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
Ensures only one instance of Synodic Client runs at a time. When a second
|
|
4
4
|
instance is launched (e.g. by clicking a ``synodic://`` URI), it sends the
|
|
5
5
|
URI to the already-running instance and exits.
|
|
6
|
+
|
|
7
|
+
Debug commands (prefixed with ``debug:``) receive a JSON response on the
|
|
8
|
+
same connection before it is closed.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
import logging
|
|
12
|
+
from collections.abc import Callable
|
|
9
13
|
|
|
10
14
|
from PySide6.QtCore import QByteArray, QObject, Signal
|
|
11
15
|
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
|
@@ -18,6 +22,13 @@ logger = logging.getLogger(__name__)
|
|
|
18
22
|
_SERVER_NAME = 'synodic-client'
|
|
19
23
|
_SERVER_NAME_DEV = 'synodic-client-dev'
|
|
20
24
|
|
|
25
|
+
_DEBUG_PREFIX = 'debug:'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _decode(raw: bytes | bytearray | memoryview) -> str:
|
|
29
|
+
"""Decode raw socket data to a UTF-8 string."""
|
|
30
|
+
return bytes(raw).decode('utf-8')
|
|
31
|
+
|
|
21
32
|
|
|
22
33
|
def _server_name() -> str:
|
|
23
34
|
"""Return the server name, namespaced for dev mode."""
|
|
@@ -41,6 +52,16 @@ class SingleInstance(QObject):
|
|
|
41
52
|
"""
|
|
42
53
|
super().__init__(parent)
|
|
43
54
|
self._server: QLocalServer | None = None
|
|
55
|
+
self._debug_handler: Callable[[str], str] | None = None
|
|
56
|
+
|
|
57
|
+
def set_debug_handler(self, handler: Callable[[str], str]) -> None:
|
|
58
|
+
"""Register a handler for ``debug:`` IPC commands.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
handler: Callable that accepts a command string (without the
|
|
62
|
+
``debug:`` prefix) and returns a JSON response string.
|
|
63
|
+
"""
|
|
64
|
+
self._debug_handler = handler
|
|
44
65
|
|
|
45
66
|
@staticmethod
|
|
46
67
|
def try_send_to_existing(message: str) -> bool:
|
|
@@ -65,6 +86,34 @@ class SingleInstance(QObject):
|
|
|
65
86
|
|
|
66
87
|
return False
|
|
67
88
|
|
|
89
|
+
@staticmethod
|
|
90
|
+
def send_debug_command(command: str) -> str:
|
|
91
|
+
"""Send a debug command to the running instance and return its response.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
command: The debug command (without the ``debug:`` prefix).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The JSON response string from the running instance, or an
|
|
98
|
+
error JSON string if the connection failed.
|
|
99
|
+
"""
|
|
100
|
+
socket = QLocalSocket()
|
|
101
|
+
socket.connectToServer(_server_name())
|
|
102
|
+
|
|
103
|
+
if not socket.waitForConnected(SOCKET_TIMEOUT_MS):
|
|
104
|
+
return '{"error": "No running instance found"}'
|
|
105
|
+
|
|
106
|
+
socket.write(QByteArray(f'{_DEBUG_PREFIX}{command}'.encode()))
|
|
107
|
+
socket.waitForBytesWritten(SOCKET_TIMEOUT_MS)
|
|
108
|
+
|
|
109
|
+
if not socket.waitForReadyRead(SOCKET_TIMEOUT_MS):
|
|
110
|
+
socket.disconnectFromServer()
|
|
111
|
+
return '{"error": "Timed out waiting for response"}'
|
|
112
|
+
|
|
113
|
+
raw = socket.readAll().data()
|
|
114
|
+
socket.disconnectFromServer()
|
|
115
|
+
return _decode(raw)
|
|
116
|
+
|
|
68
117
|
def start_server(self) -> bool:
|
|
69
118
|
"""Start the local socket server to listen for incoming messages.
|
|
70
119
|
|
|
@@ -98,8 +147,17 @@ class SingleInstance(QObject):
|
|
|
98
147
|
|
|
99
148
|
if socket.waitForReadyRead(SOCKET_TIMEOUT_MS):
|
|
100
149
|
raw = socket.readAll().data()
|
|
101
|
-
data =
|
|
102
|
-
|
|
150
|
+
data = _decode(raw)
|
|
151
|
+
|
|
152
|
+
if data.startswith(_DEBUG_PREFIX):
|
|
153
|
+
command = data[len(_DEBUG_PREFIX) :]
|
|
154
|
+
if self._debug_handler is not None:
|
|
155
|
+
response = self._debug_handler(command)
|
|
156
|
+
else:
|
|
157
|
+
response = '{"error": "debug handler not registered"}'
|
|
158
|
+
socket.write(QByteArray(response.encode('utf-8')))
|
|
159
|
+
socket.waitForBytesWritten(SOCKET_TIMEOUT_MS)
|
|
160
|
+
elif data:
|
|
103
161
|
logger.info('Received message from another instance: %s', data)
|
|
104
162
|
self.uri_received.emit(data)
|
|
105
163
|
else:
|