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',