From cdbd7b4764011ed8b4323de787bad6727c6c1e67 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 09:47:36 -0800 Subject: [PATCH 1/5] Update Chore --- pdm.lock | 80 +++++++++++++++++++++++++------------------------- pyproject.toml | 6 ++-- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/pdm.lock b/pdm.lock index fcec85d..1147064 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:51fe1fa9d99c43a533dedd1220c24ecfdc3086d950d3d7fb119ef73c17e440ee" +content_hash = "sha256:e85a2b6900e703e4c93e257c1c94c30f48735d346f031475f8380b42b70c7e95" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev53" +version = "0.2.1.dev54" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev53-py3-none-any.whl", hash = "sha256:22031fb82061b7255ccc156d2f94541591406cee2897813cfd4d760f24797780"}, - {file = "porringer-0.2.1.dev53.tar.gz", hash = "sha256:05a1673ec2ef0407750cc08f04e388e31141aaf52e46915a8bab54e4c5f50a71"}, + {file = "porringer-0.2.1.dev54-py3-none-any.whl", hash = "sha256:679363277906be375fd5d24f1cb1207cebd6d9f62958d3f5c733753a544c795f"}, + {file = "porringer-0.2.1.dev54.tar.gz", hash = "sha256:e2d51685e95440aa9c81cb826168d28b316c088c8ba79a67c5611a4d3b902bde"}, ] [[package]] @@ -631,29 +631,29 @@ files = [ [[package]] name = "ruff" -version = "0.15.2" +version = "0.15.3" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["lint"] files = [ - {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"}, + {file = "ruff-0.15.3-py3-none-linux_armv6l.whl", hash = "sha256:f7df0fd6f889a8d8de2ddb48a9eb55150954400f2157ea15b21a2f49ecaaf988"}, + {file = "ruff-0.15.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0198b5445197d443c3bbf2cc358f4bd477fb3951e3c7f2babc13e9bb490614a8"}, + {file = "ruff-0.15.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:adf95b5be57b25fbbbc07cd68d37414bee8729e807ad0217219558027186967e"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b56dbd9cd86489ccbad96bb58fa4c958342b5510fdeb60ea13d9d3566bd845c"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6f263ce511871955d8c5401b62c7e863988ea4d0527aa0a3b1b7ecff4d4abc4"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e90fa1bed82ffede5768232b9bd23212c547ab7cd74c752007ecade1d895ee1a"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e9d53760b7061ddbe5ea9e25381332c607fc14c40bde78f8a25392a93a68d74"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec90e3b78c56c4acca4264d371dd48e29215ecb673cc2fa3c4b799b72050e491"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ce448fd395f822e34c8f6f7dfcd84b6726340082950858f92c4daa6baf8915"}, + {file = "ruff-0.15.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14f7d763962d385f75b9b3b57fcc5661c56c20d8b1ddc9f5c881b5fa0ba499fa"}, + {file = "ruff-0.15.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b57084e3a3d65418d376c7023711c37cce023cd2fb038a76ba15ee21f3c2c2ee"}, + {file = "ruff-0.15.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d567523ff7dcf3112b0f71231d18c3506dd06943359476ee64dea0f9c8f63976"}, + {file = "ruff-0.15.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4223088d255bf31a50b6640445b39f668164d64c23e5fa403edfb1e0b11122e5"}, + {file = "ruff-0.15.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32399ddae088970b2db6efd8d3f49981375cb828075359b6c088ed1fe63d64e1"}, + {file = "ruff-0.15.3-py3-none-win32.whl", hash = "sha256:1f1eb95ff614351e3a89a862b6d94e6c42c170e61916e1f20facd6c38477f5f3"}, + {file = "ruff-0.15.3-py3-none-win_amd64.whl", hash = "sha256:2b22dffe5f5e1e537097aa5208684f069e495f980379c4491b1cfb198a444d0c"}, + {file = "ruff-0.15.3-py3-none-win_arm64.whl", hash = "sha256:82443c14d694d4cbd9e598ede27ef5d6f08389ccad91c933be775ea2f4e66f76"}, + {file = "ruff-0.15.3.tar.gz", hash = "sha256:78757853320d8ddb9da24e614ef69a37bcbcfd477e5a6435681188d4bce4eaa1"}, ] [[package]] @@ -765,25 +765,25 @@ files = [ [[package]] name = "velopack" -version = "0.0.1442.dev64255" +version = "0.0.1444.dev49733" requires_python = ">=3.8" summary = "Installer and automatic update framework for cross-platform desktop applications" groups = ["default"] files = [ - {file = "velopack-0.0.1442.dev64255-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8a29f8de20f807517deb9d7b6839acc4982c4f77ed00eb8a9ed33f6df33f9170"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:3ae4ae56916d8f3378e2ec90b3f3871b62c9db7f9fc2f7aedb9744f486938a65"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:912484fa5622554af79c5799c831bf0c0ef58d3af53365ad8b69ba3df6802705"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:358bba9a56efb7f06d5b16ef96b92020a2d89e9e7fbc1339082b5fb6ee47da76"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93b32b688f27f6d46c6ae3a5246befa23f3d670aa6f1bf98e9afdb5f7b53d0d9"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2fcab425cf5360783cddef2a7e53e34d66917c8dbf3e3808f064f83d0d19865b"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e960883efa8f2b184f399416bbd85d679491c3dd13ba896de311d928ea74b0"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4039decb048315ac858cb551ad083fee43a36410286abb90f245e7794961de4a"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9321b68d42bcac050aae36fbdbf64243f7c4e2ad666d39788f27406889cf1ac7"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c21ee8c6360fad3a71800afd183270bcd1adead868818ebb76228e4b4b314953"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8d031bb79d0ad3cae8d77bd99125b61355f7c8a157596c2d068ad848cb764dea"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df7aac8b54767b87a82eb3ce51b7e97c86c707f18b1edd29332dc4f6fc578c70"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-win32.whl", hash = "sha256:04c2465bcd3b0fc55b16e03690aa9ee75c3de20945c5aa4a3cfbece782fdb02d"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-win_amd64.whl", hash = "sha256:7f910a3df65c57bc9cdcc2ab2d1457c27a3a8ea8bbe8d838bbc78a5f0d282a69"}, - {file = "velopack-0.0.1442.dev64255-cp37-abi3-win_arm64.whl", hash = "sha256:b1c3999bcee7f4a6f638b75423e46dfc3d2ee9e0065a79c22ae6724042736839"}, - {file = "velopack-0.0.1442.dev64255.tar.gz", hash = "sha256:50da944cb6ebac090ed77fb0d4ad55434af2710ba9181a2c5a2447363e9e4473"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24f8564b7a391e3a5b94d05ddf6e82f612492cff84da4dc9f6585864f0055dc0"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:901d5a6ad30082beb237cfa78a975e252f485dc394383dc687ea9fbe6b325e56"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cbef500e62c135c7ef3853b3d039c2aedb34079c7d5d864319acb71692abd7f"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bbcd8a67f8eac76e14bcddf72d841c5aeddbcd6674f6637b360a98a7187c0972"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220b9946d9716c97e49493a803f9420acc896081cc4996fbc6f4b4a15e6df471"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43596c1ad16130066a42c135c43cab4383cac08a27d474ec43a1840baa2b328c"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b37c2482449e9f171b4c9ca5ba64d9506820733fac99d0285672966a11fce339"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b8bd6077bfd787d2fc1578bef5061dc91cb0914fb0a9f83dca53c803a0a5000"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e388c8a5e5d1cb1ccbc5522a5175435e2fc7c0f0d0412b7ee02a63a5e9b995b"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6d1f1b52e8fe30c50918110f4762f52f0082a55aad791b4a77f65ed6b98e0e86"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:c57207f567021f725e0871a1f316c931e040498a0ac9b308842793493755e5a0"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d48d22f0196598352a3a3f235ca60853593d40f5c76699d6fc102e58d551f75b"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-win32.whl", hash = "sha256:77ab6d866cae6b276556c4b3ad016faf0cfee7eaae0a29f8dfe86276ed8aa78b"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-win_amd64.whl", hash = "sha256:e554615d2f2c732be89afc21054308def3fb8caded3b7f504df3551085451e0b"}, + {file = "velopack-0.0.1444.dev49733-cp37-abi3-win_arm64.whl", hash = "sha256:6b581e529793bca306920ee64009f318ec9a6cba098dd9ee0cbbc45df43c240e"}, + {file = "velopack-0.0.1444.dev49733.tar.gz", hash = "sha256:f2723c4c70c380c2b1fc9f6fddcea1a9b2f0c0c912090a4450c950dc0fad41f8"}, ] diff --git a/pyproject.toml b/pyproject.toml index f26c2d8..0ba9c24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,9 +14,9 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev53", + "porringer>=0.2.1.dev54", "qasync>=0.28.0", - "velopack>=0.0.1442.dev64255", + "velopack>=0.0.1444.dev49733", "typer>=0.24.1", ] @@ -29,7 +29,7 @@ build = [ "pyinstaller>=6.19.0", ] lint = [ - "ruff>=0.15.2", + "ruff>=0.15.3", "pyrefly>=0.54.0", ] test = [ From 37c058bd59816766956c72eaf309e11810afd439 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 09:54:34 -0800 Subject: [PATCH 2/5] Update pdm.lock --- pdm.lock | 76 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/pdm.lock b/pdm.lock index 1147064..7ce71de 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,7 +2,7 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "build", "dev", "lint", "test"] +groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" content_hash = "sha256:e85a2b6900e703e4c93e257c1c94c30f48735d346f031475f8380b42b70c7e95" @@ -63,13 +63,13 @@ files = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." groups = ["default"] files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] [[package]] @@ -355,24 +355,24 @@ files = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.0b2" requires_python = ">=3.9" summary = "Data validation using Python type hints" groups = ["default"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.41.5", + "pydantic-core==2.42.0", "typing-extensions>=4.14.1", "typing-inspection>=0.4.2", ] files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, + {file = "pydantic-2.13.0b2-py3-none-any.whl", hash = "sha256:42a3dee97ad2b50b7489ad4fe8dfec509cb613487da9a3c19d480f0880e223bc"}, + {file = "pydantic-2.13.0b2.tar.gz", hash = "sha256:255b95518090cd7090b605ef975957b07f724778f71dafc850a7442e088e7b99"}, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.42.0" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" groups = ["default"] @@ -380,35 +380,35 @@ dependencies = [ "typing-extensions>=4.14.1", ] files = [ - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, + {file = "pydantic_core-2.42.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:de4c9ad4615983b3fb2ee57f5c570cf964bda13353c6c41a54dac394927f0e54"}, + {file = "pydantic_core-2.42.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:129d5e6357814e4567e18b2ded4c210919aafd9ef0887235561f8d853fd34123"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c45582a5dac4649e512840ad212a5c2f9d168622f8db8863e8a29b54a29dfd"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a97fc19afb730b45de55d2e80093f1a36effc29538dec817204c929add8f2b4a"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e45d83d38d94f22ffe9a0f0393b23e25bfefe4804ae63c8013906b76ab8de8ed"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3060192d8b63611a2abb26eccadddff5602a66491b8fafd9ae34fb67302ae84"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f17739150af9dc58b5c8fc3c4a1826ff84461f11b9f8ad5618445fcdd1ccec6"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d14e4c229467a7c27aa7c71e21584b3d77352ccb64e968fdbed4633373f73f7"}, + {file = "pydantic_core-2.42.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:aaef75e1b54366c7ccfbf4fc949ceaaa0f4c87e106df850354be6c7d45143db0"}, + {file = "pydantic_core-2.42.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:d2e362dceeeb4d56fd63e649c2de3ad4c3aa448b13ab8a9976e23a669f9c1854"}, + {file = "pydantic_core-2.42.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a8edee724b527818bf0a6c8e677549794c0d0caffd14492851bd7a4ceab0f258"}, + {file = "pydantic_core-2.42.0-cp314-cp314-win32.whl", hash = "sha256:a10c105c221f68221cb81be71f063111172f5ddf8b06f6494560e826c148f872"}, + {file = "pydantic_core-2.42.0-cp314-cp314-win_amd64.whl", hash = "sha256:232d86e00870aceee7251aa5f4ab17e3e4864a4656c015f8e03d1223bf8e17ba"}, + {file = "pydantic_core-2.42.0-cp314-cp314-win_arm64.whl", hash = "sha256:9a6fce4e778c2fe2b3f1df63bfaa522c147668517ba040c49ad7f67a66867cff"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f4d1670fbc5488cfb18dd9fc71a2c7c8e12caeeb6e5bb641aa351ac5e01963cf"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:baeae16666139d0110f1006a06809228f5293ab84e77f4b9dda2bdee95d6c4e8"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a77c7a8cedf5557a4e5547dabf55a8ec99949162bd7925b312f6ec37c24101c"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:133fccf13546ff2a0610cc5b978dd4ee2c7f55a7a86b6b722fd6e857694bacc5"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad5dbebfbab92cf0f6d0b13d55bf0a239880a1534377edf6387e2e7a4469f131"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6c0181016cb29ba4824940246606a8e13b1135de8306e00b5bd9d1efbc4cf85"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:020cfd7041cb71eac4dc93a29a6d5ec34f10b1fdc37f4f189c25bcc6748a2f97"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f73c6de3ee24f2b614d344491eda5628c4cdf3e7b79c0ac69bb40884ced2d319"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b2b448da50e1e8d5aac786dcf441afa761d26f1be4532b52cdf50864b47bd784"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0df0488b1f548ef874b45bbc60a70631eee0177b79b5527344d7a253e77a5ed2"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:b8aa32697701dc36c956f4a78172549adbe25eacba952bbfbde786fb66316151"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-win32.whl", hash = "sha256:173de56229897ff81b650ca9ed6f4c62401c49565234d3e9ae251119f6fd45c6"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2db227cf6797c286361f8d1e52b513f358a3ff9ebdede335e55a5edf4c59f06b"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a983862733ecaf0b5c7275145f86397bde4ee1ad84cf650e1d7af7febe5f7073"}, + {file = "pydantic_core-2.42.0.tar.gz", hash = "sha256:34068adadf673c872f01265fa17ec00073e99d7f53f6d499bdfae652f330b3d2"}, ] [[package]] From 335668098856a80accf9ce3586d52ad8c1d21f09 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 10:46:58 -0800 Subject: [PATCH 3/5] Extract Install View Data --- synodic_client/application/screen/install.py | 619 ++++++++++++------- synodic_client/application/screen/screen.py | 126 +--- tests/unit/qt/test_install_preview.py | 153 ----- tests/unit/qt/test_preview_model.py | 199 ++++++ tests/unit/test_config.py | 16 - 5 files changed, 623 insertions(+), 490 deletions(-) create mode 100644 tests/unit/qt/test_preview_model.py diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index b6eff7a..2a00930 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import enum import logging import shutil import tempfile @@ -48,7 +49,7 @@ ) from synodic_client.application.screen import skip_reason_label -from synodic_client.application.screen.action_card import ActionCardList +from synodic_client.application.screen.action_card import ActionCardList, action_key from synodic_client.application.screen.card import CardFrame from synodic_client.application.theme import ( ACTION_CARD_SKELETON_BAR_STYLE, @@ -84,6 +85,113 @@ def normalize_manifest_key(path_or_url: str) -> str: return path_or_url +# --------------------------------------------------------------------------- +# PreviewPhase / ActionState / PreviewModel — data layer +# --------------------------------------------------------------------------- + + +class PreviewPhase(enum.Enum): + """Lifecycle phase of a :class:`SetupPreviewWidget`. + + The widget transitions through these phases and uses them to decide + whether certain operations (like reloading the preview or toggling + buttons) are allowed. Having an explicit enum replaces the previous + ``_installing`` boolean flag and status-label-text-based implicit state. + """ + + IDLE = 'idle' + """No preview loaded.""" + + LOADING = 'loading' + """Skeleton placeholders displayed; preview worker running.""" + + PREVIEWING = 'previewing' + """Cards populated; dry-run status checks in progress.""" + + READY = 'ready' + """Dry-run complete; install button may be enabled.""" + + INSTALLING = 'installing' + """Install worker running.""" + + DONE = 'done' + """Install finished; execution logs visible.""" + + ERROR = 'error' + """Preview or install failed.""" + + +@dataclass +class ActionState: + """Per-action data that survives widget rebuilds. + + Each entry stores the authoritative execution log so that + :class:`ActionCard` widgets can be destroyed and recreated + without losing output. + """ + + action: SetupAction + """The porringer setup action.""" + + status: str = 'Checking\u2026' + """Human-readable dry-run status label.""" + + log_lines: list[tuple[str, str | None]] = field(default_factory=list) + """Accumulated execution log: ``(text, stream)`` pairs.""" + + +class PreviewModel: + """Data model for a single preview / install session. + + Holds all state that the :class:`SetupPreviewWidget` needs to + display and that must survive :class:`ActionCard` widget destruction. + The model is replaced wholesale when a new preview is loaded; during + an install it is updated in-place and outlives any UI refresh. + """ + + def __init__(self) -> None: + self.phase: PreviewPhase = PreviewPhase.IDLE + self.preview: SetupResults | None = None + self.manifest_path: Path | None = None + self.manifest_key: str | None = None + self.project_directory: Path | None = None + self.plugin_installed: dict[str, bool] = {} + self.prerelease_overrides: set[str] = set() + self.action_states: list[ActionState] = [] + self.upgradable_keys: set[tuple[object, ...]] = set() + self.checked_count: int = 0 + self.completed_count: int = 0 + self.temp_dir: str | None = None + + # -- Computed helpers -------------------------------------------------- + + @property + def actionable_count(self) -> int: + """Number of needed + upgradable actions.""" + needed = sum(1 for s in self.action_states if s.status == 'Needed') + upgradable = len(self.upgradable_keys) + return needed + upgradable + + @property + def install_enabled(self) -> bool: + """Whether the install button should be enabled.""" + if self.phase not in {PreviewPhase.READY}: + return False + return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states) + + def action_state_for(self, act: SetupAction) -> ActionState | None: + """Look up :class:`ActionState` by content key.""" + key = action_key(act) + for s in self.action_states: + if action_key(s.action) == key: + return s + return None + + def has_same_manifest(self, key: str) -> bool: + """Return ``True`` if *key* matches the current manifest key.""" + return self.manifest_key is not None and self.manifest_key == normalize_manifest_key(key) + + @dataclass(frozen=True, slots=True) class InstallConfig: """Optional execution parameters for :class:`InstallWorker`.""" @@ -187,13 +295,13 @@ class SetupPreviewWidget(QWidget): This widget is embedded by both :class:`InstallPreviewWindow` (for URI-based installs) and ``ProjectsView`` (for cached-directory - projects). It owns the action card list, command section, metadata - display, status label, and install execution pipeline. + projects). It owns the entire preview → install lifecycle including + the :class:`PreviewWorker` and :class:`InstallWorker` threads. - The caller is responsible for providing a manifest path and project - directory. Preview data is fed in via - :meth:`on_preview_ready` / :meth:`on_action_checked` / - :meth:`on_preview_finished` / :meth:`on_preview_error` signal slots. + State is held in a :class:`PreviewModel` that survives widget + rebuilds so execution logs are never lost. The widget manages its + own phase transitions via :class:`PreviewPhase` — callers only need + to call :meth:`load`. """ #: Emitted when the user clicks Close (or after a fatal preview error). @@ -202,8 +310,11 @@ class SetupPreviewWidget(QWidget): #: Emitted after a successful install completes. install_finished = Signal(object) # SetupResults - #: Emitted when per-item pre-release overrides change (debounced). - prerelease_changed = Signal() + #: Emitted when manifest metadata becomes available (name, author, …). + metadata_ready = Signal(object) # SetupResults + + #: Emitted whenever the lifecycle phase changes. + phase_changed = Signal(object) # PreviewPhase def __init__( self, @@ -226,20 +337,10 @@ def __init__( 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._model = PreviewModel() self._runner: QThread | None = None self._cancellation_token: CancellationToken | None = None - self._completed_count = 0 - self._checked_count = 0 - self._action_statuses: list[str] = [] - self._upgradable_rows: set[int] = set() - self._action_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) @@ -349,6 +450,16 @@ def _init_button_bar(self) -> QHBoxLayout: # --- Public API --- + @property + def model(self) -> PreviewModel: + """Return the current preview model (read-only access for hosts).""" + return self._model + + @property + def phase(self) -> PreviewPhase: + """Return the current lifecycle phase.""" + return self._model.phase + @property def prerelease_overrides(self) -> set[str] | None: """Return the current per-item pre-release overrides. @@ -356,46 +467,95 @@ def prerelease_overrides(self) -> set[str] | None: 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. + return self._model.prerelease_overrides or None - The key is normalised so that equivalent paths always resolve - to the same config entry. + def set_project_directory(self, path: Path) -> None: + """Set the project directory used for install execution. Args: - key: Manifest path or URL identifying this preview. + path: Working directory for project sync actions. """ - 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() + self._model.project_directory = path - def set_project_directory(self, path: Path) -> None: - """Set the project directory used for install execution. + def load( + self, + path_or_url: str, + *, + project_directory: Path | None = None, + detect_updates: bool = True, + ) -> None: + """Load a manifest preview, or skip if the same manifest is already showing results. + + If the widget is in :attr:`PreviewPhase.DONE` and *path_or_url* + matches the current manifest key, the load is silently skipped + so that execution logs remain visible. Otherwise the widget + resets and starts a new :class:`PreviewWorker`. Args: - path: Working directory for project sync actions. + path_or_url: Manifest path or URL. + project_directory: Working directory for project sync actions. + detect_updates: Query package indices for newer versions. """ - self._project_directory = path + key = normalize_manifest_key(path_or_url) + + # Preserve post-install results when re-selecting the same manifest + if self._model.phase == PreviewPhase.DONE and self._model.has_same_manifest(key): + return + + self._stop_preview() + + # Build a fresh model, carrying over config-based state + self._model = PreviewModel() + self._model.manifest_key = key + if project_directory is not None: + self._model.project_directory = project_directory + + # Load persisted prerelease overrides from config + if self._config is not None and self._config.prerelease_packages: + self._model.prerelease_overrides = set( + self._config.prerelease_packages.get(key, []), + ) + + self._set_phase(PreviewPhase.LOADING) + + # Validate local paths before spawning the worker + local = resolve_local_path(path_or_url) + if local is not None: + if not local.exists(): + self._show_error_inline(f'Path not found: {local}') + return + if not self._porringer.sync.has_manifest(local): + self._show_error_inline(f'No manifest found at: {local}') + return + + overrides = self._model.prerelease_overrides or None + + preview_worker = PreviewWorker( + self._porringer, + path_or_url, + project_directory=self._model.project_directory, + detect_updates=detect_updates, + prerelease_packages=overrides, + ) + preview_worker.manifest_parsed.connect(self._on_manifest_parsed) + preview_worker.plugins_queried.connect(self._on_plugins_queried) + preview_worker.preview_ready.connect(self._on_preview_resolved) + preview_worker.action_checked.connect(self._on_action_checked) + preview_worker.finished.connect(self._on_preview_finished) + preview_worker.error.connect(self._on_preview_error) + + self._runner = preview_worker + self._runner.start() 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._upgradable_rows = set() - self._action_index_map = {} - self._plugin_installed = {} - self._prerelease_overrides = set() - self._installing = False + """Clear all state and UI for a fresh preview. + + Callers should prefer :meth:`load` which handles resets + internally. This method is provided for explicit teardown when + the widget is being repurposed (e.g. tab destruction). + """ + self._stop_preview() + self._model = PreviewModel() self._prerelease_debounce.stop() self._card_list.clear() @@ -408,18 +568,6 @@ def reset(self) -> None: self._status_label.setStyleSheet('') self._install_btn.setEnabled(False) - def start_loading(self) -> None: - """Show skeleton placeholders for the metadata card and action cards. - - 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._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. @@ -432,125 +580,185 @@ def show_not_found(self, message: str) -> None: self._status_label.setText(message) self._status_label.setStyleSheet(MUTED_STYLE) + # --- Phase management --- + + def _set_phase(self, phase: PreviewPhase) -> None: + """Transition to *phase* and update the UI accordingly.""" + self._model.phase = phase + self.phase_changed.emit(phase) + + if phase == PreviewPhase.LOADING: + self._card_list.clear() + self._name_label.hide() + self._description_label.hide() + self._meta_label.hide() + self._metadata_card.hide() + self._metadata_skeleton.show() + self._card_list.show_skeletons(3) + self._status_label.setText('Downloading manifest\u2026') + self._status_label.setStyleSheet(MUTED_STYLE) + self._install_btn.setEnabled(False) + elif phase == PreviewPhase.ERROR: + self._metadata_skeleton.hide() + self._install_btn.setEnabled(False) + + def _show_error_inline(self, message: str) -> None: + """Display a muted error and transition to ERROR phase.""" + self._set_phase(PreviewPhase.ERROR) + self._card_list.clear() + 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) + self._model.prerelease_overrides.add(key) else: - self._prerelease_overrides.discard(key) + self._model.prerelease_overrides.discard(key) self._prerelease_debounce.start() def _flush_prerelease_overrides(self) -> None: - """Persist overrides to config and emit the changed signal. + """Persist overrides to config. - 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. + During :attr:`PreviewPhase.READY` this also triggers an + internal reload so the preview reflects the new setting. + During :attr:`PreviewPhase.INSTALLING` or + :attr:`PreviewPhase.DONE` the config is saved but no reload + occurs — execution logs are preserved. """ - if self._config is None or self._manifest_key is None: + if self._config is None or self._model.manifest_key is None: return pkgs = dict(self._config.prerelease_packages or {}) - if self._prerelease_overrides: - pkgs[self._manifest_key] = sorted(self._prerelease_overrides) + if self._model.prerelease_overrides: + pkgs[self._model.manifest_key] = sorted(self._model.prerelease_overrides) else: - pkgs.pop(self._manifest_key, None) + pkgs.pop(self._model.manifest_key, None) new_value = pkgs if pkgs else None self._config = update_user_config(prerelease_packages=new_value) - logger.info('Pre-release overrides for %s: %s', self._manifest_key, self._prerelease_overrides) + logger.info( + 'Pre-release overrides for %s: %s', + self._model.manifest_key, + self._model.prerelease_overrides, + ) - if not self._installing: - self.prerelease_changed.emit() + if self._model.phase == PreviewPhase.READY: + # Re-run the preview with updated overrides + self.load( + self._model.manifest_key, + project_directory=self._model.project_directory, + ) - # --- Preview callbacks (connect to PreviewWorker signals) --- + # --- Internal worker management --- - def on_plugins_queried(self, mapping: dict[str, bool]) -> None: - """Store plugin presence data for annotating the action cards. + def _stop_preview(self) -> None: + """Wait for any running worker to finish before starting a new one.""" + self._prerelease_debounce.stop() + if self._runner is not None and self._runner.isRunning(): + self._runner.quit() + self._runner.wait() + self._runner = None - Called before :meth:`on_preview_ready` so that card population - can flag actions whose installer plugin is not installed. + # --- Preview callbacks (wired by load()) --- - Args: - mapping: Plugin name → installed status. - """ - self._plugin_installed = mapping + 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._model.temp_dir = temp_dir_path - def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: - """Handle a successful preview — populate action cards. + if preview.metadata: + self.metadata_ready.emit(preview) - Args: - preview: The setup preview results. - manifest_path: Path to the manifest file. - temp_dir_path: Path to the temp directory (kept alive for execution). - """ + self._on_preview_ready(preview, manifest_path, temp_dir_path) + + def _on_plugins_queried(self, mapping: dict[str, bool]) -> None: + """Store plugin presence data for annotating the action cards.""" + self._model.plugin_installed = mapping + + def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: + """Handle a successful preview — populate action cards.""" logger.info('Preview ready: %d action(s) from %s', len(preview.actions), manifest_path) - self._preview = preview - self._manifest_path = Path(manifest_path) + m = self._model + m.preview = preview + m.manifest_path = Path(manifest_path) + m.temp_dir = temp_dir_path + + # Infer project directory from manifest result when available + if preview.root_directory and m.project_directory is None: + m.project_directory = preview.root_directory + self._status_label.setStyleSheet('') self._metadata_skeleton.hide() self._show_metadata(preview) + if preview.metadata: + self.metadata_ready.emit(preview) + if not preview.actions: self._card_list.clear() self._status_label.setText('No actions to perform — the manifest is empty.') + self._set_phase(PreviewPhase.READY) return - 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)} + # Build action states + m.action_states = [ActionState(action=a) for a in preview.actions] + m.checked_count = 0 total = len(preview.actions) self._status_label.setText(f'{total} action(s) \u2014 checking status\u2026') + self._set_phase(PreviewPhase.PREVIEWING) self._card_list.populate( preview.actions, - plugin_installed=self._plugin_installed, - prerelease_overrides=self._prerelease_overrides, + plugin_installed=m.plugin_installed, + prerelease_overrides=m.prerelease_overrides, ) - # Mark installer-missing actions as 'Not installed' in the status list - for i, action in enumerate(preview.actions): + # Mark installer-missing actions in the model + for state in m.action_states: + action = state.action installer_missing = ( action.installer is not None - and action.installer in self._plugin_installed - and not self._plugin_installed[action.installer] + and action.installer in m.plugin_installed + and not m.plugin_installed[action.installer] ) if installer_missing: - self._action_statuses[i] = 'Not installed' + state.status = 'Not installed' self._install_btn.setEnabled(True) - def on_preview_resolved(self, preview: SetupResults) -> None: + def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: """Handle the fully-resolved preview (CLI commands populated). Called after ``MANIFEST_LOADED`` — cards are already visible - from the earlier ``on_preview_ready`` call. This method - updates the CLI command display text on each existing card. - - Args: - preview: The fully-resolved setup results with CLI commands. + from the earlier ``_on_manifest_parsed`` handler. This only + updates CLI command text and the temp-dir reference. """ - if self._preview is None: + if self._model.preview is None: return + self._model.temp_dir = temp_dir_path + + if preview.metadata: + self.metadata_ready.emit(preview) + for action in preview.actions: if action.cli_command: card = self._card_list.get_card(action) if card is not None: card.update_command(action) - def on_action_checked(self, row: int, result: SetupActionResult) -> None: - """Update the data model and action card with the dry-run result.""" + def _on_action_checked(self, row: int, result: SetupActionResult) -> None: + """Update the model and action card with a dry-run result.""" + m = self._model if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: label = skip_reason_label(result.skip_reason) - self._upgradable_rows.add(row) + if 0 <= row < len(m.action_states): + m.upgradable_keys.add(action_key(m.action_states[row].action)) elif result.skipped: label = skip_reason_label(result.skip_reason) elif not result.success: @@ -558,40 +766,41 @@ def on_action_checked(self, row: int, result: SetupActionResult) -> None: else: label = 'Needed' - if 0 <= row < len(self._action_statuses): - self._action_statuses[row] = label + if 0 <= row < len(m.action_states): + m.action_states[row].status = label - # Find the card for this action - if self._preview and 0 <= row < len(self._preview.actions): - action = self._preview.actions[row] + # Update the card widget + if m.preview and 0 <= row < len(m.preview.actions): + action = m.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') + # Update phase text + m.checked_count += 1 + total = len(m.action_states) + self._status_label.setText( + f'{total} action(s) \u2014 checking status ({m.checked_count}/{total})\u2026', + ) - def on_preview_finished(self) -> None: + def _on_preview_finished(self) -> None: """Finalize the preview after the dry-run check completes.""" - if not self._action_statuses: + m = self._model + if not m.action_states: return - # 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\u2026': - self._action_statuses[i] = 'Needed' + for state in m.action_states: + if state.status == 'Checking\u2026': + state.status = 'Needed' - # 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') - failed = sum(1 for s in self._action_statuses if s == 'Failed') + # Compute summary + total = len(m.action_states) + needed = sum(1 for s in m.action_states if s.status == 'Needed') + upgradable = len(m.upgradable_keys) + unavailable = sum(1 for s in m.action_states if s.status == 'Not installed') + failed = sum(1 for s in m.action_states if s.status == 'Failed') satisfied = total - needed - upgradable - unavailable - failed parts: list[str] = [] @@ -613,6 +822,8 @@ def on_preview_finished(self) -> None: else: self._status_label.setText(f'{total} action(s): {", ".join(parts)}.') + self._set_phase(PreviewPhase.READY) + logger.info( 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable, %d failed', total, @@ -623,9 +834,10 @@ def on_preview_finished(self) -> None: failed, ) - def on_preview_error(self, message: str) -> None: + def _on_preview_error(self, message: str) -> None: """Handle a preview error.""" logger.error('Preview failed: %s', message) + self._set_phase(PreviewPhase.ERROR) self._metadata_skeleton.hide() self._card_list.clear() self._status_label.setText('') @@ -667,14 +879,15 @@ def _show_metadata(self, preview: SetupResults) -> None: def _on_install(self) -> None: """Handle the Install button click.""" - if self._manifest_path is None: + m = self._model + if m.manifest_path is None: return - self._installing = True self._prerelease_debounce.stop() + self._set_phase(PreviewPhase.INSTALLING) self._install_btn.setEnabled(False) self._close_btn.setEnabled(False) - self._completed_count = 0 + m.completed_count = 0 self._cancellation_token = CancellationToken() @@ -682,17 +895,17 @@ def _on_install(self) -> None: # 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 + strategy = SyncStrategy.LATEST if m.upgradable_keys else SyncStrategy.MINIMAL # Worker thread worker = InstallWorker( self._porringer, - self._manifest_path, + m.manifest_path, self._cancellation_token, InstallConfig( - project_directory=self._project_directory, + project_directory=m.project_directory, strategy=strategy, - prerelease_packages=self._prerelease_overrides or None, + prerelease_packages=m.prerelease_overrides or None, ), ) worker.action_started.connect(self._on_action_started) @@ -712,7 +925,15 @@ def _on_action_started(self, action: SetupAction) -> None: self._card_list.scroll_to_card(card) def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None: - """Handle a sub-action progress event — route output to the card.""" + """Handle a sub-action progress event — route output to the card and model.""" + # Store in model so logs survive widget rebuilds + state = self._model.action_state_for(action) + if state is not None: + if progress.output is not None: + state.log_lines.append((progress.output, progress.stream)) + elif progress.message is not None: + state.log_lines.append((progress.message, None)) + card = self._card_list.get_card(action) if card is None: return @@ -722,21 +943,19 @@ def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> 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 + m = self._model + m.completed_count += 1 - # 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\u2026 ({self._completed_count}/{total})') + total = len(m.action_states) + self._status_label.setText(f'Installing\u2026 ({m.completed_count}/{total})') def _on_cancel(self) -> None: """Handle cancel request.""" @@ -745,7 +964,7 @@ def _on_cancel(self) -> None: def _on_install_finished(self, results: SetupResults) -> None: """Handle install completion.""" - self._installing = False + self._set_phase(PreviewPhase.DONE) 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) @@ -767,7 +986,7 @@ def _on_install_finished(self, results: SetupResults) -> None: def _on_install_error(self, message: str) -> None: """Handle install error.""" - self._installing = False + self._set_phase(PreviewPhase.ERROR) self._status_label.setText(f'Install failed: {message}') self._install_btn.setEnabled(True) self._close_btn.setEnabled(True) @@ -781,8 +1000,9 @@ def _on_install_error(self, message: str) -> None: class InstallPreviewWindow(QMainWindow): """Standalone window that previews and executes a URI-based manifest install. - Wraps :class:`SetupPreviewWidget` and owns the download lifecycle - (temp directory, ``PreviewWorker``). + A thin shell around :class:`SetupPreviewWidget`. The widget owns the + full preview → install lifecycle; this window only provides the + source-card UI and project-directory field. """ def __init__( @@ -806,8 +1026,6 @@ def __init__( self._porringer = porringer self._manifest_url = manifest_url self._config = config - self._temp_dir_path: str | None = None - self._runner: QThread | None = None # Default project directory to the current working directory self._project_directory: Path = Path.cwd() @@ -835,10 +1053,10 @@ def _init_ui(self) -> None: layout.addWidget(source_card) - # Shared preview widget + # Shared preview widget — owns the full lifecycle 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.metadata_ready.connect(self._on_metadata_ready) self._preview_widget.set_project_directory(self._project_directory) layout.addWidget(self._preview_widget) @@ -887,15 +1105,11 @@ def showEvent(self, event: Any) -> None: def closeEvent(self, event: Any) -> None: """Clean up the temp directory when the window is closed.""" logger.info('Install preview window closing') - self._cleanup_temp_dir() + temp = self._preview_widget.model.temp_dir + if temp: + _safe_rmtree(temp) super().closeEvent(event) - def _cleanup_temp_dir(self) -> None: - """Remove the temporary download directory if it exists.""" - if self._temp_dir_path: - _safe_rmtree(self._temp_dir_path) - self._temp_dir_path = None - # --- Public API --- def start(self) -> None: @@ -903,75 +1117,22 @@ def start(self) -> None: Call this after ``show()`` to begin the download → preview flow. """ - self._stop_preview() logger.info('Starting install preview for: %s', self._manifest_url) self._url_label.setText(f'Manifest: {self._manifest_url}') - self._preview_widget.reset() - self._preview_widget.start_loading() - self._preview_widget.set_manifest_key(self._manifest_url) - - manifest_key = normalize_manifest_key(self._manifest_url) - config = self._config - if config is None: - return - overrides = set((config.prerelease_packages or {}).get(manifest_key, [])) - preview_worker = PreviewWorker( - self._porringer, + detect = self._config.detect_updates if self._config else True + self._preview_widget.load( self._manifest_url, project_directory=self._project_directory, - detect_updates=config.detect_updates, - prerelease_packages=overrides or None, + detect_updates=detect, ) - 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) - preview_worker.finished.connect(self._preview_widget.on_preview_finished) - preview_worker.error.connect(self._preview_widget.on_preview_error) - - self._runner = preview_worker - self._runner.start() + # --- Callbacks --- - # --- Preview callbacks (intercept to capture temp dir / metadata) --- - - 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 - if preview.metadata and preview.metadata.name: - self.setWindowTitle(f'Install Preview — {preview.metadata.name}') - - 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 + def _on_metadata_ready(self, preview: object) -> None: + """Update the window title when metadata arrives.""" + if hasattr(preview, 'metadata') and preview.metadata and preview.metadata.name: + self.setWindowTitle(f'Install Preview \u2014 {preview.metadata.name}') class PreviewWorker(QThread): diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 3a5a4eb..8510248 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -7,9 +7,9 @@ from pathlib import Path from porringer.api import API -from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo, SetupResults +from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo from porringer.schema.plugin import PluginKind -from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QResizeEvent, QStandardItem from PySide6.QtWidgets import ( QComboBox, @@ -32,11 +32,7 @@ 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, - normalize_manifest_key, -) +from synodic_client.application.screen.install import SetupPreviewWidget from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.screen.update_banner import UpdateBanner from synodic_client.application.theme import ( @@ -503,7 +499,8 @@ class ProjectsView(QWidget): Combines a cached-directory selector (editable ``QComboBox`` with Browse) and a :class:`SetupPreviewWidget` for dry-run preview and - install execution. + install execution. Preview loading is fully delegated to the + embedded widget via :meth:`SetupPreviewWidget.load`. """ def __init__(self, porringer: API, config: ResolvedConfig, parent: QWidget | None = None) -> None: @@ -517,7 +514,6 @@ def __init__(self, porringer: API, config: ResolvedConfig, parent: QWidget | Non super().__init__(parent) self._porringer = porringer self._config = config - self._runner: QThread | None = None self._refresh_in_progress = False self._init_ui() @@ -555,7 +551,6 @@ def _init_ui(self) -> None: # Row 1 — Shared preview widget (takes majority of space) 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) @@ -627,7 +622,13 @@ async def _async_refresh(self) -> None: # Trigger preview for the current selection if self._combo.currentText(): - self._load_preview() + path_text = self._combo.currentText().strip() + selected = Path(path_text) + self._preview.load( + path_text, + project_directory=selected if selected.is_dir() else selected.parent, + detect_updates=self._config.detect_updates, + ) except Exception: logger.exception('Failed to refresh projects') finally: @@ -649,10 +650,16 @@ def _grey_out_item(self, idx: int, tooltip: str, reason: str) -> None: # --- Event handlers --- def _on_selection_changed(self, _index: int) -> None: - """Handle combo box selection changes.""" + """Handle combo box selection changes — delegate to the preview widget.""" self._update_remove_btn() - if self._combo.currentText(): - self._load_preview() + path_text = self._combo.currentText().strip() + if path_text: + selected = Path(path_text) + self._preview.load( + str(selected), + project_directory=selected if selected.is_dir() else selected.parent, + detect_updates=self._config.detect_updates, + ) def _on_browse(self) -> None: """Open a file picker filtered to recognised manifest filenames.""" @@ -666,7 +673,12 @@ def _on_browse(self) -> None: ) if chosen: self._combo.setEditText(chosen) - self._load_preview() + selected = Path(chosen) + self._preview.load( + chosen, + project_directory=selected if selected.is_dir() else selected.parent, + detect_updates=self._config.detect_updates, + ) def _on_remove(self) -> None: """Remove the currently selected directory from the cache.""" @@ -685,102 +697,32 @@ def _on_install_finished(self, _results: object) -> None: The directory is added to the porringer cache and the combo box is updated *without* reloading the preview, so the execution - log remains visible. + log remains visible. Combo signals are blocked during the + update to prevent ``_on_selection_changed`` from firing a + redundant :meth:`SetupPreviewWidget.load` (which would be + skipped anyway due to the DONE-phase guard, but blocking + is cleaner). """ current_text = self._combo.currentText().strip() if not current_text: return - # Only register if the path isn't already in the combo's cached items idx = self._combo.findText(current_text) item_data = self._combo.itemData(idx, Qt.ItemDataRole.UserRole) if idx >= 0 else None if item_data is None: try: self._porringer.cache.add_directory(Path(current_text)) logger.info('Registered new project directory: %s', current_text) - # Add the entry to the combo inline so the Remove button - # works without a full refresh that would wipe the log. + self._combo.blockSignals(True) 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._combo.blockSignals(False) 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: - """Run a dry-run preview for the currently selected path.""" - path_text = self._combo.currentText().strip() - if not path_text: - return - - selected_path = Path(path_text) - - self._stop_preview() - self._preview.reset() - - if not selected_path.exists(): - self._preview.show_not_found(f'Path not found: {selected_path}') - return - - if not self._porringer.sync.has_manifest(selected_path): - self._preview.show_not_found(f'No manifest found at: {selected_path}') - return - - 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, [])) - - # For file paths, use the parent directory so the dry-run - # can detect already-cloned repositories on disk. The final - # project directory may still be overridden once porringer - # returns ``root_directory`` in the preview result. - preview_worker = PreviewWorker( - self._porringer, - str(selected_path), - project_directory=selected_path if selected_path.is_dir() else selected_path.parent, - 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) - preview_worker.plugins_queried.connect(self._preview.on_plugins_queried) - preview_worker.finished.connect(self._preview.on_preview_finished) - preview_worker.error.connect(self._on_preview_error) - - self._runner = preview_worker - self._runner.start() - - def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: - """Set the project directory from the manifest result and forward.""" - if preview.root_directory: - self._preview.set_project_directory(preview.root_directory) - self._preview.on_preview_ready(preview, manifest_path, temp_dir_path) - - def _on_preview_error(self, message: str) -> None: - """Handle preview errors inline instead of showing a modal dialog.""" - logger.warning('Preview error: %s', message) - self._preview.show_not_found(message) - def _update_remove_btn(self) -> None: """Enable the Remove button only for cached (non-freeform) entries.""" idx = self._combo.currentIndex() diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 7e2a79a..335250a 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -78,120 +78,6 @@ def test_skip_reason_label_human_readable() -> None: 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.""" @@ -994,42 +880,3 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: assert len(checked) == 1 assert checked[0][1].skipped is False assert checked[0][1].skip_reason is None - - -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/qt/test_preview_model.py b/tests/unit/qt/test_preview_model.py new file mode 100644 index 0000000..8c17c22 --- /dev/null +++ b/tests/unit/qt/test_preview_model.py @@ -0,0 +1,199 @@ +"""Tests for PreviewModel and ActionState.""" + +from __future__ import annotations + +import sys +from typing import Any +from unittest.mock import MagicMock + +from porringer.schema import SetupAction +from porringer.schema.plugin import PluginKind +from PySide6.QtWidgets import QApplication + +from synodic_client.application.screen.action_card import action_key +from synodic_client.application.screen.install import ( + ActionState, + PreviewModel, + PreviewPhase, + normalize_manifest_key, +) + +_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: Any, +) -> SetupAction: + """Create a mock SetupAction with sensible defaults.""" + 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 + + +# --------------------------------------------------------------------------- +# ActionState +# --------------------------------------------------------------------------- + + +class TestActionState: + """Verify ActionState dataclass behaviour.""" + + @staticmethod + def test_defaults() -> None: + """Defaults should be sensible for a freshly-created state.""" + act = _make_action() + state = ActionState(action=act) + assert state.status == 'Checking\u2026' + assert state.log_lines == [] + + @staticmethod + def test_log_lines_are_independent() -> None: + """Each ActionState should have its own independent log list.""" + a = ActionState(action=_make_action()) + b = ActionState(action=_make_action(package='ruff')) + a.log_lines.append(('hello', None)) + assert b.log_lines == [] + + +# --------------------------------------------------------------------------- +# PreviewModel +# --------------------------------------------------------------------------- + + +class TestPreviewModel: + """Tests for the PreviewModel data layer.""" + + @staticmethod + def test_initial_phase_is_idle() -> None: + """A fresh model starts in IDLE phase.""" + model = PreviewModel() + assert model.phase is PreviewPhase.IDLE + + @staticmethod + def test_install_enabled_false_when_idle() -> None: + """Install button should not be enabled in IDLE phase.""" + model = PreviewModel() + assert model.install_enabled is False + + @staticmethod + def test_install_enabled_true_when_ready_with_needed_actions() -> None: + """Install should be enabled when READY and there are needed actions.""" + model = PreviewModel() + model.phase = PreviewPhase.READY + state = ActionState(action=_make_action()) + state.status = 'Needed' + model.action_states.append(state) + assert model.install_enabled is True + + @staticmethod + def test_install_enabled_true_when_ready_with_upgradable() -> None: + """Install should be enabled when READY with upgradable actions.""" + model = PreviewModel() + model.phase = PreviewPhase.READY + state = ActionState(action=_make_action()) + state.status = 'Already installed' + model.action_states.append(state) + model.upgradable_keys.add(action_key(state.action)) + assert model.install_enabled is True + + @staticmethod + def test_install_enabled_false_when_ready_but_all_satisfied() -> None: + """Install should be disabled when all actions are satisfied.""" + model = PreviewModel() + model.phase = PreviewPhase.READY + state = ActionState(action=_make_action()) + state.status = 'Already installed' + model.action_states.append(state) + assert model.install_enabled is False + + @staticmethod + def test_install_enabled_true_for_command_actions() -> None: + """Command actions (kind=None) are always actionable.""" + model = PreviewModel() + model.phase = PreviewPhase.READY + state = ActionState(action=_make_action(kind=None, description='Run setup')) + state.status = 'Already installed' + model.action_states.append(state) + assert model.install_enabled is True + + @staticmethod + def test_install_enabled_false_when_installing() -> None: + """Install should be disabled during installation.""" + model = PreviewModel() + model.phase = PreviewPhase.INSTALLING + state = ActionState(action=_make_action()) + state.status = 'Needed' + model.action_states.append(state) + assert model.install_enabled is False + + @staticmethod + def test_actionable_count() -> None: + """Actionable count = needed + upgradable.""" + model = PreviewModel() + needed = ActionState(action=_make_action(package='a')) + needed.status = 'Needed' + satisfied = ActionState(action=_make_action(package='b')) + satisfied.status = 'Already installed' + upgradable = ActionState(action=_make_action(package='c')) + upgradable.status = 'Update available' + model.action_states = [needed, satisfied, upgradable] + model.upgradable_keys.add(action_key(upgradable.action)) + assert model.actionable_count == 2 # 1 needed + 1 upgradable + + @staticmethod + def test_action_state_for_found() -> None: + """action_state_for should find a state by matching action key.""" + model = PreviewModel() + act = _make_action(package='ruff') + state = ActionState(action=act) + model.action_states.append(state) + found = model.action_state_for(act) + assert found is state + + @staticmethod + def test_action_state_for_not_found() -> None: + """action_state_for should return None for unknown actions.""" + model = PreviewModel() + act = _make_action(package='ruff') + assert model.action_state_for(act) is None + + @staticmethod + def test_has_same_manifest_match() -> None: + """has_same_manifest should return True for matching keys.""" + model = PreviewModel() + model.manifest_key = normalize_manifest_key('https://example.com/porringer.json') + assert model.has_same_manifest('https://example.com/porringer.json') is True + + @staticmethod + def test_has_same_manifest_mismatch() -> None: + """has_same_manifest should return False for different keys.""" + model = PreviewModel() + model.manifest_key = normalize_manifest_key('https://example.com/a.json') + assert model.has_same_manifest('https://example.com/b.json') is False + + @staticmethod + def test_has_same_manifest_when_empty() -> None: + """has_same_manifest should return False when no manifest loaded.""" + model = PreviewModel() + assert model.has_same_manifest('https://example.com/porringer.json') is False diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 1684c22..b356f39 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -49,13 +49,6 @@ def test_defaults() -> None: assert config.prerelease_packages is None assert config.auto_start is None - @staticmethod - def test_with_values() -> None: - """Verify config accepts explicit values.""" - config = UserConfig(update_source='/path/to/releases', update_channel='dev') - 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.""" @@ -91,15 +84,6 @@ def test_json_round_trip() -> None: restored = UserConfig.model_validate(data) assert restored == original - @staticmethod - def test_json_round_trip_defaults() -> None: - """Verify default config round-trips cleanly.""" - original = UserConfig() - data = json.loads(original.model_dump_json()) - restored = UserConfig.model_validate(data) - assert restored.update_source is None - assert restored.update_channel is None - @staticmethod def test_extra_fields_ignored() -> None: """Verify unrecognized fields do not cause errors.""" From 90598be18f256369e4ba950ef21f42ef23a9f669 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 12:04:37 -0800 Subject: [PATCH 4/5] Scroll/Log Behavior Consolidation --- .../application/screen/action_card.py | 138 +++--------------- synodic_client/application/screen/install.py | 75 +++++++--- .../application/screen/log_panel.py | 46 +++--- tests/unit/qt/conftest.py | 14 ++ tests/unit/qt/test_action_card.py | 68 +-------- tests/unit/qt/test_log_panel.py | 23 ++- tests/unit/qt/test_preview_model.py | 5 - tests/unit/qt/test_settings.py | 6 - tests/unit/qt/test_update_banner.py | 6 - 9 files changed, 122 insertions(+), 259 deletions(-) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index f731d24..46289a1 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -1,9 +1,9 @@ """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. +Each card shows essential information (package name, type, version, +status badge). During install, execution output is routed to the +unified :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel` +rather than displayed inline. :class:`ActionCard` is the per-action widget. :class:`ActionCardList` is the scrollable container that holds them. @@ -11,23 +11,19 @@ from __future__ import annotations -import html as html_mod import logging from porringer.backend.command.core.action_builder import PHASE_ORDER 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.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import ( QApplication, QCheckBox, QFrame, QHBoxLayout, QLabel, - QScrollArea, - QSizePolicy, - QTextEdit, QToolButton, QVBoxLayout, QWidget, @@ -38,7 +34,6 @@ 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, @@ -61,13 +56,6 @@ COPY_BTN_STYLE, COPY_FEEDBACK_MS, COPY_ICON, - LOG_COLOR_ERROR, - LOG_COLOR_PHASE, - LOG_COLOR_STDERR, - LOG_COLOR_STDOUT, - LOG_COLOR_SUCCESS, - MONOSPACE_FAMILY, - MONOSPACE_SIZE, ) logger = logging.getLogger(__name__) @@ -214,7 +202,6 @@ def __init__( 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 @@ -278,7 +265,6 @@ def _init_skeleton_ui(self) -> None: 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) @@ -287,7 +273,6 @@ def _init_real_ui(self) -> None: outer.addLayout(self._build_top_row()) outer.addWidget(self._build_description_row()) outer.addWidget(self._build_command_row()) - outer.addWidget(self._build_log_output()) def _build_top_row(self) -> QHBoxLayout: """Build the top row: type badge | package name ... version | status/spinner | prerelease.""" @@ -366,40 +351,10 @@ def _build_command_row(self) -> QWidget: self._command_row.hide() return self._command_row - def _build_log_output(self) -> QTextEdit: - """Build the 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() - return self._log_output - # ------------------------------------------------------------------ - # Mouse events (toggle log) + # Mouse events (copy button) # ------------------------------------------------------------------ - def mousePressEvent(self, event: object) -> None: - """Toggle the inline log body on click.""" - if self._is_skeleton or not hasattr(self, '_log_output'): - return - # Don't toggle the log when clicking interactive child widgets - if hasattr(self, '_copy_btn') and self._copy_btn.underMouse(): - return - if hasattr(self, '_package_label') and self._package_label.underMouse(): - return - if hasattr(self, '_desc_label') and self._desc_label.underMouse(): - 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) - def _copy_command(self) -> None: """Copy the command label text to the clipboard with brief feedback.""" clipboard = QApplication.clipboard() @@ -644,7 +599,8 @@ def finalize_checking(self) -> None: def set_executing(self) -> None: """Transition the card into the *executing* state. - Shows the inline log body and updates the status badge. + Updates the status badge. Execution output is routed to the + unified :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`. """ if self._is_skeleton: return @@ -652,37 +608,13 @@ def set_executing(self) -> None: 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. + The card returns to the default border style. Detailed output + is displayed in the unified + :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`. Args: result: The action execution result. @@ -696,14 +628,9 @@ def set_result(self, result: SetupActionResult) -> None: 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: @@ -712,10 +639,6 @@ def set_result(self, result: SetupActionResult) -> None: 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) @@ -735,15 +658,12 @@ def is_update_available(self) -> bool: # --------------------------------------------------------------------------- -# ActionCardList — scrollable container +# ActionCardList — card 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. +class ActionCardList(QWidget): + """Container of :class:`ActionCard` widgets. Cards are keyed by :func:`action_key` (content-based) so that look-ups work across different ``execute_stream`` runs where the @@ -756,18 +676,12 @@ class ActionCardList(QScrollArea): 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 = QVBoxLayout(self) 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] = {} @@ -785,7 +699,7 @@ def show_skeletons(self, count: int = 3) -> None: """ self.clear() for _ in range(count): - card = ActionCard(self._container, skeleton=True) + card = ActionCard(self, skeleton=True) self._layout.insertWidget(self._layout.count() - 1, card) self._cards.append(card) @@ -810,7 +724,7 @@ def populate( self.clear() sorted_actions = sorted(actions, key=action_sort_key) for act in sorted_actions: - card = ActionCard(self._container) + card = ActionCard(self) card.populate( act, plugin_installed=plugin_installed, @@ -866,21 +780,3 @@ def clear(self) -> None: 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 2a00930..afc7214 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 real-time inline -log output in each :class:`~synodic_client.application.screen.action_card.ActionCard`. +Execution runs on a background ``QThread`` with real-time output routed +to a unified :class:`~synodic_client.application.screen.log_panel.ExecutionLogPanel`. """ from __future__ import annotations @@ -34,7 +34,7 @@ SubActionProgress, SyncStrategy, ) -from PySide6.QtCore import QThread, QTimer, Signal +from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtWidgets import ( QFileDialog, QFrame, @@ -44,6 +44,7 @@ QMainWindow, QMessageBox, QPushButton, + QScrollArea, QVBoxLayout, QWidget, ) @@ -51,6 +52,7 @@ from synodic_client.application.screen import skip_reason_label from synodic_client.application.screen.action_card import ActionCardList, action_key from synodic_client.application.screen.card import CardFrame +from synodic_client.application.screen.log_panel import ExecutionLogPanel from synodic_client.application.theme import ( ACTION_CARD_SKELETON_BAR_STYLE, CARD_SPACING, @@ -353,12 +355,12 @@ def __init__( # --- UI construction --- def _init_ui(self) -> None: - """Build the two-pane layout. + """Build the layout. - 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. + Top section (fixed): metadata card (or skeleton), status/phase label. + Middle section: single scroll area containing the + :class:`ActionCardList` and the :class:`ExecutionLogPanel`. + Bottom section (fixed): button bar. """ outer = QVBoxLayout(self) outer.setContentsMargins(*NO_MARGINS) @@ -375,10 +377,31 @@ def _init_ui(self) -> None: self._status_label = QLabel() outer.addWidget(self._status_label) - # --- Scrollable card list (fills remaining space) --- + # --- Single scroll area for cards + execution log --- + self._scroll_area = QScrollArea() + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self._scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff, + ) + + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setContentsMargins(0, 0, 0, 0) + scroll_layout.setSpacing(CARD_SPACING) + self._card_list = ActionCardList() self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled) - outer.addWidget(self._card_list, stretch=1) + scroll_layout.addWidget(self._card_list) + + self._log_panel = ExecutionLogPanel() + self._log_panel.hide() + scroll_layout.addWidget(self._log_panel) + + scroll_layout.addStretch() + + self._scroll_area.setWidget(scroll_content) + outer.addWidget(self._scroll_area, stretch=1) # --- Button bar (fixed at bottom) --- button_bar = self._init_button_bar() @@ -567,6 +590,8 @@ def reset(self) -> None: self._status_label.setText('') self._status_label.setStyleSheet('') self._install_btn.setEnabled(False) + self._log_panel.clear() + self._log_panel.hide() def show_not_found(self, message: str) -> None: """Display a muted 'not found' message in the status label. @@ -598,6 +623,8 @@ def _set_phase(self, phase: PreviewPhase) -> None: self._status_label.setText('Downloading manifest\u2026') self._status_label.setStyleSheet(MUTED_STYLE) self._install_btn.setEnabled(False) + self._log_panel.clear() + self._log_panel.hide() elif phase == PreviewPhase.ERROR: self._metadata_skeleton.hide() self._install_btn.setEnabled(False) @@ -893,6 +920,10 @@ def _on_install(self) -> None: self._status_label.setText('Installing\u2026') + # Show the unified execution log panel + self._log_panel.clear() + self._log_panel.show() + # Choose LATEST strategy when there are upgradable actions so # porringer actually upgrades the already-installed packages. strategy = SyncStrategy.LATEST if m.upgradable_keys else SyncStrategy.MINIMAL @@ -918,14 +949,16 @@ def _on_install(self) -> None: self._runner.start() def _on_action_started(self, action: SetupAction) -> None: - """Handle an action starting execution — expand its card inline.""" + """Handle an action starting execution — update card badge and add a log section.""" card = self._card_list.get_card(action) if card is not None: card.set_executing() - self._card_list.scroll_to_card(card) + + section = self._log_panel.add_section(action) + self._scroll_area.ensureWidgetVisible(section) def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None: - """Handle a sub-action progress event — route output to the card and model.""" + """Handle a sub-action progress event — route output to the log panel and model.""" # Store in model so logs survive widget rebuilds state = self._model.action_state_for(action) if state is not None: @@ -934,16 +967,12 @@ def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> elif progress.message is not None: state.log_lines.append((progress.message, None)) - 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) + self._log_panel.on_sub_progress(action, progress) - self._card_list.scroll_to_card_bottom(card) + # Auto-scroll the single scroll area to the bottom + scrollbar = self._scroll_area.verticalScrollBar() + if scrollbar is not None: + scrollbar.setValue(scrollbar.maximum()) def _on_action_progress(self, action: SetupAction, result: SetupActionResult) -> None: """Handle a single action completion from the worker.""" @@ -954,6 +983,8 @@ def _on_action_progress(self, action: SetupAction, result: SetupActionResult) -> if card is not None: card.set_result(result) + self._log_panel.on_action_completed(action, result) + total = len(m.action_states) self._status_label.setText(f'Installing\u2026 ({m.completed_count}/{total})') diff --git a/synodic_client/application/screen/log_panel.py b/synodic_client/application/screen/log_panel.py index 7b6e9f5..83e2422 100644 --- a/synodic_client/application/screen/log_panel.py +++ b/synodic_client/application/screen/log_panel.py @@ -1,9 +1,12 @@ """Collapsible execution log panel for install operations. -Provides :class:`ExecutionLogPanel`, a scrollable container of +Provides :class:`ExecutionLogPanel`, a container of :class:`ActionLogSection` widgets — one per setup action. Each section has a collapsible header (default: open) with a status badge and a monospace output area that receives colour-coded stdout/stderr lines. + +Scrolling is handled by the parent :class:`QScrollArea` in +:class:`~synodic_client.application.screen.install.SetupPreviewWidget`. """ from __future__ import annotations @@ -12,10 +15,10 @@ import logging from porringer.schema import SetupAction, SetupActionResult, SubActionProgress +from PySide6.QtCore import Qt from PySide6.QtGui import QFont, QTextCursor from PySide6.QtWidgets import ( QLabel, - QScrollArea, QSizePolicy, QTextEdit, QVBoxLayout, @@ -23,6 +26,7 @@ ) from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label +from synodic_client.application.screen.action_card import action_key from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader from synodic_client.application.theme import ( LOG_CHEVRON_STYLE, @@ -98,8 +102,10 @@ def __init__(self, action: SetupAction, index: int, parent: QWidget | None = Non self._output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE)) self._output.setStyleSheet(LOG_OUTPUT_STYLE) self._output.setMinimumHeight(60) - self._output.setMaximumHeight(300) - self._output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self._output.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._output.setFixedHeight(60) + self._output.document().contentsChanged.connect(self._update_output_height) layout.addWidget(self._output) # --- Public API --- @@ -155,29 +161,32 @@ def _toggle(self) -> None: self._output.setVisible(self._expanded) self._chevron.setText(CHEVRON_DOWN if self._expanded else CHEVRON_RIGHT) + def _update_output_height(self) -> None: + """Resize the output QTextEdit to fit its content.""" + doc_height = int(self._output.document().size().height()) + frame = self._output.frameWidth() * 2 + self._output.setFixedHeight(max(60, doc_height + frame)) + -class ExecutionLogPanel(QScrollArea): - """Scrollable container of :class:`ActionLogSection` widgets. +class ExecutionLogPanel(QWidget): + """Container of :class:`ActionLogSection` widgets. Used as the execution view during install operations. Sections are - added dynamically as ``ACTION_STARTED`` events arrive. + added dynamically as ``ACTION_STARTED`` events arrive. Scrolling + is handled by the parent scroll area. """ def __init__(self, parent: QWidget | None = None) -> None: """Initialise the log panel.""" super().__init__(parent) - self.setWidgetResizable(True) - self._container = QWidget() - self._layout = QVBoxLayout(self._container) + self._layout = QVBoxLayout(self) self._layout.setContentsMargins(4, 4, 4, 4) self._layout.setSpacing(6) self._layout.addStretch() - self.setWidget(self._container) - - # Map action id → section widget for quick lookup - self._sections: dict[int, ActionLogSection] = {} + # Map action content-key → section widget for quick lookup + self._sections: dict[tuple[object, ...], ActionLogSection] = {} self._section_count = 0 # --- Public API --- @@ -192,13 +201,10 @@ def add_section(self, action: SetupAction) -> ActionLogSection: The created section widget. """ self._section_count += 1 - section = ActionLogSection(action, self._section_count, self._container) + section = ActionLogSection(action, self._section_count, self) # Insert before the stretch self._layout.insertWidget(self._layout.count() - 1, section) - self._sections[id(action)] = section - - # Scroll to show the new section - self.ensureWidgetVisible(section) + self._sections[action_key(action)] = section return section @@ -211,7 +217,7 @@ def get_section(self, action: SetupAction) -> ActionLogSection | None: Returns: The section widget, or ``None`` if not found. """ - return self._sections.get(id(action)) + return self._sections.get(action_key(action)) def on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None: """Handle a sub-action progress event. diff --git a/tests/unit/qt/conftest.py b/tests/unit/qt/conftest.py index 77afb03..d0f4f3e 100644 --- a/tests/unit/qt/conftest.py +++ b/tests/unit/qt/conftest.py @@ -3,8 +3,22 @@ Tests in this directory require PySide6. When the Qt runtime libraries are not available (e.g. on headless Linux CI), the entire directory is skipped automatically. + +All Qt tests run with the ``offscreen`` platform plugin so that no +windows appear on screen during the test run. """ +import os +import sys + +# Force offscreen rendering *before* any PySide6 import. +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + import pytest pytest.importorskip('PySide6.QtWidgets', reason='PySide6 requires system Qt libraries') + +from PySide6.QtWidgets import QApplication + +# Single shared QApplication for all Qt tests in this directory. +_app = QApplication.instance() or QApplication(sys.argv) diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index 4c8fe88..b21e4ed 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from typing import Any from unittest.mock import MagicMock @@ -33,14 +32,8 @@ 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 # --------------------------------------------------------------------------- @@ -405,14 +398,12 @@ class TestActionCardExecution: @staticmethod def test_set_executing_shows_running() -> None: - """set_executing changes status to 'Running…' and expands log.""" + """set_executing changes status to 'Running…'.""" 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: @@ -422,38 +413,9 @@ def test_set_executing_changes_border_style() -> None: 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.""" + """Successful result shows 'Done' status.""" card = ActionCard() card.populate(_make_action()) card.set_executing() @@ -462,9 +424,6 @@ def test_set_result_success() -> None: 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: @@ -506,23 +465,6 @@ def test_set_result_updates_version_on_upgrade() -> None: 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 @@ -974,12 +916,6 @@ def test_bare_command_shows_pending_status() -> None: assert card.status_text() == 'Pending' assert ACTION_CARD_STATUS_PENDING in card._status_label.styleSheet() - @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 diff --git a/tests/unit/qt/test_log_panel.py b/tests/unit/qt/test_log_panel.py index 852a556..b2e124f 100644 --- a/tests/unit/qt/test_log_panel.py +++ b/tests/unit/qt/test_log_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from pathlib import Path from unittest.mock import MagicMock @@ -18,9 +17,6 @@ ) from porringer.schema.plugin import PluginKind -# PySide6 widgets require a QApplication; create one once for the module. -from PySide6.QtWidgets import QApplication - from synodic_client.application.screen.install import InstallWorker from synodic_client.application.screen.log_panel import ( CHEVRON_DOWN, @@ -42,8 +38,6 @@ _EXPECTED_SECTION_COUNT = 2 -_app = QApplication.instance() or QApplication(sys.argv) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -61,10 +55,13 @@ def _make_action( action.kind = kind action.description = description action.installer = installer - action.package = package + pkg_mock = MagicMock() + pkg_mock.name = package + action.package = pkg_mock action.package_description = package_description or description action.command = None action.cli_command = None + action.plugin_target = None return action @@ -307,8 +304,8 @@ def test_add_section_returns_section() -> None: def test_add_section_increments_index() -> None: """Section indices increment with each add_section call.""" panel = ExecutionLogPanel() - a1 = _make_action(description='First') - a2 = _make_action(description='Second') + a1 = _make_action(description='First', package='first') + a2 = _make_action(description='Second', package='second') panel.add_section(a1) panel.add_section(a2) @@ -409,8 +406,8 @@ def test_on_action_completed_ignores_unknown_action() -> None: def test_clear_removes_all_sections() -> None: """clear() removes all sections and resets the counter.""" panel = ExecutionLogPanel() - a1 = _make_action(description='First') - a2 = _make_action(description='Second') + a1 = _make_action(description='First', package='first') + a2 = _make_action(description='Second', package='second') panel.add_section(a1) panel.add_section(a2) @@ -425,8 +422,8 @@ def test_clear_removes_all_sections() -> None: def test_multiple_actions_tracked_independently() -> None: """Different actions get independent sections with separate output.""" panel = ExecutionLogPanel() - a1 = _make_action(description='First') - a2 = _make_action(description='Second') + a1 = _make_action(description='First', package='first') + a2 = _make_action(description='Second', package='second') panel.add_section(a1) panel.add_section(a2) diff --git a/tests/unit/qt/test_preview_model.py b/tests/unit/qt/test_preview_model.py index 8c17c22..f6766bc 100644 --- a/tests/unit/qt/test_preview_model.py +++ b/tests/unit/qt/test_preview_model.py @@ -2,13 +2,11 @@ from __future__ import annotations -import sys from typing import Any from unittest.mock import MagicMock from porringer.schema import SetupAction from porringer.schema.plugin import PluginKind -from PySide6.QtWidgets import QApplication from synodic_client.application.screen.action_card import action_key from synodic_client.application.screen.install import ( @@ -18,9 +16,6 @@ normalize_manifest_key, ) -_app = QApplication.instance() or QApplication(sys.argv) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index ab34abe..a6b9648 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -2,20 +2,14 @@ from __future__ import annotations -import sys from typing import Any from unittest.mock import MagicMock, patch -from PySide6.QtWidgets import QApplication - from synodic_client.application.screen.settings import SettingsWindow from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE from synodic_client.resolution import ResolvedConfig from synodic_client.updater import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES -_app = QApplication.instance() or QApplication(sys.argv) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/unit/qt/test_update_banner.py b/tests/unit/qt/test_update_banner.py index bdcef27..952dc61 100644 --- a/tests/unit/qt/test_update_banner.py +++ b/tests/unit/qt/test_update_banner.py @@ -2,14 +2,8 @@ from __future__ import annotations -import sys - -from PySide6.QtWidgets import QApplication - from synodic_client.application.screen.update_banner import UpdateBanner, UpdateBannerState -_app = QApplication.instance() or QApplication(sys.argv) - _PROGRESS_MAX = 100 _TEST_PROGRESS = 42 From 698080dc822be0d7d50d5f0343013986a747adee Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 13:17:52 -0800 Subject: [PATCH 5/5] Lint Fixes --- synodic_client/application/screen/install.py | 1 + tests/unit/qt/test_preview_model.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index afc7214..6bca5f2 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -152,6 +152,7 @@ class PreviewModel: """ def __init__(self) -> None: + """Initialise a blank preview model.""" self.phase: PreviewPhase = PreviewPhase.IDLE self.preview: SetupResults | None = None self.manifest_path: Path | None = None diff --git a/tests/unit/qt/test_preview_model.py b/tests/unit/qt/test_preview_model.py index f6766bc..35d6cb2 100644 --- a/tests/unit/qt/test_preview_model.py +++ b/tests/unit/qt/test_preview_model.py @@ -154,7 +154,8 @@ def test_actionable_count() -> None: upgradable.status = 'Update available' model.action_states = [needed, satisfied, upgradable] model.upgradable_keys.add(action_key(upgradable.action)) - assert model.actionable_count == 2 # 1 needed + 1 upgradable + expected_actionable = 2 # 1 needed + 1 upgradable + assert model.actionable_count == expected_actionable @staticmethod def test_action_state_for_found() -> None: