diff --git a/data/icon.ico b/data/icon.ico
new file mode 100644
index 0000000..0578378
Binary files /dev/null and b/data/icon.ico differ
diff --git a/pdm.lock b/pdm.lock
index 68c545a..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:20480c81a89571a4be78600a9497f9421eab4a51c003760a9ef02a62e5ad9cbd"
+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\"",
]
@@ -45,12 +45,28 @@ 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", "dev"]
+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"
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"},
@@ -61,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\"",
]
@@ -75,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"},
@@ -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", "dev"]
+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", "dev"]
+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."
-groups = ["default"]
+version = "0.28.1"
+requires_python = ">=3.8"
+summary = "The next generation HTTP client."
+groups = ["default", "dev"]
dependencies = [
+ "anyio",
"certifi",
+ "httpcore==1.*",
+ "idna",
]
files = [
- {file = "httpx-1.0.dev3-py3-none-any.whl", hash = "sha256:80b33db1bc8e1fac2a15f419839e324d472d528822608ea6b7a93fed2011722d"},
- {file = "httpx-1.0.dev3.tar.gz", hash = "sha256:e95700e4f9cf6430295f4c195f9cb0ca0549bab4294927f8002bf196851d40db"},
+ {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", "dev"]
+files = [
+ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
+ {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
]
[[package]]
@@ -205,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",
]
@@ -219,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"},
@@ -230,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"},
@@ -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"]
+groups = ["default", "dev"]
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,29 +336,27 @@ files = [
[[package]]
name = "porringer"
-version = "0.2.1.dev31"
+version = "0.2.1.dev49"
requires_python = ">=3.14"
+editable = true
+path = "d:/Projects/Synodic/porringer"
summary = ""
-groups = ["default"]
+groups = ["default", "dev"]
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"},
-]
[[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",
@@ -311,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",
]
@@ -352,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"},
@@ -391,7 +453,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 +463,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,55 +614,55 @@ 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"]
+groups = ["default", "dev"]
dependencies = [
"markdown-it-py>=2.2.0",
"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]]
@@ -608,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"},
@@ -633,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",
@@ -651,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",
]
@@ -665,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"},
@@ -676,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",
]
@@ -690,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 99d89fc..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.dev31",
+ "porringer>=0.2.1.dev49",
"qasync>=0.28.0",
"velopack>=0.0.1369.dev7516",
"typer>=0.24.0",
@@ -29,7 +29,7 @@ build = [
"pyinstaller>=6.19.0",
]
lint = [
- "ruff>=0.15.1",
+ "ruff>=0.15.2",
"pyrefly>=0.53.0",
]
test = [
@@ -103,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/bootstrap.py b/synodic_client/application/bootstrap.py
index 631cdf5..8e91d51 100644
--- a/synodic_client/application/bootstrap.py
+++ b/synodic_client/application/bootstrap.py
@@ -18,6 +18,8 @@
from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.protocol import register_protocol
+from synodic_client.resolution import resolve_auto_start, resolve_config
+from synodic_client.startup import register_startup, remove_startup
from synodic_client.updater import initialize_velopack
_PROTOCOL_SCHEME = 'synodic'
@@ -32,6 +34,12 @@
if not _dev_mode:
register_protocol(sys.executable)
+ _config = resolve_config()
+ if resolve_auto_start(_config):
+ register_startup(sys.executable)
+ else:
+ remove_startup()
+
# Heavy imports happen here — PySide6, porringer, etc.
from synodic_client.application.qt import application # noqa: E402
diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py
index b3f5ba1..f74afab 100644
--- a/synodic_client/application/qt.py
+++ b/synodic_client/application/qt.py
@@ -24,7 +24,8 @@
from synodic_client.config import GlobalConfiguration, set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.protocol import register_protocol
-from synodic_client.resolution import resolve_config, resolve_update_config
+from synodic_client.resolution import resolve_auto_start, resolve_config, resolve_update_config
+from synodic_client.startup import register_startup, remove_startup
from synodic_client.updater import initialize_velopack
@@ -135,6 +136,12 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> 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)
@@ -159,7 +166,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..29b881d 100644
--- a/synodic_client/application/screen/__init__.py
+++ b/synodic_client/application/screen/__init__.py
@@ -43,7 +43,9 @@ 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..e54e4ad
--- /dev/null
+++ b/synodic_client/application/screen/action_card.py
@@ -0,0 +1,789 @@
+"""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. 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.PACKAGE: 1,
+ PluginKind.TOOL: 2,
+ PluginKind.PROJECT: 3,
+ PluginKind.SCM: 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 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 ''
+ 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
+ self._check_available_version: str | None = None
+
+ 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 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'):
+ 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
+ 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;')
+ 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
+ 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')
+ 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 0f494c6..d1f12a1 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
@@ -28,36 +28,33 @@
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 QFont
from PySide6.QtWidgets import (
QApplication,
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,
@@ -68,15 +65,35 @@
COPY_ICON,
HEADER_STYLE,
INSTALL_PREVIEW_MIN_SIZE,
+ METADATA_SKELETON_HEIGHT,
+ METADATA_SKELETON_STYLE,
MONOSPACE_FAMILY,
MONOSPACE_SIZE,
MUTED_STYLE,
NO_MARGINS,
)
+from synodic_client.config import GlobalConfiguration, save_config
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 +116,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 +133,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 +162,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] = []
@@ -207,8 +234,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:
@@ -279,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
@@ -294,7 +323,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:
@@ -302,42 +341,101 @@ 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
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._action_to_table_row: dict[int, int] = {}
+ self._upgradable_rows: set[int] = set()
+ self._action_index_map: 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()
# --- 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)
+
+ # --- Metadata skeleton (shown during loading, replaced by real card) ---
+ self._metadata_skeleton = self._make_metadata_skeleton()
+ outer.addWidget(self._metadata_skeleton)
- row = 0
+ # --- Real metadata card (hidden until preview data arrives) ---
+ self._init_metadata_card()
+ outer.addWidget(self._metadata_card)
+
+ self._status_label = QLabel()
+ outer.addWidget(self._status_label)
- # Row 0 — Metadata card (hidden until preview metadata arrives)
+ # --- 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)
+
+ # 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)
+
+ # --- Button bar (fixed at bottom) ---
+ button_bar = self._init_button_bar()
+ 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)."""
self._metadata_card = CardFrame('Project', collapsible=True)
self._metadata_card.hide()
@@ -356,67 +454,6 @@ def _init_ui(self) -> None:
self._meta_label.hide()
self._metadata_card.content_layout.addWidget(self._meta_label)
- grid.addWidget(self._metadata_card, row, 0, 1, 2)
- row += 1
-
- # 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
-
- self._content_stack.setCurrentIndex(self._CONTENT_PAGE)
-
- 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
-
- # Row 4 — Button bar
- button_bar = self._init_button_bar()
- grid.addLayout(button_bar, row, 0, 1, 2)
-
- 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.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.Stretch)
- header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
- QShortcut(QKeySequence.StandardKey.Copy, table, self._copy_table_selection)
- return table
-
def _init_button_bar(self) -> QHBoxLayout:
"""Create the bottom button bar."""
button_bar = QHBoxLayout()
@@ -437,6 +474,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.
@@ -449,37 +510,41 @@ 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._checked_count = 0
self._action_statuses = []
- self._action_to_table_row = {}
+ self._upgradable_rows = set()
+ 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.
@@ -493,14 +558,47 @@ 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:
- """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.
@@ -508,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.
@@ -519,41 +617,90 @@ 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."""
- label = skip_reason_label(result.skip_reason) if result.skipped else 'Needed'
+ def on_preview_resolved(self, preview: SetupResults) -> None:
+ """Handle the fully-resolved preview (CLI commands populated).
- if 0 <= row < len(self._action_statuses):
- self._action_statuses[row] = label
+ 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.
- # Command actions are not shown in the table.
- table_row = self._action_to_table_row.get(row)
- if table_row is None:
+ Args:
+ preview: The fully-resolved setup results with CLI commands.
+ """
+ if self._preview is None:
return
- item = self._table.item(table_row, 4)
- if item 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)
- item.setText(label)
- if result.skipped:
- item.setForeground(self.palette().placeholderText())
+ 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:
+ label = skip_reason_label(result.skip_reason)
+ self._upgradable_rows.add(row)
+ elif result.skipped:
+ label = skip_reason_label(result.skip_reason)
else:
- item.setForeground(self.palette().text())
+ label = 'Needed'
+
+ if 0 <= row < len(self._action_statuses):
+ self._action_statuses[row] = label
+
+ # 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)
+
+ # 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."""
@@ -562,40 +709,41 @@ 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, 4)
- 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)
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:
- self._status_label.setText(f'{total} action(s) — all already satisfied.')
+ actionable = needed + upgradable
+ if actionable == 0 and unavailable == 0:
+ 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)}.')
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,
)
@@ -603,8 +751,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()
@@ -640,62 +788,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 ''))
- self._table.setItem(table_row, 3, 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, 4, status_item)
-
- # Populate the always-visible post-install commands section
- self._post_install_section.populate(actions)
-
# --- Install execution ---
def _on_install(self) -> None:
@@ -703,16 +795,19 @@ 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
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.
+ strategy = SyncStrategy.LATEST if self._upgradable_rows else SyncStrategy.MINIMAL
# Worker thread
worker = InstallWorker(
@@ -720,6 +815,8 @@ def _on_install(self) -> None:
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)
@@ -731,48 +828,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 cell for a table row from an action result."""
- item = self._table.item(row, 4)
- 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())
- 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."""
@@ -781,6 +868,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)
@@ -794,13 +883,14 @@ 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)
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)
@@ -818,17 +908,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
@@ -859,8 +959,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)
@@ -925,12 +1026,25 @@ 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)
- preview_worker = PreviewWorker(self._porringer, self._manifest_url, project_directory=self._project_directory)
+ 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,
+ detect_updates=self._config.detect_updates,
+ 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)
@@ -940,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
@@ -952,6 +1066,33 @@ 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()
+
+ 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.
@@ -959,21 +1100,53 @@ 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()
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."""
@@ -1013,23 +1186,50 @@ 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,
project_directory=self._project_directory,
+ detect_updates=self._detect_updates,
+ 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/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/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()
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/application/theme.py b/synodic_client/application/theme.py
index a02e3dc..f19e6b9 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."""
@@ -156,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/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 be502d5..695a2aa 100644
--- a/synodic_client/config.py
+++ b/synodic_client/config.py
@@ -76,6 +76,24 @@ 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
+
+ # 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/qt/test_action_card.py b/tests/unit/qt/test_action_card.py
new file mode 100644
index 0000000..1c27af3
--- /dev/null
+++ b/tests/unit/qt/test_action_card.py
@@ -0,0 +1,928 @@
+"""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
+
+ @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
+# ---------------------------------------------------------------------------
+
+
+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_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(package) < 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])
+
+ # 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)
+ 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 ec2de62..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,
@@ -28,6 +27,7 @@
InstallWorker,
PreviewWorker,
format_cli_command,
+ normalize_manifest_key,
resolve_local_path,
)
@@ -71,9 +71,125 @@ 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'
+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."""
@@ -473,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
@@ -502,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
@@ -520,3 +694,188 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any:
worker.run()
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."""
+
+ @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()
+ 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()
+ 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..7017655 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -27,6 +27,9 @@ 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
+ assert config.auto_start is None
@staticmethod
def test_with_values() -> None:
@@ -48,6 +51,9 @@ 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
+ assert config.auto_start is None
@staticmethod
def test_with_values() -> None:
@@ -56,6 +62,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."""
@@ -65,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',