From dd09f15e281cc44cdb2bc164215909b2a77338d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 06:13:39 -0400 Subject: [PATCH 1/8] Take jupyter-book out of the paper render environment and uv.lock The docs extra was both the Quarto paper render environment and the Jupyter Book 2 docs-site builder. Quarto's Jupyter engine needs only the kernel and notebook runner (ipykernel, nbclient, nbformat); jupyter-book pulled in jupyter-server, nbconvert, bleach, mistune, soupsieve and 29 other packages that the render never imports. Drop jupyter-book from the docs extra and let docs/requirements.txt, which already declared it, be its only declaration. A separate extra would not help: Dependabot scans every package in uv.lock whichever extra pulls it in. The relock removes 34 packages and changes no version. The paper re-renders identically (HTML text and PDF bytes, apart from Quarto's per-render cell id and the PDF dates and /ID), and the docs site builds through docs/requirements.txt with the same page text as main's locked jupyter-book. README and paper/README document both environments; tests/test_render_environment.py keeps the stack out. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++ docs/requirements.txt | 3 + paper/README.md | 6 +- pyproject.toml | 5 +- tests/test_render_environment.py | 55 ++++ uv.lock | 448 ------------------------------- 6 files changed, 93 insertions(+), 450 deletions(-) create mode 100644 tests/test_render_environment.py diff --git a/README.md b/README.md index 63c7b83c..e0a05bfe 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,32 @@ bun run test bun run build ``` +## Paper and documentation site + +The manuscript in `paper/` renders with Quarto. Its Python environment is the +`docs` extra, which holds only the Jupyter kernel and notebook runner that +Quarto's Jupyter engine drives (ipykernel, nbclient, nbformat). See +[`paper/README.md`](paper/README.md) for the other prerequisites. + +```bash +uv sync --extra docs +uv run python paper/render_paper.py +``` + +The operational-docs site in `docs/` builds with Jupyter Book 2, which +`docs/requirements.txt` declares outside `uv.lock`. Build it in a throwaway +environment (Jupyter Book needs Node.js 18, 20 or 22+): + +```bash +cd docs +uv run --no-project --with-requirements requirements.txt jupyter book build --html +``` + +The output lands in `docs/_build/` (gitignored). jupyter-book stays out of the +`docs` extra on purpose: it pulls in jupyter-server, nbconvert, bleach, mistune +and their dependencies, none of which the paper render uses, and every package +in `uv.lock` is in Dependabot's scan whichever extra it belongs to. + ## Benchmark run For paid/public runs, follow the concrete diff --git a/docs/requirements.txt b/docs/requirements.txt index 31024bb2..71cca9fc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,4 @@ +# Builds the docs/ site: cd docs && uv run --no-project --with-requirements +# requirements.txt jupyter book build --html. Kept out of pyproject.toml and +# uv.lock on purpose; see "Paper and documentation site" in README.md. jupyter-book>=2.0 diff --git a/paper/README.md b/paper/README.md index d044364a..f2417802 100644 --- a/paper/README.md +++ b/paper/README.md @@ -30,7 +30,11 @@ Notes: to the invoking virtualenv (`render_paper.py` sets `QUARTO_PYTHON` and `JUPYTER_PREFER_ENV_PATH=1`), so it always executes this checkout's `policybench`. No user-level kernelspec needs to be registered. The render - environment lives in the `docs` extra (`uv sync --extra docs`). + environment lives in the `docs` extra (`uv sync --extra docs`): ipykernel, + nbclient and nbformat, the kernel and notebook runner that Quarto's Jupyter + engine drives. It does not include jupyter-book, which builds only the + separate `docs/` site; see "Paper and documentation site" in the top-level + README. - The manuscript tables and figures read from the frozen source run exports under `paper/snapshot/20260501/runs/`. - `app/src/data.json` is the live site export. For this snapshot it must match diff --git a/pyproject.toml b/pyproject.toml index 48de20e3..7aa74caf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,9 +28,12 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.4", ] +# The paper render environment (paper/render_paper.py, .github/workflows/paper.yml): +# the Jupyter kernel and notebook runner that Quarto's Jupyter engine drives. +# jupyter-book, which builds the docs/ site, stays out of uv.lock on purpose: +# docs/requirements.txt declares it (README.md, "Paper and documentation site"). docs = [ "ipykernel>=6.0", - "jupyter-book>=2.0", "nbformat>=5.0", "nbclient>=0.10", ] diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py new file mode 100644 index 00000000..c2810932 --- /dev/null +++ b/tests/test_render_environment.py @@ -0,0 +1,55 @@ +"""The paper render environment stays free of Jupyter Book's server stack. + +The ``docs`` extra is the environment ``paper/render_paper.py`` and the Paper +workflow render the manuscript in. Quarto's Jupyter engine runs the notebook +through nbclient and nbformat on the ipykernel ``python3`` kernel; it never +imports jupyter-book, or the jupyter-server and nbconvert stack behind it. +jupyter-book builds only the docs/ site, from docs/requirements.txt, so it stays +out of uv.lock: Dependabot scans every locked package, whichever extra pulls it +in, and that stack brought bleach, mistune and soupsieve alerts with it. +""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RENDER_KERNEL = {"ipykernel", "nbclient", "nbformat"} +DOCS_SITE_STACK = {"jupyter-book", "jupyter-server", "nbconvert"} + + +def _name(requirement: str) -> str: + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement) + assert match, requirement + return re.sub(r"[-_.]+", "-", match.group(1)).lower() + + +def _extras() -> dict[str, set[str]]: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + return { + extra: {_name(requirement) for requirement in requirements} + for extra, requirements in project["optional-dependencies"].items() + } + + +def test_docs_extra_holds_the_render_kernel(): + assert RENDER_KERNEL <= _extras()["docs"] + + +def test_no_extra_declares_the_docs_site_stack(): + for extra, names in _extras().items(): + assert not names & DOCS_SITE_STACK, extra + + +def test_docs_site_stack_is_not_locked(): + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + locked = {package["name"] for package in lock["package"]} + assert not locked & DOCS_SITE_STACK + + +def test_docs_site_requirements_declare_jupyter_book(): + lines = (ROOT / "docs" / "requirements.txt").read_text().splitlines() + names = {_name(line) for line in lines if line.strip() and not line.startswith("#")} + assert "jupyter-book" in names diff --git a/uv.lock b/uv.lock index ee8f23b6..02bcd35e 100644 --- a/uv.lock +++ b/uv.lock @@ -209,62 +209,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] -[[package]] -name = "argon2-cffi" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argon2-cffi-bindings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, -] - -[[package]] -name = "argon2-cffi-bindings" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, - { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, - { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, - { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, - { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, - { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, - { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, - { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, - { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, -] - -[[package]] -name = "arrow" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, -] - [[package]] name = "asttokens" version = "3.0.1" @@ -283,36 +227,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, -] - -[[package]] -name = "bleach" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - [[package]] name = "blosc2" version = "4.1.2" @@ -744,15 +658,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - [[package]] name = "diskcache" version = "5.6.3" @@ -877,15 +782,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] -[[package]] -name = "fqdn" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -1335,18 +1231,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" }, ] -[[package]] -name = "isoduration" -version = "20.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "arrow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, -] - [[package]] name = "jedi" version = "0.19.2" @@ -1521,15 +1405,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, ] -[[package]] -name = "jsonpointer" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -1545,19 +1420,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] -[package.optional-dependencies] -format-nongpl = [ - { name = "fqdn" }, - { name = "idna" }, - { name = "isoduration" }, - { name = "jsonpointer" }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "rfc3987-syntax" }, - { name = "uri-template" }, - { name = "webcolors" }, -] - [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1570,22 +1432,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "jupyter-book" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "jupyter-core" }, - { name = "jupyter-server" }, - { name = "nodeenv" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/39/8643f6629002e9ea35109219d09dfbe076c338c03fb3fd170c8d5e38bc73/jupyter_book-2.1.2.tar.gz", hash = "sha256:94bb8e63ef191e88cb6e7ab8a1ac66c7dc354ac228de1f558e5beb0381d38f8a", size = 3198230, upload-time = "2026-02-01T22:55:19.724Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/25/6fe2dfc3d830ec614c5f83f88fc7472c4ed892b7f7f496367d31de4110c4/jupyter_book-2.1.2-py3-none-any.whl", hash = "sha256:1e92850680782ca777452780f9dee0550b52af338e6fe8c115961142ebf30a0e", size = 2606017, upload-time = "2026-02-01T22:55:16.714Z" }, -] - [[package]] name = "jupyter-client" version = "8.8.0" @@ -1615,86 +1461,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] -[[package]] -name = "jupyter-events" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema", extra = ["format-nongpl"] }, - { name = "packaging" }, - { name = "python-json-logger" }, - { name = "pyyaml" }, - { name = "referencing" }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, -] - -[[package]] -name = "jupyter-server" -version = "2.19.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "argon2-cffi" }, - { name = "jinja2" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "jupyter-events" }, - { name = "jupyter-server-terminals" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "overrides", marker = "python_full_version < '3.12'" }, - { name = "packaging" }, - { name = "prometheus-client" }, - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "pyzmq" }, - { name = "send2trash" }, - { name = "terminado" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/a0/eb3c511f54df7b54ca5fc7bff3f4d2277d69052d6a7f521643dfed5279d6/jupyter_server-2.19.0.tar.gz", hash = "sha256:1731236bc32b680223e1ceb9d68209a845203475012ef68773a81434b46a31a7", size = 754561, upload-time = "2026-05-29T11:21:26.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/78/d2881e68894cecdcd05912a9c585cfb776ef1fb38b62c8dba98f12ab3adc/jupyter_server-2.19.0-py3-none-any.whl", hash = "sha256:cb76591b76d7093584c2ad2ae72ac3d58614a4b597507a1bb04e1f9f683cf9ea", size = 392244, upload-time = "2026-05-29T11:21:23.871Z" }, -] - -[[package]] -name = "jupyter-server-terminals" -version = "0.5.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "terminado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, -] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - -[[package]] -name = "lark" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, -] - [[package]] name = "litellm" version = "1.88.1" @@ -1843,15 +1609,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/0a/d47611ed7953942da7992e7a0db3655594e19bb72086a07a1f7de4723501/microdf_python-1.3.10-py3-none-any.whl", hash = "sha256:c126052738b5726cb555b4c1c2d8fdab33e5ad756143909d9abba6eab0a865f0", size = 40161, upload-time = "2026-09-16T11:21:37.262Z" }, ] -[[package]] -name = "mistune" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, -] - [[package]] name = "msgpack" version = "1.1.2" @@ -2037,31 +1794,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, ] -[[package]] -name = "nbconvert" -version = "7.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, -] - [[package]] name = "nbformat" version = "5.10.4" @@ -2151,15 +1883,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - [[package]] name = "numexpr" version = "2.14.1" @@ -2329,15 +2052,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] -[[package]] -name = "overrides" -version = "7.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, -] - [[package]] name = "packaging" version = "26.0" @@ -2407,15 +2121,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, ] -[[package]] -name = "pandocfilters" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, -] - [[package]] name = "parso" version = "0.8.6" @@ -2492,7 +2197,6 @@ dev = [ ] docs = [ { name = "ipykernel" }, - { name = "jupyter-book" }, { name = "nbclient" }, { name = "nbformat" }, ] @@ -2503,7 +2207,6 @@ requires-dist = [ { name = "google-genai", specifier = ">=2.10.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, { name = "ipykernel", marker = "extra == 'docs'", specifier = ">=6.0" }, - { name = "jupyter-book", marker = "extra == 'docs'", specifier = ">=2.0" }, { name = "litellm", extras = ["caching"], specifier = ">=1.30" }, { name = "nbclient", marker = "extra == 'docs'", specifier = ">=0.10" }, { name = "nbformat", marker = "extra == 'docs'", specifier = ">=5.0" }, @@ -2596,15 +2299,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/05/263af928695ba3753d48824d1673d3487387c0a010901ff9d891cc9d4a43/policyengine_us-2.41.0-py3-none-any.whl", hash = "sha256:cf1f68d0363b01ff4b3a3e9848566eb6bd53039c0051e00917d8a01ed30f3022", size = 17394284, upload-time = "2026-10-13T01:44:55.991Z" }, ] -[[package]] -name = "prometheus-client" -version = "0.24.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, -] - [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2960,15 +2654,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, -] - [[package]] name = "pyvis" version = "0.3.2" @@ -2983,26 +2668,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/4b/e37e4e5d5ee1179694917b445768bdbfb084f5a59ecd38089d3413d4c70f/pyvis-0.3.2-py3-none-any.whl", hash = "sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555", size = 756038, upload-time = "2023-02-24T20:29:46.758Z" }, ] -[[package]] -name = "pywinpty" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/54/37c7370ba91f579235049dc26cd2c5e657d2a943e01820844ffc81f32176/pywinpty-3.0.3.tar.gz", hash = "sha256:523441dc34d231fb361b4b00f8c99d3f16de02f5005fd544a0183112bcc22412", size = 31309, upload-time = "2026-02-04T21:51:09.524Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/c3/3e75075c7f71735f22b66fab0481f2c98e3a4d58cba55cb50ba29114bcf6/pywinpty-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:dff25a9a6435f527d7c65608a7e62783fc12076e7d44487a4911ee91be5a8ac8", size = 2114430, upload-time = "2026-02-04T21:54:19.485Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1e/8a54166a8c5e4f5cb516514bdf4090be4d51a71e8d9f6d98c0aa00fe45d4/pywinpty-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc1e230e5b193eef4431cba3f39996a288f9958f9c9f092c8a961d930ee8f68", size = 236191, upload-time = "2026-02-04T21:50:36.239Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d4/aeb5e1784d2c5bff6e189138a9ca91a090117459cea0c30378e1f2db3d54/pywinpty-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c9081df0e49ffa86d15db4a6ba61530630e48707f987df42c9d3313537e81fc0", size = 2113098, upload-time = "2026-02-04T21:54:37.711Z" }, - { url = "https://files.pythonhosted.org/packages/b9/53/7278223c493ccfe4883239cf06c823c56460a8010e0fc778eef67858dc14/pywinpty-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:15e79d870e18b678fb8a5a6105fd38496b55697c66e6fc0378236026bc4d59e9", size = 234901, upload-time = "2026-02-04T21:53:31.35Z" }, - { url = "https://files.pythonhosted.org/packages/e5/cb/58d6ed3fd429c96a90ef01ac9a617af10a6d41469219c25e7dc162abbb71/pywinpty-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9c91dbb026050c77bdcef964e63a4f10f01a639113c4d3658332614544c467ab", size = 2112686, upload-time = "2026-02-04T21:52:03.035Z" }, - { url = "https://files.pythonhosted.org/packages/fd/50/724ed5c38c504d4e58a88a072776a1e880d970789deaeb2b9f7bd9a5141a/pywinpty-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:fe1f7911805127c94cf51f89ab14096c6f91ffdcacf993d2da6082b2142a2523", size = 234591, upload-time = "2026-02-04T21:52:29.821Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ad/90a110538696b12b39fd8758a06d70ded899308198ad2305ac68e361126e/pywinpty-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:3f07a6cf1c1d470d284e614733c3d0f726d2c85e78508ea10a403140c3c0c18a", size = 2112360, upload-time = "2026-02-04T21:55:33.397Z" }, - { url = "https://files.pythonhosted.org/packages/44/0f/7ffa221757a220402bc79fda44044c3f2cc57338d878ab7d622add6f4581/pywinpty-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:15c7c0b6f8e9d87aabbaff76468dabf6e6121332c40fc1d83548d02a9d6a3759", size = 233107, upload-time = "2026-02-04T21:51:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/28/88/2ff917caff61e55f38bcdb27de06ee30597881b2cae44fbba7627be015c4/pywinpty-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:d4b6b7b0fe0cdcd02e956bd57cfe9f4e5a06514eecf3b5ae174da4f951b58be9", size = 2113282, upload-time = "2026-02-04T21:52:08.188Z" }, - { url = "https://files.pythonhosted.org/packages/63/32/40a775343ace542cc43ece3f1d1fce454021521ecac41c4c4573081c2336/pywinpty-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:34789d685fc0d547ce0c8a65e5a70e56f77d732fa6e03c8f74fefb8cbb252019", size = 234207, upload-time = "2026-02-04T21:51:58.687Z" }, - { url = "https://files.pythonhosted.org/packages/8d/54/5d5e52f4cb75028104ca6faf36c10f9692389b1986d34471663b4ebebd6d/pywinpty-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c37e224a47a971d1a6e08649a1714dac4f63c11920780977829ed5c8cadead1", size = 2112910, upload-time = "2026-02-04T21:52:30.976Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/dcd184824e21d4620b06c7db9fbb15c3ad0a0f1fa2e6de79969fb82647ec/pywinpty-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c4e9c3dff7d86ba81937438d5819f19f385a39d8f592d4e8af67148ceb4f6ab5", size = 233425, upload-time = "2026-02-04T21:51:56.754Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -3249,39 +2914,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, -] - -[[package]] -name = "rfc3986-validator" -version = "0.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, -] - -[[package]] -name = "rfc3987-syntax" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lark" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, -] - [[package]] name = "rich" version = "14.3.3" @@ -3428,15 +3060,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] -[[package]] -name = "send2trash" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" @@ -3473,15 +3096,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] -[[package]] -name = "soupsieve" -version = "2.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, -] - [[package]] name = "spm-calculator" version = "1.0.0.post1" @@ -3556,20 +3170,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] -[[package]] -name = "terminado" -version = "0.18.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess", marker = "os_name != 'nt'" }, - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "tornado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, -] - [[package]] name = "tiktoken" version = "0.12.0" @@ -3624,18 +3224,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] -[[package]] -name = "tinycss2" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, -] - [[package]] name = "tokenizers" version = "0.22.2" @@ -3811,15 +3399,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] -[[package]] -name = "uri-template" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" @@ -3850,33 +3429,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] -[[package]] -name = "webcolors" -version = "25.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - [[package]] name = "websockets" version = "16.0" From 269d79f306d5120158a7d6d78f38432da777875e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 22:10:12 -0400 Subject: [PATCH 2/8] Read docs/requirements.txt comments and options the way pip does Review r1's nit: an indented comment would have reached _name() and failed. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index c2810932..0b6b5d46 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -49,7 +49,19 @@ def test_docs_site_stack_is_not_locked(): assert not locked & DOCS_SITE_STACK +def _requirement_names(text: str) -> set[str]: + """Package names in a requirements file, read the way pip reads it: a # + at a line's start or after whitespace starts a comment, and a line + starting with - is an option.""" + lines = (re.sub(r"(^|\s)#.*$", "", line).strip() for line in text.splitlines()) + return {_name(line) for line in lines if line and not line.startswith("-")} + + +def test_requirement_names_skip_comments_and_options(): + text = "# top\n # indented\n\n--index-url https://x\nfoo>=1 # why\nBar_Baz\n" + assert _requirement_names(text) == {"foo", "bar-baz"} + + def test_docs_site_requirements_declare_jupyter_book(): - lines = (ROOT / "docs" / "requirements.txt").read_text().splitlines() - names = {_name(line) for line in lines if line.strip() and not line.startswith("#")} - assert "jupyter-book" in names + text = (ROOT / "docs" / "requirements.txt").read_text() + assert "jupyter-book" in _requirement_names(text) From 60f76fa8187e7cf93cb5f6c60c06e20bb2cf78ae Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 04:53:01 -0400 Subject: [PATCH 3/8] Read requirements files as pip does: continuations, includes, options (review r2) The guard test's reader now joins backslash continuations and strips comments as pip's req_file.py does, follows -r includes relative to the including file, skips -c constraints and other options, and drops per-requirement options such as --hash. Requirements it can't name without fetching them (bare URLs, paths, -e) fail the test instead of yielding a wrong name. Names come from packaging's PEP 508 parser. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 101 +++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 11 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index 0b6b5d46..f92decde 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -15,6 +15,9 @@ import tomllib from pathlib import Path +import pytest +from packaging.requirements import InvalidRequirement, Requirement + ROOT = Path(__file__).resolve().parents[1] RENDER_KERNEL = {"ipykernel", "nbclient", "nbformat"} DOCS_SITE_STACK = {"jupyter-book", "jupyter-server", "nbconvert"} @@ -49,19 +52,95 @@ def test_docs_site_stack_is_not_locked(): assert not locked & DOCS_SITE_STACK -def _requirement_names(text: str) -> set[str]: - """Package names in a requirements file, read the way pip reads it: a # - at a line's start or after whitespace starts a comment, and a line - starting with - is an option.""" - lines = (re.sub(r"(^|\s)#.*$", "", line).strip() for line in text.splitlines()) - return {_name(line) for line in lines if line and not line.startswith("-")} +# What pip's requirements-file reader (pip/_internal/req/req_file.py) does to +# each line before parsing it: a line ending in a backslash continues on the +# next, unless it is a comment; a # at a line's start or after whitespace +# starts a comment; and a token starting with - begins the line's options. +_COMMENT = re.compile(r"(^|\s+)#.*$") +_ARCHIVE = (".whl", ".zip", ".tar.gz", ".tgz", ".tar.bz2", ".tbz", ".tar.xz", ".tar") + + +def _logical_lines(text: str) -> list[str]: + lines, pending = [], [] + for line in text.splitlines(): + if line.endswith("\\") and not _COMMENT.match(line): + pending.append(line.strip("\\")) + continue + lines.append("".join([*pending, " " + line if _COMMENT.match(line) else line])) + pending = [] + if pending: + lines.append("".join(pending)) + return [stripped for line in lines if (stripped := _COMMENT.sub("", line).strip())] + + +def _requirement_names(path: Path) -> set[str]: + """Names of the packages a requirements file asks pip to install. + + It follows -r includes, relative to the including file as pip resolves + them, and skips -c constraints and other options, which install nothing. + A requirement it can't name without fetching it (a bare URL or path, or an + -e editable install) fails the test rather than being guessed at. + """ + names = set() + for line in _logical_lines(path.read_text()): + include = re.match(r"(?:-r|--requirement)(?:\s+|=)?(\S+)$", line) + if include: + names |= _requirement_names(path.parent / include.group(1)) + continue + if line.startswith("-"): + assert not line.startswith(("-e", "--editable")), f"unnamed: {line}" + continue + arguments = [] + for token in line.split(" "): + if token.startswith("-"): + break + arguments.append(token) + requirement = Requirement(" ".join(arguments)) + assert not requirement.name.endswith(_ARCHIVE), f"a path, unnamed: {line}" + names.add(_name(requirement.name)) + return names + + +def test_requirement_names_read_requirements_files_as_pip_does(tmp_path): + (tmp_path / "base.txt").write_text("Included_Pkg\n-c constraints.txt\n") + (tmp_path / "requirements.txt").write_text( + "# top\n" + " # indented\n" + "\n" + "--index-url https://example.com/simple\n" + "foo>=1 # why\n" + "Bar_Baz[extra] ; python_version >= '3.12'\n" + "named @ https://example.com/named-1.0.tar.gz#sha256=00\n" + "hashed==1.0 --hash=sha256:00\n" + "continued \\\n" + " >=2\n" + "-r base.txt\n" + ) + assert _requirement_names(tmp_path / "requirements.txt") == { + "foo", + "bar-baz", + "named", + "hashed", + "continued", + "included-pkg", + } -def test_requirement_names_skip_comments_and_options(): - text = "# top\n # indented\n\n--index-url https://x\nfoo>=1 # why\nBar_Baz\n" - assert _requirement_names(text) == {"foo", "bar-baz"} +@pytest.mark.parametrize( + "line", + [ + "https://example.com/pkg-1.0-py3-none-any.whl", + "./vendor/pkg-1.0.tar.gz", + "pkg-1.0-py3-none-any.whl", + "-e ./local", + "git+https://example.com/pkg.git", + ], +) +def test_requirement_names_refuse_requirements_they_cant_name(tmp_path, line): + (tmp_path / "requirements.txt").write_text(line + "\n") + with pytest.raises((AssertionError, InvalidRequirement)): + _requirement_names(tmp_path / "requirements.txt") def test_docs_site_requirements_declare_jupyter_book(): - text = (ROOT / "docs" / "requirements.txt").read_text() - assert "jupyter-book" in _requirement_names(text) + assert "jupyter-book" in _requirement_names(ROOT / "docs" / "requirements.txt") From c5744ceb771789a41dab281f2e884ad4b770d183 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 05:43:56 -0400 Subject: [PATCH 4/8] Read only the requirements forms pip and uv agree on; refuse the rest (review r3) The reader of docs/requirements.txt tried to follow pip, and review r3 found four families of lines on which the test would pass while pip installs no jupyter-book: unevaluated markers, PEP 508 strings pip reads as archive paths (==2.0+local.zip), -r spellings pip reads differently (-r=FILE, -rFILE), and a ";" inside a named URL. uv, which builds the docs site, differs from pip on some of these too: it rejects a backslash continuation inside a requirement and reads -r=FILE as FILE. The reader now accepts only blank lines, comments, -r FILE / --requirement FILE / --requirement=FILE includes, and requirements made of a name, extras and version specifiers. Every other line fails with a message naming why: continuations, whitespace other than spaces and tabs (the file is read as bytes, so a lone CR isn't a line break), other options, per-requirement options, URLs and paths, markers, ===, and archive names as pip judges them. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 163 ++++++++++++++++++++----------- 1 file changed, 105 insertions(+), 58 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index f92decde..27a0803c 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -52,94 +52,141 @@ def test_docs_site_stack_is_not_locked(): assert not locked & DOCS_SITE_STACK -# What pip's requirements-file reader (pip/_internal/req/req_file.py) does to -# each line before parsing it: a line ending in a backslash continues on the -# next, unless it is a comment; a # at a line's start or after whitespace -# starts a comment; and a token starting with - begins the line's options. +# The line forms _requirement_names reads, chosen because pip (req_file.py and +# req/constructors.py) and uv, which builds the docs site, read each the same +# way. Any other form fails the test: the two readers differ on continuation +# lines, -r spellings, markers and archive-like versions, and a false marker +# makes both install nothing, so guessing could hide jupyter-book from one. +# - blank lines and comments: a # at a line's start or after whitespace; +# - includes: -r FILE, --requirement FILE or --requirement=FILE, resolved +# relative to the including file; +# - requirements: a name, optional extras and version specifiers; no URL, +# path, marker, archive name or per-requirement option. _COMMENT = re.compile(r"(^|\s+)#.*$") -_ARCHIVE = (".whl", ".zip", ".tar.gz", ".tgz", ".tar.bz2", ".tbz", ".tar.xz", ".tar") - - -def _logical_lines(text: str) -> list[str]: - lines, pending = [], [] - for line in text.splitlines(): - if line.endswith("\\") and not _COMMENT.match(line): - pending.append(line.strip("\\")) - continue - lines.append("".join([*pending, " " + line if _COMMENT.match(line) else line])) - pending = [] - if pending: - lines.append("".join(pending)) - return [stripped for line in lines if (stripped := _COMMENT.sub("", line).strip())] +_INCLUDE = re.compile( + r"(?:-r\s+|--requirement(?:\s+|=))([A-Za-z0-9._/][A-Za-z0-9._/-]*)" +) +# pip's archive extensions (pip/_internal/utils/filetypes.py), which it +# compares case-insensitively, after stripping extras, to decide that a line +# names a file rather than a project. +_ARCHIVE = (".zip", ".whl", ".tar.bz2", ".tbz", ".tar.gz", ".tgz", ".tar") +_ARCHIVE += (".tar.xz", ".txz", ".tlz", ".tar.lz", ".tar.lzma") def _requirement_names(path: Path) -> set[str]: - """Names of the packages a requirements file asks pip to install. - - It follows -r includes, relative to the including file as pip resolves - them, and skips -c constraints and other options, which install nothing. - A requirement it can't name without fetching it (a bare URL or path, or an - -e editable install) fails the test rather than being guessed at. - """ + """Names of the packages a requirements file asks pip and uv to install, + following its includes. A line outside the forms above fails the test.""" names = set() - for line in _logical_lines(path.read_text()): - include = re.match(r"(?:-r|--requirement)(?:\s+|=)?(\S+)$", line) + # Bytes, so a lone carriage return isn't read as a line break. + text = path.read_bytes().decode() + for number, raw in enumerate(text.split("\n"), 1): + raw = raw.removesuffix("\r") + where = f"{path.name}:{number}: {raw!r}" + assert all(c in " \t" for c in raw if c.isspace()), ( + f"whitespace other than spaces and tabs: {where}" + ) + assert not raw.endswith("\\"), f"a continuation line: {where}" + line = _COMMENT.sub("", raw).strip() + if not line: + continue + include = _INCLUDE.fullmatch(line) if include: names |= _requirement_names(path.parent / include.group(1)) continue - if line.startswith("-"): - assert not line.startswith(("-e", "--editable")), f"unnamed: {line}" - continue - arguments = [] - for token in line.split(" "): - if token.startswith("-"): - break - arguments.append(token) - requirement = Requirement(" ".join(arguments)) - assert not requirement.name.endswith(_ARCHIVE), f"a path, unnamed: {line}" + assert not line.startswith("-"), f"an option: {where}" + assert not re.search(r"\s-", line), f"a per-requirement option: {where}" + assert not re.search(r"[@:/\\]", line), f"a URL or path: {where}" + assert ";" not in line, f"a marker: {where}" + assert "===" not in line, f"arbitrary equality: {where}" + stem = re.sub(r"\[[^\]]+\]$", "", line).lower() + assert not stem.endswith(_ARCHIVE), f"an archive name: {where}" + requirement = Requirement(line) + assert requirement.url is None and requirement.marker is None, where names.add(_name(requirement.name)) return names -def test_requirement_names_read_requirements_files_as_pip_does(tmp_path): - (tmp_path / "base.txt").write_text("Included_Pkg\n-c constraints.txt\n") +def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): + (tmp_path / "sub").mkdir() + (tmp_path / "base.txt").write_text("Included_Pkg\n") + (tmp_path / "sub" / "more.txt").write_text("-r ../deep.txt\n") + (tmp_path / "deep.txt").write_text("deep\n") + (tmp_path / "other.txt").write_text("other\r\n") (tmp_path / "requirements.txt").write_text( "# top\n" " # indented\n" "\n" - "--index-url https://example.com/simple\n" "foo>=1 # why\n" - "Bar_Baz[extra] ; python_version >= '3.12'\n" - "named @ https://example.com/named-1.0.tar.gz#sha256=00\n" - "hashed==1.0 --hash=sha256:00\n" - "continued \\\n" - " >=2\n" + "Bar_Baz[extra]\n" + "spaced >= 1.0, < 3\n" + "\tTabbed==1.0\t# a tab before the comment\n" "-r base.txt\n" + "--requirement sub/more.txt\n" + "--requirement=other.txt\n" ) assert _requirement_names(tmp_path / "requirements.txt") == { "foo", "bar-baz", - "named", - "hashed", - "continued", + "spaced", + "tabbed", "included-pkg", + "deep", + "other", } @pytest.mark.parametrize( - "line", + ("text", "reason"), [ - "https://example.com/pkg-1.0-py3-none-any.whl", - "./vendor/pkg-1.0.tar.gz", - "pkg-1.0-py3-none-any.whl", - "-e ./local", - "git+https://example.com/pkg.git", + ("jupyter-book>=2.0 \\\n --hash=sha256:00\n", "a continuation line"), + # pip joins the next line onto a comment that doesn't start the line. + ("foo # see below \\\njupyter-book\n", "a continuation line"), + ("jupyter-book\x0c>=2.0\n", "whitespace other than"), + ("jupyter-book>=2.0\xa0# note\n", "whitespace other than"), + ("jupyter-book>=2.0\rfoo\n", "whitespace other than"), + ("-r=base.txt\n", "an option"), # pip opens "=base.txt"; uv, base.txt + ("-rbase.txt\n", "an option"), + ("--requirementbase.txt\n", "an option"), + ("--requirem base.txt\n", "an option"), + ('-r "base.txt"\n', "an option"), + ("-r base.txt --pre\n", "an option"), + ("-r ${DIR}/base.txt\n", "an option"), + ("-r https://example.com/base.txt\n", "an option"), + ("-c base.txt\n", "an option"), + ("-e ./local\n", "an option"), + ("--editable=./local\n", "an option"), + ("--index-url https://example.com/simple\n", "an option"), + ("https://example.com/pkg-1.0-py3-none-any.whl\n", "a URL or path"), + ("git+https://example.com/pkg.git\n", "a URL or path"), + ("./vendor/pkg-1.0.tar.gz\n", "a URL or path"), + ("named @ https://example.com/named-1.0.tar.gz\n", "a URL or path"), + ( + "jupyter-book @ https://h.example/p;v=1/jupyter_book-2.1.7-py3-none-any.whl\n", + "a URL", + ), + ('jupyter-book>=2.0 ; python_version < "3"\n', "a marker"), + ("jupyter-book>=2.0; extra == 'docs'\n", "a marker"), + ("jupyter-book===2.0\n", "arbitrary equality"), + ("jupyter-book>=2.0 --hash=sha256:00\n", "a per-requirement option"), + ("pkg-1.0-py3-none-any.whl\n", "an archive name"), + ("PKG-1.0-py3-none-any.WHL\n", "an archive name"), + ("jupyter-book.txz\n", "an archive name"), + ("jupyter-book==2.0+local.zip\n", "an archive name"), + ("jupyter-book.zip[docs]\n", "an archive name"), + # A naive comment rule would read this as "foo". + ("foo#bar\n", None), + ("${PKG}>=2.0\n", None), ], ) -def test_requirement_names_refuse_requirements_they_cant_name(tmp_path, line): - (tmp_path / "requirements.txt").write_text(line + "\n") - with pytest.raises((AssertionError, InvalidRequirement)): - _requirement_names(tmp_path / "requirements.txt") +def test_requirement_names_refuse_every_other_form(tmp_path, text, reason): + (tmp_path / "base.txt").write_text("jupyter-book>=2.0\n") + (tmp_path / "requirements.txt").write_text(text) + if reason is None: + with pytest.raises(InvalidRequirement): + _requirement_names(tmp_path / "requirements.txt") + else: + with pytest.raises(AssertionError, match=re.escape(reason)): + _requirement_names(tmp_path / "requirements.txt") def test_docs_site_requirements_declare_jupyter_book(): From 919b9887128702fdd94da9d4b5b11b14053b0aca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 06:25:00 -0400 Subject: [PATCH 5/8] Refuse encoding lines, cycles, symlinked .. and trailing commas (review r4) Review r4 found that pip decodes a requirements file by a PEP 263 line among its first two (_decode_req_file in req_file.py), so an accepted comment such as "# coding: utf-7" could make pip join a later line into a comment and install no jupyter-book; uv ignores the line. The reader now refuses an encoding declaration in a file's first two lines, a UTF-8 byte-order mark, and bytes that aren't UTF-8. It also refuses: - an include cycle, which pip reports as an error and uv reads as nothing; - an include whose .. comes after a symlinked directory, which pip collapses lexically while uv follows the link, so they read different files; - a trailing comma in the version specifiers, which uv 0.11.7 rejects and pip and uv 0.13.4 accept. And it strips extras together with any whitespace before them before the archive-name check, as pip's strip_extras does, so "jupyter-book.zip [docs]" is refused. A correction to c5744ceb's message: it said uv rejects a backslash continuation inside a requirement, with no logged run behind it. Review r5's differential now records one: uv 0.13.4 can't parse "jupyter-book \" followed by ">=2.0", which pip joins and installs. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 104 ++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index 27a0803c..ef8fc6eb 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -11,6 +11,8 @@ from __future__ import annotations +import codecs +import os import re import tomllib from pathlib import Path @@ -52,20 +54,32 @@ def test_docs_site_stack_is_not_locked(): assert not locked & DOCS_SITE_STACK -# The line forms _requirement_names reads, chosen because pip (req_file.py and -# req/constructors.py) and uv, which builds the docs site, read each the same -# way. Any other form fails the test: the two readers differ on continuation -# lines, -r spellings, markers and archive-like versions, and a false marker -# makes both install nothing, so guessing could hide jupyter-book from one. -# - blank lines and comments: a # at a line's start or after whitespace; +# The line forms _requirement_names reads. pip (req_file.py and +# req/constructors.py) and uv, which builds the docs site, read each of them +# the same way; any other line fails the test rather than being guessed at. +# Elsewhere they differ: on continuation lines, -r spellings, archive-like +# versions, encoding declarations, include cycles and includes through a +# symlinked directory, and uv 0.11 rejects a trailing comma that pip and +# later uv accept. A false marker makes both quietly install nothing. So a +# guess could hide jupyter-book from one or both. +# - blank lines and comments: a # at a line's start or after whitespace, +# except a PEP 263 encoding line among a file's first two, which pip +# applies to the whole file; # - includes: -r FILE, --requirement FILE or --requirement=FILE, resolved -# relative to the including file; -# - requirements: a name, optional extras and version specifiers; no URL, -# path, marker, archive name or per-requirement option. +# relative to the including file, with no cycle and no .. after a +# symlinked directory; +# - requirements: a name, optional extras and version specifiers with no +# trailing comma; no URL, path, marker, archive name or per-requirement +# option. +# Every file is UTF-8 with no byte-order mark. _COMMENT = re.compile(r"(^|\s+)#.*$") _INCLUDE = re.compile( r"(?:-r\s+|--requirement(?:\s+|=))([A-Za-z0-9._/][A-Za-z0-9._/-]*)" ) +# pip decodes a file by its byte-order mark, or else by a PEP 263 line among +# its first two that starts with # (_decode_req_file in req_file.py). The +# UTF-16 and UTF-32 marks aren't valid UTF-8, so only UTF-8's needs a check. +_PEP263 = re.compile(rb"coding[:=]\s*([-\w.]+)") # pip's archive extensions (pip/_internal/utils/filetypes.py), which it # compares case-insensitively, after stripping extras, to decide that a line # names a file rather than a project. @@ -73,12 +87,23 @@ def test_docs_site_stack_is_not_locked(): _ARCHIVE += (".tar.xz", ".txz", ".tlz", ".tar.lz", ".tar.lzma") -def _requirement_names(path: Path) -> set[str]: +def _requirement_names(path: Path, _including: tuple[str, ...] = ()) -> set[str]: """Names of the packages a requirements file asks pip and uv to install, following its includes. A line outside the forms above fails the test.""" + real = os.path.realpath(path) + assert real not in _including, f"an include cycle: {path}" + data = path.read_bytes() + assert not data.startswith(codecs.BOM_UTF8), f"a byte-order mark: {path.name}" + for first in data.split(b"\n")[:2]: + assert not (first[:1] == b"#" and _PEP263.search(first)), ( + f"an encoding declaration: {path.name}: {first!r}" + ) + try: + # Decoded from bytes, so a lone carriage return isn't a line break. + text = data.decode() + except UnicodeDecodeError: + raise AssertionError(f"not UTF-8: {path.name}") from None names = set() - # Bytes, so a lone carriage return isn't read as a line break. - text = path.read_bytes().decode() for number, raw in enumerate(text.split("\n"), 1): raw = raw.removesuffix("\r") where = f"{path.name}:{number}: {raw!r}" @@ -91,14 +116,22 @@ def _requirement_names(path: Path) -> set[str]: continue include = _INCLUDE.fullmatch(line) if include: - names |= _requirement_names(path.parent / include.group(1)) + target = path.parent / include.group(1) + # pip collapses .. before opening the file (os.path.abspath), + # while opening it follows a symlink before applying the .. + lexical = os.path.realpath(os.path.normpath(target)) + assert os.path.realpath(target) == lexical, ( + f"an include whose .. crosses a symlink: {where}" + ) + names |= _requirement_names(target, (*_including, real)) continue assert not line.startswith("-"), f"an option: {where}" assert not re.search(r"\s-", line), f"a per-requirement option: {where}" assert not re.search(r"[@:/\\]", line), f"a URL or path: {where}" assert ";" not in line, f"a marker: {where}" assert "===" not in line, f"arbitrary equality: {where}" - stem = re.sub(r"\[[^\]]+\]$", "", line).lower() + assert not re.search(r",\s*\)?$", line), f"a trailing comma: {where}" + stem = re.sub(r"\s*\[[^\]]+\]$", "", line).lower() assert not stem.endswith(_ARCHIVE), f"an archive name: {where}" requirement = Requirement(line) assert requirement.url is None and requirement.marker is None, where @@ -139,22 +172,40 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ("text", "reason"), [ ("jupyter-book>=2.0 \\\n --hash=sha256:00\n", "a continuation line"), + ("jupyter-book \\\n>=2.0\n", "a continuation line"), # pip joins the next line onto a comment that doesn't start the line. ("foo # see below \\\njupyter-book\n", "a continuation line"), ("jupyter-book\x0c>=2.0\n", "whitespace other than"), ("jupyter-book>=2.0\xa0# note\n", "whitespace other than"), ("jupyter-book>=2.0\rfoo\n", "whitespace other than"), + # pip decodes the file as UTF-7, so "+AFw" is a backslash that joins the + # jupyter-book line into the comment before it. + ("# coding: utf-7\nfoo # x+AFw\njupyter-book\n", "an encoding declaration"), + ( + "# Docs site; no coding=needed\njupyter-book>=2.0\n", + "an encoding declaration", + ), + ("\n# coding: utf-16\njupyter-book\n", "an encoding declaration"), + ("\ufeffjupyter-book\n", "a byte-order mark"), + (b"jupyter-book # caf\xe9\n", "not UTF-8"), + ("jupyter-book\n".encode("utf-16"), "not UTF-8"), + ("jupyter-book\n".encode("utf-32"), "not UTF-8"), + ("-r requirements.txt\n", "an include cycle"), ("-r=base.txt\n", "an option"), # pip opens "=base.txt"; uv, base.txt ("-rbase.txt\n", "an option"), ("--requirementbase.txt\n", "an option"), + ("--requirements=base.txt\n", "an option"), ("--requirem base.txt\n", "an option"), ('-r "base.txt"\n', "an option"), ("-r base.txt --pre\n", "an option"), + ("-r base.txt -r other.txt\n", "an option"), ("-r ${DIR}/base.txt\n", "an option"), ("-r https://example.com/base.txt\n", "an option"), ("-c base.txt\n", "an option"), ("-e ./local\n", "an option"), ("--editable=./local\n", "an option"), + ("--edit ./local\n", "an option"), + ("--pre -e ./local\n", "an option"), ("--index-url https://example.com/simple\n", "an option"), ("https://example.com/pkg-1.0-py3-none-any.whl\n", "a URL or path"), ("git+https://example.com/pkg.git\n", "a URL or path"), @@ -167,12 +218,16 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ('jupyter-book>=2.0 ; python_version < "3"\n', "a marker"), ("jupyter-book>=2.0; extra == 'docs'\n", "a marker"), ("jupyter-book===2.0\n", "arbitrary equality"), + ("jupyter-book===2.0.zip\n", "arbitrary equality"), + ("jupyter-book>=2.0,\n", "a trailing comma"), + ("jupyter-book (>=1, <3 , ) # x\n", "a trailing comma"), ("jupyter-book>=2.0 --hash=sha256:00\n", "a per-requirement option"), ("pkg-1.0-py3-none-any.whl\n", "an archive name"), ("PKG-1.0-py3-none-any.WHL\n", "an archive name"), ("jupyter-book.txz\n", "an archive name"), ("jupyter-book==2.0+local.zip\n", "an archive name"), ("jupyter-book.zip[docs]\n", "an archive name"), + ("jupyter-book.zip [docs]\n", "an archive name"), # A naive comment rule would read this as "foo". ("foo#bar\n", None), ("${PKG}>=2.0\n", None), @@ -180,7 +235,8 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ) def test_requirement_names_refuse_every_other_form(tmp_path, text, reason): (tmp_path / "base.txt").write_text("jupyter-book>=2.0\n") - (tmp_path / "requirements.txt").write_text(text) + data = text if isinstance(text, bytes) else text.encode() + (tmp_path / "requirements.txt").write_bytes(data) if reason is None: with pytest.raises(InvalidRequirement): _requirement_names(tmp_path / "requirements.txt") @@ -189,5 +245,23 @@ def test_requirement_names_refuse_every_other_form(tmp_path, text, reason): _requirement_names(tmp_path / "requirements.txt") +def test_requirement_names_refuse_an_include_whose_dotdot_crosses_a_symlink( + tmp_path, +): + """pip opens -r sub/../base.txt as docs/base.txt; the OS follows sub first.""" + (tmp_path / "elsewhere").mkdir() + (tmp_path / "elsewhere" / "more.txt").write_text("more\n") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "sub").symlink_to(tmp_path / "elsewhere") + (tmp_path / "docs" / "base.txt").write_text("foo\n") + (tmp_path / "base.txt").write_text("jupyter-book\n") + requirements = tmp_path / "docs" / "requirements.txt" + requirements.write_text("-r sub/more.txt\n") + assert _requirement_names(requirements) == {"more"} + requirements.write_text("-r sub/../base.txt\n") + with pytest.raises(AssertionError, match="an include whose .. crosses a symlink"): + _requirement_names(requirements) + + def test_docs_site_requirements_declare_jupyter_book(): assert "jupyter-book" in _requirement_names(ROOT / "docs" / "requirements.txt") From d3a430ec8eac2a893f4b941cf5d83bcae9462066 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 07:07:12 -0400 Subject: [PATCH 6/8] Refuse empty parentheses and include paths ending in / (review r5) Review r5 approved 919b9887 and listed forms the differential had never run. Run now: pip installs "jupyter-book ()" and "-r base.txt/" (or "base.txt/."), which pathlib also reads as base.txt, while uv 0.11.7, 0.12.13 and 0.13.4 all reject them. The reader now refuses both, so it again accepts only forms the two read alike. An include inside a file reached through a symlink, an absolute include path, a tab after --requirement, and pre, post and dev releases are read alike and stay accepted; the reader test now covers the absolute path and the tab. A correction to 919b9887's message: it said uv reads an include cycle as nothing. That holds for a file whose only line includes itself; with "foo" before the self-include, uv installs foo while pip reports the cycle. The reader refuses both, and both are now among the refused lines. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 33 ++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index ef8fc6eb..67a0e1cc 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -58,19 +58,20 @@ def test_docs_site_stack_is_not_locked(): # req/constructors.py) and uv, which builds the docs site, read each of them # the same way; any other line fails the test rather than being guessed at. # Elsewhere they differ: on continuation lines, -r spellings, archive-like -# versions, encoding declarations, include cycles and includes through a -# symlinked directory, and uv 0.11 rejects a trailing comma that pip and -# later uv accept. A false marker makes both quietly install nothing. So a -# guess could hide jupyter-book from one or both. +# versions, encoding declarations, include cycles, a .. after a symlinked +# directory and include paths ending in /, and uv rejects empty parentheses +# and (in 0.11) a trailing comma that pip accepts. A false marker makes both +# quietly install nothing. So a guess could hide jupyter-book from one or +# both. # - blank lines and comments: a # at a line's start or after whitespace, # except a PEP 263 encoding line among a file's first two, which pip # applies to the whole file; # - includes: -r FILE, --requirement FILE or --requirement=FILE, resolved -# relative to the including file, with no cycle and no .. after a -# symlinked directory; +# relative to the including file, with no cycle, no .. after a symlinked +# directory and no trailing / or /.; # - requirements: a name, optional extras and version specifiers with no -# trailing comma; no URL, path, marker, archive name or per-requirement -# option. +# empty parentheses or trailing comma; no URL, path, marker, archive name +# or per-requirement option. # Every file is UTF-8 with no byte-order mark. _COMMENT = re.compile(r"(^|\s+)#.*$") _INCLUDE = re.compile( @@ -116,6 +117,10 @@ def _requirement_names(path: Path, _including: tuple[str, ...] = ()) -> set[str] continue include = _INCLUDE.fullmatch(line) if include: + # pathlib drops a trailing / or /., which uv keeps. + assert not re.search(r"/\.?$", include.group(1)), ( + f"an include path ending in / or /.: {where}" + ) target = path.parent / include.group(1) # pip collapses .. before opening the file (os.path.abspath), # while opening it follows a symlink before applying the .. @@ -131,6 +136,7 @@ def _requirement_names(path: Path, _including: tuple[str, ...] = ()) -> set[str] assert ";" not in line, f"a marker: {where}" assert "===" not in line, f"arbitrary equality: {where}" assert not re.search(r",\s*\)?$", line), f"a trailing comma: {where}" + assert not re.search(r"\(\s*\)", line), f"empty parentheses: {where}" stem = re.sub(r"\s*\[[^\]]+\]$", "", line).lower() assert not stem.endswith(_ARCHIVE), f"an archive name: {where}" requirement = Requirement(line) @@ -145,6 +151,8 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): (tmp_path / "sub" / "more.txt").write_text("-r ../deep.txt\n") (tmp_path / "deep.txt").write_text("deep\n") (tmp_path / "other.txt").write_text("other\r\n") + (tmp_path / "tabbed.txt").write_text("after-tab\n") + (tmp_path / "absolute.txt").write_text("absolute\n") (tmp_path / "requirements.txt").write_text( "# top\n" " # indented\n" @@ -156,6 +164,8 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): "-r base.txt\n" "--requirement sub/more.txt\n" "--requirement=other.txt\n" + "--requirement\t./tabbed.txt\n" + f"-r {tmp_path}/absolute.txt\n" ) assert _requirement_names(tmp_path / "requirements.txt") == { "foo", @@ -165,6 +175,8 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): "included-pkg", "deep", "other", + "after-tab", + "absolute", } @@ -191,6 +203,9 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ("jupyter-book\n".encode("utf-16"), "not UTF-8"), ("jupyter-book\n".encode("utf-32"), "not UTF-8"), ("-r requirements.txt\n", "an include cycle"), + ("foo\n-r requirements.txt\n", "an include cycle"), + ("-r base.txt/\n", "an include path ending in /"), + ("--requirement=base.txt/.\n", "an include path ending in /"), ("-r=base.txt\n", "an option"), # pip opens "=base.txt"; uv, base.txt ("-rbase.txt\n", "an option"), ("--requirementbase.txt\n", "an option"), @@ -221,6 +236,8 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ("jupyter-book===2.0.zip\n", "arbitrary equality"), ("jupyter-book>=2.0,\n", "a trailing comma"), ("jupyter-book (>=1, <3 , ) # x\n", "a trailing comma"), + ("jupyter-book ()\n", "empty parentheses"), + ("jupyter-book[docs] ( ) # x\n", "empty parentheses"), ("jupyter-book>=2.0 --hash=sha256:00\n", "a per-requirement option"), ("pkg-1.0-py3-none-any.whl\n", "an archive name"), ("PKG-1.0-py3-none-any.WHL\n", "an archive name"), From a381226a2608a7136e7092ea10780db29ab5b424 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 07:41:37 -0400 Subject: [PATCH 7/8] Refuse absolute include paths and included symlinks (review r6) Review r6 approved d3a430ec and noted that the reader test's absolute include depended on the temp directory's path matching the include pattern, so a temp root or user name with a space, @ or + would fail the test falsely. It also asked whether uv reads a symlinked file reached under two names as pip does. Run now, pip and uv 0.11.7, 0.12.13 and 0.13.4 read an absolute include, an include of a symlinked file, and that file reached both directly and through the link alike. The reader refuses both forms anyway: no file here needs them, and refusing them keeps the test independent of the machine's paths and of how uv recognizes a file it has already read. Also from the review: the comment on paths ending in / now says uv rejects them, which is what the log shows, rather than why. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 37 ++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index 67a0e1cc..341e524d 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -62,20 +62,24 @@ def test_docs_site_stack_is_not_locked(): # directory and include paths ending in /, and uv rejects empty parentheses # and (in 0.11) a trailing comma that pip accepts. A false marker makes both # quietly install nothing. So a guess could hide jupyter-book from one or -# both. +# both. Absolute include paths and included symlinks are refused too, though +# pip and uv read the cases run so far alike: no file here needs them, and +# refusing them keeps the test independent of the machine's paths and of how +# uv recognizes a file it has already read. # - blank lines and comments: a # at a line's start or after whitespace, # except a PEP 263 encoding line among a file's first two, which pip # applies to the whole file; -# - includes: -r FILE, --requirement FILE or --requirement=FILE, resolved -# relative to the including file, with no cycle, no .. after a symlinked -# directory and no trailing / or /.; +# - includes: -r FILE, --requirement FILE or --requirement=FILE, where FILE +# is a relative path, resolved against the including file's directory, +# that names no symlink, has no .. after a symlinked directory, doesn't +# end in / or /. and closes no cycle; # - requirements: a name, optional extras and version specifiers with no # empty parentheses or trailing comma; no URL, path, marker, archive name # or per-requirement option. # Every file is UTF-8 with no byte-order mark. _COMMENT = re.compile(r"(^|\s+)#.*$") _INCLUDE = re.compile( - r"(?:-r\s+|--requirement(?:\s+|=))([A-Za-z0-9._/][A-Za-z0-9._/-]*)" + r"(?:-r\s+|--requirement(?:\s+|=))([A-Za-z0-9._][A-Za-z0-9._/-]*)" ) # pip decodes a file by its byte-order mark, or else by a PEP 263 line among # its first two that starts with # (_decode_req_file in req_file.py). The @@ -117,11 +121,12 @@ def _requirement_names(path: Path, _including: tuple[str, ...] = ()) -> set[str] continue include = _INCLUDE.fullmatch(line) if include: - # pathlib drops a trailing / or /., which uv keeps. + # pathlib drops a trailing / or /., and uv rejects the path. assert not re.search(r"/\.?$", include.group(1)), ( f"an include path ending in / or /.: {where}" ) target = path.parent / include.group(1) + assert not os.path.islink(target), f"an include of a symlink: {where}" # pip collapses .. before opening the file (os.path.abspath), # while opening it follows a symlink before applying the .. lexical = os.path.realpath(os.path.normpath(target)) @@ -152,7 +157,6 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): (tmp_path / "deep.txt").write_text("deep\n") (tmp_path / "other.txt").write_text("other\r\n") (tmp_path / "tabbed.txt").write_text("after-tab\n") - (tmp_path / "absolute.txt").write_text("absolute\n") (tmp_path / "requirements.txt").write_text( "# top\n" " # indented\n" @@ -165,7 +169,6 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): "--requirement sub/more.txt\n" "--requirement=other.txt\n" "--requirement\t./tabbed.txt\n" - f"-r {tmp_path}/absolute.txt\n" ) assert _requirement_names(tmp_path / "requirements.txt") == { "foo", @@ -176,7 +179,6 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): "deep", "other", "after-tab", - "absolute", } @@ -205,6 +207,7 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ("-r requirements.txt\n", "an include cycle"), ("foo\n-r requirements.txt\n", "an include cycle"), ("-r base.txt/\n", "an include path ending in /"), + ("-r /abs/base.txt\n", "an option"), ("--requirement=base.txt/.\n", "an include path ending in /"), ("-r=base.txt\n", "an option"), # pip opens "=base.txt"; uv, base.txt ("-rbase.txt\n", "an option"), @@ -280,5 +283,21 @@ def test_requirement_names_refuse_an_include_whose_dotdot_crosses_a_symlink( _requirement_names(requirements) +def test_requirement_names_refuse_an_include_of_a_symlink(tmp_path): + """A symlinked file's own includes resolve against the link's directory.""" + (tmp_path / "a").mkdir() + (tmp_path / "a" / "real.txt").write_text("-r base.txt\n") + (tmp_path / "a" / "base.txt").write_text("foo\n") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "link.txt").symlink_to("../a/real.txt") + (tmp_path / "docs" / "base.txt").write_text("jupyter-book\n") + requirements = tmp_path / "docs" / "requirements.txt" + requirements.write_text("-r ../a/real.txt\n") + assert _requirement_names(requirements) == {"foo"} + requirements.write_text("-r link.txt\n") + with pytest.raises(AssertionError, match="an include of a symlink"): + _requirement_names(requirements) + + def test_docs_site_requirements_declare_jupyter_book(): assert "jupyter-book" in _requirement_names(ROOT / "docs" / "requirements.txt") From 37c7bfbcf87e7ccfb3197343b7ee3fdc83364e78 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 25 Oct 2026 08:13:44 -0400 Subject: [PATCH 8/8] Check included symlinks as pip opens them; name absolute includes (review r7) Review r7 approved a381226a with two non-blocking findings in the reader. The symlink check looked only at the path as the OS resolves it. With "-r sub/../x.txt", where docs/sub links to a directory and docs/x.txt is a symlink, pip opens docs/x.txt (it collapses .. first) while the OS follows sub and finds a regular file, so the include was accepted. pip and uv 0.11.7, 0.12.13 and 0.13.4 read that tree alike, so no name was wrong, but the comment said "names no symlink". The check now looks at both paths, and the reader test builds that tree. An absolute include was refused only as "an option", and its one test line named a file that doesn't exist, so the mutant allowing absolute paths died of a missing file rather than an accepted include. Absolute includes now have their own message, and a new test includes a real file by its absolute path. Co-Authored-By: Claude Opus 5.5 --- tests/test_render_environment.py | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/test_render_environment.py b/tests/test_render_environment.py index 341e524d..69094c05 100644 --- a/tests/test_render_environment.py +++ b/tests/test_render_environment.py @@ -71,8 +71,9 @@ def test_docs_site_stack_is_not_locked(): # applies to the whole file; # - includes: -r FILE, --requirement FILE or --requirement=FILE, where FILE # is a relative path, resolved against the including file's directory, -# that names no symlink, has no .. after a symlinked directory, doesn't -# end in / or /. and closes no cycle; +# that names no symlink (read either with .. collapsed, as pip opens it, +# or as the OS resolves it), has no .. after a symlinked directory, +# doesn't end in / or /. and closes no cycle; # - requirements: a name, optional extras and version specifiers with no # empty parentheses or trailing comma; no URL, path, marker, archive name # or per-requirement option. @@ -126,7 +127,9 @@ def _requirement_names(path: Path, _including: tuple[str, ...] = ()) -> set[str] f"an include path ending in / or /.: {where}" ) target = path.parent / include.group(1) - assert not os.path.islink(target), f"an include of a symlink: {where}" + assert not ( + os.path.islink(target) or os.path.islink(os.path.normpath(target)) + ), f"an include of a symlink: {where}" # pip collapses .. before opening the file (os.path.abspath), # while opening it follows a symlink before applying the .. lexical = os.path.realpath(os.path.normpath(target)) @@ -135,6 +138,9 @@ def _requirement_names(path: Path, _including: tuple[str, ...] = ()) -> set[str] ) names |= _requirement_names(target, (*_including, real)) continue + assert not re.match(r"(?:-r\s+|--requirement(?:\s+|=))/", line), ( + f"an absolute include path: {where}" + ) assert not line.startswith("-"), f"an option: {where}" assert not re.search(r"\s-", line), f"a per-requirement option: {where}" assert not re.search(r"[@:/\\]", line), f"a URL or path: {where}" @@ -207,7 +213,8 @@ def test_requirement_names_read_the_forms_pip_and_uv_agree_on(tmp_path): ("-r requirements.txt\n", "an include cycle"), ("foo\n-r requirements.txt\n", "an include cycle"), ("-r base.txt/\n", "an include path ending in /"), - ("-r /abs/base.txt\n", "an option"), + ("-r /abs/base.txt\n", "an absolute include path"), + ("--requirement=/abs/base.txt\n", "an absolute include path"), ("--requirement=base.txt/.\n", "an include path ending in /"), ("-r=base.txt\n", "an option"), # pip opens "=base.txt"; uv, base.txt ("-rbase.txt\n", "an option"), @@ -283,6 +290,15 @@ def test_requirement_names_refuse_an_include_whose_dotdot_crosses_a_symlink( _requirement_names(requirements) +def test_requirement_names_refuse_an_absolute_include(tmp_path): + """Refused whatever characters the machine's temp path holds.""" + (tmp_path / "base.txt").write_text("jupyter-book\n") + requirements = tmp_path / "requirements.txt" + requirements.write_text(f"-r {tmp_path / 'base.txt'}\n") + with pytest.raises(AssertionError, match="an absolute include path"): + _requirement_names(requirements) + + def test_requirement_names_refuse_an_include_of_a_symlink(tmp_path): """A symlinked file's own includes resolve against the link's directory.""" (tmp_path / "a").mkdir() @@ -297,6 +313,15 @@ def test_requirement_names_refuse_an_include_of_a_symlink(tmp_path): requirements.write_text("-r link.txt\n") with pytest.raises(AssertionError, match="an include of a symlink"): _requirement_names(requirements) + # pip opens docs/x.txt, a symlink; the OS follows sub to e/inner first + # and opens e/x.txt, a regular file, so both read the same file. + (tmp_path / "e" / "inner").mkdir(parents=True) + (tmp_path / "e" / "x.txt").write_text("jupyter-book\n") + (tmp_path / "docs" / "x.txt").symlink_to("../e/x.txt") + (tmp_path / "docs" / "sub").symlink_to("../e/inner") + requirements.write_text("-r sub/../x.txt\n") + with pytest.raises(AssertionError, match="an include of a symlink"): + _requirement_names(requirements) def test_docs_site_requirements_declare_jupyter_book():