From ff77968275bbe75c8816cc851ee9d31b481dcb5c Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Mar 2026 09:02:12 -0700 Subject: [PATCH 1/4] Update Chore --- pdm.lock | 56 ++++++++++++++--------------- pyproject.toml | 4 +-- synodic_client/operations/schema.py | 20 ++++++----- 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/pdm.lock b/pdm.lock index adc7c91..fd6ade8 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:d5335fc9d49a28f6d627be54d05648471d89390cf4b899ce8e72faf0bd16f80d" +content_hash = "sha256:7fe4883175ad53f2473a7f6bbc8bb0c50a29bce6d9c9d09922f799b8ae6b191a" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -471,20 +471,20 @@ files = [ [[package]] name = "pyrefly" -version = "0.56.0" +version = "0.57.0" requires_python = ">=3.8" summary = "A fast type checker and language server for Python with powerful IDE features" groups = ["lint"] files = [ - {file = "pyrefly-0.56.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21f018f47debc0842b2c3072201e53c138ae32bcda4f3119bfc8d23f59c16b3e"}, - {file = "pyrefly-0.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95366056ceb224571b9f1c20e801d949f2c1fa2cf4ed6ceaadf85ca2ebe6fb27"}, - {file = "pyrefly-0.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5478229b09f4bba5bfea000b5ba20ea405f62dc7619ea81197e7ea637d6cba8d"}, - {file = "pyrefly-0.56.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e5f53781875024086a5b9f31a89c57d2977487fc3f819d9255008ad34b86fe2"}, - {file = "pyrefly-0.56.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec6ab3f9e2c03bae8dfa520f52778f47b6762020929a664177d36aa3b941db22"}, - {file = "pyrefly-0.56.0-py3-none-win32.whl", hash = "sha256:f0440a4bbf119ab646468f360e0bd047df051352db1e5d5b9fd58f89e8458809"}, - {file = "pyrefly-0.56.0-py3-none-win_amd64.whl", hash = "sha256:f4948021639288b1ccda5f124c9562dc7f0a2679111eb314fa266c7bfd9f8603"}, - {file = "pyrefly-0.56.0-py3-none-win_arm64.whl", hash = "sha256:4683f5e8820d5fbfb84231b643b2c5f6cd40b982cac48ef756d4e3d9b09a39cc"}, - {file = "pyrefly-0.56.0.tar.gz", hash = "sha256:f84d21d9b9b58481eea02204e2f73cabb93751b21ab2cd99178b4bde24be6a82"}, + {file = "pyrefly-0.57.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da29dfd8182e67357aa45c1734dca03aabfbeaa2339f06a97fcd26f24c4827bf"}, + {file = "pyrefly-0.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bdac6feeaa8aa45fd88dc650a9430a104ac4850aacba73e95138f4338e06ddfd"}, + {file = "pyrefly-0.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69cf1d5bb350aa63a797b8d1b660ff1d8ea281358558ec602f7bce6b051380"}, + {file = "pyrefly-0.57.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1413ac880ecf0cc4def339892a3b8ca065f1ae7dff94d1a8c8160fbf0068be3a"}, + {file = "pyrefly-0.57.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd048bb1db13ab42281e440306b57308fe89fde362fe622b507f3db81037d38"}, + {file = "pyrefly-0.57.0-py3-none-win32.whl", hash = "sha256:aa6e9d9e4c7e276409f99b83d964fa39c7ba567b5521244b6b1a1eaf6ec399b9"}, + {file = "pyrefly-0.57.0-py3-none-win_amd64.whl", hash = "sha256:4169abc722fc9a957366c4fc8fdd7a927a5fb890ec0aae5178011e41c45a560e"}, + {file = "pyrefly-0.57.0-py3-none-win_arm64.whl", hash = "sha256:19e2c14533c00ae6e63ac38c6000e32badde5f573ca5c1cd969ecab129de89ec"}, + {file = "pyrefly-0.57.0.tar.gz", hash = "sha256:2e1d67165d6db8bf1da8df3b88d16dd899980367bbae16867404f11ff287cfff"}, ] [[package]] @@ -765,25 +765,25 @@ files = [ [[package]] name = "velopack" -version = "0.0.1444.dev49733" +version = "0.0.1521.dev61717" requires_python = ">=3.8" summary = "Installer and automatic update framework for cross-platform desktop applications" groups = ["default"] files = [ - {file = "velopack-0.0.1444.dev49733-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24f8564b7a391e3a5b94d05ddf6e82f612492cff84da4dc9f6585864f0055dc0"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:901d5a6ad30082beb237cfa78a975e252f485dc394383dc687ea9fbe6b325e56"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cbef500e62c135c7ef3853b3d039c2aedb34079c7d5d864319acb71692abd7f"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bbcd8a67f8eac76e14bcddf72d841c5aeddbcd6674f6637b360a98a7187c0972"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220b9946d9716c97e49493a803f9420acc896081cc4996fbc6f4b4a15e6df471"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43596c1ad16130066a42c135c43cab4383cac08a27d474ec43a1840baa2b328c"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b37c2482449e9f171b4c9ca5ba64d9506820733fac99d0285672966a11fce339"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b8bd6077bfd787d2fc1578bef5061dc91cb0914fb0a9f83dca53c803a0a5000"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e388c8a5e5d1cb1ccbc5522a5175435e2fc7c0f0d0412b7ee02a63a5e9b995b"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6d1f1b52e8fe30c50918110f4762f52f0082a55aad791b4a77f65ed6b98e0e86"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:c57207f567021f725e0871a1f316c931e040498a0ac9b308842793493755e5a0"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d48d22f0196598352a3a3f235ca60853593d40f5c76699d6fc102e58d551f75b"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-win32.whl", hash = "sha256:77ab6d866cae6b276556c4b3ad016faf0cfee7eaae0a29f8dfe86276ed8aa78b"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-win_amd64.whl", hash = "sha256:e554615d2f2c732be89afc21054308def3fb8caded3b7f504df3551085451e0b"}, - {file = "velopack-0.0.1444.dev49733-cp37-abi3-win_arm64.whl", hash = "sha256:6b581e529793bca306920ee64009f318ec9a6cba098dd9ee0cbbc45df43c240e"}, - {file = "velopack-0.0.1444.dev49733.tar.gz", hash = "sha256:f2723c4c70c380c2b1fc9f6fddcea1a9b2f0c0c912090a4450c950dc0fad41f8"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c7414ad3064e3b3e9f0cab54482d1817782be0d463980bbdcfa00fa7129971d4"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:4f5855490eca3e3439795e1881fc41083f03c68e99248f1460d4b654043151dd"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e19850ed861e774fa84ef3c5f1a894a2f073ce87fef5f780e84fa53ce4db97a2"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f6d8d32e86076fe0911330ad476237e2cbd46e9f5bf10395d0b621869fd4700"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fc98b73507216356c1465ff9d71e24e63c00ce42b7d7d46c61de2930b76a7ec"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:745f4347ac5bfa005d91ec234f1d3877c23eeeae31cef0069d28658fb2c13091"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f2cc109498f97104a8957714c06bc0d0fb36445fd25128345bc48d6f798f8d4"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a68e322a42c2a5945866ac6cf51551c86ed082005d1393d6c32bac9f6dff38df"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7429f3b7110a521c82dab68c8aa6023f95f67b3583781c229da95bad47792ce"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:8ec5b3617953dfd33115c12bd052ea9c6fc9b6e44f298dc5dcbb52c34080350c"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:b815709578fb1d20416f8fbced0dd9c4cd1d2bc890c34026a57618ff8afb217d"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:84febbdb3f6bd85705c03b8537f60f68abea4ac9625ac0121018a7bad2b62616"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-win32.whl", hash = "sha256:50498279faeb349bc8d82d1b2f33d1640ebdef6b7a70a92d1a5e6de0c885410d"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-win_amd64.whl", hash = "sha256:cbe8a2961cf60a4f7f13e379ca97377dfd3d3644f5b2406dc2bc07683e770128"}, + {file = "velopack-0.0.1521.dev61717-cp37-abi3-win_arm64.whl", hash = "sha256:8a042eda1d42f0d7da8edbe2f073a17eba885964505383f505a6dc4435664024"}, + {file = "velopack-0.0.1521.dev61717.tar.gz", hash = "sha256:b6d30caad90bb0c72adb818db61164356ba3e91ca45fa466cbaf55790de57eed"}, ] diff --git a/pyproject.toml b/pyproject.toml index 8e59925..dd2cbb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "packaging>=26.0", "porringer>=0.2.1.dev85", "qasync>=0.28.0", - "velopack>=0.0.1444.dev49733", + "velopack>=0.0.1521.dev61717", "typer>=0.24.1", ] @@ -30,7 +30,7 @@ build = [ ] lint = [ "ruff>=0.15.6", - "pyrefly>=0.56.0", + "pyrefly>=0.57.0", ] test = [ "pytest>=9.0.2", diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index f06e9b1..6d72c21 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -321,12 +321,14 @@ class ConfigKeyInfo: } #: Actions that require a live GUI instance (IPC via ``--live``). -GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ - 'check_update', - 'tool_update', - 'refresh_data', - 'show_main', - 'show_settings', - 'apply_update', - 'select_project', -}) +GUI_ONLY_ACTIONS: frozenset[str] = frozenset( + { + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', + } +) From 527d2160640a9fa40649a54f8fe61ccd562bbf9f Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Mar 2026 10:44:28 -0700 Subject: [PATCH 2/4] Surface Tool Version Information --- pdm.lock | 8 +-- pyproject.toml | 2 +- synodic_client/application/bootstrap.py | 1 + synodic_client/application/package_state.py | 23 +++++++ synodic_client/application/screen/screen.py | 9 +++ .../screen/tool_update_controller.py | 15 ++--- synodic_client/cli/output.py | 8 ++- synodic_client/cli/tool.py | 4 ++ synodic_client/operations/schema.py | 22 +++---- synodic_client/operations/tool.py | 31 +++++++++ tests/unit/operations/test_tool.py | 21 +++++++ tests/unit/qt/test_package_state.py | 63 +++++++++++++++++++ tests/unit/test_cli.py | 23 +++++++ tool/pyinstaller/synodic.spec | 5 ++ 14 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 tests/unit/qt/test_package_state.py diff --git a/pdm.lock b/pdm.lock index fd6ade8..401c4e0 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:7fe4883175ad53f2473a7f6bbc8bb0c50a29bce6d9c9d09922f799b8ae6b191a" +content_hash = "sha256:fa692cc2241180fe0ae2814f1552ab87e376d5b7ae399b7fb0a9db7095faba95" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev85" +version = "0.2.1.dev86" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev85-py3-none-any.whl", hash = "sha256:762a0efda0c54bf0cb04d985be9d3ce42a59cf7b17f1c61f7f0d3c7596d44981"}, - {file = "porringer-0.2.1.dev85.tar.gz", hash = "sha256:4051d933abce66cd77742f7d58ab3a5d0776fc6c33c90c63d6941524df81cec1"}, + {file = "porringer-0.2.1.dev86-py3-none-any.whl", hash = "sha256:a18555e318ee1cff19f987482f42d1b95d950feb7163011ee16b291d7fc189a5"}, + {file = "porringer-0.2.1.dev86.tar.gz", hash = "sha256:aae21088dfb11d133a0ce6fa3ac64b1f43bdaabaee6ed8a44e34362ef718cdc7"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index dd2cbb8..dcadc17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev85", + "porringer>=0.2.1.dev86", "qasync>=0.28.0", "velopack>=0.0.1521.dev61717", "typer>=0.24.1", diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py index 5ec146d..c206a98 100644 --- a/synodic_client/application/bootstrap.py +++ b/synodic_client/application/bootstrap.py @@ -55,6 +55,7 @@ def bootstrap() -> None: run_startup_preamble(sys.executable) # Heavy imports happen here — PySide6, porringer, etc. + from synodic_client.application.qt import application application(uri=extract_uri_from_args(), dev_mode=dev_mode, debug=debug) diff --git a/synodic_client/application/package_state.py b/synodic_client/application/package_state.py index f63bb52..02674a5 100644 --- a/synodic_client/application/package_state.py +++ b/synodic_client/application/package_state.py @@ -123,3 +123,26 @@ def has_data(self) -> bool: def clear(self) -> None: """Remove all recorded state.""" self._data.clear() + + def record_updates_completed( + self, + signal_key: str, + version_map: dict[str, tuple[str, str]], + ) -> None: + """Mark packages as updated, clearing stale ``has_update`` flags. + + Called after a successful tool update run. For each entry in + *version_map* (``{package_name: (old_version, new_version)}``), + the corresponding :class:`PackageState` is updated to reflect + the new installed version and ``has_update`` is cleared. + """ + changed = False + bucket = self._data.get(signal_key, {}) + for pkg_name, (_, new_ver) in version_map.items(): + existing = bucket.get(pkg_name) + if existing is not None: + existing.installed_version = new_ver + existing.has_update = False + changed = True + if changed: + self.state_changed.emit() diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 8844ea2..3219c9c 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -242,6 +242,15 @@ def invalidate_update_data(self) -> None: if self._package_store is not None: self._package_store.clear() + def record_updates_completed( + self, + signal_key: str, + version_map: dict[str, tuple[str, str]], + ) -> None: + """Forward completed-update data to the shared store.""" + if self._package_store is not None: + self._package_store.record_updates_completed(signal_key, version_map) + def refresh(self) -> None: """Schedule an asynchronous rebuild of the tool list.""" if self._refresh_in_progress: diff --git a/synodic_client/application/screen/tool_update_controller.py b/synodic_client/application/screen/tool_update_controller.py index 6eeaac6..32e0bf4 100644 --- a/synodic_client/application/screen/tool_update_controller.py +++ b/synodic_client/application/screen/tool_update_controller.py @@ -380,13 +380,10 @@ def _on_tool_update_finished( target: Which plugin/package was updated. ``None`` for periodic (automatic) updates. """ - logger.info( - 'Tool update completed: %d manifest(s), %d updated, %d already latest, %d failed', - result.manifests_processed, - result.updated, - len(result.already_latest), - result.failed, - ) + # Log summary + per-package version transitions (shared with CLI) + from synodic_client.operations.tool import log_update_result + + log_update_result(result) # Persist timestamps for updated packages if result.updated_packages: @@ -405,6 +402,10 @@ def _on_tool_update_finished( # not None) call show() below which triggers the refresh. tools_view = self._window.tools_view if tools_view is not None: + # Clear stale "update available" badges for packages that were just updated + if result.version_map: + signal_key = target.plugin if target else result.plugin + tools_view.record_updates_completed(signal_key, result.version_map) tools_view.invalidate_update_data() if self._window.isVisible() and target is None: tools_view.refresh() diff --git a/synodic_client/cli/output.py b/synodic_client/cli/output.py index c242e86..d937e27 100644 --- a/synodic_client/cli/output.py +++ b/synodic_client/cli/output.py @@ -37,11 +37,13 @@ def render(data: Any, *, as_json: bool = False) -> None: def _serialise(obj: Any) -> Any: """Recursively convert dataclass instances to dicts.""" if dataclasses.is_dataclass(obj) and not isinstance(obj, type): - return dataclasses.asdict(obj) - if isinstance(obj, list): - return [_serialise(item) for item in obj] + return {k: _serialise(v) for k, v in dataclasses.asdict(obj).items()} if isinstance(obj, dict): return {k: _serialise(v) for k, v in obj.items()} + if isinstance(obj, (set, frozenset)): + return sorted(_serialise(v) for v in obj) + if isinstance(obj, (list, tuple)): + return [_serialise(item) for item in obj] return obj diff --git a/synodic_client/cli/tool.py b/synodic_client/cli/tool.py index 1ab1b47..bd7f479 100644 --- a/synodic_client/cli/tool.py +++ b/synodic_client/cli/tool.py @@ -63,6 +63,10 @@ def tool_update( _, porringer, _ = get_services() result = asyncio.run(_update_tool(porringer, plugin, package, runtime_tag=runtime_tag)) + + from synodic_client.operations.tool import log_update_result + + log_update_result(result) render(result, as_json=json_output) diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index 6d72c21..a7b9f0b 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -235,6 +235,8 @@ class UpdateResult: already_latest: list[str] = field(default_factory=list) manifests_processed: int = 0 updated_packages: set[str] = field(default_factory=set) + version_map: dict[str, tuple[str, str]] = field(default_factory=dict) + """Mapping of ``package_name → (old_version, new_version)``.""" @property def updated(self) -> int: @@ -321,14 +323,12 @@ class ConfigKeyInfo: } #: Actions that require a live GUI instance (IPC via ``--live``). -GUI_ONLY_ACTIONS: frozenset[str] = frozenset( - { - 'check_update', - 'tool_update', - 'refresh_data', - 'show_main', - 'show_settings', - 'apply_update', - 'select_project', - } -) +GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', +}) diff --git a/synodic_client/operations/tool.py b/synodic_client/operations/tool.py index 6c0924f..60db79d 100644 --- a/synodic_client/operations/tool.py +++ b/synodic_client/operations/tool.py @@ -46,6 +46,34 @@ def parse_plugin_key(name: str) -> tuple[str, str | None]: return name, None +def _capture_versions(result: UpdateResult, pkg_name: str, action_result: object) -> None: + """Store before/after version info from *action_result* into *result*.""" + old_ver = getattr(action_result, 'installed_version', '') or '' + new_ver = getattr(action_result, 'available_version', '') or '' + if pkg_name and (old_ver or new_ver): + result.version_map[pkg_name] = (old_ver, new_ver) + + +def log_update_result(result: UpdateResult) -> None: + """Log a human-readable summary of an :class:`UpdateResult`. + + Called by both the GUI controller and the CLI after an update + completes so that version transitions are recorded consistently. + """ + logger.info( + 'Tool update completed: %d manifest(s), %d updated, %d already latest, %d failed', + result.manifests_processed, + result.updated, + len(result.already_latest), + result.failed, + ) + for pkg_name, (old_ver, new_ver) in result.version_map.items(): + if old_ver and new_ver: + logger.info(' %s: %s \u2192 %s', pkg_name, old_ver, new_ver) + elif new_ver: + logger.info(' %s: \u2192 %s', pkg_name, new_ver) + + # --------------------------------------------------------------------------- # Update-check # --------------------------------------------------------------------------- @@ -156,6 +184,7 @@ async def update_tool( elif action_result.success: result.packages_updated.append(package_name) result.updated_packages.add(package_name) + _capture_versions(result, package_name, action_result) else: result.packages_failed.append(package_name) return result @@ -224,6 +253,7 @@ async def update_runtime_plugin( elif ar.success: result.packages_updated.append(pkg_name) result.updated_packages.add(pkg_name) + _capture_versions(result, pkg_name, ar) else: result.packages_failed.append(pkg_name) break @@ -309,6 +339,7 @@ async def update_all_tools( result.packages_updated.append(pkg_name) if pkg_name: result.updated_packages.add(pkg_name) + _capture_versions(result, pkg_name, ar) else: result.packages_failed.append(pkg_name) except asyncio.CancelledError: diff --git a/tests/unit/operations/test_tool.py b/tests/unit/operations/test_tool.py index a18682a..7e8da49 100644 --- a/tests/unit/operations/test_tool.py +++ b/tests/unit/operations/test_tool.py @@ -117,6 +117,8 @@ def test_single_package_success() -> None: action_result = MagicMock() action_result.skipped = False action_result.success = True + action_result.installed_version = '2.31.0' + action_result.available_version = '2.32.0' api.package.upgrade = AsyncMock(return_value=action_result) result = asyncio.run(update_tool(api, 'pip', 'requests')) @@ -124,6 +126,7 @@ def test_single_package_success() -> None: assert result.packages_updated == ['requests'] assert result.already_latest == [] assert result.packages_failed == [] + assert result.version_map == {'requests': ('2.31.0', '2.32.0')} @staticmethod def test_single_package_already_latest() -> None: @@ -135,6 +138,7 @@ def test_single_package_already_latest() -> None: result = asyncio.run(update_tool(api, 'pip', 'requests')) assert result.already_latest == ['requests'] + assert result.version_map == {} @staticmethod def test_single_package_failure() -> None: @@ -147,6 +151,23 @@ def test_single_package_failure() -> None: result = asyncio.run(update_tool(api, 'pip', 'requests')) assert result.packages_failed == ['requests'] + assert result.version_map == {} + + @staticmethod + def test_single_package_success_no_version_data() -> None: + """Upgrading succeeds but porringer provides no version info.""" + api = MagicMock() + action_result = MagicMock() + action_result.skipped = False + action_result.success = True + # Simulate older porringer without version attributes + del action_result.installed_version + del action_result.available_version + api.package.upgrade = AsyncMock(return_value=action_result) + + result = asyncio.run(update_tool(api, 'pip', 'requests')) + assert result.packages_updated == ['requests'] + assert result.version_map == {} # --------------------------------------------------------------------------- diff --git a/tests/unit/qt/test_package_state.py b/tests/unit/qt/test_package_state.py new file mode 100644 index 0000000..11b2e7e --- /dev/null +++ b/tests/unit/qt/test_package_state.py @@ -0,0 +1,63 @@ +"""Tests for PackageStateStore.""" + +from __future__ import annotations + +from synodic_client.application.package_state import PackageStateStore + + +class TestRecordUpdatesCompleted: + """Tests for PackageStateStore.record_updates_completed().""" + + @staticmethod + def test_clears_has_update_flag() -> None: + """Completing an update clears the has_update flag.""" + store = PackageStateStore() + store.set_check_results({'pip': {'requests': '2.32.0'}}) + + state = store.get('pip', 'requests') + assert state is not None + assert state.has_update is True + + store.record_updates_completed('pip', {'requests': ('2.31.0', '2.32.0')}) + + state = store.get('pip', 'requests') + assert state is not None + assert state.has_update is False + assert state.installed_version == '2.32.0' + + @staticmethod + def test_ignores_unknown_packages() -> None: + """Packages not in the store are silently ignored.""" + store = PackageStateStore() + store.set_check_results({'pip': {'requests': '2.32.0'}}) + + store.record_updates_completed('pip', {'unknown-pkg': ('1.0', '2.0')}) + + # Original entry unchanged + state = store.get('pip', 'requests') + assert state is not None + assert state.has_update is True + + @staticmethod + def test_emits_state_changed() -> None: + """Signal fires when at least one package state is updated.""" + store = PackageStateStore() + store.set_check_results({'pip': {'requests': '2.32.0'}}) + + calls: list[bool] = [] + store.state_changed.connect(lambda: calls.append(True)) + + store.record_updates_completed('pip', {'requests': ('2.31.0', '2.32.0')}) + assert len(calls) == 1 + + @staticmethod + def test_no_signal_when_nothing_changed() -> None: + """No signal when version_map has no matching entries.""" + store = PackageStateStore() + store.set_check_results({'pip': {'requests': '2.32.0'}}) + + calls: list[bool] = [] + store.state_changed.connect(lambda: calls.append(True)) + + store.record_updates_completed('pip', {'no-match': ('1.0', '2.0')}) + assert len(calls) == 0 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index dfb850f..27ec243 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -173,6 +173,29 @@ def test_tool_update() -> None: result = runner.invoke(app, ['tool', 'update', 'pip']) assert result.exit_code == 0 + @staticmethod + def test_tool_update_json_with_versions() -> None: + """Tool update --json serialises sets, tuples, and version_map.""" + update_result = UpdateResult( + plugin='pip', + packages_updated=['requests'], + updated_packages={'requests'}, + version_map={'requests': ('2.31.0', '2.32.0')}, + ) + with ( + patch('synodic_client.cli.context.get_services', return_value=(None, MagicMock(), None)), + patch( + 'synodic_client.operations.tool.update_tool', + new_callable=AsyncMock, + return_value=update_result, + ), + ): + result = runner.invoke(app, ['tool', 'update', 'pip', '--json']) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data['updated_packages'] == ['requests'] + assert data['version_map'] == {'requests': ['2.31.0', '2.32.0']} + @staticmethod def test_tool_remove_json() -> None: """Tool remove --json returns valid JSON with success.""" diff --git a/tool/pyinstaller/synodic.spec b/tool/pyinstaller/synodic.spec index e132b41..2c22f71 100644 --- a/tool/pyinstaller/synodic.spec +++ b/tool/pyinstaller/synodic.spec @@ -16,6 +16,11 @@ datas += copy_metadata('porringer') # are bundled without manual spec updates. hiddenimports = collect_submodules('porringer.plugin') +# httpx lazily imports httpcore inside transport constructors. Ensure all +# httpcore submodules are collected so the eager import in bootstrap.py +# fully resolves the module tree. +hiddenimports += collect_submodules('httpcore') + a = Analysis( [str(REPO_ROOT / 'synodic_client' / 'application' / 'bootstrap.py')], pathex=[], From 3a4710c10f1f7c3ee5bf390d63d41045c4668db3 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Mar 2026 10:44:41 -0700 Subject: [PATCH 3/4] Update schema.py --- synodic_client/operations/schema.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index a7b9f0b..92a3fe0 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -323,12 +323,14 @@ class ConfigKeyInfo: } #: Actions that require a live GUI instance (IPC via ``--live``). -GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ - 'check_update', - 'tool_update', - 'refresh_data', - 'show_main', - 'show_settings', - 'apply_update', - 'select_project', -}) +GUI_ONLY_ACTIONS: frozenset[str] = frozenset( + { + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', + } +) From 5ca708cc8daeb07aa45ea83d9f5e856b1e171ff8 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Mar 2026 13:18:39 -0700 Subject: [PATCH 4/4] Project -> Tool Workflow Improvement --- synodic_client/application/config_store.py | 7 +- synodic_client/application/debug.py | 2 +- .../application/screen/action_card.py | 39 ++- synodic_client/application/screen/install.py | 276 ++++++++++++------ .../application/screen/install_workers.py | 87 +++++- synodic_client/application/screen/projects.py | 42 ++- synodic_client/application/screen/schema.py | 31 +- synodic_client/application/screen/screen.py | 88 ++++-- synodic_client/application/theme.py | 16 + synodic_client/cli/__init__.py | 2 + synodic_client/cli/install.py | 213 ++++++++++++++ synodic_client/operations/__init__.py | 15 +- synodic_client/operations/config.py | 23 ++ synodic_client/operations/install.py | 195 ++++++++++++- synodic_client/operations/schema.py | 230 ++++++++++++++- tests/unit/operations/test_config.py | 38 ++- tests/unit/operations/test_install.py | 205 ++++++++++++- tests/unit/operations/test_install_plan.py | 207 +++++++++++++ tests/unit/qt/test_preview_model.py | 76 ++++- tests/unit/test_cli.py | 84 ++++++ 20 files changed, 1678 insertions(+), 198 deletions(-) create mode 100644 synodic_client/cli/install.py create mode 100644 tests/unit/operations/test_install_plan.py diff --git a/synodic_client/application/config_store.py b/synodic_client/application/config_store.py index 86791fb..5c7a97b 100644 --- a/synodic_client/application/config_store.py +++ b/synodic_client/application/config_store.py @@ -10,7 +10,8 @@ from PySide6.QtCore import QObject, Signal -from synodic_client.resolution import ResolvedConfig, resolve_config, update_user_config +from synodic_client.operations.config import get_config, update_config +from synodic_client.schema import ResolvedConfig class ConfigStore(QObject): @@ -33,7 +34,7 @@ class ConfigStore(QObject): def __init__(self, config: ResolvedConfig | None = None, parent: QObject | None = None) -> None: """Create a new store, optionally seeded with *config*.""" super().__init__(parent) - self._config = config if config is not None else resolve_config() + self._config = config if config is not None else get_config() @property def config(self) -> ResolvedConfig: @@ -52,7 +53,7 @@ def update(self, **changes: object) -> ResolvedConfig: Returns: The fresh :class:`ResolvedConfig`. """ - self._config = update_user_config(**changes) + self._config = update_config(**changes) self.changed.emit(self._config) return self._config diff --git a/synodic_client/application/debug.py b/synodic_client/application/debug.py index 95ab61d..83f0f55 100644 --- a/synodic_client/application/debug.py +++ b/synodic_client/application/debug.py @@ -227,7 +227,7 @@ def _handle_project_status(self, arg: str | None) -> str: needed = sum(1 for s in model.action_states if classify_status(s.status) == 'needed') satisfied = sum(1 for s in model.action_states if classify_status(s.status) == 'satisfied') pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending') - upgradable = len(model.upgradable_keys) + upgradable = sum(1 for s in model.action_states if s.status == 'Update available') return json.dumps({ 'path': str(target), diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index a538ec1..c445101 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -17,11 +17,12 @@ from porringer.schema import SetupAction, SetupActionResult from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QCursor, QMouseEvent from PySide6.QtWidgets import ( QApplication, QCheckBox, QFrame, + QGraphicsOpacityEffect, QHBoxLayout, QLabel, QToolButton, @@ -57,6 +58,7 @@ ACTION_CARD_STATUS_UPDATE, ACTION_CARD_STYLE, ACTION_CARD_TYPE_BADGE_STYLE, + ACTION_CARD_UPDATE_AVAILABLE_STYLE, ACTION_CARD_VERSION_STYLE, COPY_BTN_SIZE, COPY_BTN_STYLE, @@ -119,6 +121,10 @@ class ActionCard(QFrame): """Emitted with ``(package_name, checked)`` when the user toggles the per-row pre-release checkbox.""" + navigate_to_tool = Signal(str, str) + """Emitted with ``(installer, package_name)`` when the user clicks an + 'Update available' card to navigate to the Tools view.""" + def __init__( self, parent: QWidget | None = None, @@ -491,6 +497,10 @@ def set_check_result(self, result: SetupActionResult, status: str) -> None: else: self._status_label.setToolTip('') + # "Update available" — fade the card and make it clickable + if status == 'Update available': + self._apply_update_available_style() + # CLI command — update with resolved cli_command from result assert self._action is not None cmd_text = format_cli_command(self._action, result=result, suppress_description=True) @@ -524,6 +534,15 @@ def _update_version_column(self, result: SetupActionResult) -> None: self._version_label.setText(f'\u2192 {result.available_version}') self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: grey;') + def _apply_update_available_style(self) -> None: + """Fade the card and make it clickable for 'Update available' status.""" + self.setStyleSheet(ACTION_CARD_UPDATE_AVAILABLE_STYLE) + opacity = QGraphicsOpacityEffect(self) + opacity.setOpacity(0.55) + self.setGraphicsEffect(opacity) + self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + self.setToolTip('Manage this update in the Tools view') + def finalize_checking(self) -> None: """Resolve a still-pending 'Checking\u2026' status to 'Needed'. @@ -605,6 +624,16 @@ def is_update_available(self) -> bool: """Return whether the card shows an 'Update available' status.""" return self.status_text() == 'Update available' + def mousePressEvent(self, event: QMouseEvent) -> None: + """Navigate to Tools view when clicking an 'Update available' card.""" + if self.is_update_available() and self._action is not None: + installer = self._action.installer or '' + package = str(self._action.package.name) if self._action.package else '' + if installer and package: + self.navigate_to_tool.emit(installer, package) + return + super().mousePressEvent(event) + # --------------------------------------------------------------------------- # ActionCardList — card container @@ -621,6 +650,13 @@ class ActionCardList(QWidget): prerelease_toggled = Signal(str, bool) """Forwarded from child :class:`ActionCard` widgets.""" + navigate_to_tool = Signal(str, str) + """Forwarded from child :class:`ActionCard` widgets. + + Emitted with ``(installer, package_name)`` when the user clicks an + 'Update available' card. + """ + def __init__(self, parent: QWidget | None = None) -> None: """Initialise the card list.""" super().__init__(parent) @@ -680,6 +716,7 @@ def populate( prerelease_overrides=prerelease_overrides, ) card.prerelease_toggled.connect(self.prerelease_toggled.emit) + card.navigate_to_tool.connect(self.navigate_to_tool.emit) self._layout.insertWidget(self._layout.count() - 1, card) self._cards.append(card) self._action_map[act] = card diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 5ada4cb..2281678 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -23,9 +23,7 @@ SetupAction, SetupActionResult, SetupResults, - SkipReason, SubActionProgress, - SyncStrategy, ) from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QShowEvent @@ -46,7 +44,7 @@ from synodic_client.application.package_state import PackageStateStore from synodic_client.application.screen.action_card import ActionCardList from synodic_client.application.screen.card import CardFrame -from synodic_client.application.screen.install_workers import run_install, run_preview +from synodic_client.application.screen.install_workers import run_install, run_post_sync, run_preview from synodic_client.application.screen.log_panel import ExecutionLogPanel from synodic_client.application.screen.schema import ( ActionState, @@ -106,6 +104,10 @@ class SetupPreviewWidget(QWidget): #: Emitted whenever the lifecycle phase changes. phase_changed = Signal(object) # PreviewPhase + #: Emitted with ``(installer, package_name)`` when the user clicks an + #: 'Update available' card to navigate to the Tools view. + navigate_to_tool_requested = Signal(str, str) + def __init__( self, porringer: API, @@ -134,6 +136,7 @@ def __init__( self._model = PreviewModel() self._task: asyncio.Task[None] | None = None + self._install_results: SetupResults | None = None # Debounce timer for per-row pre-release checkbox changes self._prerelease_debounce = QTimer(self) @@ -183,6 +186,7 @@ def _init_ui(self) -> None: self._card_list = ActionCardList() self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled) + self._card_list.navigate_to_tool.connect(self.navigate_to_tool_requested.emit) scroll_layout.addWidget(self._card_list) self._log_panel = ExecutionLogPanel() @@ -249,6 +253,13 @@ def _init_button_bar(self) -> QHBoxLayout: button_bar = QHBoxLayout() button_bar.addStretch() + self._run_commands_btn = QPushButton('Run Commands') + self._run_commands_btn.setToolTip('Execute post-sync commands from the manifest') + self._run_commands_btn.setEnabled(False) + self._run_commands_btn.hide() + self._run_commands_btn.clicked.connect(self._on_run_commands) + button_bar.addWidget(self._run_commands_btn) + self._install_btn = QPushButton('Install') self._install_btn.setEnabled(False) self._install_btn.clicked.connect(self._on_install) @@ -370,6 +381,9 @@ def reset(self) -> None: self._status_label.setText('') self._status_label.setStyleSheet('') self._install_btn.setEnabled(False) + self._run_commands_btn.setEnabled(False) + self._run_commands_btn.hide() + self._install_results = None self._log_panel.clear() self._log_panel.hide() @@ -517,6 +531,7 @@ async def _run_install_task( on_progress=self._on_action_progress, ), plugins=self._discovered_plugins, + exclude_post_sync=self._model.has_post_sync, ) self._on_install_finished(results) except asyncio.CancelledError: @@ -614,20 +629,24 @@ def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_d self.metadata_ready.emit(preview) def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None: - """Update the model and action card with a dry-run result.""" - m = self._model - label = status + """Update the model and action card with a dry-run result. + + This callback performs only two things: + 1. Update the ``ActionState.status`` in the model. + 2. Update the ``ActionCard`` widget visually. - if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE and 0 <= row < len(m.action_states): - m.upgradable_keys.add(m.action_states[row].action) + Cross-component side effects (PackageStateStore writes) are + deferred to :meth:`_on_preview_finished` for one-way data flow. + """ + m = self._model if 0 <= row < len(m.action_states): - m.action_states[row].status = label + m.action_states[row].status = status logger.debug( 'Action checked [%d]: status=%s success=%s skipped=%s skip_reason=%s installed=%s available=%s', row, - label, + status, result.success, result.skipped, result.skip_reason, @@ -637,21 +656,10 @@ def _on_action_checked(self, row: int, result: SetupActionResult, status: str) - # Update the card widget if m.preview and 0 <= row < len(m.preview.actions): - action = m.preview.actions[row] card = self._card_list.card_for_action_index(row) if card is not None: card.set_check_result(result, status) - # Record in shared store so ToolsView can reflect the update - if self._package_store is not None and action.installer and action.package: - self._package_store.record_action_result( - action.installer, - str(action.package.name), - installed_version=result.installed_version or '', - available_version=result.available_version or '', - has_update=result.skip_reason == SkipReason.UPDATE_AVAILABLE, - ) - # Update phase text m.checked_count += 1 total = len(m.action_states) @@ -660,7 +668,15 @@ def _on_action_checked(self, row: int, result: SetupActionResult, status: str) - ) def _on_preview_finished(self) -> None: - """Finalize the preview after the dry-run check completes.""" + """Finalize the preview after the dry-run check completes. + + Computes the :class:`InstallPlan` via the operations layer, + batch-writes to :class:`PackageStateStore`, and updates all + button states. This is the single point where preview results + are materialised into actionable decisions. + """ + from synodic_client.operations.schema import ActionCheckResult, compute_install_plan + m = self._model if not m.action_states: return @@ -679,55 +695,60 @@ def _on_preview_finished(self) -> None: finalized, ) - # Compute summary using the shared operations-layer classifier - from collections import Counter - - from synodic_client.operations.schema import classify_status + # Build check results for the plan computation + check_results: list[ActionCheckResult] = [] + for i, state in enumerate(m.action_states): + # We need the dry-run result — reconstruct a minimal one from the status + # The actual result was already applied to the card; here we use the + # status string which is the canonical output of resolve_action_status. + check_results.append( + ActionCheckResult( + index=i, + action=state.action, + result=SetupActionResult(action=state.action, success=True), + status=state.status, + ), + ) - total = len(m.action_states) - counts = Counter(classify_status(s.status) for s in m.action_states) - needed = counts.get('needed', 0) - satisfied = counts.get('satisfied', 0) - pending = counts.get('pending', 0) - ready = counts.get('ready', 0) - unavailable = counts.get('unavailable', 0) - failed = counts.get('failed', 0) - upgradable = len(m.upgradable_keys) - - parts: list[str] = [] - _counts: list[tuple[int, str]] = [ - (needed, 'needed'), - (upgradable, 'upgradable'), - (satisfied, 'already satisfied'), - (ready, 'ready'), - (pending, 'pending'), - (unavailable, 'unavailable (plugin not installed)'), - (failed, 'failed'), - ] - for count, label in _counts: - if count: - parts.append(f'{count} {label}') - - actionable = needed + upgradable - if actionable == 0 and unavailable == 0 and failed == 0: - self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.') - self._install_btn.setEnabled(False) + plan = compute_install_plan(check_results) + m.install_plan = plan + + # Batch-write to PackageStateStore (one-way, after plan is computed) + if self._package_store is not None and m.preview is not None: + for state in m.action_states: + action = state.action + if action.installer and action.package: + self._package_store.record_action_result( + action.installer, + str(action.package.name), + installed_version='', + available_version='', + has_update=state.status == 'Update available', + ) + + # Update UI from the plan + self._status_label.setText(plan.summary) + self._install_btn.setEnabled(plan.install_enabled) + if not plan.install_enabled: + self._install_btn.setToolTip('No packages to install') else: - self._status_label.setText(f'{total} action(s): {", ".join(parts)}.') + self._install_btn.setToolTip('') + + # Show/enable the Run Commands button if post-sync exists + if plan.has_post_sync: + self._run_commands_btn.show() + self._run_commands_btn.setEnabled(True) self._set_phase(PreviewPhase.READY) logger.info( - 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, ' - '%d ready, %d pending, %d unavailable, %d failed', - total, - needed, - upgradable, - satisfied, - ready, - pending, - unavailable, - failed, + 'Preview complete: %d total, %d to install, %d satisfied, %d upgradable, %d post-sync, install_enabled=%s', + len(m.action_states), + len(plan.install_indices), + len(plan.satisfied_indices), + len(plan.upgradable_indices), + len(plan.post_sync_indices), + plan.install_enabled, ) def _on_preview_error(self, message: str) -> None: @@ -774,14 +795,20 @@ def _show_metadata(self, preview: SetupResults) -> None: # --- Install execution --- def _on_install(self) -> None: - """Handle the Install button click.""" + """Handle the Install button click. + + Uses the pre-computed :class:`InstallPlan` to determine the + sync strategy. Post-sync commands are excluded from this + execution — they are handled by :meth:`_on_run_commands`. + """ m = self._model - if m.manifest_path is None: + if m.manifest_path is None or m.install_plan is None: return self._prerelease_debounce.stop() self._set_phase(PreviewPhase.INSTALLING) self._install_btn.setEnabled(False) + self._run_commands_btn.setEnabled(False) self._close_btn.setEnabled(False) m.completed_count = 0 @@ -791,9 +818,8 @@ def _on_install(self) -> None: self._log_panel.clear() self._log_panel.show() - # Choose LATEST strategy when there are upgradable actions so - # porringer actually upgrades the already-installed packages. - strategy = SyncStrategy.LATEST if m.upgradable_keys else SyncStrategy.MINIMAL + # Strategy and post-sync exclusion come from the plan + strategy = m.install_plan.strategy self._task = asyncio.create_task( self._run_install_task( @@ -852,25 +878,39 @@ def _on_cancel(self) -> None: self._task.cancel() def _on_install_finished(self, results: SetupResults) -> None: - """Handle install completion.""" - self._set_phase(PreviewPhase.DONE) + """Handle install completion. - succeeded = sum(1 for r in results.results if r.success and not r.skipped) - skipped = sum(1 for r in results.results if r.skipped) + If the plan includes post-sync commands and no actions failed, + automatically triggers :meth:`_on_run_commands`. + """ + from synodic_client.operations.schema import format_install_summary + + m = self._model + pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0 failed = sum(1 for r in results.results if not r.success) - parts = [] - if succeeded: - parts.append(f'{succeeded} succeeded') - if skipped: - parts.append(f'{skipped} skipped') - if failed: - parts.append(f'{failed} failed') + # Auto-run post-sync if install succeeded and post-sync exists + if m.has_post_sync and failed == 0: + summary = format_install_summary( + install_results=list(results.results), + pre_skipped_count=pre_skipped, + ) + self._status_label.setText(f'{summary}. Running post-sync commands\u2026') + self._run_commands_btn.setEnabled(False) + self._task = asyncio.create_task(self._run_post_sync_task()) + self._install_results = results # Stash for final summary + return - summary = ', '.join(parts) if parts else 'No actions executed.' - self._status_label.setText(f'Done \u2014 {summary}') + self._set_phase(PreviewPhase.DONE) + summary = format_install_summary( + install_results=list(results.results), + pre_skipped_count=pre_skipped, + ) + self._status_label.setText(summary) self._install_btn.setEnabled(False) self._close_btn.setEnabled(True) + if m.has_post_sync: + self._run_commands_btn.setEnabled(True) self.install_finished.emit(results) def _on_install_error(self, message: str) -> None: @@ -880,6 +920,76 @@ def _on_install_error(self, message: str) -> None: self._install_btn.setEnabled(True) self._close_btn.setEnabled(True) + # --- Post-sync execution --- + + def _on_run_commands(self) -> None: + """Handle the Run Commands button click.""" + m = self._model + if m.manifest_path is None: + return + + self._run_commands_btn.setEnabled(False) + self._install_btn.setEnabled(False) + self._close_btn.setEnabled(False) + + self._status_label.setText('Running post-sync commands\u2026') + + if not self._log_panel.isVisible(): + self._log_panel.clear() + self._log_panel.show() + + self._install_results = None + self._task = asyncio.create_task(self._run_post_sync_task()) + + async def _run_post_sync_task(self) -> None: + """Run the post-sync coroutine and route completion/errors.""" + assert self._model.manifest_path is not None + try: + results = await run_post_sync( + self._porringer, + self._model.manifest_path, + project_directory=self._model.project_directory, + callbacks=InstallCallbacks( + on_action_started=self._on_action_started, + on_sub_progress=self._on_sub_progress, + on_progress=self._on_action_progress, + ), + plugins=self._discovered_plugins, + ) + self._on_post_sync_finished(results) + except asyncio.CancelledError: + self._on_post_sync_finished(SetupResults(actions=[])) + except Exception as exc: + logger.exception('Post-sync execution failed') + self._on_install_error(f'Post-sync failed: {exc}') + + def _on_post_sync_finished(self, results: SetupResults) -> None: + """Handle post-sync completion.""" + from synodic_client.operations.schema import format_install_summary + + m = self._model + m.post_sync_completed = True + m.post_sync_results = list(results.results) + + self._set_phase(PreviewPhase.DONE) + + pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0 + + # If we have stashed install results (auto-run path), use combined summary + install_results = ( + list(self._install_results.results) if hasattr(self, '_install_results') and self._install_results else None + ) + summary = format_install_summary( + install_results=install_results, + post_sync_results=m.post_sync_results, + pre_skipped_count=pre_skipped, + ) + self._status_label.setText(summary) + self._install_btn.setEnabled(False) + self._run_commands_btn.setEnabled(False) + self._close_btn.setEnabled(True) + self.install_finished.emit(results) + # --------------------------------------------------------------------------- # InstallPreviewWindow — standalone URI-based install window diff --git a/synodic_client/application/screen/install_workers.py b/synodic_client/application/screen/install_workers.py index f2f5b71..c51cd71 100644 --- a/synodic_client/application/screen/install_workers.py +++ b/synodic_client/application/screen/install_workers.py @@ -1,7 +1,9 @@ """Async worker coroutines for install and preview operations. -Contains ``run_install``, ``run_preview``, and supporting helpers that -stream porringer events back to the GUI via callbacks. +Contains ``run_install``, ``run_preview``, ``run_post_sync``, and +supporting helpers that stream porringer events back to the GUI via +callbacks. All execution delegates to the operations layer to +avoid direct porringer API coupling. """ from __future__ import annotations @@ -18,7 +20,6 @@ ManifestLoadedEvent, SetupAction, SetupActionResult, - SetupParameters, SetupResults, SubActionProgressEvent, ) @@ -30,7 +31,7 @@ PreviewConfig, ) from synodic_client.application.uri import safe_rmtree -from synodic_client.operations.install import preview_manifest_stream +from synodic_client.operations.install import execute_install, execute_post_sync, preview_manifest_stream from synodic_client.operations.schema import ( PreviewActionChecked, PreviewManifestParsed, @@ -42,7 +43,7 @@ # --------------------------------------------------------------------------- -# run_install — execute setup actions via porringer +# run_install — execute setup actions via operations layer # --------------------------------------------------------------------------- @@ -53,37 +54,91 @@ async def run_install( callbacks: InstallCallbacks | None = None, *, plugins: DiscoveredPlugins | None = None, + exclude_post_sync: bool = False, ) -> SetupResults: - """Execute setup actions via porringer and stream progress. + """Execute setup actions via the operations layer and stream progress. - Runs on the caller's event loop (typically the qasync main-thread - loop). Callbacks are invoked between ``await`` points so the GUI - stays responsive without cross-thread signalling. + Delegates to :func:`~synodic_client.operations.install.execute_install` + and routes the tagged ``(stage, event)`` stream to GUI callbacks. """ cfg = config or InstallConfig() cb = callbacks or InstallCallbacks() - params = SetupParameters( - paths=[manifest_path], + actions: list[SetupAction] = [] + collected: list[SetupActionResult] = [] + manifest_result: SetupResults | None = None + + async for stage, event in execute_install( + porringer, + manifest_path, project_directory=cfg.project_directory, strategy=cfg.strategy, prerelease_packages=cfg.prerelease_packages, + discovered=plugins, + exclude_post_sync=exclude_post_sync, + ): + if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent): + manifest_result = event.manifest + actions = list(event.manifest.actions) + + elif stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None: + cb.on_action_started(event.action) + + elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None: + cb.on_sub_progress(event.action, event.sub_action) + + elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent): + collected.append(event.result) + if cb.on_progress is not None: + cb.on_progress(event.action, event.result) + + return SetupResults( + actions=actions, + results=collected, + manifest_path=manifest_result.manifest_path if manifest_result else None, + metadata=manifest_result.metadata if manifest_result else None, ) + + +# --------------------------------------------------------------------------- +# run_post_sync — execute only post-sync commands via operations layer +# --------------------------------------------------------------------------- + + +async def run_post_sync( + porringer: API, + manifest_path: Path, + *, + project_directory: Path | None = None, + callbacks: InstallCallbacks | None = None, + plugins: DiscoveredPlugins | None = None, +) -> SetupResults: + """Execute only the post-sync commands from a manifest. + + Delegates to :func:`~synodic_client.operations.install.execute_post_sync` + and routes events to GUI callbacks. + """ + cb = callbacks or InstallCallbacks() actions: list[SetupAction] = [] collected: list[SetupActionResult] = [] manifest_result: SetupResults | None = None - async for event in porringer.sync.execute_stream(params, plugins=plugins): - if isinstance(event, ManifestLoadedEvent): + async for stage, event in execute_post_sync( + porringer, + manifest_path, + project_directory=project_directory, + discovered=plugins, + ): + if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent): manifest_result = event.manifest actions = list(event.manifest.actions) - elif isinstance(event, ActionStartedEvent) and cb.on_action_started is not None: + elif stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None: cb.on_action_started(event.action) - elif isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None: + elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None: cb.on_sub_progress(event.action, event.sub_action) - elif isinstance(event, ActionCompletedEvent): + elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent): collected.append(event.result) if cb.on_progress is not None: cb.on_progress(event.action, event.result) diff --git a/synodic_client/application/screen/projects.py b/synodic_client/application/screen/projects.py index 033e108..fad6ef5 100644 --- a/synodic_client/application/screen/projects.py +++ b/synodic_client/application/screen/projects.py @@ -9,7 +9,7 @@ from porringer.api import API from porringer.backend.command.core.discovery import DiscoveredPlugins -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, @@ -42,6 +42,10 @@ class ProjectsView(QWidget): in parallel on first refresh; switching between them is instant. """ + navigate_to_tool_requested = Signal(str, str) + """Emitted with ``(installer, package_name)`` when a child widget + requests navigation to a tool in the Tools view.""" + def __init__( self, porringer: API, @@ -127,14 +131,23 @@ async def _async_refresh(self) -> None: results = snapshot.validated_directories discovered = snapshot.discovered else: + from synodic_client.operations.project import list_projects + loop = asyncio.get_running_loop() - results = await loop.run_in_executor( - None, - lambda: self._porringer.cache.list_directories( - validate=True, - check_manifest=True, - ), - ) + projects = await loop.run_in_executor(None, lambda: list_projects(self._porringer)) + # Convert ProjectInfo list to the same shape as validated_directories + results = [] + for p in projects: + result = type( + '_Result', + (), + { + 'directory': type('_Dir', (), {'path': p.path, 'name': p.name})(), + 'exists': p.exists, + 'has_manifest': p.has_manifest, + }, + )() + results.append(result) discovered = None directories: list[tuple[Path, str, bool]] = [] @@ -207,6 +220,7 @@ def _create_directory_widgets( ) widget._discovered_plugins = discovered widget.install_finished.connect(self._on_install_finished) + widget.navigate_to_tool_requested.connect(self.navigate_to_tool_requested.emit) widget.phase_changed.connect( lambda phase, p=path: self._on_widget_phase_changed(p, phase), ) @@ -244,10 +258,12 @@ def _on_add(self) -> None: directory = selected if selected.is_dir() else selected.parent try: - self._porringer.cache.add_directory(directory) + from synodic_client.operations.project import add_project + + add_project(self._porringer, str(directory)) logger.info('Cached new project directory: %s', directory) - except ValueError: - logger.debug('Directory already cached: %s', directory) + except NotADirectoryError, ValueError: + logger.debug('Directory already cached or invalid: %s', directory) if self._coordinator is not None: self._coordinator.invalidate() @@ -256,7 +272,9 @@ def _on_add(self) -> None: def _on_remove(self, path: Path) -> None: """Remove a directory from the porringer cache.""" - self._porringer.cache.remove_directory(path) + from synodic_client.operations.project import remove_project + + remove_project(self._porringer, str(path)) logger.info('Removed project directory from cache: %s', path) # Tear down the widget immediately diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index e71e8ca..9a9db17 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -25,6 +25,7 @@ from porringer.schema.plugin import RuntimePackageResult from synodic_client.application.uri import normalize_manifest_key +from synodic_client.operations.schema import InstallPlan # --------------------------------------------------------------------------- # Package gathering & display (from screen.py) @@ -250,11 +251,15 @@ def __init__(self) -> None: self.action_states: list[ActionState] = [] self._action_state_map: dict[SetupAction, ActionState] = {} self._action_state_map_len: int = 0 - self.upgradable_keys: set[SetupAction] = set() + self.install_plan: InstallPlan | None = None self.checked_count: int = 0 self.completed_count: int = 0 self.temp_dir: str | None = None + # Post-sync tracking (independent from install) + self.post_sync_completed: bool = False + self.post_sync_results: list[SetupActionResult] | None = None + # -- Computed helpers -------------------------------------------------- def _ensure_action_state_map(self) -> dict[SetupAction, ActionState]: @@ -264,19 +269,25 @@ def _ensure_action_state_map(self) -> dict[SetupAction, ActionState]: self._action_state_map_len = len(self.action_states) return self._action_state_map - @property - def actionable_count(self) -> int: - """Number of needed + upgradable actions.""" - needed = sum(1 for s in self.action_states if s.status == 'Needed') - upgradable = len(self.upgradable_keys) - return needed + upgradable - @property def install_enabled(self) -> bool: - """Whether the install button should be enabled.""" + """Whether the install button should be enabled. + + Delegates to :attr:`install_plan` when available; falls back + to ``False`` when no plan has been computed yet. + """ if self.phase not in {PreviewPhase.READY}: return False - return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states) + if self.install_plan is not None: + return self.install_plan.install_enabled + return False + + @property + def has_post_sync(self) -> bool: + """Whether the manifest has post-sync commands.""" + if self.install_plan is not None: + return self.install_plan.has_post_sync + return False def action_state_for(self, act: SetupAction) -> ActionState | None: """Look up :class:`ActionState` for *act* (O(1) amortized).""" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 3219c9c..e325c8d 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -12,11 +12,8 @@ from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( ManifestDirectory, - ManifestParsedEvent, PluginInfo, SetupAction, - SetupParameters, - SyncStrategy, ) from porringer.schema.plugin import PluginKind, RuntimePackageResult from PySide6.QtCore import QEasingCurve, QEvent, QObject, QPropertyAnimation, Qt, QTimer, Signal @@ -59,8 +56,10 @@ FILTER_TOGGLE_ACTIVE_STYLE, FILTER_TOGGLE_STYLE, MAIN_WINDOW_MIN_SIZE, + PLUGIN_ROW_HIGHLIGHT_STYLE, PLUGIN_ROW_STATUS_AVAILABLE_STYLE, PLUGIN_ROW_STATUS_UP_TO_DATE_STYLE, + PLUGIN_ROW_STYLE, PLUGIN_SECTION_SPACING, SEARCH_INPUT_STYLE, SETTINGS_GEAR_STYLE, @@ -1045,13 +1044,13 @@ async def _gather_project_requirements( ) -> list[SetupAction]: """Load the manifest for *directory* and return its actions. - When a :class:`DataCoordinator` is available the efficient - ``async_load_manifest`` path is used (no streaming, no - ``aclosing`` needed). The legacy ``execute_stream`` path is - kept as a fallback for tests and headless usage. + Delegates to :func:`~synodic_client.operations.install.load_manifest_actions` + in the operations layer, passing pre-discovered plugins when + available for the efficient single-shot path. """ actions: list[SetupAction] = [] try: + from synodic_client.operations.install import load_manifest_actions from synodic_client.operations.project import find_manifest path = Path(directory.path) @@ -1062,25 +1061,12 @@ async def _gather_project_requirements( discovered = self._coordinator.discovered_plugins if self._coordinator else None - if discovered is not None: - # Fast path: single-shot manifest load - result = await self._porringer.sync.async_load_manifest( - manifest_path, - SyncStrategy.MINIMAL, - plugins=discovered, - ) - actions.extend(result.actions) - else: - # Legacy path: stream and break after first parse - params = SetupParameters( - paths=[str(manifest_path)], - dry_run=True, - project_directory=path, - ) - async for event in self._porringer.sync.execute_stream(params): - if isinstance(event, ManifestParsedEvent): - actions.extend(event.manifest.actions) - break + actions = await load_manifest_actions( + self._porringer, + manifest_path, + project_directory=path, + discovered=discovered, + ) except Exception: logger.debug( 'Could not gather requirements for %s', @@ -1293,6 +1279,47 @@ def _refresh_timestamps(self) -> None: if isinstance(widget, PluginRow): widget.update_timestamp() + def navigate_to_package(self, plugin_name: str, package_name: str) -> None: + """Scroll to and briefly highlight a specific package row. + + Opens the filter panel, sets the search text to filter down to the + target package, ensures the plugin chip is active, scrolls the row + into view, and applies a transient amber highlight. + """ + # Find the target row first + target: PluginRow | None = None + for widget in self._section_widgets: + if ( + isinstance(widget, PluginRow) + and widget._plugin_name == plugin_name + and widget._package_name == package_name + ): + target = widget + break + + if target is None: + return + + # Ensure the plugin chip is checked so the row won't be hidden + self._deselected_plugins.discard(plugin_name) + chip = self._filter_chips.get(plugin_name) + if chip is not None: + chip.setChecked(True) + + # Set search text and apply the filter + self._search_input.setText(package_name) + self._apply_filter() + + # Open the filter panel if closed + self._open_filter_panel() + + # Scroll the row into view + self._scroll.ensureWidgetVisible(target) + + # Apply a brief amber highlight + target.setStyleSheet(PLUGIN_ROW_HIGHLIGHT_STYLE) + QTimer.singleShot(2000, lambda: target.setStyleSheet(PLUGIN_ROW_STYLE)) + def set_plugin_updating(self, plugin_name: str, updating: bool) -> None: """Toggle the *Updating…* state on the header for *plugin_name*.""" for widget in self._section_widgets: @@ -1454,6 +1481,9 @@ def show(self) -> None: # Navigate-to-project: switch to Projects tab and select directory self._tools_view.navigate_to_project_requested.connect(self._navigate_to_project) + # Navigate-to-tool: switch to Tools tab and highlight the package + self._projects_view.navigate_to_tool_requested.connect(self._navigate_to_tool) + gear_btn = QPushButton('\u2699') gear_btn.setStyleSheet(SETTINGS_GEAR_STYLE) gear_btn.setToolTip('Settings') @@ -1484,6 +1514,12 @@ def _navigate_to_project(self, path_str: str) -> None: self._tabs.setCurrentIndex(0) self._projects_view._sidebar.select(Path(path_str)) + def _navigate_to_tool(self, plugin_name: str, package_name: str) -> None: + """Switch to the Tools tab and highlight the given package.""" + if self._tabs is not None and self._tools_view is not None: + self._tabs.setCurrentIndex(1) + self._tools_view.navigate_to_package(plugin_name, package_name) + class Screen: """Screen class for the Synodic Client application.""" diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index e96ac34..fe9aad5 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -166,6 +166,11 @@ ) """Compact row for an individual tool or package managed by a plugin.""" +PLUGIN_ROW_HIGHLIGHT_STYLE = ( + 'QFrame#pluginRow { background: #3e3417; border-radius: 4px; padding: 3px 8px 3px 20px;}' +) +"""Brief amber highlight applied when navigating to a specific package row.""" + PLUGIN_ROW_NAME_STYLE = 'font-size: 12px; color: #cccccc;' """Package / tool name in a row.""" @@ -392,6 +397,17 @@ ) """Style for an action card that is currently executing.""" +ACTION_CARD_UPDATE_AVAILABLE_STYLE = ( + 'QFrame#actionCard {' + ' border: 1px solid palette(mid);' + ' border-radius: 4px;' + ' background: palette(window);' + ' padding: 6px 8px;' + ' opacity: 0.6;' + '}' +) +"""Faded style for an action card with an available update (managed in Tools).""" + ACTION_CARD_SKELETON_STYLE = ( 'QFrame#actionCard {' ' border: 1px solid palette(mid);' diff --git a/synodic_client/cli/__init__.py b/synodic_client/cli/__init__.py index 40564bd..d5088f2 100644 --- a/synodic_client/cli/__init__.py +++ b/synodic_client/cli/__init__.py @@ -17,6 +17,7 @@ from synodic_client import __version__ from synodic_client.cli.config import config_app from synodic_client.cli.debug import debug_app +from synodic_client.cli.install import install from synodic_client.cli.project import project_app from synodic_client.cli.tool import tool_app from synodic_client.cli.update import update_app @@ -69,6 +70,7 @@ def main( app.add_typer(project_app, name='project') app.add_typer(tool_app, name='tool') +app.command('install')(install) app.add_typer(config_app, name='config') app.add_typer(update_app, name='update') app.add_typer(debug_app, name='debug') diff --git a/synodic_client/cli/install.py b/synodic_client/cli/install.py new file mode 100644 index 0000000..9b98a32 --- /dev/null +++ b/synodic_client/cli/install.py @@ -0,0 +1,213 @@ +"""Manifest install command.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +import typer + +if TYPE_CHECKING: + from porringer.api import API + from porringer.schema import SetupActionResult, SyncStrategy + + +def install( + manifest: Annotated[ + str, + typer.Argument(help='Path or URL to a porringer manifest file.'), + ], + *, + project_dir: Annotated[ + Path | None, + typer.Option('--project-dir', help='Project directory override.'), + ] = None, + strategy: Annotated[ + str, + typer.Option('--strategy', help='Sync strategy: MINIMAL, LATEST, or EXACT.'), + ] = 'MINIMAL', + prerelease: Annotated[ + list[str] | None, + typer.Option('--prerelease', help='Package names to allow prerelease versions.'), + ] = None, + json_output: Annotated[ + bool, + typer.Option('--json', help='Output results as JSON.'), + ] = False, +) -> None: + """Install packages and run commands from a porringer manifest.""" + from synodic_client.cli.context import get_services + from synodic_client.cli.output import render + + _, porringer, _ = get_services() + + # Resolve strategy enum + from porringer.schema import SyncStrategy + + try: + sync_strategy = SyncStrategy[strategy.upper()] + except KeyError: + typer.echo(f'Unknown strategy: {strategy!r}. Use MINIMAL, LATEST, or EXACT.', err=True) + raise typer.Exit(code=1) from None + + prerelease_packages = set(prerelease) if prerelease else None + + try: + result = asyncio.run( + _run( + porringer, + manifest, + project_directory=project_dir, + strategy=sync_strategy, + prerelease_packages=prerelease_packages, + json_output=json_output, + ), + ) + except FileNotFoundError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + except RuntimeError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + + render(result, as_json=json_output) + + +async def _process_install_stream( + porringer: API, + manifest_path: Path, + *, + project_directory: Path | None, + strategy: SyncStrategy, + prerelease_packages: set[str] | None, + json_output: bool, +) -> tuple[list[SetupActionResult], int]: + """Run the install stream and collect results.""" + from porringer.schema import ActionCompletedEvent, ActionStartedEvent, ManifestLoadedEvent + + from synodic_client.operations.install import execute_install + + results: list[SetupActionResult] = [] + action_count = 0 + + async for stage, event in execute_install( + porringer, + manifest_path, + project_directory=project_directory, + strategy=strategy, + prerelease_packages=prerelease_packages, + ): + if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent): + action_count = len(event.manifest.actions) + if not json_output: + typer.echo(f'Manifest loaded: {action_count} action(s)') + elif stage == 'action_started' and isinstance(event, ActionStartedEvent): + if not json_output: + typer.echo(f' Starting: {event.action.description}') + elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent): + results.append(event.result) + if not json_output: + status = 'OK' if event.result.success else 'FAILED' + if event.result.skipped: + status = 'SKIPPED' + typer.echo(f' {status}: {event.action.description}') + + return results, action_count + + +async def _process_post_sync_stream( + porringer: API, + manifest_path: Path, + *, + project_directory: Path | None, + json_output: bool, +) -> list[SetupActionResult]: + """Run the post-sync stream and collect results.""" + from porringer.schema import ActionCompletedEvent, ActionStartedEvent + + from synodic_client.operations.install import execute_post_sync + + results: list[SetupActionResult] = [] + + if not json_output: + typer.echo('Running post-sync commands...') + + async for stage, event in execute_post_sync( + porringer, + manifest_path, + project_directory=project_directory, + ): + if stage == 'action_started' and isinstance(event, ActionStartedEvent): + if not json_output: + typer.echo(f' Running: {event.action.description}') + elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent): + results.append(event.result) + if not json_output: + status = 'OK' if event.result.success else 'FAILED' + typer.echo(f' {status}: {event.action.description}') + + return results + + +async def _run( + porringer: API, + manifest_url: str, + *, + project_directory: Path | None, + strategy: SyncStrategy, + prerelease_packages: set[str] | None, + json_output: bool, +) -> dict[str, object]: + """Execute the install pipeline and return a summary dict.""" + from synodic_client.operations.install import resolve_manifest_path + from synodic_client.operations.schema import format_install_summary + + manifest_path, temp_dir = await resolve_manifest_path(manifest_url) + + try: + install_results, action_count = await _process_install_stream( + porringer, + manifest_path, + project_directory=project_directory, + strategy=strategy, + prerelease_packages=prerelease_packages, + json_output=json_output, + ) + + # Post-sync phase + has_post_sync = manifest_path.read_text(encoding='utf-8').find('"post_sync"') != -1 + post_sync_results = ( + await _process_post_sync_stream( + porringer, + manifest_path, + project_directory=project_directory, + json_output=json_output, + ) + if has_post_sync + else [] + ) + + summary = format_install_summary( + install_results=install_results or None, + post_sync_results=post_sync_results or None, + ) + + if not json_output: + typer.echo(summary) + + return { + 'manifest': manifest_url, + 'action_count': action_count, + 'install_succeeded': sum(1 for r in install_results if r.success and not r.skipped), + 'install_skipped': sum(1 for r in install_results if r.skipped), + 'install_failed': sum(1 for r in install_results if not r.success), + 'post_sync_succeeded': sum(1 for r in post_sync_results if r.success), + 'post_sync_failed': sum(1 for r in post_sync_results if not r.success), + 'summary': summary, + } + finally: + if temp_dir: + from synodic_client.application.uri import safe_rmtree + + safe_rmtree(temp_dir) diff --git a/synodic_client/operations/__init__.py b/synodic_client/operations/__init__.py index ad3af34..7ce5cc7 100644 --- a/synodic_client/operations/__init__.py +++ b/synodic_client/operations/__init__.py @@ -16,9 +16,11 @@ """ from synodic_client.operations.bootstrap import init_services -from synodic_client.operations.config import get_config, get_config_value, list_config_keys, set_config +from synodic_client.operations.config import get_config, get_config_value, list_config_keys, set_config, update_config from synodic_client.operations.install import ( execute_install, + execute_post_sync, + load_manifest_actions, preview_manifest, preview_manifest_stream, resolve_manifest_path, @@ -35,9 +37,11 @@ DEBUG_ACTIONS, GUI_ONLY_ACTIONS, SKIP_REASON_LABELS, + ActionCheckResult, ActionInfo, ConfigKeyInfo, DownloadResult, + InstallPlan, PackageInfo, PreviewActionChecked, PreviewEvent, @@ -54,6 +58,8 @@ UpdateCheckResult, UpdateResult, classify_status, + compute_install_plan, + format_install_summary, resolve_action_status, skip_reason_label, ) @@ -76,8 +82,11 @@ 'get_config_value', 'list_config_keys', 'set_config', + 'update_config', # install 'execute_install', + 'execute_post_sync', + 'load_manifest_actions', 'preview_manifest', 'preview_manifest_stream', 'resolve_manifest_path', @@ -89,8 +98,10 @@ 'remove_project', 'run_project_action', # schema + 'ActionCheckResult', 'ActionInfo', 'ConfigKeyInfo', + 'InstallPlan', 'DEBUG_ACTIONS', 'DownloadResult', 'GUI_ONLY_ACTIONS', @@ -111,6 +122,8 @@ 'UpdateCheckResult', 'UpdateResult', 'classify_status', + 'compute_install_plan', + 'format_install_summary', 'resolve_action_status', 'skip_reason_label', # tool diff --git a/synodic_client/operations/config.py b/synodic_client/operations/config.py index 073478a..0a22e7b 100644 --- a/synodic_client/operations/config.py +++ b/synodic_client/operations/config.py @@ -64,6 +64,29 @@ def set_config(key: str, value: object) -> ResolvedConfig: return update_user_config(**{key: value}) +def update_config(**changes: object) -> ResolvedConfig: + """Persist multiple configuration changes and return the new config. + + Each key is validated against :class:`ResolvedConfig` fields before + writing. This is the batch equivalent of :func:`set_config`. + + Args: + **changes: Field-name / value pairs. + + Returns: + The updated :class:`ResolvedConfig`. + + Raises: + KeyError: If any key is not a recognised config field. + """ + valid_keys = {f.name for f in dataclasses.fields(ResolvedConfig)} + for key in changes: + if key not in valid_keys: + msg = f'Unknown config key: {key!r}. Valid keys: {sorted(valid_keys)}' + raise KeyError(msg) + return update_user_config(**changes) + + def list_config_keys(config: ResolvedConfig | None = None) -> dict[str, ConfigKeyInfo]: """Return metadata for every configuration key. diff --git a/synodic_client/operations/install.py b/synodic_client/operations/install.py index 81ef83f..734c44e 100644 --- a/synodic_client/operations/install.py +++ b/synodic_client/operations/install.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import os import tempfile from collections.abc import AsyncIterator from pathlib import Path @@ -20,6 +21,7 @@ ManifestParsedEvent, PluginsDiscoveredEvent, ProgressEvent, + SetupAction, SetupParameters, SubActionProgressEvent, SyncStrategy, @@ -245,28 +247,191 @@ async def execute_install( strategy: SyncStrategy = SyncStrategy.MINIMAL, prerelease_packages: set[str] | None = None, discovered: DiscoveredPlugins | None = None, + exclude_post_sync: bool = False, ) -> AsyncIterator[tuple[str, ProgressEvent]]: """Execute setup actions and yield ``(stage, event)`` tuples. Yields ``("action_started", event)``, ``("sub_progress", event)``, ``("action_completed", event)``, etc. so callers can wire up progress tracking without coupling to porringer event kinds. + + Args: + porringer: The porringer API instance. + manifest_path: Path to the manifest file. + project_directory: Optional project directory override. + strategy: Sync strategy (MINIMAL, LATEST, EXACT). + prerelease_packages: Optional prerelease overrides. + discovered: Pre-discovered plugins. + exclude_post_sync: When ``True``, post-sync commands are + stripped from the manifest before execution. Use this + when post-sync is handled separately via + :func:`execute_post_sync`. """ + effective_path = manifest_path + + if exclude_post_sync: + effective_path = _strip_post_sync(manifest_path) + + try: + params = SetupParameters( + paths=[effective_path], + project_directory=project_directory, + strategy=strategy, + prerelease_packages=prerelease_packages, + ) + + async for event in porringer.sync.execute_stream(params, plugins=discovered): + if isinstance(event, ManifestLoadedEvent): + yield ('manifest_loaded', event) + elif isinstance(event, ActionStartedEvent): + yield ('action_started', event) + elif isinstance(event, SubActionProgressEvent): + yield ('sub_progress', event) + elif isinstance(event, ActionCompletedEvent): + yield ('action_completed', event) + else: + yield ('other', event) + finally: + if exclude_post_sync and effective_path != manifest_path: + effective_path.unlink(missing_ok=True) + + +async def execute_post_sync( + porringer: API, + manifest_path: Path, + *, + project_directory: Path | None = None, + discovered: DiscoveredPlugins | None = None, +) -> AsyncIterator[tuple[str, ProgressEvent]]: + """Execute only the post-sync commands from a manifest. + + Builds a temporary manifest containing only the ``post_sync`` + entries from the original, then streams execution events. + + Args: + porringer: The porringer API instance. + manifest_path: Path to the original manifest file. + project_directory: Optional project directory override. + discovered: Pre-discovered plugins. + + Yields: + ``(stage, event)`` tuples identical to :func:`execute_install`. + """ + post_sync_path = _extract_post_sync(manifest_path) + if post_sync_path is None: + return + + try: + params = SetupParameters( + paths=[post_sync_path], + project_directory=project_directory, + ) + + async for event in porringer.sync.execute_stream(params, plugins=discovered): + if isinstance(event, ManifestLoadedEvent): + yield ('manifest_loaded', event) + elif isinstance(event, ActionStartedEvent): + yield ('action_started', event) + elif isinstance(event, SubActionProgressEvent): + yield ('sub_progress', event) + elif isinstance(event, ActionCompletedEvent): + yield ('action_completed', event) + else: + yield ('other', event) + finally: + post_sync_path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Internal helpers for manifest filtering +# --------------------------------------------------------------------------- + + +def _strip_post_sync(manifest_path: Path) -> Path: + """Return a temp manifest copy with ``post_sync`` cleared. + + If the manifest has no ``post_sync`` entries, returns the + original path unchanged. + """ + import json + + data = json.loads(manifest_path.read_text(encoding='utf-8')) + if not data.get('post_sync'): + return manifest_path + + data['post_sync'] = [] + fd, tmp_str = tempfile.mkstemp(prefix='synodic_nosync_', suffix='.json') + tmp = Path(tmp_str) + tmp.write_text(json.dumps(data), encoding='utf-8') + os.close(fd) + return tmp + + +def _extract_post_sync(manifest_path: Path) -> Path | None: + """Return a temp manifest containing only ``post_sync`` entries. + + Returns ``None`` if the manifest has no ``post_sync`` entries. + """ + import json + + data = json.loads(manifest_path.read_text(encoding='utf-8')) + post_sync = data.get('post_sync', []) + if not post_sync: + return None + + minimal = {'version': data.get('version', '1'), 'post_sync': post_sync} + fd, tmp_str = tempfile.mkstemp(prefix='synodic_postsync_', suffix='.json') + tmp = Path(tmp_str) + tmp.write_text(json.dumps(minimal), encoding='utf-8') + os.close(fd) + return tmp + + +# --------------------------------------------------------------------------- +# Manifest action loading (lightweight, no dry-run checking) +# --------------------------------------------------------------------------- + + +async def load_manifest_actions( + porringer: API, + manifest_path: Path, + *, + project_directory: Path | None = None, + discovered: DiscoveredPlugins | None = None, +) -> list[SetupAction]: + """Load the action list from a manifest without dry-run checking. + + When *discovered* plugins are provided, uses the efficient + ``async_load_manifest`` single-shot path. Otherwise falls back + to streaming ``execute_stream`` with ``dry_run=True`` and + extracting actions from the first parse event. + + Args: + porringer: The porringer API instance. + manifest_path: Path to the manifest file. + project_directory: Optional project directory override. + discovered: Pre-discovered plugins for the fast path. + + Returns: + The list of :class:`SetupAction` entries from the manifest. + """ + if discovered is not None: + result = await porringer.sync.async_load_manifest( + manifest_path, + SyncStrategy.MINIMAL, + plugins=discovered, + ) + return list(result.actions) + + # Legacy streaming fallback params = SetupParameters( - paths=[manifest_path], + paths=[str(manifest_path)], + dry_run=True, project_directory=project_directory, - strategy=strategy, - prerelease_packages=prerelease_packages, ) - - async for event in porringer.sync.execute_stream(params, plugins=discovered): - if isinstance(event, ManifestLoadedEvent): - yield ('manifest_loaded', event) - elif isinstance(event, ActionStartedEvent): - yield ('action_started', event) - elif isinstance(event, SubActionProgressEvent): - yield ('sub_progress', event) - elif isinstance(event, ActionCompletedEvent): - yield ('action_completed', event) - else: - yield ('other', event) + actions: list[SetupAction] = [] + async for event in porringer.sync.execute_stream(params): + if isinstance(event, ManifestParsedEvent): + actions.extend(event.manifest.actions) + break + return actions diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index 92a3fe0..7437880 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field -from porringer.schema import PluginCapability, SetupAction, SetupActionResult, SetupResults, SkipReason +from porringer.schema import PluginCapability, SetupAction, SetupActionResult, SetupResults, SkipReason, SyncStrategy from porringer.schema.plugin import PluginKind # --------------------------------------------------------------------------- @@ -78,9 +78,215 @@ def classify_status(status: str) -> str: # --------------------------------------------------------------------------- -# Preview stream events +# Install plan computation # --------------------------------------------------------------------------- +#: Statuses that mean the action is already handled — nothing to install. +_SATISFIED_STATUSES: frozenset[str] = frozenset({'Already installed', 'Already latest'}) + + +@dataclass(frozen=True, slots=True) +class ActionCheckResult: + """A single action's dry-run result paired with its resolved status. + + Mirrors :class:`PreviewActionChecked` but carries the ``action`` + object directly, making it usable outside the streaming context. + """ + + index: int + """Original action index (porringer ordering).""" + + action: SetupAction + """The porringer setup action.""" + + result: SetupActionResult + """Dry-run result for this action.""" + + status: str + """Pre-resolved human-readable status label.""" + + +@dataclass(frozen=True, slots=True) +class InstallPlan: + """Deterministic, immutable plan computed from dry-run results. + + The single source of truth for what the Install button should do, + what gets skipped, and whether post-sync commands exist. Both the + GUI and CLI consume this dataclass without re-deriving the logic. + """ + + install_indices: tuple[int, ...] + """Action indices that need execution (``Needed`` / ``Ready``).""" + + satisfied_indices: tuple[int, ...] + """Action indices already satisfied (``Already installed``/``Already latest``). + + **Display-only** — porringer handles skipping internally. These + indices are used for the UI summary (``pre_skipped_count``) and + ``install_enabled`` determination, not for execution filtering.""" + + upgradable_indices: tuple[int, ...] + """Action indices with updates available — excluded from install.""" + + post_sync_indices: tuple[int, ...] + """Action indices for post-sync commands (``kind is None``).""" + + strategy: SyncStrategy + """Sync strategy to use for the install.""" + + install_enabled: bool + """Whether there are actions worth running an install for.""" + + has_post_sync: bool + """Whether the manifest contains post-sync commands.""" + + summary: str + """Pre-formatted status summary for the UI.""" + + +def _classify_action(cr: ActionCheckResult) -> tuple[str, str | None]: + """Return ``(bucket, list_name)`` for a single check result. + + ``list_name`` is ``'install'``, ``'satisfied'``, ``'upgradable'``, + ``'post_sync'``, or ``None`` (not assigned to an index list). + ``bucket`` is one of the counter keys used for the summary. + """ + bucket = classify_status(cr.status) + + if cr.action.kind is None: + return ('pending' if bucket == 'pending' else 'post_sync_only'), 'post_sync' + + if cr.status == 'Update available': + return 'upgradable', 'upgradable' + if bucket == 'satisfied': + return 'satisfied', 'satisfied' + if bucket in {'needed', 'ready'}: + return bucket, 'install' + if bucket in {'unavailable', 'failed'}: + return bucket, None + # Unknown status — include in install to be safe + return 'needed', 'install' + + +def compute_install_plan(check_results: list[ActionCheckResult]) -> InstallPlan: + """Derive an :class:`InstallPlan` from a completed dry-run. + + This is a **pure function** — no I/O, no side effects. It is the + single place where "what to do" is decided, consumed identically + by the GUI and CLI. + + Args: + check_results: Completed dry-run results for every action. + + Returns: + An immutable :class:`InstallPlan`. + """ + lists: dict[str, list[int]] = { + 'install': [], + 'satisfied': [], + 'upgradable': [], + 'post_sync': [], + } + counts: dict[str, int] = { + 'needed': 0, + 'satisfied': 0, + 'upgradable': 0, + 'pending': 0, + 'ready': 0, + 'unavailable': 0, + 'failed': 0, + } + + for cr in check_results: + bucket, list_name = _classify_action(cr) + if list_name is not None: + lists[list_name].append(cr.index) + if bucket in counts: + counts[bucket] += 1 + + # Build summary text + total = len(check_results) + label_map = { + 'needed': 'needed', + 'upgradable': 'upgradable (manage in Tools)', + 'satisfied': 'already satisfied', + 'ready': 'ready', + 'pending': 'pending', + 'unavailable': 'unavailable (plugin not installed)', + 'failed': 'failed', + } + parts = [f'{counts[k]} {v}' for k, v in label_map.items() if counts[k]] + + actionable = counts['needed'] + counts['ready'] + if actionable == 0 and counts['unavailable'] == 0 and counts['failed'] == 0: + summary = f'{total} action(s) \u2014 all already satisfied.' + else: + summary = f'{total} action(s): {", ".join(parts)}.' + + return InstallPlan( + install_indices=tuple(lists['install']), + satisfied_indices=tuple(lists['satisfied']), + upgradable_indices=tuple(lists['upgradable']), + post_sync_indices=tuple(lists['post_sync']), + strategy=SyncStrategy.MINIMAL, + install_enabled=actionable > 0, + has_post_sync=len(lists['post_sync']) > 0, + summary=summary, + ) + + +def format_install_summary( + install_results: list[SetupActionResult] | None = None, + post_sync_results: list[SetupActionResult] | None = None, + pre_skipped_count: int = 0, +) -> str: + """Build a unified completion summary string. + + Pure function that formats the results of install + post-sync phases + into a single human-readable string. + + Args: + install_results: Results from the install phase (may be ``None`` + if only post-sync was executed). + post_sync_results: Results from the post-sync phase (may be + ``None`` if no post-sync commands exist). + pre_skipped_count: Number of actions pre-skipped (already + satisfied, excluded from execution). + + Returns: + A formatted summary string. + """ + parts: list[str] = [] + + if install_results is not None: + succeeded = sum(1 for r in install_results if r.success and not r.skipped) + skipped = sum(1 for r in install_results if r.skipped) + failed = sum(1 for r in install_results if not r.success) + if succeeded: + parts.append(f'{succeeded} succeeded') + if skipped: + parts.append(f'{skipped} skipped') + if failed: + parts.append(f'{failed} failed') + + if pre_skipped_count: + parts.append(f'{pre_skipped_count} already satisfied') + + install_summary = ', '.join(parts) if parts else 'No actions executed.' + + if post_sync_results is not None: + ps_succeeded = sum(1 for r in post_sync_results if r.success and not r.skipped) + ps_failed = sum(1 for r in post_sync_results if not r.success) + ps_parts: list[str] = [] + if ps_succeeded: + ps_parts.append(f'{ps_succeeded} ran') + if ps_failed: + ps_parts.append(f'{ps_failed} failed') + ps_summary = ', '.join(ps_parts) if ps_parts else 'none ran' + return f'Done \u2014 {install_summary}. Post-sync: {ps_summary}.' + + return f'Done \u2014 {install_summary}' + @dataclass(frozen=True, slots=True) class PreviewManifestParsed: @@ -323,14 +529,12 @@ class ConfigKeyInfo: } #: Actions that require a live GUI instance (IPC via ``--live``). -GUI_ONLY_ACTIONS: frozenset[str] = frozenset( - { - 'check_update', - 'tool_update', - 'refresh_data', - 'show_main', - 'show_settings', - 'apply_update', - 'select_project', - } -) +GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', +}) diff --git a/tests/unit/operations/test_config.py b/tests/unit/operations/test_config.py index 3f614e1..0db5f5f 100644 --- a/tests/unit/operations/test_config.py +++ b/tests/unit/operations/test_config.py @@ -5,7 +5,7 @@ import pytest -from synodic_client.operations.config import get_config, list_config_keys, set_config +from synodic_client.operations.config import get_config, list_config_keys, set_config, update_config from synodic_client.operations.schema import ConfigKeyInfo @@ -75,3 +75,39 @@ def test_returns_all_fields() -> None: assert set(keys.keys()) == expected_fields for info in keys.values(): assert isinstance(info, ConfigKeyInfo) + + +class TestUpdateConfig: + """Tests for update_config().""" + + @staticmethod + def test_unknown_key_raises() -> None: + """Raises KeyError for an invalid config key.""" + with pytest.raises(KeyError, match='Unknown config key'): + update_config(nonexistent_key_xyz='value') + + @staticmethod + def test_delegates_to_update_user_config() -> None: + """Calls update_user_config with all provided kwargs.""" + with patch('synodic_client.operations.config.update_user_config') as mock: + mock_config = object() + mock.return_value = mock_config + from synodic_client.schema import ResolvedConfig + + field_names = [f.name for f in dataclasses.fields(ResolvedConfig)] + min_fields = 2 + if len(field_names) >= min_fields: + key1, key2 = field_names[0], field_names[1] + result = update_config(**{key1: 'a', key2: 'b'}) + mock.assert_called_once_with(**{key1: 'a', key2: 'b'}) + assert result is mock_config + + @staticmethod + def test_validates_all_keys_before_writing() -> None: + """All keys are validated — one bad key rejects the whole batch.""" + from synodic_client.schema import ResolvedConfig + + field_names = [f.name for f in dataclasses.fields(ResolvedConfig)] + if field_names: + with pytest.raises(KeyError, match='Unknown config key'): + update_config(**{field_names[0]: 'ok', 'bad_key_xyz': 'nope'}) diff --git a/tests/unit/operations/test_install.py b/tests/unit/operations/test_install.py index 57aee24..0cb771b 100644 --- a/tests/unit/operations/test_install.py +++ b/tests/unit/operations/test_install.py @@ -3,12 +3,18 @@ from __future__ import annotations import asyncio +import json from pathlib import Path from unittest.mock import MagicMock from porringer.schema import ProgressEvent, SetupParameters -from synodic_client.operations.install import execute_install, preview_manifest +from synodic_client.operations.install import ( + execute_install, + execute_post_sync, + load_manifest_actions, + preview_manifest, +) from synodic_client.operations.schema import ActionInfo, PreviewResult # --------------------------------------------------------------------------- @@ -219,3 +225,200 @@ async def _collect() -> list[tuple[str, ProgressEvent]]: results = asyncio.run(_collect()) assert results == [] + + @staticmethod + def test_exclude_post_sync(tmp_path: Path) -> None: + """exclude_post_sync=True strips post_sync from manifest before execution.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text( + json.dumps({ + 'version': '1', + 'actions': [{'description': 'install something'}], + 'post_sync': [{'command': 'echo hello'}], + }), + encoding='utf-8', + ) + + api = MagicMock() + captured_params: list[SetupParameters] = [] + captured_manifest_data: list[dict] = [] + + async def _capture(params: SetupParameters, **_kw: object): + captured_params.append(params) + # Read the temp manifest before it's cleaned up + assert isinstance(params.paths, (list, tuple)) + path = Path(str(params.paths[0])) + captured_manifest_data.append(json.loads(path.read_text(encoding='utf-8'))) + return + yield + + api.sync.execute_stream = _capture + + async def _run() -> list: + return [item async for item in execute_install(api, manifest, exclude_post_sync=True)] + + asyncio.run(_run()) + + assert len(captured_params) == 1 + # The effective path should differ from the original (temp file) + paths = captured_params[0].paths + assert isinstance(paths, (list, tuple)) + used_path = paths[0] + assert str(used_path) != str(manifest) + # The temp file should have had empty post_sync + assert captured_manifest_data[0]['post_sync'] == [] + + @staticmethod + def test_exclude_post_sync_no_post_sync(tmp_path: Path) -> None: + """exclude_post_sync=True with no post_sync uses original manifest.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text( + json.dumps({'version': '1', 'actions': []}), + encoding='utf-8', + ) + + api = MagicMock() + captured_params: list[SetupParameters] = [] + + async def _capture(params: SetupParameters, **_kw: object): + captured_params.append(params) + return + yield + + api.sync.execute_stream = _capture + + async def _run() -> list: + return [item async for item in execute_install(api, manifest, exclude_post_sync=True)] + + asyncio.run(_run()) + + assert len(captured_params) == 1 + # Should use original path since there's no post_sync to strip + paths = captured_params[0].paths + assert isinstance(paths, (list, tuple)) + used_path = Path(str(paths[0])) + assert used_path == manifest + + +# --------------------------------------------------------------------------- +# execute_post_sync +# --------------------------------------------------------------------------- + + +class TestExecutePostSync: + """Tests for execute_post_sync().""" + + @staticmethod + def test_yields_events_from_post_sync_manifest(tmp_path: Path) -> None: + """Extracts post_sync, executes, and yields events.""" + from porringer.schema import ManifestLoadedEvent + + manifest = tmp_path / 'porringer.json' + manifest.write_text( + json.dumps({ + 'version': '1', + 'actions': [{'description': 'install something'}], + 'post_sync': [{'command': 'echo hello'}], + }), + encoding='utf-8', + ) + + loaded = MagicMock(spec=ManifestLoadedEvent) + api = MagicMock() + + async def _stream(params: SetupParameters, **_kw: object): + yield loaded + + api.sync.execute_stream = _stream + + async def _collect() -> list[tuple[str, ProgressEvent]]: + return [(s, e) async for s, e in execute_post_sync(api, manifest)] + + results = asyncio.run(_collect()) + assert len(results) == 1 + assert results[0] == ('manifest_loaded', loaded) + + @staticmethod + def test_no_post_sync_yields_nothing(tmp_path: Path) -> None: + """No post_sync entries → yields nothing.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text( + json.dumps({'version': '1', 'actions': []}), + encoding='utf-8', + ) + + api = MagicMock() + + async def _collect() -> list[tuple[str, ProgressEvent]]: + return [(s, e) async for s, e in execute_post_sync(api, manifest)] + + results = asyncio.run(_collect()) + assert results == [] + + +# --------------------------------------------------------------------------- +# load_manifest_actions +# --------------------------------------------------------------------------- + + +class TestLoadManifestActions: + """Tests for load_manifest_actions().""" + + @staticmethod + def test_fast_path_with_discovered() -> None: + """Uses async_load_manifest when discovered plugins are provided.""" + api = MagicMock() + action1 = MagicMock() + action2 = MagicMock() + mock_result = MagicMock() + mock_result.actions = [action1, action2] + + async def _mock_load(*_a, **_kw): + return mock_result + + api.sync.async_load_manifest = _mock_load + + discovered = MagicMock() + + actions = asyncio.run( + load_manifest_actions(api, Path('/tmp/manifest.json'), discovered=discovered), + ) + expected_count = 2 + assert len(actions) == expected_count + assert actions[0] is action1 + assert actions[1] is action2 + + @staticmethod + def test_legacy_path_without_discovered() -> None: + """Falls back to execute_stream when no discovered plugins.""" + from porringer.schema import ManifestParsedEvent + + api = MagicMock() + action = MagicMock() + parsed_event = MagicMock(spec=ManifestParsedEvent) + parsed_event.manifest.actions = [action] + + async def _stream(*_a, **_kw): + yield parsed_event + + api.sync.execute_stream = _stream + + actions = asyncio.run( + load_manifest_actions(api, Path('/tmp/manifest.json'), project_directory=Path('/proj')), + ) + assert len(actions) == 1 + assert actions[0] is action + + @staticmethod + def test_empty_manifest_returns_empty_list() -> None: + """Empty stream → empty list.""" + api = MagicMock() + + async def _empty(*_a, **_kw): + return + yield + + api.sync.execute_stream = _empty + + actions = asyncio.run(load_manifest_actions(api, Path('/tmp/manifest.json'))) + assert actions == [] diff --git a/tests/unit/operations/test_install_plan.py b/tests/unit/operations/test_install_plan.py new file mode 100644 index 0000000..fec3cc5 --- /dev/null +++ b/tests/unit/operations/test_install_plan.py @@ -0,0 +1,207 @@ +"""Tests for compute_install_plan and format_install_summary.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from porringer.schema import SetupActionResult, SkipReason, SyncStrategy +from porringer.schema.plugin import PluginKind + +from synodic_client.operations.schema import ( + ActionCheckResult, + InstallPlan, + compute_install_plan, + format_install_summary, +) + + +def _make_check_result( + index: int, + status: str, + *, + kind: PluginKind | None = PluginKind.PACKAGE, + skipped: bool = False, + skip_reason: SkipReason | None = None, + success: bool = True, +) -> ActionCheckResult: + """Build an ActionCheckResult with a mock action and result.""" + action = MagicMock() + action.kind = kind + result = MagicMock(spec=SetupActionResult) + result.success = success + result.skipped = skipped + result.skip_reason = skip_reason + return ActionCheckResult(index=index, action=action, result=result, status=status) + + +# --------------------------------------------------------------------------- +# compute_install_plan +# --------------------------------------------------------------------------- + + +class TestComputeInstallPlan: + """Tests for compute_install_plan().""" + + @staticmethod + def test_empty_results() -> None: + """Empty check results → all empty, install disabled.""" + plan = compute_install_plan([]) + assert plan.install_indices == () + assert plan.satisfied_indices == () + assert plan.upgradable_indices == () + assert plan.post_sync_indices == () + assert plan.install_enabled is False + assert plan.has_post_sync is False + + @staticmethod + def test_all_satisfied() -> None: + """All actions already satisfied → install disabled.""" + results = [ + _make_check_result(0, 'Already installed'), + _make_check_result(1, 'Already latest'), + ] + plan = compute_install_plan(results) + assert plan.install_indices == () + assert plan.satisfied_indices == (0, 1) + assert plan.install_enabled is False + assert 'already satisfied' in plan.summary + + @staticmethod + def test_needed_actions() -> None: + """Needed actions → install enabled, correct indices.""" + results = [ + _make_check_result(0, 'Needed'), + _make_check_result(1, 'Already installed'), + _make_check_result(2, 'Needed'), + ] + plan = compute_install_plan(results) + assert plan.install_indices == (0, 2) + assert plan.satisfied_indices == (1,) + assert plan.install_enabled is True + + @staticmethod + def test_update_available_excluded_from_install() -> None: + """Update-available actions are tracked as upgradable, not install.""" + results = [ + _make_check_result(0, 'Update available'), + _make_check_result(1, 'Needed'), + ] + plan = compute_install_plan(results) + assert plan.upgradable_indices == (0,) + assert plan.install_indices == (1,) + assert plan.install_enabled is True + assert 'upgradable' in plan.summary + + @staticmethod + def test_post_sync_tracked_separately() -> None: + """Post-sync commands (kind=None) are tracked in post_sync_indices.""" + results = [ + _make_check_result(0, 'Needed'), + _make_check_result(1, 'Pending', kind=None), + _make_check_result(2, 'Pending', kind=None), + ] + plan = compute_install_plan(results) + assert plan.post_sync_indices == (1, 2) + assert plan.has_post_sync is True + assert plan.install_indices == (0,) + assert plan.install_enabled is True + + @staticmethod + def test_only_post_sync_means_install_disabled() -> None: + """Only post-sync commands → install disabled, has_post_sync True.""" + results = [ + _make_check_result(0, 'Pending', kind=None), + ] + plan = compute_install_plan(results) + assert plan.install_enabled is False + assert plan.has_post_sync is True + assert plan.post_sync_indices == (0,) + + @staticmethod + def test_ready_actions_included_in_install() -> None: + """Ready (PROJECT kind) actions are included in install.""" + results = [ + _make_check_result(0, 'Ready', kind=PluginKind.PROJECT), + ] + plan = compute_install_plan(results) + assert plan.install_indices == (0,) + assert plan.install_enabled is True + + @staticmethod + def test_strategy_is_always_minimal() -> None: + """Strategy should always be MINIMAL since updates are excluded.""" + results = [_make_check_result(0, 'Needed')] + plan = compute_install_plan(results) + assert plan.strategy == SyncStrategy.MINIMAL + + @staticmethod + def test_unavailable_and_failed_not_in_install() -> None: + """Unavailable and failed actions don't get install indices.""" + results = [ + _make_check_result(0, 'Not installed'), + _make_check_result(1, 'Failed'), + ] + plan = compute_install_plan(results) + assert plan.install_indices == () + assert plan.install_enabled is False + + @staticmethod + def test_plan_is_frozen() -> None: + """InstallPlan should be a frozen dataclass.""" + results = [_make_check_result(0, 'Needed')] + plan = compute_install_plan(results) + assert isinstance(plan, InstallPlan) + + +# --------------------------------------------------------------------------- +# format_install_summary +# --------------------------------------------------------------------------- + + +class TestFormatInstallSummary: + """Tests for format_install_summary().""" + + @staticmethod + def test_no_results() -> None: + """No results at all → 'No actions executed.'""" + summary = format_install_summary() + assert 'No actions executed' in summary + + @staticmethod + def test_install_results_only() -> None: + """Install results without post-sync.""" + r1 = MagicMock(spec=SetupActionResult, success=True, skipped=False) + r2 = MagicMock(spec=SetupActionResult, success=True, skipped=True) + r3 = MagicMock(spec=SetupActionResult, success=False, skipped=False) + summary = format_install_summary(install_results=[r1, r2, r3]) + assert '1 succeeded' in summary + assert '1 skipped' in summary + assert '1 failed' in summary + + @staticmethod + def test_with_pre_skipped() -> None: + """Pre-skipped count is included.""" + summary = format_install_summary(pre_skipped_count=3) + assert '3 already satisfied' in summary + + @staticmethod + def test_with_post_sync() -> None: + """Post-sync results are appended.""" + r1 = MagicMock(spec=SetupActionResult, success=True, skipped=False) + ps1 = MagicMock(spec=SetupActionResult, success=True, skipped=False) + ps2 = MagicMock(spec=SetupActionResult, success=False, skipped=False) + summary = format_install_summary( + install_results=[r1], + post_sync_results=[ps1, ps2], + ) + assert 'Post-sync' in summary + assert '1 ran' in summary + assert '1 failed' in summary + + @staticmethod + def test_post_sync_only() -> None: + """Only post-sync results → includes post-sync section.""" + ps1 = MagicMock(spec=SetupActionResult, success=True, skipped=False) + summary = format_install_summary(post_sync_results=[ps1]) + assert 'Post-sync' in summary + assert '1 ran' in summary diff --git a/tests/unit/qt/test_preview_model.py b/tests/unit/qt/test_preview_model.py index 957cef6..8476103 100644 --- a/tests/unit/qt/test_preview_model.py +++ b/tests/unit/qt/test_preview_model.py @@ -10,6 +10,7 @@ from synodic_client.application.screen.schema import ActionState, PreviewModel, PreviewPhase from synodic_client.application.uri import normalize_manifest_key +from synodic_client.operations.schema import InstallPlan, SyncStrategy # --------------------------------------------------------------------------- # Helpers @@ -87,24 +88,46 @@ def test_install_enabled_false_when_idle() -> None: @staticmethod def test_install_enabled_true_when_ready_with_needed_actions() -> None: - """Install should be enabled when READY and there are needed actions.""" + """Install should be enabled when READY and install_plan says so.""" model = PreviewModel() model.phase = PreviewPhase.READY state = ActionState(action=_make_action()) state.status = 'Needed' model.action_states.append(state) + model.install_plan = InstallPlan( + install_indices=(0,), + satisfied_indices=(), + upgradable_indices=(), + post_sync_indices=(), + strategy=SyncStrategy.MINIMAL, + install_enabled=True, + has_post_sync=False, + summary='1 action(s): 1 needed.', + ) assert model.install_enabled is True @staticmethod - def test_install_enabled_true_when_ready_with_upgradable() -> None: - """Install should be enabled when READY with upgradable actions.""" + def test_install_enabled_false_when_only_upgradable() -> None: + """Install should be disabled when only upgradable actions exist. + + Upgradable actions are managed in the Tools view, not via install. + """ model = PreviewModel() model.phase = PreviewPhase.READY state = ActionState(action=_make_action()) - state.status = 'Already installed' + state.status = 'Update available' model.action_states.append(state) - model.upgradable_keys.add(state.action) - assert model.install_enabled is True + model.install_plan = InstallPlan( + install_indices=(), + satisfied_indices=(), + upgradable_indices=(0,), + post_sync_indices=(), + strategy=SyncStrategy.MINIMAL, + install_enabled=False, + has_post_sync=False, + summary='1 action(s): 1 upgradable (manage in Tools).', + ) + assert model.install_enabled is False @staticmethod def test_install_enabled_false_when_ready_but_all_satisfied() -> None: @@ -117,14 +140,25 @@ def test_install_enabled_false_when_ready_but_all_satisfied() -> None: assert model.install_enabled is False @staticmethod - def test_install_enabled_true_for_command_actions() -> None: - """Command actions (kind=None) are always actionable.""" + def test_has_post_sync_for_command_actions() -> None: + """Command actions (kind=None) are tracked as post-sync.""" model = PreviewModel() model.phase = PreviewPhase.READY state = ActionState(action=_make_action(kind=None, description='Run setup')) - state.status = 'Already installed' + state.status = 'Pending' model.action_states.append(state) - assert model.install_enabled is True + model.install_plan = InstallPlan( + install_indices=(), + satisfied_indices=(), + upgradable_indices=(), + post_sync_indices=(0,), + strategy=SyncStrategy.MINIMAL, + install_enabled=False, + has_post_sync=True, + summary='1 action(s): 1 pending.', + ) + assert model.install_enabled is False + assert model.has_post_sync is True @staticmethod def test_install_enabled_false_when_installing() -> None: @@ -137,9 +171,10 @@ def test_install_enabled_false_when_installing() -> None: assert model.install_enabled is False @staticmethod - def test_actionable_count() -> None: - """Actionable count = needed + upgradable.""" + def test_install_plan_indices_partition() -> None: + """Install plan correctly partitions actions.""" model = PreviewModel() + model.phase = PreviewPhase.READY needed = ActionState(action=_make_action(package='a')) needed.status = 'Needed' satisfied = ActionState(action=_make_action(package='b')) @@ -147,9 +182,20 @@ def test_actionable_count() -> None: upgradable = ActionState(action=_make_action(package='c')) upgradable.status = 'Update available' model.action_states = [needed, satisfied, upgradable] - model.upgradable_keys.add(upgradable.action) - expected_actionable = 2 # 1 needed + 1 upgradable - assert model.actionable_count == expected_actionable + model.install_plan = InstallPlan( + install_indices=(0,), + satisfied_indices=(1,), + upgradable_indices=(2,), + post_sync_indices=(), + strategy=SyncStrategy.MINIMAL, + install_enabled=True, + has_post_sync=False, + summary='3 action(s): 1 needed, 1 upgradable (manage in Tools), 1 already satisfied.', + ) + assert model.install_enabled is True + assert model.install_plan.install_indices == (0,) + assert model.install_plan.satisfied_indices == (1,) + assert model.install_plan.upgradable_indices == (2,) @staticmethod def test_action_state_for_found() -> None: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 27ec243..a28db8b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -409,3 +409,87 @@ def test_debug_gui_only_action_headless() -> None: assert result.exit_code == 1 data = json.loads(result.output) assert '--live' in data['error'] + + +# --------------------------------------------------------------------------- +# Install subcommand +# --------------------------------------------------------------------------- + + +class TestInstallCli: + """Tests for synodic-c install sub-command.""" + + @staticmethod + def test_install_success(tmp_path) -> None: + """Install runs execute_install and prints summary.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{"version":"1","packages":{},"tools":{}}', encoding='utf-8') + + api = MagicMock() + + async def _empty_stream(*_a, **_kw): + return + yield # async generator + + with ( + patch('synodic_client.cli.context.get_services', return_value=(None, api, None)), + patch( + 'synodic_client.operations.install.resolve_manifest_path', + new_callable=AsyncMock, + return_value=(manifest, None), + ), + patch('synodic_client.operations.install.execute_install', return_value=_empty_stream()), + patch('synodic_client.operations.install.execute_post_sync', return_value=_empty_stream()), + ): + result = runner.invoke(app, ['install', str(manifest)]) + assert result.exit_code == 0 + + @staticmethod + def test_install_missing_manifest() -> None: + """Install with missing manifest exits with code 1.""" + with ( + patch('synodic_client.cli.context.get_services', return_value=(None, MagicMock(), None)), + patch( + 'synodic_client.operations.install.resolve_manifest_path', + new_callable=AsyncMock, + side_effect=FileNotFoundError('Manifest not found'), + ), + ): + result = runner.invoke(app, ['install', '/nonexistent/porringer.json']) + assert result.exit_code == 1 + assert 'not found' in result.output.lower() + + @staticmethod + def test_install_json_output(tmp_path) -> None: + """Install --json returns valid JSON.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{"version":"1","packages":{},"tools":{}}', encoding='utf-8') + + api = MagicMock() + + async def _empty_stream(*_a, **_kw): + return + yield + + with ( + patch('synodic_client.cli.context.get_services', return_value=(None, api, None)), + patch( + 'synodic_client.operations.install.resolve_manifest_path', + new_callable=AsyncMock, + return_value=(manifest, None), + ), + patch('synodic_client.operations.install.execute_install', return_value=_empty_stream()), + patch('synodic_client.operations.install.execute_post_sync', return_value=_empty_stream()), + ): + result = runner.invoke(app, ['install', str(manifest), '--json']) + assert result.exit_code == 0 + data = json.loads(result.output) + assert 'summary' in data + + @staticmethod + def test_install_bad_strategy() -> None: + """Invalid --strategy exits with code 1.""" + with patch('synodic_client.cli.context.get_services', return_value=(None, MagicMock(), None)): + result = runner.invoke(app, ['install', '/tmp/m.json', '--strategy', 'BOGUS']) + assert result.exit_code == 1 + assert 'Unknown strategy' in result.output