diff --git a/pdm.lock b/pdm.lock
index fcec85d..7ce71de 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -2,10 +2,10 @@
# 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:51fe1fa9d99c43a533dedd1220c24ecfdc3086d950d3d7fb119ef73c17e440ee"
+content_hash = "sha256:e85a2b6900e703e4c93e257c1c94c30f48735d346f031475f8380b42b70c7e95"
[[metadata.targets]]
requires_python = ">=3.14,<3.15"
@@ -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]]
@@ -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,30 +349,30 @@ 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]]
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]]
@@ -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 = [
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 b6eff7a..6bca5f2 100644
--- a/synodic_client/application/screen/install.py
+++ b/synodic_client/application/screen/install.py
@@ -4,13 +4,14 @@
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
import asyncio
+import enum
import logging
import shutil
import tempfile
@@ -33,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,
@@ -43,13 +44,15 @@
QMainWindow,
QMessageBox,
QPushButton,
+ QScrollArea,
QVBoxLayout,
QWidget,
)
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.screen.log_panel import ExecutionLogPanel
from synodic_client.application.theme import (
ACTION_CARD_SKELETON_BAR_STYLE,
CARD_SPACING,
@@ -84,6 +87,114 @@ 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:
+ """Initialise a blank preview model."""
+ 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 +298,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 +313,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 +340,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)
@@ -252,12 +356,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)
@@ -274,10 +378,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()
@@ -349,6 +474,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 +491,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()
@@ -407,18 +591,8 @@ def reset(self) -> None:
self._status_label.setText('')
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)
+ 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.
@@ -432,125 +606,187 @@ 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)
+ self._log_panel.clear()
+ self._log_panel.hide()
+ 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 +794,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 +850,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 +862,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,32 +907,37 @@ 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()
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 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)
@@ -705,38 +950,44 @@ 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)
- def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None:
- """Handle a sub-action progress event — route output to the card."""
- card = self._card_list.get_card(action)
- if card is None:
- return
+ section = self._log_panel.add_section(action)
+ self._scroll_area.ensureWidgetVisible(section)
- if progress.output is not None:
- card.append_output(progress.output, progress.stream)
- elif progress.message is not None:
- card.append_output(progress.message)
-
- # Follow the growing log — keep the bottom of the card in view.
- self._card_list.scroll_to_card_bottom(card)
+ def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None:
+ """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:
+ 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))
+
+ self._log_panel.on_sub_progress(action, progress)
+
+ # 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."""
- 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})')
+ self._log_panel.on_action_completed(action, result)
+
+ 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 +996,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 +1018,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 +1032,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 +1058,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 +1085,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 +1137,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 +1149,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()
-
- # --- 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.
+ # --- Callbacks ---
- 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/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/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/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_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_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
new file mode 100644
index 0000000..35d6cb2
--- /dev/null
+++ b/tests/unit/qt/test_preview_model.py
@@ -0,0 +1,195 @@
+"""Tests for PreviewModel and ActionState."""
+
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import MagicMock
+
+from porringer.schema import SetupAction
+from porringer.schema.plugin import PluginKind
+
+from synodic_client.application.screen.action_card import action_key
+from synodic_client.application.screen.install import (
+ ActionState,
+ PreviewModel,
+ PreviewPhase,
+ normalize_manifest_key,
+)
+
+# ---------------------------------------------------------------------------
+# 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))
+ expected_actionable = 2 # 1 needed + 1 upgradable
+ assert model.actionable_count == expected_actionable
+
+ @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/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
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."""