From 90f54c4ace7a6357030c3134c1033734433cc79d Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 19 Feb 2026 10:19:32 -0800 Subject: [PATCH 1/6] Lint Fixes --- synodic_client/application/screen/install.py | 6 ++++-- synodic_client/application/screen/spinner.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 0f494c6..6c593e0 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -207,8 +207,10 @@ def populate(self, actions: list[SetupAction]) -> None: # Clear previous content while self._content_layout.count(): item = self._content_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() + if item is not None: + widget = item.widget() + if widget is not None: + widget.deleteLater() commands = [(i, a) for i, a in enumerate(actions, 1) if a.kind is None] if not commands: diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index 944a6ce..000492d 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -14,6 +14,7 @@ _PEN = 3 _INTERVAL = 50 _ARC = 90 +_FULL_CIRCLE = 360 class _Canvas(QWidget): @@ -32,11 +33,11 @@ def paintEvent(self, _event: object) -> None: # noqa: N802 m = _PEN // 2 + 1 rect = QRect(m, m, _SIZE - 2 * m, _SIZE - 2 * m) - for colour, span in ((self.palette().mid(), 360), (self.palette().highlight(), _ARC)): + for colour, span in ((self.palette().mid(), _FULL_CIRCLE), (self.palette().highlight(), _ARC)): pen = QPen(colour, _PEN) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) - if span == 360: + if span == _FULL_CIRCLE: painter.drawEllipse(rect) else: painter.drawArc(rect, self._angle * 16, span * 16) @@ -58,6 +59,12 @@ class SpinnerWidget(QWidget): """ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: + """Initialize the spinner. + + Args: + text: Optional label shown beside the spinner arc. + parent: Optional parent widget. + """ super().__init__(parent) self.hide() From afe79a4518a24b21ff368a693797aa3b0bd3ff4a Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 19 Feb 2026 23:09:03 -0800 Subject: [PATCH 2/6] Almost Updates --- pdm.lock | 268 ++++++++------ pyproject.toml | 17 +- synodic_client/application/qt.py | 6 +- synodic_client/application/screen/__init__.py | 1 + synodic_client/application/screen/install.py | 343 +++++++++++++++--- synodic_client/application/screen/screen.py | 52 ++- synodic_client/application/theme.py | 1 + synodic_client/config.py | 13 + tests/unit/qt/test_install_preview.py | 291 +++++++++++++++ tests/unit/test_config.py | 13 + 10 files changed, 838 insertions(+), 167 deletions(-) diff --git a/pdm.lock b/pdm.lock index 68c545a..d20e378 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:20480c81a89571a4be78600a9497f9421eab4a51c003760a9ef02a62e5ad9cbd" +content_hash = "sha256:e595273cf72f67fbc68c94d044a00f2f3eb93e4d5526b81e21e28c4b8bac55f6" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -45,6 +45,22 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.12.1" +requires_python = ">=3.9" +summary = "High-level concurrency and networking framework on top of asyncio or Trio" +groups = ["default"] +dependencies = [ + "exceptiongroup>=1.0.2; python_version < \"3.11\"", + "idna>=2.8", + "typing-extensions>=4.5; python_version < \"3.13\"", +] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -84,95 +100,143 @@ files = [ [[package]] name = "coverage" -version = "7.13.2" +version = "7.13.4" requires_python = ">=3.10" summary = "Code coverage measurement for Python" groups = ["test"] files = [ - {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"}, - {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"}, - {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"}, - {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"}, - {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"}, - {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"}, - {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"}, - {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"}, - {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"}, - {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"}, - {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"}, - {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, + {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, + {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, + {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, + {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, + {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, + {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, + {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, + {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, ] [[package]] name = "coverage" -version = "7.13.2" +version = "7.13.4" extras = ["toml"] requires_python = ">=3.10" summary = "Code coverage measurement for Python" groups = ["test"] dependencies = [ - "coverage==7.13.2", + "coverage==7.13.4", "tomli; python_full_version <= \"3.11.0a6\"", ] files = [ - {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"}, - {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"}, - {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"}, - {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"}, - {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"}, - {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"}, - {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"}, - {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"}, - {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"}, - {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"}, - {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"}, - {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"}, - {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"}, - {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"}, - {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"}, - {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, + {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, + {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, + {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, + {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, + {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, + {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, + {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, + {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, +] + +[[package]] +name = "h11" +version = "0.16.0" +requires_python = ">=3.8" +summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +groups = ["default"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +requires_python = ">=3.8" +summary = "A minimal low-level HTTP client." +groups = ["default"] +dependencies = [ + "certifi", + "h11>=0.16", +] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [[package]] name = "httpx" -version = "1.0.dev3" -requires_python = ">=3.10" -summary = "HTTP, for Python." +version = "0.28.1" +requires_python = ">=3.8" +summary = "The next generation HTTP client." groups = ["default"] dependencies = [ + "anyio", "certifi", + "httpcore==1.*", + "idna", +] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] + +[[package]] +name = "idna" +version = "3.11" +requires_python = ">=3.8" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default"] files = [ - {file = "httpx-1.0.dev3-py3-none-any.whl", hash = "sha256:80b33db1bc8e1fac2a15f419839e324d472d528822608ea6b7a93fed2011722d"}, - {file = "httpx-1.0.dev3.tar.gz", hash = "sha256:e95700e4f9cf6430295f4c195f9cb0ca0549bab4294927f8002bf196851d40db"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [[package]] @@ -250,13 +314,13 @@ files = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.2" requires_python = ">=3.10" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." groups = ["default"] files = [ - {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, - {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, + {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, + {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, ] [[package]] @@ -272,21 +336,21 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev31" +version = "0.2.1.dev41" requires_python = ">=3.14" summary = "" groups = ["default"] dependencies = [ - "httpx>=0.28.1", + "httpx<1.0,>=0.28.1", "packaging>=26.0", - "platformdirs>=4.5.1", + "platformdirs>=4.9.2", "pydantic>=2.12.5", - "typer[all]>=0.21.1", + "typer[all]>=0.24.0", "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev31-py3-none-any.whl", hash = "sha256:54f7fa16b0f082ee213235299a1c1f0bc7bfd7949e6133ce47a88afb14287f6f"}, - {file = "porringer-0.2.1.dev31.tar.gz", hash = "sha256:135a1b90d72faa6b0a9c75b28574908821c22b82587410cfcfac3ce1fa86fff0"}, + {file = "porringer-0.2.1.dev41-py3-none-any.whl", hash = "sha256:f1cdb645c7707e2f3c65c923752c1bbaefd8ddc1228b76ed8a39c0b7f469570d"}, + {file = "porringer-0.2.1.dev41.tar.gz", hash = "sha256:c32cb77e467b69a8a4db0040b41ebb32b06565f403e617c2f17776f3fd092d29"}, ] [[package]] @@ -391,7 +455,7 @@ files = [ [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.0" +version = "2026.1" requires_python = ">=3.8" summary = "Community maintained hooks for PyInstaller" groups = ["build"] @@ -401,8 +465,8 @@ dependencies = [ "setuptools>=42.0.0", ] files = [ - {file = "pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5"}, - {file = "pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e"}, + {file = "pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180"}, + {file = "pyinstaller_hooks_contrib-2026.1.tar.gz", hash = "sha256:a5f0891a1e81e92406ab917d9e76adfd7a2b68415ee2e35c950a7b3910bc361b"}, ] [[package]] @@ -552,7 +616,7 @@ files = [ [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" requires_python = ">=3.8.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" groups = ["default"] @@ -561,46 +625,46 @@ dependencies = [ "pygments<3.0.0,>=2.13.0", ] files = [ - {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, - {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, ] [[package]] name = "ruff" -version = "0.15.1" +version = "0.15.2" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["lint"] files = [ - {file = "ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a"}, - {file = "ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602"}, - {file = "ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f"}, - {file = "ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098"}, - {file = "ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336"}, - {file = "ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416"}, - {file = "ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f"}, + {file = "ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d"}, + {file = "ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e"}, + {file = "ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8"}, + {file = "ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f"}, + {file = "ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5"}, + {file = "ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e"}, + {file = "ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342"}, ] [[package]] name = "setuptools" -version = "80.10.2" +version = "82.0.0" requires_python = ">=3.9" summary = "Easily download, build, install, upgrade, and uninstall Python packages" groups = ["build"] files = [ - {file = "setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"}, - {file = "setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70"}, + {file = "setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0"}, + {file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 99d89fc..ea5a406 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.dev31", + "porringer>=0.2.1.dev41", "qasync>=0.28.0", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", @@ -25,18 +25,9 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = [ - "pyinstaller>=6.19.0", -] -lint = [ - "ruff>=0.15.1", - "pyrefly>=0.53.0", -] -test = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", -] +build = ["pyinstaller>=6.19.0"] +lint = ["ruff>=0.15.2", "pyrefly>=0.53.0"] +test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index b3f5ba1..bd188fe 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -159,7 +159,11 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None: def _handle_install_uri(manifest_url: str) -> None: logger.info('Opening install preview for: %s', manifest_url) - window = InstallPreviewWindow(porringer, manifest_url) + window = InstallPreviewWindow( + porringer, + manifest_url, + config=config, + ) _install_windows.append(window) window.show() window.raise_() diff --git a/synodic_client/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index 3ef623e..1048f9d 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -44,6 +44,7 @@ def plugin_kind_group_label(kind: PluginKind) -> str: SKIP_REASON_LABELS: dict[SkipReason, str] = { SkipReason.ALREADY_INSTALLED: 'Already installed', SkipReason.NO_PROJECT_DIRECTORY: 'No project directory', + SkipReason.UPDATE_AVAILABLE: 'Update available', } diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 6c593e0..ef6fa98 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -28,13 +28,16 @@ SetupActionResult, SetupParameters, SetupResults, + SkipReason, SubActionProgress, + SyncStrategy, ) from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QThread, QTimer, Signal -from PySide6.QtGui import QFont, QKeySequence, QShortcut +from PySide6.QtGui import QColor, QFont, QKeySequence, QShortcut from PySide6.QtWidgets import ( QApplication, + QCheckBox, QFileDialog, QGridLayout, QHBoxLayout, @@ -73,10 +76,31 @@ MUTED_STYLE, NO_MARGINS, ) +from synodic_client.config import GlobalConfiguration, save_config + +#: Amber foreground for "Update available" status cells. +_UPDATE_AVAILABLE_COLOR = QColor('#d7ba7d') logger = logging.getLogger(__name__) +def normalize_manifest_key(path_or_url: str) -> str: + """Return a canonical key for a manifest path or URL. + + Local paths are resolved to absolute form so that the same manifest on + disk always maps to the same config entry regardless of how it was + referenced (relative, symlinked, etc.). Remote URLs are returned + unchanged. + """ + parsed = urlparse(path_or_url) + if parsed.scheme in {'http', 'https'}: + return path_or_url + try: + return str(Path(path_or_url).resolve()) + except Exception: + return path_or_url + + def format_cli_command(action: SetupAction) -> str: """Return a copyable CLI command string for *action*.""" if parts := (action.cli_command or action.command): @@ -99,13 +123,15 @@ class InstallWorker(QThread): sub_progress = Signal(object, object) # (SetupAction, SubActionProgress) error = Signal(str) - def __init__( + def __init__( # noqa: PLR0913 self, porringer: API, manifest_path: Path, cancellation_token: CancellationToken, *, project_directory: Path | None = None, + strategy: SyncStrategy = SyncStrategy.MINIMAL, + prerelease_packages: set[str] | None = None, ) -> None: """Initialize the worker. @@ -114,12 +140,18 @@ def __init__( manifest_path: Path to the manifest file to execute. cancellation_token: Token for cooperative cancellation. project_directory: Working directory for project sync actions. + strategy: Sync strategy — ``LATEST`` when upgrades are pending. + prerelease_packages: Package names whose ``include_prereleases`` + flag should be forced to ``True``, overriding the manifest + default. """ super().__init__() self._porringer = porringer self._manifest_path = manifest_path self._cancellation_token = cancellation_token self._project_directory = project_directory + self._strategy = strategy + self._prerelease_packages = prerelease_packages def run(self) -> None: """Execute the setup actions on this thread's event loop.""" @@ -137,6 +169,8 @@ async def _execute(self) -> SetupResults: params = SetupParameters( paths=[self._manifest_path], project_directory=self._project_directory, + strategy=self._strategy, + prerelease_packages=self._prerelease_packages, ) actions: list[SetupAction] = [] collected: list[SetupActionResult] = [] @@ -296,7 +330,17 @@ class SetupPreviewWidget(QWidget): #: Emitted after a successful install completes. install_finished = Signal(object) # SetupResults - def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close: bool = True) -> None: + #: Emitted when per-item pre-release overrides change (debounced). + prerelease_changed = Signal() + + def __init__( + self, + porringer: API, + parent: QWidget | None = None, + *, + show_close: bool = True, + config: GlobalConfiguration | None = None, + ) -> None: """Initialize the preview widget. Args: @@ -304,10 +348,13 @@ def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close: parent: Optional parent widget. show_close: Whether to show the Close button. Set ``False`` when embedding inside a persistent view (e.g. a tab). + config: Global configuration for per-manifest pre-release state. """ super().__init__(parent) self._porringer = porringer self._show_close = show_close + self._config = config + self._manifest_key: str | None = None self._preview: SetupResults | None = None self._manifest_path: Path | None = None self._project_directory: Path | None = None @@ -315,8 +362,17 @@ def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close: self._cancellation_token: CancellationToken | None = None self._completed_count = 0 self._action_statuses: list[str] = [] + self._upgradable_rows: set[int] = set() self._action_to_table_row: dict[int, int] = {} self._plugin_installed: dict[str, bool] = {} + self._prerelease_overrides: set[str] = set() + self._installing = False + + # Debounce timer for per-row pre-release checkbox changes + self._prerelease_debounce = QTimer(self) + self._prerelease_debounce.setSingleShot(True) + self._prerelease_debounce.setInterval(500) + self._prerelease_debounce.timeout.connect(self._flush_prerelease_overrides) self._init_ui() @@ -340,24 +396,7 @@ def _init_ui(self) -> None: row = 0 # Row 0 — Metadata card (hidden until preview metadata arrives) - self._metadata_card = CardFrame('Project', collapsible=True) - self._metadata_card.hide() - - self._name_label = QLabel() - self._name_label.setStyleSheet(HEADER_STYLE) - self._name_label.hide() - self._metadata_card.content_layout.addWidget(self._name_label) - - self._description_label = QLabel() - self._description_label.setWordWrap(True) - self._description_label.hide() - self._metadata_card.content_layout.addWidget(self._description_label) - - self._meta_label = QLabel() - self._meta_label.setStyleSheet(MUTED_STYLE) - self._meta_label.hide() - self._metadata_card.content_layout.addWidget(self._meta_label) - + self._init_metadata_card() grid.addWidget(self._metadata_card, row, 0, 1, 2) row += 1 @@ -402,11 +441,33 @@ def _init_ui(self) -> None: button_bar = self._init_button_bar() grid.addLayout(button_bar, row, 0, 1, 2) + def _init_metadata_card(self) -> None: + """Create the metadata card (hidden until preview metadata arrives).""" + self._metadata_card = CardFrame('Project', collapsible=True) + self._metadata_card.hide() + + self._name_label = QLabel() + self._name_label.setStyleSheet(HEADER_STYLE) + self._name_label.hide() + self._metadata_card.content_layout.addWidget(self._name_label) + + self._description_label = QLabel() + self._description_label.setWordWrap(True) + self._description_label.hide() + self._metadata_card.content_layout.addWidget(self._description_label) + + self._meta_label = QLabel() + self._meta_label.setStyleSheet(MUTED_STYLE) + self._meta_label.hide() + self._metadata_card.content_layout.addWidget(self._meta_label) + def _init_actions_table(self) -> QTableWidget: """Create and configure the actions table widget.""" table = QTableWidget() - table.setColumnCount(5) - table.setHorizontalHeaderLabels(['Type', 'Plugin', 'Package', 'Description', 'Status']) + table.setColumnCount(7) + table.setHorizontalHeaderLabels( + ['Type', 'Plugin', 'Package', 'Version', 'Description', 'Status', 'Pre-release'], + ) table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) table.setAlternatingRowColors(True) @@ -414,9 +475,12 @@ def _init_actions_table(self) -> QTableWidget: header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) - QShortcut(QKeySequence.StandardKey.Copy, table, self._copy_table_selection) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) + copy_sc = QShortcut(QKeySequence.StandardKey.Copy, table, self._copy_table_selection) + copy_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) return table def _init_button_bar(self) -> QHBoxLayout: @@ -439,6 +503,30 @@ def _init_button_bar(self) -> QHBoxLayout: # --- Public API --- + @property + def prerelease_overrides(self) -> set[str] | None: + """Return the current per-item pre-release overrides. + + Returns ``None`` when no user overrides are active. The + returned set contains canonical (lowered) package names. + """ + return self._prerelease_overrides or None + + def set_manifest_key(self, key: str) -> None: + """Set the manifest key and load persisted pre-release overrides. + + The key is normalised so that equivalent paths always resolve + to the same config entry. + + Args: + key: Manifest path or URL identifying this preview. + """ + self._manifest_key = normalize_manifest_key(key) + if self._config is not None and self._config.prerelease_packages: + self._prerelease_overrides = set(self._config.prerelease_packages.get(self._manifest_key, [])) + else: + self._prerelease_overrides = set() + def set_project_directory(self, path: Path) -> None: """Set the project directory used for install execution. @@ -451,12 +539,17 @@ def reset(self) -> None: """Clear all state and UI for a fresh preview.""" self._preview = None self._manifest_path = None + self._manifest_key = None self._runner = None self._cancellation_token = None self._completed_count = 0 self._action_statuses = [] + self._upgradable_rows = set() self._action_to_table_row = {} self._plugin_installed = {} + self._prerelease_overrides = set() + self._installing = False + self._prerelease_debounce.stop() self._table.setRowCount(0) self._post_install_section.hide() @@ -495,6 +588,40 @@ def show_not_found(self, message: str) -> None: self._status_label.setText(message) self._status_label.setStyleSheet(MUTED_STYLE) + # --- Per-item pre-release overrides --- + + def _on_prerelease_row_toggled(self, package_name: str, checked: bool) -> None: + """Handle a per-row pre-release checkbox toggle.""" + key = package_name.lower() + if checked: + self._prerelease_overrides.add(key) + else: + self._prerelease_overrides.discard(key) + self._prerelease_debounce.start() + + def _flush_prerelease_overrides(self) -> None: + """Persist overrides to config and emit the changed signal. + + Skips the signal emission (but still saves the config) while an + install is in progress to prevent the parent from reloading the + preview and wiping the execution log. + """ + if self._config is None or self._manifest_key is None: + return + + pkgs = self._config.prerelease_packages or {} + if self._prerelease_overrides: + pkgs[self._manifest_key] = sorted(self._prerelease_overrides) + else: + pkgs.pop(self._manifest_key, None) + + self._config.prerelease_packages = pkgs if pkgs else None + save_config(self._config) + logger.info('Pre-release overrides for %s: %s', self._manifest_key, self._prerelease_overrides) + + if not self._installing: + self.prerelease_changed.emit() + # --- Preview callbacks (connect to PreviewWorker signals) --- def on_plugins_queried(self, mapping: dict[str, bool]) -> None: @@ -537,7 +664,16 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p def on_action_checked(self, row: int, result: SetupActionResult) -> None: """Update the data model and table row with the dry-run result.""" - label = skip_reason_label(result.skip_reason) if result.skipped else 'Needed' + if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: + label = skip_reason_label(result.skip_reason) + color = _UPDATE_AVAILABLE_COLOR + self._upgradable_rows.add(row) + elif result.skipped: + label = skip_reason_label(result.skip_reason) + color = self.palette().placeholderText().color() + else: + label = 'Needed' + color = self.palette().text().color() if 0 <= row < len(self._action_statuses): self._action_statuses[row] = label @@ -547,15 +683,23 @@ def on_action_checked(self, row: int, result: SetupActionResult) -> None: if table_row is None: return - item = self._table.item(table_row, 4) + # --- Version column (col 3) --- + version_item = self._table.item(table_row, 3) + if version_item is not None: + if result.installed_version and result.available_version: + version_item.setText(f'{result.installed_version} \u2192 {result.available_version}') + version_item.setForeground(_UPDATE_AVAILABLE_COLOR) + elif result.installed_version: + version_item.setText(result.installed_version) + version_item.setForeground(self.palette().text()) + + # --- Status column (col 5) --- + item = self._table.item(table_row, 5) if item is None: return item.setText(label) - if result.skipped: - item.setForeground(self.palette().placeholderText()) - else: - item.setForeground(self.palette().text()) + item.setForeground(color) def on_preview_finished(self) -> None: """Finalize the preview after the dry-run check completes.""" @@ -569,7 +713,7 @@ def on_preview_finished(self) -> None: self._action_statuses[i] = 'Needed' table_row = self._action_to_table_row.get(i) if table_row is not None: - item = self._table.item(table_row, 4) + item = self._table.item(table_row, 5) if item is not None: item.setText('Needed') item.setForeground(self.palette().text()) @@ -577,27 +721,32 @@ def on_preview_finished(self) -> None: # Count ALL actions (including bare commands) for enablement. total = len(self._action_statuses) needed = sum(1 for s in self._action_statuses if s == 'Needed') + upgradable = len(self._upgradable_rows) unavailable = sum(1 for s in self._action_statuses if s == 'Not installed') - satisfied = total - needed - unavailable + satisfied = total - needed - upgradable - unavailable parts: list[str] = [] if needed: parts.append(f'{needed} needed') + if upgradable: + parts.append(f'{upgradable} upgradable') if satisfied: parts.append(f'{satisfied} already satisfied') if unavailable: parts.append(f'{unavailable} unavailable (plugin not installed)') - if needed == 0 and unavailable == 0: + actionable = needed + upgradable + if actionable == 0 and unavailable == 0: self._status_label.setText(f'{total} action(s) — all already satisfied.') self._install_btn.setEnabled(False) else: self._status_label.setText(f'{total} action(s): {", ".join(parts)}.') logger.info( - 'Preview complete: %d total, %d needed, %d satisfied, %d unavailable', + 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable', total, needed, + upgradable, satisfied, unavailable, ) @@ -677,7 +826,13 @@ def _populate_table(self, actions: list[SetupAction]) -> None: self._table.setItem(table_row, 0, QTableWidgetItem(ACTION_KIND_LABELS.get(action.kind, 'Action'))) self._table.setItem(table_row, 1, QTableWidgetItem(action.installer or '')) self._table.setItem(table_row, 2, QTableWidgetItem(str(action.package) if action.package else '')) - self._table.setItem(table_row, 3, QTableWidgetItem(action.package_description or action.description)) + + # Version column — populated later by on_action_checked + version_item = QTableWidgetItem('') + version_item.setForeground(self.palette().placeholderText()) + self._table.setItem(table_row, 3, version_item) + + self._table.setItem(table_row, 4, QTableWidgetItem(action.package_description or action.description)) # Check whether the installer plugin is present on the system. installer_missing = ( @@ -693,7 +848,31 @@ def _populate_table(self, actions: list[SetupAction]) -> None: status_item = QTableWidgetItem('Checking…') status_item.setForeground(self.palette().placeholderText()) - self._table.setItem(table_row, 4, status_item) + self._table.setItem(table_row, 5, status_item) + + # Per-row pre-release checkbox (only for actions with a package) + if action.package is not None: + cb = QCheckBox() + pkg_name = str(action.package.name) + is_user_override = pkg_name.lower() in self._prerelease_overrides + if action.include_prereleases and not is_user_override: + # Manifest already enables pre-releases — show checked and locked + cb.setChecked(True) + cb.setEnabled(False) + cb.setToolTip('Enabled by manifest') + else: + # User-togglable: either an explicit override or default off + cb.setChecked(is_user_override) + cb.setToolTip('Include pre-release versions for this package') + cb.toggled.connect(lambda checked, name=pkg_name: self._on_prerelease_row_toggled(name, checked)) + + # Centre the checkbox in the cell + wrapper = QWidget() + layout = QHBoxLayout(wrapper) + layout.addWidget(cb) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(0, 0, 0, 0) + self._table.setCellWidget(table_row, 6, wrapper) # Populate the always-visible post-install commands section self._post_install_section.populate(actions) @@ -705,6 +884,8 @@ def _on_install(self) -> None: if self._manifest_path is None: return + self._installing = True + self._prerelease_debounce.stop() self._install_btn.setEnabled(False) self._close_btn.setEnabled(False) self._completed_count = 0 @@ -716,12 +897,18 @@ def _on_install(self) -> None: self._log_card.show() self._status_label.setText('Installing…') + # Choose LATEST strategy when there are upgradable actions so + # porringer actually upgrades the already-installed packages. + strategy = SyncStrategy.LATEST if self._upgradable_rows else SyncStrategy.MINIMAL + # Worker thread worker = InstallWorker( self._porringer, self._manifest_path, self._cancellation_token, project_directory=self._project_directory, + strategy=strategy, + prerelease_packages=self._prerelease_overrides or None, ) worker.action_started.connect(self._on_action_started) worker.sub_progress.connect(self._on_sub_progress) @@ -761,8 +948,8 @@ def _on_action_progress(self, action: SetupAction, result: SetupActionResult) -> self._status_label.setText(f'Installing… ({self._completed_count}/{total})') def _update_table_status(self, row: int, result: SetupActionResult) -> None: - """Update the status cell for a table row from an action result.""" - item = self._table.item(row, 4) + """Update the status and version cells for a table row from an action result.""" + item = self._table.item(row, 5) if item is None: return @@ -772,6 +959,13 @@ def _update_table_status(self, row: int, result: SetupActionResult) -> None: elif result.success: item.setText('Done') item.setForeground(self.palette().text()) + + # When an upgrade completes, update the Version column to show + # the new version instead of the stale transition arrow. + version_item = self._table.item(row, 3) + if version_item is not None and result.available_version: + version_item.setText(result.available_version) + version_item.setForeground(self.palette().text()) else: item.setText(f'Failed: {result.message}' if result.message else 'Failed') item.setForeground(self.palette().text()) @@ -783,6 +977,8 @@ def _on_cancel(self) -> None: def _on_install_finished(self, results: SetupResults) -> None: """Handle install completion.""" + self._installing = False + 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) failed = sum(1 for r in results.results if not r.success) @@ -803,6 +999,7 @@ def _on_install_finished(self, results: SetupResults) -> None: def _on_install_error(self, message: str) -> None: """Handle install error.""" + self._installing = False self._status_label.setText(f'Install failed: {message}') self._install_btn.setEnabled(True) self._close_btn.setEnabled(True) @@ -820,17 +1017,27 @@ class InstallPreviewWindow(QMainWindow): (temp directory, ``PreviewWorker``). """ - def __init__(self, porringer: API, manifest_url: str, parent: QWidget | None = None) -> None: + def __init__( + self, + porringer: API, + manifest_url: str, + parent: QWidget | None = None, + *, + config: GlobalConfiguration | None = None, + ) -> None: """Initialize the install preview window. Args: porringer: The porringer API instance. manifest_url: The URL of the manifest to install. parent: Optional parent widget. + config: Resolved global configuration for per-manifest pre-release + state and update detection flags. """ super().__init__(parent) self._porringer = porringer self._manifest_url = manifest_url + self._config = config or GlobalConfiguration() self._temp_dir_path: str | None = None self._runner: QThread | None = None @@ -861,8 +1068,9 @@ def _init_ui(self) -> None: layout.addWidget(source_card) # Shared preview widget - self._preview_widget = SetupPreviewWidget(self._porringer, self) + self._preview_widget = SetupPreviewWidget(self._porringer, self, config=self._config) self._preview_widget.close_requested.connect(self.close) + self._preview_widget.prerelease_changed.connect(self._on_prerelease_changed) self._preview_widget.set_project_directory(self._project_directory) layout.addWidget(self._preview_widget) @@ -927,11 +1135,23 @@ def start(self) -> None: Call this after ``show()`` to begin the download → preview flow. """ + self._stop_preview() logger.info('Starting install preview for: %s', self._manifest_url) self._url_label.setText(f'Manifest: {self._manifest_url}') + self._preview_widget.reset() self._preview_widget.start_loading() + self._preview_widget.set_manifest_key(self._manifest_url) + + manifest_key = normalize_manifest_key(self._manifest_url) + overrides = set((self._config.prerelease_packages or {}).get(manifest_key, [])) - preview_worker = PreviewWorker(self._porringer, self._manifest_url, project_directory=self._project_directory) + preview_worker = PreviewWorker( + self._porringer, + self._manifest_url, + project_directory=self._project_directory, + detect_updates=self._config.detect_updates, + prerelease_packages=overrides or None, + ) preview_worker.plugins_queried.connect(self._preview_widget.on_plugins_queried) preview_worker.preview_ready.connect(self._on_preview_ready) @@ -954,6 +1174,17 @@ def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_ self._preview_widget.on_preview_ready(preview, manifest_path, temp_dir_path) + def _on_prerelease_changed(self) -> None: + """Re-run the preview with the updated pre-release setting.""" + self.start() + + def _stop_preview(self) -> None: + """Wait for any running preview worker to finish before starting a new one.""" + if self._runner is not None and self._runner.isRunning(): + self._runner.quit() + self._runner.wait() + self._runner = None + class PreviewWorker(QThread): """Background worker that downloads a manifest and performs a dry-run. @@ -970,12 +1201,32 @@ class PreviewWorker(QThread): finished = Signal() error = Signal(str) - def __init__(self, porringer: API, url: str, *, project_directory: Path | None = None) -> None: - """Initialize the preview worker.""" + def __init__( + self, + porringer: API, + url: str, + *, + project_directory: Path | None = None, + detect_updates: bool = True, + prerelease_packages: set[str] | None = None, + ) -> None: + """Initialize the preview worker. + + Args: + porringer: The porringer API instance. + url: Manifest URL or local path. + project_directory: Working directory for project sync actions. + detect_updates: Query package indices for newer versions. + prerelease_packages: Package names whose ``include_prereleases`` + flag should be forced to ``True``, overriding the manifest + default. + """ super().__init__() self._porringer = porringer self._url = url self._project_directory = project_directory + self._detect_updates = detect_updates + self._prerelease_packages = prerelease_packages def run(self) -> None: """Download the manifest and perform a dry-run to check status.""" @@ -1026,6 +1277,8 @@ async def _dry_run(self, manifest_path: Path, temp_dir: str) -> None: paths=[manifest_path], dry_run=True, project_directory=self._project_directory, + detect_updates=self._detect_updates, + prerelease_packages=self._prerelease_packages, ) action_index: dict[int, int] = {} diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 3d28027..eee7a85 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -32,7 +32,11 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen import plugin_kind_group_label from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader -from synodic_client.application.screen.install import PreviewWorker, SetupPreviewWidget +from synodic_client.application.screen.install import ( + PreviewWorker, + SetupPreviewWidget, + normalize_manifest_key, +) from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.theme import ( CARD_SPACING, @@ -505,15 +509,17 @@ class ProjectsView(QWidget): install execution. """ - def __init__(self, porringer: API, parent: QWidget | None = None) -> None: + def __init__(self, porringer: API, config: GlobalConfiguration, parent: QWidget | None = None) -> None: """Initialize the projects view. Args: porringer: The porringer API instance. + config: Resolved global configuration. parent: Optional parent widget. """ super().__init__(parent) self._porringer = porringer + self._config = config self._runner: QThread | None = None self._refresh_in_progress = False self._init_ui() @@ -550,8 +556,9 @@ def _init_ui(self) -> None: grid.addWidget(self._remove_btn, 0, 2) # Row 1 — Shared preview widget (takes majority of space) - self._preview = SetupPreviewWidget(self._porringer, self, show_close=False) + self._preview = SetupPreviewWidget(self._porringer, self, show_close=False, config=self._config) self._preview.install_finished.connect(self._on_install_finished) + self._preview.prerelease_changed.connect(self._on_prerelease_changed) grid.addWidget(self._preview, 1, 0, 1, 3) grid.setRowStretch(1, 1) @@ -677,7 +684,12 @@ def _on_remove(self) -> None: self.refresh() def _on_install_finished(self, _results: object) -> None: - """Register a new path in the cache after successful install.""" + """Register a new path in the cache after successful install. + + The directory is added to the porringer cache and the combo box + is updated *without* reloading the preview, so the execution + log remains visible. + """ current_text = self._combo.currentText().strip() if not current_text: return @@ -689,10 +701,27 @@ def _on_install_finished(self, _results: object) -> None: try: self._porringer.cache.add_directory(Path(current_text)) logger.info('Registered new project directory: %s', current_text) - self.refresh() + # Add the entry to the combo inline so the Remove button + # works without a full refresh that would wipe the log. + new_idx = self._combo.count() + self._combo.addItem(current_text) + self._combo.setItemData(new_idx, current_text, Qt.ItemDataRole.UserRole) + self._combo.setCurrentIndex(new_idx) + self._update_remove_btn() except ValueError: logger.debug('Directory already cached or invalid: %s', current_text) + def _on_prerelease_changed(self) -> None: + """Re-load the preview with the updated pre-release setting.""" + self._load_preview() + + def _stop_preview(self) -> None: + """Wait for any running preview worker to finish before starting a new one.""" + if self._runner is not None and self._runner.isRunning(): + self._runner.quit() + self._runner.wait() + self._runner = None + # --- Preview loading --- def _load_preview(self) -> None: @@ -703,6 +732,7 @@ def _load_preview(self) -> None: selected_path = Path(path_text) + self._stop_preview() self._preview.reset() if not selected_path.exists(): @@ -715,12 +745,22 @@ def _load_preview(self) -> None: self._preview.start_loading() + # Set the manifest key so per-item pre-release checkboxes + # reflect persisted overrides for this specific manifest. + self._preview.set_manifest_key(str(selected_path)) + + # Build prerelease_packages from persisted overrides + manifest_key = normalize_manifest_key(str(selected_path)) + overrides = set((self._config.prerelease_packages or {}).get(manifest_key, [])) + # Defer project directory assignment until the preview result # provides root_directory — handles both file and directory inputs. preview_worker = PreviewWorker( self._porringer, str(selected_path), project_directory=selected_path if selected_path.is_dir() else None, + detect_updates=self._config.detect_updates, + prerelease_packages=overrides or None, ) preview_worker.preview_ready.connect(self._on_preview_ready) preview_worker.action_checked.connect(self._preview.on_action_checked) @@ -789,7 +829,7 @@ def show(self) -> None: if self._tabs is None and self._porringer is not None: self._tabs = QTabWidget(self) - self._projects_view = ProjectsView(self._porringer, self) + self._projects_view = ProjectsView(self._porringer, self._config, self) self._tabs.addTab(self._projects_view, 'Projects') self._plugins_view = PluginsView(self._porringer, self._config, self) diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index a02e3dc..36f1fc8 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -140,6 +140,7 @@ ' min-width: 60px; max-width: 60px; }' 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }' ) + PLUGIN_SECTION_SPACING = 4 """Pixels between plugin sections in the scroll area.""" diff --git a/synodic_client/config.py b/synodic_client/config.py index be502d5..b7144b5 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -76,6 +76,19 @@ class _ConfigBase(BaseModel): # entries disable auto-update for that plugin. plugin_auto_update: dict[str, bool] | None = None + # Check for updates during dry-run previews. When True the preview + # will query package indices for newer versions. + detect_updates: bool = True + + # Per-manifest pre-release overrides. Outer key is a normalised + # manifest path (or URL for remote manifests) produced by + # ``normalize_manifest_key()``. Inner value is a sorted list of + # package names (case-insensitive) that should be checked for + # pre-release updates even when the manifest does not set + # ``include_prereleases: true`` on the package. ``None`` means + # no overrides anywhere. + prerelease_packages: dict[str, list[str]] | None = None + class LocalConfiguration(_ConfigBase): """Portable configuration embedded next to the executable. diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index ec2de62..7900c34 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -28,6 +28,7 @@ InstallWorker, PreviewWorker, format_cli_command, + normalize_manifest_key, resolve_local_path, ) @@ -71,9 +72,124 @@ def test_skip_reason_labels() -> None: def test_skip_reason_label_human_readable() -> None: """Verify skip reason labels are human-readable, not raw enum names.""" assert skip_reason_label(SkipReason.ALREADY_INSTALLED) == 'Already installed' + assert skip_reason_label(SkipReason.UPDATE_AVAILABLE) == 'Update available' assert skip_reason_label(None) == 'Skipped' +class TestUpdateAvailableLabels: + """Tests for UPDATE_AVAILABLE skip reason handling in the preview UI.""" + + @staticmethod + def _make_action( + kind: str = 'PACKAGE', + description: str = 'Install test', + installer: str = 'pip', + package: str = 'requests', + ) -> MagicMock: + """Create a mock SetupAction.""" + action = MagicMock() + action.kind = getattr(PluginKind, kind) + action.description = description + action.installer = installer + action.package = package + action.command = None + action.cli_command = None + return action + + @staticmethod + def test_update_available_label_with_versions() -> None: + """Verify UPDATE_AVAILABLE produces a clean status label without inline versions.""" + result = SetupActionResult( + action=MagicMock(), + success=True, + skipped=True, + skip_reason=SkipReason.UPDATE_AVAILABLE, + installed_version='1.0.0', + available_version='2.0.0a1', + ) + # Status column now uses the bare label; versions go to the Version column. + assert skip_reason_label(result.skip_reason) == 'Update available' + + @staticmethod + def test_version_column_update_available() -> None: + """Verify version column shows transition when an update is available.""" + result = SetupActionResult( + action=MagicMock(), + success=True, + skipped=True, + skip_reason=SkipReason.UPDATE_AVAILABLE, + installed_version='1.0.0', + available_version='2.0.0a1', + ) + # The Version column should contain the transition text. + version_text = f'{result.installed_version} \u2192 {result.available_version}' + assert '1.0.0' in version_text + assert '2.0.0a1' in version_text + + @staticmethod + def test_version_column_already_installed() -> None: + """Verify version column shows installed version for already-installed packages.""" + result = SetupActionResult( + action=MagicMock(), + success=True, + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + installed_version='3.5.2', + ) + assert result.installed_version == '3.5.2' + assert result.available_version is None + + @staticmethod + def test_update_available_label_without_versions() -> None: + """Verify UPDATE_AVAILABLE falls back to base label when versions are missing.""" + result = SetupActionResult( + action=MagicMock(), + success=True, + skipped=True, + skip_reason=SkipReason.UPDATE_AVAILABLE, + ) + assert skip_reason_label(result.skip_reason) == 'Update available' + + @staticmethod + def test_update_available_counted_as_actionable() -> None: + """Verify upgradable rows are counted as actionable alongside needed.""" + # Simulate the data model: 4 actions where row 1 is upgradable + statuses = ['Already installed', 'Update available', 'Needed', 'Not installed'] + upgradable_rows = {1} + + needed = sum(1 for s in statuses if s == 'Needed') + upgradable = len(upgradable_rows) + unavailable = sum(1 for s in statuses if s == 'Not installed') + satisfied = len(statuses) - needed - upgradable - unavailable + + assert needed == 1 + assert upgradable == 1 + assert unavailable == 1 + assert satisfied == 1 + assert needed + upgradable > 0 # Install button should be enabled + + @staticmethod + def test_version_updated_after_successful_upgrade() -> None: + """Verify the available_version is surfaced for post-install display. + + After a successful upgrade, the ``_update_table_status`` method + uses ``result.available_version`` to replace the transition arrow + in the Version column. This test validates the result carries + the new version. + """ + result = SetupActionResult( + action=MagicMock(), + success=True, + skipped=False, + installed_version='1.0.0', + available_version='2.0.0a1', + ) + # After a successful upgrade, available_version is the new version + assert result.success + assert not result.skipped + assert result.available_version == '2.0.0a1' + + class TestFormatCliCommand: """Tests for format_cli_command helper.""" @@ -192,6 +308,59 @@ async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 assert len(errors) == 1 assert 'boom' in errors[0] + @staticmethod + def test_worker_passes_prerelease_packages() -> None: + """Verify prerelease_packages is forwarded to SetupParameters.""" + porringer = MagicMock() + manifest_path = Path('/tmp/test/porringer.json') + + manifest = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=manifest) + + captured_params: list[Any] = [] + + async def mock_stream(params: Any) -> Any: + captured_params.append(params) + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + token = CancellationToken() + worker = InstallWorker( + porringer, + manifest_path, + token, + prerelease_packages={'cppython'}, + ) + worker.run() + + assert len(captured_params) == 1 + assert captured_params[0].prerelease_packages == {'cppython'} + + @staticmethod + def test_worker_omits_prerelease_when_none() -> None: + """Verify prerelease_packages defaults to None.""" + porringer = MagicMock() + manifest_path = Path('/tmp/test/porringer.json') + + manifest = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=manifest) + + captured_params: list[Any] = [] + + async def mock_stream(params: Any) -> Any: + captured_params.append(params) + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + token = CancellationToken() + worker = InstallWorker(porringer, manifest_path, token) + worker.run() + + assert len(captured_params) == 1 + assert captured_params[0].prerelease_packages is None + class TestResolveLocalPath: """Tests for _resolve_local_path helper.""" @@ -520,3 +689,125 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: worker.run() assert order == ['plugins', 'preview'] + + +class TestPreviewWorkerUpdateDetection: + """Tests for PreviewWorker passing update-detection flags to porringer.""" + + @staticmethod + def test_passes_detect_updates_and_prerelease_packages(tmp_path: Path) -> None: + """Verify detect_updates and prerelease_packages are forwarded to SetupParameters.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + porringer.plugin.list.return_value = [] + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + captured_params: list[Any] = [] + + async def mock_stream(params: Any) -> Any: + captured_params.append(params) + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker( + porringer, + str(manifest), + detect_updates=True, + prerelease_packages={'some-pkg'}, + ) + worker.run() + + assert len(captured_params) == 1 + assert captured_params[0].detect_updates is True + assert captured_params[0].prerelease_packages == {'some-pkg'} + + @staticmethod + def test_defaults_detect_updates_true(tmp_path: Path) -> None: + """Verify detect_updates defaults to True.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + porringer.plugin.list.return_value = [] + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + captured_params: list[Any] = [] + + async def mock_stream(params: Any) -> Any: + captured_params.append(params) + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + worker.run() + + assert len(captured_params) == 1 + assert captured_params[0].detect_updates is True + assert captured_params[0].prerelease_packages is None + + +class TestNormalizeManifestKey: + """Tests for normalize_manifest_key helper.""" + + @staticmethod + def test_http_url_passthrough() -> None: + """HTTP URLs are returned unchanged.""" + url = 'https://example.com/porringer.json' + assert normalize_manifest_key(url) == url + + @staticmethod + def test_local_path_resolved(tmp_path: Path) -> None: + """Local paths are resolved to absolute form.""" + result = normalize_manifest_key(str(tmp_path / 'manifest')) + assert Path(result).is_absolute() + + @staticmethod + def test_relative_path_resolved() -> None: + """Relative paths are resolved relative to cwd.""" + result = normalize_manifest_key('some/relative/path') + assert Path(result).is_absolute() + + +class TestPrereleaseCheckboxLock: + """Tests for the pre-release checkbox lock/unlock decision logic. + + The checkbox should be locked (disabled) only when the manifest + sets ``include_prereleases=True`` AND the user has NOT added the + package as an override. If the user checked the box manually + (package in ``_prerelease_overrides``), it must remain unlocked + even after the reload returns the action with + ``include_prereleases=True`` (because the upstream applied the + user's override additively). + """ + + @staticmethod + @pytest.mark.parametrize( + ('include_prereleases', 'in_overrides', 'expected_locked'), + [ + # Manifest enables pre-release, user hasn't touched it → locked + (True, False, True), + # Manifest enables pre-release, but user toggled it on → unlocked + (True, True, False), + # Manifest does not enable, user hasn't touched → unlocked + (False, False, False), + # Manifest does not enable, user checked it → unlocked + (False, True, False), + ], + ) + def test_lock_decision( + include_prereleases: bool, + in_overrides: bool, + expected_locked: bool, + ) -> None: + """Verify the manifest-native vs user-override lock decision.""" + # Mirror the conditional in _populate_table: + # if action.include_prereleases and not is_user_override → locked + is_user_override = in_overrides + locked = include_prereleases and not is_user_override + assert locked is expected_locked diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 23fb411..0f0ba92 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -27,6 +27,8 @@ def test_defaults() -> None: assert config.auto_update_interval_minutes is None assert config.tool_update_interval_minutes is None assert config.plugin_auto_update is None + assert config.detect_updates is True + assert config.prerelease_packages is None @staticmethod def test_with_values() -> None: @@ -48,6 +50,8 @@ def test_defaults() -> None: assert config.auto_update_interval_minutes is None assert config.tool_update_interval_minutes is None assert config.plugin_auto_update is None + assert config.detect_updates is True + assert config.prerelease_packages is None @staticmethod def test_with_values() -> None: @@ -56,6 +60,15 @@ def test_with_values() -> None: assert config.update_source == '/path/to/releases' assert config.update_channel == 'dev' + @staticmethod + def test_prerelease_packages_round_trip() -> None: + """Verify prerelease_packages survives JSON round-trip.""" + packages = {'/some/path': ['alpha', 'beta'], 'https://example.com/manifest.json': ['gamma']} + original = GlobalConfiguration(prerelease_packages=packages) + data = json.loads(original.model_dump_json()) + restored = GlobalConfiguration.model_validate(data) + assert restored.prerelease_packages == packages + @staticmethod def test_plugin_auto_update_round_trip() -> None: """Verify plugin_auto_update survives JSON round-trip.""" From 46be1ed8cfa70da4ce99341dc0ee59d618b5e3db Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 20 Feb 2026 15:54:22 -0800 Subject: [PATCH 3/6] Integ --- pdm.lock | 8 +- pyproject.toml | 2 +- synodic_client/application/screen/__init__.py | 1 + .../application/screen/action_card.py | 764 +++++++++++++++ synodic_client/application/screen/install.py | 430 +++------ synodic_client/application/theme.py | 118 +++ tests/unit/qt/test_action_card.py | 884 ++++++++++++++++++ tests/unit/qt/test_install_preview.py | 1 + 8 files changed, 1924 insertions(+), 284 deletions(-) create mode 100644 synodic_client/application/screen/action_card.py create mode 100644 tests/unit/qt/test_action_card.py diff --git a/pdm.lock b/pdm.lock index d20e378..60acace 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:e595273cf72f67fbc68c94d044a00f2f3eb93e4d5526b81e21e28c4b8bac55f6" +content_hash = "sha256:1d736b2e77a2c3ef7e6a1c4ddd1c4bb3095848074761ab2a82f9dc450b517440" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev41" +version = "0.2.1.dev46" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev41-py3-none-any.whl", hash = "sha256:f1cdb645c7707e2f3c65c923752c1bbaefd8ddc1228b76ed8a39c0b7f469570d"}, - {file = "porringer-0.2.1.dev41.tar.gz", hash = "sha256:c32cb77e467b69a8a4db0040b41ebb32b06565f403e617c2f17776f3fd092d29"}, + {file = "porringer-0.2.1.dev46-py3-none-any.whl", hash = "sha256:04ed9362295c1058982197f4e52fe26c11a5eb1c0c0197a165aae67332efa4ae"}, + {file = "porringer-0.2.1.dev46.tar.gz", hash = "sha256:674b4e26d4e31c254e2b4c1e04a47c2cacc11da6a4a789666e47eb68556a2176"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index ea5a406..17b9242 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.dev41", + "porringer>=0.2.1.dev46", "qasync>=0.28.0", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", diff --git a/synodic_client/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index 1048f9d..29b881d 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -43,6 +43,7 @@ def plugin_kind_group_label(kind: PluginKind) -> str: SKIP_REASON_LABELS: dict[SkipReason, str] = { SkipReason.ALREADY_INSTALLED: 'Already installed', + SkipReason.ALREADY_LATEST: 'Already latest', SkipReason.NO_PROJECT_DIRECTORY: 'No project directory', SkipReason.UPDATE_AVAILABLE: 'Update available', } diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py new file mode 100644 index 0000000..0f8bb40 --- /dev/null +++ b/synodic_client/application/screen/action_card.py @@ -0,0 +1,764 @@ +"""Action card widgets for the install preview screen. + +Replaces the previous ``QTableWidget`` + ``ExecutionLogPanel`` layout +with compact, self-contained cards — one per setup action. Each card +shows essential information (package name, type, version, status) and +expands inline to display execution output during install. + +:class:`ActionCard` is the per-action widget. +:class:`ActionCardList` is the scrollable container that holds them. +""" + +from __future__ import annotations + +import html as html_mod +import logging + +from porringer.schema import SetupAction, SetupActionResult, SkipReason +from porringer.schema.plugin import PluginKind +from PySide6.QtCore import QRect, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor +from PySide6.QtWidgets import ( + QCheckBox, + QFrame, + QHBoxLayout, + QLabel, + QScrollArea, + QSizePolicy, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label +from synodic_client.application.theme import ( + ACTION_CARD_COMMAND_STYLE, + ACTION_CARD_DESC_STYLE, + ACTION_CARD_EXECUTING_STYLE, + ACTION_CARD_LOG_STYLE, + ACTION_CARD_PACKAGE_STYLE, + ACTION_CARD_SKELETON_BAR_STYLE, + ACTION_CARD_SKELETON_STYLE, + ACTION_CARD_SPACING, + ACTION_CARD_SPINNER_PEN, + ACTION_CARD_SPINNER_SIZE, + ACTION_CARD_STATUS_DONE, + ACTION_CARD_STATUS_FAILED, + ACTION_CARD_STATUS_NEEDED, + ACTION_CARD_STATUS_RUNNING, + ACTION_CARD_STATUS_SATISFIED, + ACTION_CARD_STATUS_SKIPPED, + ACTION_CARD_STATUS_UNAVAILABLE, + ACTION_CARD_STATUS_UPDATE, + ACTION_CARD_STYLE, + ACTION_CARD_TYPE_BADGE_STYLE, + ACTION_CARD_VERSION_STYLE, + LOG_COLOR_ERROR, + LOG_COLOR_PHASE, + LOG_COLOR_STDERR, + LOG_COLOR_STDOUT, + LOG_COLOR_SUCCESS, + MONOSPACE_FAMILY, + MONOSPACE_SIZE, +) + +logger = logging.getLogger(__name__) + +#: Amber foreground for "Update available" version transitions. +_UPDATE_AVAILABLE_COLOR = QColor('#d7ba7d') + +#: Timer interval for per-card inline spinner (ms). +_SPINNER_INTERVAL = 50 + +#: Arc span for per-card spinner (degrees × 16 for Qt drawArc). +_SPINNER_ARC = 90 + + +#: Sort priority for each :class:`PluginKind`. +#: Lower numbers appear first. Runtimes and SCM come before packages +#: so that infrastructure is set up first. +_KIND_ORDER: dict[PluginKind | None, int] = { + PluginKind.RUNTIME: 0, + PluginKind.SCM: 1, + PluginKind.PROJECT: 2, + PluginKind.TOOL: 3, + PluginKind.PACKAGE: 4, + None: 99, # bare commands are excluded from ActionCardList anyway +} + + +def action_key(action: SetupAction) -> tuple[object, ...]: + """Return a stable identity key for *action*. + + Uses content-based fields (kind, installer, package name, command) + so the same logical action from different ``execute_stream`` runs + resolves to the same key. + """ + pkg_name = str(action.package.name) if action.package else None + pt_name = str(action.plugin_target.name) if action.plugin_target else None + cmd = tuple(action.command) if action.command else None + return (action.kind, action.installer, pkg_name, pt_name, cmd) + + +def action_sort_key(action: SetupAction) -> tuple[int, str]: + """Return a sort key so cards are grouped by kind then alphabetical. + + The ordering places infrastructure kinds (runtime, SCM) before + packages / tools. Within a group, actions are sorted + case-insensitively by package name. + """ + kind_order = _KIND_ORDER.get(action.kind, 50) + pkg_name = str(action.package.name).lower() if action.package else '' + return (kind_order, pkg_name) + + +def _format_command(action: SetupAction) -> str: + """Return a short CLI command string for display.""" + if parts := (action.cli_command or action.command): + return ' '.join(parts) + if action.kind == PluginKind.PACKAGE and action.package: + return f'{action.installer or "pip"} install {action.package}' + return '' + + +# --------------------------------------------------------------------------- +# _CardSpinner — tiny per-card inline spinner +# --------------------------------------------------------------------------- + + +class _CardSpinner(QWidget): + """Tiny spinning arc used inside an :class:`ActionCard` while checking. + + The spinner replaces the status text label during the dry-run check + phase and is hidden once the result arrives. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._angle = 0 + self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE) + + def paintEvent(self, _event: object) -> None: # noqa: N802 + """Draw the muted track and animated highlight arc.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + m = ACTION_CARD_SPINNER_PEN // 2 + 1 + rect = QRect(m, m, self.width() - 2 * m, self.height() - 2 * m) + + # Track circle + track_pen = QPen(self.palette().mid(), ACTION_CARD_SPINNER_PEN) + track_pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(track_pen) + painter.drawEllipse(rect) + + # Highlight arc + hl_pen = QPen(self.palette().highlight(), ACTION_CARD_SPINNER_PEN) + hl_pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(hl_pen) + painter.drawArc(rect, self._angle * 16, _SPINNER_ARC * 16) + + painter.end() + + def tick(self) -> None: + """Advance the arc and repaint.""" + self._angle = (self._angle - 10) % 360 + self.update() + + +# --------------------------------------------------------------------------- +# ActionCard — a single action row +# --------------------------------------------------------------------------- + + +class ActionCard(QFrame): + """Compact card displaying a single setup action. + + The card has three visual states: + + * **preview** — shows package name, type badge, description, CLI + command, version, and dry-run status. The inline log is hidden. + * **executing** — highlighted border, status shows "Running\u2026", + inline log body is visible and receives real-time output. + * **completed** — status updated to Done / Failed / Skipped, log body + remains visible (collapsed by default after completion). + + A **skeleton** variant shows muted placeholder bars and is used + while the manifest is still loading. + + Each card has a tiny inline spinner that replaces the status text + while the dry-run check is in progress. + """ + + prerelease_toggled = Signal(str, bool) + """Emitted with ``(package_name, checked)`` when the user toggles the + per-row pre-release checkbox.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + skeleton: bool = False, + ) -> None: + """Initialise the card. + + Args: + parent: Optional parent widget. + skeleton: When ``True`` the card shows placeholder bars with + no real content. + """ + super().__init__(parent) + self.setObjectName('actionCard') + self._action: SetupAction | None = None + self._is_skeleton = skeleton + self._log_expanded = False + self._checking = False + + if skeleton: + self._init_skeleton_ui() + else: + self._init_real_ui() + + # ------------------------------------------------------------------ + # Skeleton UI + # ------------------------------------------------------------------ + + def _init_skeleton_ui(self) -> None: + """Build the placeholder skeleton layout.""" + self.setStyleSheet(ACTION_CARD_SKELETON_STYLE) + + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + row = QHBoxLayout() + row.setSpacing(8) + + # Placeholder bars + bar1 = QFrame() + bar1.setObjectName('skeletonBar') + bar1.setStyleSheet(ACTION_CARD_SKELETON_BAR_STYLE) + bar1.setFixedSize(50, 14) + row.addWidget(bar1) + + bar2 = QFrame() + bar2.setObjectName('skeletonBar') + bar2.setStyleSheet(ACTION_CARD_SKELETON_BAR_STYLE) + bar2.setFixedSize(120, 14) + row.addWidget(bar2) + + row.addStretch() + + bar3 = QFrame() + bar3.setObjectName('skeletonBar') + bar3.setStyleSheet(ACTION_CARD_SKELETON_BAR_STYLE) + bar3.setFixedSize(70, 14) + row.addWidget(bar3) + + layout.addLayout(row) + + row2 = QHBoxLayout() + row2.setSpacing(8) + bar4 = QFrame() + bar4.setObjectName('skeletonBar') + bar4.setStyleSheet(ACTION_CARD_SKELETON_BAR_STYLE) + bar4.setFixedSize(200, 12) + row2.addWidget(bar4) + row2.addStretch() + layout.addLayout(row2) + + # ------------------------------------------------------------------ + # Real UI + # ------------------------------------------------------------------ + + def _init_real_ui(self) -> None: + """Build the action card layout.""" + self.setStyleSheet(ACTION_CARD_STYLE) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + outer = QVBoxLayout(self) + outer.setContentsMargins(6, 6, 6, 6) + outer.setSpacing(2) + + # --- Top row: type badge | package name ... version | status/spinner | prerelease --- + top = QHBoxLayout() + top.setSpacing(8) + + self._type_badge = QLabel() + self._type_badge.setStyleSheet(ACTION_CARD_TYPE_BADGE_STYLE) + top.addWidget(self._type_badge) + + self._package_label = QLabel() + self._package_label.setStyleSheet(ACTION_CARD_PACKAGE_STYLE) + top.addWidget(self._package_label) + + top.addStretch() + + self._version_label = QLabel() + self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE) + top.addWidget(self._version_label) + + # Inline spinner (replaces status text while checking) + self._spinner_canvas = _CardSpinner(self) + self._spinner_canvas.hide() + self._spinner_timer = QTimer(self) + self._spinner_timer.setInterval(_SPINNER_INTERVAL) + self._spinner_timer.timeout.connect(self._spinner_canvas.tick) + top.addWidget(self._spinner_canvas) + + self._status_label = QLabel() + top.addWidget(self._status_label) + + self._prerelease_cb = QCheckBox('Pre-release') + self._prerelease_cb.hide() + top.addWidget(self._prerelease_cb) + + outer.addLayout(top) + + # --- Description row --- + self._desc_label = QLabel() + self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE) + self._desc_label.setWordWrap(True) + outer.addWidget(self._desc_label) + + # --- CLI command row (always visible, muted monospace) --- + self._command_label = QLabel() + self._command_label.setStyleSheet(ACTION_CARD_COMMAND_STYLE) + self._command_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse, + ) + self._command_label.hide() + outer.addWidget(self._command_label) + + # --- Inline log body (hidden by default) --- + self._log_output = QTextEdit() + self._log_output.setReadOnly(True) + self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE)) + self._log_output.setStyleSheet(ACTION_CARD_LOG_STYLE) + self._log_output.setMinimumHeight(40) + self._log_output.setMaximumHeight(250) + self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self._log_output.hide() + outer.addWidget(self._log_output) + + # ------------------------------------------------------------------ + # Mouse events (toggle log) + # ------------------------------------------------------------------ + + def mousePressEvent(self, _event: object) -> None: # noqa: N802 + """Toggle the inline log body on click.""" + if self._is_skeleton or not hasattr(self, '_log_output'): + return + self._toggle_log() + + def _toggle_log(self) -> None: + """Expand or collapse the inline log body.""" + self._log_expanded = not self._log_expanded + self._log_output.setVisible(self._log_expanded) + + # ------------------------------------------------------------------ + # Public API — populate from action data + # ------------------------------------------------------------------ + + @property + def action(self) -> SetupAction | None: + """Return the action bound to this card, or ``None``.""" + return self._action + + def populate( + self, + action: SetupAction, + *, + plugin_installed: dict[str, bool] | None = None, + prerelease_overrides: set[str] | None = None, + ) -> None: + """Fill the card with data from a :class:`SetupAction`. + + Args: + action: The setup action to display. + plugin_installed: Plugin name → installed mapping. + prerelease_overrides: Package names with user-enabled pre-release. + """ + if self._is_skeleton: + return + + self._action = action + plugin_installed = plugin_installed or {} + prerelease_overrides = prerelease_overrides or set() + + kind_label = ACTION_KIND_LABELS.get(action.kind, 'Action') + self._type_badge.setText(kind_label) + if action.installer: + self._type_badge.setToolTip(f'Plugin: {action.installer}') + + package_text = str(action.package) if action.package else action.description + self._package_label.setText(package_text) + + desc = action.package_description or action.description + if desc and desc != package_text: + self._desc_label.setText(desc) + self._desc_label.show() + else: + self._desc_label.hide() + + # CLI command (always visible when present) + cmd_text = _format_command(action) + if cmd_text: + self._command_label.setText(cmd_text) + self._command_label.show() + else: + self._command_label.hide() + + # Version — populated later by set_check_result() + self._version_label.setText('') + + # Status — check plugin presence first + installer_missing = ( + action.installer is not None + and action.installer in plugin_installed + and not plugin_installed[action.installer] + ) + + if installer_missing: + self._status_label.setText('Not installed') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE) + self._status_label.show() + else: + # Show spinner instead of status text while checking + self._status_label.hide() + self._checking = True + self._spinner_canvas.show() + self._spinner_timer.start() + + # Pre-release checkbox + if action.package is not None: + pkg_name = str(action.package.name) + is_user_override = pkg_name.lower() in prerelease_overrides + if action.include_prereleases and not is_user_override: + self._prerelease_cb.setChecked(True) + self._prerelease_cb.setEnabled(False) + self._prerelease_cb.setToolTip('Enabled by manifest') + else: + self._prerelease_cb.setChecked(is_user_override) + self._prerelease_cb.setToolTip('Include pre-release versions') + self._prerelease_cb.toggled.connect( + lambda checked, name=pkg_name: self.prerelease_toggled.emit(name, checked), + ) + self._prerelease_cb.show() + else: + self._prerelease_cb.hide() + + def initial_status(self) -> str: + """Return the initial status text set during :meth:`populate`.""" + if self._is_skeleton or not hasattr(self, '_status_label'): + return '' + if self._checking: + return 'Checking\u2026' + return self._status_label.text() + + def _stop_spinner(self) -> None: + """Stop the inline checking spinner and show the status label.""" + if not hasattr(self, '_spinner_timer'): + return + self._checking = False + self._spinner_timer.stop() + self._spinner_canvas.hide() + self._status_label.show() + + # ------------------------------------------------------------------ + # Public API — dry-run check result + # ------------------------------------------------------------------ + + def set_check_result(self, result: SetupActionResult) -> None: + """Update the card with a dry-run check result. + + Args: + result: The action check result from the preview worker. + """ + if self._is_skeleton: + return + + self._stop_spinner() + + if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: + label = skip_reason_label(result.skip_reason) + self._status_label.setText(label) + self._status_label.setStyleSheet(ACTION_CARD_STATUS_UPDATE) + elif result.skipped: + label = skip_reason_label(result.skip_reason) + self._status_label.setText(label) + self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED) + else: + label = 'Needed' + self._status_label.setText(label) + self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED) + + # Version column + if result.installed_version and result.available_version: + self._version_label.setText(f'{result.installed_version} \u2192 {result.available_version}') + self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: #d7ba7d;') + elif result.installed_version: + self._version_label.setText(result.installed_version) + + def finalize_checking(self) -> None: + """Resolve a still-pending 'Checking\u2026' status to 'Needed'. + + Called after the preview finishes if the dry-run never sent a + result for this card. 'Not installed' statuses are left alone. + """ + if self._is_skeleton or not hasattr(self, '_status_label'): + return + if self._checking: + self._stop_spinner() + self._status_label.setText('Needed') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED) + + # ------------------------------------------------------------------ + # Public API — execution (inline log) + # ------------------------------------------------------------------ + + def set_executing(self) -> None: + """Transition the card into the *executing* state. + + Shows the inline log body and updates the status badge. + """ + if self._is_skeleton: + return + self._stop_spinner() + self.setStyleSheet(ACTION_CARD_EXECUTING_STYLE) + self._status_label.setText('Running\u2026') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_RUNNING) + self._log_expanded = True + self._log_output.setVisible(True) + + def append_output(self, text: str, stream: str | None = None) -> None: + """Append a line of output to the inline log. + + Args: + text: The output line. + stream: ``'stdout'``, ``'stderr'``, or ``None`` for phase messages. + """ + if self._is_skeleton or not hasattr(self, '_log_output'): + return + + colour = LOG_COLOR_STDOUT + if stream == 'stderr': + colour = LOG_COLOR_STDERR + elif stream is None: + colour = LOG_COLOR_PHASE + + escaped = html_mod.escape(text) + self._log_output.append(f'{escaped}') + + cursor = self._log_output.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self._log_output.setTextCursor(cursor) + + def set_result(self, result: SetupActionResult) -> None: + """Update the card with the final execution result. + + The card returns to the default border style. The log body stays + visible but can be collapsed by clicking the card. + + Args: + result: The action execution result. + """ + if self._is_skeleton: + return + + self.setStyleSheet(ACTION_CARD_STYLE) + + if result.skipped: + label = skip_reason_label(result.skip_reason) + self._status_label.setText(label) + self._status_label.setStyleSheet(ACTION_CARD_STATUS_SKIPPED) + self.append_output(f'\u23ed Skipped: {label}', None) + elif result.success: + self._status_label.setText('Done') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_DONE) + msg = result.message or 'Completed successfully' + self._log_output.append( + f'\u2713 {html_mod.escape(msg)}', + ) + # Update version if an upgrade completed + if result.available_version: + self._version_label.setText(result.available_version) + self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE) + else: + self._status_label.setText('Failed') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED) + msg = result.message or 'Unknown error' + self._log_output.append( + f'\u2717 {html_mod.escape(msg)}', + ) + + # ------------------------------------------------------------------ + # Public API — status text accessors (for counting) + # ------------------------------------------------------------------ + + def status_text(self) -> str: + """Return the current status label text.""" + if self._is_skeleton or not hasattr(self, '_status_label'): + return '' + if self._checking: + return 'Checking\u2026' + return self._status_label.text() + + def is_update_available(self) -> bool: + """Return whether the card shows an 'Update available' status.""" + return self.status_text() == 'Update available' + + +# --------------------------------------------------------------------------- +# ActionCardList — scrollable container +# --------------------------------------------------------------------------- + + +class ActionCardList(QScrollArea): + """Scrollable container of :class:`ActionCard` widgets. + + Replaces both the ``QTableWidget`` and the ``ExecutionLogPanel`` from + the previous install screen layout. One scrollbar, no nesting. + + Cards are keyed by :func:`action_key` (content-based) so that + look-ups work across different ``execute_stream`` runs where the + ``SetupAction`` objects are different Python instances. + """ + + prerelease_toggled = Signal(str, bool) + """Forwarded from child :class:`ActionCard` widgets.""" + + def __init__(self, parent: QWidget | None = None) -> None: + """Initialise the card list.""" + super().__init__(parent) + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setFrameShape(QFrame.Shape.NoFrame) + + self._container = QWidget() + self._layout = QVBoxLayout(self._container) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(ACTION_CARD_SPACING) + self._layout.addStretch() + + self.setWidget(self._container) + + self._cards: list[ActionCard] = [] + self._action_map: dict[tuple[object, ...], ActionCard] = {} + + # ------------------------------------------------------------------ + # Skeleton loading + # ------------------------------------------------------------------ + + def show_skeletons(self, count: int = 3) -> None: + """Display *count* skeleton placeholder cards. + + Any existing cards are removed first. + + Args: + count: Number of skeleton cards to show. + """ + self.clear() + for _ in range(count): + card = ActionCard(self._container, skeleton=True) + self._layout.insertWidget(self._layout.count() - 1, card) + self._cards.append(card) + + # ------------------------------------------------------------------ + # Populate from preview + # ------------------------------------------------------------------ + + def populate( + self, + actions: list[SetupAction], + *, + plugin_installed: dict[str, bool] | None = None, + prerelease_overrides: set[str] | None = None, + ) -> None: + """Replace skeleton cards with real action cards. + + Args: + actions: The setup actions to display. + plugin_installed: Plugin name → installed mapping. + prerelease_overrides: Package names with user pre-release overrides. + """ + self.clear() + sorted_actions = sorted( + (a for a in actions if a.kind is not None), + key=action_sort_key, + ) + for act in sorted_actions: + card = ActionCard(self._container) + card.populate( + act, + plugin_installed=plugin_installed, + prerelease_overrides=prerelease_overrides, + ) + card.prerelease_toggled.connect(self.prerelease_toggled.emit) + self._layout.insertWidget(self._layout.count() - 1, card) + self._cards.append(card) + self._action_map[action_key(act)] = card + + # ------------------------------------------------------------------ + # Card lookup + # ------------------------------------------------------------------ + + def card_at(self, index: int) -> ActionCard | None: + """Return the card at the given index, or ``None``.""" + if 0 <= index < len(self._cards): + return self._cards[index] + return None + + def card_count(self) -> int: + """Return the number of cards (including skeletons).""" + return len(self._cards) + + def get_card(self, action: SetupAction) -> ActionCard | None: + """Look up the card for a given action by stable content key. + + Works across different ``execute_stream`` runs — the preview + and install phases produce different ``SetupAction`` instances + but the same logical action maps to the same card. + + Args: + action: The setup action to find. + + Returns: + The card widget, or ``None`` if not found. + """ + return self._action_map.get(action_key(action)) + + # ------------------------------------------------------------------ + # Bulk operations + # ------------------------------------------------------------------ + + def finalize_all_checking(self) -> None: + """Resolve all still-pending 'Checking\u2026' cards to 'Needed'.""" + for card in self._cards: + card.finalize_checking() + + def clear(self) -> None: + """Remove all cards.""" + for card in self._cards: + self._layout.removeWidget(card) + card.deleteLater() + self._cards.clear() + self._action_map.clear() + + # ------------------------------------------------------------------ + # Scroll helpers + # ------------------------------------------------------------------ + + def scroll_to_card(self, card: ActionCard) -> None: + """Ensure *card* is visible in the scroll area.""" + self.ensureWidgetVisible(card) + + def scroll_to_card_bottom(self, card: ActionCard) -> None: + """Scroll so the bottom of *card* is visible. + + Used during execution to follow the growing inline log output. + Unlike :meth:`scroll_to_card` (which may show the top of a tall + card), this always brings the bottom edge into view. + """ + bottom_y = card.geometry().bottom() + self.ensureVisible(0, bottom_y, 0, 50) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index ef6fa98..2ca353b 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -4,8 +4,8 @@ previews and executing porringer setup actions, along with the standalone :class:`InstallPreviewWindow` used for URI-based manifest installs. -Execution runs on a background ``QThread`` with a real-time -:class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`. +Execution runs on a background ``QThread`` with real-time inline +log output in each :class:`~synodic_client.application.screen.action_card.ActionCard`. """ from __future__ import annotations @@ -34,33 +34,27 @@ ) from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QThread, QTimer, Signal -from PySide6.QtGui import QColor, QFont, QKeySequence, QShortcut +from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QApplication, - QCheckBox, QFileDialog, - QGridLayout, + QFrame, QHBoxLayout, - QHeaderView, QLabel, QLineEdit, QMainWindow, QMessageBox, QPushButton, - QSizePolicy, - QStackedWidget, - QTableWidget, - QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, ) -from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label +from synodic_client.application.screen import skip_reason_label +from synodic_client.application.screen.action_card import ActionCardList from synodic_client.application.screen.card import CardFrame -from synodic_client.application.screen.log_panel import ExecutionLogPanel -from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.theme import ( + ACTION_CARD_SKELETON_BAR_STYLE, CARD_SPACING, COMMAND_HEADER_STYLE, COMPACT_MARGINS, @@ -71,6 +65,8 @@ COPY_ICON, HEADER_STYLE, INSTALL_PREVIEW_MIN_SIZE, + METADATA_SKELETON_HEIGHT, + METADATA_SKELETON_STYLE, MONOSPACE_FAMILY, MONOSPACE_SIZE, MUTED_STYLE, @@ -78,9 +74,6 @@ ) from synodic_client.config import GlobalConfiguration, save_config -#: Amber foreground for "Update available" status cells. -_UPDATE_AVAILABLE_COLOR = QColor('#d7ba7d') - logger = logging.getLogger(__name__) @@ -315,8 +308,8 @@ class SetupPreviewWidget(QWidget): This widget is embedded by both :class:`InstallPreviewWindow` (for URI-based installs) and ``ProjectsView`` (for cached-directory - projects). It owns the actions table, command list, metadata display, - status label, and install execution pipeline. + projects). It owns the action card list, command section, metadata + display, status label, and install execution pipeline. The caller is responsible for providing a manifest path and project directory. Preview data is fed in via @@ -361,9 +354,10 @@ def __init__( self._runner: QThread | None = None self._cancellation_token: CancellationToken | None = None self._completed_count = 0 + self._checked_count = 0 self._action_statuses: list[str] = [] self._upgradable_rows: set[int] = set() - self._action_to_table_row: dict[int, int] = {} + self._action_index_map: dict[int, int] = {} self._plugin_installed: dict[str, bool] = {} self._prerelease_overrides: set[str] = set() self._installing = False @@ -378,68 +372,67 @@ def __init__( # --- UI construction --- - _SPINNER_PAGE = 0 - _CONTENT_PAGE = 1 - def _init_ui(self) -> None: - """Build the card-based grid layout. + """Build the two-pane layout. - The spinner and the actions card share a :class:`QStackedWidget` - so that switching between loading and content states never - changes the grid geometry — eliminating layout shifts. + Top pane (fixed): metadata card (or skeleton), status/phase label, + button bar. Bottom pane (scrollable): :class:`ActionCardList` + holding one :class:`ActionCard` per action, with inline execution + logs and per-card spinners. No global overlay spinner. """ - grid = QGridLayout(self) - grid.setContentsMargins(*NO_MARGINS) - grid.setVerticalSpacing(CARD_SPACING) - grid.setHorizontalSpacing(CARD_SPACING) + outer = QVBoxLayout(self) + outer.setContentsMargins(*NO_MARGINS) + outer.setSpacing(CARD_SPACING) - row = 0 + # --- Metadata skeleton (shown during loading, replaced by real card) --- + self._metadata_skeleton = self._make_metadata_skeleton() + outer.addWidget(self._metadata_skeleton) - # Row 0 — Metadata card (hidden until preview metadata arrives) + # --- Real metadata card (hidden until preview data arrives) --- self._init_metadata_card() - grid.addWidget(self._metadata_card, row, 0, 1, 2) - row += 1 + outer.addWidget(self._metadata_card) - # Row 1 — Status label self._status_label = QLabel() - grid.addWidget(self._status_label, row, 0, 1, 2) - row += 1 - - # Row 2 — Content stack: spinner (page 0) / actions card (page 1) - # - # Both pages live in the same grid cell with the same stretch, - # so toggling the current page causes *no* layout shift. - self._content_stack = QStackedWidget() - - self._spinner = SpinnerWidget('Loading\u2026') - self._spinner.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self._content_stack.addWidget(self._spinner) # page 0 - - self._actions_card = CardFrame() - self._table = self._init_actions_table() - self._actions_card.content_layout.addWidget(self._table) - self._post_install_section = PostInstallSection() - self._actions_card.content_layout.addWidget(self._post_install_section) - self._content_stack.addWidget(self._actions_card) # page 1 + outer.addWidget(self._status_label) - self._content_stack.setCurrentIndex(self._CONTENT_PAGE) + # --- Scrollable card list (fills remaining space) --- + self._card_list = ActionCardList() + self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled) + outer.addWidget(self._card_list, stretch=1) - grid.addWidget(self._content_stack, row, 0, 1, 2) - grid.setRowStretch(row, 2) - row += 1 - - # Row 3 — Execution log card (hidden until install starts) - self._log_card = CardFrame('Execution Log', collapsible=True) - self._log_panel = ExecutionLogPanel() - self._log_card.content_layout.addWidget(self._log_panel) - self._log_card.hide() - grid.addWidget(self._log_card, row, 0, 1, 2) - grid.setRowStretch(row, 1) - row += 1 + # Post-install section lives below the card list but still scrolls + self._post_install_section = PostInstallSection() + self._card_list._layout.insertWidget(self._card_list._layout.count() - 1, self._post_install_section) - # Row 4 — Button bar + # --- Button bar (fixed at bottom) --- button_bar = self._init_button_bar() - grid.addLayout(button_bar, row, 0, 1, 2) + outer.addLayout(button_bar) + + @staticmethod + def _make_metadata_skeleton() -> QFrame: + """Create a fixed-height skeleton placeholder for the metadata card.""" + frame = QFrame() + frame.setObjectName('card') + frame.setStyleSheet(METADATA_SKELETON_STYLE) + frame.setFixedHeight(METADATA_SKELETON_HEIGHT) + + layout = QVBoxLayout(frame) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + bar1 = QFrame() + bar1.setStyleSheet(ACTION_CARD_SKELETON_BAR_STYLE) + bar1.setFixedSize(140, 14) + layout.addWidget(bar1) + + bar2 = QFrame() + bar2.setStyleSheet(ACTION_CARD_SKELETON_BAR_STYLE) + bar2.setFixedSize(260, 12) + layout.addWidget(bar2) + + layout.addStretch() + frame.hide() + return frame def _init_metadata_card(self) -> None: """Create the metadata card (hidden until preview metadata arrives).""" @@ -461,28 +454,6 @@ def _init_metadata_card(self) -> None: self._meta_label.hide() self._metadata_card.content_layout.addWidget(self._meta_label) - def _init_actions_table(self) -> QTableWidget: - """Create and configure the actions table widget.""" - table = QTableWidget() - table.setColumnCount(7) - table.setHorizontalHeaderLabels( - ['Type', 'Plugin', 'Package', 'Version', 'Description', 'Status', 'Pre-release'], - ) - table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - table.setAlternatingRowColors(True) - header = table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) - copy_sc = QShortcut(QKeySequence.StandardKey.Copy, table, self._copy_table_selection) - copy_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) - return table - def _init_button_bar(self) -> QHBoxLayout: """Create the bottom button bar.""" button_bar = QHBoxLayout() @@ -543,38 +514,37 @@ def reset(self) -> None: self._runner = None self._cancellation_token = None self._completed_count = 0 + self._checked_count = 0 self._action_statuses = [] self._upgradable_rows = set() - self._action_to_table_row = {} + self._action_index_map = {} self._plugin_installed = {} self._prerelease_overrides = set() self._installing = False self._prerelease_debounce.stop() - self._table.setRowCount(0) + self._card_list.clear() self._post_install_section.hide() - self._log_panel.clear() - self._log_card.hide() self._name_label.hide() self._description_label.hide() self._meta_label.hide() self._metadata_card.hide() + self._metadata_skeleton.hide() self._status_label.setText('') self._status_label.setStyleSheet('') - self._spinner._timer.stop() - self._content_stack.setCurrentIndex(self._CONTENT_PAGE) self._install_btn.setEnabled(False) def start_loading(self) -> None: - """Show the centered loading spinner. + """Show skeleton placeholders for the metadata card and action cards. - Switches the content stack to the spinner page and starts the - animation. The grid geometry stays constant because the spinner - and the actions card share the same :class:`QStackedWidget` cell. + The metadata skeleton reserves the space that the real metadata + card will occupy. Each action card skeleton shows placeholder + bars with a per-card spinner built in. """ - self._content_stack.setCurrentIndex(self._SPINNER_PAGE) - self._spinner._canvas._angle = 0 - self._spinner._timer.start() + self._metadata_skeleton.show() + self._card_list.show_skeletons(3) + self._status_label.setText('Downloading manifest\u2026') + self._status_label.setStyleSheet(MUTED_STYLE) def show_not_found(self, message: str) -> None: """Display a muted 'not found' message in the status label. @@ -625,11 +595,10 @@ def _flush_prerelease_overrides(self) -> None: # --- Preview callbacks (connect to PreviewWorker signals) --- def on_plugins_queried(self, mapping: dict[str, bool]) -> None: - """Store plugin presence data for annotating the preview table. + """Store plugin presence data for annotating the action cards. - Called before :meth:`on_preview_ready` so that - :meth:`_populate_table` can flag actions whose installer plugin - is not installed. + Called before :meth:`on_preview_ready` so that card population + can flag actions whose installer plugin is not installed. Args: mapping: Plugin name → installed status. @@ -637,7 +606,7 @@ def on_plugins_queried(self, mapping: dict[str, bool]) -> None: self._plugin_installed = mapping def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: - """Handle a successful preview. + """Handle a successful preview — populate action cards. Args: preview: The setup preview results. @@ -648,58 +617,71 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p self._preview = preview self._manifest_path = Path(manifest_path) self._status_label.setStyleSheet('') - self._spinner._timer.stop() - self._content_stack.setCurrentIndex(self._CONTENT_PAGE) + self._metadata_skeleton.hide() self._show_metadata(preview) if not preview.actions: + self._card_list.clear() self._status_label.setText('No actions to perform — the manifest is empty.') return - self._action_statuses = ['Checking…'] * len(preview.actions) - self._status_label.setText(f'{len(preview.actions)} action(s) — checking status…') - self._populate_table(preview.actions) + self._action_statuses = ['Checking\u2026'] * len(preview.actions) + self._checked_count = 0 + + # Build the action-index → identity map for card lookup during execution + self._action_index_map = {id(a): i for i, a in enumerate(preview.actions)} + + total = len(preview.actions) + self._status_label.setText(f'{total} action(s) \u2014 checking status\u2026') + + self._card_list.populate( + preview.actions, + plugin_installed=self._plugin_installed, + prerelease_overrides=self._prerelease_overrides, + ) + + # Mark installer-missing actions as 'Not installed' in the status list + for i, action in enumerate(preview.actions): + if action.kind is None: + continue + installer_missing = ( + action.installer is not None + and action.installer in self._plugin_installed + and not self._plugin_installed[action.installer] + ) + if installer_missing: + self._action_statuses[i] = 'Not installed' + + # Populate the post-install commands section + self._post_install_section.populate(preview.actions) + self._install_btn.setEnabled(True) def on_action_checked(self, row: int, result: SetupActionResult) -> None: - """Update the data model and table row with the dry-run result.""" + """Update the data model and action card with the dry-run result.""" if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: label = skip_reason_label(result.skip_reason) - color = _UPDATE_AVAILABLE_COLOR self._upgradable_rows.add(row) elif result.skipped: label = skip_reason_label(result.skip_reason) - color = self.palette().placeholderText().color() else: label = 'Needed' - color = self.palette().text().color() if 0 <= row < len(self._action_statuses): self._action_statuses[row] = label - # Command actions are not shown in the table. - table_row = self._action_to_table_row.get(row) - if table_row is None: - return - - # --- Version column (col 3) --- - version_item = self._table.item(table_row, 3) - if version_item is not None: - if result.installed_version and result.available_version: - version_item.setText(f'{result.installed_version} \u2192 {result.available_version}') - version_item.setForeground(_UPDATE_AVAILABLE_COLOR) - elif result.installed_version: - version_item.setText(result.installed_version) - version_item.setForeground(self.palette().text()) - - # --- Status column (col 5) --- - item = self._table.item(table_row, 5) - if item is None: - return + # Find the card for this action + if self._preview and 0 <= row < len(self._preview.actions): + action = self._preview.actions[row] + card = self._card_list.get_card(action) + if card is not None: + card.set_check_result(result) - item.setText(label) - item.setForeground(color) + # Update phase text with progress count + self._checked_count += 1 + total = len(self._action_statuses) + self._status_label.setText(f'{total} action(s) \u2014 checking status ({self._checked_count}/{total})\u2026') def on_preview_finished(self) -> None: """Finalize the preview after the dry-run check completes.""" @@ -708,15 +690,11 @@ def on_preview_finished(self) -> None: # Resolve any still-pending statuses as 'Needed', but leave # 'Not installed' entries untouched — they indicate a missing plugin. + self._card_list.finalize_all_checking() + for i, status in enumerate(self._action_statuses): - if status == 'Checking…': + if status == 'Checking\u2026': self._action_statuses[i] = 'Needed' - table_row = self._action_to_table_row.get(i) - if table_row is not None: - item = self._table.item(table_row, 5) - if item is not None: - item.setText('Needed') - item.setForeground(self.palette().text()) # Count ALL actions (including bare commands) for enablement. total = len(self._action_statuses) @@ -737,7 +715,7 @@ def on_preview_finished(self) -> None: actionable = needed + upgradable if actionable == 0 and unavailable == 0: - self._status_label.setText(f'{total} action(s) — all already satisfied.') + self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.') self._install_btn.setEnabled(False) else: self._status_label.setText(f'{total} action(s): {", ".join(parts)}.') @@ -754,8 +732,8 @@ def on_preview_finished(self) -> None: def on_preview_error(self, message: str) -> None: """Handle a preview error.""" logger.error('Preview failed: %s', message) - self._spinner._timer.stop() - self._content_stack.setCurrentIndex(self._CONTENT_PAGE) + self._metadata_skeleton.hide() + self._card_list.clear() self._status_label.setText('') QMessageBox.critical(self, 'Preview Failed', message) self.close_requested.emit() @@ -791,92 +769,6 @@ def _show_metadata(self, preview: SetupResults) -> None: if has_content: self._metadata_card.show() - # --- Table --- - - def _copy_table_selection(self) -> None: - """Copy selected table rows to the clipboard as tab-separated text.""" - rows = sorted({idx.row() for idx in self._table.selectedIndexes()}) - if not rows: - return - cols = self._table.columnCount() - lines = [ - '\t'.join((item.text() if (item := self._table.item(r, c)) else '') for c in range(cols)) for r in rows - ] - clipboard = QApplication.clipboard() - if clipboard: - clipboard.setText('\n'.join(lines)) - - def _populate_table(self, actions: list[SetupAction]) -> None: - """Fill the actions table and post-install section from *actions*. - - Command actions (``kind is None``) are excluded from the table - because they cannot be dry-run checked — they always appear as - *Needed* which is misleading. They are shown in the dedicated - :class:`PostInstallSection` instead. - - Actions whose installer plugin is not installed are immediately - flagged as *Not installed* so the user knows the plugin must be - set up before the action can succeed. - """ - self._action_to_table_row = {} - table_actions = [(i, a) for i, a in enumerate(actions) if a.kind is not None] - self._table.setRowCount(len(table_actions)) - for table_row, (action_idx, action) in enumerate(table_actions): - self._action_to_table_row[action_idx] = table_row - self._table.setItem(table_row, 0, QTableWidgetItem(ACTION_KIND_LABELS.get(action.kind, 'Action'))) - self._table.setItem(table_row, 1, QTableWidgetItem(action.installer or '')) - self._table.setItem(table_row, 2, QTableWidgetItem(str(action.package) if action.package else '')) - - # Version column — populated later by on_action_checked - version_item = QTableWidgetItem('') - version_item.setForeground(self.palette().placeholderText()) - self._table.setItem(table_row, 3, version_item) - - self._table.setItem(table_row, 4, QTableWidgetItem(action.package_description or action.description)) - - # Check whether the installer plugin is present on the system. - installer_missing = ( - action.installer is not None - and action.installer in self._plugin_installed - and not self._plugin_installed[action.installer] - ) - - if installer_missing: - status_item = QTableWidgetItem('Not installed') - self._action_statuses[action_idx] = 'Not installed' - else: - status_item = QTableWidgetItem('Checking…') - - status_item.setForeground(self.palette().placeholderText()) - self._table.setItem(table_row, 5, status_item) - - # Per-row pre-release checkbox (only for actions with a package) - if action.package is not None: - cb = QCheckBox() - pkg_name = str(action.package.name) - is_user_override = pkg_name.lower() in self._prerelease_overrides - if action.include_prereleases and not is_user_override: - # Manifest already enables pre-releases — show checked and locked - cb.setChecked(True) - cb.setEnabled(False) - cb.setToolTip('Enabled by manifest') - else: - # User-togglable: either an explicit override or default off - cb.setChecked(is_user_override) - cb.setToolTip('Include pre-release versions for this package') - cb.toggled.connect(lambda checked, name=pkg_name: self._on_prerelease_row_toggled(name, checked)) - - # Centre the checkbox in the cell - wrapper = QWidget() - layout = QHBoxLayout(wrapper) - layout.addWidget(cb) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.setContentsMargins(0, 0, 0, 0) - self._table.setCellWidget(table_row, 6, wrapper) - - # Populate the always-visible post-install commands section - self._post_install_section.populate(actions) - # --- Install execution --- def _on_install(self) -> None: @@ -892,10 +784,7 @@ def _on_install(self) -> None: self._cancellation_token = CancellationToken() - # Show the execution log card below the actions card - self._log_panel.clear() - self._log_card.show() - self._status_label.setText('Installing…') + self._status_label.setText('Installing\u2026') # Choose LATEST strategy when there are upgradable actions so # porringer actually upgrades the already-installed packages. @@ -920,55 +809,38 @@ def _on_install(self) -> None: self._runner.start() def _on_action_started(self, action: SetupAction) -> None: - """Handle an action starting execution — create a log section.""" - self._log_panel.add_section(action) + """Handle an action starting execution — expand its card inline.""" + card = self._card_list.get_card(action) + if card is not None: + card.set_executing() + self._card_list.scroll_to_card(card) def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None: - """Handle a sub-action progress event — route to the log panel.""" - self._log_panel.on_sub_progress(action, progress) + """Handle a sub-action progress event — route output to the card.""" + card = self._card_list.get_card(action) + if card is None: + return + + if progress.output is not None: + card.append_output(progress.output, progress.stream) + elif progress.message is not None: + card.append_output(progress.message) + + # Follow the growing log — keep the bottom of the card in view. + self._card_list.scroll_to_card_bottom(card) def _on_action_progress(self, action: SetupAction, result: SetupActionResult) -> None: """Handle a single action completion from the worker.""" self._completed_count += 1 - # Update the execution log panel - self._log_panel.on_action_completed(action, result) - - # Map the action back to its table row (commands have no table row) - if self._preview: - for idx, a in enumerate(self._preview.actions): - if a is action: - table_row = self._action_to_table_row.get(idx) - if table_row is not None: - self._update_table_status(table_row, result) - break + # Update the action card inline + card = self._card_list.get_card(action) + if card is not None: + card.set_result(result) # Update status label total = len(self._preview.actions) if self._preview else 0 - self._status_label.setText(f'Installing… ({self._completed_count}/{total})') - - def _update_table_status(self, row: int, result: SetupActionResult) -> None: - """Update the status and version cells for a table row from an action result.""" - item = self._table.item(row, 5) - if item is None: - return - - if result.skipped: - item.setText(skip_reason_label(result.skip_reason)) - item.setForeground(self.palette().placeholderText()) - elif result.success: - item.setText('Done') - item.setForeground(self.palette().text()) - - # When an upgrade completes, update the Version column to show - # the new version instead of the stale transition arrow. - version_item = self._table.item(row, 3) - if version_item is not None and result.available_version: - version_item.setText(result.available_version) - version_item.setForeground(self.palette().text()) - else: - item.setText(f'Failed: {result.message}' if result.message else 'Failed') - item.setForeground(self.palette().text()) + self._status_label.setText(f'Installing\u2026 ({self._completed_count}/{total})') def _on_cancel(self) -> None: """Handle cancel request.""" @@ -992,7 +864,7 @@ def _on_install_finished(self, results: SetupResults) -> None: parts.append(f'{failed} failed') summary = ', '.join(parts) if parts else 'No actions executed.' - self._status_label.setText(f'Done — {summary}') + self._status_label.setText(f'Done \u2014 {summary}') self._install_btn.setEnabled(False) self._close_btn.setEnabled(True) self.install_finished.emit(results) diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 36f1fc8..f19e6b9 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -157,3 +157,121 @@ CARD_SPACING = 8 """Pixels between cards in a grid or box layout.""" + +# --------------------------------------------------------------------------- +# Action card (install screen) +# --------------------------------------------------------------------------- +ACTION_CARD_STYLE = ( + 'QFrame#actionCard {' + ' border: 1px solid palette(mid);' + ' border-radius: 4px;' + ' background: palette(window);' + ' padding: 6px 8px;' + '}' +) +"""Default style for an action card in the install preview.""" + +ACTION_CARD_EXECUTING_STYLE = ( + 'QFrame#actionCard {' + ' border: 1px solid #3794ff;' + ' border-radius: 4px;' + ' background: palette(window);' + ' padding: 6px 8px;' + '}' +) +"""Style for an action card that is currently executing.""" + +ACTION_CARD_SKELETON_STYLE = ( + 'QFrame#actionCard {' + ' border: 1px solid palette(mid);' + ' border-radius: 4px;' + ' background: palette(midlight);' + ' padding: 6px 8px;' + '}' +) +"""Muted style for skeleton/placeholder action cards.""" + +ACTION_CARD_SPACING = 4 +"""Pixels between action cards in the list.""" + +ACTION_CARD_TYPE_BADGE_STYLE = ( + 'QLabel { font-size: 10px; font-weight: bold;' + ' padding: 1px 6px; border-radius: 3px;' + ' background: palette(midlight); color: palette(text); }' +) +"""Small type badge (Package, Tool, Runtime, etc.) on each action card.""" + +ACTION_CARD_PACKAGE_STYLE = 'font-weight: bold; font-size: 12px;' +"""Primary line: package name.""" + +ACTION_CARD_DESC_STYLE = 'color: grey; font-size: 11px;' +"""Secondary line: description text.""" + +ACTION_CARD_VERSION_STYLE = 'font-size: 11px;' +"""Version transition text.""" + +ACTION_CARD_STATUS_CHECKING = 'color: grey; font-size: 11px;' +"""Status label: Checking…""" + +ACTION_CARD_STATUS_NEEDED = 'color: palette(text); font-size: 11px; font-weight: bold;' +"""Status label: Needed.""" + +ACTION_CARD_STATUS_SATISFIED = 'color: grey; font-size: 11px;' +"""Status label: Already installed.""" + +ACTION_CARD_STATUS_UPDATE = 'color: #d7ba7d; font-size: 11px; font-weight: bold;' +"""Status label: Update available (amber).""" + +ACTION_CARD_STATUS_UNAVAILABLE = 'color: #f48771; font-size: 11px;' +"""Status label: Plugin not installed (red-orange).""" + +ACTION_CARD_STATUS_RUNNING = 'color: #3794ff; font-size: 11px; font-weight: bold;' +"""Status label: Running… (blue).""" + +ACTION_CARD_STATUS_DONE = 'color: #89d185; font-size: 11px; font-weight: bold;' +"""Status label: Done (green).""" + +ACTION_CARD_STATUS_FAILED = 'color: #f48771; font-size: 11px; font-weight: bold;' +"""Status label: Failed (red-orange).""" + +ACTION_CARD_STATUS_SKIPPED = 'color: grey; font-size: 11px;' +"""Status label: Skipped.""" + +ACTION_CARD_LOG_STYLE = ( + 'QTextEdit {' + ' background: #1e1e1e;' + ' border: 1px solid palette(mid);' + ' border-radius: 3px;' + ' padding: 4px;' + ' margin-top: 4px;' + '}' +) +"""Inline log output area within an action card.""" + +ACTION_CARD_SKELETON_BAR_STYLE = 'QFrame { background: palette(mid); border-radius: 2px; }' +"""Placeholder bar used inside skeleton action cards.""" + +ACTION_CARD_COMMAND_STYLE = 'color: grey; font-size: 10px; font-family: Consolas, monospace;' +"""Muted monospace line showing the CLI command on each action card.""" + +ACTION_CARD_SPINNER_SIZE = 12 +"""Diameter (px) of the per-card inline checking spinner.""" + +ACTION_CARD_SPINNER_PEN = 2 +"""Pen width (px) for the per-card inline spinner arc.""" + +# --------------------------------------------------------------------------- +# Metadata skeleton card +# --------------------------------------------------------------------------- +METADATA_SKELETON_HEIGHT = 72 +"""Fixed height for the metadata skeleton card shown during loading.""" + +METADATA_SKELETON_STYLE = ( + 'QFrame#card {' + ' border: 1px solid palette(mid);' + ' border-radius: 6px;' + ' background: palette(midlight);' + ' padding: 8px;' + '}' +) +"""Muted card frame used as the metadata placeholder during loading.""" diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py new file mode 100644 index 0000000..def43c9 --- /dev/null +++ b/tests/unit/qt/test_action_card.py @@ -0,0 +1,884 @@ +"""Tests for the ActionCard and ActionCardList widgets.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock + +from porringer.schema import ( + SetupAction, + SetupActionResult, + SkipReason, +) +from porringer.schema.plugin import PluginKind +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from synodic_client.application.screen.action_card import ( + ActionCard, + ActionCardList, + action_key, + action_sort_key, +) +from synodic_client.application.theme import ( + ACTION_CARD_EXECUTING_STYLE, + ACTION_CARD_SKELETON_STYLE, + ACTION_CARD_STATUS_DONE, + ACTION_CARD_STATUS_FAILED, + ACTION_CARD_STATUS_NEEDED, + ACTION_CARD_STATUS_RUNNING, + ACTION_CARD_STATUS_SATISFIED, + ACTION_CARD_STATUS_SKIPPED, + ACTION_CARD_STATUS_UPDATE, + ACTION_CARD_STYLE, + LOG_COLOR_STDERR, + LOG_COLOR_STDOUT, + LOG_COLOR_SUCCESS, +) + +_app = QApplication.instance() or QApplication(sys.argv) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_action( + *, + kind: PluginKind | None = PluginKind.PACKAGE, + description: str = 'Install requests', + installer: str = 'pip', + package: str = 'requests', + **overrides: object, +) -> SetupAction: + """Create a mock SetupAction with sensible defaults. + + Extra keyword arguments are set as attributes on the mock, supporting + ``package_description``, ``include_prereleases``, ``command``, + ``cli_command``, and ``plugin_target``. + """ + action = MagicMock(spec=SetupAction) + action.kind = kind + action.description = description + action.installer = installer + pkg_mock = MagicMock() + pkg_mock.name = package + pkg_mock.configure_mock(**{'__str__': MagicMock(return_value=package)}) + action.package = pkg_mock + action.package_description = overrides.get('package_description', description) + action.command = overrides.get('command') + action.cli_command = overrides.get('cli_command') + action.include_prereleases = overrides.get('include_prereleases', False) + action.plugin_target = overrides.get('plugin_target') + return action + + +def _make_result( + *, + success: bool = True, + skipped: bool = False, + skip_reason: SkipReason | None = None, + message: str | None = None, + **overrides: object, +) -> SetupActionResult: + """Create a SetupActionResult. + + Extra keyword arguments (``action``, ``installed_version``, + ``available_version``) are forwarded to the constructor. + """ + return SetupActionResult( + action=overrides.get('action') or _make_action(), # type: ignore[arg-type] + success=success, + skipped=skipped, + skip_reason=skip_reason, + message=message, + installed_version=overrides.get('installed_version'), # type: ignore[arg-type] + available_version=overrides.get('available_version'), # type: ignore[arg-type] + ) + + +# --------------------------------------------------------------------------- +# ActionCard — skeleton +# --------------------------------------------------------------------------- + + +class TestActionCardSkeleton: + """Tests for skeleton ActionCard.""" + + @staticmethod + def test_skeleton_card_has_skeleton_style() -> None: + """Skeleton cards use the skeleton stylesheet.""" + card = ActionCard(skeleton=True) + assert ACTION_CARD_SKELETON_STYLE in card.styleSheet() + + @staticmethod + def test_skeleton_card_status_text_is_empty() -> None: + """Skeleton cards return empty status text.""" + card = ActionCard(skeleton=True) + assert not card.status_text() + + @staticmethod + def test_skeleton_populate_does_nothing() -> None: + """Calling populate on a skeleton card is a no-op.""" + card = ActionCard(skeleton=True) + action = _make_action() + card.populate(action) + assert not card.status_text() + + @staticmethod + def test_skeleton_set_check_result_does_nothing() -> None: + """set_check_result on skeleton is a no-op.""" + card = ActionCard(skeleton=True) + result = _make_result() + card.set_check_result(result) + assert not card.status_text() + + @staticmethod + def test_skeleton_set_executing_does_nothing() -> None: + """set_executing on skeleton is a no-op.""" + card = ActionCard(skeleton=True) + card.set_executing() + assert not card.status_text() + + +# --------------------------------------------------------------------------- +# ActionCard — populated +# --------------------------------------------------------------------------- + + +class TestActionCardPopulated: + """Tests for populated ActionCard.""" + + @staticmethod + def test_populate_shows_package_name() -> None: + """populate() fills the package label.""" + card = ActionCard() + action = _make_action(package='ruff') + card.populate(action) + assert card._package_label.text() == 'ruff' + + @staticmethod + def test_populate_shows_type_badge() -> None: + """populate() sets the type badge.""" + card = ActionCard() + action = _make_action(kind=PluginKind.TOOL) + card.populate(action) + assert card._type_badge.text() == 'Tool' + + @staticmethod + def test_populate_shows_description() -> None: + """populate() sets the description label.""" + card = ActionCard() + action = _make_action(package='ruff', package_description='A fast Python linter') + card.populate(action) + assert card._desc_label.text() == 'A fast Python linter' + + @staticmethod + def test_initial_status_is_checking() -> None: + """Card starts with 'Checking…' status (via spinner) after populate.""" + card = ActionCard() + action = _make_action() + card.populate(action) + assert card.status_text() == 'Checking\u2026' + assert card._checking + assert card._status_label.isHidden() + assert not card._spinner_canvas.isHidden() + + @staticmethod + def test_installer_missing_shows_not_installed() -> None: + """Card shows 'Not installed' when the plugin is missing.""" + card = ActionCard() + action = _make_action(installer='uv') + card.populate(action, plugin_installed={'uv': False}) + assert card.status_text() == 'Not installed' + + @staticmethod + def test_installer_present_shows_checking() -> None: + """Card shows spinner (Checking) when the plugin is installed.""" + card = ActionCard() + action = _make_action(installer='pip') + card.populate(action, plugin_installed={'pip': True}) + assert card.status_text() == 'Checking\u2026' + assert card._checking + + @staticmethod + def test_prerelease_checkbox_shown_for_packages() -> None: + """Pre-release checkbox is visible for package actions.""" + card = ActionCard() + action = _make_action(package='requests') + card.populate(action) + assert not card._prerelease_cb.isHidden() + + @staticmethod + def test_prerelease_checkbox_locked_by_manifest() -> None: + """Pre-release checkbox locked if manifest enables it and no user override.""" + card = ActionCard() + action = _make_action(include_prereleases=True) + card.populate(action, prerelease_overrides=set()) + assert card._prerelease_cb.isChecked() + assert not card._prerelease_cb.isEnabled() + + @staticmethod + def test_prerelease_checkbox_unlocked_if_user_override() -> None: + """Pre-release checkbox unlocked when user has an override.""" + card = ActionCard() + action = _make_action(package='requests', include_prereleases=True) + card.populate(action, prerelease_overrides={'requests'}) + assert card._prerelease_cb.isChecked() + assert card._prerelease_cb.isEnabled() + + +# --------------------------------------------------------------------------- +# ActionCard — dry-run check results +# --------------------------------------------------------------------------- + + +class TestActionCardCheckResult: + """Tests for set_check_result.""" + + @staticmethod + def test_needed_status() -> None: + """Non-skipped result shows 'Needed'.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result(success=True, skipped=False) + card.set_check_result(result) + assert card.status_text() == 'Needed' + assert ACTION_CARD_STATUS_NEEDED in card._status_label.styleSheet() + + @staticmethod + def test_already_installed_status() -> None: + """Skipped ALREADY_INSTALLED shows 'Already installed'.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + installed_version='3.5.2', + ) + card.set_check_result(result) + assert card.status_text() == 'Already installed' + assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet() + + @staticmethod + def test_update_available_status() -> None: + """Skipped UPDATE_AVAILABLE shows 'Update available'.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result( + skipped=True, + skip_reason=SkipReason.UPDATE_AVAILABLE, + installed_version='1.0.0', + available_version='2.0.0', + ) + card.set_check_result(result) + assert card.status_text() == 'Update available' + assert card.is_update_available() + assert ACTION_CARD_STATUS_UPDATE in card._status_label.styleSheet() + + @staticmethod + def test_version_transition_shown() -> None: + """Version label shows 'old → new' for update-available actions.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result( + skipped=True, + skip_reason=SkipReason.UPDATE_AVAILABLE, + installed_version='1.0.0', + available_version='2.0.0', + ) + card.set_check_result(result) + assert '1.0.0' in card._version_label.text() + assert '2.0.0' in card._version_label.text() + assert '\u2192' in card._version_label.text() + + @staticmethod + def test_installed_version_shown() -> None: + """Version label shows installed version for satisfied actions.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + installed_version='3.5.2', + ) + card.set_check_result(result) + assert card._version_label.text() == '3.5.2' + + @staticmethod + def test_finalize_checking_resolves_to_needed() -> None: + """finalize_checking stops spinner and changes to 'Needed'.""" + card = ActionCard() + card.populate(_make_action()) + assert card.status_text() == 'Checking\u2026' + assert card._checking + card.finalize_checking() + assert card.status_text() == 'Needed' + assert not card._checking + assert not card._status_label.isHidden() + + @staticmethod + def test_finalize_checking_leaves_not_installed() -> None: + """finalize_checking does not change 'Not installed'.""" + card = ActionCard() + card.populate(_make_action(installer='uv'), plugin_installed={'uv': False}) + assert card.status_text() == 'Not installed' + card.finalize_checking() + assert card.status_text() == 'Not installed' + + +# --------------------------------------------------------------------------- +# ActionCard — execution (inline log) +# --------------------------------------------------------------------------- + + +class TestActionCardExecution: + """Tests for execution-related methods.""" + + @staticmethod + def test_set_executing_shows_running() -> None: + """set_executing changes status to 'Running…' and expands log.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + assert card.status_text() == 'Running\u2026' + assert ACTION_CARD_STATUS_RUNNING in card._status_label.styleSheet() + assert not card._log_output.isHidden() + assert card._log_expanded + + @staticmethod + def test_set_executing_changes_border_style() -> None: + """set_executing applies the executing card style.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + assert ACTION_CARD_EXECUTING_STYLE in card.styleSheet() + + @staticmethod + def test_append_stdout() -> None: + """append_output with stdout stream adds coloured text.""" + card = ActionCard() + card.populate(_make_action()) + card.append_output('Processing package', 'stdout') + html = card._log_output.toHtml() + assert LOG_COLOR_STDOUT in html + assert 'Processing package' in html + + @staticmethod + def test_append_stderr() -> None: + """append_output with stderr stream uses amber colour.""" + card = ActionCard() + card.populate(_make_action()) + card.append_output('warning: something', 'stderr') + html = card._log_output.toHtml() + assert LOG_COLOR_STDERR in html + assert 'warning: something' in html + + @staticmethod + def test_append_html_escaping() -> None: + """Special HTML characters are escaped in output.""" + card = ActionCard() + card.populate(_make_action()) + card.append_output('', 'stdout') + html = card._log_output.toHtml() + assert '<script>' in html + + @staticmethod + def test_set_result_success() -> None: + """Successful result shows 'Done' with success message in log.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + result = _make_result(success=True, message='Installed ruff-0.8.0') + card.set_result(result) + assert card.status_text() == 'Done' + assert ACTION_CARD_STATUS_DONE in card._status_label.styleSheet() + assert ACTION_CARD_STYLE in card.styleSheet() + html = card._log_output.toHtml() + assert LOG_COLOR_SUCCESS in html + assert 'Installed ruff-0.8.0' in html + + @staticmethod + def test_set_result_failure() -> None: + """Failed result shows 'Failed' with error in log.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + result = _make_result(success=False, message='Network timeout') + card.set_result(result) + assert card.status_text() == 'Failed' + assert ACTION_CARD_STATUS_FAILED in card._status_label.styleSheet() + + @staticmethod + def test_set_result_skipped() -> None: + """Skipped result shows skip reason.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + result = _make_result( + success=True, + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + ) + card.set_result(result) + assert card.status_text() == 'Already installed' + assert ACTION_CARD_STATUS_SKIPPED in card._status_label.styleSheet() + + @staticmethod + def test_set_result_updates_version_on_upgrade() -> None: + """Successful upgrade updates version label to new version.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + result = _make_result( + success=True, + installed_version='1.0.0', + available_version='2.0.0', + ) + card.set_result(result) + assert card._version_label.text() == '2.0.0' + + @staticmethod + def test_toggle_log_visibility() -> None: + """Clicking the card toggles log visibility.""" + card = ActionCard() + card.populate(_make_action()) + card.set_executing() + assert card._log_expanded + assert not card._log_output.isHidden() + + card._toggle_log() + assert not card._log_expanded + assert card._log_output.isHidden() + + card._toggle_log() + assert card._log_expanded + assert not card._log_output.isHidden() + + +# --------------------------------------------------------------------------- +# ActionCardList +# --------------------------------------------------------------------------- + + +class TestActionCardList: + """Tests for ActionCardList container.""" + + @staticmethod + def test_show_skeletons() -> None: + """show_skeletons creates placeholder cards.""" + skeleton_count = 5 + card_list = ActionCardList() + card_list.show_skeletons(skeleton_count) + assert card_list.card_count() == skeleton_count + for i in range(skeleton_count): + c = card_list.card_at(i) + assert c is not None + assert c._is_skeleton + + @staticmethod + def test_populate_replaces_skeletons() -> None: + """Populate clears skeletons and creates real cards.""" + card_list = ActionCardList() + card_list.show_skeletons(3) + + action_count = 2 + actions = [_make_action(package=f'pkg-{i}') for i in range(action_count)] + card_list.populate(actions) + assert card_list.card_count() == action_count + for i in range(action_count): + c = card_list.card_at(i) + assert c is not None + assert not c._is_skeleton + + @staticmethod + def test_populate_skips_command_actions() -> None: + """Populate skips actions with kind=None.""" + card_list = ActionCardList() + a1 = _make_action(package='pkg1') + a2 = _make_action(package='pkg2') + a2.kind = None # bare command + card_list.populate([a1, a2]) + assert card_list.card_count() == 1 + + @staticmethod + def test_get_card_by_stable_key() -> None: + """get_card finds the correct card by stable content key.""" + card_list = ActionCardList() + a1 = _make_action(package='first') + a2 = _make_action(package='second') + card_list.populate([a1, a2]) + + c1 = card_list.get_card(a1) + c2 = card_list.get_card(a2) + assert c1 is not None + assert c2 is not None + assert c1 is not c2 + assert c1._package_label.text() == 'first' + assert c2._package_label.text() == 'second' + + @staticmethod + def test_get_card_cross_instance() -> None: + """get_card works with a different object that has the same content.""" + card_list = ActionCardList() + original = _make_action(package='numpy', installer='pip') + card_list.populate([original]) + + # Create a separate mock with the same content fields + duplicate = _make_action(package='numpy', installer='pip') + assert original is not duplicate + + card = card_list.get_card(duplicate) + assert card is not None + assert card._package_label.text() == 'numpy' + + @staticmethod + def test_get_card_returns_none_for_unknown() -> None: + """get_card returns None for an unknown action.""" + card_list = ActionCardList() + card_list.populate([_make_action()]) + unknown = _make_action(package='unknown') + assert card_list.get_card(unknown) is None + + @staticmethod + def test_clear_removes_all() -> None: + """Clear removes all cards.""" + actions = [_make_action(package='pkg1'), _make_action(package='pkg2')] + card_list = ActionCardList() + card_list.populate(actions) + assert card_list.card_count() == len(actions) + + card_list.clear() + assert card_list.card_count() == 0 + + @staticmethod + def test_finalize_all_checking() -> None: + """finalize_all_checking resolves pending cards to 'Needed'.""" + card_list = ActionCardList() + a1 = _make_action(package='pkg1') + a2 = _make_action(package='pkg2') + card_list.populate([a1, a2]) + + # Simulate: a1 gets a check result, a2 stays as 'Checking…' + c1 = card_list.get_card(a1) + assert c1 is not None + c1.set_check_result( + _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + ) + ) + + card_list.finalize_all_checking() + + assert c1.status_text() == 'Already installed' # unchanged + c2 = card_list.get_card(a2) + assert c2 is not None + assert c2.status_text() == 'Needed' # resolved + + @staticmethod + def test_prerelease_signal_forwarded() -> None: + """prerelease_toggled from a card is forwarded through the list.""" + card_list = ActionCardList() + action = _make_action(package='requests') + card_list.populate([action]) + + received: list[tuple[str, bool]] = [] + card_list.prerelease_toggled.connect(lambda name, checked: received.append((name, checked))) + + card = card_list.get_card(action) + assert card is not None + card._prerelease_cb.setChecked(True) + + assert len(received) == 1 + assert received[0] == ('requests', True) + + @staticmethod + def test_card_at_out_of_range() -> None: + """card_at returns None for out-of-range indices.""" + card_list = ActionCardList() + assert card_list.card_at(0) is None + assert card_list.card_at(-1) is None + + +# --------------------------------------------------------------------------- +# action_key — stable identity +# --------------------------------------------------------------------------- + + +class TestActionKey: + """Tests for the action_key function.""" + + @staticmethod + def test_same_content_same_key() -> None: + """Two actions with identical content produce the same key.""" + a = _make_action(package='numpy', installer='pip') + b = _make_action(package='numpy', installer='pip') + assert a is not b + assert action_key(a) == action_key(b) + + @staticmethod + def test_different_package_different_key() -> None: + """Actions with different packages produce different keys.""" + a = _make_action(package='numpy') + b = _make_action(package='scipy') + assert action_key(a) != action_key(b) + + @staticmethod + def test_different_installer_different_key() -> None: + """Actions with different installers produce different keys.""" + a = _make_action(package='numpy', installer='pip') + b = _make_action(package='numpy', installer='uv') + assert action_key(a) != action_key(b) + + @staticmethod + def test_different_kind_different_key() -> None: + """Actions with different kinds produce different keys.""" + a = _make_action(package='ruff', kind=PluginKind.PACKAGE) + b = _make_action(package='ruff', kind=PluginKind.TOOL) + assert action_key(a) != action_key(b) + + @staticmethod + def test_command_action_key() -> None: + """Command actions include the command in the key.""" + a = _make_action(command=['echo', 'hello']) + b = _make_action(command=['echo', 'hello']) + c = _make_action(command=['echo', 'world']) + assert action_key(a) == action_key(b) + assert action_key(a) != action_key(c) + + +# --------------------------------------------------------------------------- +# ActionCard — CLI command label +# --------------------------------------------------------------------------- + + +class TestActionCardCommandLabel: + """Tests for the CLI command label on action cards.""" + + @staticmethod + def test_default_package_command() -> None: + """Package actions show 'installer install package' by default.""" + card = ActionCard() + action = _make_action(package='ruff', installer='pip') + card.populate(action) + assert card._command_label.text() == 'pip install ruff' + assert not card._command_label.isHidden() + + @staticmethod + def test_explicit_cli_command() -> None: + """Actions with cli_command show that instead of the default.""" + card = ActionCard() + action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff']) + card.populate(action) + assert card._command_label.text() == 'uv tool install ruff' + + @staticmethod + def test_command_label_selectable() -> None: + """Command label text is selectable by mouse.""" + card = ActionCard() + action = _make_action() + card.populate(action) + flags = card._command_label.textInteractionFlags() + assert flags & Qt.TextInteractionFlag.TextSelectableByMouse + + +# --------------------------------------------------------------------------- +# ActionCard — per-card spinner +# --------------------------------------------------------------------------- + + +class TestActionCardSpinner: + """Tests for the per-card inline checking spinner.""" + + @staticmethod + def test_spinner_active_during_checking() -> None: + """Spinner timer is active while card is checking.""" + card = ActionCard() + card.populate(_make_action()) + assert card._checking + assert card._spinner_timer.isActive() + + @staticmethod + def test_spinner_stops_on_check_result() -> None: + """set_check_result stops the spinner.""" + card = ActionCard() + card.populate(_make_action()) + assert card._checking + card.set_check_result(_make_result()) + assert not card._checking + assert not card._spinner_timer.isActive() + assert card._spinner_canvas.isHidden() + + @staticmethod + def test_spinner_stops_on_finalize() -> None: + """finalize_checking stops the spinner.""" + card = ActionCard() + card.populate(_make_action()) + assert card._checking + card.finalize_checking() + assert not card._checking + assert not card._spinner_timer.isActive() + + @staticmethod + def test_spinner_stops_on_executing() -> None: + """set_executing stops the spinner if still checking.""" + card = ActionCard() + card.populate(_make_action()) + assert card._checking + card.set_executing() + assert not card._checking + assert not card._spinner_timer.isActive() + + @staticmethod + def test_no_spinner_for_unavailable() -> None: + """Unavailable (plugin missing) actions don't spin.""" + card = ActionCard() + card.populate(_make_action(installer='uv'), plugin_installed={'uv': False}) + assert not card._checking + assert not card._spinner_timer.isActive() + + +# --------------------------------------------------------------------------- +# action_sort_key — ordering +# --------------------------------------------------------------------------- + + +class TestActionSortKey: + """Tests for the action_sort_key function.""" + + @staticmethod + def test_runtime_before_package() -> None: + """Runtime actions sort before packages.""" + runtime = _make_action(kind=PluginKind.RUNTIME, package='python') + package = _make_action(kind=PluginKind.PACKAGE, package='numpy') + assert action_sort_key(runtime) < action_sort_key(package) + + @staticmethod + def test_scm_before_tool() -> None: + """SCM actions sort before tools.""" + scm = _make_action(kind=PluginKind.SCM, package='git') + tool = _make_action(kind=PluginKind.TOOL, package='ruff') + assert action_sort_key(scm) < action_sort_key(tool) + + @staticmethod + def test_alphabetical_within_kind() -> None: + """Same-kind actions are sorted alphabetically by package name.""" + alpha = _make_action(package='alpha') + beta = _make_action(package='beta') + assert action_sort_key(alpha) < action_sort_key(beta) + + @staticmethod + def test_case_insensitive() -> None: + """Package name comparison is case-insensitive.""" + upper = _make_action(package='Alpha') + lower = _make_action(package='alpha') + assert action_sort_key(upper) == action_sort_key(lower) + + +# --------------------------------------------------------------------------- +# ActionCardList — ordering & scroll +# --------------------------------------------------------------------------- + + +class TestActionCardListOrdering: + """Tests for card ordering in ActionCardList.""" + + @staticmethod + def test_cards_sorted_by_kind_then_name() -> None: + """Cards are sorted by kind priority, then alphabetically.""" + card_list = ActionCardList() + a_pkg_b = _make_action(kind=PluginKind.PACKAGE, package='beta') + a_tool = _make_action(kind=PluginKind.TOOL, package='ruff') + a_pkg_a = _make_action(kind=PluginKind.PACKAGE, package='alpha') + a_runtime = _make_action(kind=PluginKind.RUNTIME, package='python') + + # Populate in unsorted order + card_list.populate([a_pkg_b, a_tool, a_pkg_a, a_runtime]) + + expected = ['python', 'ruff', 'alpha', 'beta'] + assert card_list.card_count() == len(expected) + for i, name in enumerate(expected): + card = card_list.card_at(i) + assert card is not None + assert card._package_label.text() == name + + @staticmethod + def test_bare_commands_excluded() -> None: + """Actions with kind=None are excluded from the card list.""" + card_list = ActionCardList() + pkg = _make_action(kind=PluginKind.PACKAGE, package='requests') + cmd = _make_action(kind=None, package='run-something') + card_list.populate([pkg, cmd]) + assert card_list.card_count() == 1 + + @staticmethod + def test_scroll_to_card_bottom_exists() -> None: + """scroll_to_card_bottom method exists and is callable.""" + card_list = ActionCardList() + assert callable(card_list.scroll_to_card_bottom) + + +# --------------------------------------------------------------------------- +# ActionCard — ALREADY_LATEST skip reason +# --------------------------------------------------------------------------- + + +class TestActionCardAlreadyLatest: + """Tests for the ALREADY_LATEST skip reason from porringer.""" + + @staticmethod + def test_already_latest_shows_satisfied_style() -> None: + """ALREADY_LATEST check result uses the satisfied style.""" + card = ActionCard() + card.populate(_make_action()) + card.set_check_result( + _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_LATEST, + ) + ) + assert card.status_text() == 'Already latest' + assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet() + + @staticmethod + def test_already_latest_shows_version() -> None: + """ALREADY_LATEST preserves the installed version label.""" + card = ActionCard() + card.populate(_make_action()) + card.set_check_result( + _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_LATEST, + installed_version='1.2.0', + ) + ) + assert card._version_label.text() == '1.2.0' + + @staticmethod + def test_already_installed_vs_already_latest() -> None: + """ALREADY_INSTALLED and ALREADY_LATEST both use satisfied style.""" + card_installed = ActionCard() + card_installed.populate(_make_action(package='a')) + card_installed.set_check_result( + _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_INSTALLED, + ) + ) + + card_latest = ActionCard() + card_latest.populate(_make_action(package='b')) + card_latest.set_check_result( + _make_result( + skipped=True, + skip_reason=SkipReason.ALREADY_LATEST, + ) + ) + + assert card_installed.status_text() == 'Already installed' + assert card_latest.status_text() == 'Already latest' + # Both use the satisfied stylesheet + assert ACTION_CARD_STATUS_SATISFIED in card_installed._status_label.styleSheet() + assert ACTION_CARD_STATUS_SATISFIED in card_latest._status_label.styleSheet() diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 7900c34..c1997dc 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -72,6 +72,7 @@ def test_skip_reason_labels() -> None: def test_skip_reason_label_human_readable() -> None: """Verify skip reason labels are human-readable, not raw enum names.""" assert skip_reason_label(SkipReason.ALREADY_INSTALLED) == 'Already installed' + assert skip_reason_label(SkipReason.ALREADY_LATEST) == 'Already latest' assert skip_reason_label(SkipReason.UPDATE_AVAILABLE) == 'Update available' assert skip_reason_label(None) == 'Skipped' From e99f922bafe699a3778e31ec92b78d197860c5ea Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 20 Feb 2026 21:27:57 -0800 Subject: [PATCH 4/6] Perf --- pdm.lock | 62 ++++++------ pyproject.toml | 22 ++++- .../application/screen/action_card.py | 40 ++++++-- synodic_client/application/screen/install.py | 97 ++++++++++++++++--- tests/unit/qt/test_action_card.py | 52 +++++++++- tests/unit/qt/test_install_preview.py | 83 ++++++++++++++-- 6 files changed, 287 insertions(+), 69 deletions(-) diff --git a/pdm.lock b/pdm.lock index 60acace..f2a07d0 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "build", "lint", "test"] +groups = ["default", "build", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:1d736b2e77a2c3ef7e6a1c4ddd1c4bb3095848074761ab2a82f9dc450b517440" +content_hash = "sha256:322b631cbaf8628fb9710d64c42c7a88ca7dcf0aa97639790947246c3aeb9ff6" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -25,7 +25,7 @@ name = "annotated-doc" version = "0.0.4" requires_python = ">=3.8" summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, @@ -36,7 +36,7 @@ name = "annotated-types" version = "0.7.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "typing-extensions>=4.0.0; python_version < \"3.9\"", ] @@ -50,7 +50,7 @@ name = "anyio" version = "4.12.1" requires_python = ">=3.9" summary = "High-level concurrency and networking framework on top of asyncio or Trio" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", @@ -66,7 +66,7 @@ name = "certifi" version = "2026.1.4" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, @@ -77,7 +77,7 @@ name = "click" version = "8.3.1" requires_python = ">=3.10" summary = "Composable command line interface toolkit" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "colorama; platform_system == \"Windows\"", ] @@ -91,7 +91,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["default", "test"] +groups = ["default", "dev", "test"] marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -190,7 +190,7 @@ name = "h11" version = "0.16.0" requires_python = ">=3.8" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -201,7 +201,7 @@ name = "httpcore" version = "1.0.9" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "certifi", "h11>=0.16", @@ -216,7 +216,7 @@ name = "httpx" version = "0.28.1" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "anyio", "certifi", @@ -233,7 +233,7 @@ name = "idna" version = "3.11" requires_python = ">=3.8" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -269,7 +269,7 @@ name = "markdown-it-py" version = "4.0.0" requires_python = ">=3.10" summary = "Python port of markdown-it. Markdown parsing, done right!" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "mdurl~=0.1", ] @@ -283,7 +283,7 @@ name = "mdurl" version = "0.1.2" requires_python = ">=3.7" summary = "Markdown URL utilities" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -294,7 +294,7 @@ name = "packaging" version = "26.0" requires_python = ">=3.8" summary = "Core utilities for Python packages" -groups = ["default", "build", "test"] +groups = ["default", "build", "dev", "test"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, @@ -317,7 +317,7 @@ name = "platformdirs" version = "4.9.2" requires_python = ">=3.10" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, @@ -336,10 +336,12 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev46" +version = "0.2.1.dev49" requires_python = ">=3.14" +editable = true +path = "d:/Projects/Synodic/porringer" summary = "" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "httpx<1.0,>=0.28.1", "packaging>=26.0", @@ -348,17 +350,13 @@ dependencies = [ "typer[all]>=0.24.0", "userpath>=1.9.2", ] -files = [ - {file = "porringer-0.2.1.dev46-py3-none-any.whl", hash = "sha256:04ed9362295c1058982197f4e52fe26c11a5eb1c0c0197a165aae67332efa4ae"}, - {file = "porringer-0.2.1.dev46.tar.gz", hash = "sha256:674b4e26d4e31c254e2b4c1e04a47c2cacc11da6a4a789666e47eb68556a2176"}, -] [[package]] name = "pydantic" version = "2.12.5" requires_python = ">=3.9" summary = "Data validation using Python type hints" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "annotated-types>=0.6.0", "pydantic-core==2.41.5", @@ -375,7 +373,7 @@ name = "pydantic-core" version = "2.41.5" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "typing-extensions>=4.14.1", ] @@ -416,7 +414,7 @@ name = "pygments" version = "2.19.2" requires_python = ">=3.8" summary = "Pygments is a syntax highlighting package written in Python." -groups = ["default", "test"] +groups = ["default", "dev", "test"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -619,7 +617,7 @@ name = "rich" version = "14.3.3" requires_python = ">=3.8.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", @@ -672,7 +670,7 @@ name = "shellingham" version = "1.5.4" requires_python = ">=3.7" summary = "Tool to Detect Surrounding Shell" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -697,7 +695,7 @@ name = "typer" version = "0.24.0" requires_python = ">=3.10" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "annotated-doc>=0.0.2", "click>=8.2.1", @@ -715,7 +713,7 @@ version = "0.24.0" extras = ["all"] requires_python = ">=3.10" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "typer==0.24.0", ] @@ -729,7 +727,7 @@ name = "typing-extensions" version = "4.15.0" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -740,7 +738,7 @@ name = "typing-inspection" version = "0.4.2" requires_python = ">=3.9" summary = "Runtime typing introspection tools" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "typing-extensions>=4.12.0", ] @@ -754,7 +752,7 @@ name = "userpath" version = "1.9.2" requires_python = ">=3.7" summary = "Cross-platform tool for adding locations to the user PATH" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "click", ] diff --git a/pyproject.toml b/pyproject.toml index 17b9242..ce7b121 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.dev46", + "porringer>=0.2.1.dev49", "qasync>=0.28.0", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", @@ -25,9 +25,18 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = ["pyinstaller>=6.19.0"] -lint = ["ruff>=0.15.2", "pyrefly>=0.53.0"] -test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] +build = [ + "pyinstaller>=6.19.0", +] +lint = [ + "ruff>=0.15.2", + "pyrefly>=0.53.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] [project.scripts] synodic-c = "synodic_client.cli:app" @@ -94,6 +103,11 @@ serve = "zensical serve" [tool.pdm.build.wheel-data] data = [{ path = "data/**/*", relative-to = "data/" }] + +[tool.pdm.dev-dependencies] +dev = [ + "-e file:///d:/Projects/Synodic/porringer#egg=porringer", +] [build-system] build-backend = "pdm.backend" requires = ["pdm.backend"] diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 0f8bb40..1e77957 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -75,14 +75,16 @@ #: Sort priority for each :class:`PluginKind`. -#: Lower numbers appear first. Runtimes and SCM come before packages -#: so that infrastructure is set up first. +#: Lower numbers appear first. Matches the execution phase order +#: defined in ``porringer.backend.command.core.action_builder.PHASE_ORDER`` +#: so that cards are displayed in the same order they execute: +#: runtime → package → tool → project → SCM. _KIND_ORDER: dict[PluginKind | None, int] = { PluginKind.RUNTIME: 0, - PluginKind.SCM: 1, - PluginKind.PROJECT: 2, - PluginKind.TOOL: 3, - PluginKind.PACKAGE: 4, + PluginKind.PACKAGE: 1, + PluginKind.TOOL: 2, + PluginKind.PROJECT: 3, + PluginKind.SCM: 4, None: 99, # bare commands are excluded from ActionCardList anyway } @@ -103,9 +105,10 @@ def action_key(action: SetupAction) -> tuple[object, ...]: def action_sort_key(action: SetupAction) -> tuple[int, str]: """Return a sort key so cards are grouped by kind then alphabetical. - The ordering places infrastructure kinds (runtime, SCM) before - packages / tools. Within a group, actions are sorted - case-insensitively by package name. + The ordering matches the execution phase order + (runtime → package → tool → project → SCM) so that displayed + cards appear in the same sequence as they execute. Within a + group, actions are sorted case-insensitively by package name. """ kind_order = _KIND_ORDER.get(action.kind, 50) pkg_name = str(action.package.name).lower() if action.package else '' @@ -410,6 +413,7 @@ def populate( self._command_label.hide() # Version — populated later by set_check_result() + self._version_label.setText('') # Status — check plugin presence first @@ -448,6 +452,24 @@ def populate( else: self._prerelease_cb.hide() + def update_command(self, action: SetupAction) -> None: + """Update the CLI command label after the resolved preview arrives. + + Called from the two-phase display flow once ``MANIFEST_LOADED`` + provides actions with their ``cli_command`` populated. + + Args: + action: The setup action with resolved CLI command. + """ + if self._is_skeleton: + return + cmd_text = _format_command(action) + if cmd_text: + self._command_label.setText(cmd_text) + self._command_label.show() + else: + self._command_label.hide() + def initial_status(self) -> str: """Return the initial status text set during :meth:`populate`.""" if self._is_skeleton or not hasattr(self, '_status_label'): diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 2ca353b..d1f12a1 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -658,6 +658,25 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p self._install_btn.setEnabled(True) + def on_preview_resolved(self, preview: SetupResults) -> None: + """Handle the fully-resolved preview (CLI commands populated). + + Called after ``MANIFEST_LOADED`` — cards are already visible + from the earlier ``on_preview_ready`` call. This method + updates the CLI command display text on each existing card. + + Args: + preview: The fully-resolved setup results with CLI commands. + """ + if self._preview is None: + return + + for action in preview.actions: + if action.cli_command: + card = self._card_list.get_card(action) + if card is not None: + card.update_command(action) + def on_action_checked(self, row: int, result: SetupActionResult) -> None: """Update the data model and action card with the dry-run result.""" if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: @@ -1025,6 +1044,7 @@ def start(self) -> None: prerelease_packages=overrides or None, ) + preview_worker.manifest_parsed.connect(self._on_manifest_parsed) preview_worker.plugins_queried.connect(self._preview_widget.on_plugins_queried) preview_worker.preview_ready.connect(self._on_preview_ready) preview_worker.action_checked.connect(self._preview_widget.on_action_checked) @@ -1034,10 +1054,10 @@ def start(self) -> None: self._runner = preview_worker self._runner.start() - # --- Preview callback (intercepts to capture temp dir) --- + # --- Preview callbacks (intercept to capture temp dir / metadata) --- - def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: - """Capture the temp dir path and forward to the preview widget.""" + def _on_manifest_parsed(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: + """Handle the fast MANIFEST_PARSED event — show cards immediately.""" self._temp_dir_path = temp_dir_path # Update window title from metadata @@ -1046,6 +1066,22 @@ def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_ self._preview_widget.on_preview_ready(preview, manifest_path, temp_dir_path) + def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: + """Handle the fully-resolved MANIFEST_LOADED event. + + At this point CLI commands are populated on each action. We + forward to the preview widget so it can refresh any command + display text, but cards are already visible from the earlier + ``_on_manifest_parsed`` handler. + """ + self._temp_dir_path = temp_dir_path + + # Update window title from metadata (in case it changed) + if preview.metadata and preview.metadata.name: + self.setWindowTitle(f'Install Preview — {preview.metadata.name}') + + self._preview_widget.on_preview_resolved(preview) + def _on_prerelease_changed(self) -> None: """Re-run the preview with the updated pre-release setting.""" self.start() @@ -1064,10 +1100,22 @@ class PreviewWorker(QThread): Combines two stages into a single background pipeline: 1. Download the manifest (if remote). - 2. Run ``execute_stream`` with ``dry_run=True`` to list actions and check status. + 2. Run ``execute_stream`` with ``dry_run=True`` to stream events. + + The worker emits signals in phases: + + * ``manifest_parsed`` — emitted as soon as the JSON is loaded, + before plugin discovery. GUI clients can populate cards + immediately from this. + * ``plugins_queried`` — emitted after porringer discovers plugins, + carrying plugin name → installed mapping. + * ``preview_ready`` — emitted once CLI commands are resolved. + * ``action_checked`` — emitted per-action as dry-run results + stream in (may arrive out of order due to parallel checks). """ - preview_ready = Signal(object, str, str) # (SetupResults, manifest_path, temp_dir_path) + manifest_parsed = Signal(object, str, str) # (SetupResults, manifest_path, temp_dir_path) — fast preview + preview_ready = Signal(object, str, str) # (SetupResults, manifest_path, temp_dir_path) — fully resolved action_checked = Signal(int, object) # (row_index, SetupActionResult) plugins_queried = Signal(object) # dict[str, bool] — plugin name → installed finished = Signal() @@ -1138,13 +1186,19 @@ def run(self) -> None: self.error.emit(str(exc)) async def _dry_run(self, manifest_path: Path, temp_dir: str) -> None: - """Stream dry-run events, emitting preview_ready and action_checked signals.""" - # Query plugin presence before the dry-run so the widget can - # annotate actions whose installer is not available. - plugins = self._porringer.plugin.list() - plugin_installed = {p.name: p.installed for p in plugins} - self.plugins_queried.emit(plugin_installed) + """Stream dry-run events, emitting signals as each phase completes. + + The new event pipeline is: + + 1. ``MANIFEST_PARSED`` → emit ``manifest_parsed`` (fast, before discovery) + 2. ``PLUGINS_DISCOVERED`` → emit ``plugins_queried`` + 3. ``MANIFEST_LOADED`` → emit ``preview_ready`` (CLI commands populated) + 4. ``ACTION_COMPLETED`` → emit ``action_checked`` (per-action status) + Falls back to the previous behaviour when the porringer version + does not emit the newer event kinds (``MANIFEST_PARSED`` / + ``PLUGINS_DISCOVERED``). + """ params = SetupParameters( paths=[manifest_path], dry_run=True, @@ -1153,10 +1207,29 @@ async def _dry_run(self, manifest_path: Path, temp_dir: str) -> None: prerelease_packages=self._prerelease_packages, ) action_index: dict[int, int] = {} + got_parsed = False async for event in self._porringer.sync.execute_stream(params): - if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: + if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: + # Fast path: cards can be shown immediately action_index = {id(a): i for i, a in enumerate(event.manifest.actions)} + self.manifest_parsed.emit(event.manifest, str(manifest_path), temp_dir) + got_parsed = True + + elif event.kind == ProgressEventKind.PLUGINS_DISCOVERED: + # Use the plugin availability from porringer's own discovery + # rather than making a separate plugin.list() call. + if event.plugin_availability is not None: + self.plugins_queried.emit(event.plugin_availability) + elif event.plugin_names is not None: + # Fallback: only names available, assume all installed + self.plugins_queried.emit({name: True for name in event.plugin_names}) + + elif event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: + # Fully-resolved preview with CLI commands + if not got_parsed: + # Fallback: older porringer without MANIFEST_PARSED + action_index = {id(a): i for i, a in enumerate(event.manifest.actions)} self.preview_ready.emit(event.manifest, str(manifest_path), temp_dir) elif event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action: diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index def43c9..1c27af3 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -683,6 +683,42 @@ def test_command_label_selectable() -> None: flags = card._command_label.textInteractionFlags() assert flags & Qt.TextInteractionFlag.TextSelectableByMouse + @staticmethod + def test_update_command_updates_text() -> None: + """update_command replaces the command label text.""" + card = ActionCard() + action = _make_action(package='ruff', installer='pip') + card.populate(action) + assert card._command_label.text() == 'pip install ruff' + + resolved = _make_action(cli_command=['uv', 'tool', 'install', 'ruff']) + card.update_command(resolved) + assert card._command_label.text() == 'uv tool install ruff' + assert not card._command_label.isHidden() + + @staticmethod + def test_update_command_hides_label_when_empty() -> None: + """update_command hides the label when the resolved action has no command.""" + card = ActionCard() + action = _make_action(package='ruff', installer='pip') + card.populate(action) + assert not card._command_label.isHidden() + + empty_action = _make_action(kind=PluginKind.RUNTIME) + empty_action.cli_command = None + empty_action.command = None + empty_action.package = None + card.update_command(empty_action) + assert card._command_label.isHidden() + + @staticmethod + def test_update_command_noop_on_skeleton() -> None: + """update_command does nothing when card is a skeleton.""" + card = ActionCard(skeleton=True) + action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff']) + # Should not raise — skeleton simply returns early + card.update_command(action) + # --------------------------------------------------------------------------- # ActionCard — per-card spinner @@ -756,11 +792,18 @@ def test_runtime_before_package() -> None: assert action_sort_key(runtime) < action_sort_key(package) @staticmethod - def test_scm_before_tool() -> None: - """SCM actions sort before tools.""" + def test_tool_before_scm() -> None: + """Tool actions sort before SCM (matches execution phase order).""" + tool = _make_action(kind=PluginKind.TOOL, package='ruff') scm = _make_action(kind=PluginKind.SCM, package='git') + assert action_sort_key(tool) < action_sort_key(scm) + + @staticmethod + def test_package_before_tool() -> None: + """Package actions sort before tools (matches execution phase order).""" + package = _make_action(kind=PluginKind.PACKAGE, package='numpy') tool = _make_action(kind=PluginKind.TOOL, package='ruff') - assert action_sort_key(scm) < action_sort_key(tool) + assert action_sort_key(package) < action_sort_key(tool) @staticmethod def test_alphabetical_within_kind() -> None: @@ -797,7 +840,8 @@ def test_cards_sorted_by_kind_then_name() -> None: # Populate in unsorted order card_list.populate([a_pkg_b, a_tool, a_pkg_a, a_runtime]) - expected = ['python', 'ruff', 'alpha', 'beta'] + # Execution-phase order: RUNTIME(0) → PACKAGE(1) → TOOL(2) + expected = ['python', 'alpha', 'beta', 'ruff'] assert card_list.card_count() == len(expected) for i, name in enumerate(expected): card = card_list.card_at(i) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index c1997dc..0fb51bb 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -10,7 +10,6 @@ from porringer.schema import ( CancellationToken, DownloadResult, - PluginInfo, ProgressEvent, ProgressEventKind, SetupActionResult, @@ -643,15 +642,16 @@ def test_emits_plugins_queried(tmp_path: Path) -> None: manifest.write_text('{}') porringer = MagicMock() - porringer.plugin.list.return_value = [ - PluginInfo(name='pip', kind=PluginKind.PACKAGE, version=MagicMock(), installed=True, tool_version=None), - PluginInfo(name='uv', kind=PluginKind.PACKAGE, version=MagicMock(), installed=False, tool_version=None), - ] preview = SetupResults(actions=[]) + plugins_event = ProgressEvent( + kind=ProgressEventKind.PLUGINS_DISCOVERED, + plugin_availability={'pip': True, 'uv': False}, + ) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield plugins_event yield manifest_event porringer.sync.execute_stream = mock_stream @@ -672,12 +672,16 @@ def test_plugins_queried_emitted_before_preview_ready(tmp_path: Path) -> None: manifest.write_text('{}') porringer = MagicMock() - porringer.plugin.list.return_value = [] preview = SetupResults(actions=[]) + plugins_event = ProgressEvent( + kind=ProgressEventKind.PLUGINS_DISCOVERED, + plugin_availability={}, + ) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield plugins_event yield manifest_event porringer.sync.execute_stream = mock_stream @@ -691,6 +695,71 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: assert order == ['plugins', 'preview'] + @staticmethod + def test_emits_manifest_parsed(tmp_path: Path) -> None: + """Verify manifest_parsed is emitted from MANIFEST_PARSED events.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + + preview = SetupResults(actions=[]) + parsed_event = ProgressEvent( + kind=ProgressEventKind.MANIFEST_PARSED, + manifest=preview, + ) + loaded_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield parsed_event + yield loaded_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + + parsed_data: list[Any] = [] + worker.manifest_parsed.connect(lambda *a: parsed_data.append(a)) + worker.run() + + assert len(parsed_data) == 1 + + @staticmethod + def test_two_phase_signal_order(tmp_path: Path) -> None: + """Verify the full two-phase signal order: parsed → plugins → ready.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + + preview = SetupResults(actions=[]) + parsed_event = ProgressEvent( + kind=ProgressEventKind.MANIFEST_PARSED, + manifest=preview, + ) + plugins_event = ProgressEvent( + kind=ProgressEventKind.PLUGINS_DISCOVERED, + plugin_availability={'pip': True}, + ) + loaded_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield parsed_event + yield plugins_event + yield loaded_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + + order: list[str] = [] + worker.manifest_parsed.connect(lambda *_: order.append('parsed')) + worker.plugins_queried.connect(lambda _: order.append('plugins')) + worker.preview_ready.connect(lambda *_: order.append('ready')) + worker.run() + + assert order == ['parsed', 'plugins', 'ready'] + class TestPreviewWorkerUpdateDetection: """Tests for PreviewWorker passing update-detection flags to porringer.""" @@ -702,7 +771,6 @@ def test_passes_detect_updates_and_prerelease_packages(tmp_path: Path) -> None: manifest.write_text('{}') porringer = MagicMock() - porringer.plugin.list.return_value = [] preview = SetupResults(actions=[]) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) @@ -733,7 +801,6 @@ def test_defaults_detect_updates_true(tmp_path: Path) -> None: manifest.write_text('{}') porringer = MagicMock() - porringer.plugin.list.return_value = [] preview = SetupResults(actions=[]) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) From 3f4f438c2147047d474bae1c578566c65566c7ab Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 20 Feb 2026 21:41:17 -0800 Subject: [PATCH 5/6] Update action_card.py --- synodic_client/application/screen/action_card.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 1e77957..e54e4ad 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -216,6 +216,7 @@ def __init__( self._is_skeleton = skeleton self._log_expanded = False self._checking = False + self._check_available_version: str | None = None if skeleton: self._init_skeleton_ui() @@ -516,6 +517,7 @@ def set_check_result(self, result: SetupActionResult) -> None: self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED) # Version column + self._check_available_version = result.available_version if result.installed_version and result.available_version: self._version_label.setText(f'{result.installed_version} \u2192 {result.available_version}') self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: #d7ba7d;') @@ -603,8 +605,9 @@ def set_result(self, result: SetupActionResult) -> None: f'\u2713 {html_mod.escape(msg)}', ) # Update version if an upgrade completed - if result.available_version: - self._version_label.setText(result.available_version) + new_version = result.available_version or self._check_available_version + if new_version: + self._version_label.setText(new_version) self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE) else: self._status_label.setText('Failed') From 6d6c72297136312af15dd2ac1a473b5b97ed673e Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 23 Feb 2026 12:21:01 -0800 Subject: [PATCH 6/6] AutoStart + Ico --- data/icon.ico | Bin 0 -> 99250 bytes synodic_client/application/bootstrap.py | 8 + synodic_client/application/qt.py | 9 +- synodic_client/application/screen/tray.py | 33 +++- synodic_client/client.py | 1 + synodic_client/config.py | 5 + synodic_client/resolution.py | 16 ++ synodic_client/startup.py | 91 +++++++++++ synodic_client/updater.py | 10 +- tests/unit/test_config.py | 11 ++ tests/unit/test_resolution.py | 23 +++ tests/unit/windows/test_startup.py | 190 ++++++++++++++++++++++ tool/pyinstaller/synodic.spec | 1 + tool/scripts/common.py | 1 + tool/scripts/package.py | 4 +- 15 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 data/icon.ico create mode 100644 synodic_client/startup.py create mode 100644 tests/unit/windows/test_startup.py diff --git a/data/icon.ico b/data/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0578378862a4b163caf6f9993d8ea00ccfa386a5 GIT binary patch literal 99250 zcmV(~K+nGb00962000000096X0GxvX02TlM0EtjeM-2)Z3IG5A4M|8uQUCw}00001 z00;&E003NasAd2FfB;EEK~#9!l$~{;WJi_oe^qs*<>|Ov+})J~LLd-=1X-NL7hM)z z2oAdo3oI<|1Xx@X5{MFFB*Zh3$z&ws`mFUz)%VA}-Bt9K}rY{+T=>5pQVn{whl;21{L;7;>hRBS^7!a7=AfpB{{iNCy%<{<( zF#l7;{}=u7h+KCf)s7MIZzBdYZ%U^sGEXEkHaXaf)Y~R*T%;Ewzc6usBK4Gs-z*1A z5ojiNzxb}neKVQ+qHHk=Xi&abCXXW}V9hsG4$DFl#ks{dvqQ?QmgpmV(z2ku#XKIB zwUr;hz`6ks+Tfgra-7c1oTVCEaDFVR{26MiNoL(6V6`K_ODlm%ouE#6c+DW1P{t zrmhS?lmgH)a~?43enWXSB}x&$Frb8C4s1CK%JNy7RDWf^d;iTcRFhhm3>!`UlSjCe zAj?86LofYTOfrECktgl;(WPuDQe7qEOJ5v7UR&KfE@TRzCXW#V`W6G; zFF}R^7fcYe&*_9He}A@#=fC$6H2W=j&h+syi(WcS`elvw-;Z81NgHL{qOh2Wgf6sK z+S8!fk-1-@Jc&$Kxi(F4{CWBpn2dl;z+w7pQJhMC z4rPB+Ovji^e`T=j@u?yP=cR9UOlgLl@Xc%=BGX!mKjiKkVe&6ficCdSn*xInDH!A! zi|lLCBHe&sFyiOGXaDsl)dN+IONo+eB{Q8xRUI_W*(C`BpAso1lW#Bk`zf%XSzy&$ zkTkj^aw+q;nWf!9!|Q(xS>!02TYB!4&R?X)*Js3{w8QL72R$N~?M6KE0b>>E(Uf$U zM&dMXrTgrw5~)5!?)e-pi(GqRR(vbL^URoYYhC(A{%`(Yv4`TbWs6X*y#rW1c95Wq z8PM6_q0ja)J$2Yb622!av&(5x|1Fzf-nPt-CS|zvI!LSGxiM$d!xG8VlPDthZt1Lh zwo^uO#qFonKS(%ne&!jjT%tOsX5H`VDRHCpxJ*vmn3%A7m-9+|_Sr0NnuSvpE{W`Q zk$W_+S!7^G$r`2Z=gmYzvhJBSa$1!T(PO3z*Yoo!2Xyw$CPpJNfEJ|yDC1b=94sxB2FfE-2F@}D zV@mrm;>n#dC6LbkF0*Y1wKFRY=85geBtuz)y|wF83BC2n&`8dYWeR=Lf3G-}rwAXD z`}b7f^JG9Ps0JxDKQ+9l%W_T5ls7hTZdtMxnd#*J7TIXA^>X4(^^=!}srx-`BflP^ zl-d%ZDCjc)6Fafu%VmoL9*e{5gS>y;B>yV_!j$(| zs-ooKF$g|oa4nmgDfwA!Grf-eg*3%>2IJQNoHu=}&jIeK%_7}$Lf7&5O?s75EGyR? z=`Kq(uL1rflR!!uV-lFmI($?9HK$f)_;10BVp38k{XKbp5YV0+@eLWs#ltG1TyEhC zkTEILFfn6F-Yo5N;3&)Ff|#ZM{M%;MyHA}t5FIlfg<1e&+x0a{L*fBBtO}V2UaH&kK~y^n04R$!Q2FH%x)7M4>YZ zOIOmd)oZ;d+>?Hlkx*&oN)iTqYVV5y_wv_^ z&!o+6Aq>XEUKx|0L|P)`+Y!Y_3KFDboC9q1h-m128&YI%XZ!{| zYb0-<fm{o86Q<6DWjU$yg&#Z~yS~j`a z2)oR0LxLCmCUTZe8Q}V8Ys!hwm>xm*aG+!esj&#akX;lYq2D1x^j1fJ@0RdTLV z=3R*oU_eU+jv`_=R)zznpjhxIJ{5w`&MyOpetV+dd{1^}@y?XBP}+YQFY;OUrZD#0 zJX7;72#g%XMoMqi{3z4NMXK4X*49@VQzH>E-t}zlq^HDA%?>wC!HAe>F9t4%vOoJ4 zrN}aYlnq?^F=Y8m4$^61nf3X^_n%A}r*+l7gT`cGp0SsDqZXNE%eI$W&(s+DCX6V< zf&5Sm={**`6^Op%!i+g<*igjK#0^v;x`oN4nPhwhC7<+K^chc@gi<(S=xG2Zi^F|j zq%MjS2REt3>2ok6`2VE!B82)sMaHJ-_m$$H<;rV*!Lqqqspd01mr32hVyH46#vZF(hPl&xg`>q z4x-I_xcQR@HcNNSFjc23QaA7m?{d?PN+M zYgSW9Hn`*oucrZIp_mLiCB0KO%IyC18f4ot*?tE+pO@~Ukn$!HCeeE%BkFpv$TCEc zdbdA*A^K)zVI5N#AxtlO)ue|Owbv7j^zc!nFfg^IDQZtpAt%BL<4BOrWTBmdSUGMF z0Co;B(>{O!#=a?BJ`V#)F2!Iu-oMf# zmJCCBay*IpGZ`+E)>sPqQR-_U0F&3l>8aZ#!x(7sq=>JlkfatcA^t&_lzR3l2!rx0 zz8H4poVWDpL7(26vM9>Ttj!emgAD9LJcTO>LTB&KhFm=##FGOtQE#*IFr)hC%z-@M z%lKkX12Cn%rTQzubCY>8f4`@<3`kZ=t$o4v%nUOp21J@*o*ZV`CzG`hg>~xv8o+!8 zjK}0wx_l-jK9)iD2PqRIrLCm>C#FA#lqpGSy;JIcqWOqSJIPSfnnTim|F4KNj9wFY zJP?`pV+LIc(bAa?f>L$>1K~x++K^@X>>%zlNHv{xK!wFFV+^97Eb(ML^np;8a}f73 z+cxP!CU{CR=9xrBiO#E(#w31`*a1-7;8e!Ik^o?Km}P{V(@{h@a%461BIzbJXBlA9 z_FO@K9Qb^m0hPVkpJ8FU?qEHvcs<2jOtLnaZ$-tU)C9<^W1k`C)XFpy$h2D~LQ)G; zYDy$udKy%(sh&pIqp7q4Qm!9mL$ku9F;tYiFpHXz>O`igS_o0-XyD2ed;-aUL}ro7 zmo)=CLCOPKt|yd{Z@Cms*@3ws4$>>4zs1C&0!a#yLH8{3%tp@TV>J18QlK4#(}PH7 z5Y%|m=bv03&B7?pDvN!lRrDuk=~?EK->g$M=TiH(Fp#tzkQ;Ff*uKUfiv1Ote-ZL| zGjAa9Om8?b4ke1&EGtwr?|Sy7fmzImGSM*jsbn+IEFjOcXLwSaDOU!2O^E!%DHUi? z-zD-TEwBa-^vOTTloIuu{J{8mb95>RGGmH8f5vfa_cxt|K&DQ0`F9MZi>V9+s%%=4}j8qj^ezEpS-Bb zTPfuUYFQI8E%gUaI?J8XnTrFTE2EbIin+|(oJ|U+br>^f&_%G70jfl4A4sk__h5cn zPdbiS%Xsu~Bt=ANIh4=K#lN@w0w=be7d3&ra4e96^ zDFjM^QUWEj`x6U>)qt$0tokoX#ZuNEViGu{g-J$IXCWbS4W*Z40Da|N%&tpbEf?A5 zQ|q69p?D#wT16rMqM%3jX)+Q2Pr(;h>^f~b&@(3o3A`fn=--LgL#FP>ujHLQInbq< zN`XMitM8Sm>HQRzfshtY0-)k*7ltz!9GMHf#G1kg22!NRvVv5_nuL+$7*;CAmp72I zI)%w~{v?J#{G1fM@=oRRPQyhC2m(!OCZIhN(Cvf>nSCacewv1{0437bv&M6Jv;}`f znhB);7L2U~q01G{?0OeJd4?s5>ud7Cwe%oU1l4~aJx`Is;UZm>{LHNx8AcH0jLi0S zlvC0V$)=b`9(tmLj#5+3Y5J+nKu_knE4!Lz;0afrjRe!nkpNf}`l0_sae`@2L+_d? zZ6SYcR?Q*a=uwb8{Q(qf<@9^)$B5Grh7yRDv{Me90l&*sWfLKX=NtSW$$m&vUB;{t zhK{FMwLtNMk1pk^FR$X_lWKIjAu`z+63QG0|iaa)CC1YVd$_(x_1{KVi z*>tX1*=EaQ5eDB3gWhuDy@IXZC*K0ppfhskxG>_@S zDXj;k#dk8tqSvg*R)t+#6LsI`EQx2Wuc`GpR%$@;XoTpIv&bM_7NCRFlR}Bk0neqE zJIgMTyC&E2puT(ZB}=Y3IoJP@tCgAppDFZSm`jY75dNE zS((C&gWfN4u{?jx+jt`Vq$#uUMCR?ZW|8hFX(wy5_ZDm*@nh(39LfweB`(sOHa!|) z(9o;cP8!|&+LHh&3|-G~#FBt}zgFXx&(&En6yoa=v&KX*-=J$abxp|8bN0}^cL$x@ zcA)Os%q5H35XN#ZXY)tG+n~dMAG}d<-{f=Un~cI<@>IE$ooxVVC8f8+uJ>MXA&c$w(ty(-F77T9$(@{; zmfy%x?i(u0KoYpA{ZujiiKrnQl`y{sb!Nv6NLh&TBc@ z2eO;Sve=e4j7;(MVvBtfAp4)}^J!Xmav}dzA4&lruYwm~U3jCcPm^geeJj0c$?}UF z7DYBP*|jg>bBXy-W?$v{?ROiKZ5gkO|43ZBY7QUx`V`;3rc1RZ(K^*f zUo{shWCcmdb31|l)A6(Pr;|I2}D?ZokcX;U?!_0&>3efrC ziO6y2YZ$izme({7OxZ}KDBETn;lIJQG(UOY9F~k$xa5;N*flM%ErIR})?l&$kXfCy7eMrXCC>UR z@Y6@2<(XFbyhPuYQxde%Xd|#B5doCG5t&2hEtz4C-Qejf9lotqqV^Wn$e<0lmgJF{ z=ypQ0w-c!@m$n2|;L3LLf!&XqK!8%*4<3XDE6mXcjRN zODk!k3_k|pPhl%}8ZH?P#1tFpUGJB)EB2Sc&soNiy;H=y;z?eE-tECCQ)U!l*A6g< z!MR=O;8vfbnn5x%3>oJ!c!iyeWMqhfNyta;{`jCq5>s0gQ5s}#U0EDUCap`+Kek(Beq2{Frd(@TT zdk3TIQo%#WpYHhPc-_2}xSpj~z zLqpAzs40CfY2M45UDQFWDm${GT4e_GS2RMh+D@;vJo%GvN%Y=X+gwO}&7%VALYJAZs>iJMb>N`YgcK=^!2(^hR zTojo|x{yJ?AwYtX0;QroV^R=@3S$W9sQrG4?7lEh#^`yG;BZ};?Jfl`DWTKVyyfC? z{{6Lc*uKl-4WHS{eOsYw2|}G(?GzzrS0yd8iwC17UuKh)DdWE2lPDli2?pkyN*gqJ ztN+P0dGgC!UHPy}Dps9w zG!PSIn%s+Oe@eS5vFb_yoO1zW+bdd7Szg<-@kEZuMdoY%(_6H_3%LGMhO^JCLsK&O z$P_1i%S(4Lnwgfy524-FDIp+13WO!FRP;~A z$fiwFIX7-TnMXzPMw9}ZtExbw?n)M^3(pTGQaAJ6)c#Hm>5Fa>DwRy&pWjJ$MXYKc0ka_VCQ>&Tc*roinw|XK!lJbSfx>qt#OSMO)y)s2o zYpA%I#rtO9x+dO!&6o(8>khNNNm7zEHmwW^;RNwDB@?FQhYra54N z;lL$=1D7ZcTq#+(z+&MX!PuC?P~AmV?Z}8%HoZMy7!$en5ny5ot>XV^1v|IG?TjWW-<8P)lfNwr2WfdfvgD zHccjy_Ow$Qp+Q^DKm8pcBxV3D~h8HtdFNdqZ~a zH|(Ai?3#qB8EAH)jvm^zmry#%q=R@C^4_Y>k9gpX-_V%Fc~ zZ=^Ml^zXDvk`@GIY|cC>eU%#|Icm#k0I?G=k+lN}Hf7hF0s|-mM{)Ss!43+4l$#($ z(57p&q(2EW$d4d#9X-{+NjbmmnaM$D2s#4G71$0@=4_f-#L}k|!11FYa~;i1dnKWe zs8n%INn{5bpc{bhMnHAlfFqWKoPC7mg5yF?Iz%vUp$$?pxnJ|>Hp!z~CHFr8k8Xj- zwi@5zS#Sh1O^>TGCnMrJ7%$Fxy!MK);QttA&xw_ z&Z^}O)~JnXY93$L=5PO);o3V}+;)GH%{zUx7N8VPMPWbi(M*Mg zn!d=G+dh*gnTDqY?^jo_k>^JUnXIhTbz(dDi7S?s;k{k#J@GagaI z#y;~Y2%q9nWLjJO4>HFFo2>0F(7t43wVpb zFXy*~3Flf&+(oGwW*kK2V@kIu1^A(6#TdI^9i>^gND_7gYd6Eq4-0O4RC3=Y!G>LuW?K@m z4*^2P_IMQQm%SZ+DWvL4ojhDJmkbPGj*)#IF}^U=v1rynh*E~RqmqMGIGlV`opVnb zB@bflnRQGlbcHmLLBc8L49F6D+VN-#Y@-K2QE(EvB{#cAgG zbB=wKCGn>?tI740-=Q~KrnRLEt%l}HFAI3*8w0NWgUvJl$HuLRl*1||flQ>brpN7t z5Oe{fSu&@~^N#88veSJ|JIuoxl5E|fxb}XFYwoxC`(ug?dnCq4K!GK&ZGjy--h_$B zFwlm;L^D)?isveOo|vK}>$?2>PrLZ_)jPR+U7L<)p&XZLMMfb;oeDFWtS+Y% zv}WKZ@3i@+SG#=fzh-#f&qC_M7D1S@ybFA;U>TDf?Vk1#iE`~Uk6t+p&L|z*u?3&@pyd<9)dtf zJR#9CPWe@eUMx^xumvb%kf~Jh{)~H0%Rm!&y3Y_6&$l@L)KOk`(PGX&cQNjm#g<1V zx%@Xf`0*ch@$jY)gu<;k*p}>vFVhGvg`w>mEHp2vR z!l6K2v2d8BC`z`ZSJvg1c33Cfn^I~??hbb96^562e>S6=#H1*}_kN?jYR5?!Fe_1e z@0h;MZ^-CMnc z7Oo@FVb~|?v`!<0!4IH@p>06LyJC_hgbgR9Uyv0wh=x*Adv#^eYhiCNd+?fHaI)APH(+JWuwq2BpeC48my8WdhK4L$TOox+sHp93ThnTWblaLvCuGlb$j+&d z9WBAuwqTQQu{pHaDI6Ng#V8x3LI|+YSVm{O2O?&!5XJhnA2EXkb1W`8XAZA_@nTLp zdk%C0uK)8MzWR$j{N?Y{G}{WRV&OU>arE^VfdJ13GzbNjmEdZ*BTVE3PnH-G8I>|| z3ofJxzXq%6MO;f80a=|meI?O!iuUy^UbX(lyq*@5vQWq~fE?zh%aSEgfTZ~X=_QVU z;?&~@%D9;5tyT)?`Eh0TKxaIT>Dug+pPWOw4ESJx^^zac{8%Z0-!%lD&!YJj#~eD! zvSlMUj>Fb1Q{4679=7jpW4TpqTM>q-J>a6JDnp1tl1|o%{cFNRwtd$yRu6dD@lD=* zUX!B_4%od*artd7m*4Jk|JDk|D1oj7hDua2h$ew?0Hvor@(eGadAEOvZ64SQPf z=!{~r3q!7E^{~gG!vRMvR2+Sv!}8TOa~3$r1$CHLhw&QJt59*GT}ILcVMkPiA_@Fm}c9C2Aj6Fd0>y>o~FfvL6yg;(y|9KY z8w$6xK8v0}1`#oUv?@~|C<7*aHcd649vNLZ{%Jzbn!VtZ%YggH)};p77pkW%>c z??+*_PRp=jiROK8TEL4hSm1-2u|rpXn_rEgZI2r{ke z)5rIxvbiWL_=;ZqwAZW{P|v%}%S?c>zeVIvk3W^QVAo;|K;$Sb^X~tTl~R5;eI-53 zGL56qT86k&?8cP5RB8p7zN)08rrk1}bxgpozA~2uhc3W)qZmfeat>5aL0ZEIMUOJvYdz!o^)giV37p zK}zU&hWGu`7;k@3op1kQickLEG_GsKT1iS<=q0}mLdXmO7JUFcd@N?DlEK%WhEYrn zPCE{ZF2($S2Gm9_47sRxXX)oDGJrwUR`PuRYWjZh-Mk7_UeRZe6QpM|rC-Ra068;) z$d}diHHR*0@wcyan73*ko<9#&A4j@lV2?oUG;o$hny|L8YkTPIyPM}c=XYFpN0Zu+ zLl8#YNMVpd)A0q`*BrT|&4-`X;3cPbnQ05Yetm_n-dy8}DHrJ|Do(6X>BvOrXh9eV z9Lwj-(H0lYZ?mEj@Va;)oLoQamf~ZxJEqR_sH+|as+H`hyXzpy#+1;W$ z*~Odc5;Q!FA2|t?lqgr?)Et~q2WM`D>cSx^i$-u3j9|~HBS&jcu`!`R%(P+4Zn$qV z`tO_A`QUzT+u-wuNt;{!8XIMuP`Q0m!7@57f)W)&>2@NmV97j-cfMi?Z+pWsYE{Yq z{d52IXPpj;A?wjo`M=xly!qdz-U95peUKN}OO+;uy^Ot>fSM zr)45FXB`BLuL&!)ydi5*b-F^Q9~#{eL40`|FoVQx9`nhFji11^H5 z%*UVQOR0_wS+72naKu^kk%C!ZGQCM5=z0NHymvPjJ>A2T^RP!2fHMm2FxYi)7eW10 zu;v4XKr3u#J)7>kmD4V~kv&ZtTgA>pAHuHY@I@Uyec?2hoYrCQe!=IiuJWBb>+Ek; zP>#g46A5F0ZXnPa4jAh4(j^Vf8f&w+Be-U-!=LuJ>}gjRsTf{%+!U`lwav+=k06ga z80M}bwB{n+8bV2eeJ!T$+rz{S8<}}<7pg9pfBZ0`C(OlJIgA>%Bk3L`{bObV8k-wT zuHDD}2PSB4nx?h2Nq27>Z`wz9K!o&bM5h0#m@pKH^Hf@3RRz|VgS(){(5g{}kDSNQ z5%Z}WFb8YN5Y%n(eZ=-DcxWr!zM1`h-^HDecw9RnxxuTkR@CXJ3P>3-19XH3LelAI zbkJtOJjpv=wupDXVI|e7#Sbpu!Kc5sjmNk8I8_@<$=JS+utf=I`-0;adi?$3DJ&g= zu{iUOBiypri8dLf$!ic&fzt`@Q!VK;+rZ z4rsuPGdE}7+#pK6UIJ?)AmriCSd&@xMwT=y}Nnv!6tv)BDkvIa-XTw zwyR*-k>4OTvASI!-|w<$uEl#_wTQR9ektgX|N7reeBmckOf)Urs*Kh<#uX``6NJ2F zrN^5N_4(lzn=d}(;#$3&ioCtg0O8lWmP82>r+uENa$)d+c4@w&e9WMnn#!0;=`ag! zj;zMqS9q-20m+HxV!)tt26xf5G59mc2atxrAR5locd3N$8(pceNN#KfgDnlMrr}f1 z+t0sW(!f_>jS8sM!5vSC->HFB0p)@-2C9PgXHZqc{r63C!o_#vSQhU+b(#-c(8d+; zzc*C)%&kN0oN=QjltAl6m#4cr>o4oDk>1*l|`Qs_g_ zF@%1!d(iX1YeoP3HiR94?iyrZu!X@9k+q>l1?tcdhR#_`?d&DkYlhL5LU%({D?sdS z!^8XN-o2Lx|1rgNk9GLVew(|33X@d8wh@lRwG3g`$M1AlzR2eNuUW{yT5Dj8Vl1Y&(USg}wO$h&4s0nfeiA89?FTfz)6MZzR97T1oNrt71ip z%-y{FE|~B+@XV$G6o_x&^h`<1#c7wl@udE{(jIy$n}{mHWXy<|{beR@qHU+0fZx4( zKhHa{O&~#y0CgErf;|j&HTkF9$SEcz28FaG&FPSZ=ikfImvs2ud%9eAv(3ByG|Yp$ zD_E|=wW8>mh*zTR36$`8?!pG|T--ol_`$Xszt~fwB@{v%OaRY3WSY;syv^~?oR3~| z61sXYYG?$}ZL#O($9d@c_b~PMCs^cZmX5*D)8|tEr&U-7k6>D$JD^5uAZ@nazK^v( z-pKZAb|5A~suoy|z)^ycs$uRZjLm`CNW|}qjB*To9VHiqy>*cW+ZH%AaH|oBlwjQ3|Qmn_ONt}0p#%Rs*1&TFGB*GZ(-z;c)k>=1_eyw}w zB}5_XAQQ+DxxXbUbP7Rx>sq8bol3Szj<&fBNB>wh0+=k+^LZH;Rys;*$XrkOux zm@{T@TxbP?iKbw01E!kL=!z&`!N|D4hlvw`=?h6|fm1Qm>M%ZRSTq+F&VxB)2D=vF zjwWIz$|BlL96)OtCbvQ32?+NK+)z-nH8M1W4mc+cG4#S^R8C)jz(zMU##NZAgLLg^ zxAaU0wzp|LHp7OsGu-p2&t01W9^DhNej;FF+b4`x2(anrGsK39as)~V1d=5FoyRZ{ z2RIVy&(s^vl}FE_;UF(qjv@>6Q*H#59dQ5u4pdy zr6C(-fn%c_g|I9z4zvYkQm}7NhsU>0vtfIaJ$pQM?eo|-6VPb*1ir_x>v7Fpf-UvPm_PS#V^Y7wbTqKY6yc|Ur)*a=+pV^44_vP zxMy&P(v-UaIuRutsytYbd%GC+i(VD||E6GGt^s7f6lQP58E1jkU^JF6w7QyqJ8pu{ z9o5FK8?2QvQ*w!zfJ+phwqz6+mjYy@Mk*`hp>-C=y{C$n6&xAmo+}{;jKB*thYfYO zY-NK}M|{4qqr#UrS7`?}6*qEZwc3Ic*LZySjT4-9UWIV(shHs-utw@M9^S>hpSzXq zzkiG+%i#EDD%>f}6Zb2IUO%7tm(Ia-=c2pAh*}j{uk!HEH*o7`?#0{Gq~a>Z>#$~t zX7L>cDB=@^gab%HcLH`l2on!LO~c$Vfz{P`7K9fq zVC>Z^aF&f=8bk>av1V!F!XgF@(x5Dftf*M%o20{*hp~Kw?()kY?B`#;f;W9PjL%W$C>9?96ZePgn{Zk7(S=lwnunfQhEZdv;Tm4U=lYM_%(}}T zr7i?Rb;+vvhGp{&?KW)KA!sxWBbJ~oB@YLZn?jen1Djo1qT`7PVX%aVJpYt`3{kR9 z2n10I9vID#G^`eylZDSwDr889c!8ldA~@^_!%@c?s`KJ%K*k^fFhWEHh%D$%2zK0Q zm|1HWvIWx!!b#2W8pZglmN9yV?>G)MarhmIM!#; zJF-?$dQwGyrHJ*dvMR0l%>IH!U&d8(0}&$HO8SPgN9cF~KfHMc$BK^1Qo_*ccKQ5? z)4XR@ht5`oy%@v+8Zk~{LRuzf3bFL}V6v$=>2pJ@pR6(D3Oa!y2o3+Vq{XKWm|?@D z@DZ zoCLC}*>=65H8#wePmiG%tU?DzU_=EqR%PG1JzV+5KQj4`U5r$0R?O8Lyi^l(Vbd#}f@U|Q;f0tzA+@;`#?NsOPGr|>A+Sa)e)f|o-u9U(_Dx&V>XKGV z^WKYWKK7YxEvQK z68B)ALD4Zjs04qiR%U-Vsn(1sv{@Wb#LN;O0KKh{V!)flLG-5+s|uy|Pkc37r#0m- z96;OYYbNz0^M3BhUUk{C4>J7qG8;XkIq@=&d-f|TROM=cImHU_))!DQ1gzYZgo;n$OjFU-`~ z>^lf);RsMd#QO}0#JLO^aYI(rLRJlhEUiTqi|Te1{0I%q_=25n*xHml(Nt`0Dq5Zu zt)miD?6{Ph+QEx4A>DGOBaF$to;*g zzSpqVk~EZLEYysL8pn#9i#q~=9~utOu;S@emc4C+>Y8fgyi^J`r1>SprK&jzsgMbdbZ} zjD!^_952J}lK06A(EQVBg*|1qCj#crOpNF&ZP+D6ixbwr;vgKD#aNv=O%fM=XwAsu|XX{WDOh8`g{)wt0%TZ?L#>vVv3& z6&c0vJ7M&9xDs;8xX=0XeNLX|v2sXLcOpA|CKPl-gKr{9@7OR_f$?e-F4Vx58TiKz z#WkBOuG!|YVafraa2;4qUAG;b+!x`o)X*`M0+6=1*;5+Ow%v6hROnUavw^RE09}R;)acz)6cj zmX3kfg=;oi{O|o0uG`|`YnzG_om1G=hEQmZls>O_+N>aCe;_%0jp5`A4Dxv{#s#rn z!!8X|L4>dFgnHNV?Me}q#sY&%F$)7&B9e#;?0{3o+WcXg#*h3H$=;D?AaEFpP~tUgQ^Mk-lv%gDy;e37>qpwp_hk*f^b@GpqIv%k@|EtF_J=x~c*FVS&_jag{R3c5FXUZngvK(oOep+;| z&Oz3XoqjQOvQS9~u*~i)H=_%bFpvt1riDoTxA%C$0F<)FKh{_2MHzQ14kuQ0OS(;; zv_t<&wx#60=;c-y_FO0LW}W>BK>L+uM3lghsoHIN5xm~@s2wFD)zFav-#&RCpFFh1 zrYW1d8x?kSZ626(xNpi~UDIJQv@s}bsrl0CCZAu~=Hr`P{$*pG7B)3Y(heoUggj@s z$yBJ>>j*2NPQP0PBbvrdCDF8A7a?gHZypyUEy?VE^M1PYagXhQ%k@xbGT6b`3~Lpau4r;_{!g zdHd2T z$!`&5#l;UABcjuOi1Y0%ux!bUr?_#G!w>GY@w)KpQ$1dLoX7gzng@274tBzjk9NxU|KS<6P7Q6Iq#T*T;YHh zd;4pW$JU3u_+4Gv+Qt>ILiud>1%KJ1xNe)p+b-0+?^2t!TLf#L&{&Rz5P~oixD}Vj zx3u`(pSJUiQ^t7fTNkr$^9;A#+r+6j(YX^Qi?@adPM)W6l;D6RHqB;0*Ed)y!S;eC zn=xApw{v?7vIR*~I!B*23l!Jy+9c0q9x8LMCm*Hk@sDNcKHY-I^p*S(eWg23tfeZ9 zD3Ejls~#enY#&AcXX5DdL(OTJ8^ac+3Wuc_J=D@QboD=@l5P@k)*DZ_(Pic9Yt!V?xoO6mlh zZ#U@(i(fWJ+2mKLSQ1CZUB*=IYw&!5-xc`2!1uxTqxyz^2!1H~{{kH)IqCScUKy{j zCSnRof@4cszQuLxU4FAxapK~D_g(1Ygdu-fZy{y$mL(MHjm4G_p5gdRwgh`030Sql zAPygcz=BXAmMuW&0BiFUJ0FK-XH*$_`MmxPM}t6Gnm2r)#odo7grhjyZS(q}CKVa7 z$tX7Nv-r-97AuxoeEuT~7`Gg*x~)lw#IXfIU~sE8yCyWhxMCM49aQ73?_7o1-{!i% zPshw6D@v4zl7$Xj27mZ$mCwFuhzpK_Ywv8bx23RqXA|VK=%fHhLa!OpNF6!XQ`SqU zFU6sA6p}a|3NIA|Y>~K7(jNa<_I;U}Ly-aG+sc0i=St_UTzti{zJ(2EU{E z*1P8L>X!_$V8r9%H63pKM}u_}7LMwbwTd{dr3@Wku%zO1^{e)A%8HN||FX{a9~!1w zQT@JDtjc-5=KU*Y`1V0rXPW(QY?ATFxq(m!k!n; z&%$WI)o=bW-i8Slj40L&YlauX*rEtYH1N_p>#S?JxC$yJ;2`1i5JNm13}Fj}6DKkT zM$q*oz9$Gn&BA)X35&Zt^WY8_9pUl9BRwuT+~cByyF7D^$GIzgPFNUn@OZ$AdcYhz zz>W_M@0w_{P&)c!Omqf;V@r0-Sp4uFMYE~-#0xxDkNEuN0UK#Wm4$5-k7KbF!G*Pe zy$uN0by#$A6|-m@5ynT7EuW9GeLwr|>ag(T^Kp+J#&`;ig*7Jm#c$et@;d=m#bJT) zdG$~Oh2|(X;8-_cgI4SbEdKa+pGVedKJ$*HJok(`f4O;*J(D4oibWWL>sT~8@bf?I z;)o?R-tz9{)Y~3c-7<~sI+;CXQbG_2zW*+t^H28h_jgz^+GOrNkKaCQW82xfkZA_c zuc_J722wt+-u*#la9=|97)9POpUE`L(_SfoX1IV=?$0FGs7aE7%E>qNc9Om@Sn(+I zHhAE<32SYz95`aU1$Q-RJkns)YjMhg3fF9oRM(zES4l(L6U?vp{OPs(IC5dYh2I(G z&l~I1YLTPNm;dKsD~vIme1>Fbo+k97O@=`O_CF?B^l=5_ zCnD5p5I#m*$gwI9{p415-Moppb&D01kfBkSv&0YrL$2Y!AGEk-uS?C9_}WlI@_-m7 zKv9(uhIK=M_6(NobLLWy7aZtw+H#+{wUDV$uw_QDe!^zmE}QMsiiwV(<400o2!RqB zR~Uv>z(^&8P%zz5Jnq>{Vy86Z2!R6wt}QXf<#V^#JhZpX75~ye%4uHpvk@Fuq7Ar0 zabr+po@aRPaEq-QVBR-&v-Cd~p;Z;31=uyjxd*fOsylIx8HF&aM`&4sV7kqJe!BxG zY&82r!Eaj?F0Hq)6`WAl967hi7j{ejy3gh3H%@ZbrH}H*|2=^F|G1h<-nfbD?(b5s zR|rCbYb*TF$^>ICN>)y%Y3eJIPI>Gh`em8eVV3SSy@fCn#ppj$ z%wbAdIwfRX&Mx``OT{Pq?Ub4F29z*(f#zqwnBmwX+l=S{-*WitRvRpdj*}vkFm!#v zJlEq-|GbC8ml~e&)e)}WRHIgt(IFBdIxn*wNXD!#zdvb$6Ncg3+w0u4zeY9Q<=67z z!ud@;en5+HX?UQm__xO$mX8>|d6AFMA>mjJ=jq3@_ufga`L}x*vA_=uhaLed57LAI zQSn_HcHLsAo+hXqJB~J|AfyH?gi;W|+8^IWl_(BBcNpd@0U@C#4O?4+&#kW^EER8U zl}zHI05>jAW&*?N@h)#XrOl;lx{Nu7+xA#|?_Qg~ZmY6k(jf@qLqdBJMw&!AWD0FY z3xvGKTl%1ZG}Rcg$M0omu%yk@y}>6Zo7~rEeU)-z6CnOi;&6yAq)TBZgGuWmc^kj{ zBTBwyn@c#%Y^_D13&E{B1skUvc6&DeexJ*YyX~mVlZkTM{Saz0;7_mJ!^tZGF8szQ z*Ts@QIzcAdAk+3GE9+fuJZ+LW(s0&oL)<-6qwdJ4SZAPl-SQS6U(=?lG;LpU*#?&f zXC%LQj?d9cHJ)d%FFFywKF04}eGC5PCXOqq4I9omPhi^-dzY@Dy&LvECRy^X06F&z zMCAmctSp5b9%Abq>$(3w{z%BKWTd9j@9@rE06lZy`i{&Yr;Q z!og!5zIf&oUp&3Zu!L{jU*lbW8|Ir240HGHI{SSW+qQ9CiQ_~k3|Th)Bfgc2_CZAs zQ4#Hnk^&_qp-=?EN_;mab`s{+UmK{nuyLQw)ekGa_WU+$cL>(*a&eqE15gP5?ki5T zeO5@#zV!_jKWiCcs2Uw)qAI8rL&!?duTCO`;C-Lk&AQD{wGF2YciGaG2%~7CxUuCj zWQCluKr?R@JmU<*q2rS49<^u$l3)F064`?P{?t;czUJ!Nn$cbX3{pmWnSXQbehwb9 zdGo)nKuvYH=C)}Z*N*c?1UK$hY}#kh3KUio>1P2RSmiv?_MTfB39oxkmO_`q=7aRYy zlc7hhd#)*`jP-jn>1 z&EN6_M~}7m+u4(Bo)$dg&SCbNY8>AeI3nb`$2EAv3J=HD+%)6x%7=!zbHd?}xrPs% z>_b~4hF#PJ$FSwk+j#WyO%d^Xn)wUhphLj;(YS;K{yu}>l#D+&1hoQDSsu^ZLOL$q zu1R*@_#m!=@e#v809e|=;iJ|Zl$mPtDz4lzc5qXHbhmei_> zzdSa~ckZh4@pGrakCM4UpdtmELYHqf9OjO~*57U>+}c38HV`kCs-T4nl?s2jc80bO z6$@sJJ_Lt zv2!j7*7ZTAGTSRNB%8^RF)8Me9B!JH@}iA^x;3lKg(CF=%XMXa|C$$2#12?i3GA+gy0ZFw@$ht_1B+Qsuh;L&tX*t!ccDL7jC7fmP-9Pu>eg<0yj*9D10+u9)O_Ys)aT&fp%S zaMl>KIt)}5gp9yhb=Y(NCcG#1QK?E+tOjQ|@*4=-fHYjOE!wVVON|$TBYnPm+9W?e zafXln!{wOYjdSb1VJbB{Do+-QSjy+oCx^6|s8T}AeQYiuMyjPggUJ3BQlg|n3!8e~ z;tRJ7vwI4Tp4X-mhiT)xzMus^GyvP@*_wj z6QRSa?jGUI_tyB26FYqG^eJQ*Vp*E2HVDrD{xBQ2M@bz^=63k=`$8^0%mQI?!+len zc=3ardg3tGerFXGJiNG`o2?}6K=9&sJ;vJGCivzTm-F1yTpCjzmWsopy~B~?&|H$B z7NwcDJ0{_sY0OVa+2rCSA)zpQX`{=#*A644 z#193}SkmQH2X+Z(401sYapnO$`1Q5ydte&7uBaNroKeHd)nI}sY$+s|4m5TMhECH_ zR+4*AJbVQvDnPRLAM25A7#dd0Un0-~gdH95(+c3`y%yL}8L!p#E_XeBl9Prs$6Y&hr4vp}kGNB+X}Lovg~LK3Rdi0E-|f(8H|Vsc zXf+!UcKFhr754cKmX#!JNg%}He`j257xrKI1ZIB=VMR_fjY3u&wmji+-y>~M7UL>l zm;gVN)GLxdJyGYBKaI0$M03Y;r#ZOpgM|C{S)B9zA@1DZATiY44!?fC;FZT)AZ+e` z!so;nJjwy9T(1B23hMZD{peWoimli+Ex6=g9%si^&CmY#08TkX(rEf|;!lEo{qM2w zX?#R-{6SW~_()!cXX^BcUK=U1K^j161o7aMG=v@a!YdSi|E|l+P89fUjgrM+h9#ldGMs;ql;J-wPB5x(Jko+HnkO7+tJy5C(rA0=hGVeb(h1jH_TyOr%tV+ z&{`52nX=(hwtbojnAC5RULO+^b800;iW%f^$n=rKpe%(Bd^*kjgn`G>)hjsZ+%q`; zlIL*FMHh11X{Vvama%QhBj`*60;LpMM5e&aU57v8;B0I&b?Yuf%}&n!tJwVGo+ z0}z52E%3PE)M<_x>Co&5>J`H-PjTL#$N2eXn;W0q;DyUO=)ka}3m5#L!c7lVupEO9 zTm0}}1+P2R!Dx&1djd{;(W5L~T;sYQtf3~l_+f}Pf_lYa{q~Sc-~Kpm-R5UsUCom5 zkd7DjRs2jT4gosQ{OApuyS^+qZ;hnerGGeT1gu{V%>1hVAhACqDwnT8^+XbhlW&*nD4ZLoX4q<lRQW|CBFXPdmR3BjdfFs29$b7|oe5K+W<|w4MpeKs z&+l;VLPN)g_uS|5jdc|&PBiDX2Ny2waL!Uq*fz+yHN^4DdGPb=XzuW^Ml>)rC281IxPV?z?H9q~wC>1w4uP^8)mxu!FGZ@+<>P)CtWQo4$>`Bl;h$vMyg#$tgI*kd& z7B1kN7rdA=pLIT~4nBan<8?+W77~r$4cNbTKM($6Er0y!AGzh)>j->}Q>j51f{^@R z#^t=4NBgf^8GH7sXpN&|mbvw=DS8{2*0vOLMg@Prq`}*75Bc^ZHCzXVT*;^I8RDM3 zE&lJECWnn^K6QVU86&y)dy3y$f~TF}<9Q9f`>%DnpI7|k4#V~t!x{hd7`OlO5U%|C za-Q+}CkSN)1k{IIZh5fH>;G*7-~Z8JeESp2x%h3H(RLLXFLOrDOTo5BT1+{DspfE0 zy(mNUDY(V|DU<94=%>T<8CuHHVqX0(vhD0gDSOVx(%#9QYThH;tXm=N>L|DOb3uxP zVe|-ha9ANTVPm2*DX(7#U;eA%!94=omi-e{d+rF0L4`h7y=5O2Tk!NR57QM6mW&SL zP{QDcf-yVbs`K}=u%da|HFY+3E7W3dzbit1{){dctkP&9dH3xOUw^nl)s@la5CLHr z@`V#y95O#7?0|jND%x|#`0EGn!*oNc6~P?KU=0fnKQke(yQt5Lu1iBt~hH`6;ppTv!*8J2AJ190D09ka+DWPJ7`cyz#$3%}EzNgW8;N zl<{%=2C_4Q6*Q?UkNIO24nAfLXFmS|jy~aJ9(~|pc5mN`?N+h1W=kMAuHti4*v39@ z1+s2Oz8xv~$~T{2-R6Lbqj)LX zIA%`3?>5=^#_;RAC8sRX9JtcQ3qziNmcv6EBoA#fOnaK&{AnK_dDDDOJ<{dJS59HY z;XTK-xpVCl!`*;azjFzd$qrZF+N4skqfL^1tc_rD)jFH6-R7`yCfXUP%Y=3e;-YIX zA;e6PACY$;rs*+l?mfvT*HyL@T;G<WZd_>>&iyAJnT+~S_LXY29${kY1)7b4IL%*|8>9O zvs*{|nRTgsd^y&fXTuqfRE8JNwz^72j#xp~1DuEpOqXPks`rb$0C7&D7Kk zCJgbkq@itQ0*i(xXt%qVsr`(3`#9@@WBAhVzRd||o<_ShfwC+xil22Vgu6Y0NA`jv zPy~e2K2PlM0g2Y&T9OIh=8bK z=KLmFLo<{-|64XU-euuf8gz@Fd{FVs!)>$?Y}{)&%p+9^YP`~7q2v^|83%9&Ma zH9qi_9bEhK{k-oZ2lB$Rt2AajY%2>nDF92-Kww)Y=YUONd_kKRDZnNU!xBm?eB zJeEKP4`i=1k$Wl|U6{?kpPGp&4zhnY?e!~j`)6=AI`qY zu+2YxcbGf3)u>k#ei$7Xq6Bz8eBp#CE?VXB+P@8P)9yNTR}mQS1I^dZ>++IA1F!_2 zxy#|d?{=uTR_r^7?JlEv{Q(`6EeP5mSIvcLoyUH)6-$AwU~XODg|J{z+Nm080N#WF z3#@SiKC#^&R~;3xp&x{}I;7?b+!_!$tE1C?op{>D=5MVLd|}1fPueO>+4Lzm%|_m7 z$)AxQiIAt$F;Q8r5W*mGXbJ;JLD*`r{7r9R>V>ak`<5LvTU`QS;c3Za*D&Kj(>Kic zFzLbmu3*wrbVG?dvzK|Jl8^l0D;#<9DKuI$RBgqrU5oo?z3ZprUQt=m*gV{Jeoy>R7Y6? zPsx8%`fBoiX~iHx!V${A(wlbHktl_s=?M;5(Bhk~n&#VoarwV{s?=(cR2L%3U~75s zw!@}*$MG%xnJs?bKz?PxALGvE!!8?XdFe8lF%x7xU_9mmTf4|PWy7S(;8 zyF`O8kQ&6X%h>f`o4xl?A>^kU^Wc?|!o@4C13G`qsR?@~l%&7OTmz{9^9 z;`g7m8L#+++8``R*qmbcY3H%`S+8LGhTS+)(AI)ZXy}B7X%F^x3=>_$v}c%!|4n-^ z)qzG}VRu@zrR2?@{|w{v7GOM|wz0XgVt`(hNdcoC0)GWjAo<0K__I4GQe#PR6EfT4H#z$NH z{4$p%bs}|tozL^%y_FBWdjYRFze=+aAcdsrT5R2;dHwqzWO!+nZ+>Daj_F3HL5ZxR zbdc7c!S4K8K_;d@kfucDPl`I9YR}2bGIPPCs4RtFWkrWg>Th1c_phcIRz{o1ewQLT zgF~ulXIX|>*KEu-%HTcSz4gR#=BN-Ckn}3`-I|+e^GHtXDY)%0D1sE`%X4n$b$YSN>@a%hN%ph6F8Y!2bG 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%0DvEZ383(rm=(47n`;Sq9zN^rVha7(e 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 avz`o*Ot8AVZBvC>=-5i# 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 None: initialize_velopack() register_protocol(sys.executable) + startup_config = resolve_config() + if resolve_auto_start(startup_config): + register_startup(sys.executable) + else: + remove_startup() + if uri: logger.info('Received URI: %s', uri) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 56f0406..3c31746 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -2,6 +2,7 @@ import asyncio import logging +import sys from pathlib import Path from porringer.api import API @@ -28,9 +29,15 @@ from synodic_client.application.screen.screen import MainWindow from synodic_client.application.theme import UPDATE_SOURCE_DIALOG_MIN_WIDTH from synodic_client.client import Client -from synodic_client.config import GlobalConfiguration +from synodic_client.config import GlobalConfiguration, save_config from synodic_client.logging import log_path -from synodic_client.resolution import resolve_config, resolve_enabled_plugins, resolve_update_config, update_and_resolve +from synodic_client.resolution import ( + resolve_config, + resolve_enabled_plugins, + resolve_update_config, + update_and_resolve, +) +from synodic_client.startup import is_startup_registered, register_startup, remove_startup from synodic_client.updater import GITHUB_REPO_URL, UpdateChannel, UpdateInfo logger = logging.getLogger(__name__) @@ -280,6 +287,15 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: self.settings_menu.addSeparator() + # Start with Windows toggle + self._auto_start_action = QAction('Start with Windows', self.settings_menu) + self._auto_start_action.setCheckable(True) + self._auto_start_action.setChecked(is_startup_registered()) + self._auto_start_action.triggered.connect(self._on_auto_start_toggled) + self.settings_menu.addAction(self._auto_start_action) + + self.settings_menu.addSeparator() + self.open_log_action = QAction('Open Log...', self.settings_menu) self.open_log_action.triggered.connect(self._open_log) self.settings_menu.addAction(self.open_log_action) @@ -380,6 +396,19 @@ def _on_channel_changed(self, channel: UpdateChannel) -> None: self._sync_channel_checks() self._reinitialize_updater(config) + def _on_auto_start_toggled(self, checked: bool) -> None: + """Handle Start with Windows toggle.""" + config = self._resolve_config() + config.auto_start = checked + save_config(config) + + if checked: + register_startup(sys.executable) + else: + remove_startup() + + logger.info('Auto-startup %s', 'enabled' if checked else 'disabled') + def _reinitialize_updater(self, config: GlobalConfiguration) -> None: """Re-derive update settings and restart the updater and timers.""" update_cfg = update_and_resolve(config) diff --git a/synodic_client/client.py b/synodic_client/client.py index 7a7314d..aec853f 100644 --- a/synodic_client/client.py +++ b/synodic_client/client.py @@ -20,6 +20,7 @@ class Client: distribution: LiteralString = 'synodic_client' icon: LiteralString = 'icon.png' + icon_ico: LiteralString = 'icon.ico' _updater: Updater | None = None @property diff --git a/synodic_client/config.py b/synodic_client/config.py index b7144b5..695a2aa 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -89,6 +89,11 @@ class _ConfigBase(BaseModel): # no overrides anywhere. prerelease_packages: dict[str, list[str]] | None = None + # Whether the application should start automatically with the OS. + # None means use the default (enabled). Explicitly False disables + # auto-startup. + auto_start: bool | None = None + class LocalConfiguration(_ConfigBase): """Portable configuration embedded next to the executable. diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index 3117459..3a96fd3 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -124,6 +124,22 @@ def resolve_enabled_plugins( return [n for n in all_plugin_names if n not in disabled] +def resolve_auto_start(config: GlobalConfiguration) -> bool: + """Determine whether auto-startup should be enabled. + + ``None`` (the default) is treated as enabled. + + Args: + config: A resolved global configuration. + + Returns: + ``True`` when the application should register for auto-startup. + """ + if config.auto_start is None: + return True + return config.auto_start + + def update_and_resolve(config: GlobalConfiguration) -> UpdateConfig: """Save a modified global config and resolve it into an UpdateConfig. diff --git a/synodic_client/startup.py b/synodic_client/startup.py new file mode 100644 index 0000000..26a8176 --- /dev/null +++ b/synodic_client/startup.py @@ -0,0 +1,91 @@ +r"""Windows auto-startup registration via the registry. + +Manages a value under ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run`` +so the application launches automatically when the user logs in. + +Other platforms are stubbed with no-op implementations, matching the +approach in :mod:`synodic_client.protocol`. +""" + +import logging +import sys + +logger = logging.getLogger(__name__) + +STARTUP_VALUE_NAME = 'SynodicClient' +"""Registry value name used in the ``Run`` key.""" + +_RUN_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Run' + + +if sys.platform == 'win32': + import winreg + + def register_startup(exe_path: str) -> None: + """Register the application to start automatically on login. + + Writes a value to ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run`` + pointing to *exe_path*. Calling this repeatedly is safe and will + update the path (useful after Velopack relocates the executable). + + Args: + exe_path: Absolute path to the application executable. + """ + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key: + winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_SZ, f'"{exe_path}"') + logger.info('Registered auto-startup -> %s', exe_path) + except OSError: + logger.exception('Failed to register auto-startup') + + def remove_startup() -> None: + """Remove the auto-startup registration. + + Silently succeeds if the value does not exist. + """ + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key: + winreg.DeleteValue(key, STARTUP_VALUE_NAME) + logger.info('Removed auto-startup registration') + except FileNotFoundError: + logger.debug('Auto-startup registration not found, nothing to remove') + except OSError: + logger.exception('Failed to remove auto-startup registration') + + def is_startup_registered() -> bool: + """Check whether the auto-startup value is currently present. + + Returns: + ``True`` if the ``Run`` key contains the startup value. + """ + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: + winreg.QueryValueEx(key, STARTUP_VALUE_NAME) + return True + except FileNotFoundError: + return False + except OSError: + logger.exception('Failed to query auto-startup registration') + return False + +else: + + def register_startup(exe_path: str) -> None: + """Register auto-startup (no-op on non-Windows). + + Args: + exe_path: Absolute path to the application executable. + """ + logger.warning('Auto-startup registration is only supported on Windows (current: %s)', sys.platform) + + def remove_startup() -> None: + """Remove auto-startup registration (no-op on non-Windows).""" + logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform) + + def is_startup_registered() -> bool: + """Check auto-startup registration (always ``False`` on non-Windows). + + Returns: + ``False``. + """ + return False diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 081b875..0c19626 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -374,17 +374,25 @@ def _get_velopack_manager(self) -> Any: def _on_before_uninstall(version: str) -> None: """Velopack hook: called before the app is uninstalled. - Removes the ``synodic://`` URI protocol handler registration. + Removes the ``synodic://`` URI protocol handler and auto-startup + registrations. Args: version: The current version string (provided by Velopack). """ + from synodic_client.startup import remove_startup + logger.info('Velopack uninstall hook fired for version %s', version) try: remove_protocol() logger.info('Protocol handler removed successfully') except Exception: logger.warning('Protocol removal failed during uninstall hook', exc_info=True) + try: + remove_startup() + logger.info('Auto-startup registration removed successfully') + except Exception: + logger.warning('Auto-startup removal failed during uninstall hook', exc_info=True) def initialize_velopack() -> None: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 0f0ba92..7017655 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -29,6 +29,7 @@ def test_defaults() -> None: assert config.plugin_auto_update is None assert config.detect_updates is True assert config.prerelease_packages is None + assert config.auto_start is None @staticmethod def test_with_values() -> None: @@ -52,6 +53,7 @@ def test_defaults() -> None: assert config.plugin_auto_update is None assert config.detect_updates is True assert config.prerelease_packages is None + assert config.auto_start is None @staticmethod def test_with_values() -> None: @@ -78,6 +80,15 @@ def test_plugin_auto_update_round_trip() -> None: restored = GlobalConfiguration.model_validate(data) assert restored.plugin_auto_update == mapping + @staticmethod + def test_auto_start_round_trip() -> None: + """Verify auto_start survives JSON round-trip.""" + for value in (True, False, None): + original = GlobalConfiguration(auto_start=value) + data = json.loads(original.model_dump_json()) + restored = GlobalConfiguration.model_validate(data) + assert restored.auto_start is value + @staticmethod def test_json_round_trip() -> None: """Verify config can round-trip through JSON.""" diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index 978cbb2..83e6cf1 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -7,6 +7,7 @@ from synodic_client.config import GlobalConfiguration, LocalConfiguration from synodic_client.resolution import ( merge_config, + resolve_auto_start, resolve_config, resolve_enabled_plugins, resolve_update_config, @@ -67,6 +68,28 @@ def test_local_overrides_plugin_auto_update() -> None: assert result.plugin_auto_update == {'pip': True, 'pipx': False} +class TestResolveAutoStart: + """Tests for resolve_auto_start.""" + + @staticmethod + def test_none_defaults_to_true() -> None: + """Verify None (default) resolves to True.""" + config = GlobalConfiguration() + assert resolve_auto_start(config) is True + + @staticmethod + def test_explicit_true() -> None: + """Verify explicit True is returned.""" + config = GlobalConfiguration(auto_start=True) + assert resolve_auto_start(config) is True + + @staticmethod + def test_explicit_false() -> None: + """Verify explicit False is returned.""" + config = GlobalConfiguration(auto_start=False) + assert resolve_auto_start(config) is False + + class TestResolveEnabledPlugins: """Tests for resolve_enabled_plugins.""" diff --git a/tests/unit/windows/test_startup.py b/tests/unit/windows/test_startup.py new file mode 100644 index 0000000..4e3bc95 --- /dev/null +++ b/tests/unit/windows/test_startup.py @@ -0,0 +1,190 @@ +"""Tests for Windows auto-startup registration.""" + +import winreg +from unittest.mock import MagicMock, patch + +import pytest + +from synodic_client.startup import ( + _RUN_KEY_PATH, + STARTUP_VALUE_NAME, + is_startup_registered, + register_startup, + remove_startup, +) + +_TEST_VALUE_NAME = f'{STARTUP_VALUE_NAME}_test' +"""Temporary value name used by integration tests to avoid clobbering the real registration.""" + + +class TestRegisterStartup: + """Tests for register_startup.""" + + @staticmethod + def test_writes_registry_value() -> None: + """Verify correct registry value is written on Windows.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key) as mock_open, + patch.object(winreg, 'SetValueEx') as mock_set, + ): + register_startup(r'C:\Program Files\Synodic\synodic.exe') + + mock_open.assert_called_once_with( + winreg.HKEY_CURRENT_USER, + _RUN_KEY_PATH, + 0, + winreg.KEY_SET_VALUE, + ) + mock_set.assert_called_once_with( + mock_key, + STARTUP_VALUE_NAME, + 0, + winreg.REG_SZ, + r'"C:\Program Files\Synodic\synodic.exe"', + ) + + @staticmethod + def test_noop_on_non_windows() -> None: + """Verify register_startup is a no-op on non-Windows platforms.""" + with patch('synodic_client.startup.sys') as mock_sys: + mock_sys.platform = 'linux' + register_startup('/usr/bin/synodic') + + +class TestRemoveStartup: + """Tests for remove_startup.""" + + @staticmethod + def test_deletes_registry_value() -> None: + """Verify the startup value is deleted.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object(winreg, 'DeleteValue') as mock_delete, + ): + remove_startup() + + mock_delete.assert_called_once_with(mock_key, STARTUP_VALUE_NAME) + + @staticmethod + def test_handles_missing_value_gracefully() -> None: + """Verify no error when startup value doesn't exist.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object(winreg, 'DeleteValue', side_effect=FileNotFoundError), + ): + # Should not raise + remove_startup() + + @staticmethod + def test_noop_on_non_windows() -> None: + """Verify remove_startup is a no-op on non-Windows platforms.""" + with patch('synodic_client.startup.sys') as mock_sys: + mock_sys.platform = 'linux' + remove_startup() + + +class TestIsStartupRegistered: + """Tests for is_startup_registered.""" + + @staticmethod + def test_returns_true_when_present() -> None: + """Verify True when the value exists.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object(winreg, 'QueryValueEx', return_value=(r'"C:\synodic.exe"', winreg.REG_SZ)), + ): + assert is_startup_registered() is True + + @staticmethod + def test_returns_false_when_missing() -> None: + """Verify False when the value does not exist.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError), + ): + assert is_startup_registered() is False + + +class TestStartupIntegration: + """Integration tests that read/write real registry values under a test name.""" + + @staticmethod + def test_register_creates_valid_entry() -> None: + """Register under a test value name, verify, then clean up.""" + test_exe = r'C:\test\synodic_test.exe' + + try: + with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME): + register_startup(test_exe) + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: + value, reg_type = winreg.QueryValueEx(key, _TEST_VALUE_NAME) + assert reg_type == winreg.REG_SZ + assert test_exe in value + + finally: + with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME): + remove_startup() + + @staticmethod + def test_remove_deletes_entry() -> None: + """Register then remove under a test value name, verify it is gone.""" + with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME): + register_startup(r'C:\test\synodic_test.exe') + remove_startup() + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: + with pytest.raises(FileNotFoundError): + winreg.QueryValueEx(key, _TEST_VALUE_NAME) + + @staticmethod + def test_register_is_idempotent() -> None: + """Calling register twice with a different exe updates the value.""" + exe_v1 = r'C:\test\v1\synodic.exe' + exe_v2 = r'C:\test\v2\synodic.exe' + + try: + with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME): + register_startup(exe_v1) + register_startup(exe_v2) + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: + value, _ = winreg.QueryValueEx(key, _TEST_VALUE_NAME) + assert exe_v2 in value + assert exe_v1 not in value + + finally: + with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME): + remove_startup() + + @staticmethod + def test_is_startup_registered_reflects_state() -> None: + """Verify is_startup_registered returns the correct state.""" + with patch('synodic_client.startup.STARTUP_VALUE_NAME', _TEST_VALUE_NAME): + assert is_startup_registered() is False + + register_startup(r'C:\test\synodic_test.exe') + assert is_startup_registered() is True + + remove_startup() + assert is_startup_registered() is False diff --git a/tool/pyinstaller/synodic.spec b/tool/pyinstaller/synodic.spec index 94d5dcc..2ab6d43 100644 --- a/tool/pyinstaller/synodic.spec +++ b/tool/pyinstaller/synodic.spec @@ -66,6 +66,7 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, + icon=str(REPO_ROOT / 'data' / 'icon.ico'), ) coll = COLLECT( diff --git a/tool/scripts/common.py b/tool/scripts/common.py index 8f4000a..1c025cc 100644 --- a/tool/scripts/common.py +++ b/tool/scripts/common.py @@ -11,6 +11,7 @@ PACK_DIR = REPO_ROOT / 'dist' / 'synodic' OUTPUT_DIR = REPO_ROOT / 'Releases' MAIN_EXE = 'synodic.exe' +ICON_FILE = REPO_ROOT / 'data' / 'icon.ico' PACK_ID = 'Synodic.SynodicClient' diff --git a/tool/scripts/package.py b/tool/scripts/package.py index 3c84f23..5282bf2 100644 --- a/tool/scripts/package.py +++ b/tool/scripts/package.py @@ -19,7 +19,7 @@ import typer from synodic_client import __version__ -from tool.scripts.common import MAIN_EXE, OUTPUT_DIR, PACK_DIR, PACK_ID, build, kill_running_instances, run +from tool.scripts.common import ICON_FILE, MAIN_EXE, OUTPUT_DIR, PACK_DIR, PACK_ID, build, kill_running_instances, run app = typer.Typer(help='Package Synodic Client with PyInstaller and Velopack.') @@ -86,6 +86,8 @@ def main( str(PACK_DIR), '--mainExe', MAIN_EXE, + '--icon', + str(ICON_FILE), '--channel', channel.value, '-o',