From 100ee87bad4384e3df40535920acb71997185810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 03:50:29 +0000 Subject: [PATCH 01/28] Upgraded the locked dependencies `uv lock --upgrade`, not mine: twelve packages move, numpy 2.5.3 to 2.5.4, pint 0.26.1 to 0.26.2, flask 3.1.8 to 3.1.9, ruff 0.16.9 to 0.17.3 and the rest of the dev tools with them. `pyproject.toml` is unchanged, so nothing about what is required moved -- only what was resolved for it. On master rather than on `kvik/ninja`, which is where it was sitting, because it has nothing to do with that branch and everything else does. Co-Authored-By: Claude Opus 5 --- uv.lock | 420 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 234 insertions(+), 186 deletions(-) diff --git a/uv.lock b/uv.lock index 79c24a25..8fbed705 100644 --- a/uv.lock +++ b/uv.lock @@ -126,16 +126,16 @@ wheels = [ [[package]] name = "flake8" -version = "7.4.1" +version = "7.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/94/85870fc02dfe4ab0885c440e7e41f6a986b10e43eca6717e90f8ce1215cc/flake8-7.4.1.tar.gz", hash = "sha256:84ea5afcaf344487b0ea5baaebb8100f4cfaebc01f755998f75876664029f587", size = 47897, upload-time = "2026-09-23T12:21:59.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/da/b0d00f8c4c676212284edd3cc3d371588ce0e36a349373c3a01e188b5639/flake8-7.5.0.tar.gz", hash = "sha256:0dab7566341aca0d80aa29b5812bdadf256caa6a63e6068e49ac2649918236a4", size = 47811, upload-time = "2026-10-28T20:17:22.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/d4/8e58a7221419bf8cb194f362ab26abf504c983135975e4c785fac0852886/flake8-7.4.1-py2.py3-none-any.whl", hash = "sha256:78480274a6d7289d9cb8eafeda241fac57d4ea687d26e32dfdca37b72cdeddad", size = 57449, upload-time = "2026-09-23T12:21:58.085Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/a3adc5f380f50ef3d01014cb9d3b8c17b25de9b89def20d559def4fc3c4e/flake8-7.5.0-py2.py3-none-any.whl", hash = "sha256:637ea1a443a78ed6ee461725cdf71ca93bc5cde0abf25436dbebdc3880b4e078", size = 57487, upload-time = "2026-10-28T20:17:21.061Z" }, ] [[package]] @@ -181,11 +181,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.3.0" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] [[package]] @@ -211,32 +211,80 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, ] [[package]] @@ -250,53 +298,53 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, - { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, - { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, - { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, - { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, - { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, - { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, - { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, - { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, - { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, - { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, - { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, - { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, - { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, - { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, - { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, - { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, - { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, ] [[package]] @@ -334,7 +382,7 @@ wheels = [ [[package]] name = "pint" -version = "0.26.1" +version = "0.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flexcache" }, @@ -342,27 +390,27 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/2c38c32e0fb1f966d3695f4493a3f3ce2cc0cca1bbe7c958b92261b31af9/pint-0.26.1.tar.gz", hash = "sha256:1bbde36eae57a5a289cd05081c6405618a5899814940752064ce351cd0204f71", size = 273631, upload-time = "2026-09-10T21:16:49.712Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/b0/cef9c8913ebe959d97b2d57b1fc2a77a4291c4f2bd4dc3fe4d3313c938ea/pint-0.26.2.tar.gz", hash = "sha256:391e5d2fc01fdb121c113b8eb47386688575542c1f5144c465ff318bf5133573", size = 295834, upload-time = "2026-10-19T19:49:31.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/5c/9507ea3c732f8a259f45ddf190a741e9bf7d3fe229b5d473917ee6efbf1e/pint-0.26.1-py3-none-any.whl", hash = "sha256:e982b129415c09c63308f314ae44697d83e5c96f253bcc0d5b833fc3329eb6d4", size = 326478, upload-time = "2026-09-10T21:16:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d5/01ea71591a29e88926582e3ca7ed32f8b933e9ebc99187a7ab100587242d/pint-0.26.2-py3-none-any.whl", hash = "sha256:c94124cdb19baa1a84bb65c8bb9a620a9636b9b9cd80f32f30ad7ff795ea2eb8", size = 351853, upload-time = "2026-10-19T19:49:29.966Z" }, ] [[package]] name = "platformdirs" -version = "4.11.13" +version = "4.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0e/7be2983c52622d6c0899300f760e31ba143355cedf1afcb3e65689d5ca85/platformdirs-4.11.13.tar.gz", hash = "sha256:6985eefdc2298693e4ce1fe124645524cb967428eb6384e0ce5b49767e7ea8ba", size = 40415, upload-time = "2026-09-25T04:44:25.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/886dc7c9d21e2d95aceb696f1e31271742033aeef3661f6e9c8921493592/platformdirs-4.14.3.tar.gz", hash = "sha256:021add1ab113483edbcd4694f5fbb2d688b893ea30d35e1155bb371cf4be0be8", size = 71124, upload-time = "2026-11-01T16:27:32.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/a6/76a492f6a937e196641b3abd1588c504c71438e243daf1bebde4d3bb8ee9/platformdirs-4.11.13-py3-none-any.whl", hash = "sha256:228b2283af5c1f32673cdf55271216a52bbd8258cb31374e302ad7bd26cd7513", size = 25054, upload-time = "2026-09-25T04:44:23.975Z" }, + { url = "https://files.pythonhosted.org/packages/93/41/f0aa003909ee77a9e7e55f8659abb635ca23ab0d022a449bbb18da87a1ef/platformdirs-4.14.3-py3-none-any.whl", hash = "sha256:cba2d05fe5e18b26b1fa6f376b8e6234c012abe1f8696c486fface7d0054e931", size = 35276, upload-time = "2026-11-01T16:27:31.498Z" }, ] [[package]] name = "pluggy" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] [[package]] @@ -376,11 +424,11 @@ wheels = [ [[package]] name = "pyflakes" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/07/587d938ce8ffea54aa23337c4a827a72c27a3a994b4346899682704af588/pyflakes-4.0.0.tar.gz", hash = "sha256:492b27735181e3d4a6acfc08738948b666bf3e696781854ea6e0d8540d566d52", size = 66927, upload-time = "2026-09-22T22:37:01.881Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/43/f4566b992ab97abd18426aeba516a83c7a833230614a2c3f4d3e9d8ff0f0/pyflakes-4.1.0.tar.gz", hash = "sha256:592beb57058eb82eb190fb7514f2081c555c51000b029ff71286a681e6cf2f83", size = 67243, upload-time = "2026-10-28T20:10:02.469Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/f3/2664f53ab17a9249c58317a00aabf76acdd18d4b1b98b3a0a36af7763950/pyflakes-4.0.0-py2.py3-none-any.whl", hash = "sha256:0f7b7a78e8fcffd78b205200f045a70911f4179be1b42eac82c3323e0bf5c8aa", size = 65907, upload-time = "2026-09-22T22:37:00.744Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/98c9a0e4718abe887121279457bc7140115df22d714b7183880eb61e359f/pyflakes-4.1.0-py2.py3-none-any.whl", hash = "sha256:b2e05e6bac8e49b67be2a6ffa63422c60f7193fc72e8180255907b41ec997dc1", size = 66258, upload-time = "2026-10-28T20:10:01.109Z" }, ] [[package]] @@ -394,7 +442,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.1" +version = "9.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -403,9 +451,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, ] [[package]] @@ -442,99 +490,99 @@ sdist = { url = "https://files.pythonhosted.org/packages/f1/a7/8fac2e213db8108d8 [[package]] name = "regex" -version = "2026.9.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/5c/f403115361de25809e8f785686ec7096e30fef73be9ae35aa51da4e80abb/regex-2026.9.10.tar.gz", hash = "sha256:1e321e2c84f0e52c457f5ea5944f796d6e8e09cb99738ea98dcc1bfe402a128d", size = 417072, upload-time = "2026-09-09T21:00:21.521Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/ed/98e9b07d8bb9c765d07774f0b2c19b301b96d51f44630fea48951051c94e/regex-2026.9.10-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd6bd89b9fc06018d35851cab0240adb7dd84d51941b19f6574ac90cd54e3ae5", size = 496662, upload-time = "2026-09-09T20:58:05.118Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a8/9dfe9be48378b47c5a8f04b0f200ea225f9ee0f8e93f010433f661a37878/regex-2026.9.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ef4c0a9dfdc90581b90b1b95a8c3d1557f8ff8f5a2a53536d26314de699d1468", size = 297115, upload-time = "2026-09-09T20:58:06.822Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e4/5d1f005a3825ec49842ad349061c1c26e6d42f47ccf105e6e5aa6aeed392/regex-2026.9.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14caa05ce39ec70437af5aac8814c50ee6628f4a90353871c059692f448a164f", size = 291896, upload-time = "2026-09-09T20:58:08.675Z" }, - { url = "https://files.pythonhosted.org/packages/be/15/44ce83fca50c6058f42b62fa8300a8030eea7e4e5a973a2dd33db0f557fb/regex-2026.9.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3264132d576847ab5f88bb83e7debe67854bf165b3ea613bd467312b6099536a", size = 800534, upload-time = "2026-09-09T20:58:10.339Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/a62e070a5a033643b287489576f02ae6a9c584c337d62349e340a2b4d001/regex-2026.9.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cef9f3d14796500ea834c41dbe688f1f6b23c7024dc23e8a794d7ebaf5d71d0", size = 872038, upload-time = "2026-09-09T20:58:12.186Z" }, - { url = "https://files.pythonhosted.org/packages/f3/06/8b8e2483949b1329df10c5b615e85d332066deb426b11332b799629b9201/regex-2026.9.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d278ad30ec83b6b9202685b0f80b741a51ea3ca7f0595ebda96e7628b6398876", size = 918927, upload-time = "2026-09-09T20:58:13.903Z" }, - { url = "https://files.pythonhosted.org/packages/bb/64/9b56f69100d3afdbc9c4fa6e302764f9cb717fbc06a9d50558d98ca89cd2/regex-2026.9.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb018cfe7144585fc83882405906ff84994a2d154afc2509ecc7752c51f864", size = 803694, upload-time = "2026-09-09T20:58:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/07/43/d00d59a7c8fd0e070ae8457a8743597f45ad9682b100f57b9c9c405fbdfd/regex-2026.9.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fd555fc9abef50c530869690b2daca054c8811a7aff632d11f9a7b2590b2742", size = 777770, upload-time = "2026-09-09T20:58:17.628Z" }, - { url = "https://files.pythonhosted.org/packages/46/6b/a11d0446484efbc9eb67abec133f254c6d66a1568b8f3fb36d39a73a1129/regex-2026.9.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8d5c4518235a2ec1611e57af85fa488d529c1106aacff12adadcedf8687012cd", size = 791234, upload-time = "2026-09-09T20:58:19.476Z" }, - { url = "https://files.pythonhosted.org/packages/12/09/bcd24e78b373fd4f98090caa43eade439223f6703b909be79b9efd9ab0ab/regex-2026.9.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:175cf49ce7a994c88b8f15e3cb17cdb66a48ebb2d36de736b8205033db950f89", size = 866259, upload-time = "2026-09-09T20:58:21.683Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c6/c57ba5e94222a813260af4c17ced92d40cdb44737eb6b50979688310b6a3/regex-2026.9.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b71649169a9fcf30b395ee01047fa7ad6654a4c900ca75b23c04dedcce6a1f8c", size = 768219, upload-time = "2026-09-09T20:58:23.842Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a2/3820037587d00901ace96c5864335c2cd1b899d5263ea0dd2261359e0bee/regex-2026.9.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ba1f78bd4fef2d8f84b894ec28ac3481afe6cc07aaa253ad4717ef7b3fe6bcb", size = 858582, upload-time = "2026-09-09T20:58:25.607Z" }, - { url = "https://files.pythonhosted.org/packages/47/f0/f9a838ca6219ae4821de0175e4548db73ef56de5ec08d032fb427732fa07/regex-2026.9.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:217e98ba5fc8908ed8ffd4ebac04753a0c831067cbfb495b9821b94cc61eaa76", size = 791405, upload-time = "2026-09-09T20:58:27.406Z" }, - { url = "https://files.pythonhosted.org/packages/f7/bf/67d71cc4e13ae2e0022d21243ca069e0868701a99eb29cdecd13d356e694/regex-2026.9.10-cp314-cp314-win32.whl", hash = "sha256:b298cdc33c5cc6969ff07f0fba19cc73e0fd8576373c50935feadaca2f6b4405", size = 272702, upload-time = "2026-09-09T20:58:29.116Z" }, - { url = "https://files.pythonhosted.org/packages/c1/38/40a93e72703a741235115ed1b1e5f6b869917677b7643005034ce1611d70/regex-2026.9.10-cp314-cp314-win_amd64.whl", hash = "sha256:c32818b28bcd153b25b63038348a9fe9b9fbcddb60df43f204c3ab55eeb57f77", size = 281170, upload-time = "2026-09-09T20:58:30.899Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ac/e95387f00617c16bb41b786d900bacd17eab88afa81b4f263df532b3a731/regex-2026.9.10-cp314-cp314-win_arm64.whl", hash = "sha256:75242f44a3e283106077be4ab717bc535e4701c9d54ad69e195945c22f137a1d", size = 281511, upload-time = "2026-09-09T20:58:32.594Z" }, - { url = "https://files.pythonhosted.org/packages/39/e5/a4b12262edc488a8a7a95b672db317dd8aa9bf2fab98297f9c91bb11ad4d/regex-2026.9.10-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2dd9286093c71afc8f55ef035c5b9d2776641fd72c6535f1febc92d0b0be9666", size = 501128, upload-time = "2026-09-09T20:58:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/55/c8/9ca31c0fa5197ded8614c8ae0e105bcff2d979025ffa3c75580baa334e2f/regex-2026.9.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:71879292c9c7ac67b1680345b16daba1be937cb027362cfa04e68f65db2dcfdd", size = 299428, upload-time = "2026-09-09T20:58:36.296Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d1/ee7662561735f90475443c3ca1977e5cecaeaa8f29620dec75580aebc839/regex-2026.9.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ccd139b2061132e7b265cfb4b4721baeb9f8928b81415304abf1ec7e3181c26", size = 294494, upload-time = "2026-09-09T20:58:37.964Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b1/333168e45ed6cfe71f6d17e21e5f54725f44d171f84edd12469a2f739227/regex-2026.9.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7327795089ddb44912dce1434e1d7244be2e9fb48fcc2d6782936af7a3062db", size = 814925, upload-time = "2026-09-09T20:58:40.438Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a5/df1b38536d0a3b24a030eb4130ce98b403cc925220ff530f5313a7c436eb/regex-2026.9.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff4d7b14ea19e50c8d9d6d83f45bd9b45cbb624c07ac1fa54db0a019049abed7", size = 873323, upload-time = "2026-09-09T20:58:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/93/ea/aa71fe62dd63a8336bbdec1ff002a6c53b40ccfca95961c18ee4f09bf03c/regex-2026.9.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f0407474ffac8e5e89d93ca41d60891e29f0ab8423eb66ff292d850a86a0843", size = 923080, upload-time = "2026-09-09T20:58:44.488Z" }, - { url = "https://files.pythonhosted.org/packages/86/38/49f8d6fd34fc1a9c75b5b96364ebdde702a8144e5ed63d2213c58d454c27/regex-2026.9.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ad10a135fa0b4e4a462a61d07c6654d7518cfdb5cb8da08f9ff7d61384af1fe", size = 821284, upload-time = "2026-09-09T20:58:46.56Z" }, - { url = "https://files.pythonhosted.org/packages/32/45/91a977c96d4be13d1ade8208c260c26841c836d4c91745e1828a30589070/regex-2026.9.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9fbd2e5d8002dc49a6129fb321ec51c57a025e752ed525ddce0ba9223c4350a7", size = 789256, upload-time = "2026-09-09T20:58:48.473Z" }, - { url = "https://files.pythonhosted.org/packages/2d/79/4d110bf01bf9651bf9b3f88a6d9fa7e643e0586921a18432b25a478edbfa/regex-2026.9.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4a761ea45f2ad74c575ef5850ea514cef97302a552d3c7c9d1a1a870d4661d6c", size = 803722, upload-time = "2026-09-09T20:58:50.286Z" }, - { url = "https://files.pythonhosted.org/packages/e7/67/b587a0d3bbac2635309ed9c40c197120c39e1ec0afb8813cbed1338fdd75/regex-2026.9.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:75aa39d3f4f1650eea84e46b0d8cefe77dd5478c10e3d0aaf0b0f00493475a7a", size = 870085, upload-time = "2026-09-09T20:58:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/07/fc/0827bca20ddba1d70fa5111a2a64e6b6b38bfdab43fc435022b54172a111/regex-2026.9.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f5c629df03adec31ee505dda3c8988f106c9390e4cbd343600036eb8b3d6724f", size = 776970, upload-time = "2026-09-09T20:58:54.478Z" }, - { url = "https://files.pythonhosted.org/packages/9b/03/ab9d08d30568ca868791bfb99551db60b947f05e2e65dcd1261e87083c21/regex-2026.9.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3a66e40a1a20de96a2fee00ed67e11012b62d85b277688258677fd19997addb7", size = 863611, upload-time = "2026-09-09T20:58:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6d/8063ae86b543ae878a7d6e7ba21ebf0af4af06161230e0012e2d652320c6/regex-2026.9.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:968c1e33edd9a104d1bf24c8d476c72de7e3839ae7f894b37e9e4f4739fdeeca", size = 804064, upload-time = "2026-09-09T20:58:59.066Z" }, - { url = "https://files.pythonhosted.org/packages/c5/63/b19305c4b8d3d7867699f7d83c43550273d91a2f31793452d87edb1d259f/regex-2026.9.10-cp314-cp314t-win32.whl", hash = "sha256:fbc4e2f3cb7ce8436154e6483079e7d35eeb321a952fa936e180300630d8b873", size = 274607, upload-time = "2026-09-09T20:59:00.942Z" }, - { url = "https://files.pythonhosted.org/packages/32/b8/1695072a512a49060294024e23b945eeb87675b02c06e53a92a1b42b0bfd/regex-2026.9.10-cp314-cp314t-win_amd64.whl", hash = "sha256:c37fa93bf18bf4f90b01c0fa9f11ea567ee4b7dd8bf96e63663e5edc37aa38cf", size = 283944, upload-time = "2026-09-09T20:59:02.876Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9f/65bac17f39991a67e22f8b3c849fdc02a56147c97029b5351494341959b4/regex-2026.9.10-cp314-cp314t-win_arm64.whl", hash = "sha256:ffc2da104e43db716ce30cef9f28049a1faa6aca385dd8771b033268d0730b07", size = 283780, upload-time = "2026-09-09T20:59:05.009Z" }, - { url = "https://files.pythonhosted.org/packages/67/ca/1d1f83bc2f8fff4f186266ac82d73254e686565530cec9ab5228fb5c63dc/regex-2026.9.10-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6afcad14310f1311d077553ed374b42a5e538f85a8c884b4e38e52de091c8077", size = 496869, upload-time = "2026-09-09T20:59:07.051Z" }, - { url = "https://files.pythonhosted.org/packages/33/42/4217510286501a2ebcd372b781b4754ac961e043fa13ef8dce803c44d89c/regex-2026.9.10-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:3fb4ae8cf83ef4e9addd43b2da31a9f45be816a8036fae8af59c8998b72718e2", size = 297121, upload-time = "2026-09-09T20:59:09.007Z" }, - { url = "https://files.pythonhosted.org/packages/55/a7/595468ed0bbccd94be92c6b5d67736ba128b204d942429a8485c2693914d/regex-2026.9.10-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f7d4656e17ab736e9415a6442a345bfc97bb8b7dcce47884bb74a37f70f08d0c", size = 292139, upload-time = "2026-09-09T20:59:10.859Z" }, - { url = "https://files.pythonhosted.org/packages/29/1c/ac92c123e0ab9bea75a904272171b356940bd6139e4a35de44f2254dca8f/regex-2026.9.10-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ba3bab0c45079735f55ac61526774de1d84bc4a0333cc554e1a4ab74913924", size = 802375, upload-time = "2026-09-09T20:59:12.823Z" }, - { url = "https://files.pythonhosted.org/packages/8d/16/d349f6fa9f908162359004e4f067353a0146e8074d24989490778244be44/regex-2026.9.10-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff6b3267318661dfddf6b3628663e00e5946bd0a5c8fa678537a1401f0388f91", size = 872328, upload-time = "2026-09-09T20:59:15.352Z" }, - { url = "https://files.pythonhosted.org/packages/df/81/251b5aef23147057e926346fdb7c8c352d0568f65a492e4e9eb6120f6446/regex-2026.9.10-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030fa9e23624e39b3b94e46b90a5abd1a1678eb2f58fcdd3fd6c27526bf91c7e", size = 919594, upload-time = "2026-09-09T20:59:17.31Z" }, - { url = "https://files.pythonhosted.org/packages/d0/6e/1f25319dc1b9cf4b7ffa303f3d16ca53260fe92aff45e81aa1b3c6c7cba2/regex-2026.9.10-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fbc8314436353e097c050e11b01a6c11433579437ed0579730157676ef59e2f", size = 807394, upload-time = "2026-09-09T20:59:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/cc/fc/3da6b3dffddf12d5e96cbbe6f5e65ebcd64f92e3388a6690a53e0878232d/regex-2026.9.10-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e6c0b5ec6ddee4032247585dc491b0fa58627745b66a705728703a3f0331231", size = 786018, upload-time = "2026-09-09T20:59:21.592Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6f/1cc86dafddc912ef44c5ccd4f729060be8c6735600932664eb6d0e25469b/regex-2026.9.10-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bf29611e5376fec8f795879bb5c6153a76c3a292573d173c26784042b01eb840", size = 793504, upload-time = "2026-09-09T20:59:23.734Z" }, - { url = "https://files.pythonhosted.org/packages/b1/cb/11692e29388d006211163627fcefa0c79917ab9f94d46a1b2254fe5efc81/regex-2026.9.10-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:ec8855f08c17895a26fbf5f19ed829722e19b34a96629e49a43c92974924026b", size = 866766, upload-time = "2026-09-09T20:59:25.798Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/3631df969830f94d83fcfc5fc71a7b39caad905e18f7b17350a94135d625/regex-2026.9.10-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94c5ce3bc41d226b4eb89ca3f842b2e28c031487fb1f34eb2153d98235831325", size = 775825, upload-time = "2026-09-09T20:59:28.51Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/762892a8e3e21d119eacaffde848aa247e6cf4e1d90c46011671e86b1b9e/regex-2026.9.10-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:9ce239acb15843ab03976626af810a4424b0409689ec2bbc52088ab5479ab487", size = 858901, upload-time = "2026-09-09T20:59:30.656Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/78048fada5c2f61d795efb26ea24f820a5564c4aa26574920284d45cd656/regex-2026.9.10-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c22df8dd6373bbe3898e77429ffc85594300e39d752fd0e68a31e59d37899376", size = 796208, upload-time = "2026-09-09T20:59:32.852Z" }, - { url = "https://files.pythonhosted.org/packages/aa/58/632681f7b9aaa3d83b40e5862ba46364a453c8eb2bc7d43fece3dc31f972/regex-2026.9.10-cp315-cp315-win32.whl", hash = "sha256:1aa309ab7ba89a62d6cf70dbd38d4176440bce3c7001ab86256704cf4c18c6eb", size = 272704, upload-time = "2026-09-09T20:59:34.927Z" }, - { url = "https://files.pythonhosted.org/packages/5d/64/81cce28754c37037b1fe740b6d7a556d51d97cf935ea4547bfab104f43f7/regex-2026.9.10-cp315-cp315-win_amd64.whl", hash = "sha256:58da726d3e766c0b3f5a3997dfaf0275898a1107b8191cdd6b0437fe45fd817d", size = 281182, upload-time = "2026-09-09T20:59:36.86Z" }, - { url = "https://files.pythonhosted.org/packages/33/28/5a13a340c9c759e863a0e7f765d323601d03d6538c8a627ed628662e083b/regex-2026.9.10-cp315-cp315-win_arm64.whl", hash = "sha256:75f9297b16fcb588a1f8d8a55dabef3c0c20b0c7bac43c87ceaaaf1a825c12f4", size = 281512, upload-time = "2026-09-09T20:59:38.875Z" }, - { url = "https://files.pythonhosted.org/packages/f7/38/a3caebcd5105be90708071db20bd261b0961b8ea4fe5e8be45c2632519b5/regex-2026.9.10-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1270cdec69248592bbe38a0b263ed58d907b891bd2b93703e225c317e421bda1", size = 501336, upload-time = "2026-09-09T20:59:40.987Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b5/ec8887b2658bf0a5df143c7c1fcd562b0abd2fa208c04ebf15c6607c9bb2/regex-2026.9.10-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:681ed38664b64c6617d3c3c332018d1948c77e139c5ea667c1886efa671e426f", size = 299318, upload-time = "2026-09-09T20:59:43.039Z" }, - { url = "https://files.pythonhosted.org/packages/d5/58/84724a9eccf6e8cd46f7e4534576093e2476a5eeb55e2453aa6606e86059/regex-2026.9.10-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8e127d9a80cbf1c3276bb465c6d047e8705e97b58c2b8f2f0c0a69c336b44b37", size = 294847, upload-time = "2026-09-09T20:59:44.954Z" }, - { url = "https://files.pythonhosted.org/packages/50/03/70ccc5e53905984abf8eab63eebd3ce740522ef8c8d392622b64d79ef290/regex-2026.9.10-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:990797e765d89a423880052c68b61c31afe701de94a8c060f61c40605ca6c727", size = 814359, upload-time = "2026-09-09T20:59:47.194Z" }, - { url = "https://files.pythonhosted.org/packages/29/53/40f7a11ec547e4a947883c9d5e8a075f6d4f59af2b8dbcaa8bf5b504aca0/regex-2026.9.10-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e5e4a6e0734a685d13b9685622bb503bdbb2927f8b0df025a5085f0ea067475b", size = 875586, upload-time = "2026-09-09T20:59:49.537Z" }, - { url = "https://files.pythonhosted.org/packages/0b/9d/83f3e022d99ce601727c4ef5f7b527753901ce4509ed89a1bb6a2263380a/regex-2026.9.10-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63bb62cf62217dc38c8a6b2b61b165b0e4eb8fa93b0aba12139251c0986a8fa3", size = 920990, upload-time = "2026-09-09T20:59:51.776Z" }, - { url = "https://files.pythonhosted.org/packages/b2/64/dfdb367d8f4f5c9b8ccb4b59789c2ce996f2c69c3b8192aa986fe7d92ec4/regex-2026.9.10-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb76a9c4e07a6a47849726af0ed14c41741a182f097f134a8cf29c1bc0f4dde8", size = 818660, upload-time = "2026-09-09T20:59:54.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/a6/fb7d0b64487913845834f319aa84f8377d55305958a5db7e19c128798366/regex-2026.9.10-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31e4df2b11d48f61d511019bc1ee9b477055f17c352b68fe72db7a98b14d603c", size = 794976, upload-time = "2026-09-09T20:59:56.799Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1c/23484edaae387ea0d31f4d414463211e3516c8aa210ce8d0640f5aff3502/regex-2026.9.10-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cf377960d2ac37d987394a9dbaa75e91338c41a46d41e1d25e90125e7b3ee2dc", size = 804081, upload-time = "2026-09-09T20:59:59.39Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/e30d138f13aecec99ea9aecef7e31563de6ec6a2f5ce1d65fc506aee33fe/regex-2026.9.10-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8fbd9cb30c68c1686b94029b9ef845d5870d3d65baf66cb126b676849b9d72b", size = 870430, upload-time = "2026-09-09T21:00:03.304Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/81f9ce86f7ae4f57901543597c95751fa01c41a672ec1636dd913f8a000a/regex-2026.9.10-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:53e182b6b04d0011909b47d51a2d72d908de07c7b1c7f16b3adda2204d723bc1", size = 783327, upload-time = "2026-09-09T21:00:05.68Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1b/d7bf8f91534740f6a8ca17e5ac9c5337903baf527c8de240fbac1bbedfd0/regex-2026.9.10-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:0aa7589394230e0f0a422ab6b90841ff12c87e855e7aaf75d192a54a5f124548", size = 860874, upload-time = "2026-09-09T21:00:08.41Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1d/52cc88364aca7c9013dfe9abe0fea67f8d394efe384d07798300a6e2f27d/regex-2026.9.10-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1b891f77554bff991804cee24b78b40789f7d5993a24c7907bc7025fd2a70c8d", size = 806705, upload-time = "2026-09-09T21:00:10.768Z" }, - { url = "https://files.pythonhosted.org/packages/cb/51/37a194df7707f92f33173260ba7b221d4ec376419a53babaec50019a2804/regex-2026.9.10-cp315-cp315t-win32.whl", hash = "sha256:5bef622850cf760154719d4e0d74b0a855962432995168e250069899ae12fe8f", size = 274780, upload-time = "2026-09-09T21:00:14.003Z" }, - { url = "https://files.pythonhosted.org/packages/77/12/3227a52970d90908b230f15b2c86df903f49ac72c9eedf4f6e8b5bb5e1a7/regex-2026.9.10-cp315-cp315t-win_amd64.whl", hash = "sha256:07b45ba5c94b8fcb30cb6c56a11f715c57533a3017964504322ea52690a27b72", size = 283936, upload-time = "2026-09-09T21:00:16.275Z" }, - { url = "https://files.pythonhosted.org/packages/f3/bc/c567c5a61671f04d30e83f20b496b465432879196574d051007285576205/regex-2026.9.10-cp315-cp315t-win_arm64.whl", hash = "sha256:f70b9f0e39c2dba1d9da6bf7ef7c377cad7277f8440e9a69be05ede529ff024c", size = 283747, upload-time = "2026-09-09T21:00:19.113Z" }, +version = "2026.9.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, + { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, + { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" }, + { url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" }, + { url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" }, + { url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" }, + { url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" }, + { url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" }, + { url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, ] [[package]] name = "ruff" -version = "0.16.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, - { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, - { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, - { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, - { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, - { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, - { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, - { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, - { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, - { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] [[package]] @@ -557,12 +605,12 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.8" +version = "3.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, ] From b736e2bd98d3f4a1c78e56439b6b9bc567a7faf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 03:59:23 +0000 Subject: [PATCH 02/28] Followed pint 0.26.2 on the temperature units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade changes how pint formats three units for siunitx, and this repository declares its own macros for all three, so both halves of the booklet's temperature typography went with them. Up to 0.26.1 a Celsius quantity formatted as `\degree_Celsius`, which is not valid TeX, so `PhysicsQuantity.PINT_TO_SIUNITX` caught it by name through `_UNDERSCORE_MACRO` and wrote `\celsius`. 0.26.2 writes `\degreeCelsius`, which *is* valid TeX and so never reaches that mapping at all: siunitx's own `\degreeCelsius` sets a math ring operator with a space before the C, so `25 °C` became `25 ∘ C` in the 146 source files that declare one. `siunitx.tex` now declares `\degreeCelsius` as `\text{°}C` beside the `\celsius` it already had, and the page is what it was. Fahrenheit is worse and is a repair rather than a renaming. 0.26.2 writes `\degree F` -- a macro and a bare letter -- and this document sets `forbid-literal-units`, so siunitx raises *Literal units disabled* and the unit disappears: `phys/23/yankees` printed `25` with nothing after it, in five languages. `PINT_LITERALS` puts it back to `\fahrenheit`. `\dcelsius` is collateral: pint now spells a temperature *difference* `\degreeCelsius` too, so the rendered TeX no longer distinguishes it from an absolute. Both set `°C`, so no page moves, and the macro stays for anything written by hand. Co-Authored-By: Claude Opus 5 --- .../context/quantities/physics_quantity.py | 21 ++++++++++++++++--- core/latex/siunitx.tex | 7 +++++++ core/tests/test_quantities.py | 14 +++++++++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/core/builder/context/quantities/physics_quantity.py b/core/builder/context/quantities/physics_quantity.py index c0ade189..247ba534 100644 --- a/core/builder/context/quantities/physics_quantity.py +++ b/core/builder/context/quantities/physics_quantity.py @@ -73,6 +73,18 @@ class PhysicsQuantity: 'electron_volt': r'\electronvolt', # siunitx built-in } + #: What pint emits as a macro **and a bare letter**, which `siunitx.tex` refuses outright: + #: this document sets `forbid-literal-units`, so `\qty{25}{\degree F}` raises *Literal units + #: disabled* and the unit vanishes from the page. pint 0.26.2 formats Fahrenheit that way + #: where 0.26.1 wrote `\degree_Fahrenheit` and so reached `PINT_TO_SIUNITX` by name. + #: + #: Separate from both tables above because it is neither: the macro is valid TeX, so the + #: underscore pass never sees it, and it is a repair rather than a choice of spelling. + #: `phys/23/yankees` is the problem that printed `25` with nothing after it. + PINT_LITERALS = { + r'\degree F': r'\fahrenheit', + } + #: A `\macro` whose name contains an underscore, i.e. one pint built from a #: multi-word unit name. `\kilo\meter_per_hour` matches only the second part. _UNDERSCORE_MACRO = re.compile(r'\\([A-Za-z]+(?:_[A-Za-z0-9]+)+)') @@ -525,13 +537,16 @@ def latex_unit(self) -> str: @classmethod def _latex_unit(cls, unit: str) -> str: r""" - Rewrite pint's multi-word unit macros into the siunitx macros DGS declares. - Raises `UnknownUnitMacroError` for anything unmapped rather than emitting - an invalid `\foo_bar`. + Repair what pint spells as a macro and a literal, then rewrite its multi-word unit + macros into the siunitx macros DGS declares. Raises `UnknownUnitMacroError` for + anything unmapped rather than emitting an invalid `\foo_bar`. Then put the two units pint spells American into British, to match the sources. Multi-word names go first, so a mapping that produces one of them is caught too. """ + for literal, macro in cls.PINT_LITERALS.items(): + unit = unit.replace(literal, macro) + def substitute(match: re.Match) -> str: name = match.group(1) if name not in cls.PINT_TO_SIUNITX: diff --git a/core/latex/siunitx.tex b/core/latex/siunitx.tex index 486689be..11e9e4d0 100644 --- a/core/latex/siunitx.tex +++ b/core/latex/siunitx.tex @@ -49,6 +49,13 @@ \DeclareSIUnit{\celsius}{\text{°}C} \DeclareSIUnit{\dcelsius}{\text{°}C} \DeclareSIUnit{\fahrenheit}{\text{°}F} +% siunitx ships its own `\degreeCelsius`, and it sets a math ring operator with a space before +% the C -- `25 ∘ C` where every other temperature in the booklet is `25 °C`. It is redeclared +% here rather than avoided because pint emits it: up to 0.26.1 a Celsius quantity formatted as +% `\degree_Celsius`, which is not valid TeX, so `PhysicsQuantity.PINT_TO_SIUNITX` caught it by +% name and wrote `\celsius`; 0.26.2 emits `\degreeCelsius`, which is valid TeX and therefore +% never reaches that mapping. 146 source files declare a Celsius quantity. +\DeclareSIUnit{\degreeCelsius}{\text{°}C} \DeclareSIUnit{\pound}{lb} \DeclareSIUnit{\calorie}{cal} diff --git a/core/tests/test_quantities.py b/core/tests/test_quantities.py index 0e518e14..1efda1b8 100644 --- a/core/tests/test_quantities.py +++ b/core/tests/test_quantities.py @@ -468,10 +468,13 @@ def test_meter_per_second(self): r'\unit{\metre\per\second}' def test_celsius(self): - """Degree-Celsius is converted to siunitx \\celsius.""" + """ + Degree-Celsius reaches the page as the macro pint spells it, which `siunitx.tex` + declares so that it sets `°C` like every other temperature in the booklet. + """ from pint import UnitRegistry as u T = PhysicsQuantity(u.Quantity(25, 'degree_Celsius')) - assert T.only_unit() == r'\unit{\celsius}' + assert T.only_unit() == r'\unit{\degreeCelsius}' def test_joule(self): assert PhysicsQuantity.construct(1.5, 'joule').only_unit() == r'\unit{\joule}' @@ -1197,8 +1200,11 @@ class TestUnitMacros: pytest.param('ly', r'\lightyear', id='light_year'), pytest.param('t', r'\tonne', id='metric_ton'), pytest.param('eV', r'\electronvolt', id='electron_volt'), - pytest.param('degC', r'\celsius', id='degree_Celsius'), - pytest.param('delta_degC', r'\dcelsius', id='delta_degree_Celsius'), + # pint 0.26.2 spells all three itself, and in valid TeX, so they no longer reach + # `PINT_TO_SIUNITX` -- see the note beside `\degreeCelsius` in `core/latex/siunitx.tex`. + # Each still sets as `°C` or `°F`, which is what the entries there are for. + pytest.param('degC', r'\degreeCelsius', id='degree_Celsius'), + pytest.param('delta_degC', r'\degreeCelsius', id='delta_degree_Celsius'), pytest.param('degF', r'\fahrenheit', id='degree_Fahrenheit'), pytest.param('rpm', r'\rpm', id='revolutions_per_minute'), pytest.param('atm', r'\atmosphere', id='standard_atmosphere'), From 47d7fbc0afe6b6dbbc215b80f7a092ff3c0c1239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 03:50:57 +0000 Subject: [PATCH 03/28] Caught the stale language directory, both halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make output/naboj/phys/29/languages/uk/booklet.pdf` built a 40-page Ukrainian problems booklet for a volume that has `en`, `es` and `sk`. The language documents are pattern rules, so `%` takes any string, and the rest of each prerequisite list is `$(wildcard …)`, which yields nothing for a language that is not there rather than failing. A target with no prerequisites whose file exists is up to date, so make reported success over whatever an older run had left in `build/`. Clearing `build/` was the only thing that turned it back into an error. Two halves, because there are two states. Cold: every language document now takes `source/naboj/$*/meta.yaml`, the one prerequisite in those lists not derived from something else, so a language that does not exist fails at once and names the missing file. Warm, where the stale `build/` is already there: `stale.py` splits into `unit_orphans` and `language_orphans` over a shared `_sweep`, and `build-orphan` says which it found, since the consequences differ -- a stale unit prints a deleted problem, a stale language is a whole booklet for a translation nobody has. The language half is self-limiting rather than a rule about Náboj: a module with no `source//languages/` yields nothing, which is how seminar and scholar pass through. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 27 +++++++++++++- core/audit/checks.py | 10 ++++-- core/audit/stale.py | 77 ++++++++++++++++++++++++++++++---------- core/tests/test_audit.py | 45 +++++++++++++++++++++-- modules/naboj/module.mk | 28 +++++++++++++++ 5 files changed, 164 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3fe0120a..37d4ed2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,32 @@ word between asking and deleting is not a safe thing to offer. `core/audit/stale of it, and it is the one check that reads outside `source/`: a directory listing, no file opened, and a tree that has never been built reports nothing. -**It looks at unit directories and nothing coarser**, which is the only reason it is usable. +**It looks at two kinds of directory and nothing coarser**, which is the only reason it is +usable. A **unit** directory, above; and a **language** directory, which the unit sweep cannot +see, because `languages/` is not a place units live. That half is self-limiting rather than a +rule about Náboj: a module with no `source//languages/` has no notion of one and yields +nothing, which is how seminar and scholar pass through. + +It is worth having for the same reason as the first half, only louder. The language documents in +`modules/naboj/module.mk` are pattern rules, so `%` matches any string and the rest of each +prerequisite list is `$(wildcard …)`, which yields *nothing* for a language that is not there +rather than failing. A stale `build//languages//` therefore supplies everything +they ask for: `make output/naboj/phys/29/languages/uk/booklet.pdf` built a **40-page Ukrainian +problems booklet** out of intermediates whose sources had been removed, for a volume that has +`en`, `es` and `sk`. Clearing `build/` was the only thing that turned it back into an error. + +Two changes came out of that, and they catch the two halves. Every language document now takes +**`source/naboj/$*/meta.yaml`** as a prerequisite — the one thing in those lists not derived from +something else — so a cold build of a language that does not exist fails at once and names the +missing file. And this check finds the warm one, where the stale `build/` is already there. The +aggregates never had the problem: `output/naboj/%/languages` and `booklets` wildcard +`source/naboj/$*/languages/*` and always did. + +`fks-naboj`'s ten language directories have no meta and are unbuildable by name as a result. +They already were — it spells languages `slovak` rather than `sk`, so nothing in +`SUPPORTED_LANGUAGES` ever matched them. + +The rest of the narrow rule, and why it is narrow: `build/` is full of directories that never had a source — `answers/`, `solutions/`, `build/core/`, every aggregate a template produces — so the obvious rule, *is there a source directory of this name*, calls **567** of them stale. What makes a unit different is that the audit already knows diff --git a/core/audit/checks.py b/core/audit/checks.py index 1ccd826a..e5ae4a1d 100644 --- a/core/audit/checks.py +++ b/core/audit/checks.py @@ -825,11 +825,17 @@ def build_orphan(sources): without its sweep. """ for orphan in stale.orphans(sources): + # The tail differs because the consequence does. A stale unit is pulled in by + # `\protectedInput` and prints the problem that was deleted; a stale language is a whole + # booklet that still builds for a translation nobody has any more. + consequence = (f"if {sources.scope}/meta.yaml still names it, the booklet is printing " + f"this copy" if orphan.kind == 'unit' else + "the language documents are pattern rules, so this is enough to build a " + "whole booklet from") yield Finding('build-orphan', 'warning', f"`{orphan}` has no source directory left -- {orphan.files} " f"file{'' if orphan.files == 1 else 's'} from a rename or a deletion. " - f"`make prune` removes it; if {sources.scope}/meta.yaml still names it, " - f"the booklet is printing this copy") + f"`make prune` removes it; {consequence}") @check('translation-set', 'warning', diff --git a/core/audit/stale.py b/core/audit/stale.py index f944901e..95390fd9 100644 --- a/core/audit/stale.py +++ b/core/audit/stale.py @@ -12,7 +12,7 @@ case is an error, and `listed-missing` already reports it from the sources; this reports the artefact, which is the half nobody can see. -**A unit directory, and nothing coarser.** `build/` is full of directories with no source +**A unit directory or a language directory, and nothing coarser.** `build/` is full of directories with no source counterpart by design -- `answers/`, `solutions/`, `build/core/`, every aggregate a template produces -- and a rule that merely asks "is there a source directory of this name" calls 567 of them stale. What makes a *unit* different is that the audit already knows where units live: the @@ -36,34 +36,28 @@ @dataclass(frozen=True, slots=True) class Orphan: - """One built unit directory with no source left.""" + """One built directory with no source left.""" stage: str # 'render' or 'build' rel: str # path below the stage root, e.g. 'phys/pool/problems/old-name' path: Path # absolute files: int # how much is in there, for a report that says what it would cost + #: What went missing, because the consequence differs and so must the sentence that reports + #: it. A stale unit is included by `\protectedInput` and prints the deleted problem; a stale + #: language is a whole booklet nobody asked for, built for a translation that is gone. + kind: str = 'unit' # 'unit' or 'language' def __str__(self): return f'{self.stage}/{self.rel}' -def orphans(sources) -> list[Orphan]: +def _sweep(sources, parents: set, known: set, kind: str) -> list[Orphan]: """ - Every stale unit directory for one scope, `render/` before `build/`. + Directories beside `parents` whose names are not in `known` and have no source left. - Reads directories and nothing else -- no file is opened, nothing is written, and a tree with - no `build/` in it simply yields nothing, the way `macro-undefined` reports nothing when its - sweep has never run. + The one loop both passes below share. Reads directories and nothing else -- no file is + opened, nothing is written, and a tree with no `build/` in it simply yields nothing, the way + `macro-undefined` reports nothing when its sweep has never run. """ - units = [Path(unit.path) for unit in sources.unit_list] - if not units: - return [] # no units, so no notion of where a unit belongs - - # Where units live, and which names are theirs. Both come from the sources, which is what - # keeps this module-agnostic: naboj puts them under `problems/`, seminar under `1/1`, `1/2` - # and so on, and neither is written down here. - parents = {unit.parent for unit in units} - known = {str(unit) for unit in units} - found = [] for stage in STAGES: for parent in sorted(parents): @@ -77,10 +71,57 @@ def orphans(sources) -> list[Orphan]: if str(rel) in known or (sources.module_root / rel).is_dir(): continue found.append(Orphan(stage, str(rel), child, - sum(1 for f in child.rglob('*') if f.is_file()))) + sum(1 for f in child.rglob('*') if f.is_file()), kind)) return found +def unit_orphans(sources) -> list[Orphan]: + """Stale unit directories: a problem renamed or deleted, its build left behind.""" + units = [Path(unit.path) for unit in sources.unit_list] + if not units: + return [] # no units, so no notion of where a unit belongs + + # Where units live, and which names are theirs. Both come from the sources, which is what + # keeps this module-agnostic: naboj puts them under `problems/`, seminar under `1/1`, `1/2` + # and so on, and neither is written down here. + return _sweep(sources, {unit.parent for unit in units}, + {str(unit) for unit in units}, 'unit') + + +def language_orphans(sources) -> list[Orphan]: + r""" + Stale language directories: a translation dropped, its `.tex` left behind. + + The same narrow shape as the unit pass -- a named set of children of one known parent -- and + it is here because the unit pass cannot see it. `languages/` is not a place units live, so + `build/naboj/phys/29/languages/uk/` is invisible to that sweep however long it sits there. + + **It is self-limiting rather than naboj-specific.** A module with no `languages/` directory + beside its scope has no notion of a language directory and yields nothing, which is how + seminar and scholar pass through -- monolingual, one language and not one of several. + + This is the half that lets a deleted translation keep building. The language documents in + `modules/naboj/module.mk` are pattern rules, so `%` matches any string; a stale `build/` + language directory supplies everything they ask for, and `make …/languages/uk/booklet.pdf` + for a volume with no Ukrainian produces a booklet out of files whose sources are gone. + Volume 29 did exactly that -- a 40-page Ukrainian problems booklet, in a volume that has + `en`, `es` and `sk`. The `source/naboj/$*/meta.yaml` prerequisite added there is the other + half: it refuses a cold build, and this finds the warm one. + """ + languages = sources.module_root / sources.scope / 'languages' + if not languages.is_dir(): + return [] + + parent = Path(sources.scope) / 'languages' + known = {str(parent / child.name) for child in languages.iterdir() if child.is_dir()} + return _sweep(sources, {parent}, known, 'language') + + +def orphans(sources) -> list[Orphan]: + """Every stale directory for one scope -- units, then languages, `render/` before `build/`.""" + return unit_orphans(sources) + language_orphans(sources) + + def remove(orphan: Orphan) -> int: """ Delete one orphan, returning how many files went. diff --git a/core/tests/test_audit.py b/core/tests/test_audit.py index 92f5fe83..1532db05 100644 --- a/core/tests/test_audit.py +++ b/core/tests/test_audit.py @@ -937,17 +937,23 @@ def test_unlisted_units_sort_last_and_keep_their_place(self, tmp_path): assert sources.unit_list[-1].order is None -def make_repo(tmp_path, built=(), beside_the_scope=()): +def make_repo(tmp_path, built=(), beside_the_scope=(), languages=(), built_languages=()): """ A repository shape rather than a bare module root, because `build-orphan` is the one check that looks outside `source/` and has to find `build/` beside it. `built` names directories to create under both mirrors at the unit level; `beside_the_scope` - names directories to create one level up, where the generated aggregates live. + names directories to create one level up, where the generated aggregates live. `languages` + names the translations the scope has, and `built_languages` the ones its mirrors hold -- + which is how a dropped translation is staged, by listing it in the second and not the first. """ module_root = tmp_path / 'source' / 'naboj' module_root.mkdir(parents=True) make_problem(module_root, name='widget') + for name in languages: + target = module_root / 'phys' / '99' / 'languages' / name + target.mkdir(parents=True) + (target / 'meta.yaml').write_text('booklet:\n contents:\n problems: True\n') for stage in ('render', 'build'): for name in built: target = tmp_path / stage / 'naboj' / 'phys' / '99' / 'problems' / name @@ -957,6 +963,10 @@ def make_repo(tmp_path, built=(), beside_the_scope=()): target = tmp_path / stage / 'naboj' / 'phys' / '99' / name target.mkdir(parents=True) (target / 'answers.tex').write_text('generated') + for name in built_languages: + target = tmp_path / stage / 'naboj' / 'phys' / '99' / 'languages' / name + target.mkdir(parents=True) + (target / 'booklet.tex').write_text('stale') return module_root @@ -1001,6 +1011,37 @@ def test_a_tree_that_was_never_built_is_quiet(self, tmp_path): assert 'build-orphan' not in ids(audit(module_root, 'naboj', 'phys/99', ['phys/99/problems/widget'])) + def test_a_dropped_translation_leaves_an_orphan(self, tmp_path): + r""" + The case the unit sweep cannot see: `languages/` is not a place units live, so a stale + `build/naboj/phys/29/languages/uk/` sits there unnoticed -- and the language documents + are pattern rules, so it is enough to build a whole booklet from. Volume 29 printed a + 40-page Ukrainian problems booklet that way, out of a volume that has `en`, `es`, `sk`. + """ + module_root = make_repo(tmp_path, built=('widget',), + languages=('sk',), built_languages=('sk', 'uk')) + report = audit(module_root, 'naboj', 'phys/99', ['phys/99/problems/widget']) + messages = [f.message for f in report.findings if f.check == 'build-orphan'] + assert len(messages) == 2 # one per mirror + assert all('languages/uk' in m for m in messages) + assert not any('languages/sk' in m for m in messages) + + def test_a_translation_that_still_has_its_source_is_quiet(self, tmp_path): + module_root = make_repo(tmp_path, built=('widget',), + languages=('sk', 'en'), built_languages=('sk', 'en')) + assert 'build-orphan' not in ids(audit(module_root, 'naboj', 'phys/99', + ['phys/99/problems/widget'])) + + def test_a_module_with_no_languages_at_all_is_quiet(self, tmp_path): + """ + Seminar's shape. There is no `source//languages/`, so there is no notion of a + language directory here and the pass yields nothing -- it is self-limiting rather than + a rule about naboj. The built `languages/` below would otherwise be a tempting orphan. + """ + module_root = make_repo(tmp_path, built=('widget',), beside_the_scope=('languages',)) + assert 'build-orphan' not in ids(audit(module_root, 'naboj', 'phys/99', + ['phys/99/problems/widget'])) + def test_remove_takes_the_directory_and_reports_what_went(self, tmp_path): module_root = make_repo(tmp_path, built=('widget', 'old-name')) sources = read_scope(module_root, 'naboj', 'phys/99', ['phys/99/problems/widget']) diff --git a/modules/naboj/module.mk b/modules/naboj/module.mk index 03ad88c2..74f899ad 100644 --- a/modules/naboj/module.mk +++ b/modules/naboj/module.mk @@ -429,9 +429,31 @@ build/naboj/%/answers: \ ### Languages ################################### +# `source/naboj/$*/meta.yaml` on every language document below, and it is the only prerequisite +# here that is not derived from something else. It is what pins `%` to a language that exists. +# +# These are pattern rules, so `%` matches any string, and the rest of each list is built with +# `$(wildcard …)`, which yields nothing for a language that is not there rather than failing. +# A target with no prerequisites whose file exists is up to date, so `make …/languages/uk/ +# booklet.pdf` for a volume with no Ukrainian would report success over whatever `build/` still +# held from an older run -- and volume 29 did exactly that, printing a 40-page Ukrainian +# problems booklet out of intermediates whose sources had been removed. Clearing `build/` was +# the only thing that turned it back into an error. +# +# The meta is the source of truth and make has to find it before anything else can run, so a +# language that does not exist now fails at once and says which file is missing, whatever is +# lying in `build/`. The aggregates -- `output/naboj/%/languages` and `booklets` -- already +# worked this way, wildcarding `source/naboj/$*/languages/*`; it was only the single-document +# rules that would take any name. +# +# `fks-naboj`'s ten language directories have no meta and are now unbuildable by name. They +# already were: its layout spells languages `slovak` rather than `sk`, so nothing in +# `SUPPORTED_LANGUAGES` ever matched them. + # Full booklet # % //languages/ output/naboj/%/booklet.pdf: \ + source/naboj/$$*/meta.yaml \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \ $$(subst $$(cdir),,$$(abspath build/naboj/%/../../problems/$$(word 4,$$(subst /, ,$$*)))) \ @@ -446,6 +468,7 @@ output/naboj/%/booklet.pdf: \ # for this language, the shared pictures, and the rendered statements. # % //languages/ output/naboj/%/problems.pdf: \ + source/naboj/$$*/meta.yaml \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \ $$(subst $$(cdir),,$$(abspath build/naboj/%/../../problems/$$(word 4,$$(subst /, ,$$*)))) \ @@ -454,6 +477,7 @@ output/naboj/%/problems.pdf: \ # % //languages/ output/naboj/%/solutions.pdf: \ + source/naboj/$$*/meta.yaml \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \ $$(subst $$(cdir),,$$(abspath build/naboj/%/../../solutions/$$(word 4,$$(subst /, ,$$*)))) \ @@ -468,6 +492,7 @@ output/naboj/%/booklet-print.pdf: \ mv output/naboj/$*/booklet-book.pdf $@ output/naboj/%/answers.pdf: \ + source/naboj/$$*/meta.yaml \ $$(subst $$(cdir),,$$(abspath build/naboj/%/../../answers/$$(word 4,$$(subst /, ,$$*)))) \ $$(subst $$(cdir),,$$(abspath build/naboj/%/../../pdf-prerequisites)) \ build/naboj/%/answers.tex @@ -490,6 +515,7 @@ output/naboj/%/constants.pdf: \ $(call double_xelatex,naboj) output/naboj/%/evaluation.pdf: \ + source/naboj/$$*/meta.yaml \ build/naboj/%/evaluators.tex $(call double_xelatex,naboj) @@ -501,6 +527,7 @@ output/naboj/%/instructions-online.pdf: \ $(call double_xelatex,naboj) output/naboj/%/cover.pdf: \ + source/naboj/$$*/meta.yaml \ build/naboj/%/cover.tex $(call double_xelatex,naboj) @@ -511,6 +538,7 @@ output/naboj/%/cover-print.pdf: \ # Tearoffs, three problems per page, aligned for cutting # //languages/ output/naboj/%/tearoff.pdf: \ + source/naboj/$$*/meta.yaml \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../$$(word 4,$$(subst /, ,$$*)))) \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../pdf-prerequisites)) \ build/naboj/%/tearoff.tex From 2225cce708a9027071f128735a801b006f58579b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 03:51:09 +0000 Subject: [PATCH 04/28] A warm interpreter for the build's Python steps Every recipe in this build shells out to `python -m something`, and 92 % of what that costs is the interpreter coming up: 445 ms of wall and 1.10 s of CPU to import `core.builder.jinja` and the pint/numpy stack under it, against 53 ms to do the render once those are in memory. A volume booklet runs about 320 such steps. `server.py` holds the imports and forks a child per job; `client.py` is what the build invokes, and imports four stdlib modules so that it starts in 14 ms. Measured end to end: 0.457 s per render becomes 0.067 s. Fork, not a reused worker. The expensive state -- the pint registry at `core/builder/jinja.py` module level, i18n's thirteen parsed and validated locales, Jinja's caches -- is shared copy-on-write and read only, while anything a job mutates dies with the child. That is what separates this from a Bazel-style persistent worker, where state leaking between jobs is the standard failure. The fork costs about 2 ms of the 53. Three things it has to get right, each of which cost a round to find: - The length prefix rides in the same `sendmsg` as the descriptors. Ancillary data attaches to one byte of the stream, so a plain `recv` of a separate header can swallow that byte and the descriptors are closed without a word. - An acknowledgement byte divides "never started" from "failed". Before it the client falls back and runs the job itself; after it, a lost connection is a failure, because falling back would run half a job twice. - `OPENBLAS_NUM_THREADS=1` before the first import. `import numpy` spawns a pool at 630 % CPU for a build that does no linear algebra, and forking a process whose threads hold locks is how children deadlock. The CPU it saves is incidental. The daemon fingerprints every `.py`, `.yaml` and `.lua` under `core/`, `modules/` and `tools/` before each fork -- 176 files, 1.2 ms -- and retires rather than answer with code that is no longer on disk. Co-Authored-By: Claude Opus 5 --- core/tests/test_runner.py | 177 +++++++++++++++++++ tools/runner/__init__.py | 19 ++ tools/runner/client.py | 162 +++++++++++++++++ tools/runner/server.py | 357 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 715 insertions(+) create mode 100644 core/tests/test_runner.py create mode 100644 tools/runner/__init__.py create mode 100644 tools/runner/client.py create mode 100644 tools/runner/server.py diff --git a/core/tests/test_runner.py b/core/tests/test_runner.py new file mode 100644 index 00000000..86b4f1c7 --- /dev/null +++ b/core/tests/test_runner.py @@ -0,0 +1,177 @@ +r""" +The warm runner, `tools/runner/`. + +Each test runs a real client against a real daemon, because what is worth asserting here is +exactly what a unit test of the pieces would stub out: that descriptors reach the child, that +an exit status comes back, that the environment and the working directory are the client's and +not the daemon's. The daemon is given a socket of its own through `DGS_RUNNER_SOCKET`, so a +test run never restarts -- or inherits the state of -- the one serving a build in the next +terminal. + +The job every test runs is `core.builder.reference`, which is core's own, needs no content +repository, and is cheap: `--check` reads `docs/filters.md` and says whether it is stale. +""" +import os +import pathlib +import subprocess +import sys +import time + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CLIENT = ('-S', 'tools/runner/client.py') +#: A job that exists in a bare checkout, writes to stdout, and takes an argument that decides +#: whether it succeeds. +JOB = ('-m', 'core.builder.reference', '--check') + + +@pytest.fixture +def runner(tmp_path): + """A client bound to a daemon of this test's own, stopped however the test ends.""" + environment = dict(os.environ, DGS_RUNNER_SOCKET=str(tmp_path / 'sock')) + + def run(*arguments, **extra): + return subprocess.run([sys.executable, *CLIENT, *arguments], check=False, + cwd=ROOT, capture_output=True, text=True, + env=dict(environment, **extra.pop('env', {})), **extra) + + yield run + subprocess.run([sys.executable, '-m', 'tools.runner.server', '--stop'], + cwd=ROOT, env=environment, capture_output=True, check=False) + + +def direct(*arguments): + return subprocess.run([sys.executable, *arguments], check=False, + cwd=ROOT, capture_output=True, text=True) + + +class TestTheWarmRunner: + def test_a_job_matches_the_interpreter_it_replaces(self, runner): + """The whole contract: same stdout, same stderr, same status, warm or cold.""" + warm, cold = runner(*JOB), direct(*JOB) + assert (warm.stdout, warm.stderr, warm.returncode) \ + == (cold.stdout, cold.stderr, cold.returncode) + + def test_a_second_job_reuses_the_daemon(self, runner): + first = runner(*JOB) + second = runner(*JOB) + assert first.returncode == second.returncode + assert first.stdout == second.stdout + + def test_a_failure_keeps_its_status_and_its_complaint(self, runner): + """`--check` is not the point; a status other than 0 surviving the socket is.""" + warm = runner('-m', 'core.builder.reference', '--nonsense') + cold = direct('-m', 'core.builder.reference', '--nonsense') + assert warm.returncode == cold.returncode != 0 + assert warm.stderr == cold.stderr + + def test_the_fallback_is_the_plain_interpreter(self, runner): + """`DGS_NO_DAEMON` must leave nothing behind, because it never connects.""" + warm = runner(*JOB, env={'DGS_NO_DAEMON': '1'}) + assert warm.returncode == direct(*JOB).returncode + + def test_a_daemon_holding_stale_code_retires_and_the_job_still_runs(self, runner, + tmp_path): + """ + The failure this exists to prevent is a daemon answering with code that is no longer + on disk. It must refuse instead -- and refusing before the acknowledgement means the + client runs the job itself, so the edit costs one slow job and nothing else. + """ + runner(*JOB) + socket = tmp_path / 'sock' + assert socket.exists() + + watched = ROOT / 'core' / 'builder' / 'jinja.py' + stat = watched.stat() + try: + os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9)) + done = runner(*JOB) + finally: + os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + + assert done.returncode == direct(*JOB).returncode + deadline = time.monotonic() + 5 + while socket.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert not socket.exists(), 'the stale daemon is still listening' + + def test_a_daemon_that_cannot_start_does_not_stop_the_build(self, tmp_path): + """ + The job still runs, and in reasonable time. A Unix socket name is capped near 108 + bytes, so this one can be neither bound nor connected to; the daemon retires without + a word and the client notices it go rather than waiting out `SPAWN_TIMEOUT`. + """ + impossible = tmp_path / ('d' * 120) / 'sock' + started = time.monotonic() + done = subprocess.run([sys.executable, *CLIENT, *JOB], cwd=ROOT, check=False, + capture_output=True, text=True, + env=dict(os.environ, DGS_RUNNER_SOCKET=str(impossible))) + assert (done.stdout, done.returncode) == (direct(*JOB).stdout, direct(*JOB).returncode) + assert time.monotonic() - started < 10 + + def test_the_child_takes_the_client_s_directory_and_environment(self, runner, tmp_path): + """ + Not the daemon's. It is started from wherever the first client happened to be and + lives for half an hour, so a job that inherited its directory or its environment would + be right until the moment somebody built from somewhere else. + + The first call is what starts the daemon, from the repository root; the probe then + runs from `tmp_path` and has to report `tmp_path` back. + """ + runner(*JOB) + + probe = tmp_path / 'probe.py' + probe.write_text('import os\nprint(os.getcwd())\nprint(os.environ["DGS_PROBE"])\n') + answer = subprocess.run( + [sys.executable, *CLIENT[:1], str(ROOT / CLIENT[1]), str(probe)], check=False, + cwd=tmp_path, capture_output=True, text=True, + env=dict(os.environ, DGS_RUNNER_SOCKET=str(tmp_path / 'sock'), + DGS_PROBE='from the client')) + + assert answer.returncode == 0, answer.stderr + assert answer.stdout.splitlines() == [str(tmp_path), 'from the client'] + + +class TestTheFingerprint: + def test_it_moves_when_a_watched_file_does(self, tmp_path): + """What makes the daemon refuse to answer with code that is no longer on disk.""" + from tools.runner import server + + before = server.fingerprint() + assert before == server.fingerprint() + + watched = ROOT / 'core' / 'builder' / 'jinja.py' + stat = watched.stat() + try: + os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9)) + assert server.fingerprint() != before + finally: + os.utime(watched, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + assert server.fingerprint() == before + + def test_it_reads_the_code_and_not_the_sources(self): + """A problem is not a dependency of the interpreter. Only what was imported is.""" + from tools.runner import server + + watched = {pathlib.Path(path) for path in server.watched()} + assert ROOT / 'pandoc.py' in watched + assert ROOT / 'core' / 'builder' / 'jinja.py' in watched + assert not any(path.is_relative_to(ROOT / 'source') for path in watched) + assert not any('__pycache__' in path.parts for path in watched) + # A template is a dependency of the build graph, not of this process. + assert not any(path.suffix == '.jtex' for path in watched) + + def test_it_is_anchored_to_the_checkout_and_not_to_the_directory(self, tmp_path, + monkeypatch): + """ + The daemon outlives the terminal it was started from. Taken relative to the working + directory, the walk would find nothing from anywhere else and the fingerprint would + never move again -- which is the one way this could go on serving deleted code. + """ + from tools.runner import server + + assert all(pathlib.Path(path).is_absolute() for path in server.watched()) + before = server.fingerprint() + monkeypatch.chdir(tmp_path) + assert server.fingerprint() == before diff --git a/tools/runner/__init__.py b/tools/runner/__init__.py new file mode 100644 index 00000000..b44c40f9 --- /dev/null +++ b/tools/runner/__init__.py @@ -0,0 +1,19 @@ +r""" +A warm interpreter for the build's Python steps. + +Every recipe in this build shells out to `python -m something`, and **92 % of what that costs +is the interpreter coming up**: 445 ms of wall and 1.10 s of CPU to import `core.builder.jinja` +and the pint/numpy stack underneath it, against 53 ms to do the actual render once those are in +memory. A volume booklet runs about 320 such steps, so the startup alone is roughly 40 % of the +whole build's CPU. + +`server.py` holds the imports once and forks per job; `client.py` is what the build actually +invokes, and imports nothing but `os`, `socket`, `struct` and `sys` so that it starts in 14 ms. + +**Fork, not a reused worker.** The expensive state -- the pint registry at `core/builder/jinja.py` +module level, `core.i18n`'s thirteen parsed and schema-validated locales, Jinja's template caches +-- is shared copy-on-write and read only, while anything a job mutates (`sys.argv`, the working +directory, the environment, a global some builder sets) dies with the child. That is what +separates this from a Bazel-style persistent worker, where state leaking from one job into the +next is the standard failure and the reason such pools rot. The fork costs about 2 ms of the 53. +""" diff --git a/tools/runner/client.py b/tools/runner/client.py new file mode 100644 index 00000000..39eb4d7b --- /dev/null +++ b/tools/runner/client.py @@ -0,0 +1,162 @@ +r""" +What the build invokes in place of `python -m …`. Run it with `-S`. + + .venv/bin/python3 -S tools/runner/client.py -m modules.naboj.builder.renderer sk … + .venv/bin/python3 -S tools/runner/client.py pandoc.py --format latex sk … + +The argument list after the script is **exactly what `python` itself would have taken**, so any +command in `build.ninja` can be run by hand by deleting `-S tools/runner/client.py` from it, and +`DGS_NO_DAEMON=1` does that for you -- the client `exec`s the real interpreter and nothing else +about the invocation changes. + +It imports four stdlib modules and nothing else, because its own startup is the floor on what +this whole exercise can save: 14 ms here against 445 ms for the renderer. `-S` skips `site`, so +the venv's packages are not on the path and must not be wanted. Everything else -- `fcntl`, +`subprocess`, `time` -- is imported inside `spawn`, which runs once per build at most. + +**Every failure before the daemon acknowledges falls back to running the job here.** No daemon, +a refused socket, one too old to answer, one that died holding the request: each ends in +`os.execv` of the real interpreter, so this cannot break a build, only slow it back down to +where it was. After the acknowledgement the opposite rule applies -- see `request`. +""" +import os +import socket +import struct +import sys + +#: The checkout this file belongs to, so that the client works from a subdirectory and two +#: checkouts never share a daemon. `tools/runner/client.py` is three directories down. +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +#: Under `build/`, which is already ignored and already swept. A path too long for a Unix +#: socket -- they are capped near 108 bytes -- simply fails to bind and to connect, and the +#: build falls back to plain interpreters, which is the right way for that to go wrong. The +#: override is what lets a test have a daemon of its own rather than restarting yours. +SOCKET = os.environ.get('DGS_RUNNER_SOCKET') or os.path.join(ROOT, 'build', '.dgsd', 'sock') +LOCK = SOCKET + '.lock' +#: Long enough for the daemon to bind, which it does before importing anything; a client that +#: connects during the import simply waits in `accept`. Not long enough to matter if the +#: daemon cannot start at all, which `spawn` notices directly anyway. +SPAWN_TIMEOUT = 20.0 +#: How long a daemon may take to acknowledge before we give up on it and do the job here. The +#: job itself is then given no limit at all: a volume booklet legitimately takes minutes. +READY_TIMEOUT = 60.0 +#: The daemon's "I have your descriptors, your directory and your environment, and I am about +#: to run this". See `request`. +READY = b'\x01' + + +def direct(argv): + """Be `python` after all. Does not return.""" + os.execv(sys.executable, [sys.executable, *argv]) + + +def connect(): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(READY_TIMEOUT) + sock.connect(SOCKET) + return sock + + +def spawn(): + """ + Start a daemon, under a lock so that eight parallel clients do not start eight of them. + + Whoever takes the lock tries to connect first, because by then the daemon the previous + holder started is usually up and nothing needs starting at all. A daemon that dies on the + way up is noticed as it dies rather than waited out, so a broken one costs each client the + half second it takes to fail, not `SPAWN_TIMEOUT`. + """ + import fcntl + import subprocess + import time + + os.makedirs(os.path.dirname(SOCKET) or '.', exist_ok=True) + with open(LOCK, 'a+') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + return connect() + except OSError: + pass + daemon = subprocess.Popen( + [sys.executable, '-m', 'tools.runner.server'], cwd=ROOT, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + deadline = time.monotonic() + SPAWN_TIMEOUT + while time.monotonic() < deadline: + try: + return connect() + except OSError: + if daemon.poll() is not None: + return None + time.sleep(0.02) + return None + + +def request(sock, argv): + """ + One job: the working directory, the environment and the argument list, with our own three + streams passed as descriptors so that the child writes straight to them. + + Fields are NUL-separated and the length of `argv` is sent before it, so the environment + needs no delimiter of its own. Nothing here can hold a NUL: a command line and an + environment are both NUL-terminated strings to begin with. + + **`None` means the job has not started** -- nothing acknowledged, or the daemon died + holding the request -- and the caller may safely run it here instead. Once the + acknowledgement is in, the rule inverts: anything that goes wrong is the job's own failure + and is reported as one, because falling back then would run a half-finished job a second + time. + """ + fields = [os.getcwd(), str(len(argv)), *argv, + *(f'{key}={value}' for key, value in os.environ.items())] + payload = '\0'.join(fields).encode() + try: + # The length goes in the **same** `sendmsg` as the descriptors, not in a `sendall` + # before it. On a stream socket the ancillary data rides on one byte of the stream and + # is delivered to whichever `recvmsg` consumes that byte -- so a plain `recv` of a + # separate header can coalesce the first payload byte with it, and the descriptors are + # closed without a word. One send, one `recv_fds` on the far side, no race. + socket.send_fds(sock, [struct.pack(' int: + argv = sys.argv[1:] + if not argv or os.environ.get('DGS_NO_DAEMON'): + direct(argv) + + try: + sock = connect() + except OSError: + sock = spawn() + if sock is None: + direct(argv) + + with sock: + code = request(sock, argv) + if code is None: + direct(argv) + return code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/runner/server.py b/tools/runner/server.py new file mode 100644 index 00000000..60bad2a3 --- /dev/null +++ b/tools/runner/server.py @@ -0,0 +1,357 @@ +r""" +The warm interpreter. Started by `client.py` when nothing is listening; never started by hand +in the course of a build. + + python -m tools.runner.server run in the foreground, to watch it + python -m tools.runner.server --stop tell a running one to go away + +It imports the build's Python once and forks a child per job. The child inherits every parsed +locale, the pint registry and whatever Jinja has cached, which is the whole point, and inherits +them *copy-on-write*, so nothing it does to them outlives it. See `__init__.py`. + +**It must hold no threads.** `import numpy` spawns an OpenBLAS pool -- 0.57 s of CPU at 630 %, +for a build that does no linear algebra -- and forking a process whose threads hold locks is +the classic way to a child that deadlocks on its first allocation. The variables below are set +before the first import for that reason: this is a correctness measure and the CPU it saves is +incidental. + +**Staleness is checked in the parent, before every fork.** A hash of every `.py`, `.yaml` and +`.lua` under `core/`, `modules/` and `tools/` -- 176 files, 1.2 ms -- stands for what was +imported. When it moves, the daemon unlinks its socket and exits rather than answering with +code that is no longer on disk; the client whose request met the change runs the job itself, +and the next one starts a fresh daemon. A job already running when a file changes is a race +`make` has too. + +**One thing a job does not inherit: the build's process group.** The daemon is its own session, +so interrupting `ninja` kills the clients and leaves their jobs to finish into descriptors +nobody is reading any more. They exit within a second or two and Ninja re-runs the edges whose +commands never reported, which is what it does for an interrupted build anyway. +""" +from __future__ import annotations + +import os + +# Before anything imports numpy. See the module docstring: this is what makes the fork safe. +for _variable in ('OPENBLAS_NUM_THREADS', 'OMP_NUM_THREADS', 'MKL_NUM_THREADS', + 'NUMEXPR_NUM_THREADS', 'VECLIB_MAXIMUM_THREADS'): + os.environ[_variable] = '1' + +import contextlib +import dataclasses +import hashlib +import importlib +import io +import locale +import runpy +import socket +import struct +import sys +import traceback + +#: The checkout this file belongs to. Everything below is anchored to it rather than to the +#: working directory, because the daemon outlives whichever one it was started from -- and a +#: fingerprint taken relative to the wrong directory would find no files and never go stale, +#: which is the one way this could serve code that is no longer on disk. +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SOCKET = os.environ.get('DGS_RUNNER_SOCKET') or os.path.join(ROOT, 'build', '.dgsd', 'sock') + +#: No client for this long and there is no build going on. Cheap to start again. +IDLE = 1800.0 +#: How often `accept` is interrupted to ask whether the socket is still ours, which is how +#: `--stop` -- and `ninja -t clean`, and `rm -rf build/` -- is noticed rather than waited out. +TICK = 5.0 +#: What a request may hold. A command line and an environment, no more. +LIMIT = 1 << 20 +#: Sent once the child holds the job's descriptors, directory and environment and is about to +#: run it. It is what divides "the daemon never started this" from "this failed". +READY = b'\x01' + +#: Imported at startup so that the first job is as fast as the second. +#: +#: **Libraries only.** Eight of the builders -- `core/builder/i18n.py`, naboj's `language.py` +#: and `venue.py`, seminar's `round.py`, scholar's three -- end with a bare +#: `BuilderX().build_templates()` at module level rather than under an `if __name__`, so +#: importing one *runs* it: the first draft of this list printed an argparse usage message and +#: took the daemon down with it. What they cost is in the modules underneath, which is what is +#: named here. A module that is not there -- scholar and seminar are optional checkouts -- is +#: skipped, and the job that wants it imports it itself, once. +#: +#: **Nor the modules the build runs as `__main__`**, the three `builder.renderer` among them. +#: `runpy` warns when asked to execute a module already in `sys.modules`, and it is right to: +#: the job would hold two copies of every class in that file. Preloading buys nothing there +#: anyway -- the expense is `core.builder.renderer` underneath, and executing the entry module +#: itself is a handful of class statements. +PRELOAD = ( + 'core.builder.jinja', + 'core.builder.builder', + 'core.builder.convertor', + 'core.builder.standalone', + 'core.builder.renderer', + 'modules.naboj.builder.builder', + 'modules.naboj.builder.contexts', + 'modules.seminar.builder.builder', + 'modules.seminar.builder.context', + 'modules.scholar.builder.builder', + 'modules.scholar.builder.contexts', +) + +#: Where the code is. Templates are deliberately absent: a `.jtex` is a dependency of the build +#: graph, not of the interpreter, and changing one must not cost a restart. +SOURCES = ('core', 'modules', 'tools') +SCRIPTS = ('pandoc.py', 'standalone.py') +#: Directories whose contents say nothing about what was imported. +SKIPPED = {'__pycache__', 'tests', 'node_modules'} +SUFFIXES = ('.py', '.yaml', '.lua') + + +def watched(): + """Every file whose contents could be in this process's memory.""" + for name in SOURCES: + for directory, children, files in os.walk(os.path.join(ROOT, name)): + children[:] = [c for c in children if c not in SKIPPED and not c.startswith('.')] + for found in files: + if found.endswith(SUFFIXES): + yield os.path.join(directory, found) + for name in SCRIPTS: + yield os.path.join(ROOT, name) + + +def fingerprint() -> str: + """What was imported, as far as the file system can say: name, mtime and size of each.""" + digest = hashlib.blake2b(digest_size=16) + for path in sorted(watched()): + try: + stat = os.stat(path) + except OSError: + digest.update(f'{path}\0gone\0'.encode()) + else: + digest.update(f'{path}\0{stat.st_mtime_ns}\0{stat.st_size}\0'.encode()) + return digest.hexdigest() + + +def preload() -> None: + """ + Best effort, and `BaseException` rather than `ImportError`: a module that insists on + parsing arguments or calling `sys.exit` at import must cost one slow job, never the daemon. + """ + for name in PRELOAD: + with contextlib.suppress(BaseException): + importlib.import_module(name) + + +@dataclasses.dataclass(frozen=True, slots=True) +class Request: + """One job as the client described it, and the three descriptors it sent with it.""" + + cwd: str + argv: list[str] + environment: dict[str, str] + descriptors: list[int] + + @classmethod + def read(cls, connection) -> Request | None: + """ + `None` for anything malformed, with the descriptors closed on the way out. The child + is about to exit either way, but a half-read request must not go on holding the + client's three streams open while it does. + """ + held, descriptors, _, _ = socket.recv_fds(connection, 65536, 3) + request = cls.parse(connection, held, descriptors) + if request is None: + for descriptor in descriptors: + os.close(descriptor) + return request + + @classmethod + def parse(cls, connection, held: bytes, descriptors: list[int]) -> Request | None: + """ + The rest of the stream, and the fields in it. The first `recv` is the one that carried + the descriptors and may have carried any amount of the payload with them, so what is + left is read here -- plainly, the ancillary data being long since delivered. + """ + if len(held) < 4 or len(descriptors) != 3: + return None + try: + (length,) = struct.unpack(' LIMIT: + return None + held = held[4:] + while len(held) < length: + piece = connection.recv(length - len(held)) + if not piece: + return None + held += piece + + fields = held.decode().split('\0') + count = int(fields[1]) + return cls(cwd=fields[0], argv=fields[2:2 + count], + environment=dict(entry.split('=', 1) + for entry in fields[2 + count:] if '=' in entry), + descriptors=descriptors) + except (OSError, UnicodeDecodeError, ValueError, IndexError, struct.error): + return None + + def become(self) -> None: + """Make this child look like the process the client would otherwise have started.""" + for target, descriptor in enumerate(self.descriptors): + os.dup2(descriptor, target) + os.close(descriptor) + # Rebuilt rather than reused: the daemon's own `sys.stdout` settled its buffering + # against whatever it was started with -- `/dev/null`, so block-buffered -- and a job + # whose output is a terminal wants that decision taken again. UTF-8 outright, because + # every file in this repository is UTF-8 and the alternative is that a job's output + # depends on the locale of the terminal the daemon happened to be started from half an + # hour ago, which is the one thing a long-lived process must not let happen. + sys.stdin = io.TextIOWrapper(io.FileIO(0, 'r', closefd=False), encoding='utf-8') + sys.stdout = io.TextIOWrapper(io.FileIO(1, 'w', closefd=False), encoding='utf-8', + line_buffering=os.isatty(1)) + sys.stderr = io.TextIOWrapper(io.FileIO(2, 'w', closefd=False), encoding='utf-8', + errors='backslashreplace', line_buffering=True) + + os.environ.clear() + os.environ.update(self.environment) + # The client's locale and not the daemon's, for the same reason: `Path.open()` + # throughout the builders takes its encoding from it. + with contextlib.suppress(locale.Error): + locale.setlocale(locale.LC_ALL, '') + os.chdir(self.cwd) + + def execute(self) -> int: + """`python -m module …` or `python script.py …`, as the interpreter would read it.""" + try: + if self.argv[0] == '-m': + sys.argv = [self.argv[1], *self.argv[2:]] + runpy.run_module(self.argv[1], run_name='__main__', alter_sys=True) + else: + sys.argv = list(self.argv) + runpy.run_path(self.argv[0], run_name='__main__') + except SystemExit as exit: + if exit.code is None or isinstance(exit.code, int): + return exit.code or 0 + # What the interpreter does with `sys.exit('…')`, which nothing here writes today. + print(exit.code, file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + except Exception: # noqa: BLE001 -- the top level of a process, and it behaves like + # one: whatever the job raised is printed and becomes a status, because there is + # nobody above to handle it and letting it reach the daemon would end the build. + traceback.print_exc() + return 1 + return 0 + + +def serve(connection) -> None: + """The child, which never returns from here.""" + code = 70 + try: + request = Request.read(connection) + if request is not None: + request.become() + # In place and about to run, so from here on a client that loses the connection + # must report a failure rather than run the job itself. Before this byte it may + # safely fall back; after it, falling back would run the job twice. + connection.sendall(READY) + code = request.execute() + finally: + for stream in (sys.stdout, sys.stderr): + with contextlib.suppress(Exception): + stream.flush() + with contextlib.suppress(OSError): + connection.sendall(struct.pack(' tuple[socket.socket, int] | None: + """ + The listening socket and the inode of the path it is bound to. + + `None` is the answer to every reason there should be no daemon here, and the client reads + all of them the same way -- it runs the job itself. Somebody else is already serving, which + is how a duplicate from a spawn race retires itself; or the path cannot be bound at all, + a Unix socket name being capped near 108 bytes. + """ + os.makedirs(os.path.dirname(SOCKET) or '.', exist_ok=True) + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + listener.bind(SOCKET) + except OSError: + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.connect(SOCKET) + except OSError: + pass + else: + probe.close() + return None + # Nothing was listening, so whatever is there is a corpse and may be cleared. + with contextlib.suppress(OSError): + os.unlink(SOCKET) + try: + listener.bind(SOCKET) + except OSError: + return None + listener.listen(64) + # The inode of the **path**, not of the socket: `fstat` on a bound socket descriptor + # reports its node in the socket filesystem, which says nothing about the path at all. + return listener, os.stat(SOCKET).st_ino + + +def ours(inode: int) -> bool: + """Is the socket we bound still the socket at that path? `--stop` is what makes it not.""" + try: + return os.stat(SOCKET).st_ino == inode + except OSError: + return False + + +def reap() -> None: + """Children that have finished, collected where no signal handler has to be involved.""" + with contextlib.suppress(ChildProcessError): + while os.waitpid(-1, os.WNOHANG)[0]: + pass + + +def main() -> int: + if '--stop' in sys.argv[1:]: + with contextlib.suppress(OSError): + os.unlink(SOCKET) + return 0 + + bound = listen() + if bound is None: + return 0 + listener, inode = bound + held = fingerprint() + preload() + listener.settimeout(TICK) + + try: + idle = 0.0 + while True: + try: + connection, _ = listener.accept() + except TimeoutError: + reap() + idle += TICK + if idle >= IDLE or not ours(inode): + return 0 + continue + idle = 0.0 + reap() + with connection: + if fingerprint() != held: + return 0 + if os.fork() == 0: + listener.close() + serve(connection) + finally: + if ours(inode): + with contextlib.suppress(OSError): + os.unlink(SOCKET) + + +if __name__ == '__main__': + sys.exit(main()) From 2b41c46d093defb4c9121f149718b6aa82bdfd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 03:51:25 +0000 Subject: [PATCH 05/28] The build graph, generated for Ninja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/ninja/generate.py` writes every edge of the build from a walk of `source/`, which is possible because no `$(wildcard …)` in any of the four makefiles reads `build/`: every `build/` prerequisite is `$(subst source/,build/,…)` of a `source/` glob, so the graph is a pure function of the source tree. 35 155 edges for the whole repository, 1.9 s to generate, 0.13 s for Ninja to decide there is nothing to do. What it is for is not speed. Make decides with pattern rules whose prerequisites come from wildcards, and a wildcard that matches nothing yields nothing rather than failing, so a rule silently does not apply and an existing target is declared current. That has hidden three defects on record -- `Makefile:20-23`, `Makefile:146`, and the Ukrainian booklet of the previous commit. Ninja has no pattern rules and no implicit search: a target either has an edge or it is `unknown target`, and it says which real one you probably meant. Checked against make rather than assumed: a volume's 142 `.tex` come out byte-identical and the 44-page booklet differs in one line, the colophon's build clock. `ink.py` carries the Makefile's four colours onto Ninja's `description` lines, so the build still says what it is doing in the words it used to. One departure: make's single `jinja` macro called everything a "Markdown template", which is right for a volume's 161 fragments and wrong for its 30 pictures, so `$kind` now names the format. A rule block may not carry a default for such a variable -- that is a parse error, not something Ninja ignores -- so every edge binds it. Nothing in `Makefile` or `modules/*/module.mk` is touched. The two drive the same trees, which is what lets them be diffed. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 + core/tests/test_ninja_ink.py | 78 +++ pyproject.toml | 1 + tools/ninja/__init__.py | 0 tools/ninja/fls.py | 93 ++++ tools/ninja/generate.py | 889 +++++++++++++++++++++++++++++++++++ tools/ninja/ink.py | 50 ++ tools/ninja/writer.py | 110 +++++ tools/ninja/zip.py | 44 ++ uv.lock | 28 ++ 10 files changed, 1299 insertions(+) create mode 100644 core/tests/test_ninja_ink.py create mode 100644 tools/ninja/__init__.py create mode 100644 tools/ninja/fls.py create mode 100644 tools/ninja/generate.py create mode 100644 tools/ninja/ink.py create mode 100644 tools/ninja/writer.py create mode 100644 tools/ninja/zip.py diff --git a/.gitignore b/.gitignore index d8f13bd2..6cf7997a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ *.dblite .idea/ build/ + +# The Ninja spike (`tools/ninja/generate.py`). The graph is generated, never written by hand, +# and `.ninja_log` / `.ninja_deps` are Ninja's own state beside it. +build.ninja +.ninja_log +.ninja_deps render/ output/ source/ diff --git a/core/tests/test_ninja_ink.py b/core/tests/test_ninja_ink.py new file mode 100644 index 00000000..07ee9991 --- /dev/null +++ b/core/tests/test_ninja_ink.py @@ -0,0 +1,78 @@ +r""" +`tools/ninja/ink.py` -- the Makefile's colours on Ninja's `description` lines. + +Two things can go wrong here and neither shows up as a failed build, which is why they are +pinned: a `{` reaching `str.format` that was meant for the reader, and a line that does not +end in the default colour, which leaves the next one tinted. +""" +import re + +import pytest + +from tools.ninja import ink + +#: What `tput sgr0` writes, with the charset reset `tput` adds for xterm allowed either way. +RE_ESCAPE = re.compile(r'(?:\x1b\(B)?\x1b\[[0-9;]*m') + + +@pytest.fixture +def coloured(monkeypatch): + monkeypatch.delenv('NO_COLOR', raising=False) + monkeypatch.setenv('TERM', 'xterm-256color') + + +class TestTheColours: + def test_a_description_ends_in_the_default(self, coloured): + """Otherwise the colour of the last thing named bleeds into Ninja's next line.""" + assert ink.described('{a}Copying {f}$in').endswith(ink.PALETTE['d']) + + def test_ninja_s_own_variables_pass_through(self, coloured): + """`$in` is expanded by Ninja when the edge runs, long after `format` has been.""" + assert '$in' in ink.described('{a}Copying {f}$in') + assert '$out' in ink.described('{a}to {f}$out') + + def test_the_text_survives_the_colours(self, coloured): + said = ink.described('{a}[jinja] Rendering {x}$kind template{a} {f}$in') + assert RE_ESCAPE.sub('', said) == '[jinja] Rendering $kind template $in' + + @pytest.mark.parametrize('environment', [{'NO_COLOR': '1'}, {'TERM': 'dumb'}, + {'TERM': ''}]) + def test_a_terminal_that_is_not_one_gets_no_escapes(self, monkeypatch, environment): + monkeypatch.delenv('NO_COLOR', raising=False) + monkeypatch.setenv('TERM', 'xterm-256color') + for key, value in environment.items(): + monkeypatch.setenv(key, value) + said = ink.described('{a}[jinja] Rendering {x}$kind template{a} {f}$in') + assert said == '[jinja] Rendering $kind template $in' + assert '\x1b' not in said + + +class TestEveryDescriptionInTheGraph: + """ + Over the rules themselves, because a stray brace in one of them is a `KeyError` at + generation time and a missing reset is invisible until two lines run together. + """ + + @staticmethod + def descriptions() -> list[str]: + import io + + from tools.ninja.generate import rules + from tools.ninja.writer import Writer + + out = io.StringIO() + rules(Writer(out)) + return [line.split(' = ', 1)[1] + for line in out.getvalue().splitlines() if line.startswith(' description = ')] + + def test_there_are_some(self): + assert len(self.descriptions()) > 15 + + def test_each_one_ends_in_the_default(self, coloured): + for said in self.descriptions(): + assert said.endswith(ink.PALETTE['d']), said + + def test_none_of_them_leaks_a_brace(self): + """A `{` that `format` did not consume is a marker nobody defined.""" + for said in self.descriptions(): + assert '{' not in said and '}' not in said, said diff --git a/pyproject.toml b/pyproject.toml index bd430ea1..06797594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "enschema>=0.1.5", "flask>=3.1.3", "jinja2>=3.1.6", + "ninja>=1.11", "numpy>=2.5.1", "pandoc-crossref>=0.1.1", "pandoc-minted>=0.2.0", diff --git a/tools/ninja/__init__.py b/tools/ninja/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/ninja/fls.py b/tools/ninja/fls.py new file mode 100644 index 00000000..746c87bd --- /dev/null +++ b/tools/ninja/fls.py @@ -0,0 +1,93 @@ +r""" +Turn xelatex's `.fls` recorder log into a Ninja depfile. + +**Why this is the right source.** Every other way of knowing what a document includes is a +guess. `\protectedInput` resolves its path against `\activeDirectory`, a macro set at run time by +`\setActive`, and then asks `\IfFileExists`; `\insertPicture` does the same. Nothing upstream can +predict the answer, which is why `modules/naboj/module.mk` falls back to depending on *directories* +(`naboj_problem_dirs`, `:114-132`) and on every `.jtex` in the tree at once. With `-recorder`, +TeX writes down what it actually opened, and the next build is told exactly that. + +What is filtered out, and why: + +- **Everything outside the repository.** TeX Live is some 1200 files per run -- fonts, `.fmt`, + every `.sty` -- and none of it changes without a distribution upgrade, which is not an event + this build should try to track. Keeping them would multiply the graph by two orders of + magnitude for nothing. +- **The run's own outputs** (`.aux`, `.log`, `.out`, `.synctex.gz`, the `.pdf` itself). TeX reads + its own `.aux` from the previous pass, and a depfile naming the target as its own dependency is + a cycle Ninja refuses with `dependency cycle`. +- **`OUTPUT` lines.** Only `INPUT` is a dependency. + +Usage, as the second half of a build command: + + xelatex -recorder … && python -m tools.ninja.fls out.fls out.pdf out.pdf.d +""" +import argparse +import pathlib +import sys + +#: Produced by the run itself, so never a dependency of it. +OUTPUTS = {'.aux', '.log', '.out', '.pdf', '.synctex', '.gz', '.toc', '.nav', '.snm', '.fls'} + + +def dependencies(fls: pathlib.Path, repo: pathlib.Path, target: pathlib.Path) -> list[str]: + """Every file inside the repository that this run read, once each, in a stable order.""" + seen, out = set(), [] + target = target.resolve() + lines = fls.read_text(errors='replace').splitlines() + # A relative INPUT is relative to the directory TeX ran in, which the log states on its + # first line -- *not* to wherever the `.fls` itself landed. `-jobname` puts the log under + # `output/…` while the run happens at the repository root, so resolving against the file's + # own directory turns `build/core/i18n/sk.tex` into + # `output/…/languages/sk/build/core/i18n/sk.tex`, a path that exists nowhere. + base = fls.parent + for line in lines: + if line.startswith('PWD '): + base = pathlib.Path(line[4:]) + break + for line in lines: + if not line.startswith('INPUT '): + continue + path = pathlib.Path(line[6:]) + if not path.is_absolute(): + path = base / path + try: + path = path.resolve() + relative = path.relative_to(repo) + except (ValueError, OSError): + continue # outside the repository: TeX Live, and it does not move + if path == target or path.suffix in OUTPUTS or path in seen: + continue + seen.add(path) + out.append(str(relative)) + return sorted(out) + + +def write(fls: pathlib.Path, target: str, depfile: pathlib.Path, repo: pathlib.Path) -> int: + deps = dependencies(fls, repo, pathlib.Path(target)) + # Ninja's depfile parser takes one target and a space-separated list; a space in a path is + # escaped with a backslash, which is also how make spells it. + escaped = ' '.join(d.replace(' ', r'\ ') for d in deps) + depfile.write_text(f"{target}: {escaped}\n") + return len(deps) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument('fls', type=pathlib.Path) + parser.add_argument('target') + parser.add_argument('depfile', type=pathlib.Path) + parser.add_argument('--repo', type=pathlib.Path, default=pathlib.Path('.')) + args = parser.parse_args() + if not args.fls.is_file(): + # No recorder log means the run did not get that far; an empty depfile is correct and + # lets Ninja fall back to the declared inputs rather than failing on a missing file. + args.depfile.write_text(f'{args.target}:\n') + return 0 + write(args.fls, args.target, args.depfile, args.repo.resolve()) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py new file mode 100644 index 00000000..6645821b --- /dev/null +++ b/tools/ninja/generate.py @@ -0,0 +1,889 @@ +r""" +Generate `build.ninja` for one Náboj volume, as a spike against the Makefile. + +**Why this exists.** The make build decides what to do with pattern rules whose prerequisites come +from `$(wildcard …)`, and a wildcard that matches nothing yields *nothing* rather than failing. A +rule then either silently does not apply -- and an existing target is declared current -- or applies +against whatever is left in `build/`. That has hidden three defects on record: `Makefile:20-23` +(`$(lang)` unset, every picture rule dying on `invalid choice`), `Makefile:146`, and a 40-page +Ukrainian booklet built for a volume that has no Ukrainian. Ninja has no pattern rules and no +implicit search: a target either has an edge here or it is `ninja: error: unknown target`. + +**Why it is possible.** No `$(wildcard …)` in any of the four makefiles reads `build/` -- every +`build/` prerequisite is `$(subst source/,build/,…)` of a `source/` glob. The dependency graph is a +pure function of the source tree, so it can be computed up front. + +Run: + + uv run python -m tools.ninja.generate --scope phys/29 -o build.ninja + uv run --with ninja ninja -f build.ninja output/naboj/phys/29/languages/sk/booklet.pdf + +Nothing in `Makefile` or `modules/*/module.mk` is touched; the two drive the same trees and the +point of the exercise is to diff their output. +""" +import argparse +import pathlib +import sys + +from tools.ninja.ink import described +from tools.ninja.writer import Writer + +#: Files a problem holds inside its language directory, and beside it. The split is +#: `modules/naboj/editor.yaml`'s `targets` / `translated`; it is repeated here rather than read +#: because the spike must not depend on the descriptor being right about a module it is testing. +#: Which fragments each document actually includes. Make expresses this with the three +#: per-language stamps `problems/`, `solutions/` and `answers/` +#: (`modules/naboj/module.mk:321-343`); the sets are the same, named rather than stamped. +#: +#: Getting it wrong is not merely wasteful. Declaring every fragment on every document made +#: `problems.pdf` wait for a `solution.md` it never includes -- and in `FKS/42/1/1` that is a +#: solution which does not currently convert, so a booklet that builds under make did not here. +NABOJ_GROUPS = { + 'problems': ('problem', 'problem-extra'), + 'solutions': ('solution',), + 'answers': ('answer', 'answer-also', 'answer-interval', 'answer-extra'), +} +NABOJ_DOCUMENT_GROUPS = { + 'booklet': ('problems', 'solutions', 'answers'), + 'online': ('problems', 'solutions', 'answers'), + 'tearoff': ('problems', 'solutions', 'answers'), + 'problems': ('problems',), + 'solutions': ('solutions',), + 'answers': ('answers',), + 'constants': (), + 'cover': (), + 'evaluation': (), +} +#: The same, for seminar, from `modules/seminar/module.mk:171-199`. +SEMINAR_DOCUMENT_GROUPS = { + 'problems': ('problem',), + 'solutions': ('solution',), + 'solutions-full': ('problem', 'solution'), +} + +#: Scholar has two kinds of issue and each has its own builder, writing three documents and two +#: respectively (`modules/scholar/builder/{handout,homework}.py`). A unit here is an *issue*, +#: which holds a `text.md` of its own and a directory per problem -- two editable levels, which +#: `modules/scholar/editor.yaml` declares as two `units:` entries. +SCHOLAR_KINDS = { + 'handouts': ('handout', ('handout-students', 'handout-solutions', 'handout-solved')), + 'homework': ('homework', ('homework-students', 'homework-solutions')), +} +#: `content` is in neither `modules/scholar/module.mk`'s `RULE_TEMPLATE_SCHOLAR` (which names +#: `text problem solution`) nor `modules/scholar/editor.yaml` (`targets: [text]`), and 28 files +#: are called that -- which is why `make output/scholar/…/handout-students.pdf` dies on the +#: XFAIL fall-through at `Makefile:186` rather than building. Taken from the tree, not from +#: either declaration. +SCHOLAR_TARGETS = ('text', 'content', 'problem', 'solution') + +NABOJ_RENDERER = 'modules.naboj.builder.renderer' +SEMINAR_RENDERER = 'modules.seminar.builder.renderer' +SCHOLAR_RENDERER = 'modules.scholar.builder.renderer' + +TRANSLATED = ('problem', 'problem-extra', 'solution', 'answer-extra') +SHARED = ('answer', 'answer-also', 'answer-interval') + +#: What `build/%.pdf` can be made from, in the order the Makefile tries them -- which matters, +#: because a hand-drawn `.svg` matches two rules and must be copied rather than round-tripped +#: (`Makefile:227-235`). Here the choice is made once, by extension, and written down. +PICTURE_SOURCES = ('svg', 'tikz', 'gp') +PICTURE_COPIES = ('png', 'jpg', 'pdf') + +#: The documents one language directory holds. `modules/naboj/builder/language.py:templates` +#: is the authority; `intro` and `evaluators` come from `i18n_templates` and are skipped when +#: their source is absent, which is the one place this generator has to guess. +LANGUAGE_DOCUMENTS = ('booklet', 'answers', 'constants', 'cover', 'online', + 'evaluation', 'tearoff', 'problems', 'solutions') +#: Of those, the ones with a `double_xelatex` rule to a PDF in `output/`. +LANGUAGE_PDFS = ('booklet', 'problems', 'solutions', 'answers', 'constants', 'cover', + 'evaluation', 'tearoff', 'online') + +#: What `modules/seminar/builder/round.py` writes, and which of those reach `output/` as a PDF. +#: `instagram` is built but its `output/` rule is commented out in `modules/seminar/module.mk:270`; +#: it is kept here as a `.tex` so nothing is lost, and not as a PDF so nothing new appears. +ROUND_DOCUMENTS = ('problems', 'solutions', 'solutions-full', 'instagram') +ROUND_PDFS = ('problems', 'solutions', 'solutions-full') + +#: A seminar problem's two fragments. Seminar is monolingual -- one language, not one of several +#: that happens to be checked out -- so there is no language directory and no shared/translated +#: split to make. +SEMINAR_TARGETS = ('problem', 'solution') + +#: What `modules/naboj/builder/venue.py` writes: two from the template root, and +#: `instructions-inner.tex` per language, which it skips when the source is absent. +VENUE_DOCUMENTS = ('instructions', 'answers-modulo') + + +def rules(w: Writer) -> None: + """Every command shape the volume needs, taken verbatim from the Makefile's recipes.""" + # The interpreter that generated this file, by absolute path, rather than a bare `python` + # the way the Makefile's recipes spelled it. Bare, every invocation has to go through + # `uv run` to put one on PATH -- which costs about 55 ms each time and fails outright if + # you type `ninja` directly, including the generator rule rebuilding this very file. + w.comment('Commands are the Makefile\'s, unchanged, so the two builds can be diffed.') + w.variable('python', sys.executable) + # Every Python step goes through the warm runner rather than starting an interpreter of + # its own: 92 % of what one of these costs is the import of `core.builder.jinja` and the + # pint/numpy stack under it -- 0.46 s of wall and 1.10 s of CPU, against 0.05 s to do the + # work. `tools/runner/` carries the argument; what matters here is that the argument list + # after the script is exactly what `python` itself would have taken, so deleting + # `-S tools/runner/client.py` from any command below runs it the old way. So does + # `DGS_NO_DAEMON=1`, and so does any failure to reach a daemon, which falls back by itself. + # + # `regenerate` is deliberately *not* among them: it rewrites `build.ninja` precisely when + # the code a daemon is holding has changed, which is the one moment a warm interpreter is + # the wrong thing to ask. + w.variable('run', f'{sys.executable} -S tools/runner/client.py') + w.newline() + + # **No pool for xelatex.** One was here as a precaution -- two runs in a directory, and + # `-shell-escape` lets a document write anything -- and it was wrong: `-jobname` is the full + # output path, so no two runs ever share an output name, and nothing else is written to a + # shared name. What the precaution cost was every scrap of parallelism on the one expensive + # step: a depth-1 pool put all 1549 xelatex edges in single file, so `-j8` bought nothing at + # all and a picture-heavy volume ran slower than it had under make. + w.rule('jinja', + '$run -m $renderer $lang $in $out --context $meta', + # `$kind` is bound by every edge, through `_rendered`. + description=described('{a}[jinja] Rendering {x}$kind template{a} {f}$in{a} ' + 'to {x}$kind{a} file {f}$out')) + w.rule('pandoc_tex', + '$run pandoc.py --format latex $lang $in $out', + description=described('{a}[convert] Converting {x}Markdown{a} file {f}$in{a} ' + 'to {x}TeX{a} file {f}$out')) + w.rule('copy', + 'mkdir -p $$(dirname $out) && cp $in $out', + # `$kind` is bound by every edge, through `_copied`: a rule block may not carry a + # default for it, and an unbound variable would print as nothing. + description=described('{a}Copying {x}$kind{a} file {f}$in{a} to {f}$out')) + w.rule('i18n', + 'mkdir -p build/core/i18n && $run -m core.builder.i18n ' + "'core/i18n/' 'core/templates/' $lang -o build/core/i18n", + description=described('{a}Building {x}i18n{a} files for {f}$lang')) + w.rule('copy_static', + 'mkdir -p $$(dirname $out) && cp -r $src/.static/ $$(dirname $out)/ && touch $out', + description=described('{a}Copying {x}static{a} files for {f}$src')) + w.rule('language', + "mkdir -p $outdir && $run -m modules.naboj.builder.language 'source/naboj/' " + "'modules/naboj/templates/' $competition $volume $lang -o '$outdir'", + description=described('{a}Building {x}language{a} documents for ' + '{f}$competition/$volume/$lang')) + w.rule('picture_tex', + 'mkdir -p $$(dirname $out) && $run standalone.py $lang $in $out', + description=described('{a}[standalone] Rendering {x}picture template{a} {f}$in{a} ' + 'to {x}TeX{a} file {f}$out')) + w.rule('svg_pdf', + # `${out}-crop`, not `$out-crop`: a Ninja variable name may contain a hyphen, so the + # bare form is read as a variable called `out-crop`, expands to nothing, and `mv` is + # left with one operand. The error says `missing destination file operand`, which + # names neither the rule nor the variable. + 'mkdir -p $$(dirname $out) && rsvg-convert --format pdf --keep-aspect-ratio ' + '--output $out $in && pdfcrop $out ${out}-crop && mv ${out}-crop $out', + description=described('{a}[rsvg-convert] Converting {f}$in{a} ' + 'to {x}PDF{a} file {f}$out')) + w.rule('tikz_pdf', + 'mkdir -p $$(dirname $out) && ' + _xelatex('$in', '$out', first=True) + + ' && ' + _xelatex('$in', '$out', first=False) + + ' && ' + _depfile('$out'), + description=described('{a}[xelatex] Rendering {f}$in{a} ' + 'to {x}PDF{a} file {f}$out'), + depfile='$out.d', deps='gcc') + # `Makefile:350-354`, written out rather than referenced. `gpterm` carries the terminal *and* + # its font because re-issuing `set terminal` resets the font, and the two `-e` options cannot + # be one: gnuplot expands a macro while parsing the line it is on. + w.rule('gnuplot_pdf', + 'mkdir -p $$(dirname $out) && cd $$(dirname $out) && gnuplot ' + '-e "set macros; gppt = 1.0 / 72; ' + '''gpterm = 'pdfcairo fontscale 1 font \\"Minion Pro, 6\\"' " ''' + '-e "set terminal @gpterm size 5, 3; ' + "set output '$$(basename $out)'; set fit quiet;\" $$(basename $in)", + description=described('{a}[gnuplot] Rendering {f}$in{a} ' + 'to {x}PDF{a} file {f}$out')) + w.rule('document_pdf', + 'mkdir -p $$(dirname $out) && ' + _xelatex('$tex', '$out', first=True) + + ' && ' + _xelatex('$tex', '$out', first=False) + + ' && ' + _depfile('$out'), + description=described('{a}[XeLaTeX] Compiling {x}PDF{a} file {f}$out'), + depfile='$out.d', deps='gcc') + w.rule('standalone_tex', + "mkdir -p $outdir && $run -m modules.naboj.builder.standalone " + "$competition $volume $problem $lang -o '$outdir'", + description=described('{a}Building {x}standalone{a} for {f}$outdir')) + w.rule('scholar_issue', + "mkdir -p $outdir && $run -m modules.scholar.builder.$builder 'source/scholar/' " + "'modules/scholar/templates/' $course $year $issue -o '$outdir'", + description=described('{a}Building {x}$builder{a} for {f}$outdir')) + w.rule('round', + "mkdir -p $outdir && $run -m modules.seminar.builder.round 'source/seminar/' " + "'modules/seminar/templates/' -c $competition -v $volume -s $semester -r $round " + "-o '$outdir'", + description=described('{a}Building {x}round{a} documents for {f}$outdir')) + w.rule('venue', + "mkdir -p $outdir && $run -m modules.naboj.builder.venue 'source/naboj/' " + "'modules/naboj/templates/' $competition $volume $venue -o '$outdir'", + description=described('{a}Building {x}venue{a} documents for ' + '{f}$competition/$volume/$venue')) + w.rule('standalone_pdf', + 'mkdir -p $$(dirname $out) && ' + _xelatex('$tex', '$out', first=True) + + ' && ' + _xelatex('$tex', '$out', first=False) + + ' && ' + _depfile('$out'), + description=described('{a}[XeLaTeX] Compiling {x}PDF{a} file {f}$out'), + depfile='$out.d', deps='gcc') + # `pdfbook2` names its own output and the recipe renames it; `pdfjam` takes `--outfile`. + w.rule('pdfbook', + 'mkdir -p $$(dirname $out) && pdfbook2 --paper a4paper --outer-margin=30 ' + '--inner-margin=50 --top-margin=30 --bottom-margin=30 $in && ' + 'mv $$(dirname $in)/$$(basename $in .pdf)-book.pdf $out', + description=described('{a}[pdfbook2] Imposing {f}$in{a} into {f}$out')) + w.rule('pdfjam_nup', + 'mkdir -p $$(dirname $out) && pdfjam --quiet --nup "2x1" --landscape ' + '--outfile $out $in', + description=described('{a}[pdfjam] Imposing {f}$in{a} into {f}$out')) + # `pdftk burst` writes one PDF per page and the count is only known after xelatex ran, so + # the pages are not declared; the stamp is what downstream depends on. + w.rule('burst', + 'pdftk $in burst output $$(dirname $in)/%02d.pdf && touch $out', + description=described('{a}[pdftk] Splitting {f}$in{a} into individual pages')) + w.rule('tearoff_zip', + 'mkdir -p $$(dirname $out) && rm -f $out && $run -m tools.ninja.zip $out $in', + description=described('{a}Packing {x}tear-offs{a} into {f}$out')) + w.rule('phony_clean', + 'rm -rf $paths', + description=described('{a}Removing {f}$paths')) + w.rule('prune', + '$python tools/prune.py $flags', + description=described('{a}Prune: {f}$flags')) + w.rule('regenerate', + '$python -m tools.ninja.generate --scope $scope -o $out', + description=described('{a}[ninja] Regenerating {f}$out'), generator='1') + w.newline() + + +#: `Makefile:68-72`. The first pass is allowed wrong cross-references, the second is not. +TEXFOT_FIRST = ("--no-interactive --ignore 'Underfull.*' " + "--ignore 'LaTeX Warning: Hyper reference.*' " + "--ignore 'LaTeX Warning: Reference.*' --ignore 'LaTeX Warning: Citation.*'") +TEXFOT = "--no-interactive --ignore 'Underfull.*'" + + +def _xelatex(source: str, target: str, *, first: bool) -> str: + r""" + One xelatex pass, spelled as `Makefile:85-90` spells it, plus `-recorder`. + + `-recorder` is the whole of the dependency story for a document. It costs nothing -- TeX + writes a `.fls` listing every file it opened -- and it is the only honest answer to + `\protectedInput`, whose path is built from a macro at run time and then tested with + `\IfFileExists`. `tools/ninja/fls.py` turns the log into a depfile. + """ + return ('max_print_line=1000 error_line=254 half_error_line=238 texfot ' + f'{TEXFOT_FIRST if first else TEXFOT} xelatex -file-line-error -shell-escape ' + f'-recorder -jobname=$$(echo {target} | sed s/\\.pdf$$//) -halt-on-error -synctex=1 ' + f'-interaction=nonstopmode {source}') + + +def _depfile(target: str) -> str: + """The command tail that turns this run's `.fls` into the depfile Ninja reads next time.""" + return (f'$run -m tools.ninja.fls $$(echo {target} | sed s/\\.pdf$$//).fls ' + f'{target} {target}.d') + + +def languages_of(volume: pathlib.Path) -> list[str]: + """ + The translations this volume has, from `languages/` on disk. + + **This is the whole point of the exercise.** Make matched any string here, so + `…/languages/uk/booklet.pdf` was a legal target for a volume with no Ukrainian; a language + that is not in this list simply has no edges and Ninja refuses it by name. + """ + root = volume / 'languages' + return sorted(child.name for child in root.iterdir() if child.is_dir()) if root.is_dir() else [] + + +def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: + """Rules once, then the edges of every scope, then the edge that rebuilds this file.""" + w = Writer(out) + w.comment('GENERATED by `python -m tools.ninja.generate`. Do not edit.') + w.comment('A spike. The Makefile still builds the same trees; see tools/ninja/generate.py.') + w.newline() + rules(w) + + totals = {'edges': 0, 'problems': 0, 'pictures': 0, 'languages': [], 'listed': set()} + needs = {'languages': set(), 'competitions': set()} + + # Two passes over the scopes: the first writes their edges into a buffer and collects what + # they share, the second puts the shared edges in front. Ninja does not care about order -- + # it reads the whole file before deciding anything -- but a person reading `build.ninja` + # does, and the core comes first in the Makefile too. + import io + body = io.StringIO() + inner = Writer(body) + inner.emitted = w.emitted # one map across both, or a clash across the split is missed + for scope in scopes: + # `:`; a bare scope is naboj's, which is what the spike started with. + module, _, path = scope.rpartition(':') + counts = (seminar_edges(repo, path, inner) if module == 'seminar' + else scholar_edges(repo, path, inner) if module == 'scholar' + else scope_edges(repo, path, inner)) + for key in ('edges', 'problems', 'pictures'): + totals[key] += counts[key] + totals['languages'] += counts['languages'] + totals['listed'] |= counts['listed'] + for key in ('languages', 'competitions'): + needs[key] |= counts.get('needs', {}).get(key, set()) + + totals['edges'] += core_edges(repo, w, needs) + w.out.write(body.getvalue()) + + # The names that are not files. `clean` and friends keep their `rm -rf`; `stale` and + # `prune` are `tools/prune.py`, which is not about this build system at all -- it exists + # because make kept intermediates it could not clean up after, and it still answers the + # question "what is in build/ that has no source". + w.comment('Housekeeping. `clean` removes `build/`, `distclean` also `render/` and `output/`; ' + '`stale` lists what has no source left and `prune` removes it.') + w.build('clean', 'phony_clean', None, variables={'paths': 'build'}) + w.build('distclean', 'phony_clean', None, variables={'paths': 'build render output'}) + w.build('stale', 'prune', None, variables={'flags': ''}) + w.build('prune', 'prune', None, variables={'flags': '--delete'}) + totals['edges'] += 4 + w.newline() + + w.comment('Rerun this generator when the set of files changes -- Ninja does it itself, ' + 'given the `generator` rule and something to notice the change by.') + w.comment('**The directories, not their contents.** A file edited inside one does not change ' + 'this graph; a problem or a translation added or removed does, and a directory\'s ' + 'mtime moves on exactly that event and no other. `naboj_problem_dirs` in ' + 'modules/naboj/module.mk:114-132 is the same trick for the same reason -- the ' + 'difference is that here it is needed in one place instead of on every document.') + w.build('build.ninja', 'regenerate', None, + implicit=['tools/ninja/generate.py', 'tools/ninja/writer.py', + *sorted(totals['listed'])], + variables={'scope': ' '.join(scopes)}) + totals['edges'] += 1 + return totals + + +def core_edges(repo: pathlib.Path, w: Writer, needs: dict) -> int: + r""" + The edges every scope shares, emitted once however many scopes there are. + + The i18n tables, and each competition's `.static/` tree. One `core.builder.i18n` invocation + writes both the `.tex` the booklet `\input`s and the `.yaml` every pandoc run reads as + `crossrefYaml` -- two outputs on one edge, where `Makefile:165` declares only the first and + leaves `core/templates/crossref.jyaml` a prerequisite of nothing at all. + """ + w.comment('==== core ' + '=' * 60) + edges = 0 + for lang in sorted(needs['languages']): + w.build(f'build/core/i18n/{lang}.tex', 'i18n', [f'core/i18n/{lang}.yaml'], + implicit=['core/templates/override.jtex', 'core/templates/crossref.jyaml'], + implicit_outputs=[f'build/core/i18n/{lang}.yaml'], + variables={'lang': lang}) + edges += 1 + for competition in sorted(needs['competitions']): + static = repo / 'source/naboj' / competition / '.static' + w.build(f'build/naboj/{competition}/copy-static', 'copy_static', None, + implicit=sorted(str(p.relative_to(repo)) for p in static.rglob('*') + if p.is_file()) if static.is_dir() else None, + variables={'src': f'source/naboj/{competition}'}) + edges += 1 + w.newline() + return edges + + +def scope_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict: + counts = {'edges': 0, 'languages': [], 'listed': set()} + + def listed(directory: pathlib.Path) -> pathlib.Path: + """Record a directory whose *listing* this graph depends on, and return it.""" + if directory.is_dir(): + counts['listed'].add(str(directory.relative_to(repo))) + return directory + + def build(*args, **kwargs): + counts['edges'] += 1 + return w.build(*args, **kwargs) + + competition, volume_id = scope.split('/') + volume = repo / 'source' / 'naboj' / scope + listed(volume / 'languages') + languages = languages_of(volume) + problems = sorted(p.parent for p in listed(volume / 'problems').glob('*/meta.yaml')) + + w.comment(f'==== {scope} ' + '=' * 60) + + # The i18n tables and the competition's static files are shared by every scope, so they are + # emitted once by `generate()`. Declaring them here instead gave Ninja `multiple rules + # generate build/core/i18n/sk.tex` the moment a second volume was added -- which is Ninja + # refusing an ambiguity make would have resolved silently by taking whichever rule it saw + # last. + static = f'build/naboj/{competition}/copy-static' + counts['needs'] = {'languages': set(languages), 'competitions': {competition}} + templates = sorted(str(q.relative_to(repo)) + for q in (repo / 'modules/naboj/templates').rglob('*.jtex')) + + # -- pictures -------------------------------------------------------------------------------- + w.comment('Pictures. A `.svg`, `.tikz` and `.gp` are Jinja templates first; the meta beside ' + 'them is the context, so a drawing can print the number the meta computes.') + pictures = [] + for problem in problems: + rel = listed(problem).relative_to(repo / 'source').as_posix() + meta = (problem / 'meta.yaml').resolve() + for extension in PICTURE_SOURCES: + for picture in sorted(problem.glob(f'*.{extension}')): + stem = f'{rel}/{picture.stem}' + _rendered(build, f'render/{stem}.{extension}', f'source/{stem}.{extension}', + implicit=[str(meta.relative_to(repo))], + variables={'lang': 'sk', 'meta': str(meta), + 'renderer': NABOJ_RENDERER}) + pictures.append(_picture_pdf(build, w, stem, extension, problem, repo)) + for extension in PICTURE_COPIES: + for picture in sorted(problem.glob(f'*.{extension}')): + stem = f'{rel}/{picture.stem}' + if _already_drawn(w, stem, extension): + continue + _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') + pictures.append(f'build/{stem}.{extension}') + w.newline() + + # -- fragments ------------------------------------------------------------------------------- + w.comment('One fragment per problem per language. A translated file lives inside the language ' + 'directory; a shared one sits beside the meta and is rendered once per language.') + fragments = {lang: {group: [] for group in NABOJ_GROUPS} for lang in languages} + for problem in problems: + rel = problem.relative_to(repo / 'source').as_posix() + meta = (problem / 'meta.yaml').resolve() + for lang in languages: + listed(problem / lang) + for name in TRANSLATED: + if not (problem / lang / f'{name}.md').is_file(): + continue + fragments[lang][_group(name)].append(_fragment( + build, f'source/{rel}/{lang}/{name}.md', f'{rel}/{lang}/{name}', lang, meta, + repo)) + for name in SHARED: + if not (problem / f'{name}.md').is_file(): + continue + fragments[lang][_group(name)].append(_fragment( + build, f'source/{rel}/{name}.md', f'{rel}/{lang}/{name}', lang, meta, repo)) + + # The standalone: one problem, one language, one PDF. **This is the editor's + # preview** -- `modules/naboj/editor.yaml`'s `preview:` names it and `/api/compile` + # builds nothing else -- so leaving it out of the graph took the editor's compile + # button with it, which is not slowness but looks like it from the outside. + unit = f'{rel.split("/", 1)[1]}/{lang}' # //problems// + mine = [f for group in fragments[lang].values() for f in group + if f.startswith(f'build/{rel}/{lang}/')] + build(f'build/naboj/{unit}/standalone.tex', 'standalone_tex', None, + implicit=[*templates, f'source/{rel}/meta.yaml'], + variables={'competition': competition, 'volume': volume_id, + 'problem': problem.name, 'lang': lang, + 'outdir': f'build/naboj/{unit}'}) + pdf = f'output/naboj/{unit}/standalone.pdf' + build(pdf, 'document_pdf', None, + implicit=[f'build/naboj/{unit}/standalone.tex', *mine, + *[p for p in pictures if p.startswith(f'build/{rel}/')], + f'build/core/i18n/{lang}.tex', static, 'core/latex/dgs.cls'], + implicit_outputs=[pdf.replace('.pdf', e) + for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], + variables={'tex': f'build/naboj/{unit}/standalone.tex'}) + w.newline() + + # -- documents ------------------------------------------------------------------------------- + w.comment('The language documents. One builder invocation writes all of them, which Ninja ' + 'takes as one edge with many outputs -- where make needs a `touch` stamp and a row ' + 'of empty-recipe rules, whose trap is that an empty recipe marks a target out of ' + 'date, runs nothing, and leaves the stale file in place.') + for lang in languages: + outdir = f'build/naboj/{scope}/languages/{lang}' + documents = [f'{outdir}/{name}.tex' for name in LANGUAGE_DOCUMENTS] + extra = [f'{outdir}/{name}.tex' for name in ('intro', 'evaluators') + if (volume / 'languages' / lang / f'{name}.jtex').is_file()] + build(documents + extra, 'language', None, + implicit=[*templates, static, + f'source/naboj/{scope}/meta.yaml', + f'source/naboj/{competition}/meta.yaml', + f'source/naboj/{scope}/languages/{lang}/meta.yaml', + f'source/naboj/{competition}/.static/i18n/{lang}.yaml', + *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)], + variables={'competition': competition, 'volume': volume_id, 'lang': lang, + 'outdir': outdir}) + + for name in LANGUAGE_PDFS: + tex = f'{outdir}/{name}.tex' + pdf = f'output/naboj/{scope}/languages/{lang}/{name}.pdf' + included = [f for group in NABOJ_DOCUMENT_GROUPS[name] + for f in fragments[lang][group]] + build(pdf, 'document_pdf', None, + implicit=[tex, *included, *pictures, + f'build/core/i18n/{lang}.tex', static, + 'core/latex/dgs.cls'], + implicit_outputs=[pdf.replace('.pdf', e) + for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], + variables={'tex': tex}) + # Folded for printing, and the cover two-up. Both take a finished PDF and rename it. + build(f'output/naboj/{scope}/languages/{lang}/booklet-print.pdf', 'pdfbook', + [f'output/naboj/{scope}/languages/{lang}/booklet.pdf']) + build(f'output/naboj/{scope}/languages/{lang}/cover-print.pdf', 'pdfjam_nup', + [f'output/naboj/{scope}/languages/{lang}/cover.pdf']) + + # `online.pdf` is burst into one PDF per page. The page count is known only after + # xelatex has run, so the pages are not declared and a stamp stands for them. + build(f'build/naboj/{scope}/languages/{lang}/online.burst', 'burst', + [f'output/naboj/{scope}/languages/{lang}/online.pdf']) + + # Everything this language has, as one name to ask for -- `output/naboj/%/languages/` + # in the Makefile, which had to be spelled out per language there to beat make's + # pattern-tie rules against the venue aggregate. Here it is just a phony edge. + w.build(f'output/naboj/{scope}/languages/{lang}/all', 'phony', + [f'output/naboj/{scope}/languages/{lang}/{name}.pdf' + for name in (*LANGUAGE_PDFS, 'booklet-print', 'cover-print')]) + counts['edges'] += 1 + counts['languages'].append(f'{scope}:{lang}') + w.newline() + + # -- the tearoff archive --------------------------------------------------------------- + if languages: + build(f'output/naboj/{scope}/languages/tearoffs.zip', 'tearoff_zip', + [f'output/naboj/{scope}/languages/{lang}/tearoff.pdf' for lang in languages]) + + # -- venues -------------------------------------------------------------------------------- + w.comment('Venues. One `venue.py` invocation writes `instructions.tex`, `answers-modulo.tex` ' + 'and, when the source is there, `instructions-inner.tex` for the venue\'s language.') + venues = sorted(child.name for child in listed(volume / 'venues').iterdir() + if child.is_dir()) if (volume / 'venues').is_dir() else [] + for venue in venues: + outdir = f'build/naboj/{scope}/venues/{venue}' + documents = [f'{outdir}/{name}.tex' for name in VENUE_DOCUMENTS] + build(documents, 'venue', None, + implicit=[*templates, static, + f'source/naboj/{scope}/venues/{venue}/meta.yaml', + f'source/naboj/{scope}/meta.yaml', + f'source/naboj/{competition}/meta.yaml', + *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)], + variables={'competition': competition, 'volume': volume_id, 'venue': venue, + 'outdir': outdir}) + for name in VENUE_DOCUMENTS: + pdf = f'output/naboj/{scope}/venues/{venue}/{name}.pdf' + build(pdf, 'document_pdf', None, + implicit=[f'{outdir}/{name}.tex', *pictures, static, 'core/latex/dgs.cls', + *[f'build/core/i18n/{l}.tex' for l in languages]], + implicit_outputs=[pdf.replace('.pdf', e) + for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], + variables={'tex': f'{outdir}/{name}.tex'}) + w.build(f'output/naboj/{scope}/venues/{venue}/all', 'phony', + [f'output/naboj/{scope}/venues/{venue}/{name}.pdf' for name in VENUE_DOCUMENTS]) + counts['edges'] += 1 + w.newline() + + # -- aggregates ---------------------------------------------------------------------------- + w.comment('The names a person types. Every one is a `phony` edge over a list computed here, ' + 'where make had to generate a rule per language and lean on stem length.') + w.build(f'output/naboj/{scope}/booklets', 'phony', + [f'output/naboj/{scope}/languages/{lang}/booklet.pdf' for lang in languages]) + w.build(f'output/naboj/{scope}/languages', 'phony', + [f'output/naboj/{scope}/languages/{lang}/all' for lang in languages] + + ([f'output/naboj/{scope}/languages/tearoffs.zip'] if languages else [])) + w.build(f'output/naboj/{scope}/venues', 'phony', + [f'output/naboj/{scope}/venues/{venue}/all' for venue in venues]) + w.build(f'output/naboj/{scope}/all', 'phony', + [f'output/naboj/{scope}/languages', f'output/naboj/{scope}/venues']) + counts['edges'] += 4 + w.newline() + + counts['problems'] = len(problems) + counts['pictures'] = len(pictures) + return counts + + +def seminar_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict: + """ + One seminar round: `FKS/42/1/1`. + + Much the smaller half of the build, and the shape is simpler in one way that matters -- + seminar is monolingual, so there is no language dimension at all and `pathlang` resolves to + `sk` everywhere. What it adds is `.py` listings: `(§ include('x.py') §)` inlines a program + sitting beside the solution, and `module.mk` copies the same file into `output/` for the + reader to download. + """ + counts = {'edges': 0, 'languages': [], 'listed': set()} + + def listed(directory: pathlib.Path) -> pathlib.Path: + if directory.is_dir(): + counts['listed'].add(str(directory.relative_to(repo))) + return directory + + def build(*args, **kwargs): + counts['edges'] += 1 + return w.build(*args, **kwargs) + + competition, volume_id, semester, round_id = scope.split('/') + root = repo / 'source' / 'seminar' / scope + problems = sorted(p.parent for p in listed(root).glob('*/meta.yaml')) + + w.comment(f'==== seminar/{scope} ' + '=' * 50) + + counts['needs'] = {'languages': {'sk'}, 'competitions': set()} + templates = sorted(str(p.relative_to(repo)) + for p in (repo / 'modules/seminar/templates').rglob('*.jtex')) + + fragments = {name: [] for name in SEMINAR_TARGETS} + pictures = [] + for problem in problems: + rel = listed(problem).relative_to(repo / 'source').as_posix() + meta = (problem / 'meta.yaml').resolve() + # the programs a solution lists, copied for the reader as well as inlined by `include()` + for program in sorted(problem.glob('*.py')): + _copied(build, f'output/{rel}/{program.name}', f'source/{rel}/{program.name}') + for extension in PICTURE_SOURCES: + for picture in sorted(problem.glob(f'*.{extension}')): + stem = f'{rel}/{picture.stem}' + _rendered(build, f'render/{stem}.{extension}', f'source/{stem}.{extension}', + implicit=[str(meta.relative_to(repo))], + variables={'lang': 'sk', 'meta': str(meta), + 'renderer': SEMINAR_RENDERER}) + pictures.append(_picture_pdf(build, w, stem, extension, problem, repo)) + for extension in PICTURE_COPIES: + for picture in sorted(problem.glob(f'*.{extension}')): + stem = f'{rel}/{picture.stem}' + if _already_drawn(w, stem, extension): + continue + _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') + pictures.append(f'build/{stem}.{extension}') + for name in SEMINAR_TARGETS: + if (problem / f'{name}.md').is_file(): + fragments[name].append(_fragment(build, f'source/{rel}/{name}.md', + f'{rel}/{name}', 'sk', meta, repo, + SEMINAR_RENDERER)) + + outdir = f'build/seminar/{scope}' + build([f'{outdir}/{name}.tex' for name in ROUND_DOCUMENTS], 'round', None, + implicit=[*templates, + f'source/seminar/{scope}/meta.yaml', + *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)], + variables={'competition': competition, 'volume': volume_id, 'semester': semester, + 'round': round_id, 'outdir': outdir}) + + for name in ROUND_PDFS: + pdf = f'output/seminar/{scope}/{name}.pdf' + included = [f for kind in SEMINAR_DOCUMENT_GROUPS[name] for f in fragments[kind]] + build(pdf, 'document_pdf', None, + implicit=[f'{outdir}/{name}.tex', *included, *pictures, + 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'], + implicit_outputs=[pdf.replace('.pdf', e) + for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], + variables={'tex': f'{outdir}/{name}.tex'}) + + w.build(f'output/seminar/{scope}/all', 'phony', + [f'output/seminar/{scope}/{name}.pdf' for name in ROUND_PDFS]) + counts['edges'] += 1 + w.newline() + + counts['problems'] = len(problems) + counts['pictures'] = len(pictures) + return counts + + +def scholar_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict: + """ + One scholar year: `TA1/2018`. + + The shape neither other module has: **two editable levels**. An issue (`handouts/02`) holds + its own `text.md` *and* a directory per problem, each with `problem.md` and `solution.md`, + and one builder invocation turns the lot into three documents (or two, for homework). + """ + counts = {'edges': 0, 'languages': [], 'listed': set()} + + def listed(directory: pathlib.Path) -> pathlib.Path: + if directory.is_dir(): + counts['listed'].add(str(directory.relative_to(repo))) + return directory + + def build(*args, **kwargs): + counts['edges'] += 1 + return w.build(*args, **kwargs) + + course, year = scope.split('/') + root = repo / 'source' / 'scholar' / scope + w.comment(f'==== scholar/{scope} ' + '=' * 50) + + # Scholar's `.static/` trees -- `source/scholar/{STA,TA1}/.static` -- get no + # `copy_static` edge and are a prerequisite of nothing, where every naboj document + # takes its competition's. Recorded rather than guessed at: scholar builds under + # neither system today, so a fix here could not be checked against anything. + counts['needs'] = {'languages': {'sk'}, 'competitions': set()} + templates = sorted(str(p.relative_to(repo)) + for p in (repo / 'modules/scholar/templates').rglob('*.jtex')) + + units, pictures = 0, [] + for kind, (builder, documents) in SCHOLAR_KINDS.items(): + if not (root / kind).is_dir(): + continue + for issue in sorted(i for i in listed(root / kind).iterdir() if i.is_dir()): + units += 1 + issue_rel = issue.relative_to(repo / 'source').as_posix() + # the issue's own text, then one directory per problem below it + places = [issue, *sorted(c for c in listed(issue).iterdir() if c.is_dir())] + fragments = [] + for place in places: + rel = place.relative_to(repo / 'source').as_posix() + meta = place / 'meta.yaml' + if not meta.is_file(): + continue + for extension in PICTURE_SOURCES: + for picture in sorted(place.glob(f'*.{extension}')): + stem = f'{rel}/{picture.stem}' + _rendered(build, f'render/{stem}.{extension}', + f'source/{stem}.{extension}', + implicit=[f'source/{rel}/meta.yaml'], + variables={'lang': 'sk', 'meta': str(meta.resolve()), + 'renderer': SCHOLAR_RENDERER}) + pictures.append(_picture_pdf(build, w, stem, extension, place, repo)) + for extension in PICTURE_COPIES: + for picture in sorted(place.glob(f'*.{extension}')): + stem = f'{rel}/{picture.stem}' + if _already_drawn(w, stem, extension): + continue + _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') + pictures.append(f'build/{stem}.{extension}') + for name in SCHOLAR_TARGETS: + if (place / f'{name}.md').is_file(): + fragments.append(_fragment(build, f'source/{rel}/{name}.md', + f'{rel}/{name}', 'sk', meta.resolve(), repo, + SCHOLAR_RENDERER)) + + outdir = f'build/{issue_rel}' + build([f'{outdir}/{name}.tex' for name in documents], 'scholar_issue', None, + implicit=[*templates, f'source/{issue_rel}/meta.yaml'], + variables={'builder': builder, 'course': course, 'year': year, + 'issue': issue.name, 'outdir': outdir}) + for name in documents: + pdf = f'output/{issue_rel}/{name}.pdf' + build(pdf, 'document_pdf', None, + implicit=[f'{outdir}/{name}.tex', *fragments, *pictures, + 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'], + implicit_outputs=[pdf.replace('.pdf', e) + for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], + variables={'tex': f'{outdir}/{name}.tex'}) + w.build(f'output/{issue_rel}/all', 'phony', + [f'output/{issue_rel}/{name}.pdf' for name in documents]) + counts['edges'] += 1 + + w.build(f'output/scholar/{scope}/{kind}', 'phony', + [f'output/scholar/{scope}/{kind}/{i.name}/all' + for i in sorted(x for x in (root / kind).iterdir() if x.is_dir())]) + counts['edges'] += 1 + w.newline() + + counts['problems'] = units + counts['pictures'] = len(pictures) + return counts + + +def _already_drawn(w: Writer, stem: str, extension: str) -> bool: + """ + Whether a drawing already produces this path, so the copy beside it must not. + + `seminar/FKS/35/1/2/02` holds both `svah.svg` and `svah.pdf`, and both want to become + `build/…/svah.pdf`. Make picked the SVG, silently, by rule order -- `build/%.pdf: render/%.svg` + at `Makefile:213` is tried before the `.pdf` copy at `:350`, and make takes the first rule + whose prerequisites can be made. Keep that answer, and say so rather than relying on the + order two loops happen to run in; Ninja would otherwise refuse the graph outright, which is + how this was found. + """ + return extension == 'pdf' and w.has(f'build/{stem}.pdf') + + +def _group(name: str) -> str: + """Which of the three per-language sets a fragment belongs to.""" + return next(group for group, names in NABOJ_GROUPS.items() if name in names) + + +def _fragment(build, source: str, stem: str, lang: str, meta: pathlib.Path, + repo: pathlib.Path, renderer: str | None = None) -> str: + """`source/…/x.md` → `render/…/x.md` → `build/…/x.tex`, the two steps every fragment takes.""" + _rendered(build, f'render/{stem}.md', source, + implicit=[str(meta.relative_to(repo))], + variables={'lang': lang, 'meta': str(meta), + 'renderer': renderer or NABOJ_RENDERER}) + build(f'build/{stem}.tex', 'pandoc_tex', [f'render/{stem}.md'], + implicit=[f'build/core/i18n/{lang}.yaml'], + variables={'lang': lang}) + return f'build/{stem}.tex' + + +#: What a template is called in the line the build prints. The jinja step serves four formats, +#: and the Makefile's single `jinja` macro called every one of them a "Markdown template" -- +#: true of the 161 fragments in a volume and not of its 30 pictures. +TEMPLATE_KINDS = {'md': 'Markdown', 'gp': 'gnuplot', 'svg': 'SVG', 'tikz': 'TikZ'} + + +def _rendered(build, target: str, source: str, **kwargs): + """A jinja edge that says which format it is rendering. See `_copied`.""" + kind = TEMPLATE_KINDS.get(pathlib.Path(source).suffix.lstrip('.'), 'template') + variables = {'kind': kind, **kwargs.pop('variables', {})} + return build(target, 'jinja', [source], variables=variables, **kwargs) + + +def _copied(build, target: str, source: str, **kwargs): + """ + A copy edge that says what kind of file it is copying, which is all the description wants. + + The Makefile spelled that out at each call -- `$(call _copy,PNG)`, `$(call _copy,SVG)` -- + and the suffix is where it came from every time, so it is taken from the path here. + """ + kind = pathlib.Path(source).suffix.lstrip('.').upper() + variables = {'kind': kind or 'source', **kwargs.pop('variables', {})} + return build(target, 'copy', [source], variables=variables, **kwargs) + + +def _picture_pdf(build, w: Writer, stem: str, extension: str, problem: pathlib.Path, + repo: pathlib.Path) -> str: + """The three routes a drawing takes to the `.pdf` the booklet includes.""" + if extension == 'svg': + build(f'build/{stem}.pdf', 'svg_pdf', [f'render/{stem}.svg']) + elif extension == 'tikz': + build(f'build/{stem}.tikz.tex', 'picture_tex', [f'render/{stem}.tikz'], + implicit=['core/templates/standalone.jtex'], variables={'lang': 'sk'}) + build(f'build/{stem}.pdf', 'tikz_pdf', [f'build/{stem}.tikz.tex']) + else: + data = sorted(str(p.relative_to(repo / 'source')) for p in problem.glob('*.dat')) + for name in data: + _copied(build, f'build/{name}', f'source/{name}') + _copied(build, f'build/{stem}.gp', f'render/{stem}.gp', + implicit=[f'build/{name}' for name in data]) + build(f'build/{stem}.pdf', 'gnuplot_pdf', [f'build/{stem}.gp']) + return f'build/{stem}.pdf' + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument('--scope', required=True, nargs='+', + help='one or more, e.g. phys/29 seminar:FKS/42/1/1; ' + '`all` for every scope of every module') + parser.add_argument('-o', '--output', type=pathlib.Path, default=pathlib.Path('build.ninja')) + parser.add_argument('--repo', type=pathlib.Path, default=pathlib.Path('.')) + args = parser.parse_args() + + repo = args.repo.resolve() + scopes = args.scope + if 'all' in scopes: + scopes = [s for s in scopes if s != 'all'] + scopes += sorted(str(p.parent.relative_to(repo / 'source/naboj')) + for p in (repo / 'source/naboj').glob('*/*/problems') + if (p.parent / 'languages').is_dir()) + scopes += sorted(f'seminar:{p.relative_to(repo / "source/seminar")}' + for p in (repo / 'source/seminar').glob('*/*/*/*') + if p.is_dir() and any(p.glob('*/meta.yaml'))) + scopes += sorted(f'scholar:{p.relative_to(repo / "source/scholar")}' + for p in (repo / 'source/scholar').glob('*/*') + if p.is_dir() and (p / 'handouts').is_dir() or (p / 'homework').is_dir()) + with args.output.open('w') as out: + counts = generate(repo, scopes, out) + print(f"{args.output}: {counts['edges']} edges, {len(scopes)} scopes, " + f"{counts['problems']} problems, {counts['pictures']} pictures, " + f"{len(counts['languages'])} scope-languages") + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/ninja/ink.py b/tools/ninja/ink.py new file mode 100644 index 00000000..cba45673 --- /dev/null +++ b/tools/ninja/ink.py @@ -0,0 +1,50 @@ +r""" +The Makefile's colours, for Ninja's `description` lines. + +Every recipe in `Makefile` opened with an `@echo -e` in four colours -- a blue sentence, the +file names in magenta, the format names in bold green -- and Ninja prints a rule's +`description` where make printed that echo, so the same sentences belong there. They are +written with `{a}`, `{f}`, `{x}` and `{d}` markers and passed through `described`: + + described('{a}[jinja] Rendering {x}Markdown template{a} {f}$in{a} to {f}$out') + +`$in` and `$out` are Ninja's, expanded when the edge runs; the braces are Python's, expanded +when the graph is written. The two never meet, which is why one string can carry both. + +**Plain SGR rather than `tput`.** The Makefile asked terminfo at parse time -- `$(shell tput +sgr0; tput bold; tput setaf 4)` -- which costs six subprocesses and, more to the point, gives +a different answer depending on `TERM` in the shell that happened to run `make`. The generator +is itself usually run *by* Ninja, where there is no terminal at all, so terminfo would answer +for nothing. These are the codes `tput` emits for every terminal this repository is built on, +minus the `\033(B` charset reset, which does nothing on a UTF-8 terminal. + +Colour is baked into `build.ninja` when it is generated, so `NO_COLOR` takes effect at the next +regeneration rather than the next build. Ninja 1.13's status line elides the *visible* text and +re-emits the escapes around it, so a long description truncated to the terminal's width still +ends in the right colour. +""" +import os + +#: `c_error` and `c_special` from the Makefile are deliberately absent. The first coloured its +#: three `Incorrect fall-through rule called on …` recipes, which exist because a pattern rule +#: can silently fail to apply -- the defect this build does not have, since a target either has +#: an edge or Ninja says `unknown target`. The second was defined and never used. +PALETTE = { + 'a': '\033[0m\033[1m\033[34m', # the sentence + 'f': '\033[0m\033[35m', # a file name + 'x': '\033[0m\033[1m\033[32m', # a format, or the kind of thing being built + 'd': '\033[0m\033[37m', # back to the default, which every line ends in +} +PLAIN = dict.fromkeys(PALETTE, '') + + +def palette() -> dict[str, str]: + """Honours `NO_COLOR`, and `TERM=dumb`, which is what a terminal says when it is not one.""" + if os.environ.get('NO_COLOR') or os.environ.get('TERM', '') in ('', 'dumb'): + return PLAIN + return PALETTE + + +def described(sentence: str) -> str: + """One `description`, coloured, ending in the default so nothing bleeds into the next.""" + return sentence.format(**palette()) + palette()['d'] diff --git a/tools/ninja/writer.py b/tools/ninja/writer.py new file mode 100644 index 00000000..4c370d8d --- /dev/null +++ b/tools/ninja/writer.py @@ -0,0 +1,110 @@ +r""" +A minimal `build.ninja` writer. + +Ninja's own `misc/ninja_syntax.py` is the canonical version of this and is about the same size; +it is vendored here rather than depended on because `ninja` is a *build-time* tool for this +repository, not a Python dependency, and `pyproject.toml` should not gain one for 60 lines. + +The only subtlety is escaping, and there are two different rules. In a **path** (an output or an +input) Ninja takes `$ ` for a space, `$:` for a colon and `$$` for a dollar. In a **value** (the +right-hand side of `command = …`) only `$` and the newline are special. Getting the two confused +is how a path with a space in it silently becomes two paths. +""" +import textwrap + + +def escape_path(word: str) -> str: + return word.replace('$ ', '$$ ').replace(' ', '$ ').replace(':', '$:') + + +def escape(value: str) -> str: + return value.replace('$', '$$') + + +class Writer: + def __init__(self, out, width: int = 100): + self.out = out + self.width = width + #: Every output written so far, to the edge that wrote it. Ninja refuses two rules for + #: one output -- `multiple rules generate …` -- and it is right to: make resolved the + #: same ambiguity silently by taking whichever rule it saw last, which is how + #: `Makefile:377` and `:397` came to be the same rule head with the second unreachable. + #: + #: Benign repetition is real here, though. Two gnuplot scripts in one directory share a + #: `.dat`, so the same copy edge is reached twice; that is the identical edge and is + #: skipped. A *different* edge for the same output is a generator bug and raises. + self.emitted: dict[str, str] = {} + + def comment(self, text: str) -> None: + for line in textwrap.wrap(text, self.width - 2, break_long_words=False, + break_on_hyphens=False): + self.out.write(f'# {line}\n') + + def newline(self) -> None: + self.out.write('\n') + + def variable(self, key: str, value, indent: int = 0) -> None: + if value is None: + return + if isinstance(value, (list, tuple)): + value = ' '.join(str(v) for v in value if v) + self.out.write(f"{' ' * indent}{key} = {value}\n") + + def pool(self, name: str, depth: int) -> None: + self.out.write(f'pool {name}\n') + self.variable('depth', depth, 1) + + KNOWN = ('description', 'depfile', 'deps', 'pool', 'generator', 'restat') + + def rule(self, name: str, command: str, **kwargs) -> None: + """ + A rule block takes only the bindings Ninja knows -- an arbitrary one is a parse error, + `unexpected variable 'kind'`, not something it ignores. So a `$kind` in a description + has to be bound by every edge that uses the rule, and the whitelist here is what turns + a misspelt keyword into an error at generation time rather than a silent omission. + """ + if unknown := set(kwargs) - set(self.KNOWN): + raise TypeError(f'rule {name}: unknown {", ".join(sorted(unknown))}') + self.out.write(f'rule {name}\n') + self.variable('command', command, 1) + for key in self.KNOWN: + if key in kwargs: + self.variable(key, kwargs[key], 1) + + def build(self, outputs, rule: str, inputs=None, *, implicit=None, order_only=None, + implicit_outputs=None, variables=None) -> list: + outputs = [escape_path(str(o)) for o in _listify(outputs)] + line = f"build {' '.join(outputs)}" + if implicit_outputs: + line += ' | ' + ' '.join(escape_path(str(o)) for o in _listify(implicit_outputs)) + line += f': {rule}' + for prefix, items in ((' ', inputs), (' | ', implicit), (' || ', order_only)): + items = [escape_path(str(i)) for i in _listify(items)] + if items: + line += prefix + ' '.join(items) + signature = line + ''.join(f'|{k}={v}' for k, v in sorted((variables or {}).items())) + first = outputs[0] + if (previous := self.emitted.get(first)) is not None: + if previous == signature: + return [] # the same edge reached twice; write it once + raise ValueError(f'two different edges produce {first}:\n {previous}\n {signature}') + for output in outputs: + self.emitted[output] = signature + + self.out.write(line + '\n') + for key, value in (variables or {}).items(): + self.variable(key, value, 1) + return outputs + + + def has(self, output: str) -> bool: + """Whether something already produces this path.""" + return escape_path(str(output)) in self.emitted + + +def _listify(value) -> list: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return sorted(value) if isinstance(value, set) else list(value) + return [value] diff --git a/tools/ninja/zip.py b/tools/ninja/zip.py new file mode 100644 index 00000000..d09fecdb --- /dev/null +++ b/tools/ninja/zip.py @@ -0,0 +1,44 @@ +r""" +The tearoff archive: one PDF per language, each named after its language. + +`output/…/languages//tearoff.pdf` is what the build produces, and what a venue wants is an +archive of those under the names `sk.pdf`, `en.pdf` and so on. The Makefile did it by laying a +symlink beside each PDF and running `zip --junk-paths` over the links +(`modules/naboj/module.mk:548-551`), which leaves the links behind permanently -- `core/audit/stale.py` +carries a note about having to `unlink` rather than follow them -- and which **appends**, so a +language removed from the volume stayed in the archive for ever. + +Writing the archive directly costs nothing and fixes both: no symlinks, and the file is replaced +rather than added to. +""" +import argparse +import pathlib +import sys +import zipfile + + +def language_of(pdf: pathlib.Path) -> str: + """`output/naboj/phys/29/languages/sk/tearoff.pdf` → `sk`, from the directory it sits in.""" + return pdf.parent.name + + +def write(archive: pathlib.Path, pdfs: list[pathlib.Path]) -> int: + archive.parent.mkdir(parents=True, exist_ok=True) + # `w`, not `a`: the archive is the set of languages that exist now. + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as out: + for pdf in sorted(pdfs): + out.write(pdf, f'{language_of(pdf)}.pdf') + return len(pdfs) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument('archive', type=pathlib.Path) + parser.add_argument('pdfs', type=pathlib.Path, nargs='+') + args = parser.parse_args() + print(f'{args.archive}: {write(args.archive, args.pdfs)} language(s)') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 8fbed705..fbcd90e6 100644 --- a/uv.lock +++ b/uv.lock @@ -61,6 +61,7 @@ dependencies = [ { name = "enschema" }, { name = "flask" }, { name = "jinja2" }, + { name = "ninja" }, { name = "numpy" }, { name = "pandoc-crossref" }, { name = "pandoc-minted" }, @@ -86,6 +87,7 @@ requires-dist = [ { name = "enschema", specifier = ">=0.1.5" }, { name = "flask", specifier = ">=3.1.3" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "ninja", specifier = ">=1.11" }, { name = "numpy", specifier = ">=2.5.1" }, { name = "pandoc-crossref", specifier = ">=0.1.1" }, { name = "pandoc-minted", specifier = ">=0.2.0" }, @@ -296,6 +298,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "ninja" +version = "1.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/92/410b7917d16ab54c04b05cc32b9284803671d91cf79d33be6009c28d4ea8/ninja-1.13.2.tar.gz", hash = "sha256:525bfa3fc88aa30a4467df270fd5be6f9fcae8061d54d4df74ea1dc5abd5a975", size = 243739, upload-time = "2026-08-30T15:49:51.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/b8/90a9518f2264637084d6199bc20c2f3fb97fefc4c474d277720150c3bd6d/ninja-1.13.2-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fd82e26c0706ad4ab88e5fdd26f3fab0a987a90f810160f6c322e752c6af298b", size = 306611, upload-time = "2026-08-30T15:49:28.267Z" }, + { url = "https://files.pythonhosted.org/packages/35/54/7368ce188625e39acc03ee362bb86cc9bfa6ad15e25c50889ae36e2889b3/ninja-1.13.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d775a5e43e9088f507a6250d57fcf5678eb31268c545feb5064ffeee33735622", size = 177180, upload-time = "2026-08-30T15:49:29.59Z" }, + { url = "https://files.pythonhosted.org/packages/80/1a/0b5601ece2a5de97253e7c7c442b70315333955593c2b55616fd17f1706f/ninja-1.13.2-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:81d95081c0ad7c95f67bf682220361ed2a32659d7861b4766b03433c58f22516", size = 199485, upload-time = "2026-08-30T15:49:30.72Z" }, + { url = "https://files.pythonhosted.org/packages/48/23/fcbe234a66966e35928c47b86336f92a7612db4781665f4e5f5fddef9630/ninja-1.13.2-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:227cbc3ae3e5e429692388103cae8c09451df086cd2d342dae0795af0d162547", size = 197676, upload-time = "2026-08-30T15:49:32.026Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/a6ca97ef0ff7bb8bdcb395ec65a716e65d7c1f40896c3afe0090bb3e1535/ninja-1.13.2-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1684c60d031c54c1d049541b64243c0c567dca5463dbd77682a8901780af293d", size = 187980, upload-time = "2026-08-30T15:49:33.372Z" }, + { url = "https://files.pythonhosted.org/packages/6e/53/ebfed7b689c338dd8ebeec9c0730c8d56821292f14e2536e5f3ef1a05744/ninja-1.13.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65a24341b5ac09fcadcc37082660be40a94174e51a937fabf6e2cae26225fa2c", size = 183365, upload-time = "2026-08-30T15:49:34.53Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d6/dcf06d7ab44ade992ae5aa1228feff317684b463a1bd47e8642b30ac922e/ninja-1.13.2-py3-none-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa3d2ae5706a2c4d1e93edc951d1c6cbb45107413c404f8fde1741239efbc9a0", size = 155089, upload-time = "2026-08-30T15:49:35.72Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6b/6513c09c33382b17c05b4349b8e81437b18680d0d7ec6fb8f7edc29adda1/ninja-1.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:919572cbc3f233261ecd41fe1f3efc9d44aa02464a4588867e06a8b4f6f416ea", size = 152149, upload-time = "2026-08-30T15:49:36.971Z" }, + { url = "https://files.pythonhosted.org/packages/4d/70/d59fa4261f5ce586f43d8716de1da33d813afc116f0bf9173bf4cdbfdb2a/ninja-1.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e083700470c02ca154a855ae6d692d03564f5064cae52e113896f9ccc078418", size = 525392, upload-time = "2026-08-30T15:49:38.136Z" }, + { url = "https://files.pythonhosted.org/packages/37/04/c8c2dc5b2f5fee79a1691d490256b178b7e1af97d56769117422ae8a23cc/ninja-1.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:59d71c3e15b6b6f3d903eb0c27285544e0747ca59925ada7037bb1af781ad4b3", size = 466268, upload-time = "2026-08-30T15:49:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/fce288647e53e96e0f0929a3c5b23986aae1b7101ea8889a3090237c5d13/ninja-1.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f90f84affc441e219f15fe52532806c1c9dbd22fb66c3addddce88a3deaabab7", size = 605088, upload-time = "2026-08-30T15:49:40.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/a2/d8eedd25d0ae80b9e874aea362013e67416877c8f732eb4d5e7c971fdb9c/ninja-1.13.2-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f687437fac460b27b7eadc99039b1163016fb4ba7276e2782a192d9f24ee0e", size = 610806, upload-time = "2026-08-30T15:49:42.168Z" }, + { url = "https://files.pythonhosted.org/packages/14/0f/696d96821fad1b5767fd311c1569dde8881a57412369bfe7b11bcbfde036/ninja-1.13.2-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:09de9ab04f7352f51570c73fd4913acb1e6c24be0a72cd8b80243d4d3ed04925", size = 533978, upload-time = "2026-08-30T15:49:43.451Z" }, + { url = "https://files.pythonhosted.org/packages/5d/69/28844ca579156776a202217a7cd66f60d06a0710a935e879bb89ce396ecc/ninja-1.13.2-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:6a87bf42b123abe2f37737300185f0a303a891899da85d73a3613ee80547e578", size = 653822, upload-time = "2026-08-30T15:49:44.724Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/c511f2952f94ab2966d60edd9c34e744ea32f2724b1184b62270bde55b3a/ninja-1.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:915bd482c4be41c75120fd67a22e0bb3f0fbb3bbc5f95b89787deadd59e27ef2", size = 544460, upload-time = "2026-08-30T15:49:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/79/e7/fb0e828e89ac77ef77183a0834f17c4108e66088732fed86a3cc3c776a7a/ninja-1.13.2-py3-none-win32.whl", hash = "sha256:792cadbb9decfd1f776d4d0a6930feb46d08302eb57c176bcf26b09de5748e9f", size = 270319, upload-time = "2026-08-30T15:49:47.942Z" }, + { url = "https://files.pythonhosted.org/packages/3f/dd/3766b5f4d32e8a9b97d195496b0b01fbbe2e1a41669dab0cd6492a6ce199/ninja-1.13.2-py3-none-win_amd64.whl", hash = "sha256:1293f4078278b70d0ee4b6cc8f3a9e030656c9b2f59909970343c4fe76070118", size = 311798, upload-time = "2026-08-30T15:49:49.351Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8d/59a31fa508070d042571d9d226b541a21000756817313f397da22287ad34/ninja-1.13.2-py3-none-win_arm64.whl", hash = "sha256:1db9852e528efa7702f5123969f86678663e46d57ff28ab13f5fd84d64a85fb1", size = 290620, upload-time = "2026-08-30T15:49:50.639Z" }, +] + [[package]] name = "numpy" version = "2.5.4" From bd0694415ee3d3e1e6348dc53d1e7d3a506de87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 03:51:33 +0000 Subject: [PATCH 06/28] The editor builds through Ninja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_make` becomes `run_build`, the capability probe looks for `ninja` instead of GNU make, and the launcher's argv follows. `ensure_graph` generates `build.ninja` when it is absent, which is the one state Ninja cannot recover from itself -- it keeps the graph current through the `generator` edge, but a first run after a clone has none, and `ninja: error: loading 'build.ninja'` names a file nobody has heard of. The probe gets simpler rather than merely renamed. `probe_make` had to reject a make that was not GNU, because BSD make accepts the invocation and then misparses `.SECONDEXPANSION`, which every rule in this repository leans on. There is one Ninja and no dialects, so finding it is the whole test. This is also what lets `/api/compile` work again: its target is `modules/naboj/editor.yaml`'s `preview:`, `output/…/standalone.pdf`, which was missing from the graph until the previous commit. Co-Authored-By: Claude Opus 5 --- core/tests/test_editor_capabilities.py | 22 +++++----- tools/editor/app.py | 56 +++++++++++++++++-------- tools/editor/capabilities.py | 57 +++++++++++++------------- 3 files changed, 80 insertions(+), 55 deletions(-) diff --git a/core/tests/test_editor_capabilities.py b/core/tests/test_editor_capabilities.py index 544a6149..5ee79fab 100644 --- a/core/tests/test_editor_capabilities.py +++ b/core/tests/test_editor_capabilities.py @@ -1,7 +1,7 @@ """ The editor's capability probe, and the TeX target it exposes. -Everything here runs without pandoc, xelatex or make: the probe's job is to *report* what is +Everything here runs without pandoc, xelatex or ninja: the probe's job is to *report* what is missing, so the tests feed it a fake environment rather than the machine's real one. That is also why these live outside `tools/` -- `pytest.ini` collects `core/tests/`, and a probe that only worked on a fully installed machine would be exactly the thing this change exists to remove. @@ -47,7 +47,7 @@ def test_a_module_with_no_render_rule_has_no_tex_rule(self): assert UnitKind(glob='*').tex == '' def test_something_that_is_not_a_render_target_is_refused(self): - """Better no TeX tab than a target make has no rule for.""" + """Better no TeX tab than a target the build graph has no edge for.""" assert UnitKind(glob='*', render='output/naboj/{unit}.pdf').tex == '' @pytest.mark.parametrize('descriptor', sorted(ROOT.glob('modules/*/editor.yaml'))) @@ -81,7 +81,7 @@ def fake_run(argv, *, stdin=None, timeout=30): return (0, f'/texmf/{argv[1]}\n') if argv[1] in present else (1, '') if tool not in present: return None - if tool == 'make': + if tool == 'ninja': return 0, 'GNU Make 4.4\n' if tool == 'pandoc': if stdin is None: # `pandoc --version` @@ -97,11 +97,11 @@ def tiers(self, payload): return {t['id']: t['ok'] for t in payload['tiers']} def test_markdown_alone_needs_no_pandoc_and_no_latex(self, monkeypatch): - payload = self._payload(monkeypatch, present={'make', 'uv'}) + payload = self._payload(monkeypatch, present={'ninja', 'uv'}) assert self.tiers(payload) == { 'markdown': True, 'tex': False, 'latex': False, 'pdf': False} - def test_no_make_takes_every_pipeline_tier_with_it(self, monkeypatch): + def test_no_ninja_takes_every_pipeline_tier_with_it(self, monkeypatch): payload = self._payload(monkeypatch, present={'pandoc', 'pandoc-crossref', 'xelatex'}) tiers = self.tiers(payload) assert tiers['markdown'] is False @@ -114,7 +114,7 @@ def test_the_macro_sweep_does_not_need_pandoc(self, monkeypatch): straight to xelatex, so a machine with TeX but no pandoc can still run it. """ payload = self._payload(monkeypatch, present={ - 'make', 'uv', 'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'}) + 'ninja', 'uv', 'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'}) tiers = self.tiers(payload) assert tiers['latex'] is True assert tiers['tex'] is False # no pandoc @@ -123,19 +123,19 @@ def test_the_macro_sweep_does_not_need_pandoc(self, monkeypatch): def test_xelatex_without_the_class_is_not_a_pdf(self, monkeypatch): """`which xelatex` is not enough: dgs.cls and MinionPro.sty decide it.""" payload = self._payload(monkeypatch, present={ - 'make', 'uv', 'pandoc', 'pandoc-crossref', 'xelatex', 'texfot'}) + 'ninja', 'uv', 'pandoc', 'pandoc-crossref', 'xelatex', 'texfot'}) assert self.tiers(payload)['tex'] is True assert self.tiers(payload)['pdf'] is False def test_a_missing_tier_carries_a_reason_and_a_command(self, monkeypatch): - payload = self._payload(monkeypatch, present={'make', 'uv'}) + payload = self._payload(monkeypatch, present={'ninja', 'uv'}) tex = next(t for t in payload['tiers'] if t['id'] == 'tex') assert tex['reason'] assert any('install-pandoc' in line for line in tex['install']) def test_the_install_hint_is_not_repeated(self, monkeypatch): """pandoc and its filter share one script; saying it twice reads like two steps.""" - payload = self._payload(monkeypatch, present={'make', 'uv'}) + payload = self._payload(monkeypatch, present={'ninja', 'uv'}) tex = next(t for t in payload['tiers'] if t['id'] == 'tex') assert len(tex['install']) == len(set(tex['install'])) @@ -143,12 +143,12 @@ def test_the_install_hint_is_not_repeated(self, monkeypatch): class TestTheLauncherDoesNotNeedUv: def test_uv_is_used_when_it_is_there(self, monkeypatch): monkeypatch.setattr(capabilities, '_which', lambda name: '/usr/bin/uv' if name == 'uv' else '') - assert capabilities.probe_python_launcher()['argv'] == ['uv', 'run', 'make'] + assert capabilities.probe_python_launcher()['argv'] == ['uv', 'run', 'ninja'] def test_without_uv_it_falls_back_to_the_running_interpreter(self, monkeypatch): monkeypatch.setattr(capabilities, '_which', lambda name: '') launcher = capabilities.probe_python_launcher() - assert launcher['argv'] == ['make'] + assert launcher['argv'] == ['ninja'] # the venv's bin directory, not the base interpreter it symlinks to assert launcher['path_prefix'] == str(pathlib.Path(sys.executable).parent) diff --git a/tools/editor/app.py b/tools/editor/app.py index e9c083a3..0f082d5f 100644 --- a/tools/editor/app.py +++ b/tools/editor/app.py @@ -367,16 +367,16 @@ def api_unit(module, unit): }) -# --- running make ----------------------------------------------------------- +# --- running the build ------------------------------------------------------ def make_env(): """ - How to invoke make, and in what environment. + How to invoke ninja, and in what environment. - `uv run` is here only to put the project's interpreter on PATH, because the Makefile's - recipes call a bare `python`. When uv is absent we call make directly and prepend the - directory of the interpreter running this app -- in a venv that is exactly where `python` - lives -- so an editor started from an activated venv works with no uv installed at all. + `uv run` is here only to put the project's interpreter on PATH, because the generated rules + call a bare `python`. When uv is absent we call ninja directly and prepend the directory of + the interpreter running this app -- in a venv that is exactly where both `python` and + `ninja` live -- so an editor started from an activated venv works with no uv installed. """ launcher = capabilities.capabilities(REPO_ROOT)["launcher"] env = None @@ -386,15 +386,39 @@ def make_env(): return launcher["argv"], env -def run_make(target, *, timeout=60): +#: The generated graph. Ninja keeps it current itself -- `tools/ninja/generate.py` is emitted as +#: a `generator` rule depending on the directories whose listings it was computed from -- but it +#: has to exist before Ninja can read it at all. +BUILD_NINJA = REPO_ROOT / 'build.ninja' + + +def ensure_graph(env): + """ + `build.ninja` present. Ninja regenerates it when it is stale; only its absence is ours. + + A first run after a clone has no graph, and `ninja: error: loading 'build.ninja'` names a + file nobody has heard of. One generator run costs about a second for the whole repository. + """ + if BUILD_NINJA.is_file(): + return None + result = subprocess.run( + [sys.executable, '-m', 'tools.ninja.generate', '--scope', 'all', '-o', str(BUILD_NINJA)], + cwd=REPO_ROOT, capture_output=True, text=True, timeout=300, env=env) + return None if result.returncode == 0 else result + + +def run_build(target, *, timeout=60): """ - Run make and return the same dict `make_result` does -- or say why we could not. + Run the build and return the same dict `make_result` does -- or say why we could not. - Without the guards, a machine with no make and no uv raises `FileNotFoundError` out of the + Without the guards, a machine with no ninja and no uv raises `FileNotFoundError` out of the route, Flask answers 500 with an HTML body, and the front end's `fetchJSON` reports a bare "HTTP 500", which tells nobody what to install. """ argv, env = make_env() + if (failed := ensure_graph(env)) is not None: + return refusal(target, 'the build graph could not be generated', + f'`tools.ninja.generate` failed:\n\n{failed.stderr.strip()}\n') try: result = subprocess.run( [*argv, target], cwd=REPO_ROOT, capture_output=True, text=True, @@ -407,7 +431,7 @@ def run_make(target, *, timeout=60): f"See install.md, or run\n\n uv run python tools/editor/capabilities.py\n") except subprocess.TimeoutExpired: return refusal(target, f"timed out after {timeout} s", - f"`make {target}` did not finish within {timeout} seconds.\n") + f"`ninja {target}` did not finish within {timeout} seconds.\n") return make_result(target, result) @@ -479,10 +503,10 @@ def tier_refusal(tier_id, target): def missing_meta_result(target, unit): """ - Every render rule takes the unit's `meta.yaml` as a prerequisite, so without one make cannot - build the chain at all and reports `No rule to make target`, naming the PDF rather than the - file that is actually missing. Say what is wrong instead -- an unconverted unit opens in the - editor precisely so that a meta.yaml can be written for it. + A unit with no `meta.yaml` is not in the build graph at all, so Ninja answers + `unknown target`, naming the PDF rather than the file that is actually missing. (Make said + `No rule to make target` for the same reason.) Say what is wrong instead -- an unconverted + unit opens in the editor precisely so that a meta.yaml can be written for it. """ return refusal( target, "meta.yaml does not exist", @@ -495,7 +519,7 @@ def missing_meta_result(target, unit): def build(unit, make_target, *, timeout=60): if not unit.meta_path.is_file(): return missing_meta_result(make_target, unit) - return run_make(make_target, timeout=timeout) + return run_build(make_target, timeout=timeout) # --- writing ---------------------------------------------------------------- @@ -1109,7 +1133,7 @@ def api_audit_build(module, scope): def run(make_target, timeout): with BUILD_LOCK: - result = run_make(make_target, timeout=timeout) + result = run_build(make_target, timeout=timeout) return {"returncode": result["returncode"], "log": (result["stdout"] or "") + (result["stderr"] or "")} diff --git a/tools/editor/capabilities.py b/tools/editor/capabilities.py index 4364be69..5c8d21c5 100644 --- a/tools/editor/capabilities.py +++ b/tools/editor/capabilities.py @@ -4,7 +4,8 @@ The pipeline is three stages and the editor's tabs are those three stages: `render/…md` is Jinja and needs nothing but Python; `build/…tex` adds pandoc; `output/…pdf` adds the whole TeX stack and the fonts. A machine that has only the first is still useful to a translator, and this -module is what lets the app know that rather than shelling out and reporting whatever make printed. +module is what lets the app know that rather than shelling out and reporting whatever the +build printed. Nothing here may raise because a tool is absent -- that is the entire point of it -- so every probe goes through `_run`, which swallows `FileNotFoundError` and a timeout and returns `None`. @@ -24,7 +25,7 @@ # One place for install text, quoting the repository's own scripts rather than inventing # commands that would drift from them. INSTALL = { - 'make': ['sudo apt install make'], + 'ninja': ['uv sync', 'sudo apt install ninja-build'], 'uv': ['curl -LsSf https://astral.sh/uv/install.sh | sh'], 'python': ['uv sync --dev', '# or activate the venv you installed into'], 'pandoc': ['./install-pandoc.sh', 'export PATH="$HOME/.local/bin:$PATH"'], @@ -84,44 +85,44 @@ def _run(argv, *, stdin=None, timeout=30): # --- individual probes ------------------------------------------------------- -def probe_make(): +def probe_ninja(): """ - BSD make accepts the invocation and then misparses `.SECONDEXPANSION`, which every rule in - this repository leans on, so finding *a* make is not enough. + Ninja, which the build runs through since `tools/ninja/generate.py` replaced the Makefile. + + Unlike make there is nothing to disambiguate -- there is one Ninja and no dialects -- so this + only has to find it. It is a project dependency (`pyproject.toml`), so `uv run ninja` always + has one; the probe is for an editor started outside uv. """ - path = _which('make') + path = _which('ninja') if not path: - return Tool('make', False) - result = _run(['make', '--version'], timeout=10) - first = result[1].splitlines()[0] if result and result[1] else '' - if 'GNU Make' not in first: - return Tool('make', False, path, first, 'found, but it is not GNU make') - return Tool('make', True, path, first) + return Tool('ninja', False) + result = _run(['ninja', '--version'], timeout=10) + return Tool('ninja', True, path, (result[1].strip() if result and result[1] else '')) def probe_python_launcher(): """ - How `run_make` should invoke make, and why `uv` is not required. + How `run_build` should invoke ninja, and why `uv` is not required. - `uv` is only there to put the project's interpreter on PATH for the Makefile's bare `python` - in `define jinja` and `define _pandoc`. This process *is* such an interpreter -- it imported - jinja2, yaml and all of core.audit to get here -- so when uv is absent we call make directly - with our own interpreter's directory prepended to PATH. In a venv that directory holds the - `python` the recipes call. + `uv` is only there to put the project's interpreter on PATH, because the generated rules call + a bare `python` exactly as the Makefile's recipes did. This process *is* such an interpreter + -- it imported jinja2, yaml and all of core.audit to get here -- so when uv is absent we call + ninja directly with our own interpreter's directory prepended to PATH. In a venv that + directory holds both the `python` the rules call and the `ninja` that calls them. """ # Deliberately NOT resolve()d: a venv's `bin/python` is a symlink to the base interpreter, # so resolving it lands in /usr/local/bin and loses the venv -- which is the one directory # that matters, because that is where the dependencies are importable from. bindir = str(Path(sys.executable).parent) if _which('uv'): - return {'argv': ['uv', 'run', 'make'], 'via': 'uv', 'path_prefix': '', + return {'argv': ['uv', 'run', 'ninja'], 'via': 'uv', 'path_prefix': '', 'note': ''} if (Path(bindir) / 'python').exists(): - return {'argv': ['make'], 'via': 'python', 'path_prefix': bindir, - 'note': f'uv is not installed; running make with {bindir} on PATH'} - return {'argv': ['make'], 'via': 'none', 'path_prefix': bindir, + return {'argv': ['ninja'], 'via': 'python', 'path_prefix': bindir, + 'note': f'uv is not installed; running ninja with {bindir} on PATH'} + return {'argv': ['ninja'], 'via': 'none', 'path_prefix': bindir, 'note': ('uv is not installed and there is no `python` beside this interpreter, so ' - "the Makefile's recipes will not find one")} + 'the build rules will not find one')} def probe_pandoc(): @@ -229,23 +230,23 @@ def capabilities(repo_root, *, refresh=False): return _CACHE['payload'] launcher = probe_python_launcher() - make = probe_make() + ninja = probe_ninja() pandoc, crossref = probe_pandoc() latex = probe_latex(repo_root) advisories = probe_advisories() python_ok = launcher['via'] != 'none' - floor = [make, Tool('python', python_ok, sys.executable, detail=launcher['note'])] + floor = [ninja, Tool('python', python_ok, sys.executable, detail=launcher['note'])] tools = floor + [pandoc, crossref] + latex - markdown = _tier('markdown', 'Rendered Markdown', tools, {'make', 'python'}) + markdown = _tier('markdown', 'Rendered Markdown', tools, {'ninja', 'python'}) # What it takes to compile a `.tex` -- no pandoc. The macro sweep needs exactly this and # nothing more: it writes its own probe document and hands it straight to xelatex. latex = _tier('latex', 'LaTeX', tools, {'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'}) - tex = _tier('tex', 'TeX', tools, {'make', 'python', 'pandoc', 'pandoc-crossref'}) + tex = _tier('tex', 'TeX', tools, {'ninja', 'python', 'pandoc', 'pandoc-crossref'}) pdf = _tier('pdf', 'PDF', tools, - {'make', 'python', 'pandoc', 'pandoc-crossref', + {'ninja', 'python', 'pandoc', 'pandoc-crossref', 'xelatex', 'texfot', 'dgs.cls', 'MinionPro.sty'}) # A tier is never usable when the one below it is not, whatever its own tools say. From 89f9c7853ecb40faf521676f4208cbce525afa56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 04:11:37 +0000 Subject: [PATCH 07/28] The capability probe looks beside the interpreter too Starting the editor the documented way -- `python tools/editor/app.py` from the root, no activated venv -- reported **markdown, tex and pdf all unusable** on a machine that builds all three. `_which('ninja')` searched `PATH` and nothing else, and Ninja is a project dependency, so the one the build runs is `.venv/bin/ninja`, which is on `PATH` only inside an activated venv. A tier is never usable when one below it is missing, so that single lookup took the whole pipeline down. The launcher already knew better: `probe_python_launcher` prepends exactly that directory when uv is absent, and returns `uv run ninja` when it is not. The probe and the thing it probes for disagreed. `_which` now falls back to the interpreter's own directory, which is the same directory and the same reason, and `probe_ninja` runs the path it found rather than the bare name -- the two are different questions once the venv is not activated. Verified against a running editor: all four tiers report true, `/api/render` and `/api/tex` build, `/api/compile` writes `standalone.pdf` in 3.9 s, and the audit's build checks run 15 of 15 targets across en, es and sk in 61 s. Co-Authored-By: Claude Opus 5 --- tools/editor/capabilities.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tools/editor/capabilities.py b/tools/editor/capabilities.py index 5c8d21c5..9d1a50f5 100644 --- a/tools/editor/capabilities.py +++ b/tools/editor/capabilities.py @@ -70,7 +70,17 @@ class Tier: def _which(name): - return shutil.which(name) or '' + """ + On `PATH`, or beside the interpreter running this app. + + The second half is not a convenience. `ninja` is a project dependency, so the one the build + actually uses is `.venv/bin/ninja`, and that directory is on `PATH` only inside an activated + venv -- `probe_python_launcher` prepends it for exactly this reason when uv is absent. + Looking only at `PATH`, an editor started as `python tools/editor/app.py` probed its way to + "no ninja", and since a tier is never usable when one below it is missing, all three + pipeline tiers went dark on a machine that builds perfectly. + """ + return shutil.which(name) or shutil.which(name, path=str(Path(sys.executable).parent)) or '' def _run(argv, *, stdin=None, timeout=30): @@ -90,13 +100,15 @@ def probe_ninja(): Ninja, which the build runs through since `tools/ninja/generate.py` replaced the Makefile. Unlike make there is nothing to disambiguate -- there is one Ninja and no dialects -- so this - only has to find it. It is a project dependency (`pyproject.toml`), so `uv run ninja` always - has one; the probe is for an editor started outside uv. + only has to find it, and `_which` is what knows where to look. + + It runs the path it found rather than the bare name, because the two are not the same + question: the venv's `ninja` answers `which` only when the venv is activated. """ path = _which('ninja') if not path: return Tool('ninja', False) - result = _run(['ninja', '--version'], timeout=10) + result = _run([path, '--version'], timeout=10) return Tool('ninja', True, path, (result[1].strip() if result and result[1] else '')) From 1a4fb30b502a7a3dd2af2ec7b3b468cfb6dd933b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 04:11:49 +0000 Subject: [PATCH 08/28] Finished the rename to Ninja `run_make` became `run_build` when the editor switched over and the rest of the names did not, so the module read half in one vocabulary and half in the other: `make_env`, `make_result`, twenty-odd `make_target` locals, and the docstrings around them. They are `build_env`, `build_result` and `build_target` now, and `core/audit/build.py`'s injected runner with them. Two of those were not cosmetic. `build_result` and `refusal` both wrote `"command": f"make {target}"` into the payload the editor displays, so every build the page reported named a command nobody had run -- and `app.js` printed "make reported success but wrote no fragment" in the preview pane for the same reason. Both say `ninja` now. The two mentions of make that remain are deliberate: `tex_target` explains what the Makefile's fall-through rule used to print for a `.gp.tex`, and `missing_meta_result` quotes `No rule to make target` beside Ninja's `unknown target`, because both name the PDF rather than the meta.yaml that is actually missing. Co-Authored-By: Claude Opus 5 --- .claude/skills/dgs-editor/SKILL.md | 8 +-- core/audit/build.py | 13 ++-- tools/editor/app.py | 96 +++++++++++++++--------------- tools/editor/descriptors.py | 4 +- tools/editor/static/app.js | 21 +++---- tools/editor/static/audit.js | 2 +- 6 files changed, 74 insertions(+), 70 deletions(-) diff --git a/.claude/skills/dgs-editor/SKILL.md b/.claude/skills/dgs-editor/SKILL.md index d66a5123..ca391676 100644 --- a/.claude/skills/dgs-editor/SKILL.md +++ b/.claude/skills/dgs-editor/SKILL.md @@ -40,7 +40,7 @@ a reference key and `sk:` is a language — so neither gets one. `POST /api/evaluate` does the work, in-process and writing nothing. It reads the **buffers** out of the request rather than the files, because the whole point is to answer for the text on screen, which is usually unsaved; and it shares `build_render_context` and `render_twice` with -`core/builder/renderer.py`, so it cannot answer differently from what `make` would write — +`core/builder/renderer.py`, so it cannot answer differently from what the build would write — including whether an `eq:` display carries its `{#eq:…}` label, which depends on the open tab through the module's `equation_numbering`. A test asserts that agreement rather than trusting it. @@ -55,12 +55,12 @@ in the pane and the rest are map lookups; a keystroke in either pane, a differen different problem throws the lot away. **It degrades rather than failing.** The three output tabs are the pipeline's three stages -- -Rendered Markdown is Jinja and needs only make, TeX adds pandoc, the PDF adds all of TeX Live and -the fonts -- and `tools/editor/capabilities.py` probes for each at startup. A stage this machine +Rendered Markdown is Jinja and needs only Ninja, TeX adds pandoc, the PDF adds all of TeX Live +and the fonts -- and `tools/editor/capabilities.py` probes for each at startup. A stage this machine cannot do is greyed, and the pane behind it says what is missing and the command that fixes it, so a translator with no TeX Live can still check their own rendering. `install.md` has the tiers; `uv run python tools/editor/capabilities.py` prints the same report. `uv` is not required -- -without it the app calls `make` directly with its own interpreter's directory on `PATH`. +without it the app calls `ninja` directly with its own interpreter's directory on `PATH`. It must run from the root, and it `chdir`s there itself: several `core` modules open their data by a repository-relative path, so `core/i18n` fails to import from anywhere diff --git a/core/audit/build.py b/core/audit/build.py index 8b815362..f84db5cf 100644 --- a/core/audit/build.py +++ b/core/audit/build.py @@ -2,8 +2,8 @@ The audit checks that cost minutes rather than milliseconds: does it render, does it compile. Kept apart from `core.audit.checks` because everything there is a file read and a regex, and this -shells out to make. Run one scope at a time, when asked, and cache the answer -- the source-only -layer can afford to recompute on every request and this cannot. +shells out to the build. Run one scope at a time, when asked, and cache the answer -- the +source-only layer can afford to recompute on every request and this cannot. """ import json import re @@ -56,14 +56,15 @@ def run_targets(repo_root: Path, module: str, scope: str, languages, targets, *, """ Build every target of every language, collecting what came back. - `run` is injected rather than called directly so the Flask app can pass its own make runner -- - the one that holds `BUILD_LOCK` and strips ANSI -- and so this is testable without a toolchain. + `run` is injected rather than called directly so the Flask app can pass its own build + runner -- the one that holds `BUILD_LOCK` and strips ANSI -- and so this is testable without + a toolchain. """ results = [] for language in languages: for target in targets: - make_target = f"output/{module}/{scope}/languages/{language}/{target}.pdf" - outcome = run(make_target, timeout=timeout) + build_target = f"output/{module}/{scope}/languages/{language}/{target}.pdf" + outcome = run(build_target, timeout=timeout) log = RE_ANSI.sub('', outcome.get('log', '')) overfulls = sorted( ({'box': m['box'], 'size': float(m['size'])} for m in RE_OVERFULL.finditer(log)), diff --git a/tools/editor/app.py b/tools/editor/app.py index 0f082d5f..a0f56a25 100644 --- a/tools/editor/app.py +++ b/tools/editor/app.py @@ -184,7 +184,7 @@ def existing_targets(self): def meta_path(self): return self.path / "meta.yaml" - # -- make targets -------------------------------------------------------- + # -- build targets ------------------------------------------------------- def _format(self, template): parent = self.name.rsplit("/", 1)[0] if "/" in self.name else "" @@ -193,7 +193,8 @@ def _format(self, template): def render_target(self, target): """ - The make target that renders this file. A gnuplot script keeps its own name and extension + The build target that renders this file. A gnuplot script keeps its own name and + extension (`render//%.gp`); a declared target is always `.md`. """ if self.is_aux(target): @@ -205,11 +206,12 @@ def render_path(self, target): def tex_target(self, target): """ - The make target that converts this file to TeX, or None when there is not one. + The build target that converts this file to TeX, or None when there is not one. An auxiliary file has none on purpose: a `.gp` becomes a figure through gnuplot, never a - `.tex`, and asking for `build/.../time.gp.tex` would hit the Makefile's fall-through rule - and print `Incorrect fall-through rule called`. + `.tex`, and `build/.../time.gp.tex` is a target the graph does not declare -- under make + it reached the fall-through rule that printed `Incorrect fall-through rule called`, + which is the shape of defect a generated graph makes impossible. """ if self.is_aux(target) or not self.kind.tex: return None @@ -221,7 +223,7 @@ def tex_path(self, target): def html_target(self, target): """ - The make target that converts this file to an HTML fragment, or None when there is not + The build target that converts this file to an HTML fragment, or None when there is not one. Auxiliary files are excluded for the reason `tex_target` excludes them: a `.gp` becomes a figure, never a page. """ @@ -369,7 +371,7 @@ def api_unit(module, unit): # --- running the build ------------------------------------------------------ -def make_env(): +def build_env(): """ How to invoke ninja, and in what environment. @@ -409,13 +411,13 @@ def ensure_graph(env): def run_build(target, *, timeout=60): """ - Run the build and return the same dict `make_result` does -- or say why we could not. + Run the build and return the same dict `build_result` does -- or say why we could not. Without the guards, a machine with no ninja and no uv raises `FileNotFoundError` out of the route, Flask answers 500 with an HTML body, and the front end's `fetchJSON` reports a bare "HTTP 500", which tells nobody what to install. """ - argv, env = make_env() + argv, env = build_env() if (failed := ensure_graph(env)) is not None: return refusal(target, 'the build graph could not be generated', f'`tools.ninja.generate` failed:\n\n{failed.stderr.strip()}\n') @@ -432,7 +434,7 @@ def run_build(target, *, timeout=60): except subprocess.TimeoutExpired: return refusal(target, f"timed out after {timeout} s", f"`ninja {target}` did not finish within {timeout} seconds.\n") - return make_result(target, result) + return build_result(target, result) # XeLaTeX runs with `-file-line-error`, so its errors arrive as `file.tex:12: message`. @@ -469,12 +471,12 @@ def summarise_failure(text): return None -def make_result(target, result): +def build_result(target, result): combined = ANSI_RE.sub("", result.stdout + result.stderr) return { "ok": result.returncode == 0, "returncode": result.returncode, - "command": f"make {target}", + "command": f"ninja {target}", "summary": None if result.returncode == 0 else summarise_failure(combined), "stdout": ANSI_RE.sub("", result.stdout), "stderr": ANSI_RE.sub("", result.stderr), @@ -482,8 +484,8 @@ def make_result(target, result): def refusal(target, summary, explanation): - """A failure of ours rather than make's: no exit code, and a reason worth reading.""" - return {"ok": False, "returncode": None, "command": f"make {target}", + """A failure of ours rather than the build's: no exit code, and a reason worth reading.""" + return {"ok": False, "returncode": None, "command": f"ninja {target}", "summary": summary, "stdout": explanation, "stderr": ""} @@ -516,10 +518,10 @@ def missing_meta_result(target, unit): ) -def build(unit, make_target, *, timeout=60): +def build(unit, build_target, *, timeout=60): if not unit.meta_path.is_file(): - return missing_meta_result(make_target, unit) - return run_build(make_target, timeout=timeout) + return missing_meta_result(build_target, unit) + return run_build(build_target, timeout=timeout) # --- writing ---------------------------------------------------------------- @@ -576,9 +578,9 @@ def evaluation_context(unit, target, meta_text): """ The context a tag in this file would be rendered against, built from the buffer as it stands. - `build_render_context` is the renderer's own, so what the popup shows and what `make` writes - come out of one definition. The meta is parsed from `meta_text` rather than read from disk - because the whole point is to answer for the text on screen, which is usually unsaved. + `build_render_context` is the renderer's own, so what the popup shows and what the build + writes come out of one definition. The meta is parsed from `meta_text` rather than read from + disk because the whole point is to answer for the text on screen, which is usually unsaved. """ cli = module_cli(unit.module) language = unit.lang or DEFAULT_LANGUAGE @@ -626,8 +628,8 @@ def api_evaluate(): The editor asks this to show, in the reference popup, what the tag under the pointer actually produces here -- the number, the equation, the word -- rather than the generic example the - reference table carries. So it is in-process and reads the buffers out of the request: a make - target would write the file, take seconds, and answer for the last save. + reference table carries. So it is in-process and reads the buffers out of the request: a + build target would write the file, take seconds, and answer for the last save. A fragment that fails is reported as a failure of its own; only a context that cannot be built at all fails the request, because that is one answer for every fragment and it is the @@ -695,12 +697,12 @@ def api_render(): with BUILD_LOCK: write_files(unit, body.get("files") or {}) - make_target = unit.render_target(target) - response = build(unit, make_target) + build_target = unit.render_target(target) + response = build(unit, build_target) # The path as well as the text, for the reason `/api/html` returns `html_target`: when - # make succeeds and the file is not there, only the path tells you whether the rule wrote - # somewhere else or did not write at all. - response["render_target"] = make_target + # the build succeeds and the file is not there, only the path tells you whether the edge + # wrote somewhere else or did not write at all. + response["render_target"] = build_target response["rendered_md"] = ( read_if_exists(unit.render_path(target)) if response["ok"] else None ) @@ -712,27 +714,27 @@ def api_tex(): """ The TeX pandoc produces for one file. - Its make target takes the rendered Markdown as its prerequisite, so asking for TeX exercises - the Markdown stage in the same call -- and `build()` keeps the `missing_meta_result` answer - for a unit that has no meta.yaml yet. + Its build target takes the rendered Markdown as its prerequisite, so asking for TeX + exercises the Markdown stage in the same call -- and `build()` keeps the + `missing_meta_result` answer for a unit that has no meta.yaml yet. """ body = request.get_json(force=True) unit = unit_from_body(body) target = body.get("target") unit.source_path(target) # validates - make_target = unit.tex_target(target) + build_target = unit.tex_target(target) with BUILD_LOCK: write_files(unit, body.get("files") or {}) - if make_target is None: + if build_target is None: response = refusal( "(none)", "no TeX for this file", f"{target} is not converted to TeX -- an auxiliary file becomes a figure, " f"and {unit.module.label} may declare no TeX rule.\n") elif not capabilities.tier(REPO_ROOT, "tex").ok: - response = tier_refusal("tex", make_target) + response = tier_refusal("tex", build_target) else: - response = build(unit, make_target) + response = build(unit, build_target) response["tex"] = ( read_if_exists(unit.tex_path(target)) if response["ok"] else None ) @@ -757,26 +759,26 @@ def api_html(): unit = unit_from_body(body) target = body.get("target") unit.source_path(target) # validates - make_target = unit.html_target(target) + build_target = unit.html_target(target) with BUILD_LOCK: write_files(unit, body.get("files") or {}) - if make_target is None: + if build_target is None: response = refusal( "(none)", "no HTML for this file", f"{target} is not converted to HTML -- an auxiliary file becomes a figure, " f"and {unit.module.label} may declare no render rule.\n") elif not capabilities.tier(REPO_ROOT, "markdown").ok: - response = tier_refusal("markdown", make_target) + response = tier_refusal("markdown", build_target) else: - response = build(unit, make_target) + response = build(unit, build_target) response["html"] = ( read_if_exists(unit.html_path(target)) if response["ok"] else None ) # Where the fragment lives, so the pane can point a `` at its directory and let the # relative pictures resolve. Named here rather than reconstructed in the front end, which # would be a second copy of the path rule. - response["html_target"] = make_target + response["html_target"] = build_target return jsonify(response) @@ -785,14 +787,14 @@ def api_compile(): """Write every buffer, then build whichever document previews this unit.""" body = request.get_json(force=True) unit = unit_from_body(body) - make_target = unit.preview_target() + build_target = unit.preview_target() with BUILD_LOCK: write_files(unit, body.get("files") or {}) - if make_target is not None and not capabilities.tier(REPO_ROOT, "pdf").ok: - response = tier_refusal("pdf", make_target) - elif make_target is None: + if build_target is not None and not capabilities.tier(REPO_ROOT, "pdf").ok: + response = tier_refusal("pdf", build_target) + elif build_target is None: response = refusal( "(none)", "no preview for this module", f"{unit.module.label} declares no preview document in " @@ -800,8 +802,8 @@ def api_compile(): ) else: # A whole handout or round takes longer than a single problem. - response = build(unit, make_target, timeout=600) - built = REPO_ROOT / make_target + response = build(unit, build_target, timeout=600) + built = REPO_ROOT / build_target if response["ok"] and built.is_file(): cached = unit.cached_pdf() cached.parent.mkdir(parents=True, exist_ok=True) @@ -1131,9 +1133,9 @@ def api_audit_build(module, scope): resolved, units = scope_units(module, scope) sources = run_scope_audit(resolved, scope, units).sources - def run(make_target, timeout): + def run(build_target, timeout): with BUILD_LOCK: - result = run_build(make_target, timeout=timeout) + result = run_build(build_target, timeout=timeout) return {"returncode": result["returncode"], "log": (result["stdout"] or "") + (result["stderr"] or "")} diff --git a/tools/editor/descriptors.py b/tools/editor/descriptors.py index b2846669..11517013 100644 --- a/tools/editor/descriptors.py +++ b/tools/editor/descriptors.py @@ -33,7 +33,7 @@ class UnitKind: @property def tex(self): """ - The make target that converts this file to TeX. + The build target that converts this file to TeX. Derived from `render` rather than written out per module, because the relation is the build system's own: every `.tex` rule in every `module.mk` reads @@ -50,7 +50,7 @@ def tex(self): @property def html(self): """ - The make target that converts this file to an HTML fragment. + The build target that converts this file to an HTML fragment. Derived from `render` exactly as `tex` is, and for the same reason: the root Makefile has one `output/%.html: render/%.md` for every module, so the relation belongs to the build diff --git a/tools/editor/static/app.js b/tools/editor/static/app.js index 4e411583..abc95e6d 100644 --- a/tools/editor/static/app.js +++ b/tools/editor/static/app.js @@ -912,7 +912,7 @@ function captureEditors() { /** * Only dirty buffers go over the wire. Writing every file on every compile would bump - * their mtimes and make `make` redo the whole problem each time instead of just the part + * their mtimes and make the build redo the whole problem each time instead of just the part * that changed. */ function dirtyFiles() { @@ -1104,15 +1104,16 @@ async function doCompile() { * MathJax typesets it, with `static/mathjax-dgs.js` ahead of it supplying the siunitx macros * MathJax has none of. `allow-scripts` is therefore required, and is given *without* * `allow-same-origin`: the frame runs on an opaque origin and cannot reach back into the editor. */ -// What the pane shows when make succeeded and the fragment is not where we looked for it. +// What the pane shows when the build succeeded and the fragment is not where we looked. // Without this the body is empty, the iframe is white and the status still reads `HTML OK` -- // a success message over a blank page, which reads as the preview being broken rather than as // a build that did not produce what it claimed. The two paths that reach it are a target whose -// rule is satisfied without writing the file, and a `html_target` that disagrees with where make -// put it; naming the path is what tells them apart. +// edge is satisfied without writing the file, and a `html_target` that disagrees with where +// the build put it; naming the path is what tells them apart. const missingFragment = (target) => `

` + - `make reported success but wrote no fragment at
${escapeForHtml(target ?? "(no target)")}

`; + `the build reported success but wrote no fragment at
` + + `${escapeForHtml(target ?? "(no target)")}

`; // An empty fragment is not that, and must not be reported as a fault: `28/john-doe`'s answer.md // exists and is deliberately empty, and so is the HTML pandoc makes of it. @@ -1120,10 +1121,10 @@ const emptyFragment = `

` + `the source is empty, so the fragment is too

`; -/* The pane while make is running, and the pane when the call failed. +/* The pane while the build is running, and the pane when the call failed. * * Both exist because the iframe keeps whatever it last held until something replaces it, and - * `make` can take seconds: a click answered by the previous problem's page -- or, on the first + * a build can take seconds: a click answered by the previous problem's page -- or, on the first * preview of a session, by nothing at all -- reads as the preview being broken, which is the * one reading the pane must never invite. It is the same reason `missingFragment` exists. */ const frameDocument = (body) => @@ -1138,7 +1139,7 @@ async function doHtml() { const frame = el("html-view"); switchOutputTab("html"); // Only if the wait is long enough to be noticed: a conversion that answers in 80 ms should - // not flash a message, and one that takes four seconds of `make` must not look like an + // not flash a message, and one that takes four seconds of building must not look like an // empty pane. const busy = setTimeout(() => { frame.srcdoc = frameDocument(`converting…`); @@ -1229,11 +1230,11 @@ async function doRender() { markSaved(); if (body.ok) { - // `rendered_md` is null when make succeeded and the file is not where we looked for it -- + // `rendered_md` is null when the build succeeded and the file is not where we looked -- // the Markdown half of what `missingFragment` says for the HTML pane. Painting that as an // empty block under `Rendered OK` is the one reading to avoid. code.innerHTML = body.rendered_md == null - ? `make reported success but wrote no rendered Markdown at\n` + ? `the build reported success but wrote no rendered Markdown at\n` + `${escapeForHtml(body.render_target ?? "(no target)")}` : highlight(body.rendered_md, "dgs-md"); out.classList.toggle("error", body.rendered_md == null); diff --git a/tools/editor/static/audit.js b/tools/editor/static/audit.js index 9a584c41..5fa01bfc 100644 --- a/tools/editor/static/audit.js +++ b/tools/editor/static/audit.js @@ -812,7 +812,7 @@ function wireErrorReporting() { } /** - * Both buttons here shell out: the build checks run make all the way to a PDF, and the macro + * Both buttons here shell out: the build checks go all the way to a PDF, and the macro * sweep needs xelatex. The source-only checks -- which is every other thing on this page -- need * no toolchain at all, so the page stays fully useful on a machine that can do neither. */ From 8de823a4225ee53057d0fd3aeb1b739e930bfe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 05:49:03 +0000 Subject: [PATCH 09/28] The web path, in the graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Markdown file was rendered once and converted once, to TeX. The second writer was missing, and with it everything downstream of it: the HTML fragments, the pictures in the format a browser wants, and the pages those two assemble into. `ninja output/naboj/phys/29/problems/chopper/sk/index.html` answered `unknown target`, which is the honest version of what make said -- its own `output/%.html` has been an XFAIL fall-through since the per-module rules landed. Four rules and one helper each side of them: - `pandoc_html` hangs off the same `render/…md` as `pandoc_tex`. One Jinja stage, two writers, which is the point: a second route to the web would be a second chance for the page and the booklet to disagree about what the problem says. - `pdf_svg` and `gnuplot_svg` give every drawing its web twin. There is no separate web picture -- a TikZ drawing and a PDF copied in are converted from the artefact the booklet prints, and a plot is drawn a second time by gnuplot, which is one step shorter and keeps the PDF parser out of the chain for the files that provoked the conversion bug. A hand-drawn `.svg` is copied. - `page` runs `core.html.page`, which is the `output/` step: fragments in, one `index.html` with its pictures beside it and its maths typeset. `_page` takes the module rather than inferring it from the path, because where a fragment lives is the module's to say and `page.py` reads the same fact out of `modules//editor.yaml`: Náboj nests a directory per language inside a problem, the other two do not. `_beside` matches the unit's directory exactly rather than by prefix -- scholar's issue directory contains its problems, and a prefix would hand an issue's page every drawing below it. New names: `output/naboj///languages//pages`, and `pages` over all of a volume's languages; `output/seminar//pages`. Not folded into `all`, which is the Makefile's reading too -- the documents are what a volume is run from and the pages are a second output of the same sources. Scholar gets the fragments and no pages, and that asymmetry is deliberate: `modules/scholar/editor.yaml` says `targets: [text]` where 28 of its issues hold a `content.md`, so `page.py` would find nothing to assemble and the edge could only ever fail. The fragments are still right whatever the descriptor calls them, and that is what the editor's HTML tab shows. Measured on `phys/29`: 1774 edges against 1507, 118 of 120 pages in 64 s warm, and the no-op decision still 0.006 s. The two that do not build are `chopper`, whose `freq.gp` still opens with `set terminal pdf size 6, 1.5` -- the pre-`@gpterm` spelling, which overrides the terminal the build supplies and makes gnuplot write a PDF into a file named `.svg`. The sniff in `gnuplot_svg` is what says so rather than leaving a blank figure on the page. Thirteen scripts across the repository are in that state, `FKS/42/1/1/03`'s `blocks:` preamble among them; each wants `set terminal @gpterm size W*gppt, H*gppt` and each changes the printed figure's typeface when it gets it, so none is touched here. Co-Authored-By: Claude Opus 5 --- core/tests/test_ninja_graph.py | 143 +++++++++++++++++++++ tools/ninja/generate.py | 222 +++++++++++++++++++++++++++++---- 2 files changed, 340 insertions(+), 25 deletions(-) create mode 100644 core/tests/test_ninja_graph.py diff --git a/core/tests/test_ninja_graph.py b/core/tests/test_ninja_graph.py new file mode 100644 index 00000000..655ddf8f --- /dev/null +++ b/core/tests/test_ninja_graph.py @@ -0,0 +1,143 @@ +r""" +The HTML half of the generated graph, over the helpers that write it. + +These are the pure parts of `tools/ninja/generate.py` -- the ones that decide *which* edge a +file takes -- so they can be exercised with a `Writer` over a `StringIO` and no source tree, +which is what `core/tests/` is allowed to assume. The edges themselves are checked by building +a volume and diffing against make; what is pinned here is the shape, because every one of these +choices has a wrong answer that produces a plausible-looking graph: + +- a fragment that goes to only one writer, so the web is built from something else than print; +- a web picture that is drawn or converted the wrong way, which is a hairline turned into a + heavy frame (`dvisvgm`) or three blank figures (a `.gp` setting its own terminal); +- a page that looks for its fragments one directory off, which for Náboj is the difference + between a language and the problem above it. +""" +import io +import pathlib + +import pytest + +from tools.ninja.generate import _beside, _fragment, _page, _picture_svg +from tools.ninja.writer import Writer + +REPO = pathlib.Path('/repo') +META = REPO / 'source/naboj/phys/29/problems/drenched/meta.yaml' + + +@pytest.fixture +def writer(): + return Writer(io.StringIO()) + + +def written(writer) -> str: + return writer.out.getvalue() + + +class TestAFragmentGoesToBothWriters: + """ + One Jinja stage, two writers. A second route to the web would be a second chance for the + page and the booklet to disagree about what the problem says, which is the whole reason the + `.html` hangs off the same `render/…md` as the `.tex`. + """ + + def test_it_returns_the_tex_and_the_html(self, writer): + tex, html = _fragment(writer.build, 'source/naboj/x/sk/solution.md', + 'naboj/x/sk/solution', 'sk', META, REPO) + assert tex == 'build/naboj/x/sk/solution.tex' + assert html == 'build/naboj/x/sk/solution.html' + + def test_both_are_made_from_the_one_rendered_markdown(self, writer): + _fragment(writer.build, 'source/naboj/x/sk/solution.md', 'naboj/x/sk/solution', + 'sk', META, REPO) + text = written(writer) + assert 'build/naboj/x/sk/solution.tex: pandoc_tex render/naboj/x/sk/solution.md' in text + assert 'build/naboj/x/sk/solution.html: pandoc_html render/naboj/x/sk/solution.md' in text + + def test_the_html_waits_for_the_crossref_table_too(self, writer): + """pandoc reads `build/core/i18n/.yaml` whichever writer it is running.""" + _fragment(writer.build, 'source/naboj/x/sk/solution.md', 'naboj/x/sk/solution', + 'sk', META, REPO) + line = next(l for l in written(writer).splitlines() if 'pandoc_html' in l) + assert '| build/core/i18n/sk.yaml' in line + + +class TestAWebPictureIsTheBookletsPicture: + """ + There is no separate web drawing: every route goes through the artefact the booklet prints, + except the two that would cost a conversion to arrive where they started. + """ + + def test_a_hand_drawn_svg_is_copied(self, writer): + """Round-tripping it through a PDF would be two conversions for nothing.""" + assert _picture_svg(writer.build, writer, 'naboj/x/pic', 'svg') == 'build/naboj/x/pic.svg' + assert 'build/naboj/x/pic.svg: copy render/naboj/x/pic.svg' in written(writer) + + def test_a_plot_is_drawn_again_rather_than_converted(self, writer): + """One step shorter, and it takes the PDF parser out of the chain for these files.""" + _picture_svg(writer.build, writer, 'naboj/x/plot', 'gp') + assert 'build/naboj/x/plot.svg: gnuplot_svg build/naboj/x/plot.gp' in written(writer) + + @pytest.mark.parametrize('extension', ['tikz', 'pdf']) + def test_everything_else_is_converted_from_the_printed_pdf(self, writer, extension): + _picture_svg(writer.build, writer, 'naboj/x/pic', extension) + assert 'build/naboj/x/pic.svg: pdf_svg build/naboj/x/pic.pdf' in written(writer) + + def test_a_picture_reached_twice_is_declared_once(self, writer): + """ + `seminar/FKS/35/1/2/02` holds both `svah.svg` and `svah.pdf`. The PDF side resolves that + by rule order and this side must not then declare a second edge for the same output -- + Ninja refuses the graph outright, which is how the PDF case was found. + """ + _picture_svg(writer.build, writer, 'seminar/x/svah', 'svg') + assert _picture_svg(writer.build, writer, 'seminar/x/svah', 'pdf') \ + == 'build/seminar/x/svah.svg' + assert written(writer).count('build/seminar/x/svah.svg:') == 1 + + +class TestWhereAPageLooksForItsFragments: + """ + Náboj holds a directory per language inside a problem; seminar is monolingual and its + fragments sit in the unit directory. `page.py` reads that out of the module's descriptor, + so the edge has to agree with it or the page is assembled from the wrong directory. + """ + + def test_naboj_nests_the_language(self, writer): + page = _page(writer.build, 'naboj/phys/29/problems/drenched', 'sk', 'naboj', + fragments=['build/naboj/phys/29/problems/drenched/sk/solution.html'], + pictures=[], nested=True) + assert page == 'output/naboj/phys/29/problems/drenched/sk/index.html' + assert 'unit = build/naboj/phys/29/problems/drenched' in written(writer) + assert 'module = naboj' in written(writer) + + def test_seminar_does_not(self, writer): + page = _page(writer.build, 'seminar/FKS/42/1/1/01', 'sk', 'seminar', + fragments=['build/seminar/FKS/42/1/1/01/problem.html'], + pictures=[], nested=False) + assert page == 'output/seminar/FKS/42/1/1/01/index.html' + + def test_it_waits_for_the_maths_the_booklet_is_set_with(self, writer): + r""" + `mathpage.jtex` `\input`s the language's i18n table and five of `core/latex/`. The + document edges learn those from xelatex's `-recorder`; a page runs its xelatex inside + `core.html.texmath`, where there is no recorder, so the edge has to name them. + """ + _page(writer.build, 'naboj/x', 'sk', 'naboj', fragments=['build/naboj/x/sk/a.html'], + pictures=[], nested=True) + line = written(writer).splitlines()[0] + for wanted in ('build/core/i18n/sk.tex', 'core/templates/mathpage.jtex', + 'core/latex/math.tex', 'core/templates/page.jhtml', + 'core/html/page.css'): + assert wanted in line, wanted + + +class TestWhichPicturesAPageNames: + def test_only_the_ones_in_the_units_own_directory(self): + """ + An exact directory match, not a prefix. Scholar's issue directory *contains* its + problems, so a prefix would hand an issue's page every drawing below it. + """ + drawings = ['build/scholar/TA1/2018/handouts/02/cylinder.svg', + 'build/scholar/TA1/2018/handouts/02/01/graph.svg'] + assert _beside(drawings, 'scholar/TA1/2018/handouts/02') == [drawings[0]] + assert _beside(drawings, 'scholar/TA1/2018/handouts/02/01') == [drawings[1]] diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index 6645821b..24369e1c 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -151,6 +151,14 @@ def rules(w: Writer) -> None: '$run pandoc.py --format latex $lang $in $out', description=described('{a}[convert] Converting {x}Markdown{a} file {f}$in{a} ' 'to {x}TeX{a} file {f}$out')) + # The other writer, from the same rendered Markdown. A fragment is to a page what a + # `.tex` is to a PDF, so it lands in `build/` and the assembly into `output/` is the `page` + # rule below. The two writers disagree -- maths above all -- and where they disagree the web + # is the half nobody looks at, which is the reason it is built at all. + w.rule('pandoc_html', + '$run pandoc.py --format html $lang $in $out', + description=described('{a}[convert] Converting {x}Markdown{a} file {f}$in{a} ' + 'to {x}HTML{a} fragment {f}$out')) w.rule('copy', 'mkdir -p $$(dirname $out) && cp $in $out', # `$kind` is bound by every edge, through `_copied`: a rule block may not carry a @@ -205,6 +213,43 @@ def rules(w: Writer) -> None: + ' && ' + _depfile('$out'), description=described('{a}[XeLaTeX] Compiling {x}PDF{a} file {f}$out'), depfile='$out.d', deps='gcc') + # A picture in the one format a browser wants, converted from **the PDF the LaTeX path + # already built** -- so the web picture and the printed one cannot drift. `pdftocairo` and + # not `dvisvgm --pdf`: dvisvgm reads line widths and embedded rasters wrongly, which is a + # hairline turned into a heavy frame and a bitmap smeared into the corner (`Makefile:276-299` + # has the whole account). A hand-drawn `.svg` short-circuits this and is copied instead. + w.rule('pdf_svg', + 'mkdir -p $$(dirname $out) && pdftocairo -svg $in $out', + description=described('{a}[pdftocairo] Rendering {f}$in{a} ' + 'to {x}SVG{a} file {f}$out')) + # A gnuplot plot is drawn twice by gnuplot rather than converted, one terminal each. The two + # disagree about their units -- the `svg` terminal counts CSS pixels, 96 to the inch, where + # `pdfcairo` counts points and hides a `fontscale 0.5` -- so `gppt` is 4/3 here against 1/72 + # there and the font is 8 against 6. The same type at the same size on the same canvas, said + # in each terminal's own units. + w.rule('gnuplot_svg', + 'mkdir -p $$(dirname $out) && cd $$(dirname $out) && gnuplot ' + '-e "set macros; gppt = 4.0 / 3; ' + '''gpterm = 'svg font \\"Minion Pro, 8\\"' " ''' + '-e "set terminal @gpterm size 480, 288; ' + "set output '$$(basename $out)'; set fit quiet;\" $$(basename $in) && " + # A script that sets a terminal of its own wins over the `-e` above, and gnuplot then + # writes a PDF into a file named `.svg` with no error anywhere -- `FKS/42/1/1/03` did + # it from a `blocks:` entry, which is a `.gp` no grep over `source/**/*.gp` can see, + # and the page showed three blank figures. + '{ head -c 5 $$(basename $out) | grep -q "<" || { echo "$out is not an SVG: does ' + 'its script (or a blocks: entry) set its own terminal?" >&2; ' + 'rm -f $$(basename $out); exit 1; }; }', + description=described('{a}[gnuplot] Rendering {f}$in{a} ' + 'to {x}SVG{a} file {f}$out')) + # One unit's fragments into `/index.html`, with the pictures it names copied in + # beside it and its maths typeset, so the directory opens from `file://` with nothing else + # present. The pictures it copies are not declared: which ones they are is decided by + # reading the fragments, and they are a copy of files this graph already builds. + w.rule('page', + 'mkdir -p $outdir && $run -m core.html.page $unit $lang $outdir --module $module', + description=described('{a}[page] Assembling {f}$unit{a} into {x}HTML{a} page ' + '{f}$outdir/index.html')) w.rule('standalone_tex', "mkdir -p $outdir && $run -m modules.naboj.builder.standalone " "$competition $volume $problem $lang -o '$outdir'", @@ -424,7 +469,7 @@ def build(*args, **kwargs): # -- pictures -------------------------------------------------------------------------------- w.comment('Pictures. A `.svg`, `.tikz` and `.gp` are Jinja templates first; the meta beside ' 'them is the context, so a drawing can print the number the meta computes.') - pictures = [] + pictures, drawings = [], [] for problem in problems: rel = listed(problem).relative_to(repo / 'source').as_posix() meta = (problem / 'meta.yaml').resolve() @@ -435,7 +480,9 @@ def build(*args, **kwargs): implicit=[str(meta.relative_to(repo))], variables={'lang': 'sk', 'meta': str(meta), 'renderer': NABOJ_RENDERER}) - pictures.append(_picture_pdf(build, w, stem, extension, problem, repo)) + printed, shown = _picture(build, w, stem, extension, problem, repo) + pictures.append(printed) + drawings.append(shown) for extension in PICTURE_COPIES: for picture in sorted(problem.glob(f'*.{extension}')): stem = f'{rel}/{picture.stem}' @@ -443,28 +490,45 @@ def build(*args, **kwargs): continue _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') pictures.append(f'build/{stem}.{extension}') + # A raster goes to the page as it is; a PDF copied in is converted like any + # other drawing. + drawings.append(_picture_svg(build, w, stem, extension) if extension == 'pdf' + else f'build/{stem}.{extension}') w.newline() # -- fragments ------------------------------------------------------------------------------- w.comment('One fragment per problem per language. A translated file lives inside the language ' 'directory; a shared one sits beside the meta and is rendered once per language.') fragments = {lang: {group: [] for group in NABOJ_GROUPS} for lang in languages} + pages = {lang: [] for lang in languages} for problem in problems: rel = problem.relative_to(repo / 'source').as_posix() meta = (problem / 'meta.yaml').resolve() for lang in languages: listed(problem / lang) + web = [] for name in TRANSLATED: if not (problem / lang / f'{name}.md').is_file(): continue - fragments[lang][_group(name)].append(_fragment( + tex, fragment = _fragment( build, f'source/{rel}/{lang}/{name}.md', f'{rel}/{lang}/{name}', lang, meta, - repo)) + repo) + fragments[lang][_group(name)].append(tex) + web.append(fragment) for name in SHARED: if not (problem / f'{name}.md').is_file(): continue - fragments[lang][_group(name)].append(_fragment( - build, f'source/{rel}/{name}.md', f'{rel}/{lang}/{name}', lang, meta, repo)) + tex, fragment = _fragment( + build, f'source/{rel}/{name}.md', f'{rel}/{lang}/{name}', lang, meta, repo) + fragments[lang][_group(name)].append(tex) + web.append(fragment) + + # The page: the same problem in the same language, for a browser. Only where there + # is something to assemble -- `page.py` refuses an empty unit, and rightly, so a + # declared edge with no fragments would be an edge that cannot run. + if web: + pages[lang].append(_page(build, rel, lang, 'naboj', fragments=web, + pictures=_beside(drawings, rel), nested=True)) # The standalone: one problem, one language, one PDF. **This is the editor's # preview** -- `modules/naboj/editor.yaml`'s `preview:` names it and `/api/compile` @@ -537,7 +601,11 @@ def build(*args, **kwargs): w.build(f'output/naboj/{scope}/languages/{lang}/all', 'phony', [f'output/naboj/{scope}/languages/{lang}/{name}.pdf' for name in (*LANGUAGE_PDFS, 'booklet-print', 'cover-print')]) - counts['edges'] += 1 + # Every page of this volume in this language. Not folded into `all`, which is the + # Makefile's reading too: the documents are what a volume is run from, and the pages + # are a second output of the same sources rather than part of the first. + w.build(f'output/naboj/{scope}/languages/{lang}/pages', 'phony', pages[lang]) + counts['edges'] += 2 counts['languages'].append(f'{scope}:{lang}') w.newline() @@ -583,11 +651,13 @@ def build(*args, **kwargs): w.build(f'output/naboj/{scope}/languages', 'phony', [f'output/naboj/{scope}/languages/{lang}/all' for lang in languages] + ([f'output/naboj/{scope}/languages/tearoffs.zip'] if languages else [])) + w.build(f'output/naboj/{scope}/pages', 'phony', + [f'output/naboj/{scope}/languages/{lang}/pages' for lang in languages]) w.build(f'output/naboj/{scope}/venues', 'phony', [f'output/naboj/{scope}/venues/{venue}/all' for venue in venues]) w.build(f'output/naboj/{scope}/all', 'phony', [f'output/naboj/{scope}/languages', f'output/naboj/{scope}/venues']) - counts['edges'] += 4 + counts['edges'] += 5 w.newline() counts['problems'] = len(problems) @@ -627,7 +697,7 @@ def build(*args, **kwargs): for p in (repo / 'modules/seminar/templates').rglob('*.jtex')) fragments = {name: [] for name in SEMINAR_TARGETS} - pictures = [] + pictures, drawings, pages = [], [], [] for problem in problems: rel = listed(problem).relative_to(repo / 'source').as_posix() meta = (problem / 'meta.yaml').resolve() @@ -641,7 +711,9 @@ def build(*args, **kwargs): implicit=[str(meta.relative_to(repo))], variables={'lang': 'sk', 'meta': str(meta), 'renderer': SEMINAR_RENDERER}) - pictures.append(_picture_pdf(build, w, stem, extension, problem, repo)) + printed, shown = _picture(build, w, stem, extension, problem, repo) + pictures.append(printed) + drawings.append(shown) for extension in PICTURE_COPIES: for picture in sorted(problem.glob(f'*.{extension}')): stem = f'{rel}/{picture.stem}' @@ -649,11 +721,22 @@ def build(*args, **kwargs): continue _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') pictures.append(f'build/{stem}.{extension}') + drawings.append(_picture_svg(build, w, stem, extension) if extension == 'pdf' + else f'build/{stem}.{extension}') + web = [] for name in SEMINAR_TARGETS: if (problem / f'{name}.md').is_file(): - fragments[name].append(_fragment(build, f'source/{rel}/{name}.md', - f'{rel}/{name}', 'sk', meta, repo, - SEMINAR_RENDERER)) + tex, fragment = _fragment(build, f'source/{rel}/{name}.md', + f'{rel}/{name}', 'sk', meta, repo, + SEMINAR_RENDERER) + fragments[name].append(tex) + web.append(fragment) + # A seminar page sits in the problem directory, not in a language directory under it: + # seminar is monolingual, which `modules/seminar/editor.yaml` says by carrying no + # `languages:` key and which `page.py` reads from there. + if web: + pages.append(_page(build, rel, 'sk', 'seminar', fragments=web, + pictures=_beside(drawings, rel), nested=False)) outdir = f'build/seminar/{scope}' build([f'{outdir}/{name}.tex' for name in ROUND_DOCUMENTS], 'round', None, @@ -675,7 +758,8 @@ def build(*args, **kwargs): w.build(f'output/seminar/{scope}/all', 'phony', [f'output/seminar/{scope}/{name}.pdf' for name in ROUND_PDFS]) - counts['edges'] += 1 + w.build(f'output/seminar/{scope}/pages', 'phony', pages) + counts['edges'] += 2 w.newline() counts['problems'] = len(problems) @@ -714,6 +798,13 @@ def build(*args, **kwargs): templates = sorted(str(p.relative_to(repo)) for p in (repo / 'modules/scholar/templates').rglob('*.jtex')) + # **Fragments but no pages**, which is the one asymmetry with the other two modules. + # `page.py` collects the targets `modules/scholar/editor.yaml` declares, and that descriptor + # says `targets: [text]` where 28 of this module's issues hold a `content.md` instead -- so + # an issue page would be an edge that could only ever fail with `no fragments to assemble`. + # The `.html` fragments are still built, because those are what the editor's HTML tab shows + # and they are right whatever the descriptor calls them. Settling `content` against `text` + # is scholar's own question and is noted at `SCHOLAR_TARGETS`. units, pictures = 0, [] for kind, (builder, documents) in SCHOLAR_KINDS.items(): if not (root / kind).is_dir(): @@ -737,7 +828,11 @@ def build(*args, **kwargs): implicit=[f'source/{rel}/meta.yaml'], variables={'lang': 'sk', 'meta': str(meta.resolve()), 'renderer': SCHOLAR_RENDERER}) - pictures.append(_picture_pdf(build, w, stem, extension, place, repo)) + # The `.svg` the second half of this emits is unreferenced here and + # is still worth having: `build/.svg` is a target anyone may ask + # for, and it is the same picture the booklet prints. + printed, _ = _picture(build, w, stem, extension, place, repo) + pictures.append(printed) for extension in PICTURE_COPIES: for picture in sorted(place.glob(f'*.{extension}')): stem = f'{rel}/{picture.stem}' @@ -745,11 +840,14 @@ def build(*args, **kwargs): continue _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') pictures.append(f'build/{stem}.{extension}') + if extension == 'pdf': + _picture_svg(build, w, stem, extension) for name in SCHOLAR_TARGETS: if (place / f'{name}.md').is_file(): - fragments.append(_fragment(build, f'source/{rel}/{name}.md', - f'{rel}/{name}', 'sk', meta.resolve(), repo, - SCHOLAR_RENDERER)) + tex, _ = _fragment(build, f'source/{rel}/{name}.md', + f'{rel}/{name}', 'sk', meta.resolve(), repo, + SCHOLAR_RENDERER) + fragments.append(tex) outdir = f'build/{issue_rel}' build([f'{outdir}/{name}.tex' for name in documents], 'scholar_issue', None, @@ -799,8 +897,15 @@ def _group(name: str) -> str: def _fragment(build, source: str, stem: str, lang: str, meta: pathlib.Path, - repo: pathlib.Path, renderer: str | None = None) -> str: - """`source/…/x.md` → `render/…/x.md` → `build/…/x.tex`, the two steps every fragment takes.""" + repo: pathlib.Path, renderer: str | None = None) -> tuple[str, str]: + """ + `source/…/x.md` → `render/…/x.md` → `build/…/x.{tex,html}`. + + One Jinja stage, one language, **two writers**. The `.tex` goes to xelatex and the `.html` + goes into a page, and both are made from the same rendered Markdown -- which is the whole + point of the arrangement, since a second route to the web would be a second chance for the + two to disagree about what the problem says. + """ _rendered(build, f'render/{stem}.md', source, implicit=[str(meta.relative_to(repo))], variables={'lang': lang, 'meta': str(meta), @@ -808,7 +913,10 @@ def _fragment(build, source: str, stem: str, lang: str, meta: pathlib.Path, build(f'build/{stem}.tex', 'pandoc_tex', [f'render/{stem}.md'], implicit=[f'build/core/i18n/{lang}.yaml'], variables={'lang': lang}) - return f'build/{stem}.tex' + build(f'build/{stem}.html', 'pandoc_html', [f'render/{stem}.md'], + implicit=[f'build/core/i18n/{lang}.yaml'], + variables={'lang': lang}) + return f'build/{stem}.tex', f'build/{stem}.html' #: What a template is called in the line the build prints. The jinja step serves four formats, @@ -836,9 +944,14 @@ def _copied(build, target: str, source: str, **kwargs): return build(target, 'copy', [source], variables=variables, **kwargs) -def _picture_pdf(build, w: Writer, stem: str, extension: str, problem: pathlib.Path, - repo: pathlib.Path) -> str: - """The three routes a drawing takes to the `.pdf` the booklet includes.""" +def _picture(build, w: Writer, stem: str, extension: str, problem: pathlib.Path, + repo: pathlib.Path) -> tuple[str, str]: + """ + The routes a drawing takes to the two formats it is wanted in. + + `build/.pdf` is what the booklet includes and `build/.svg` is what a page + shows, and **it is the same picture** -- see `_picture_svg` for why that matters. + """ if extension == 'svg': build(f'build/{stem}.pdf', 'svg_pdf', [f'render/{stem}.svg']) elif extension == 'tikz': @@ -852,7 +965,66 @@ def _picture_pdf(build, w: Writer, stem: str, extension: str, problem: pathlib.P _copied(build, f'build/{stem}.gp', f'render/{stem}.gp', implicit=[f'build/{name}' for name in data]) build(f'build/{stem}.pdf', 'gnuplot_pdf', [f'build/{stem}.gp']) - return f'build/{stem}.pdf' + return f'build/{stem}.pdf', _picture_svg(build, w, stem, extension) + + +def _picture_svg(build, w: Writer, stem: str, extension: str) -> str: + """ + The same picture, in the one format a browser wants. + + **There is no separate web drawing.** A TikZ drawing, a PDF copied in and a `.gp` all reach + the page through the artefact the booklet prints, so the two cannot drift -- the first two + by conversion and the plot by being drawn a second time in the `svg` terminal, which is one + step shorter and takes the PDF parser out of the chain for the files that provoked the + conversion bug. A hand-drawn `.svg` is copied: round-tripping it through a PDF would cost + two conversions to arrive where it started. + + `w.has` because a picture with two sources of the same name reaches this twice -- the same + guard `_already_drawn` applies on the PDF side, and for the same reason. + """ + target = f'build/{stem}.svg' + if w.has(target): + return target + if extension == 'svg': + _copied(build, target, f'render/{stem}.svg') + elif extension == 'gp': + build(target, 'gnuplot_svg', [f'build/{stem}.gp']) + else: + build(target, 'pdf_svg', [f'build/{stem}.pdf']) + return target + + +#: What a page's maths is set with when the engine is `latex`, which is the default: the +#: `\input`s of `core/templates/mathpage.jtex`. Every document edge gets these from xelatex's +#: own `-recorder`; a page runs its xelatex inside `core.html.texmath`, where there is no +#: recorder to read, so they are named here instead. +MATHPAGE_INPUTS = ('core/templates/mathpage.jtex', 'core/latex/fonts.tex', + 'core/latex/utilities.tex', 'core/latex/math.tex', + 'core/latex/symbols.tex', 'core/latex/siunitx.tex') + + +def _page(build, rel: str, lang: str, module: str, *, fragments: list, pictures: list, + nested: bool) -> str: + """ + One unit's fragments assembled into a page, which is what goes in `output/`. + + `nested` is the module's own answer to where a fragment lives: Náboj holds a directory per + language inside a problem, so its page is `//index.html`; seminar and scholar + are monolingual and theirs sits in the unit directory. `page.py` reads the same fact out of + `modules//editor.yaml`, which is why the module is passed rather than guessed from + the path. + """ + outdir = f'output/{rel}/{lang}' if nested else f'output/{rel}' + build(f'{outdir}/index.html', 'page', None, + implicit=[*fragments, *pictures, f'build/core/i18n/{lang}.tex', + 'core/templates/page.jhtml', 'core/html/page.css', *MATHPAGE_INPUTS], + variables={'unit': f'build/{rel}', 'lang': lang, 'module': module, 'outdir': outdir}) + return f'{outdir}/index.html' + + +def _beside(drawings: list, rel: str) -> list: + """The pictures that sit in this unit's own directory, which are the ones its page names.""" + return [picture for picture in drawings if picture.rsplit('/', 1)[0] == f'build/{rel}'] def main() -> int: From 61305878896a205da9aba3cb1a7faee849cc21d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 05:49:12 +0000 Subject: [PATCH 10/28] The editor's HTML tab asks for a file that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UnitKind.html` derived `output//…/.html` from the render target, and said so in its docstring: "the root Makefile has one `output/%.html: render/%.md` for every module". That rule became an XFAIL fall-through when the per-module `.html` rules landed beside the `.tex` ones, and this did not follow them -- so `/api/html` has been asking the build for a target nothing makes. Under make that is `Incorrect fall-through rule called`; under Ninja it is `unknown target`, which is how it was found. A fragment is an intermediate and belongs in `build/`, for the same reason a `.tex` does: no ``, nothing to typeset its `\(…\)`, and `` pointing at files that are not beside it. So `html` is now `tex` with the other extension, which is also what every `module.mk` writes. That moves where the preview pane's pictures resolve, and the fragment's own directory is the wrong answer for Náboj: a fragment sits inside the language directory and its drawings beside the problem above it. The server names the directory instead -- `html_base`, the unit's own `build/` directory, which is where the generator puts a picture in all three modules -- and `/api/build/` serves it, path-checked exactly as `/api/output/` is. Co-Authored-By: Claude Opus 5 --- core/tests/test_editor_capabilities.py | 35 ++++++++++++++-------- tools/editor/app.py | 40 ++++++++++++++++++++++++-- tools/editor/descriptors.py | 17 +++++++---- tools/editor/static/app.js | 10 ++++--- 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/core/tests/test_editor_capabilities.py b/core/tests/test_editor_capabilities.py index 5ee79fab..4f34adc4 100644 --- a/core/tests/test_editor_capabilities.py +++ b/core/tests/test_editor_capabilities.py @@ -185,41 +185,52 @@ def test_a_populated_checkout_is_not_reported_as_empty(self): class TestTheHtmlTargetIsDerived: - """ - `html` is derived the same way `tex` is, and for a stronger reason: there is exactly one - `output/%.html: render/%.md` in the root Makefile, shared by every module, so a per-module key - could only ever drift from it. + r""" + `html` is derived the same way `tex` is, and lands beside it. + + **A fragment is an intermediate and belongs in `build/`.** It has no ``, so nothing + typesets its `\(…\)`, and its `` point at files that are not next to it; assembling + fragments into a page is `core/html/page.py`, and that is the step whose output is in + `output/`. This used to say `output/` and was wrong for long enough to matter -- the rule it + named became an XFAIL fall-through when the per-module `.html` rules landed, so the editor's + HTML tab asked the build for something nothing made. """ def test_it_swaps_the_prefix_and_the_extension(self): kind = UnitKind(glob='*', render='render/naboj/{unit}/{language}/{target}.md') - assert kind.html == 'output/naboj/{unit}/{language}/{target}.html' + assert kind.html == 'build/naboj/{unit}/{language}/{target}.html' + + def test_it_sits_beside_the_tex(self): + """The two writers, one rendered Markdown: the only difference is the extension.""" + kind = UnitKind(glob='*', render='render/naboj/{unit}/{language}/{target}.md') + assert kind.html == kind.tex.removesuffix('.tex') + '.html' def test_a_module_with_no_render_rule_has_no_html_rule(self): assert UnitKind(glob='*').html == '' def test_something_that_is_not_a_render_target_is_refused(self): - """The quiet half: better no HTML tab than a target make has no rule for.""" + """The quiet half: better no HTML tab than a target the graph has no edge for.""" assert UnitKind(glob='*', render='output/naboj/{unit}.pdf').html == '' def test_the_tex_override_does_not_leak_into_it(self): """ - `tex_override` exists for a module whose `.tex` rule is not the usual one. The HTML rule - is the root Makefile's for everybody, so an override of the TeX target must not silently - redirect it. + `tex_override` exists for a module whose `.tex` rule is not the usual one. An override of + the TeX target must not silently redirect the HTML one, which is a different writer. """ kind = UnitKind(glob='*', render='render/x/{target}.md', tex_override='build/odd.tex') - assert kind.html == 'output/x/{target}.html' + assert kind.html == 'build/x/{target}.html' @pytest.mark.parametrize('descriptor', sorted(ROOT.glob('modules/*/editor.yaml'))) - def test_every_real_descriptor_yields_an_output_path(self, descriptor): + def test_every_real_descriptor_yields_a_build_path(self, descriptor): """Every module that renders Markdown can show its web fragment.""" import yaml spec = yaml.safe_load(descriptor.read_text()) or {} + module = descriptor.parent.name for entry in spec.get('units') or []: kind = UnitKind(glob=entry['glob'], render=entry.get('render', '')) if kind.render: - assert kind.html.startswith('output/') and kind.html.endswith('.html') + assert kind.html.startswith(f'build/{module}/') + assert kind.html.endswith('.html') class TestAssetVersion: diff --git a/tools/editor/app.py b/tools/editor/app.py index a0f56a25..d2f9f9e8 100644 --- a/tools/editor/app.py +++ b/tools/editor/app.py @@ -235,6 +235,19 @@ def html_path(self, target): target = self.html_target(target) return REPO_ROOT / target if target else None + def pictures_dir(self): + """ + Where a relative `` in this unit's fragments resolves. + + A fragment says `![](x.svg)` because that is what the author wrote next to the problem, + and the built drawing lands in the unit's own `build/` directory whatever the module -- + beside the problem for Náboj, whose fragments are a level deeper inside the language + directory, and in the unit directory itself for the monolingual two. `page.py` copies + the pictures next to the page instead, which is what makes a page self-contained; the + HTML pane has no page, so it is pointed here. + """ + return f"build/{self.module.name}/{self.name}/" + def preview_target(self): if not self.kind.preview: return None @@ -775,10 +788,12 @@ def api_html(): response["html"] = ( read_if_exists(unit.html_path(target)) if response["ok"] else None ) - # Where the fragment lives, so the pane can point a `` at its directory and let the - # relative pictures resolve. Named here rather than reconstructed in the front end, which - # would be a second copy of the path rule. + # Where the fragment lives, and where its pictures do -- which for Náboj is not the + # same directory, since a fragment sits inside the language and a drawing beside the + # problem. Named here rather than reconstructed in the front end, which would be a + # second copy of two path rules. response["html_target"] = build_target + response["html_base"] = unit.pictures_dir() return jsonify(response) @@ -850,6 +865,25 @@ def parse_mdcheck_output(stdout): return violations +@app.get("/api/build/") +def api_build(relative): + """ + One file out of `build/`, so the HTML pane's pictures resolve. + + The twin of `api_output` below, and it is needed because a fragment is an intermediate: it + lives in `build/` beside the drawings it names, where a page lives in `output/` with its + pictures copied in. Serving it the same way keeps the pane honest -- a picture that does + not resolve here has not been built, and the pane should show that rather than hide it. + + Path-checked rather than trusted, exactly as `api_output` is. + """ + root = (REPO_ROOT / "build").resolve() + path = (root / relative).resolve() + if not path.is_relative_to(root) or not path.is_file(): + raise BadRequest(f"No such build file: {relative}") + return send_file(path) + + @app.get("/api/output/") def api_output(relative): """ diff --git a/tools/editor/descriptors.py b/tools/editor/descriptors.py index 11517013..68a0b07a 100644 --- a/tools/editor/descriptors.py +++ b/tools/editor/descriptors.py @@ -49,16 +49,23 @@ def tex(self): @property def html(self): - """ + r""" The build target that converts this file to an HTML fragment. - Derived from `render` exactly as `tex` is, and for the same reason: the root Makefile has - one `output/%.html: render/%.md` for every module, so the relation belongs to the build - system and a hand-written key per module could only drift from it. + Derived from `render` exactly as `tex` is, and it lands in **`build/`** beside the + `.tex`, because that is what it is: a fragment is to a page what a `.tex` is to a PDF -- + no ``, nothing to typeset its `\(…\)`, and `` pointing at files that are not + beside it. The page is the `output/` step, and `core/html/page.py` is what makes one. + + This said `output/` until now, which was true of a root-Makefile rule that has since + become an XFAIL fall-through: the per-module `.html` rules landed beside the `.tex` ones + and this did not follow them, so the editor's HTML tab has been asking for a target + nothing builds. Under make that is `Incorrect fall-through rule called`; under Ninja it + is `unknown target`, which is how it was found. """ if not self.render.startswith("render/") or not self.render.endswith(".md"): return "" - return "output/" + self.render[len("render/"):-len(".md")] + ".html" + return "build/" + self.render[len("render/"):-len(".md")] + ".html" @property def fixed_levels(self): diff --git a/tools/editor/static/app.js b/tools/editor/static/app.js index abc95e6d..11cd9a01 100644 --- a/tools/editor/static/app.js +++ b/tools/editor/static/app.js @@ -1097,7 +1097,7 @@ async function doCompile() { /* The fragment the web gets, shown as a page rather than as markup. * - * `srcdoc` with an absolute `` under `/api/output/`, so the relative pictures resolve + * `srcdoc` with an absolute `` under `/api/build/`, so the relative pictures resolve * exactly as they would on the web -- one that does not load here would not load there either, * and the pane should show that rather than hide it. * @@ -1152,9 +1152,11 @@ async function doHtml() { if (body.ok) { // Absolute, because a `srcdoc` document has no URL of its own for a relative one to - // resolve against. - const base = `${location.origin}/api/output/` + - (body.html_target ?? "").replace(/^output\//, "").replace(/[^/]*$/, ""); + // resolve against. The server says which directory, because it is not the fragment's + // own: a Náboj fragment sits inside the language directory and its drawings beside the + // problem above it. + const base = `${location.origin}/api/build/` + + (body.html_base ?? "").replace(/^build\//, ""); frame.srcdoc = `` + `` + From 8dca2b746609599e308c261e7f0684d962446fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 06:49:15 +0000 Subject: [PATCH 11/28] What a stale `set terminal` actually costs The note said "a script that wants the default writes nothing at all, which is 39 of the 42", and 30 of the 42 do: nine of scholar's plots size their own canvas and were written with `@gpterm`, and naboj's three were not. Those three and `FKS/42/1/1/03`'s `blocks:` preamble still opened `set terminal pdf size 6, 1.5`; all four are converted now, in their own repositories. The reason they lasted is worth writing down, because it is the opposite of what the section implies. Re-issued over a `pdfcairo` that is already current, `set terminal pdf` keeps both the font and the `fontscale` it finds, so the printed figure is unchanged -- the six plots and volume 29's whole booklet are pixel-identical across the conversion. Only the `svg` terminal notices, because there it is a different terminal rather than a reset one, and gnuplot then writes a PDF into a file named `.svg`. Nothing was wrong until the web path was built, and then three problems' pages were blank figures. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37d4ed2f..eb47c257 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -581,7 +581,19 @@ terminal counts in, and a script that wants its own canvas writes set terminal @gpterm size 432*gppt, 108*gppt -A script that wants the default writes nothing at all, which is 39 of the 42. +A script that wants the default writes nothing at all, which is 30 of the 42. Of the 12 that do +not, nine are scholar's and were written this way; the other three are naboj's and were not -- +they still opened `set terminal pdf size 6, 1.5` when the web path was added, and so did +`FKS/42/1/1/03`'s `blocks:` preamble, which is a thirteenth script that no `.gp` on disk carries. +All four say `@gpterm` now. + +**`set terminal pdf` left in a script is not a print defect and is a web one**, which is what +made it survive. Re-issued over a `pdfcairo` that is already current it keeps the font *and* the +`fontscale`, so the printed figure is unchanged -- all six of those plots are pixel-identical +across the conversion, and so is volume 29's whole booklet. On the `svg` terminal it is simply a +different terminal, so gnuplot writes a PDF into a file named `.svg` and the page shows a blank +figure. Hence the sniff below, and hence the rule that the terminal is the build's to supply +even where leaving it in the script appears to cost nothing. Three things about that, each of which cost a wrong picture to find: From e520c7f2337e61e0ab11329dc2aec6b672a21889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 12:24:23 +0000 Subject: [PATCH 12/28] A seminar round names the static files it inputs `build/seminar/FKS/.static/` was in no edge, so the first round document Ninja reached died on `File 'build/seminar/FKS/.static/format-override.tex' not found`. make had it as `copy-static`, one `cp -r` of the whole tree -- and a rule whose own target it never touched, so it had no prerequisites, re-ran on every build and told the documents nothing about what they read. Five files here, one copy edge each, named in the PDFs' implicit deps. FKS is the only competition with a `.static`; a module without one yields nothing. Co-Authored-By: Claude Opus 5 --- tools/ninja/generate.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index 24369e1c..868bf698 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -696,6 +696,19 @@ def build(*args, **kwargs): templates = sorted(str(p.relative_to(repo)) for p in (repo / 'modules/seminar/templates').rglob('*.jtex')) + # `source/seminar//.static/` is what every round of that competition inputs: + # the format override, the logo, and the intro and rules templates. `module.mk` copied the + # whole tree with one `cp -r` whose own target it never touched, so it depended on nothing + # and ran on every build; here each file is an edge and the documents name what they read. + static = [] + for path in sorted(p for p in listed(repo / 'source/seminar' / competition / '.static') + .rglob('*') if p.is_file()): + listed(path.parent) + source = path.relative_to(repo).as_posix() + target = source.replace('source/', 'build/', 1) + _copied(build, target, source) + static.append(target) + fragments = {name: [] for name in SEMINAR_TARGETS} pictures, drawings, pages = [], [], [] for problem in problems: @@ -750,7 +763,7 @@ def build(*args, **kwargs): pdf = f'output/seminar/{scope}/{name}.pdf' included = [f for kind in SEMINAR_DOCUMENT_GROUPS[name] for f in fragments[kind]] build(pdf, 'document_pdf', None, - implicit=[f'{outdir}/{name}.tex', *included, *pictures, + implicit=[f'{outdir}/{name}.tex', *included, *pictures, *static, 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'], implicit_outputs=[pdf.replace('.pdf', e) for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], From cbe5b45e65f36aff760ff0c96c1d261f64841e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 12:24:33 +0000 Subject: [PATCH 13/28] `solutions-full` has never compiled Its one block put a bare `\hline` between the restated problem and its solution. `\hline` is a tabular command, so in ordinary body text it is `Misplaced \noalign` every time -- not for this round, for every round of every seminar since the template was written. Nothing caught it because nothing builds that document: `output/seminar/%/pdf` asks for `problems` and `solutions` and leaves the third alone. A rule across the measure is vertical-mode material, so `\hrule`, with the `\par` that gets there and a `\medskip` on each side. Co-Authored-By: Claude Opus 5 --- modules/seminar/templates/solutions-full.jtex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/seminar/templates/solutions-full.jtex b/modules/seminar/templates/solutions-full.jtex index c0be71d3..938f211a 100644 --- a/modules/seminar/templates/solutions-full.jtex +++ b/modules/seminar/templates/solutions-full.jtex @@ -2,5 +2,8 @@ (@ block problem_again @) \protectedInput{\activeDirectory/problem.tex}% - \hline + % `\hline` stood here and is a *tabular* command, so every `solutions-full.pdf` ever + % asked for died on `Misplaced \noalign`. A rule between the restated problem and its + % solution is vertical-mode material and wants `\hrule`, with a `\par` to get there. + \par\medskip\hrule\medskip (@ endblock @) From 650f5ba6d78157e14a6d989df18344db4ec842e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Mon, 2 Nov 2026 12:24:33 +0000 Subject: [PATCH 14/28] The dioptre An optician writes `+5 D` and never `+5 m^{-1}`, so the unit wants declaring rather than spelling out. `forbid-literal-units` is on, which is what made `FKS/42/1/3/05` fail -- the eye-model problem that asked for it -- and the message it fails with, `Literal units disabled`, says nothing about which unit or where. `core/filters/siunitx.py` reads this file, so the web side needs nothing. Co-Authored-By: Claude Opus 5 --- core/latex/siunitx.tex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/latex/siunitx.tex b/core/latex/siunitx.tex index 11e9e4d0..4f43bc49 100644 --- a/core/latex/siunitx.tex +++ b/core/latex/siunitx.tex @@ -44,6 +44,10 @@ \DeclareSIUnit{\earthmass}{\ensuremath{\mathrm{M}_{\Earth}}} \DeclareSIUnit{\horsepower}{hp} +% Optometry's unit of optical power, which is a reciprocal metre and is never written as one +% on a prescription. `FKS/42/1/3/05` is the eye-model problem that asked for it. +\DeclareSIUnit{\dioptre}{D} + \DeclareSIUnit{\byte}{B} \DeclareSIUnit{\litre}{l} \DeclareSIUnit{\celsius}{\text{°}C} From 62354f96c5db4571847b851b6ce8e4535c589dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 3 Nov 2026 05:55:06 +0000 Subject: [PATCH 15/28] Removing a directory no longer bricks the graph The generator edge takes every source directory the graph was read from as an input, which is how adding a problem or a translation re-runs it. Removing one was the other half and did not work: an input with no rule that has gone missing is a hard error -- ninja: error: rebuilding 'build.ninja': 'source/naboj/phys/29/problems/amusement-speed/es', needed by 'build.ninja', missing and no known rule to make it -- and an unrecoverable one, because the edge that would have noticed the removal is the edge Ninja is refusing to run. Deleting a language left the whole tree unbuildable until somebody ran the generator by hand, which is exactly the state a `generator` rule exists to prevent. A `phony` for each of those directories fixes it: a phony whose output does not exist is simply dirty, so `build.ninja` is out of date, the generator reruns, and the directory leaves the graph. Same trick CMake uses for its own manifest dependencies. Co-Authored-By: Claude Opus 5 --- tools/ninja/generate.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index 868bf698..1d9c6f4c 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -404,6 +404,19 @@ def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: *sorted(totals['listed'])], variables={'scope': ' '.join(scopes)}) totals['edges'] += 1 + + w.newline() + w.comment('And a `phony` for each of those directories, which is what makes *removing* one ' + 'recoverable. An input with no rule that has gone missing is a hard error -- ' + '`\'source/naboj/phys/29/problems/amusement-speed/es\', needed by ' + '\'build.ninja\', missing and no known rule to make it` -- and it is ' + 'unrecoverable, because the edge that would notice the removal is the one Ninja ' + 'is refusing to run. A phony whose output does not exist is simply dirty, so the ' + 'generator reruns and the directory leaves the graph. Deleting a language cost a ' + 'hand-run generator until this was here.') + for directory in sorted(totals['listed']): + w.build(directory, 'phony', None) + totals['edges'] += 1 return totals From 2d58177ca59b3565e14d43b265cdfafba3fb76e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 3 Nov 2026 05:55:17 +0000 Subject: [PATCH 16/28] A clone can build without being told how `build.ninja` is generated and gitignored, so a fresh clone has none, and Ninja has no bootstrap of its own -- it does not find a manifest the way make finds a Makefile. What a new machine got was ninja: error: loading 'build.ninja': No such file or directory naming a file nobody has heard of and saying nothing about a generator. The editor already handled this in `run_build`. That rule moves to `tools/ninja/bootstrap.py` so there is one of it, and `./dgs-build` is the same thing for a terminal: generate the graph if it is absent, then become Ninja. How to invoke Ninja is `capabilities.probe_python_launcher`'s answer rather than a second one of our own, so the editor and the command line cannot drift. `exec` rather than a subprocess, so Ninja owns the terminal and Ctrl-C reaches it. The shell half chooses an interpreter, which is the one thing that cannot be done from Python; the venv goes before `uv run` because it is a process cheaper and reaches the same place. Not in `install.sh`, which is where one would first look. The graph is a function of `source/`, cloned in step 2 and added to whenever another content repository is, so a graph written during the install would be wrong by the end of it; and `install.sh` is the PDF tier, which a translator never runs and still needs a build. `ninja` itself arrives with `uv sync`, as a project dependency, and `capabilities.py` already says so when it is missing. `install.md` gets the section, including the three changes the generator edge does not notice -- a new volume, a new template, a different scope set -- and loses three references to make that the editor stopped making a while ago. Co-Authored-By: Claude Opus 5 --- dgs-build | 23 ++++++++++ install.md | 51 ++++++++++++++++++++--- tools/editor/app.py | 28 ++++--------- tools/ninja/bootstrap.py | 90 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 26 deletions(-) create mode 100755 dgs-build create mode 100644 tools/ninja/bootstrap.py diff --git a/dgs-build b/dgs-build new file mode 100755 index 00000000..03312eba --- /dev/null +++ b/dgs-build @@ -0,0 +1,23 @@ +#!/bin/bash +# +# The build, from a terminal: `./dgs-build output/naboj/phys/29/languages/sk/booklet.pdf`. +# +# All it does is choose an interpreter and hand over to `tools.ninja.bootstrap`, which generates +# `build.ninja` if a fresh clone has none and then becomes Ninja. Choosing the interpreter is the +# one thing that cannot be done from Python, and everything after it is in that module -- so the +# editor and the command line answer "how is this build run" in one place rather than two. +# +# The venv goes first because it is the cheap answer and its existence means somebody has already +# synced: `uv run` costs an extra process and reaches the same interpreter. uv is still what runs +# when there is no venv, because it makes one. A bare `python3` is the last resort and may be +# older than the 3.14 `pyproject.toml` asks for, which is why it is tried last rather than first. +set -euo pipefail +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +if [[ -x .venv/bin/python3 ]]; then + exec .venv/bin/python3 -m tools.ninja.bootstrap "$@" +elif command -v uv > /dev/null 2>&1; then + exec uv run python -m tools.ninja.bootstrap "$@" +else + exec python3 -m tools.ninja.bootstrap "$@" +fi diff --git a/install.md b/install.md index b7c88b15..3d9335b3 100644 --- a/install.md +++ b/install.md @@ -12,7 +12,7 @@ uv run python tools/editor/capabilities.py | tier | what you can do | what it needs | |---|---|---| | — | open, edit and **save** files; the whole `/audit` page | Python and the dependencies below | -| Rendered Markdown | see what your Jinja tags expand to | GNU make | +| Rendered Markdown | see what your Jinja tags expand to | Ninja, which `uv sync` installs | | TeX | see what pandoc makes of it | + `pandoc` and `pandoc-crossref` | | PDF | compile and read the page | + TeX Live, `dgs.cls`, and the MinionPro fonts | @@ -87,7 +87,7 @@ against, installs that one, and verifies the result with a probe document. **The trap:** it installs into `$HOME/.local/bin` and only *warns* if that is not on your `PATH`, so the install goes green and nothing works. There is a second half the script cannot warn about — -the editor shells out to make with **its own** environment, so a `PATH` exported in `~/.zshrc` is +the editor shells out to the build with **its own** environment, so a `PATH` exported in `~/.zshrc` is invisible to an editor started from a desktop launcher or a systemd unit. The symptom is a TeX tab greyed out on a machine where `pandoc` works fine in the terminal. `capabilities.py` reports the PATH the server would actually use. @@ -115,7 +115,46 @@ It installs the apt packages itself, including `lcdf-typetools` — FontPro's sc It does **not** install the Python side. Do step 1 as well. -## 5. Running the editor +## 5. The build graph + +``` +./dgs-build output/naboj/phys/29/languages/sk/booklet.pdf +``` + +That is the whole of it, and the first run is the only interesting one. The build is a Ninja +graph that `tools/ninja/generate.py` writes from the source tree, and **`build.ninja` is +generated and gitignored, so a fresh clone has none**. Ninja has no bootstrap of its own -- it +finds no manifest the way `make` finds a `Makefile`, and stops at + +``` +ninja: error: loading 'build.ninja': No such file or directory +``` + +which names a file nobody has heard of. `./dgs-build` generates it when it is missing and then +becomes Ninja, so there is nothing to remember; the editor does the same thing from its own +Build button. Running `ninja` directly is fine once a graph exists. + +**It is not part of `install.sh`, deliberately.** The graph is a function of `source/`, which is +cloned in step 2 and grows a repository whenever you clone another one -- so a graph written at +install time would be wrong by the end of the install. And `install.sh` is the PDF tier; somebody +writing or translating never runs it and still needs a build. + +After that, regenerating is Ninja's job. `build.ninja` is itself an output of a `generator` edge +that depends on `generate.py` and on every source *directory* the graph was read from, so adding +or removing a problem, a language or a picture re-runs the generator mid-build. Three things it +will not notice, where you run the generator yourself: + +``` +uv run python -m tools.ninja.generate --scope all +``` + +- **a new volume, round or year.** The scope directories themselves are not tracked; +- **a new `modules/*/templates/*.jtex`** -- changing one is seen, adding one is not; +- **a different scope set.** The scopes are baked into the graph, and a target outside them is + `unknown target` rather than a reason to regenerate. `--scope phys/29` is a much smaller graph + if you want one; `all` is what `./dgs-build` writes and costs about two seconds. + +## 6. Running the editor ``` uv run python tools/editor/app.py --port 5001 @@ -126,7 +165,7 @@ path — `core/i18n/__init__.py` reads `core/i18n/default.yaml` at import time from anywhere else. The app `chdir`s there itself, but the working copy it picks is the one the file lives in. Port 5000 is the default and is often taken. -`uv` is not required to run it. Without uv the editor calls `make` directly, with the directory of +`uv` is not required to run it. Without uv the editor calls `ninja` directly, with the directory of the interpreter running it prepended to `PATH` — so an editor started from an activated venv works with no uv installed at all. @@ -136,8 +175,8 @@ Worth knowing, because two of them fail quietly: - **A missing `build/core/i18n/.yaml`** does not break the TeX tier; it silently changes it. pandoc-crossref falls back to English prefixes, so a Slovak booklet says `eq.~\ref{…}` where it - should say `rovnica~\ref{…}`. The `.tex` rules depend on `build/core/i18n.stamp` so make builds - it for you; if you ever bypass make, build it first. + should say `rovnica~\ref{…}`. Every `.tex` edge depends on `build/core/i18n/.yaml`, so + the build makes it for you; if you ever bypass the build, build it first. - **A font present but lacking a glyph** is not a compile error. xelatex writes `Missing character:` into the log, exits 0, and ships a hole in the page. `core/tests/test_latex.py` exists to catch that. diff --git a/tools/editor/app.py b/tools/editor/app.py index d2f9f9e8..d861c4b9 100644 --- a/tools/editor/app.py +++ b/tools/editor/app.py @@ -1,5 +1,6 @@ import argparse import collections +import functools import importlib import os import re @@ -46,6 +47,7 @@ from core.builder import reference from core.builder.jinja import MarkdownJinjaRenderer from core.builder.renderer import build_render_context, render_to_fixpoint +from tools.ninja import bootstrap from core.i18n import languages as LOCALES @@ -401,25 +403,11 @@ def build_env(): return launcher["argv"], env -#: The generated graph. Ninja keeps it current itself -- `tools/ninja/generate.py` is emitted as -#: a `generator` rule depending on the directories whose listings it was computed from -- but it -#: has to exist before Ninja can read it at all. -BUILD_NINJA = REPO_ROOT / 'build.ninja' - - -def ensure_graph(env): - """ - `build.ninja` present. Ninja regenerates it when it is stale; only its absence is ours. - - A first run after a clone has no graph, and `ninja: error: loading 'build.ninja'` names a - file nobody has heard of. One generator run costs about a second for the whole repository. - """ - if BUILD_NINJA.is_file(): - return None - result = subprocess.run( - [sys.executable, '-m', 'tools.ninja.generate', '--scope', 'all', '-o', str(BUILD_NINJA)], - cwd=REPO_ROOT, capture_output=True, text=True, timeout=300, env=env) - return None if result.returncode == 0 else result +#: `build.ninja` present before Ninja is asked to read it. Ninja regenerates the graph when it is +#: stale -- `tools/ninja/generate.py` is emitted as a `generator` rule depending on the +#: directories whose listings it was computed from -- so only its *absence* is ours to fix, and +#: `./dgs-build` fixes it the same way from a terminal. +ensure_graph = functools.partial(bootstrap.ensure_graph, REPO_ROOT, python=sys.executable) def run_build(target, *, timeout=60): @@ -431,7 +419,7 @@ def run_build(target, *, timeout=60): "HTTP 500", which tells nobody what to install. """ argv, env = build_env() - if (failed := ensure_graph(env)) is not None: + if (failed := ensure_graph(env=env)) is not None: return refusal(target, 'the build graph could not be generated', f'`tools.ninja.generate` failed:\n\n{failed.stderr.strip()}\n') try: diff --git a/tools/ninja/bootstrap.py b/tools/ninja/bootstrap.py new file mode 100644 index 00000000..a11e3cb0 --- /dev/null +++ b/tools/ninja/bootstrap.py @@ -0,0 +1,90 @@ +r""" +`build.ninja` into existence, and then Ninja -- which is the one step Ninja cannot do itself. + +The graph is generated and gitignored, so a fresh clone has none and `ninja` stops at + + ninja: error: loading 'build.ninja': No such file or directory + +naming a file nobody has heard of and saying nothing about a generator. Unlike `make`, which +finds a `Makefile` checked into the tree, Ninja has no bootstrap: the `generator = 1` edge keeps +an *existing* graph current -- it depends on `generate.py`, `writer.py` and every source +directory the graph was read from, so adding a problem or a language re-runs it mid-build -- but +it cannot write the first one. + +So every entry point calls `ensure_graph` before it calls Ninja. There are two: the editor, from +`tools/editor/app.py`'s `run_build`, and the command line, through `./dgs-build`. + +**The scope is `all`, deliberately.** A graph generated for one volume answers `unknown target` +for every other, which reads exactly like a broken build to somebody who was never told a graph +has a scope -- and regenerating the whole repository costs about a second. A narrower graph is +for someone who has asked for one, by running `tools.ninja.generate` themselves. + +**Only absence is ours to fix.** This never regenerates a graph that is merely old: that is the +generator edge's job, it knows what it read, and second-guessing it here would put two different +answers to "is the graph stale" in the tree. +""" +import os +import pathlib +import subprocess +import sys + +#: This file is `/tools/ninja/bootstrap.py`. +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent + + +def ensure_graph(root=ROOT, *, env=None, python=None): + """ + Generate `build.ninja` if it is not there. `None` when nothing was wrong, else the failure. + + `python` is the interpreter to generate with, and defaults to the one running this. The + generator imports nothing outside the standard library, but the project asks for 3.14 and a + system `python3` may be older, so the caller that knows better passes it. + """ + root = pathlib.Path(root) + graph = root / 'build.ninja' + if graph.is_file(): + return None + result = subprocess.run( + [python or sys.executable, '-m', 'tools.ninja.generate', + '--scope', 'all', '-o', str(graph)], + check=False, cwd=root, capture_output=True, text=True, timeout=300, env=env) + return None if result.returncode == 0 else result + + +def main(argv=None): + """ + `./dgs-build …`: make sure there is a graph, then become Ninja. + + How to invoke Ninja is `capabilities.probe_python_launcher`'s answer rather than one of our + own -- it is the same question the editor asks, and two answers to it would drift. That + module is under `tools/editor/` because the editor is what made it worth writing, and it + probes the machine rather than serving a page, so this is not a layering violation. + + `exec` rather than a subprocess, so Ninja owns the terminal: its one-line progress display + needs a tty, and Ctrl-C has to reach it rather than us. + """ + argv = sys.argv[1:] if argv is None else argv + sys.path.insert(0, str(ROOT)) + from tools.editor import capabilities + + launcher = capabilities.probe_python_launcher() + env = dict(os.environ) + if launcher['path_prefix']: + env['PATH'] = launcher['path_prefix'] + os.pathsep + env.get('PATH', '') + + if (failed := ensure_graph(ROOT, env=env)) is not None: + sys.stderr.write(f'could not generate build.ninja:\n{failed.stderr}') + return 1 + + os.chdir(ROOT) + try: + os.execvpe(launcher['argv'][0], [*launcher['argv'], *argv], env) + except FileNotFoundError: + sys.stderr.write( + f"{launcher['argv'][0]}: not found. " + f"{' or '.join(capabilities.INSTALL['ninja'])}\n") + return 127 + + +if __name__ == '__main__': + sys.exit(main()) From c83a7e199698d1ed5a481be723ebdb237dd8260b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 3 Nov 2026 07:15:49 +0000 Subject: [PATCH 17/28] Three things the graph was wrong about, found by building everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full cold rebuild of the last volume of every content repository -- phys/29, chem/04, test/01, FKS/42, TA1/2025, STA/2023 -- turned these up. They are independent but they are one file and one sitting, and they share a shape: the generator deciding what counts as content. **A venue is set in its own language.** `output/naboj/phys/29/all` failed 34 documents at once on `File 'build/core/i18n/cs.tex' not found`. The venue edges took the *volume's* languages, and a venue need not be in one of them: 29 has `en` and `sk` and runs in Praha, Olomouc, Budapest, Gdańsk, Lisboa, Madrid, Toruń, Wien, Wrocław and Uzhhorod. Under make nothing showed, because `i18n.stamp` built every locale whether anything asked for one or not. Each venue's documents now take their own locale, and that locale joins the set `core_edges` emits -- which also fixes the narrow case, where `--scope phys/29` emitted two locales and the Czech venue had no rule to reach a third. The language is read out of the venue meta, the first time this generator reads a file rather than a directory listing, so the meta joins the generator edge's inputs -- otherwise changing `language:` would leave the graph pointing at the locale the file used to name. A line scan, not `yaml.safe_load`, because `tools/ninja/bootstrap.py`'s last-resort path runs this module under whatever `python3` is on PATH. 69 of the 144 venue metas name no language at all (`phys/02`, `20`-`25`, several of them empty); they get no dependency and no guess, and they cannot build today for a reason that is not the graph's: `contexts/buildable.py:66` subscripts `i18n.languages[venue.language]` outright. **A dot directory is not a unit.** `pathlib.glob` is not the `glob` module -- `*` matches a leading dot -- so `seminar/FKS/.template` became six scopes and 48 problems of scaffolding, and `scholar/STA/2023/homework/02/.priors`, a draft kept beside the real problems, was rendered and failed on a meta the schema refuses. 118 scopes become 112 and 1893 problems 1845. **Scholar's `.static/` is copied.** The same gap seminar's had: every document of a course `\input`s `source/scholar//.static/format-override.tex` and nothing built it. The comment here used to say the fix could not be checked because scholar built under neither system; it builds now, and `STA/2023` goes from five failures to none. While in there, `A and B or C` in the scholar scope discovery gets the brackets it meant. Co-Authored-By: Claude Opus 5 --- tools/ninja/generate.py | 143 ++++++++++++++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 26 deletions(-) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index 1d9c6f4c..c0d4652e 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -341,7 +341,8 @@ def languages_of(volume: pathlib.Path) -> list[str]: that is not in this list simply has no edges and Ninja refuses it by name. """ root = volume / 'languages' - return sorted(child.name for child in root.iterdir() if child.is_dir()) if root.is_dir() else [] + return sorted(child.name for child in root.iterdir() + if child.is_dir() and _visible(child)) if root.is_dir() else [] def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: @@ -352,7 +353,8 @@ def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: w.newline() rules(w) - totals = {'edges': 0, 'problems': 0, 'pictures': 0, 'languages': [], 'listed': set()} + totals = {'edges': 0, 'problems': 0, 'pictures': 0, 'languages': [], + 'listed': set(), 'read': set()} needs = {'languages': set(), 'competitions': set()} # Two passes over the scopes: the first writes their edges into a buffer and collects what @@ -373,6 +375,7 @@ def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: totals[key] += counts[key] totals['languages'] += counts['languages'] totals['listed'] |= counts['listed'] + totals['read'] |= counts.get('read', set()) for key in ('languages', 'competitions'): needs[key] |= counts.get('needs', {}).get(key, set()) @@ -399,9 +402,13 @@ def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: 'mtime moves on exactly that event and no other. `naboj_problem_dirs` in ' 'modules/naboj/module.mk:114-132 is the same trick for the same reason -- the ' 'difference is that here it is needed in one place instead of on every document.') + w.comment('A handful of *files* are in there too. A venue meta names the language its ' + 'documents are set in, which decides which i18n table they need, and that is a ' + 'fact no directory listing holds -- so the file itself is watched, or changing ' + '`language:` would leave the graph pointing at the locale it used to say.') w.build('build.ninja', 'regenerate', None, implicit=['tools/ninja/generate.py', 'tools/ninja/writer.py', - *sorted(totals['listed'])], + *sorted(totals['listed']), *sorted(totals['read'])], variables={'scope': ' '.join(scopes)}) totals['edges'] += 1 @@ -449,7 +456,7 @@ def core_edges(repo: pathlib.Path, w: Writer, needs: dict) -> int: def scope_edges(repo: pathlib.Path, scope: str, w: Writer) -> dict: - counts = {'edges': 0, 'languages': [], 'listed': set()} + counts = {'edges': 0, 'languages': [], 'listed': set(), 'read': set()} def listed(directory: pathlib.Path) -> pathlib.Path: """Record a directory whose *listing* this graph depends on, and return it.""" @@ -465,7 +472,8 @@ def build(*args, **kwargs): volume = repo / 'source' / 'naboj' / scope listed(volume / 'languages') languages = languages_of(volume) - problems = sorted(p.parent for p in listed(volume / 'problems').glob('*/meta.yaml')) + problems = sorted(p.parent for p in listed(volume / 'problems').glob('*/meta.yaml') + if _visible(p.parent)) w.comment(f'==== {scope} ' + '=' * 60) @@ -631,13 +639,23 @@ def build(*args, **kwargs): w.comment('Venues. One `venue.py` invocation writes `instructions.tex`, `answers-modulo.tex` ' 'and, when the source is there, `instructions-inner.tex` for the venue\'s language.') venues = sorted(child.name for child in listed(volume / 'venues').iterdir() - if child.is_dir()) if (volume / 'venues').is_dir() else [] + if child.is_dir() and _visible(child)) if (volume / 'venues').is_dir() else [] for venue in venues: + venue_meta = f'source/naboj/{scope}/venues/{venue}/meta.yaml' + # **A venue is set in its own language, which need not be one of the volume's.** Volume + # 29 has `en` and `sk` and runs in Praha and Olomouc, so its Czech venue documents + # `\input{build/core/i18n/cs.tex}` -- a file nothing asked for, because the edges were + # taking the *volume's* languages. The whole of `all` failed on it, 34 documents at + # once, and it was invisible under make, whose `i18n.stamp` built every locale whether + # anything wanted it or not. + venue_language = _venue_language(repo / venue_meta) + counts['needs']['languages'] |= {venue_language} - {None} + counts['read'].add(venue_meta) outdir = f'build/naboj/{scope}/venues/{venue}' documents = [f'{outdir}/{name}.tex' for name in VENUE_DOCUMENTS] build(documents, 'venue', None, implicit=[*templates, static, - f'source/naboj/{scope}/venues/{venue}/meta.yaml', + venue_meta, f'source/naboj/{scope}/meta.yaml', f'source/naboj/{competition}/meta.yaml', *sorted(str((p / 'meta.yaml').relative_to(repo)) for p in problems)], @@ -647,7 +665,8 @@ def build(*args, **kwargs): pdf = f'output/naboj/{scope}/venues/{venue}/{name}.pdf' build(pdf, 'document_pdf', None, implicit=[f'{outdir}/{name}.tex', *pictures, static, 'core/latex/dgs.cls', - *[f'build/core/i18n/{l}.tex' for l in languages]], + *([f'build/core/i18n/{venue_language}.tex'] + if venue_language else [])], implicit_outputs=[pdf.replace('.pdf', e) for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], variables={'tex': f'{outdir}/{name}.tex'}) @@ -701,7 +720,7 @@ def build(*args, **kwargs): competition, volume_id, semester, round_id = scope.split('/') root = repo / 'source' / 'seminar' / scope - problems = sorted(p.parent for p in listed(root).glob('*/meta.yaml')) + problems = sorted(p.parent for p in listed(root).glob('*/meta.yaml') if _visible(p.parent)) w.comment(f'==== seminar/{scope} ' + '=' * 50) @@ -816,14 +835,24 @@ def build(*args, **kwargs): root = repo / 'source' / 'scholar' / scope w.comment(f'==== scholar/{scope} ' + '=' * 50) - # Scholar's `.static/` trees -- `source/scholar/{STA,TA1}/.static` -- get no - # `copy_static` edge and are a prerequisite of nothing, where every naboj document - # takes its competition's. Recorded rather than guessed at: scholar builds under - # neither system today, so a fix here could not be checked against anything. counts['needs'] = {'languages': {'sk'}, 'competitions': set()} templates = sorted(str(p.relative_to(repo)) for p in (repo / 'modules/scholar/templates').rglob('*.jtex')) + # `source/scholar//.static/`, which every document of that course `\input`s, copied + # one file to an edge exactly as seminar's is. It used to be a prerequisite of nothing at + # all, on the grounds that scholar built under neither system so a fix could not be checked; + # it builds now, and the first thing it did was stop at `File + # 'build/scholar/STA/.static/format-override.tex' not found`. + static = [] + for path in sorted(p for p in listed(repo / 'source/scholar' / course / '.static') + .rglob('*') if p.is_file()): + listed(path.parent) + source = path.relative_to(repo).as_posix() + target = source.replace('source/', 'build/', 1) + _copied(build, target, source) + static.append(target) + # **Fragments but no pages**, which is the one asymmetry with the other two modules. # `page.py` collects the targets `modules/scholar/editor.yaml` declares, and that descriptor # says `targets: [text]` where 28 of this module's issues hold a `content.md` instead -- so @@ -835,11 +864,13 @@ def build(*args, **kwargs): for kind, (builder, documents) in SCHOLAR_KINDS.items(): if not (root / kind).is_dir(): continue - for issue in sorted(i for i in listed(root / kind).iterdir() if i.is_dir()): + for issue in sorted(i for i in listed(root / kind).iterdir() + if i.is_dir() and _visible(i)): units += 1 issue_rel = issue.relative_to(repo / 'source').as_posix() # the issue's own text, then one directory per problem below it - places = [issue, *sorted(c for c in listed(issue).iterdir() if c.is_dir())] + places = [issue, *sorted(c for c in listed(issue).iterdir() + if c.is_dir() and _visible(c))] fragments = [] for place in places: rel = place.relative_to(repo / 'source').as_posix() @@ -883,7 +914,7 @@ def build(*args, **kwargs): for name in documents: pdf = f'output/{issue_rel}/{name}.pdf' build(pdf, 'document_pdf', None, - implicit=[f'{outdir}/{name}.tex', *fragments, *pictures, + implicit=[f'{outdir}/{name}.tex', *fragments, *pictures, *static, 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'], implicit_outputs=[pdf.replace('.pdf', e) for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], @@ -894,7 +925,8 @@ def build(*args, **kwargs): w.build(f'output/scholar/{scope}/{kind}', 'phony', [f'output/scholar/{scope}/{kind}/{i.name}/all' - for i in sorted(x for x in (root / kind).iterdir() if x.is_dir())]) + for i in sorted(x for x in (root / kind).iterdir() + if x.is_dir() and _visible(x))]) counts['edges'] += 1 w.newline() @@ -1048,6 +1080,50 @@ def _page(build, rel: str, lang: str, module: str, *, fragments: list, pictures: return f'{outdir}/index.html' +def _visible(path: pathlib.Path) -> bool: + """ + A directory whose name begins with a dot is not content, and nothing may treat it as one. + + Two were being built as though they were. `scholar/STA/2023/homework/02/.priors` is a draft + kept beside the real problems: it was rendered, and failed on a meta the problem schema + refuses. `seminar/FKS/.template` is the scaffolding a new volume is copied from, and + `--scope all` made six scopes out of it. Neither is a defect in the sources -- a leading dot + is how a filesystem has always said *this is not one of the others*, and `.static/` sits + beside the units in every module without anyone mistaking it for one. + """ + return not path.name.startswith('.') + + +def _venue_language(meta: pathlib.Path) -> str | None: + """ + The one thing in this generator read out of a file rather than off a directory listing. + + A venue's documents are set in the venue's language, which decides the i18n table they + `\\input`, and no listing anywhere holds that fact -- so it is read here, and the meta goes + into the generator edge's inputs so that changing `language:` regenerates the graph. + + A line scan rather than `yaml.safe_load`, because this module imports nothing outside the + standard library and `tools/ninja/bootstrap.py` leans on that: its last-resort path runs the + generator under whatever `python3` is on PATH, where the project's packages are not + importable. `language:` is a plain scalar in every venue meta that has one, and the schema + requires it to be one, so there is no YAML here to get wrong. + + **`None` where there is no key, and no guess.** 69 of the 144 venue metas have none -- all + of `phys/02` and `20`-`25`, several of them empty files -- and those venues cannot build + today for a reason that has nothing to do with this graph: `contexts/buildable.py:66` + subscripts `i18n.languages[venue.language]` outright, so the builder raises before anything + reads an i18n table. Falling back to the volume's language would paper over that with the + wrong locale; raising here would be worse still, because one incomplete venue in a volume + nobody builds would stop the generator emitting *any* scope's edges. + """ + for line in meta.read_text(encoding='utf-8').splitlines(): + key, separator, value = line.partition(':') + if (separator and key.strip() == 'language' + and (language := value.split('#')[0].strip().strip('\'"'))): + return language + return None + + def _beside(drawings: list, rel: str) -> list: """The pictures that sit in this unit's own directory, which are the ones its page names.""" return [picture for picture in drawings if picture.rsplit('/', 1)[0] == f'build/{rel}'] @@ -1066,15 +1142,30 @@ def main() -> int: scopes = args.scope if 'all' in scopes: scopes = [s for s in scopes if s != 'all'] - scopes += sorted(str(p.parent.relative_to(repo / 'source/naboj')) - for p in (repo / 'source/naboj').glob('*/*/problems') - if (p.parent / 'languages').is_dir()) - scopes += sorted(f'seminar:{p.relative_to(repo / "source/seminar")}' - for p in (repo / 'source/seminar').glob('*/*/*/*') - if p.is_dir() and any(p.glob('*/meta.yaml'))) - scopes += sorted(f'scholar:{p.relative_to(repo / "source/scholar")}' - for p in (repo / 'source/scholar').glob('*/*') - if p.is_dir() and (p / 'handouts').is_dir() or (p / 'homework').is_dir()) + + def named(root: pathlib.Path, pattern: str): + """Every directory matching `pattern` under `root`, by its path relative to it. + + `pathlib.glob` is not the `glob` module: `*` matches a leading dot here, which is + how `seminar:FKS/.template/1/1` became six scopes of a scaffolding directory. Every + part has to be visible, not only the last -- `.template/1/1` ends in `1`. + """ + for path in root.glob(pattern): + relative = path.relative_to(root) + if path.is_dir() and all(not part.startswith('.') for part in relative.parts): + yield path, relative + + scopes += sorted(str(relative.parent) + for path, relative in named(repo / 'source/naboj', '*/*/problems') + if (path.parent / 'languages').is_dir()) + scopes += sorted(f'seminar:{relative}' + for path, relative in named(repo / 'source/seminar', '*/*/*/*') + if any(path.glob('*/meta.yaml'))) + # `A and (B or C)`. Written without the brackets this was `(A and B) or C`, so a + # `homework/` directory got in whether or not its parent was a directory at all. + scopes += sorted(f'scholar:{relative}' + for path, relative in named(repo / 'source/scholar', '*/*') + if ((path / 'handouts').is_dir() or (path / 'homework').is_dir())) with args.output.open('w') as out: counts = generate(repo, scopes, out) print(f"{args.output}: {counts['edges']} edges, {len(scopes)} scopes, " From 4fc8b869dbe3dd3d96a045d1739b730ba1ff3b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 3 Nov 2026 08:21:29 +0000 Subject: [PATCH 18/28] A scholar issue reads what is under it, not the whole year The picture list accumulated across every issue of a year and every document took all of it, so one drawing that would not render stopped the issues after it as well: `TA1/2025/homework/03/monte-carlo.gp` fails on its own line 1, and `04` -- which names no drawing of 03's -- never got built at all. It is collected per issue now and added to the year's total afterwards, so the count the generator prints is unchanged. An issue is a document; what it depends on is what sits under it. The other two modules were already right for their own shape: a naboj volume's documents really do take every problem's drawings, and seminar collects per round, which is its scope. Co-Authored-By: Claude Opus 5 --- tools/ninja/generate.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index c0d4652e..20ddf1ec 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -860,6 +860,10 @@ def build(*args, **kwargs): # The `.html` fragments are still built, because those are what the editor's HTML tab shows # and they are right whatever the descriptor calls them. Settling `content` against `text` # is scholar's own question and is noted at `SCHOLAR_TARGETS`. + # **Per issue, not per year.** These used to accumulate over the whole loop and every + # document took the lot, so one plot that would not draw stopped the issues *after* it as + # well: `2025/homework/03/monte-carlo.gp` took `04` down with it although `04` names no + # drawing of 03's. An issue is a document; what it reads is what is under it. units, pictures = 0, [] for kind, (builder, documents) in SCHOLAR_KINDS.items(): if not (root / kind).is_dir(): @@ -868,6 +872,7 @@ def build(*args, **kwargs): if i.is_dir() and _visible(i)): units += 1 issue_rel = issue.relative_to(repo / 'source').as_posix() + drawn = [] # the issue's own text, then one directory per problem below it places = [issue, *sorted(c for c in listed(issue).iterdir() if c.is_dir() and _visible(c))] @@ -889,14 +894,14 @@ def build(*args, **kwargs): # is still worth having: `build/.svg` is a target anyone may ask # for, and it is the same picture the booklet prints. printed, _ = _picture(build, w, stem, extension, place, repo) - pictures.append(printed) + drawn.append(printed) for extension in PICTURE_COPIES: for picture in sorted(place.glob(f'*.{extension}')): stem = f'{rel}/{picture.stem}' if _already_drawn(w, stem, extension): continue _copied(build, f'build/{stem}.{extension}', f'source/{stem}.{extension}') - pictures.append(f'build/{stem}.{extension}') + drawn.append(f'build/{stem}.{extension}') if extension == 'pdf': _picture_svg(build, w, stem, extension) for name in SCHOLAR_TARGETS: @@ -914,7 +919,7 @@ def build(*args, **kwargs): for name in documents: pdf = f'output/{issue_rel}/{name}.pdf' build(pdf, 'document_pdf', None, - implicit=[f'{outdir}/{name}.tex', *fragments, *pictures, *static, + implicit=[f'{outdir}/{name}.tex', *fragments, *drawn, *static, 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'], implicit_outputs=[pdf.replace('.pdf', e) for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], @@ -922,6 +927,7 @@ def build(*args, **kwargs): w.build(f'output/{issue_rel}/all', 'phony', [f'output/{issue_rel}/{name}.pdf' for name in documents]) counts['edges'] += 1 + pictures += drawn w.build(f'output/scholar/{scope}/{kind}', 'phony', [f'output/scholar/{scope}/{kind}/{i.name}/all' From 57ad626ca05075269c554bf02970f0066625812a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 3 Nov 2026 08:33:06 +0000 Subject: [PATCH 19/28] Scholar's plots set a font the terminal already set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recorded rather than fixed. All nine of scholar's gnuplot scripts write `set terminal @gpterm size … font "Minion Pro, 10"`, and `@gpterm` carries the font, so pdfcairo refuses the line: *Duplicated or contradicting arguments in cairo terminal options*. They have been broken since `@gpterm` landed and nothing reported it, because nothing built scholar until the Ninja graph did. The fix is a choice rather than a repair -- drop the font and the labels come out at 6pt instead of the 10pt somebody chose for a 510pt canvas, or teach `@gpterm` a size, which is a change in dgs. Both wait; scholar is not being run and Náboj is. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index eb47c257..535037e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -587,6 +587,26 @@ they still opened `set terminal pdf size 6, 1.5` when the web path was added, an `FKS/42/1/1/03`'s `blocks:` preamble, which is a thirteenth script that no `.gp` on disk carries. All four say `@gpterm` now. +> **Open: scholar's nine say `@gpterm` and then set a font of their own, and none of them +> draws.** Every one of them -- the `monte-carlo.gp` of `TA1/2018`, `2019`, `2022`, `2024` and +> `2025`, the `voigt.gp` of `2018`, `2019` and `2022`, plus the copy inside `TA1/2025/2025/` -- +> opens +> +> set terminal @gpterm size 510.236*gppt, 311.811*gppt font "Minion Pro, 10" +> +> and `@gpterm` already carries the font, so pdfcairo refuses the line outright: *Duplicated or +> contradicting arguments in cairo terminal options*. They have been broken since `@gpterm` +> landed and nothing said so, because nothing built scholar until the Ninja graph did. It is +> one failing edge today, `build/scholar/TA1/2025/homework/03/monte-carlo.pdf`, and it is the +> only thing between `TA1/2025` and a clean build. +> +> **Deliberately not fixed, and the reason is that the fix is a choice.** Deleting the trailing +> `font` makes all nine build at once, at the 6pt the build supplies rather than the 10pt their +> authors asked for on a 510pt canvas -- visibly smaller labels on somebody else's drawings. +> Letting `@gpterm` take a size instead is a change here rather than in the content, and it is +> the only way a script can ask for larger type without re-specifying the face. Neither is +> urgent: scholar is not being run, and Náboj is. + **`set terminal pdf` left in a script is not a print defect and is a web one**, which is what made it survive. Re-issued over a `pdfcairo` that is already current it keeps the font *and* the `fontscale`, so the printed figure is unchanged -- all six of those plots are pixel-identical From a550af2dc67ca8c0936a156a399917e77bee416b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Tue, 3 Nov 2026 08:42:26 +0000 Subject: [PATCH 20/28] Seminar and scholar get the preview the editor asks for `modules//editor.yaml` names `output//standalone.pdf` as its `preview:` and `/api/compile` builds that and nothing else, so a module whose standalone has no edge has an editor that renders Markdown, converts TeX and then cannot show the page. The graph had 3523 of naboj's and none of the other two, while make builds all three -- the one place where the spike had become a regression rather than an addition, because `capabilities.py` and `run_build` were switched to Ninja a while ago and the Makefile was not consulted again. A second rule, because the two builders take the unit as one path where naboj's takes the competition, volume, problem and language apart; `-l` defaults to `source//`, so what they are given is the path below that and not the one the graph uses. Scholar gets one for both shapes `editor.yaml` declares -- the sheet with its `text.md`, and a problem below it. The edge names only the unit's own sources, which keeps the property both builders were written for: each reads its meta through a `read_yaml` that answers `{}` to anything unparseable, so a preview compiles for the problem in front of you even when the round around it does not. Checked by building four of them -- two seminar problems, a scholar sheet and a scholar problem -- one page each, the figure of `FKS/42/1/3/03` among them. Co-Authored-By: Claude Opus 5 --- core/tests/test_ninja_graph.py | 39 +++++++++++++++++++++++- tools/ninja/generate.py | 54 +++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/core/tests/test_ninja_graph.py b/core/tests/test_ninja_graph.py index 655ddf8f..f3c45029 100644 --- a/core/tests/test_ninja_graph.py +++ b/core/tests/test_ninja_graph.py @@ -18,7 +18,7 @@ import pytest -from tools.ninja.generate import _beside, _fragment, _page, _picture_svg +from tools.ninja.generate import _beside, _fragment, _page, _picture_svg, _standalone from tools.ninja.writer import Writer REPO = pathlib.Path('/repo') @@ -141,3 +141,40 @@ def test_only_the_ones_in_the_units_own_directory(self): 'build/scholar/TA1/2018/handouts/02/01/graph.svg'] assert _beside(drawings, 'scholar/TA1/2018/handouts/02') == [drawings[0]] assert _beside(drawings, 'scholar/TA1/2018/handouts/02/01') == [drawings[1]] + + +class TestTheEditorsPreview: + """ + `modules//editor.yaml` names `output//standalone.pdf` as its `preview:`, and + `/api/compile` asks for that and nothing else -- so a module whose standalone has no edge + has an editor that renders and converts and then cannot show the page. Seminar and scholar + were in exactly that state while make built both. + """ + + def test_it_builds_the_target_the_descriptor_names(self, writer): + assert _standalone(writer.build, 'seminar', 'seminar/FKS/42/1/3/01', + [], [], 'sk') == 'output/seminar/FKS/42/1/3/01/standalone.pdf' + + def test_the_builder_is_given_the_path_below_its_own_source_root(self, writer): + """ + `-l/--launch` defaults to `source//`, so the argument is the unit *below* that. + Passing the whole `seminar/FKS/...` would look right and resolve one level too high. + """ + _standalone(writer.build, 'seminar', 'seminar/FKS/42/1/3/01', [], [], 'sk') + assert '\n unit = FKS/42/1/3/01\n' in written(writer) + + def test_a_scholar_sheet_and_a_problem_below_it_each_get_one(self, writer): + """Both shapes `editor.yaml` declares: the issue holding `text.md`, and a problem.""" + for rel in ('scholar/TA1/2025/handouts/01', 'scholar/TA1/2025/handouts/01/1-wien'): + assert _standalone(writer.build, 'scholar', rel, [], [], 'sk') \ + == f'output/{rel}/standalone.pdf' + + def test_the_pdf_waits_for_the_tex_and_the_class(self, writer): + _standalone(writer.build, 'seminar', 'seminar/FKS/42/1/3/01', + ['build/seminar/FKS/42/1/3/01/problem.tex'], [], 'sk') + pdf = next(line for line in written(writer).splitlines() + if 'document_pdf' in line) + for wanted in ('build/seminar/FKS/42/1/3/01/standalone.tex', + 'build/seminar/FKS/42/1/3/01/problem.tex', + 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'): + assert wanted in pdf, wanted diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index 20ddf1ec..ad5bf31a 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -254,6 +254,12 @@ def rules(w: Writer) -> None: "mkdir -p $outdir && $run -m modules.naboj.builder.standalone " "$competition $volume $problem $lang -o '$outdir'", description=described('{a}Building {x}standalone{a} for {f}$outdir')) + # Seminar's and scholar's standalone builders take the unit as one path and find their own + # templates, where naboj's takes the competition, volume, problem and language apart. Two + # rules rather than one contorted one. + w.rule('unit_standalone_tex', + "mkdir -p $outdir && $run -m modules.$module.builder.standalone $unit -o '$outdir'", + description=described('{a}Building {x}standalone{a} for {f}$outdir')) w.rule('scholar_issue', "mkdir -p $outdir && $run -m modules.scholar.builder.$builder 'source/scholar/' " "'modules/scholar/templates/' $course $year $issue -o '$outdir'", @@ -768,14 +774,22 @@ def build(*args, **kwargs): pictures.append(f'build/{stem}.{extension}') drawings.append(_picture_svg(build, w, stem, extension) if extension == 'pdf' else f'build/{stem}.{extension}') - web = [] + web, mine = [], [] for name in SEMINAR_TARGETS: if (problem / f'{name}.md').is_file(): tex, fragment = _fragment(build, f'source/{rel}/{name}.md', f'{rel}/{name}', 'sk', meta, repo, SEMINAR_RENDERER) fragments[name].append(tex) + mine.append(tex) web.append(fragment) + + # The standalone: one problem, one PDF, and **this is the editor's preview** -- + # `modules/seminar/editor.yaml`'s `preview:` names it and `/api/compile` asks for + # nothing else. It was missing while naboj's was there, so the editor could render and + # convert a seminar problem and then not show it, which reads as a broken button. + _standalone(build, 'seminar', rel, [*mine, *_beside(pictures, rel), *static], + templates, 'sk') # A seminar page sits in the problem directory, not in a language directory under it: # seminar is monolingual, which `modules/seminar/editor.yaml` says by carrying no # `languages:` key and which `page.py` reads from there. @@ -904,12 +918,20 @@ def build(*args, **kwargs): drawn.append(f'build/{stem}.{extension}') if extension == 'pdf': _picture_svg(build, w, stem, extension) + mine = [] for name in SCHOLAR_TARGETS: if (place / f'{name}.md').is_file(): tex, _ = _fragment(build, f'source/{rel}/{name}.md', f'{rel}/{name}', 'sk', meta.resolve(), repo, SCHOLAR_RENDERER) fragments.append(tex) + mine.append(tex) + + # The editor's preview, for both shapes a scholar unit comes in: the sheet + # itself, which holds `text.md`, and a problem below it. `editor.yaml` names + # the same target for all four of its unit kinds. + _standalone(build, 'scholar', rel, [*mine, *_beside(drawn, rel), *static], + templates, 'sk') outdir = f'build/{issue_rel}' build([f'{outdir}/{name}.tex' for name in documents], 'scholar_issue', None, @@ -1130,6 +1152,36 @@ def _venue_language(meta: pathlib.Path) -> str | None: return None +def _standalone(build, module: str, rel: str, inputs: list, templates: list, lang: str) -> str: + """ + One unit, one PDF: the document `modules//editor.yaml` names as its `preview:`. + + naboj's is written at its own call site, because its builder takes the competition, volume, + problem and language apart where these two take the unit as one path. What the three share + is the reason for existing: `/api/compile` asks for this target and nothing else, so a + module whose standalone has no edge has an editor that renders Markdown, converts TeX and + then cannot show the page. That was seminar and scholar until now, and make built both. + + The builders are deliberately forgiving -- each reads its meta through a `read_yaml` that + answers `{}` to anything unparseable -- because a preview has to compile for the problem in + front of you even when the round around it does not. This edge keeps that: the only source + it names is the unit's own. + """ + unit = rel.split('/', 1)[1] # the path below `source//` + outdir = f'build/{rel}' + build(f'{outdir}/standalone.tex', 'unit_standalone_tex', None, + implicit=[*templates, f'source/{rel}/meta.yaml'], + variables={'module': module, 'unit': unit, 'outdir': outdir}) + pdf = f'output/{rel}/standalone.pdf' + build(pdf, 'document_pdf', None, + implicit=[f'{outdir}/standalone.tex', *inputs, + f'build/core/i18n/{lang}.tex', 'core/latex/dgs.cls'], + implicit_outputs=[pdf.replace('.pdf', e) + for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], + variables={'tex': f'{outdir}/standalone.tex'}) + return pdf + + def _beside(drawings: list, rel: str) -> list: """The pictures that sit in this unit's own directory, which are the ones its page names.""" return [picture for picture in drawings if picture.rsplit('/', 1)[0] == f'build/{rel}'] From 432ecc7c001190518912cfc2dbc313fbf7c8835d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 02:01:45 +0000 Subject: [PATCH 21/28] The names make offered for a seminar round, kept Three of them had no edge, so a build command that worked yesterday answered `unknown target` today -- which is the right answer for a volume with no Ukrainian and the wrong one for a target nobody retired. `instagram.pdf` is built now. The round builder already wrote `instagram.tex` and the graph stopped there; it stays out of `all`, which is `modules/seminar/module.mk:270`'s own reading -- its line in the aggregate is commented out, and what a round is run for is its problems and its solutions. `output/.../instagram` splits that into one PNG per page, as make did. How many there are depends on how long the document came out, so the outputs cannot be declared: a stamp carries the date, the way `burst` already does, and a phony carries the name people type. Make had it as a file target whose recipe never created it, so it re-ran on every build. `pdf`, `problems` and `solutions` are phonies over what they were there, and the last two reach the HTML fragments as well, which is what `html-problems` and `html-solutions` were for. **`output/*/copy` is deliberately not here.** Those two are deployment -- naboj's rsyncs to `ago:/home/balaz/public_html/`, seminar's shells out to `dgs-copy.py` with a `$(user)` the Makefile never defines, so it has been passing an empty argument. A build graph that uploads to a host when you name a target is a surprise worth not building; they want to be a script, and until they are, `make` still has them. Co-Authored-By: Claude Opus 5 --- tools/ninja/generate.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index ad5bf31a..c9b4e966 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -59,6 +59,7 @@ 'problems': ('problem',), 'solutions': ('solution',), 'solutions-full': ('problem', 'solution'), + 'instagram': ('problem',), } #: Scholar has two kinds of issue and each has its own builder, writing three documents and two @@ -102,6 +103,9 @@ #: `instagram` is built but its `output/` rule is commented out in `modules/seminar/module.mk:270`; #: it is kept here as a `.tex` so nothing is lost, and not as a PDF so nothing new appears. ROUND_DOCUMENTS = ('problems', 'solutions', 'solutions-full', 'instagram') +#: What `all` is. `instagram.pdf` is built on request and is deliberately not in it, which is +#: `modules/seminar/module.mk:270`'s reading too -- its line in the round aggregate is commented +#: out, and what a round is run for is its problems and its solutions. ROUND_PDFS = ('problems', 'solutions', 'solutions-full') #: A seminar problem's two fragments. Seminar is monolingual -- one language, not one of several @@ -295,6 +299,13 @@ def rules(w: Writer) -> None: w.rule('burst', 'pdftk $in burst output $$(dirname $in)/%02d.pdf && touch $out', description=described('{a}[pdftk] Splitting {f}$in{a} into individual pages')) + # `pdftoppm` names its files `-01.png` and how many there are depends on how long + # the document came out, so the outputs cannot be declared -- the same shape as `burst` + # above, and the same answer: a stamp Ninja can date, beside the files themselves. + w.rule('page_images', + 'mkdir -p $$(dirname $prefix) && pdftoppm -png -r 150 -aa yes -aaVector yes ' + '$in $prefix && mkdir -p $$(dirname $out) && touch $out', + description=described('{a}[pdftoppm] Rendering {f}$in{a} to images at {f}$prefix')) w.rule('tearoff_zip', 'mkdir -p $$(dirname $out) && rm -f $out && $run -m tools.ninja.zip $out $in', description=described('{a}Packing {x}tear-offs{a} into {f}$out')) @@ -748,6 +759,7 @@ def build(*args, **kwargs): static.append(target) fragments = {name: [] for name in SEMINAR_TARGETS} + web_fragments = {name: [] for name in SEMINAR_TARGETS} pictures, drawings, pages = [], [], [] for problem in problems: rel = listed(problem).relative_to(repo / 'source').as_posix() @@ -781,6 +793,7 @@ def build(*args, **kwargs): f'{rel}/{name}', 'sk', meta, repo, SEMINAR_RENDERER) fragments[name].append(tex) + web_fragments[name].append(fragment) mine.append(tex) web.append(fragment) @@ -805,7 +818,7 @@ def build(*args, **kwargs): variables={'competition': competition, 'volume': volume_id, 'semester': semester, 'round': round_id, 'outdir': outdir}) - for name in ROUND_PDFS: + for name in ROUND_DOCUMENTS: pdf = f'output/seminar/{scope}/{name}.pdf' included = [f for kind in SEMINAR_DOCUMENT_GROUPS[name] for f in fragments[kind]] build(pdf, 'document_pdf', None, @@ -815,10 +828,26 @@ def build(*args, **kwargs): for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], variables={'tex': f'{outdir}/{name}.tex'}) + # The instagram pictures: one PNG per page of `instagram.pdf`, under the name make gave + # them. `output/.../instagram` was a file target there that the recipe never created, so it + # re-ran on every build; here the stamp carries the date and the phony carries the name. + build(f'{outdir}/instagram.images', 'page_images', + [f'output/seminar/{scope}/instagram.pdf'], + variables={'prefix': f'output/seminar/{scope}/instagram'}) + + # The names make offered, kept. `all` and `pages` are this generator's; `pdf`, `problems` + # and `solutions` are `modules/seminar/module.mk:258-270`, and the last two reach the HTML + # fragments as well, exactly as `html-problems` and `html-solutions` did there. w.build(f'output/seminar/{scope}/all', 'phony', [f'output/seminar/{scope}/{name}.pdf' for name in ROUND_PDFS]) + w.build(f'output/seminar/{scope}/pdf', 'phony', + [f'output/seminar/{scope}/{name}.pdf' for name in ('problems', 'solutions')]) + for name, kind in (('problems', 'problem'), ('solutions', 'solution')): + w.build(f'output/seminar/{scope}/{name}', 'phony', + [f'output/seminar/{scope}/{name}.pdf', *web_fragments[kind]]) + w.build(f'output/seminar/{scope}/instagram', 'phony', [f'{outdir}/instagram.images']) w.build(f'output/seminar/{scope}/pages', 'phony', pages) - counts['edges'] += 2 + counts['edges'] += 6 w.newline() counts['problems'] = len(problems) From 2305cd336cc7951487ce332da611f183dd79500d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 02:01:55 +0000 Subject: [PATCH 22/28] The container and CI can reach the graph `./dgs-container ninja ` alongside `make`. It runs `./dgs-build` rather than `ninja`, because a fresh mount has no `build.ninja` and no image can carry one -- the graph is a function of `source/`, which is bind-mounted from the host and deliberately never baked. No apt package was needed: `ninja` is a project dependency, so `uv sync` has already put it in `/opt/venv/bin`, which is first on PATH. CI gets the two steps its commented-out `make` line was standing in for. The generator runs on its own, so a crash or a pair of edges claiming one output is named in under two seconds instead of arriving as a Ninja parse error. Then one real round is built end to end -- Jinja, pandoc, the pictures, two xelatex passes -- which is the first time this repository has compiled anything in CI. The round is **read out of the graph**, not typed into the workflow: a path written there is a second copy of this repository's layout and goes stale in silence, which is what the line being replaced had already done. The testing repository is also cloned to `source/seminar/test` now, where `modules/seminar/editor.yaml` says it goes, rather than `.../testing`; nothing read it before, and a scope is named after its directory. The discovery line is tested here against `source/seminar/FKS`, where it finds `FKS/30/1/1` and the target resolves. The workflow itself is unverified until it runs. Co-Authored-By: Claude Opus 5 --- .github/workflows/python-app.yml | 31 +++++++++++++++++++++++++++---- dgs-container | 21 ++++++++++++++++++--- install.md | 2 +- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e8a1635c..d7916c37 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -135,12 +135,35 @@ jobs: - name: Test with pytest run: | uv run python -m pytest -s -v + # `source/seminar/test`, which is where `modules/seminar/editor.yaml` says this repository + # goes. It was cloned to `.../testing` while nothing read it; a scope is named after the + # directory, so the two had to agree before anything could be built by name. - name: "Clone testing repository" uses: actions/checkout@master with: repository: TrojstenSK/dgs-seminar-testing - path: source/seminar/testing - - name: "Build testing/01/1/1" + path: source/seminar/test + + # The graph before the build, as its own step. It reads the whole source tree and writes + # every edge, so a generator that crashes or emits two different edges for one output fails + # here -- named, and in under two seconds -- rather than as a confusing Ninja parse error in + # the step below. + - name: "Generate the build graph" + run: | + uv run python -m tools.ninja.generate --scope all + + # One real round, end to end: Jinja, pandoc, the pictures and two xelatex passes. The scope + # is read out of the graph rather than written here, because it is this repository's own + # layout and a path typed into CI is a second copy of it that goes stale silently -- which + # is exactly what the commented-out line this replaces had done. + - name: "Build a seminar round" run: | - pwd - # pipenv run make output/seminar/test/01/1/1 + set -euo pipefail + scope=$(./dgs-build -t targets all \ + | sed -n 's|^output/seminar/\(.*\)/all: phony.*|\1|p' | head -1) + if [ -z "$scope" ]; then + echo "::error::no seminar round in the graph; is source/seminar/test populated?" + exit 1 + fi + echo "building output/seminar/$scope/all" + ./dgs-build -k 0 "output/seminar/$scope/all" diff --git a/dgs-container b/dgs-container index a984b5b5..4f894826 100755 --- a/dgs-container +++ b/dgs-container @@ -4,8 +4,8 @@ # # This exists for two reasons, and brevity is not one of them: # -# - forgetting `-v "$PWD/output:/dgs/output"` is *silent*. make builds the booklet into the -# container's ephemeral layer, prints every success line, and the PDF is gone. +# - forgetting `-v "$PWD/output:/dgs/output"` is *silent*. The build writes the booklet into +# the container's ephemeral layer, prints every success line, and the PDF is gone. # - `build` reads the FontPro commit out of git, because `.git/` is not in the build context # and the Containerfile's ARG default can only ever be a second copy of it. set -euo pipefail @@ -26,7 +26,7 @@ mount=(-v "$PWD:/dgs") usage() { sed -n '2,9p' "$0" echo - echo "usage: $0 {build|editor|make|test|check|shell} [args...]" + echo "usage: $0 {build|editor|ninja|make|test|check|shell} [args...]" echo " DGS_TARGET=light|full (default full) DGS_PORT (default 5001)" } @@ -67,9 +67,24 @@ editor) python tools/editor/app.py --host 0.0.0.0 --port 5000 "$@" ;; +ninja) + shift + mkdir -p build render output + # `./dgs-build`, not `ninja`: a fresh mount has no `build.ninja` and nothing inside the + # image can have one, since the graph is a function of `source/`, which is bind-mounted + # from the host and never baked. The wrapper generates it on the first run. + # + # `ninja` itself needs no apt package here -- it is a project dependency, so `uv sync` + # already put it in /opt/venv/bin, which is first on PATH. + exec podman run --rm -it --network=none "${mount[@]}" "$image:$target" ./dgs-build "$@" + ;; + make) shift mkdir -p build render output + # The Makefile still builds every tree the Ninja graph does; it is kept while the graph is + # new, and this is how you reach it. See `ninja` above for the one that is being moved to. + # # --network=none: nothing in a build needs the network, UV_OFFLINE is already set, and # -shell-escape is unconditional, so this costs nothing and closes the one real hole. exec podman run --rm -it --network=none "${mount[@]}" "$image:$target" make "$@" diff --git a/install.md b/install.md index 3d9335b3..ca7d8990 100644 --- a/install.md +++ b/install.md @@ -27,7 +27,7 @@ an image: ``` ./dgs-container build # or: podman pull ghcr.io/trojstensk/dgs:full ./dgs-container editor # browser at localhost:5001 -./dgs-container make output/naboj/phys/29/languages/sk/booklet.pdf +./dgs-container ninja output/naboj/phys/29/languages/sk/booklet.pdf ``` `DGS_TARGET=light` gets you a much smaller image with the Markdown and TeX tiers but no PDF — From e81a6a21297f6bd0f171eda5825745c19a47cee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 03:15:12 +0000 Subject: [PATCH 23/28] Instagram has one page per problem, so the pages are declared `instagram.jtex` sets each problem on its own page and ends it with a `\clearpage`, so the document's page count is the round's problem count -- a fact the generator already holds. The split is therefore not `burst`, whose count genuinely is unknowable, and the stamp it borrowed is gone: each PNG is a declared output of its own edge, named by `-f`/`-l` and `-singlefile` rather than by pdftoppm's digit-padding rule. The count is also checked, once per document, and every page hangs off the check. Without it a document that came out longer would be cut into images silently misnumbered -- `instagram-02.png` holding the tail of the first problem rather than the second -- which is worse than no image at all. pdftoppm does not treat a page number past the end as an error, it writes nothing, so the per-page edges end in `test -f` as well. **No round satisfies the invariant today**, which is what the check is for: FKS/42/1/1 is 12 pages for 7 problems, 42/1/2 is 10, 42/1/3 is 12 and 41/1/1 is 9. The cause is the problems, not the template -- 1800 characters at 12pt do not fit a 160mm square, and `\vspace*{\fill}` then spends a whole page on the glue alone. `output//instagram` is red everywhere until that is settled; it is in no aggregate, so nothing else notices. Co-Authored-By: Claude Opus 5 --- core/tests/test_ninja_graph.py | 42 +++++++++++++++++++++- tools/ninja/generate.py | 66 ++++++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/core/tests/test_ninja_graph.py b/core/tests/test_ninja_graph.py index f3c45029..ce8a6d0e 100644 --- a/core/tests/test_ninja_graph.py +++ b/core/tests/test_ninja_graph.py @@ -18,7 +18,14 @@ import pytest -from tools.ninja.generate import _beside, _fragment, _page, _picture_svg, _standalone +from tools.ninja.generate import ( + _beside, + _fragment, + _instagram, + _page, + _picture_svg, + _standalone, +) from tools.ninja.writer import Writer REPO = pathlib.Path('/repo') @@ -178,3 +185,36 @@ def test_the_pdf_waits_for_the_tex_and_the_class(self, writer): 'build/seminar/FKS/42/1/3/01/problem.tex', 'build/core/i18n/sk.tex', 'core/latex/dgs.cls'): assert wanted in pdf, wanted + + +class TestTheInstagramPages: + """ + One page per problem is a fact about `instagram.jtex`, not a thing to discover by listing + whatever `pdftoppm` happened to write -- so the pages are declared, and a document that + does not have that many stops the build instead of being cut into misnumbered images. + """ + + PREFIX = 'output/seminar/FKS/42/1/3/instagram' + COUNTED = 'build/seminar/FKS/42/1/3/instagram.pages' + + def test_there_is_one_image_per_problem(self, writer): + assert _instagram(writer.build, self.PREFIX, self.COUNTED, 3) == [ + f'{self.PREFIX}-01.png', f'{self.PREFIX}-02.png', f'{self.PREFIX}-03.png'] + + def test_every_page_waits_for_the_count_to_be_checked(self, writer): + _instagram(writer.build, self.PREFIX, self.COUNTED, 3) + pages = [line for line in written(writer).splitlines() if 'page_image' in line] + assert len(pages) == 3 + for page in pages: + assert f'{self.PREFIX}.pdf | {self.COUNTED}' in page, page + + def test_the_check_is_told_how_many_pages_to_expect(self, writer): + _instagram(writer.build, self.PREFIX, self.COUNTED, 7) + written_out = written(writer) + assert f'build {self.COUNTED}: page_count {self.PREFIX}.pdf' in written_out + assert '\n pages = 7\n' in written_out + + def test_a_page_names_itself_as_pdftoppms_prefix(self, writer): + """`-singlefile` writes `.png`, so the prefix is the output without its suffix.""" + _instagram(writer.build, self.PREFIX, self.COUNTED, 1) + assert f'\n prefix = {self.PREFIX}-01\n' in written(writer) diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index c9b4e966..a017486c 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -299,13 +299,29 @@ def rules(w: Writer) -> None: w.rule('burst', 'pdftk $in burst output $$(dirname $in)/%02d.pdf && touch $out', description=described('{a}[pdftk] Splitting {f}$in{a} into individual pages')) - # `pdftoppm` names its files `-01.png` and how many there are depends on how long - # the document came out, so the outputs cannot be declared -- the same shape as `burst` - # above, and the same answer: a stamp Ninja can date, beside the files themselves. - w.rule('page_images', - 'mkdir -p $$(dirname $prefix) && pdftoppm -png -r 150 -aa yes -aaVector yes ' - '$in $prefix && mkdir -p $$(dirname $out) && touch $out', - description=described('{a}[pdftoppm] Rendering {f}$in{a} to images at {f}$prefix')) + # One page of a PDF to one PNG. Unlike `burst` above the count is **not** unknowable here: + # `instagram.jtex` ends every problem with a `\clearpage`, so the document is one page per + # problem and the generator knows how many problems there are. So the pages are declared, + # one edge each, and `-f`/`-l`/`-singlefile` let the name be ours rather than pdftoppm's + # digit-padding rule. `test -f` is what makes a page that did not come out loud: a page + # number past the end of the document is not an error to pdftoppm, it simply writes + # nothing, and an undeclared-looking silence is exactly what this build is migrating away + # from. + # An instagram document is one page per problem -- `instagram.jtex` ends each with a + # `\clearpage` -- so the page count is a fact the generator already knows, and a document + # that does not have it is a layout defect rather than a thing to render anyway. Checked + # once per document; every page then hangs off the check, so no PNG is cut from a page that + # belongs to the problem before it. + w.rule('page_count', + "count=$$(pdfinfo $in | sed -n 's/^Pages: *//p'); " + 'if [ "$$count" != "$pages" ]; then ' + 'echo "$in has $$count pages, one per problem would be $pages" >&2; exit 1; fi; ' + 'mkdir -p $$(dirname $out) && touch $out', + description=described('{a}Checking {f}$in{a} is {x}$pages{a} pages')) + w.rule('page_image', + 'mkdir -p $$(dirname $out) && pdftoppm -png -r 150 -aa yes -aaVector yes ' + '-f $page -l $page -singlefile $in $prefix && test -f $out', + description=described('{a}[pdftoppm] Rendering page {x}$page{a} of {f}$in')) w.rule('tearoff_zip', 'mkdir -p $$(dirname $out) && rm -f $out && $run -m tools.ninja.zip $out $in', description=described('{a}Packing {x}tear-offs{a} into {f}$out')) @@ -828,12 +844,8 @@ def build(*args, **kwargs): for e in ('.aux', '.log', '.out', '.synctex.gz', '.fls')], variables={'tex': f'{outdir}/{name}.tex'}) - # The instagram pictures: one PNG per page of `instagram.pdf`, under the name make gave - # them. `output/.../instagram` was a file target there that the recipe never created, so it - # re-ran on every build; here the stamp carries the date and the phony carries the name. - build(f'{outdir}/instagram.images', 'page_images', - [f'output/seminar/{scope}/instagram.pdf'], - variables={'prefix': f'output/seminar/{scope}/instagram'}) + images = _instagram(build, f'output/seminar/{scope}/instagram', + f'{outdir}/instagram.pages', len(problems)) # The names make offered, kept. `all` and `pages` are this generator's; `pdf`, `problems` # and `solutions` are `modules/seminar/module.mk:258-270`, and the last two reach the HTML @@ -845,7 +857,7 @@ def build(*args, **kwargs): for name, kind in (('problems', 'problem'), ('solutions', 'solution')): w.build(f'output/seminar/{scope}/{name}', 'phony', [f'output/seminar/{scope}/{name}.pdf', *web_fragments[kind]]) - w.build(f'output/seminar/{scope}/instagram', 'phony', [f'{outdir}/instagram.images']) + w.build(f'output/seminar/{scope}/instagram', 'phony', images) w.build(f'output/seminar/{scope}/pages', 'phony', pages) counts['edges'] += 6 w.newline() @@ -1211,6 +1223,32 @@ def _standalone(build, module: str, rel: str, inputs: list, templates: list, lan return pdf +def _instagram(build, prefix: str, counted: str, problems: int) -> list: + """ + The instagram pictures: one PNG per problem, under the name make gave them. + + The count is not a thing to discover after the fact. `modules/seminar/templates/instagram.jtex` + sets each problem on its own page and ends it with a `\\clearpage`, so the document is one + page per problem and the generator knows how many problems a round has -- which is why this + is not `burst`, whose page count genuinely is unknowable until pdftk has run. Every PNG is + a declared output, and `output//instagram` was a *file* target in make whose recipe + never created it, so it re-ran on every build. + + The count is also checked, once per document rather than once per page, and every page hangs + off the check. Without it a document that came out longer would be cut into images silently + misnumbered -- `instagram-02.png` holding the tail of the first problem rather than the + second, which is worse than no image at all. + """ + build(counted, 'page_count', [f'{prefix}.pdf'], variables={'pages': str(problems)}) + images = [] + for number in range(1, problems + 1): + image = f'{prefix}-{number:02d}.png' + build(image, 'page_image', [f'{prefix}.pdf'], implicit=[counted], + variables={'page': str(number), 'prefix': image.removesuffix('.png')}) + images.append(image) + return images + + def _beside(drawings: list, rel: str) -> list: """The pictures that sit in this unit's own directory, which are the ones its page names.""" return [picture for picture in drawings if picture.rsplit('/', 1)[0] == f'build/{rel}'] From fcbca7928e1a11e761d853d130f8e0400eea6efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 03:15:20 +0000 Subject: [PATCH 24/28] Deployment is not a build target `output/naboj/%/copy` rsynced a volume to `ago:/home/balaz/public_html/` and `output/seminar/%/copy` shelled out to `dgs-copy.py`, and neither was ever a build: they were a temporary workaround that stuck. A graph that uploads to a host because you named a target is a surprise worth not having, and it is the one kind of edge that cannot be undone by `rm -rf build/`. Both are gone from make and neither was ported to the Ninja graph, so deployment now lives nowhere on it. `dgs-copy.py` stays as the script it always was, to be run by hand. The seminar one had never worked anyway: `$(user)` is defined nowhere in the Makefile, so the recipe passed an empty fifth argument for as long as it has existed. Co-Authored-By: Claude Opus 5 --- modules/naboj/module.mk | 4 ---- modules/seminar/module.mk | 5 ----- 2 files changed, 9 deletions(-) diff --git a/modules/naboj/module.mk b/modules/naboj/module.mk index 74f899ad..d6c6e62e 100644 --- a/modules/naboj/module.mk +++ b/modules/naboj/module.mk @@ -681,7 +681,3 @@ output/naboj/%/venues: \ output/naboj/%/all: \ output/naboj/%/languages \ output/naboj/%/venues ; - -output/naboj/%/copy: \ - output/naboj/% - rsync -rzvhPL output/naboj/$*/ ago:/home/balaz/public_html/naboj/$(subst output/,,$*) diff --git a/modules/seminar/module.mk b/modules/seminar/module.mk index fb96eb79..4c65db05 100644 --- a/modules/seminar/module.mk +++ b/modules/seminar/module.mk @@ -271,8 +271,3 @@ output/seminar/%: \ # output/seminar/$$*/instagram ; .PHONY: - -output/seminar/%/copy: \ - output/seminar/%/ - $(eval words := $(subst /, ,$*)) - python ./dgs-copy.py $(word 1,$(words)) $(word 2,$(words)) $(word 3,$(words)) $(word 4,$(words)) $(user) From 54409cb471d2f65977ad674181b56f53c2edaf21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 03:25:16 +0000 Subject: [PATCH 25/28] A bare `ninja` is not a four-hour build Ninja's default, unsaid, is every root of the graph -- here 33 929 outputs, the whole repository down to each unit's preview PDF. A bare `make` touched a stamp and stopped, so the two spellings of "I typed the command to see what happens" differed by about four hours of xelatex. `default core` now, where `core` is the i18n tables: core's own, needed by every other edge, and a second or two. `Writer.default` is the one line of manifest syntax that was missing. Along the way, `probe_advisories` learns the three tools that take a finished PDF apart -- `pdftk` for a volume's online burst, `pdftoppm` for a round's instagram images, `pdfjam` for a semester's imposition. None was listed, which is how this machine reports the PDF tier green, has no `pdftk`, and fails exactly one edge out of 59 355 with nothing having said why. They are advisories and not a gate, because most documents want none of them. Co-Authored-By: Claude Opus 5 --- tools/editor/capabilities.py | 15 ++++++++++++--- tools/ninja/generate.py | 10 ++++++++++ tools/ninja/writer.py | 12 ++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/tools/editor/capabilities.py b/tools/editor/capabilities.py index 9d1a50f5..038ac72a 100644 --- a/tools/editor/capabilities.py +++ b/tools/editor/capabilities.py @@ -203,9 +203,18 @@ def probe_latex(repo_root): def probe_advisories(): - """Needed only by a unit that has a figure, so never a gate -- but worth naming.""" + """ + Needed only by some units and some documents, so never a gate -- but worth naming. + + The first four draw a figure. The last three take a finished PDF apart: `pdftk` bursts a + volume's online booklet into one file per page, `pdftoppm` renders a seminar round's + instagram document to PNGs, and `pdfjam` imposes a semester booklet. Those three were + missing from this list, which is how a machine with no `pdftk` -- this one -- could report + the PDF tier green and then fail one edge out of 59 355 with nothing having said why. + """ return [Tool(name, bool(_which(name)), _which(name)) - for name in ('gnuplot', 'rsvg-convert', 'dvisvgm', 'pdfcrop')] + for name in ('gnuplot', 'rsvg-convert', 'dvisvgm', 'pdfcrop', + 'pdftk', 'pdftoppm', 'pdfjam')] def _first_line(text): @@ -309,7 +318,7 @@ def main(): for line in t['install']: print(f' $ {line}') if report['advisories']: - print('\n figures may not build without:') + print('\n some figures and documents may not build without:') for t in report['advisories']: print(f' {t["name"]}') if report['launcher']['note']: diff --git a/tools/ninja/generate.py b/tools/ninja/generate.py index a017486c..30606a54 100644 --- a/tools/ninja/generate.py +++ b/tools/ninja/generate.py @@ -457,6 +457,16 @@ def generate(repo: pathlib.Path, scopes: list[str], out) -> dict: for directory in sorted(totals['listed']): w.build(directory, 'phony', None) totals['edges'] += 1 + + w.newline() + w.comment('What a bare `ninja` builds. Left unsaid it would be every root of the graph -- ' + 'the whole repository -- where a bare `make` touched a stamp and stopped. The ' + 'i18n tables are the nearest honest equivalent: core\'s own, needed by every ' + 'other edge, and a second or two.') + w.build('core', 'phony', None, + implicit=[f'build/core/i18n/{lang}.tex' for lang in sorted(needs['languages'])]) + w.default('core') + totals['edges'] += 1 return totals diff --git a/tools/ninja/writer.py b/tools/ninja/writer.py index 4c370d8d..851e9518 100644 --- a/tools/ninja/writer.py +++ b/tools/ninja/writer.py @@ -97,6 +97,18 @@ def build(self, outputs, rule: str, inputs=None, *, implicit=None, order_only=No return outputs + def default(self, targets) -> None: + """ + What `ninja` with no arguments builds. + + Ninja's own default is every output nothing else depends on, which here is 33 929 of + them -- the whole repository, every preview PDF included. `make` with no goal touched a + stamp and stopped, so the two spellings of "I typed the command to see what happens" + must not differ by four hours of xelatex. + """ + self.out.write('default ' + ' '.join(escape_path(t) for t in _listify(targets)) + '\n') + + def has(self, output: str) -> bool: """Whether something already produces this path.""" return escape_path(str(output)) in self.emitted From 9b0c469e6b2d046bc8a9cdaf040568c41b67d773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 03:26:10 +0000 Subject: [PATCH 26/28] The prose that still said make Three sentences that named make for something the graph now does: the audit's build layer takes an injected runner and the editor's is Ninja's, `build-orphan`'s message told the reader to run `make prune`, and CLAUDE.md said the same. `make` still has both targets and still works, so this is accuracy rather than removal. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +++-- core/audit/__init__.py | 3 ++- core/audit/checks.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 535037e4..064eb018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ stale entry is still worth catching in the sources rather than in a PDF. Both di are checked (`unit-unlisted`, `listed-missing`). The page can sort alphabetically instead, and keeps showing the competition number when it does. A pass over the whole repository is a fifth of a second, so nothing is cached — except the two checks that cannot be answered by reading files, which land in -`build/.audit/` with a fingerprint of the sources: the build checks, which shell out to make, and +`build/.audit/` with a fingerprint of the sources: the build checks, which shell out to the build, and the macro sweep. **There is a third direction, and it is the one make cannot fix: `build-orphan`.** Rename a @@ -58,7 +58,8 @@ because `\protectedInput` finds the stale `.tex` and the booklet goes on printin problem instead of the red box it is owed; volume 19's `onion` was exactly that. So `listed-missing` is the error and this is the warning, and together they say what happened. -`make stale` lists them and **`make prune` removes them** — two targets on purpose, since one +`./dgs-build stale` lists them and **`./dgs-build prune` removes them** (`make stale` and +`make prune` are the same two targets) — two targets on purpose, since one word between asking and deleting is not a safe thing to offer. `core/audit/stale.py` is the whole of it, and it is the one check that reads outside `source/`: a directory listing, no file opened, and a tree that has never been built reports nothing. diff --git a/core/audit/__init__.py b/core/audit/__init__.py index 6fd959b0..7a27a443 100644 --- a/core/audit/__init__.py +++ b/core/audit/__init__.py @@ -3,7 +3,8 @@ Two layers. This one is source-only: it reads every file once and runs a registry of checks over the result, which costs about a fifth of a second for the whole repository, so nothing needs to be -cached or made incremental. `core.audit.build` is the other one, and it shells out to make. +cached or made incremental. `core.audit.build` is the other one, and it shells out to the +build -- to whatever runner its caller injects, which is the editor's Ninja one. from core.audit import audit report = audit(Path('source/naboj'), 'naboj', 'phys/28', units) diff --git a/core/audit/checks.py b/core/audit/checks.py index e5ae4a1d..0dba3e0d 100644 --- a/core/audit/checks.py +++ b/core/audit/checks.py @@ -835,7 +835,7 @@ def build_orphan(sources): yield Finding('build-orphan', 'warning', f"`{orphan}` has no source directory left -- {orphan.files} " f"file{'' if orphan.files == 1 else 's'} from a rename or a deletion. " - f"`make prune` removes it; {consequence}") + f"`dgs-build prune` removes it; {consequence}") @check('translation-set', 'warning', From 3c8896154099ec297dc29dd7892a09300b1e9cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 05:32:39 +0000 Subject: [PATCH 27/28] Ghostscript 10.01 took dvisvgm's PDF reader away Every formula on an HTML page goes through `dvisvgm --pdf`, and Ghostscript dropped the interpreter dvisvgm drove in 10.01.0. So on anything that new -- Debian 13 ships 10.05.1, and so does the runner -- the conversion does not degrade, it refuses: ERROR: To process PDF files, either Ghostscript < 10.01.0 or mutool is required. The installed Ghostscript version 10.05.1 isn't supported. `mutool` is the answer dvisvgm itself names, and it picks it up by itself when it finds it; `DVISVGM_PDF_PROC` forces the choice either way, which is what this was verified with here, where Ghostscript is still 10.00. So the package goes into the three places that install the toolchain -- CI, the container and `install.sh` -- and `mutool` joins the two lists that fail by name rather than letting a test skip itself: CI's prerequisite check and the image's. The editor's advisories name it too, and deliberately without asking Ghostscript's version: a machine where the old interpreter is still there is one upgrade away from this, and there is no reading of a version that makes it safe to be missing. Co-Authored-By: Claude Opus 5 --- .github/workflows/python-app.yml | 12 ++++++++++-- Containerfile | 10 ++++++++-- install.sh | 4 +++- tools/editor/capabilities.py | 11 ++++++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index d7916c37..b443a1a4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -33,11 +33,19 @@ jobs: # `poppler-utils` is `pdftocairo`, which converts every picture for the web, and # `gnuplot-nox` draws the plots. Both were absent, so `test_picture_svg.py` skipped itself # and CI had never once exercised the picture pipeline. + # + # `mupdf-tools` is `mutool`, and it is dvisvgm's PDF reader now. Ghostscript dropped the + # interpreter dvisvgm drove in **10.01.0**, so on a runner carrying 10.05 every `--pdf` + # run ends in `To process PDF files, either Ghostscript < 10.01.0 or mutool is required` -- + # which is the whole of the HTML maths, and it surfaced the moment those tests stopped + # skipping. dvisvgm picks mutool up by itself when it finds it; `DVISVGM_PDF_PROC` forces + # the choice. Ghostscript stays -- `pdfcrop` is Ghostscript, and dvisvgm still wants it for + # PostScript specials. - name: "Install utilities" run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends \ - librsvg2-bin lcdf-typetools dvisvgm ghostscript poppler-utils gnuplot-nox + librsvg2-bin lcdf-typetools dvisvgm ghostscript mupdf-tools poppler-utils gnuplot-nox # MathJax is the other HTML maths engine, and it is node's. A hosted runner has node # whether or not anyone installed the packages, which is exactly how CI reported # `Cannot find module '@mathjax/mathjax-pagella-font/package.json'` from a test that should @@ -122,7 +130,7 @@ jobs: - name: "Verify the HTML output's prerequisites" run: | missing="" - for tool in dvisvgm pdftocairo gnuplot rsvg-convert; do + for tool in dvisvgm mutool pdftocairo gnuplot rsvg-convert; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool" done for package in mathjax @mathjax/mathjax-pagella-font; do diff --git a/Containerfile b/Containerfile index 1f083c4f..bea6f02f 100644 --- a/Containerfile +++ b/Containerfile @@ -112,6 +112,12 @@ FROM common AS tex # --no-install-recommends silently drops dvisvgm and ghostscript, which is why # both are named: Makefile:208 runs pdfcrop (ghostscript) and :226 dvisvgm. # +# mupdf-tools is mutool, which is dvisvgm's PDF reader now: Ghostscript dropped +# the interpreter dvisvgm drove in 10.01.0 and trixie ships 10.05, so without it +# every `dvisvgm --pdf` in the image dies on "either Ghostscript < 10.01.0 or +# mutool is required" -- i.e. all of the HTML maths. Ghostscript stays anyway, +# for pdfcrop and for PostScript specials. +# # texlive-full is 5-6 GB and drags in CJK, ConTeXt, asymptote and the doc tree. # This list is about 2.4 GB. texlive-fonts-extra is 1.7 GB of that and is not # optional: MnSymbol (dgs.cls:93), esvect (:100), ifsym and skull (fonts.tex:51) @@ -125,7 +131,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ texlive-lang-german texlive-lang-french texlive-lang-polish \ texlive-lang-spanish texlive-lang-portuguese texlive-lang-cyrillic \ lcdf-typetools fontconfig \ - ghostscript dvisvgm librsvg2-bin poppler-utils gnuplot-nox \ + ghostscript dvisvgm mupdf-tools librsvg2-bin poppler-utils gnuplot-nox \ pdftk-java zip rsync \ && rm -rf /var/lib/apt/lists/* @@ -135,7 +141,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN missing=""; \ for tool in tex fontinst vptovf pltotf otfinfo otftotfm cfftot1 kpsewhich \ mktexlsr updmap-sys perl \ - xelatex texfot pdfcrop pdfjam pdftk pdftoppm dvisvgm \ + xelatex texfot pdfcrop pdfjam pdftk pdftoppm dvisvgm mutool \ rsvg-convert gnuplot zip rsync fc-cache; do \ command -v "$tool" >/dev/null || missing="$missing $tool"; \ done; \ diff --git a/install.sh b/install.sh index 9a67b876..c28d90e1 100755 --- a/install.sh +++ b/install.sh @@ -7,8 +7,10 @@ DIR=$(dirname $0) # # `lcdf-typetools` is not optional: FontPro's scripts call otfinfo, otftotfm and cfftot1, # and die without them. CI has always installed it; this script used to forget. +# `mupdf-tools` is `mutool`, which dvisvgm needs to read a PDF at all from Ghostscript 10.01.0 +# on -- Debian 13 ships 10.05, and without it every formula on an HTML page fails to convert. sudo apt install texlive-full texlive-fonts-extra texlive-science librsvg2-bin gnuplot \ - lcdf-typetools + lcdf-typetools mupdf-tools "$DIR/install-pandoc.sh" TEXHOME=`kpsewhich -var-value=TEXMFHOME` diff --git a/tools/editor/capabilities.py b/tools/editor/capabilities.py index 038ac72a..148faa6a 100644 --- a/tools/editor/capabilities.py +++ b/tools/editor/capabilities.py @@ -42,6 +42,7 @@ ], 'gnuplot': ['sudo apt install gnuplot'], 'rsvg-convert': ['sudo apt install librsvg2-bin'], + 'mutool': ['sudo apt install mupdf-tools'], } @@ -211,9 +212,17 @@ def probe_advisories(): instagram document to PNGs, and `pdfjam` imposes a semester booklet. Those three were missing from this list, which is how a machine with no `pdftk` -- this one -- could report the PDF tier green and then fail one edge out of 59 355 with nothing having said why. + + **`mutool` is dvisvgm's, not ours.** Ghostscript dropped the PDF interpreter dvisvgm drove + in 10.01.0, so from Debian 13 onwards `dvisvgm --pdf` refuses the file outright -- `To + process PDF files, either Ghostscript < 10.01.0 or mutool is required` -- and that is the + whole of the HTML maths, which goes through dvisvgm for every formula. dvisvgm finds mutool + by itself; `DVISVGM_PDF_PROC=gs|mutool` forces the choice. It is listed even where + Ghostscript is old enough not to need it, because there is no reading of a version that + makes a machine without it safe to upgrade. """ return [Tool(name, bool(_which(name)), _which(name)) - for name in ('gnuplot', 'rsvg-convert', 'dvisvgm', 'pdfcrop', + for name in ('gnuplot', 'rsvg-convert', 'dvisvgm', 'mutool', 'pdfcrop', 'pdftk', 'pdftoppm', 'pdfjam')] From 272a83fff4ed42e57bda67a330614033aea7802e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Wed, 4 Nov 2026 05:32:39 +0000 Subject: [PATCH 28/28] A page size is poppler's to report `canvas()` read a gnuplot PDF's `/MediaBox` with a regex over the raw bytes, which works only while cairo writes the page dictionary as a plain object. From 1.17 it writes it into a *compressed object stream*, so on a newer machine the regex finds nothing and the test dies as `NoneType has no attribute groups` -- with nothing to say that the PDF was perfectly good and the reader was wrong. It passed here on cairo 1.16 and failed in CI, which is the worst shape for a test to be in. `pdfinfo` answers the question instead. It is poppler, the same package as the `pdftocairo` this module already requires, so it costs no new dependency, and it keeps working whatever cairo does with its object layout next. Co-Authored-By: Claude Opus 5 --- core/tests/test_picture_svg.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/core/tests/test_picture_svg.py b/core/tests/test_picture_svg.py index 7ab77c28..3e23da4f 100644 --- a/core/tests/test_picture_svg.py +++ b/core/tests/test_picture_svg.py @@ -121,10 +121,17 @@ class TestGnuplotDrawsTheSamePlotTwice: def canvas(path: pathlib.Path) -> tuple[float, float]: if path.suffix == '.svg': found = re.search(r'width="([\d.]+)"\s+height="([\d.]+)"', path.read_text()) - else: # a PDF's MediaBox, in points - found = re.search(rb'/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)', - path.read_bytes()) + assert found, 'the SVG declares no canvas' return tuple(float(x) for x in found.groups()) + # The page size is **asked of poppler**, not read out of the file. cairo puts the page + # dictionary into a compressed object stream from 1.17 on, so the `/MediaBox` a regex + # over the bytes used to find is simply not there on a newer machine -- which is how + # this passed here and failed in CI, with `NoneType has no attribute groups` and + # nothing to say that the PDF was perfectly good. `pdfinfo` is poppler, the same + # package as the `pdftocairo` this module already requires. + out = subprocess.run(['pdfinfo', str(path)], check=True, capture_output=True, text=True) + found = re.search(r'Page size:\s+([\d.]+) x ([\d.]+) pts', out.stdout) + assert found, f'pdfinfo reported no page size:\n{out.stdout}' return tuple(float(x) for x in found.groups()) def test_the_canvas_is_the_same_physical_size(self, tmp_path):