From 20c1c08df7a90eb9e92865d6337747d5afb6d853 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 17 Aug 2026 22:01:09 +0000 Subject: [PATCH 01/26] ci: add security-aware Dependabot updates for Python and GitHub Actions --- .github/dependabot.yml | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..025d160d9f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +version: 2 + +# Cooldowns apply only to routine version updates; security fixes stay prompt. +updates: + # Dependabot supports pyproject.toml but cannot update Rye-generated locks. + # Before merging a Python update, regenerate requirements.lock and + # requirements-dev.lock with `rye lock --all-features`, then run `uv lock`. + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Etc/UTC + cooldown: + default-days: 8 + open-pull-requests-limit: 3 + groups: + python-maintenance: + applies-to: version-updates + patterns: + - "*" + update-types: + - minor + - patch + python-security: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:30" + timezone: Etc/UTC + cooldown: + default-days: 8 + open-pull-requests-limit: 5 + groups: + # CodeQL init/analyze share versioned state and must change together. + codeql: + patterns: + - "github/codeql-action/*" From 4688d3b821c184c10b081312898d3fb41cd2eff6 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 17 Aug 2026 22:56:10 +0000 Subject: [PATCH 02/26] fix: track Python lockfiles and pinned Actions securely --- .github/dependabot.yml | 16 +- .github/workflows/ci.yml | 35 ++ pyproject.toml | 27 +- uv.lock | 1282 +++++++++++++++++++++++++++++++++++++- 4 files changed, 1333 insertions(+), 27 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 025d160d9f..fc60a975e1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,11 @@ version: 2 -# Cooldowns apply only to routine version updates; security fixes stay prompt. +# Python cooldowns apply only to routine version updates, not security fixes. updates: - # Dependabot supports pyproject.toml but cannot update Rye-generated locks. - # Before merging a Python update, regenerate requirements.lock and - # requirements-dev.lock with `rye lock --all-features`, then run `uv lock`. - - package-ecosystem: pip + # uv owns pyproject.toml and uv.lock; CI verifies the legacy Rye locks. + - package-ecosystem: uv directory: / + versioning-strategy: increase-if-necessary schedule: interval: weekly day: monday @@ -31,15 +30,12 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: weekly - day: monday + interval: daily time: "09:30" timezone: Etc/UTC - cooldown: - default-days: 8 open-pull-requests-limit: 5 groups: # CodeQL init/analyze share versioned state and must change together. codeql: patterns: - - "github/codeql-action/*" + - "github/codeql-action" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6783246815..8e11cb8007 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,41 @@ permissions: contents: read jobs: + dependency-locks: + timeout-minutes: 10 + name: dependency lock freshness + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Rye + uses: eifinger/setup-rye@c694239a43768373e87d0103d7f547027a23f3c8 + with: + version: '0.44.0' + enable-cache: true + + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Verify dependency groups and committed locks + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] == project["tool"]["rye"]["dev-dependencies"], "Keep uv and Rye development dependencies synchronized"' + uv lock --check + rye lock --all-features + git diff --exit-code -- requirements.lock requirements-dev.lock + if [ "$GITHUB_EVENT_NAME" = pull_request ] && [ "$GITHUB_ACTOR" = 'dependabot[bot]' ] && + ! git diff --quiet "$BASE_SHA"...HEAD -- uv.lock && + git diff --quiet "$BASE_SHA"...HEAD -- requirements.lock requirements-dev.lock; then + echo 'Regenerate and commit the affected Rye locks before merging this uv update.' + exit 1 + fi + lint: timeout-minutes: 10 name: lint diff --git a/pyproject.toml b/pyproject.toml index 552d30e64b..5919715387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,9 +52,34 @@ bedrock = [ "botocore>=1.40.0,<2", ] +[dependency-groups] +# Mirror Rye's legacy development group until the repository migrates to uv. +dev = [ + "pyright==1.1.399", + "mypy==1.17", + "pytest", + "pytest-asyncio", + "jsonschema>=4.23.0", + "ruff", + "time-machine", + "nox", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "inline-snapshot>=0.28.0", + "azure-identity >=1.14.1", + "botocore==1.42.97", + "types-tqdm > 4", + "types-pyaudio > 0", + "trio >=0.22.2", + "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", + "griffe>=1", +] + [tool.rye] managed = true -# version pins are in requirements-dev.lock +# Keep in sync with [dependency-groups].dev for the existing Rye toolchain. dev-dependencies = [ "pyright==1.1.399", "mypy==1.17", diff --git a/uv.lock b/uv.lock index 639ea6e4b1..15e1fa5139 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -193,6 +196,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, ] +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -211,18 +232,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "botocore" -version = "1.43.46" +version = "1.42.97" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc", size = 14950367, upload-time = "2026-04-27T20:39:01.261Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -307,6 +375,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -316,6 +471,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "dependency-groups" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/55/f054de99871e7beb81935dea8a10b90cd5ce42122b1c3081d5282fdb3621/dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd", size = 10093, upload-time = "2025-05-02T00:34:29.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, +] + +[[package]] +name = "dirty-equals" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067, upload-time = "2025-11-17T01:51:24.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -337,6 +592,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -458,6 +740,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "griffe" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/44/63913c007814cab5ba9d36f25ad40dfc640c2e2931d195bd2d05f774a5d6/griffe-2.1.0.tar.gz", hash = "sha256:c58845df5a364feaabd05ee8c767b97b03e478da8aa18b9923553c812fb0d955", size = 244879, upload-time = "2026-06-19T12:05:41.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl", hash = "sha256:2ccdab17fb9cd76f278d7b5611cfc8f68cbe846d8d48df63dff80b62ecfa6f65", size = 5140, upload-time = "2026-06-19T12:05:39.913Z" }, +] + +[[package]] +name = "griffecli" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c6/90f85d47af96300d629b38c25b71aad9467a620cac964a39280e822efc8a/griffecli-2.1.0.tar.gz", hash = "sha256:2ff68dbee9395fdb668b10374c51683392d697b226ac60159798f4add1ee716c", size = 56913, upload-time = "2026-06-19T12:05:43.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl", hash = "sha256:6e22b1423d562ddc510997b4be1fe89de59e19dcff78831c0f4bfc3b8134a718", size = 9500, upload-time = "2026-06-19T12:05:37.517Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -496,6 +813,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, ] +[[package]] +name = "humanize" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/ea/13a1ef3c12d12662905801495283530251918b70d62d368f1d2e0272c70d/humanize-4.16.0.tar.gz", hash = "sha256:7dc2244a2f84a4bfb1d36c37bac80cd78e35cdc5c119206d87b018e1445f3a3f", size = 89515, upload-time = "2026-06-30T16:17:29.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/aa/0b7365d30fed43e7a3449aba1fe20a0a7174d9cf13e282af4e69ac825441/humanize-4.16.0-py3-none-any.whl", hash = "sha256:353eb2f34c09d098b2880eee8bef21832eae6d174f48c5762fff7e5fcb74d01d", size = 137209, upload-time = "2026-06-30T16:17:28.36Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -505,6 +831,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "inline-snapshot" +version = "0.35.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/8e/baacae89b98ead1940164468fca01812c66186b12c89523f3ea66efcf87c/inline_snapshot-0.35.3.tar.gz", hash = "sha256:5d76f3b4b134fb190b7883669eb496f5c3e7d1c9549bbd6d78b5b8ac17e553e3", size = 2535863, upload-time = "2026-07-27T11:34:46.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/9c/b8a9697e4c4c98fb25fbb49d70734432cab917c98fa50b258aea687584e4/inline_snapshot-0.35.3-py3-none-any.whl", hash = "sha256:3decb255b15d6f5956fbc0648c56acf6ceeb9a331b26868e3bf16d2a393c2e31", size = 95167, upload-time = "2026-07-27T11:34:45.234Z" }, +] + [[package]] name = "jiter" version = "0.15.0" @@ -617,6 +981,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +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]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +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 = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msal" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -755,6 +1194,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "mypy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/31/e762baa3b73905c856d45ab77b4af850e8159dffffd86a52879539a08c6b/mypy-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8e08de6138043108b3b18f09d3f817a4783912e48828ab397ecf183135d84d6", size = 10998313, upload-time = "2025-07-14T20:33:24.519Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c1/25b2f0d46fb7e0b5e2bee61ec3a47fe13eff9e3c2f2234f144858bbe6485/mypy-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4a17920ec144647d448fc43725b5873548b1aae6c603225626747ededf582d", size = 10128922, upload-time = "2025-07-14T20:34:06.414Z" }, + { url = "https://files.pythonhosted.org/packages/02/78/6d646603a57aa8a2886df1b8881fe777ea60f28098790c1089230cd9c61d/mypy-1.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff25d151cc057fdddb1cb1881ef36e9c41fa2a5e78d8dd71bee6e4dcd2bc05b", size = 11913524, upload-time = "2025-07-14T20:33:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/19/dae6c55e87ee426fb76980f7e78484450cad1c01c55a1dc4e91c930bea01/mypy-1.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93468cf29aa9a132bceb103bd8475f78cacde2b1b9a94fd978d50d4bdf616c9a", size = 12650527, upload-time = "2025-07-14T20:32:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/86/e1/f916845a235235a6c1e4d4d065a3930113767001d491b8b2e1b61ca56647/mypy-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:98189382b310f16343151f65dd7e6867386d3e35f7878c45cfa11383d175d91f", size = 12897284, upload-time = "2025-07-14T20:33:38.168Z" }, + { url = "https://files.pythonhosted.org/packages/ae/dc/414760708a4ea1b096bd214d26a24e30ac5e917ef293bc33cdb6fe22d2da/mypy-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:c004135a300ab06a045c1c0d8e3f10215e71d7b4f5bb9a42ab80236364429937", size = 9506493, upload-time = "2025-07-14T20:34:01.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" }, + { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371, upload-time = "2025-07-14T20:33:33.503Z" }, + { url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558, upload-time = "2025-07-14T20:33:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447, upload-time = "2025-07-14T20:32:20.594Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019, upload-time = "2025-07-14T20:32:07.99Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457, upload-time = "2025-07-14T20:33:47.285Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838, upload-time = "2025-07-14T20:33:14.462Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358, upload-time = "2025-07-14T20:32:25.579Z" }, + { url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480, upload-time = "2025-07-14T20:34:21.868Z" }, + { url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666, upload-time = "2025-07-14T20:34:16.841Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[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 = "nox" +version = "2026.8.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "humanize" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/78/74026b59fd3becd3a549498e8f7809e768c9a1db808f6f016f88b638eda6/nox-2026.8.10.tar.gz", hash = "sha256:13f45e46552eade9f4b60474133e31e1e0bb341234a3cc0c9d6fff17570ce8bb", size = 4075448, upload-time = "2026-08-10T21:53:18.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/6a/020c992d7121377b27676af368d5d5287121a9bb12216264d979961e90d8/nox-2026.8.10-py3-none-any.whl", hash = "sha256:943f1ebcace1efb35d91ad2034007df1942d2a1bed75fb33e1bf0ed5a0df1cdb", size = 94662, upload-time = "2026-08-10T21:53:16.477Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -825,9 +1351,12 @@ name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -909,7 +1438,7 @@ wheels = [ [[package]] name = "openai" -version = "3.2.0" # x-release-please-version +version = "3.2.0" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -946,6 +1475,30 @@ voice-helpers = [ { name = "sounddevice" }, ] +[package.dev-dependencies] +dev = [ + { name = "azure-identity" }, + { name = "botocore" }, + { name = "dirty-equals" }, + { name = "griffe" }, + { name = "importlib-metadata" }, + { name = "inline-snapshot" }, + { name = "jsonschema" }, + { name = "mypy" }, + { name = "nest-asyncio" }, + { name = "nox" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, + { name = "rich" }, + { name = "ruff" }, + { name = "time-machine" }, + { name = "trio" }, + { name = "types-pyaudio" }, + { name = "types-tqdm" }, +] + [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.14.1" }, @@ -967,6 +1520,51 @@ requires-dist = [ ] provides-extras = ["aiohttp", "realtime", "datalib", "voice-helpers", "bedrock"] +[package.metadata.requires-dev] +dev = [ + { name = "azure-identity", specifier = ">=1.14.1" }, + { name = "botocore", specifier = "==1.42.97" }, + { name = "dirty-equals", specifier = ">=0.6.0" }, + { name = "griffe", specifier = ">=1" }, + { name = "importlib-metadata", specifier = ">=6.7.0" }, + { name = "inline-snapshot", specifier = ">=0.28.0" }, + { name = "jsonschema", specifier = ">=4.23.0" }, + { name = "mypy", specifier = "==1.17" }, + { name = "nest-asyncio", specifier = "==1.6.0" }, + { name = "nox" }, + { name = "pyright", specifier = "==1.1.399" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "rich", specifier = ">=13.7.1" }, + { name = "ruff" }, + { name = "time-machine" }, + { name = "trio", specifier = ">=0.22.2" }, + { name = "types-pyaudio", specifier = ">0" }, + { name = "types-tqdm", specifier = ">4" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "pandas" version = "2.3.3" @@ -1036,9 +1634,12 @@ name = "pandas" version = "3.0.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1120,9 +1721,12 @@ name = "pandas-stubs" version = "3.0.3.260530" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1135,6 +1739,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/e0/99ec5b02203c4e9ce878bc63d8caa06ac1f891e4d63bded9a5ced70fcb4f/pandas_stubs-3.0.3.260530-py3-none-any.whl", hash = "sha256:a6277eb1c8cebf48d9b2413fcd2e9a6b4ff479c934a223c29eacbc3058c4cb55", size = 173780, upload-time = "2026-05-30T17:47:39.13Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/98/0bf930c4f97d0266b58a89e36c015f56232c52b5d2f207215d48cca9e8f7/platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d", size = 32716, upload-time = "2026-08-10T15:48:06.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e2/4e6eee633809c376c024821b91ade709cbfd040ec53939ffbcc292aa7eee/platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4", size = 23361, upload-time = "2026-08-10T15:48:04.855Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -1403,6 +2034,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyright" +version = "1.1.399" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/9d/d91d5f6d26b2db95476fefc772e2b9a16d54c6bd0ea6bb5c1b6d635ab8b4/pyright-1.1.399.tar.gz", hash = "sha256:439035d707a36c3d1b443aec980bc37053fbda88158eded24b8eedcf1c7b7a1b", size = 3856954, upload-time = "2025-04-10T04:40:25.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b5/380380c9e7a534cb1783c70c3e8ac6d1193c599650a55838d0557586796e/pyright-1.1.399-py3-none-any.whl", hash = "sha256:55f9a875ddf23c9698f24208c764465ffdfd38be6265f7faf9a176e1dc549f3b", size = 5592584, upload-time = "2025-04-10T04:40:23.502Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1415,6 +2130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -1424,6 +2151,333 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +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 = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1442,6 +2496,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +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 = "sounddevice" version = "0.5.5" @@ -1458,6 +2521,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, ] +[[package]] +name = "time-machine" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/18/fba7dc699ddbd0a023e9a2e4ff55e90e09b4409bae707654801defa8f664/time_machine-3.4.0.tar.gz", hash = "sha256:f1c2d8c581f4be50e2f4ff3afd753f8157d52816560de284db716c2b6e9949a8", size = 21106, upload-time = "2026-08-10T22:25:26.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/67/e6c53e0f2659af85f11b416e18e767d38a72033392ca305a28eae96a800e/time_machine-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:63b6d9ef1a31c991b609cba1c1c7e0dfa99bbbcbb860efced2358fafe6d5fb04", size = 20695, upload-time = "2026-08-10T22:24:18.92Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/d98425e7059b7ced4deb707363619bb19721d20349268aa9057acf1f9ec7/time_machine-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:06240d685ef7bc86a37ae699592a31251b75519d351442b610e7e767a0c0d700", size = 21106, upload-time = "2026-08-10T22:24:20.157Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/f25d06413ab9190dd8f34498a261473bf1ce1d1029a2465719bb0bf4c08c/time_machine-3.4.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85af767435a7780a5082662db6ee6acd60fbd00512c0baa97355324f04865ee6", size = 47986, upload-time = "2026-08-10T22:24:21.234Z" }, + { url = "https://files.pythonhosted.org/packages/31/23/e9c1052bd905dcc3ada71910299cdba4f54270fffbf5cf318208cbc51ab1/time_machine-3.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8ee095ceccbf52697b56acd452d6c20f5c2158435473a8b277b56d988603dfd", size = 49258, upload-time = "2026-08-10T22:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/db/95/09445204111236bcfeb0546eb4a7ce706a96fcadbdd0ceeb36f41bee39f6/time_machine-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c1913334e2ba4f5a2a71b336ee98789509c0132f8fddc81d2ea76ef4d4bce45e", size = 48100, upload-time = "2026-08-10T22:24:23.439Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/9dc5327acc34984c0836c55abd27b016c281fc1cd58825e85d87ecd8f36c/time_machine-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49d88c9ce9f85cbf5ad68ad0441569a7e621e3a34f3aacd9d2d2fd0c88783a3d", size = 47431, upload-time = "2026-08-10T22:24:24.621Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/b3872de51d570f70a5b1b8056aebc1cf34902f2557831c4adc071ea10b3c/time_machine-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:c2297be1c6daad869d3c02e5811f3d045403ad138b8a93cc5d9f878a3a14b207", size = 23035, upload-time = "2026-08-10T22:24:25.608Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7c/82746a39c2d0dc662238b41b7a862bcb76a1ee2615179cfa41ebda375f49/time_machine-3.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ce156491ed5c150c7d2f8083628970950db9684590f7f46232414985784ae40a", size = 22169, upload-time = "2026-08-10T22:24:26.663Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9f/05cdf638d4e8404d3bfe64aa11fbc7703c65b15a86f3c9c877b7a6c8899c/time_machine-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:925bf5837bdc57020b7b7376869e0c8ca418edc125a8e2f92996f87c51b1aff3", size = 20697, upload-time = "2026-08-10T22:24:27.875Z" }, + { url = "https://files.pythonhosted.org/packages/0b/10/1bcac67bc79118d98a9406d1a61e57a7826ff5a493c03c4578e3dd19c21b/time_machine-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0f2d2d271d7551f00e968883085d2cfb7b24cbbdcf4bfb275d7dfe3a31eb4df7", size = 21108, upload-time = "2026-08-10T22:24:28.875Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3b/eb1b1ae4713d16aecbbce722e26eb4234457ba89f1a7a713fc20155991e6/time_machine-3.4.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:202828c0fa0a06a5fb48056c9ea96057edc996023011eb162214074ccfc147a2", size = 48856, upload-time = "2026-08-10T22:24:29.893Z" }, + { url = "https://files.pythonhosted.org/packages/98/e9/bdb1b4eacc5594a9d1a9df6a9209b1f3bf24be9d29e1460898f43250c138/time_machine-3.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:329d844d6de14393c6bc68dd06c7f0e6e3c4759fd92e670397df2f9ac25a5bc0", size = 50095, upload-time = "2026-08-10T22:24:31.327Z" }, + { url = "https://files.pythonhosted.org/packages/35/7d/cdaf974a18ae1cbd435adba43675c2549ab740172695c3bbba018a373551/time_machine-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4158f139f28cfb7c35f32825c86e1bc211a223a45f558867a7d68d786f58fa31", size = 48903, upload-time = "2026-08-10T22:24:32.379Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d5/0d0775ac98a3a54659386b9157d73d1d19f60955c85777fe2552aac9d732/time_machine-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56b9d6a386750f9509ba13e7e3ad07a9f70ddc5419df15c105ae738d9831f1c7", size = 48235, upload-time = "2026-08-10T22:24:33.415Z" }, + { url = "https://files.pythonhosted.org/packages/a8/af/5d56a8886e1dbfe6265c4fa84dd80a68ef3d7d5553de278cd72fdcd59a90/time_machine-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5b0429189b465222f5ce50c0f4fc2ec0937d286369084976092531a77dc02d20", size = 23034, upload-time = "2026-08-10T22:24:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/64/78/92e5d04329d10689f3098b85a9775f8c3e1d7c6213673475f44c0d8c8082/time_machine-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:429d457e65c6f6f8016e3783af07635a84ea690c6d3cf807a48139b51b94b167", size = 22165, upload-time = "2026-08-10T22:24:35.414Z" }, + { url = "https://files.pythonhosted.org/packages/ac/04/215493f076672ab0de8af20ec0cd4b749e7e09d66acf2f52523d81ac7121/time_machine-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fda97799fab3eb119adb075a576d63a3df88db2b23e93d8b1e5851ceb9d17154", size = 21118, upload-time = "2026-08-10T22:24:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/d0/49/02d1c81cb7144cd98af5557db64646ad78b6c8b04525c09e528a6d0705d8/time_machine-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fc4fb05990fbb7168e85787c20fc6baca821a9b4f42d49673b3bd6379575832", size = 21296, upload-time = "2026-08-10T22:24:37.355Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0e/9c65ab4e5a0fd595233b3b1286ba8889b25e98951e215c9eb7845fb821e7/time_machine-3.4.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0a9b7dad3edaae88cefe69f52bdd7da92818a569dfb350858add0a11ff9f3991", size = 53182, upload-time = "2026-08-10T22:24:38.3Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/06f57223a62ce8f8a6491f7ac33595432f1477935385cd375d4ed5a831f4/time_machine-3.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a61e150140bb7c9742d8f7178d80510055d88d78bae4368cc3e5dc6edca458", size = 53938, upload-time = "2026-08-10T22:24:39.606Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/dd76ea677731ea91c52bc36bb5985fe0190378705ff17e0fb1124d9645c0/time_machine-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e566243d5a37ef998d33ba5a735db48ce4851fd6ac4e6b9325b0a45a9b2ce04", size = 52611, upload-time = "2026-08-10T22:24:40.753Z" }, + { url = "https://files.pythonhosted.org/packages/37/d3/9c8597e16319957cad5f3619137704bfc6c7a447f909abbf90db09e440d9/time_machine-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:40bbf2f1003abd9d3a89504f083f81f74699cd38642c588b1e260e2eea849fdc", size = 52330, upload-time = "2026-08-10T22:24:41.688Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/c141797f0a7ff80d233d0ad02b1e7df5fba1b54358089092c293736960cc/time_machine-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:20d2eaf9e1c0e2db01349209692abbc79e7163ff585eda28a75fe8f6fde278fa", size = 23137, upload-time = "2026-08-10T22:24:42.813Z" }, + { url = "https://files.pythonhosted.org/packages/72/25/d3078cf01eeba7492d111c57ca42a9f417015d3c7160a38e42ca4b4536f0/time_machine-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:cf97a7ae5d471329c8256c8373e7886de429b921b8cb0490beb3543527f4e438", size = 22385, upload-time = "2026-08-10T22:24:43.793Z" }, + { url = "https://files.pythonhosted.org/packages/2c/63/59d08c82903f683c7e66e67f8282e886a8ebdafbee959e686e21d74a0e01/time_machine-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3e49e5fe91b0d837f31f36bae1b7cebba7345291d24d211564a7a212a4483fd0", size = 21117, upload-time = "2026-08-10T22:24:44.796Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/d2172f40f728c91eb6d0564bf2ba146aa2f9f6fc7462740c09442cf26206/time_machine-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:003856c428c213cc67be56324141846270d63617dfda106d47b74da6c23942ed", size = 21287, upload-time = "2026-08-10T22:24:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/82/b6/ee758a267b602aa5ba961a84f2e32713a886fdada5aa8d31c883661bc186/time_machine-3.4.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77a0c3cef60b4e5f330a23cbe2ba16bebdb713edff9e0e356c0a286bb5ed8096", size = 53125, upload-time = "2026-08-10T22:24:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a8/51526254bae5bad971701d1713e3d59b000bb0c934c3b49845cc79837821/time_machine-3.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a9e7bdb8d79d0120984fd2091e4c31db0309fda49741b1fd48719f44d2ef008", size = 53857, upload-time = "2026-08-10T22:24:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/3a/35/accc4a259685ed246363bb18928b3523c939ebf4daf9e9b327fd6ef35fb9/time_machine-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:be6743f4ebd6266a5924bb211dcdca7fbaeb82e65e6b297224202dd159447fe3", size = 52533, upload-time = "2026-08-10T22:24:48.976Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/d01ab9ed5fd533a00c44634c68c8abf81c15477730761465aa09988621e9/time_machine-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ec199f5d4a4215642f90c74c9b76c4465ab40793bcebc81835f5fe7f7d8a2aa", size = 52256, upload-time = "2026-08-10T22:24:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0f/038238d513bd36347085224da7acc065d0d66a0070caae0dfc88a362b316/time_machine-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab11b42c7c220cb65e8229e3fe5359c6488e2427557a1a07597e24f16c08843b", size = 23147, upload-time = "2026-08-10T22:24:51.04Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/47e13d91670fadc095bbf00b878abc72cf9bd36b5a370a060b979ac7ef43/time_machine-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:a50a09883b5a7456e568650a4c6b95f2d61f05e705c53ed9fc796fc985d5b1ca", size = 22379, upload-time = "2026-08-10T22:24:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/be/20/0f45d2cd21af472872bdfc92b8884d4986ed4e8c9c9ff0f6d5bb1c84d83f/time_machine-3.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4908c39d1a67d0bd52b1ae4105e67a754117eb5be630999fff22f5a11f9b7284", size = 21180, upload-time = "2026-08-10T22:24:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/31/0c/2dc10a49350e8fe7d8960866245f3c7959cae85358ac3246f52c8391f6e5/time_machine-3.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:27fb02ce2a704acea455033aa4f14e5f6d3d45bd9e41df9c0e344d35a88e7597", size = 21301, upload-time = "2026-08-10T22:24:54.055Z" }, + { url = "https://files.pythonhosted.org/packages/a8/61/7799681a1bd63c46b4955bdd89cf193a337bc882c68b0464c3f7de9d56b4/time_machine-3.4.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c11a8fcdcecc736b0b2f9ec1452a8f62d95887c68de65e03479176c4a0c70e94", size = 53163, upload-time = "2026-08-10T22:24:55.014Z" }, + { url = "https://files.pythonhosted.org/packages/80/a9/551e819a9386966c097bb74cba6b1bbfad0f7fee73d5f1342faa9fe12718/time_machine-3.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d072f266d63394d71a097cd38776af448bae72ef59675327ccae1c45f0aa7010", size = 53960, upload-time = "2026-08-10T22:24:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/8b/6c/0f70359e31393f8571afa1850ea520f869047b7c5b34d3ee132485af12d4/time_machine-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4f2b88a4a786e652b182432dd5a49f0fdbdd25213fa310dd8319d3bf4dfba1b", size = 52596, upload-time = "2026-08-10T22:24:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/3e/84/43d6ba29552778eaaa542c0100e7f3e4795c11178de9ffd0097fe4e4c356/time_machine-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c445ccaca7c4e1d296d0a3a56685aacca125235d4bc3b10f425b108853ec906", size = 52307, upload-time = "2026-08-10T22:24:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/94/e0/7146a51da51aebd8be011c6c610c90f278ba3504dfbdee7c882dbfe1c227/time_machine-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd837ca1733a2703cb4241e7a1498cfce51b75aa831158e0aa1910e4a77e0b1e", size = 23431, upload-time = "2026-08-10T22:24:59.467Z" }, + { url = "https://files.pythonhosted.org/packages/28/0a/332d7d0e4f5b77690908e708a57edecc7cad6744c8ec6aa0d3c49edb7578/time_machine-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:ee6e9d63b44c9c8710fe774781cfb1d07552244fef85b08d8e98411fbf241641", size = 22609, upload-time = "2026-08-10T22:25:00.398Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5e/0a7560596776d6ea7400c287b38cb4014fc66c96dd273fb5622807268be6/time_machine-3.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9caeda46d3412b3b593b2edf58b4b620122e9cc13aa01651da83e2dada2a1abb", size = 21879, upload-time = "2026-08-10T22:25:01.382Z" }, + { url = "https://files.pythonhosted.org/packages/2c/81/280b1f97156218d1f3119e454c91c03249ce88fbbc3ec2b1f92f7ce91410/time_machine-3.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d62174295d5a4367ecbaa6caae25769a2bef5d5c650a17275b64a1703e6206d7", size = 22116, upload-time = "2026-08-10T22:25:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/99/9cdd9b9cca1eac08092baa02c059d5dc8cda120aa5292be5bba037de0b78/time_machine-3.4.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55f1e91c8b1555b24d6b864e638b362a2b63f31e3240ebe67a6a627e9e23223d", size = 64493, upload-time = "2026-08-10T22:25:03.459Z" }, + { url = "https://files.pythonhosted.org/packages/bc/02/a9ecc16bbb5c4a9a9fc38aff95b1be51dedae61c68af24243643971a4d49/time_machine-3.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57d95bf5f8b481186ecdf44e712720bcf21bb25c08a4e5c677e83d277924b9ab", size = 66730, upload-time = "2026-08-10T22:25:04.437Z" }, + { url = "https://files.pythonhosted.org/packages/76/e7/9ebcc420a39af6d12614d282f13503a0a586f6a421a5438c3202e1b9e915/time_machine-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:15f4e45d2518e59f668b676a4885b06a9444bc3168ec261ef0a83cbb22b0b290", size = 65050, upload-time = "2026-08-10T22:25:05.65Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/5b4a2b1e8502b1572f0245070904a2ca9d3ab9116238851c70b14927a89b/time_machine-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d195dcbfbcb35484d8d60c8dc795dad182e91aa44bc9059472895c6b25e218fd", size = 63054, upload-time = "2026-08-10T22:25:06.614Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7c/d32292a627a0eaf2ece9e99373f03a30cbff3d032bf7ad047a0e40c3088d/time_machine-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78e29bb47707fa27f2663e8295962bdfffc5e07544fdbe79123e70451d9a6fb5", size = 24544, upload-time = "2026-08-10T22:25:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/45/ba/13b7c18ff03e79d744cb06d48bd8860b4db9ae40716bfe062a791ab140f4/time_machine-3.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f847837598ce1ee6bdd60308ceba1d4d5244798b40de8fc105a998f729bf8e66", size = 22964, upload-time = "2026-08-10T22:25:08.613Z" }, + { url = "https://files.pythonhosted.org/packages/63/f4/c9b9bebc9f9a9b7b93486f217985d86c23d47dfc2193b30930295d7b4e17/time_machine-3.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:53879f1bef6afc5eb6d3a1a5bb9df52f6a5156b20c8dcea609c865c6e09a5e48", size = 21179, upload-time = "2026-08-10T22:25:09.6Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/b370d6fa5b8cfb95c652387aefed0de119384eb0251117cb53ecfaec7f59/time_machine-3.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6161ec6ae1140a1d6400e69504492194988a1c8aa907e6b3da2ba6441c19d4a3", size = 21307, upload-time = "2026-08-10T22:25:10.602Z" }, + { url = "https://files.pythonhosted.org/packages/22/26/a8f81aa3f561904f6296e651a9a7389ded7a0a0c6ed3e3f7d5898d2f9372/time_machine-3.4.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f413cc07e9745602d23dba5265d34366d97557100fd55a3b477c5c1be945a130", size = 53350, upload-time = "2026-08-10T22:25:11.541Z" }, + { url = "https://files.pythonhosted.org/packages/8e/88/4cc9a78c900a3ba0de84cd15fc95aa7d07797ecfd55a5a4ba7885d6faa24/time_machine-3.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea8f7713052575c50a46c3c44c7acf3fffb4b36d2685802d9bf0e42662ed28eb", size = 54136, upload-time = "2026-08-10T22:25:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3c/2a47e62d5b80b4d2846a726dcf6ca668150e0be69815e33eaff808536bb2/time_machine-3.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e0231d701687e18b519692496cb895885c0d48eaa1922ea808e3b8aa41e70ed4", size = 52811, upload-time = "2026-08-10T22:25:13.658Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/8933307d0357561d2ce0c8d5583a2c931272b39dda0557ccbd87d3f2a2c2/time_machine-3.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3be51fd52d9df96a9df4a8fa3f0331f412d7f934f06a577a53481604bcb52dce", size = 52504, upload-time = "2026-08-10T22:25:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/10/82/98e081d10dc8cae0fbd300ca70f18684a51430738415b0b0612eeceb2a85/time_machine-3.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:97c7c71cc336580e37be1edbc85252c2dd0593f1c043ed1b81bd8fb9e24e54e3", size = 23425, upload-time = "2026-08-10T22:25:15.746Z" }, + { url = "https://files.pythonhosted.org/packages/24/8c/458b98472868a34ed5a7c6aba03aae2fef65b9e57604e5859ee7a34030d6/time_machine-3.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:a8d69afa09a025b64a0e9418dca9d32254efd34588f95e81497b759d22a7cca9", size = 22610, upload-time = "2026-08-10T22:25:16.759Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/2b11cf9165adda3fe76ef2e1b40c55e01ffd59db0972416e56c3068d9877/time_machine-3.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:213e7ccbdb4395f69e7d1b5276e5c8f11fdb2707a848f7cfe3c1ac09e0656452", size = 21883, upload-time = "2026-08-10T22:25:17.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1c/00cf4669a95d072d2ca4a75eafe0b6a58a9a4b85b932bf9c55816d1afe57/time_machine-3.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:56a053893c2716d56f7a0758a2ca55b8373dc7ff9e6c8a7e51e0d568f279d235", size = 22124, upload-time = "2026-08-10T22:25:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/51/45/637512ef5cd9f263e0921d4c8465f4b5358ca109b41d6539ce26f8a5aafe/time_machine-3.4.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2022621d25a1ddba7dc6b80736338164df8f51d1d4f6f49f37f6b09324990bc3", size = 64515, upload-time = "2026-08-10T22:25:19.695Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/ea7386751ead6e668b999b6c5336c4736248cf52d2a482422a7ef8388192/time_machine-3.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60b330f0892922e9b554cc4576143e5254eefb0414d37a9fed400e10775df1a", size = 66749, upload-time = "2026-08-10T22:25:21.208Z" }, + { url = "https://files.pythonhosted.org/packages/53/8a/e2cea3c675656e2f55d2235b1d1963213fa58a66045828b90fb9f87cad3c/time_machine-3.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7bcab0dbfc5cb383e98fb12b83844919ad4c2d9069858969b3511b194f962536", size = 65087, upload-time = "2026-08-10T22:25:22.421Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/d8de7791f40c0e24fea5ddb624c7845abc6af435b3b04d84950bc2fc9910/time_machine-3.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8a33f6e733b6ecead64ec05458bcfe14fca34264e2154ae190e145cb876c77da", size = 63063, upload-time = "2026-08-10T22:25:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a8/5be0904aa61051bd37af346f87ff289c43940433b98bd43573152e3228ee/time_machine-3.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:5ad7e43c180aad9f944225ef98d9d8b6482cf136ed6260efe43e4a0624e16ba4", size = 24533, upload-time = "2026-08-10T22:25:24.854Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d1/5534b797baf238a0815fde6ce57438e4bdc2ce8917ed7da7c7c4dfac0cb3/time_machine-3.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:37248d78a69b5be2ecbeb4a23c3fc3b3a42c3b4a5d6a5c37f27a190e5ff32d0b", size = 22967, upload-time = "2026-08-10T22:25:25.859Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tqdm" version = "4.68.3" @@ -1470,6 +2659,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + [[package]] name = "truststore" version = "0.10.4" @@ -1479,6 +2686,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "types-pyaudio" +version = "0.2.16.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/48/cbc101e7600b196fb29176b1c1f650fed882c11217ac6e25873d0ec0928e/types_pyaudio-0.2.16.20260508.tar.gz", hash = "sha256:e12cb0379f1311c6252ac8ec0ddf0dcc3ac254bbb86aa3fdfc46648f84e7fcdc", size = 9915, upload-time = "2026-05-08T04:49:58.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/87/390c5e7b8051506402c652dfd085e4d4801f1e2b0c9d84c80583a1516064/types_pyaudio-0.2.16.20260508-py3-none-any.whl", hash = "sha256:471075ed00dea034b922d2685728c972a03c58a6af5d220a18e2a351968c9c96", size = 8821, upload-time = "2026-05-08T04:49:57.355Z" }, +] + [[package]] name = "types-pytz" version = "2026.2.0.20260518" @@ -1488,6 +2704,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/89/41e80670779a223d8bc8bc83019a619988cfa5c432cedac5cec23884fbc4/types_pytz-2026.2.0.20260518-py3-none-any.whl", hash = "sha256:3a12eaa38f476bd650902a9c9bb442f03f3c7dee2be5c5848bce61bd708d205a", size = 10125, upload-time = "2026-05-18T06:02:44.968Z" }, ] +[[package]] +name = "types-tqdm" +version = "4.70.0.20260805" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/41/284465d9bb5e657a1a4b17a4addf8800bbfad3d2e903f5a1a4d5bd10dd53/types_tqdm-4.70.0.20260805.tar.gz", hash = "sha256:569efd0fd8b2a460e6e6548634ec253c1362ed8907f8b0daacf0a07eab204a26", size = 19115, upload-time = "2026-08-05T04:52:04.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/92/acbd53e79df923bca3881192558a3bc73a47b8b7bd6c94b3649ae45e8094/types_tqdm-4.70.0.20260805-py3-none-any.whl", hash = "sha256:c83113470a46caee2d045428554714f3c314be94eb4ed4030aaa8a8ab21b830c", size = 25135, upload-time = "2026-08-05T04:52:03.166Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1527,6 +2752,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "virtualenv" +version = "21.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/10/8b7a5454efc032be50c1c5641467dbb5d31314500205212b2aa66d3c8af4/virtualenv-21.7.3.tar.gz", hash = "sha256:5e9e287f5c808070eea3b40403d3368248e64b24f1b60bd9a36e85ac841d2c3e", size = 5525482, upload-time = "2026-08-08T14:43:20.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl", hash = "sha256:26dfda3c34f29bf1a3ca167426a67658d59979b9954e705aef60a5f724ce1773", size = 5504590, upload-time = "2026-08-08T14:43:18.57Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -1701,3 +2942,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From 42e7f7392ee6489cc11691e6ea4f13e5f7437499 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 17 Aug 2026 23:03:12 +0000 Subject: [PATCH 03/26] fix: preserve release marker in uv dependency lock --- .github/workflows/ci.yml | 1 + uv.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e11cb8007..a035f6d438 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] == project["tool"]["rye"]["dev-dependencies"], "Keep uv and Rye development dependencies synchronized"' + python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); marker = re.search(r"(?m)^name = \"openai\"\nversion = \"([^\"]+)\" # x-release-please-version$", pathlib.Path("uv.lock").read_text()); assert marker and marker.group(1) == project["project"]["version"], "Preserve the uv.lock release-please marker and project version"' uv lock --check rye lock --all-features git diff --exit-code -- requirements.lock requirements-dev.lock diff --git a/uv.lock b/uv.lock index 15e1fa5139..ed75144877 100644 --- a/uv.lock +++ b/uv.lock @@ -1438,7 +1438,7 @@ wheels = [ [[package]] name = "openai" -version = "3.2.0" +version = "3.2.0" # x-release-please-version source = { editable = "." } dependencies = [ { name = "anyio" }, From 9eacb6d87a28628f3f82f3a85f940e93091ee1cc Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 17 Aug 2026 23:30:42 +0000 Subject: [PATCH 04/26] fix: use Python 3.11 for dependency lock policy checks --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a035f6d438..f36d11bdbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,8 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] == project["tool"]["rye"]["dev-dependencies"], "Keep uv and Rye development dependencies synchronized"' - python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); marker = re.search(r"(?m)^name = \"openai\"\nversion = \"([^\"]+)\" # x-release-please-version$", pathlib.Path("uv.lock").read_text()); assert marker and marker.group(1) == project["project"]["version"], "Preserve the uv.lock release-please marker and project version"' + uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] == project["tool"]["rye"]["dev-dependencies"], "Keep uv and Rye development dependencies synchronized"' + uv run --no-project --python '>=3.11' python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); marker = re.search(r"(?m)^name = \"openai\"\nversion = \"([^\"]+)\" # x-release-please-version$", pathlib.Path("uv.lock").read_text()); assert marker and marker.group(1) == project["project"]["version"], "Preserve the uv.lock release-please marker and project version"' uv lock --check rye lock --all-features git diff --exit-code -- requirements.lock requirements-dev.lock From 86b9b626e6cbbdea3a1a9b64a84616dcbe542cc7 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 16:37:10 +0000 Subject: [PATCH 05/26] fix: reject untrusted uv lockfile dependency sources --- .github/workflows/ci.yml | 2 +- tests/test_uv_workflows.py | 136 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b441b6136..58ea3ea849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: run: | uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] and "dev" in project["tool"]["uv"]["default-groups"], "Keep the uv development dependency group enabled"' uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); text = pathlib.Path("uv.lock").read_text(); lock = tomllib.loads(text); version = project["project"]["version"]; assert next(package["version"] for package in lock["package"] if package["name"] == "openai") == version and "version = " + chr(34) + version + chr(34) + " # x-release-please-version" in text, "Preserve the uv.lock release-please marker and project version"' - uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); assert all(package.get("source", {}).get("registry", "https://pypi.org/simple") == "https://pypi.org/simple" for package in lock["package"]), "Use only the public PyPI registry in uv.lock"' + uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package.get("source") == {"registry": "https://pypi.org/simple"} or package is roots[0] for package in lock["package"]), "Use only the public PyPI registry and the exact editable root project in uv.lock"' uv lock --check lint: diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 1bc73aff29..1fa4547527 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -43,6 +43,142 @@ def test_dependabot_delays_only_ordinary_version_updates() -> None: assert "open-pull-requests-limit: 0" not in entry +def dependency_lock_source_command() -> str: + path = ROOT / ".github/workflows/ci.yml" + if not path.exists(): + pytest.skip("GitHub workflows are not included in source distributions") + + line = next( + entry + for entry in path.read_text().splitlines() + if "python -c '" in entry and "Use only the public PyPI registry" in entry + ) + command = line.split("python -c '", 1)[1].rsplit("'", 1)[0] + if sys.version_info < (3, 11): + command = "import sys, tomli; sys.modules['tomllib'] = tomli; " + command + return command + + +def run_dependency_lock_source_check( + tmp_path: Path, packages: list[dict[str, object]] +) -> subprocess.CompletedProcess[str]: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + (tmp_path / "pyproject.toml").write_text( + f"[project]\nname = {json.dumps(project['name'])}\nversion = {json.dumps(project['version'])}\n" + ) + + lines: list[str] = [] + for package in packages: + lines.extend( + [ + "[[package]]", + f"name = {json.dumps(package['name'])}", + f"version = {json.dumps(package['version'])}", + ] + ) + source = package.get("source") + if source is not None: + assert isinstance(source, dict) + values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in source.items()) + lines.append("source = { " + values + " }") + lines.append("") + + (tmp_path / "uv.lock").write_text("\n".join(lines)) + return subprocess.run( + [sys.executable, "-c", dependency_lock_source_command()], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("source", "accepted"), + [ + pytest.param({"registry": "https://pypi.org/simple"}, True, id="public-pypi"), + pytest.param({"git": "https://github.com/unreviewed/package"}, False, id="git"), + pytest.param({"url": "https://unreviewed.example/package.whl"}, False, id="url"), + pytest.param({"path": "../unreviewed"}, False, id="path"), + pytest.param({"directory": "../unreviewed"}, False, id="directory"), + pytest.param({"editable": "."}, False, id="third-party-editable-root"), + pytest.param({"registry": "https://private.example/simple"}, False, id="private-registry"), + pytest.param({}, False, id="empty-source"), + pytest.param(None, False, id="missing-source"), + pytest.param({"unknown": "unreviewed"}, False, id="unknown-source"), + pytest.param( + {"registry": "https://pypi.org/simple", "git": "https://github.com/unreviewed/package"}, + False, + id="hybrid-registry-source", + ), + ], +) +def test_dependency_lock_accepts_only_public_registry_dependencies( + tmp_path: Path, source: dict[str, str] | None, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = {"name": "reviewed-dependency", "version": "1.0.0"} + if source is not None: + dependency["source"] = source + + result = run_dependency_lock_source_check(tmp_path, [root, dependency]) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if not accepted: + assert "Use only the public PyPI registry" in result.stderr + + +@pytest.mark.parametrize( + "variant", ["missing", "duplicate", "wrong-name", "wrong-version", "wrong-path", "hybrid-root"] +) +def test_dependency_lock_requires_one_exact_editable_root(tmp_path: Path, variant: str) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + if variant == "wrong-name": + root["name"] = "unreviewed-root" + elif variant == "wrong-version": + root["version"] = "0.0.0" + elif variant == "wrong-path": + root["source"] = {"editable": "../unreviewed"} + elif variant == "hybrid-root": + root["source"] = {"editable": ".", "registry": "https://pypi.org/simple"} + + packages = [dependency] if variant == "missing" else [root, dependency] + if variant == "duplicate": + packages.append(dict(root)) + + result = run_dependency_lock_source_check(tmp_path, packages) + + assert result.returncode == 1, result.stdout + result.stderr + assert "Use only the public PyPI registry" in result.stderr + + +def test_dependency_lock_source_check_accepts_the_committed_lock() -> None: + result = subprocess.run( + [sys.executable, "-c", dependency_lock_source_command()], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + def test_agents_integration_selects_its_typechecking_runtime() -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" if not path.exists(): From 6af5be9bb116a799d250d2e73ab6f50ea971e98f Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 16:48:07 +0000 Subject: [PATCH 06/26] fix: gate dependency installs on lock provenance --- .github/workflows/ci.yml | 12 ++++- tests/test_uv_workflows.py | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58ea3ea849..4da9795aac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,15 @@ jobs: timeout-minutes: 10 name: dependency lock freshness runs-on: ubuntu-latest - if: github.event_name != 'schedule' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false + - name: Verify dependency source provenance before installing tools + run: | + python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package.get("source") == {"registry": "https://pypi.org/simple"} or package is roots[0] for package in lock["package"]), "Use only the public PyPI registry and the exact editable root project in uv.lock"' + - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: @@ -39,13 +42,13 @@ jobs: run: | uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] and "dev" in project["tool"]["uv"]["default-groups"], "Keep the uv development dependency group enabled"' uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); text = pathlib.Path("uv.lock").read_text(); lock = tomllib.loads(text); version = project["project"]["version"]; assert next(package["version"] for package in lock["package"] if package["name"] == "openai") == version and "version = " + chr(34) + version + chr(34) + " # x-release-please-version" in text, "Preserve the uv.lock release-please marker and project version"' - uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package.get("source") == {"registry": "https://pypi.org/simple"} or package is roots[0] for package in lock["package"]), "Use only the public PyPI registry and the exact editable root project in uv.lock"' uv lock --check lint: timeout-minutes: 10 name: lint runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -67,6 +70,7 @@ jobs: run: ./scripts/lint build: + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork timeout-minutes: 10 name: build @@ -119,6 +123,7 @@ jobs: timeout-minutes: 15 name: test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork strategy: fail-fast: false @@ -155,6 +160,7 @@ jobs: timeout-minutes: 20 name: test (HTTPX2) runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -190,6 +196,7 @@ jobs: name: examples environment: ci runs-on: ubuntu-latest + needs: dependency-locks if: github.repository == 'openai/openai-python' && github.event_name == 'push' && github.ref == 'refs/heads/main' steps: @@ -232,6 +239,7 @@ jobs: timeout-minutes: 20 name: compatibility (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' continue-on-error: ${{ matrix.experimental }} strategy: diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 1fa4547527..45a1d0793a 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -179,6 +179,106 @@ def test_dependency_lock_source_check_accepts_the_committed_lock() -> None: assert result.returncode == 0, result.stdout + result.stderr +def dependency_workflow_jobs() -> dict[str, str]: + path = ROOT / ".github/workflows/ci.yml" + if not path.exists(): + pytest.skip("GitHub workflows are not included in source distributions") + + workflow = path.read_text().split("\njobs:\n", 1)[1] + return { + match.group("name"): match.group("body") + for match in re.finditer( + r"^ (?P[\w-]+):\n(?P.*?)(?=^ [\w-]+:\n|\Z)", + workflow, + re.MULTILINE | re.DOTALL, + ) + } + + +def dependency_installer_jobs(jobs: dict[str, str]) -> set[str]: + return { + name + for name, job in jobs.items() + if name != "dependency-locks" + and ( + "astral-sh/setup-uv@" in job + or "./.github/actions/setup-node-tooling" in job + or re.search( + r"\b(?:uv\s+(?:sync|run)|pip\s+install|(?:npm|pnpm)\s+(?:ci|install|add))\b", + job, + ) + or re.search(r"run:\s*\./scripts/(?:bootstrap|build)\b", job) + ) + } + + +def test_dependency_provenance_runs_before_tool_setup() -> None: + gate = dependency_workflow_jobs()["dependency-locks"] + source = next(line for line in gate.splitlines() if "Use only the public PyPI registry" in line) + assert source.strip().startswith("python -c '") + + before = gate.split(source, 1)[0] + actions = re.findall(r"^ - uses:\s*(\S+)", before, re.MULTILINE) + assert len(actions) == 1 + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}", actions[0]) + assert "persist-credentials: false" in before + assert not re.search( + r"^\s*(?:- )?(?:run:|uses:).*(?:setup-uv|setup-node|uv\s|pip\s|npm\s|pnpm\s|scripts/)", + before, + re.MULTILINE, + ) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param({"git": "https://github.com/unreviewed/package"}, id="git"), + pytest.param({"path": "../unreviewed"}, id="path"), + pytest.param({"url": "https://unreviewed.example/package.whl"}, id="url"), + ], +) +def test_untrusted_provenance_leaves_no_dependency_install_reachable(tmp_path: Path, source: dict[str, str]) -> None: + jobs = dependency_workflow_jobs() + installers = dependency_installer_jobs(jobs) + assert installers == {"lint", "build", "test", "test-httpx2", "examples", "compatibility"} + + needs = {name: re.findall(r"^ needs:\s*([^\s#]+)", jobs[name], re.MULTILINE) for name in installers} + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": source, + } + rejected = run_dependency_lock_source_check(tmp_path, [root, dependency]) + assert rejected.returncode != 0 + reachable = { + name + for name in installers + if not needs[name] or (needs[name] == ["dependency-locks"] and rejected.returncode == 0) + } + assert not reachable + + dependency["source"] = {"registry": "https://pypi.org/simple"} + accepted = run_dependency_lock_source_check(tmp_path, [root, dependency]) + assert accepted.returncode == 0 + assert { + name for name in installers if needs[name] == ["dependency-locks"] and accepted.returncode == 0 + } == installers + + +def test_scheduled_compatibility_keeps_dependency_provenance_gate() -> None: + jobs = dependency_workflow_jobs() + assert not re.search(r"^ if:.*schedule", jobs["dependency-locks"], re.MULTILINE) + assert re.search(r"^ needs:\s*dependency-locks\s*$", jobs["compatibility"], re.MULTILINE) + assert "github.event_name == 'schedule'" in jobs["compatibility"] + assert "github.event_name == 'workflow_dispatch'" in jobs["compatibility"] + + def test_agents_integration_selects_its_typechecking_runtime() -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" if not path.exists(): From 69acefe2efee06f25feae76eba5dcc32d5f189ba Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 17:08:05 +0000 Subject: [PATCH 07/26] fix: validate dependency artifacts and security update floors --- .github/workflows/ci.yml | 89 ++++- .github/workflows/detect-breaking-changes.yml | 9 + tests/test_uv_workflows.py | 329 +++++++++++++++++- 3 files changed, 425 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4da9795aac..eddde7c2f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,94 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package.get("source") == {"registry": "https://pypi.org/simple"} or package is roots[0] for package in lock["package"]), "Use only the public PyPI registry and the exact editable root project in uv.lock"' + python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]), "Use only the public PyPI registry, reviewed artifacts, and the exact editable root project in uv.lock"' + + - name: Require published minimums for direct security updates + if: >- + github.event_name == 'pull_request' + && github.actor == 'dependabot[bot]' + && github.event.pull_request.user.login == 'dependabot[bot]' + && contains(github.event.pull_request.head.ref, 'python-security') + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + python - <<'PY' + import os + import pathlib + import re + import subprocess + import tomllib + + base = os.environ.get("BASE_SHA", "") + if not re.fullmatch(r"[0-9a-f]{40}", base): + raise SystemExit("Untrusted security-update base commit") + origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() + if origin not in { + "https://github.com/openai/openai-python", + "https://github.com/openai/openai-python.git", + "git@github.com:openai/openai-python.git", + "ssh://git@github.com/openai/openai-python.git", + }: + raise SystemExit("Untrusted security-update comparison origin") + subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) + + def read_base(path): + return tomllib.loads(subprocess.check_output(["git", "show", base + ":" + path], text=True)) + + def canonical(name): + return re.sub(r"[-_.]+", "-", name).lower() + + def direct(project): + requirements = list(project["project"].get("dependencies", [])) + for group in project["project"].get("optional-dependencies", {}).values(): + requirements.extend(group) + result = {} + for requirement in requirements: + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", requirement) + if match is None: + raise SystemExit("Ambiguous direct security dependency requirement") + name = canonical(match.group(1)) + normalized = name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() + result.setdefault(name, set()).add(normalized) + return result + + def versions(lock): + result = {} + for package in lock["package"]: + result.setdefault(canonical(package["name"]), set()).add(package["version"]) + return result + + def minimums(requirements): + result = set() + for requirement in requirements: + matches = re.findall(r"(?=!~])>=([^,;]+)", requirement) + if len(matches) != 1: + raise SystemExit("Missing or ambiguous direct security dependency minimum") + result.add(matches[0]) + return result + + old_project = read_base("pyproject.toml") + old_lock = read_base("uv.lock") + new_project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + new_lock = tomllib.loads(pathlib.Path("uv.lock").read_text()) + old_direct, new_direct = direct(old_project), direct(new_project) + old_versions, new_versions = versions(old_lock), versions(new_lock) + for name, requirements in new_direct.items(): + if old_versions.get(name, set()) == new_versions.get(name, set()): + continue + previous = old_direct.get(name, set()) + if previous == requirements: + raise SystemExit("Raise the published security-fixed minimum for " + name) + updated_minimums = minimums(requirements) + previous_minimums = { + match.group(1) + for requirement in previous + for match in [re.search(r"(?=!~])>=([^,;]+)", requirement)] + if match is not None + } + if not updated_minimums or updated_minimums == previous_minimums: + raise SystemExit("Raise the published security-fixed minimum for " + name) + PY - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index 5ca06d149d..2dd7084bf5 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -24,6 +24,10 @@ jobs: persist-credentials: false fetch-depth: 1 + - name: Verify dependency source provenance before installing tools + run: | + python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]), "Use only the public PyPI registry, reviewed artifacts, and the exact editable root project in uv.lock"' + - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -57,6 +61,11 @@ jobs: persist-credentials: false path: openai-python + - name: Verify dependency source provenance before installing tools + working-directory: openai-python + run: | + python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]), "Use only the public PyPI registry, reviewed artifacts, and the exact editable root project in uv.lock"' + - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 45a1d0793a..11c5be51e1 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -5,6 +5,7 @@ import sys import json import subprocess +from typing import cast from pathlib import Path import pytest @@ -79,8 +80,29 @@ def run_dependency_lock_source_check( source = package.get("source") if source is not None: assert isinstance(source, dict) - values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in source.items()) + source_values = cast(dict[str, object], source) + values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in source_values.items()) lines.append("source = { " + values + " }") + artifact = package.get("sdist") + if "sdist" not in package and source == {"registry": "https://pypi.org/simple"}: + artifact = { + "url": "https://files.pythonhosted.org/packages/reviewed-1.0.0.tar.gz", + "hash": "sha256:" + "a" * 64, + } + if artifact is not None: + assert isinstance(artifact, dict) + artifact_values = cast(dict[str, object], artifact) + values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in artifact_values.items()) + lines.append("sdist = { " + values + " }") + if "wheels" in package: + wheels = package["wheels"] + assert isinstance(wheels, list) + typed_wheels = cast(list[dict[str, object]], wheels) + wheel_values = [ + "{ " + ", ".join(f"{key} = {json.dumps(value)}" for key, value in wheel.items()) + " }" + for wheel in typed_wheels + ] + lines.append("wheels = [" + ", ".join(wheel_values) + "]") lines.append("") (tmp_path / "uv.lock").write_text("\n".join(lines)) @@ -133,6 +155,75 @@ def test_dependency_lock_accepts_only_public_registry_dependencies( assert "Use only the public PyPI registry" in result.stderr +@pytest.mark.parametrize("kind", ["sdist", "wheel"]) +@pytest.mark.parametrize( + ("url", "digest", "accepted"), + [ + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl", "a" * 64, True, id="public-pypi"), + pytest.param("https://unreviewed.example/packages/reviewed.whl", "a" * 64, False, id="foreign-host"), + pytest.param("http://files.pythonhosted.org/packages/reviewed.whl", "a" * 64, False, id="insecure-http"), + pytest.param( + "https://user:pass@files.pythonhosted.org/packages/reviewed.whl", + "a" * 64, + False, + id="credentials", + ), + pytest.param("https://files.pythonhosted.org:443/packages/reviewed.whl", "a" * 64, False, id="port"), + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl?redirect=1", "a" * 64, False, id="query"), + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl#redirect", "a" * 64, False, id="fragment"), + pytest.param("https://files.pythonhosted.org/redirect/reviewed.whl", "a" * 64, False, id="path"), + pytest.param( + "https://files.pythonhosted.org.attacker.test/packages/reviewed.whl", "a" * 64, False, id="suffix" + ), + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl", "invalid", False, id="hash"), + ], +) +def test_dependency_lock_rejects_untrusted_distribution_artifacts( + tmp_path: Path, kind: str, url: str, digest: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + artifact = {"url": url, "hash": "sha256:" + digest} + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + if kind == "sdist": + dependency["sdist"] = artifact + else: + dependency["sdist"] = None + dependency["wheels"] = [artifact] + + result = run_dependency_lock_source_check(tmp_path, [root, dependency]) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("artifact", [None, {}, {"url": "https://files.pythonhosted.org/packages/reviewed.whl"}]) +def test_dependency_lock_rejects_missing_distribution_artifacts( + tmp_path: Path, artifact: dict[str, str] | None +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + "sdist": artifact, + } + result = run_dependency_lock_source_check(tmp_path, [root, dependency]) + assert result.returncode != 0, result.stdout + result.stderr + + @pytest.mark.parametrize( "variant", ["missing", "duplicate", "wrong-name", "wrong-version", "wrong-path", "hybrid-root"] ) @@ -279,6 +370,242 @@ def test_scheduled_compatibility_keeps_dependency_provenance_gate() -> None: assert "github.event_name == 'workflow_dispatch'" in jobs["compatibility"] +@pytest.mark.parametrize("name", ["detect_breaking_changes", "agents_sdk"]) +def test_breaking_change_installers_validate_provenance_first(name: str) -> None: + path = ROOT / ".github/workflows/detect-breaking-changes.yml" + if not path.exists(): + pytest.skip("GitHub workflows are not included in source distributions") + + match = re.search( + rf"^ {name}:\n(?P.*?)(?=^ [\w-]+:\n|\Z)", + path.read_text(), + re.MULTILINE | re.DOTALL, + ) + assert match is not None + job = match.group("body") + steps = re.findall(r"^ - (?:name|uses):\s*(.+)$", job, re.MULTILINE) + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}.*", steps[0]) + assert steps[1] == "Verify dependency source provenance before installing tools" + source = next(line for line in job.splitlines() if "Use only the public PyPI registry" in line) + command = source.split("python -c '", 1)[1].rsplit("'", 1)[0] + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + expected = next(line for line in workflow.splitlines() if "Use only the public PyPI registry" in line) + assert command == expected.split("python -c '", 1)[1].rsplit("'", 1)[0] + if name == "agents_sdk": + gate = job.split(" - name: Verify dependency source provenance before installing tools\n", 1)[1] + gate = gate.split("\n - name:", 1)[0] + assert "working-directory: openai-python" in gate + + +def security_dependency_floor_program() -> str: + gate = dependency_workflow_jobs()["dependency-locks"] + match = re.search( + r" - name: Require published minimums for direct security updates\n(?P.*?)(?=\n - name:|\Z)", + gate, + re.DOTALL, + ) + assert match is not None, "Direct Dependabot security updates must validate published dependency floors" + body = match.group("body") + for condition in ( + "github.event_name == 'pull_request'", + "github.actor == 'dependabot[bot]'", + "github.event.pull_request.user.login == 'dependabot[bot]'", + "contains(github.event.pull_request.head.ref, 'python-security')", + ): + assert condition in body + script = re.search(r" python - <<'PY'\n(?P.*?)(?=\n PY)", body, re.DOTALL) + assert script is not None + program = "\n".join(line[10:] for line in script.group("source").splitlines()) + if sys.version_info < (3, 11): + program = "import sys, tomli; sys.modules['tomllib'] = tomli\n" + program + return program + + +def run_security_dependency_floor_check( + tmp_path: Path, + *, + base_requirements: list[str], + head_requirements: list[str], + base_packages: list[tuple[str, str]], + head_packages: list[tuple[str, str]], + optional: bool = False, + sha: str = "a" * 40, + origin: str = "https://github.com/openai/openai-python", +) -> subprocess.CompletedProcess[str]: + def project(requirements: list[str]) -> str: + if optional: + return ( + '[project]\nname = "openai"\nversion = "1.0"\ndependencies = []\n' + + "[project.optional-dependencies]\nfeature = " + + json.dumps(requirements) + + "\n" + ) + return '[project]\nname = "openai"\nversion = "1.0"\ndependencies = ' + json.dumps(requirements) + "\n" + + def lock(packages: list[tuple[str, str]]) -> str: + return "\n".join( + f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" for name, version in packages + ) + + (tmp_path / "pyproject.toml").write_text(project(head_requirements)) + (tmp_path / "uv.lock").write_text(lock(head_packages)) + (tmp_path / "base-project.toml").write_text(project(base_requirements)) + (tmp_path / "base-lock.toml").write_text(lock(base_packages)) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + f"root = pathlib.Path({str(tmp_path)!r})\n" + f"origin = {origin!r}\n" + f"sha = {sha!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['fetch', '--no-tags', '--depth=1', 'origin', sha]:\n" + " pass\n" + "elif arguments == ['show', sha + ':pyproject.toml']:\n" + " print((root / 'base-project.toml').read_text(), end='')\n" + "elif arguments == ['show', sha + ':uv.lock']:\n" + " print((root / 'base-lock.toml').read_text(), end='')\n" + "else:\n" + " raise SystemExit('Unexpected or unsafe git operation')\n" + ) + fake_git.chmod(0o755) + environment = dict(os.environ, BASE_SHA=sha, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"]) + return subprocess.run( + [sys.executable, "-c", security_dependency_floor_program()], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("base", "head", "before", "after", "optional", "accepted"), + [ + pytest.param( + ["Danger_Pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0")], + [("danger_pkg", "1.1")], + False, + False, + id="direct-lock-only", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + True, + id="direct-floor-raised", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0,<3"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + False, + id="unchanged-lower-bound", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + True, + False, + id="optional-lock-only", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + True, + True, + id="optional-floor-raised", + ), + pytest.param( + ["safe-direct>=1.0"], + ["safe-direct>=1.0"], + [("safe-direct", "1.0"), ("transitive", "1.0")], + [("safe-direct", "1.0"), ("transitive", "1.1")], + False, + True, + id="transitive-only", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0"), ("danger-pkg", "2.0")], + [("danger-pkg", "1.0"), ("danger-pkg", "2.1")], + False, + False, + id="multiple-locked-versions", + ), + pytest.param( + ["danger-pkg"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + True, + id="previously-unbounded", + ), + ], +) +def test_only_direct_security_updates_must_raise_published_minimums( + tmp_path: Path, + base: list[str], + head: list[str], + before: list[tuple[str, str]], + after: list[tuple[str, str]], + optional: bool, + accepted: bool, +) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base, + head_requirements=head, + base_packages=before, + head_packages=after, + optional=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("sha", "origin"), + [ + pytest.param("invalid", "https://github.com/openai/openai-python", id="untrusted-base"), + pytest.param("a" * 40, "https://github.com/attacker/openai-python", id="untrusted-origin"), + ], +) +def test_security_floor_guard_rejects_untrusted_base(tmp_path: Path, sha: str, origin: str) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["danger-pkg>=1.0"], + head_requirements=["danger-pkg>=1.1"], + base_packages=[("danger-pkg", "1.0")], + head_packages=[("danger-pkg", "1.1")], + sha=sha, + origin=origin, + ) + assert result.returncode != 0 + + +def test_routine_dependency_updates_preserve_lock_only_strategy() -> None: + config = (ROOT / ".github/dependabot.yml").read_text() + assert "versioning-strategy: increase-if-necessary" in config + assert re.search(r"python-security:\n\s+applies-to: security-updates", config) + security_dependency_floor_program() + + def test_agents_integration_selects_its_typechecking_runtime() -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" if not path.exists(): From 3e188b8afc0c438979ce80d5266c10dd0fb98c77 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 17:29:51 +0000 Subject: [PATCH 08/26] fix:enforce-strict-dependabot-security-dependency-floors --- .github/workflows/ci.yml | 39 +++++--- tests/test_uv_workflows.py | 198 ++++++++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eddde7c2f8..1de04de8e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,6 @@ jobs: - name: Require published minimums for direct security updates if: >- github.event_name == 'pull_request' - && github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]' && contains(github.event.pull_request.head.ref, 'python-security') env: @@ -87,14 +86,26 @@ jobs: result.setdefault(canonical(package["name"]), set()).add(package["version"]) return result - def minimums(requirements): - result = set() + def stable_version(value): + match = re.fullmatch(r"(?:(\d+)!)?(\d+(?:\.\d+)*)", value) + if match is None: + raise SystemExit("Unsupported direct security dependency minimum") + release = tuple(int(part) for part in match.group(2).split(".")) + while release and release[-1] == 0: + release = release[:-1] + return int(match.group(1) or 0), release + + def minimums(requirements, *, allow_missing=False): + result = [] for requirement in requirements: - matches = re.findall(r"(?=!~])>=([^,;]+)", requirement) + specifier = requirement.split(";", 1)[0] + matches = re.findall(r"(?=!~])>=([^,;]+)", specifier) if len(matches) != 1: + if allow_missing and not matches: + continue raise SystemExit("Missing or ambiguous direct security dependency minimum") - result.add(matches[0]) - return result + result.append(stable_version(matches[0])) + return sorted(result) old_project = read_base("pyproject.toml") old_lock = read_base("uv.lock") @@ -109,13 +120,15 @@ jobs: if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) updated_minimums = minimums(requirements) - previous_minimums = { - match.group(1) - for requirement in previous - for match in [re.search(r"(?=!~])>=([^,;]+)", requirement)] - if match is not None - } - if not updated_minimums or updated_minimums == previous_minimums: + previous_minimums = minimums(previous, allow_missing=True) + if not updated_minimums or ( + previous_minimums + and ( + len(updated_minimums) != len(previous_minimums) + or any(updated < previous for previous, updated in zip(previous_minimums, updated_minimums)) + or updated_minimums[0] <= previous_minimums[0] + ) + ): raise SystemExit("Raise the published security-fixed minimum for " + name) PY diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 64e5fb9031..155b7dd7bf 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -433,9 +433,9 @@ def security_dependency_floor_program() -> str: ) assert match is not None, "Direct Dependabot security updates must validate published dependency floors" body = match.group("body") + assert "github.actor" not in body, "Maintainer updates must not disable an authored Dependabot security guard" for condition in ( "github.event_name == 'pull_request'", - "github.actor == 'dependabot[bot]'", "github.event.pull_request.user.login == 'dependabot[bot]'", "contains(github.event.pull_request.head.ref, 'python-security')", ): @@ -448,6 +448,68 @@ def security_dependency_floor_program() -> str: return program +@pytest.mark.parametrize( + ("actor", "author", "reference", "event", "accepted"), + [ + pytest.param( + "maintainer", + "dependabot[bot]", + "dependabot/uv/python-security-123", + "pull_request", + True, + id="maintainer-updated-security-pr", + ), + pytest.param( + "dependabot[bot]", + "dependabot[bot]", + "dependabot/uv/python-security-123", + "pull_request", + True, + id="dependabot-updated-security-pr", + ), + pytest.param( + "dependabot[bot]", + "untrusted-maintainer", + "dependabot/uv/python-security-123", + "pull_request", + False, + id="spoofed-security-pr-author", + ), + pytest.param( + "maintainer", + "dependabot[bot]", + "dependabot/uv/python-maintenance-123", + "pull_request", + False, + id="routine-dependency-pr", + ), + pytest.param( + "dependabot[bot]", + "dependabot[bot]", + "dependabot/uv/python-security-123", + "push", + False, + id="non-pull-request-event", + ), + ], +) +def test_security_floor_guard_uses_immutable_pr_identity( + actor: str, author: str, reference: str, event: str, accepted: bool +) -> None: + gate = dependency_workflow_jobs()["dependency-locks"] + step = gate.split(" - name: Require published minimums for direct security updates\n", 1)[1] + condition = step.split(" if: >-\n", 1)[1].split(" env:\n", 1)[0] + values = { + "github.event_name == 'pull_request'": event == "pull_request", + "github.actor == 'dependabot[bot]'": actor == "dependabot[bot]", + "github.event.pull_request.user.login == 'dependabot[bot]'": author == "dependabot[bot]", + "contains(github.event.pull_request.head.ref, 'python-security')": "python-security" in reference, + } + clauses = [line.strip().removeprefix("&& ").strip() for line in condition.splitlines() if line.strip()] + assert all(clause in values for clause in clauses), clauses + assert all(values[clause] for clause in clauses) is accepted + + def run_security_dependency_floor_check( tmp_path: Path, *, @@ -530,6 +592,78 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="direct-floor-raised", ), + pytest.param( + ["danger-pkg>=2.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "2.0")], + [("danger-pkg", "2.1")], + False, + False, + id="direct-floor-lowered", + ), + pytest.param( + ["danger-pkg>=1.10"], + ["danger-pkg>=1.9"], + [("danger-pkg", "1.10")], + [("danger-pkg", "1.11")], + False, + False, + id="numeric-release-floor-lowered", + ), + pytest.param( + ["danger-pkg>=1.9"], + ["danger-pkg>=1.10"], + [("danger-pkg", "1.9")], + [("danger-pkg", "1.10")], + False, + True, + id="numeric-release-floor-raised", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + False, + id="trailing-zero-equivalent-floor", + ), + pytest.param( + ["danger-pkg>=0!9.0"], + ["danger-pkg>=1!1.0"], + [("danger-pkg", "9.0")], + [("danger-pkg", "1!1.0")], + False, + True, + id="epoch-floor-raised", + ), + pytest.param( + ["danger-pkg>=1!1.0"], + ["danger-pkg>=0!9.0"], + [("danger-pkg", "1!1.0")], + [("danger-pkg", "1!1.1")], + False, + False, + id="epoch-floor-lowered", + ), + pytest.param( + ["danger-pkg>=1.0; python_version >= '3.11'"], + ["danger-pkg>=1.1; python_version >= '3.11'"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + True, + id="python-version-marker-floor-raised", + ), + pytest.param( + ["Danger_Pkg[extra]>=1.0,<3; python_version >= '3.11'"], + ["danger-pkg[extra]>=1.1,<3; python_version >= '3.11'"], + [("danger-pkg", "1.0")], + [("danger_pkg", "1.1")], + True, + True, + id="optional-alias-extra-and-marker-floor-raised", + ), pytest.param( ["danger-pkg>=1.0"], ["danger-pkg>=1.0,<3"], @@ -557,6 +691,42 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="optional-floor-raised", ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1", "numpy>=2.0.2"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + True, + id="optional-multiple-floors-weakest-raised", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1", "numpy>=2.0.1"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-multiple-floors-one-lowered", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1", "numpy>=2.0.3"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-multiple-floors-weakest-unchanged", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-floor-branch-removed", + ), pytest.param( ["safe-direct>=1.0"], ["safe-direct>=1.0"], @@ -584,6 +754,32 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="previously-unbounded", ), + *[ + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=" + version], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + False, + id="unsupported-floor-" + label, + ) + for label, version in ( + ("prerelease", "1.1rc1"), + ("postrelease", "1.1.post1"), + ("development", "1.1.dev1"), + ("local", "1.1+local"), + ) + ], + pytest.param( + ["danger-pkg>=1.0rc1"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0rc1")], + [("danger-pkg", "1.1")], + False, + False, + id="unsupported-previous-floor", + ), ], ) def test_only_direct_security_updates_must_raise_published_minimums( From 0b3f84b54af31c4b10169229f10046ef3df634ad Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 17:48:19 +0000 Subject: [PATCH 09/26] fix: validate dependency floors against patched lock releases --- .github/workflows/ci.yml | 8 ++- tests/test_uv_workflows.py | 123 ++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1de04de8e2..a437a654b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: if allow_missing and not matches: continue raise SystemExit("Missing or ambiguous direct security dependency minimum") - result.append(stable_version(matches[0])) + result.append(stable_version(matches[0].strip())) return sorted(result) old_project = read_base("pyproject.toml") @@ -119,9 +119,13 @@ jobs: previous = old_direct.get(name, set()) if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) + patched_versions = new_versions.get(name, set()) - old_versions.get(name, set()) + if not patched_versions: + raise SystemExit("Missing patched direct security dependency release for " + name) + patched_minimum = max(stable_version(version) for version in patched_versions) updated_minimums = minimums(requirements) previous_minimums = minimums(previous, allow_missing=True) - if not updated_minimums or ( + if not updated_minimums or any(updated < patched_minimum for updated in updated_minimums) or ( previous_minimums and ( len(updated_minimums) != len(previous_minimums) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 155b7dd7bf..ba74c9b72f 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -592,6 +592,60 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="direct-floor-raised", ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5")], + False, + False, + id="direct-floor-below-patched-lock", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5")], + False, + True, + id="direct-floor-equals-patched-lock", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.6"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5")], + False, + True, + id="direct-floor-above-patched-lock", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5.0")], + False, + True, + id="patched-lock-trailing-zero-equivalence", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5rc1")], + False, + False, + id="patched-lock-prerelease-fails-closed", + ), + pytest.param( + ["danger-pkg>=0!9.0"], + ["danger-pkg>=1!1.0"], + [("danger-pkg", "9.0")], + [("danger-pkg", "1!2.0")], + False, + False, + id="epoch-floor-below-patched-lock", + ), pytest.param( ["danger-pkg>=2.0"], ["danger-pkg>=1.0"], @@ -664,6 +718,51 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="optional-alias-extra-and-marker-floor-raised", ), + pytest.param( + ["websockets >= 12"], + ["websockets >= 13"], + [("websockets", "12")], + [("websockets", "13")], + True, + True, + id="repository-whitespace-websockets", + ), + pytest.param( + ["numpy >= 1"], + ["numpy >= 2.1"], + [("numpy", "1")], + [("numpy", "2.1")], + True, + True, + id="repository-whitespace-numpy", + ), + pytest.param( + ["pandas >= 1.2.3"], + ["pandas >= 1.5.0"], + [("pandas", "1.2.3")], + [("pandas", "1.5.0")], + True, + True, + id="repository-whitespace-pandas", + ), + pytest.param( + ["pydantic>=1.10.13,<3"], + ["pydantic>=1.10.26,<3"], + [("pydantic", "1.10.13"), ("pydantic", "2.12.5")], + [("pydantic", "1.10.26"), ("pydantic", "2.12.5")], + False, + True, + id="unchanged-alternate-pydantic-line-preserved", + ), + pytest.param( + ["pydantic>=1.10.13,<3"], + ["pydantic>=1.10.26,<3"], + [("pydantic", "1.10.13"), ("pydantic", "2.12.4")], + [("pydantic", "1.10.26"), ("pydantic", "2.12.5")], + False, + False, + id="multiple-patched-pydantic-lines-fail-closed", + ), pytest.param( ["danger-pkg>=1.0"], ["danger-pkg>=1.0,<3"], @@ -691,14 +790,32 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="optional-floor-raised", ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=2.1", "numpy>=2.1,<3"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + True, + id="optional-multiple-floors-reach-patched-release", + ), pytest.param( ["numpy>=1", "numpy>=2.0.2"], ["numpy>=1.1", "numpy>=2.0.2"], [("numpy", "2.0.2")], [("numpy", "2.1.0")], True, + False, + id="optional-multiple-floors-below-patched-release", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=2.1", "numpy>=2.1,<3"], + [("numpy", "1.26"), ("numpy", "2.0.2")], + [("numpy", "1.26"), ("numpy", "2.1.0")], + True, True, - id="optional-multiple-floors-weakest-raised", + id="unchanged-alternate-numpy-lock-preserved", ), pytest.param( ["numpy>=1", "numpy>=2.0.2"], @@ -802,6 +919,10 @@ def test_only_direct_security_updates_must_raise_published_minimums( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +def test_security_floor_parser_strips_requirement_whitespace() -> None: + assert "stable_version(matches[0].strip())" in security_dependency_floor_program() + + @pytest.mark.parametrize( ("sha", "origin"), [ From d5e8f30a99a07bc7c44b01d13677dc610e1c32aa Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 17:58:07 +0000 Subject: [PATCH 10/26] fix: reject weakened security floors with unchanged locks --- .github/workflows/ci.yml | 10 ++- tests/test_uv_workflows.py | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a437a654b3..47bb73b701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,9 +114,17 @@ jobs: old_direct, new_direct = direct(old_project), direct(new_project) old_versions, new_versions = versions(old_lock), versions(new_lock) for name, requirements in new_direct.items(): + previous = old_direct.get(name, set()) + if previous != requirements: + previous_minimums = minimums(previous, allow_missing=True) + if previous_minimums: + updated_minimums = minimums(requirements) + if len(updated_minimums) != len(previous_minimums) or any( + updated < previous for previous, updated in zip(previous_minimums, updated_minimums) + ): + raise SystemExit("Do not lower a published security-fixed minimum for " + name) if old_versions.get(name, set()) == new_versions.get(name, set()): continue - previous = old_direct.get(name, set()) if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) patched_versions = new_versions.get(name, set()) - old_versions.get(name, set()) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index ba74c9b72f..8f9299217f 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -844,6 +844,141 @@ def lock(packages: list[tuple[str, str]]) -> str: False, id="optional-floor-branch-removed", ), + pytest.param( + ["other>=2"], + ["other>=1"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-direct-floor-lowered", + ), + pytest.param( + ["other>=2"], + ["other"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-direct-floor-removed", + ), + pytest.param( + ["other>=2,<4"], + ["other>=2,<5"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-equal-floor-preserved", + ), + pytest.param( + ["other>=1"], + ["other>=2"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-direct-floor-raised", + ), + pytest.param( + ["other>=2"], + ["other>=2"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-identical-requirement-preserved", + ), + pytest.param( + ["other >= 1.10"], + ["other >= 1.9"], + [("other", "1.10")], + [("other", "1.10")], + False, + False, + id="unchanged-lock-whitespace-numeric-floor-lowered", + ), + pytest.param( + ["other>=1!1"], + ["other>=0!9"], + [("other", "1!1")], + [("other", "1!1")], + False, + False, + id="unchanged-lock-epoch-floor-lowered", + ), + pytest.param( + ["other>=2.0"], + ["other>=2.0.0,<4"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-trailing-zero-equivalent-floor", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1", "numpy>=2.0.1"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + False, + id="unchanged-lock-optional-floor-lowered", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + False, + id="unchanged-lock-optional-floor-branch-removed", + ), + pytest.param( + ["numpy>=2"], + ["numpy>=2", "numpy"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + False, + id="unchanged-lock-unbounded-optional-branch-added", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1", "numpy>=2.0.2"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + True, + id="unchanged-lock-optional-floor-branches-preserved", + ), + pytest.param( + ["other>=2; python_version >= '3.11'"], + ["other>=1; python_version >= '3.11'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-marker-floor-lowered", + ), + pytest.param( + ["other>=2"], + ["other>=2rc1"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-unsupported-floor-fails-closed", + ), + pytest.param( + ["other"], + ["other>=2"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-previously-unbounded-floor-added", + ), pytest.param( ["safe-direct>=1.0"], ["safe-direct>=1.0"], From 05d1c292742ac2336693a27484b9bde4cbb63bad Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 18:10:21 +0000 Subject: [PATCH 11/26] fix: preserve contextual security floors and reject lock downgrades --- .github/workflows/ci.yml | 103 +++++++++++++--- tests/test_uv_workflows.py | 232 +++++++++++++++++++++++++++++++++++-- 2 files changed, 308 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47bb73b701..bf5c9abbfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,14 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | python - <<'PY' + import ast + import io import os import pathlib import re import subprocess import tomllib + import tokenize base = os.environ.get("BASE_SHA", "") if not re.fullmatch(r"[0-9a-f]{40}", base): @@ -66,19 +69,68 @@ jobs: def canonical(name): return re.sub(r"[-_.]+", "-", name).lower() + def marker_context(marker): + if not marker.strip(): + return () + try: + if any( + token.type == tokenize.OP and token.string in {"(", ")"} + for token in tokenize.generate_tokens(io.StringIO(marker).readline) + ): + raise ValueError("Parenthesized security dependency marker") + expression = ast.parse(marker.strip(), mode="eval").body + except (SyntaxError, tokenize.TokenError, ValueError): + raise SystemExit("Ambiguous direct security dependency marker") + if isinstance(expression, ast.BoolOp): + if not isinstance(expression.op, ast.And): + raise SystemExit("Ambiguous direct security dependency marker") + parts = expression.values + else: + parts = [expression] + result = [] + for part in parts: + if ( + not isinstance(part, ast.Compare) + or not isinstance(part.left, ast.Name) + or len(part.ops) != 1 + or len(part.comparators) != 1 + or not isinstance(part.comparators[0], ast.Constant) + or not isinstance(part.comparators[0].value, str) + or type(part.ops[0]) + not in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} + ): + raise SystemExit("Ambiguous direct security dependency marker") + result.append((part.left.id.lower(), type(part.ops[0]).__name__, part.comparators[0].value)) + return tuple(sorted(result)) + def direct(project): - requirements = list(project["project"].get("dependencies", [])) - for group in project["project"].get("optional-dependencies", {}).values(): - requirements.extend(group) - result = {} - for requirement in requirements: - match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", requirement) - if match is None: - raise SystemExit("Ambiguous direct security dependency requirement") - name = canonical(match.group(1)) - normalized = name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() - result.setdefault(name, set()).add(normalized) - return result + groups = [("runtime", "", project["project"].get("dependencies", []))] + groups.extend( + ("optional", canonical(group), requirements) + for group, requirements in project["project"].get("optional-dependencies", {}).items() + ) + result, contexts = {}, {} + for scope, group, requirements in groups: + for requirement in requirements: + match = re.fullmatch( + r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", requirement + ) + if match is None: + raise SystemExit("Ambiguous direct security dependency requirement") + name = canonical(match.group(1)) + extra = match.group(2) + requested = () + if extra: + requested = tuple(sorted(canonical(value.strip()) for value in extra[1:-1].split(","))) + if any(not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value) for value in requested): + raise SystemExit("Ambiguous direct security dependency extras") + context = (scope, group, requested, marker_context(match.group(3).partition(";")[2])) + normalized = ( + name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() + ) + result.setdefault(name, set()).add(normalized) + contexts.setdefault(name, {}).setdefault(context, set()).add(normalized) + return result, contexts def versions(lock): result = {} @@ -111,11 +163,14 @@ jobs: old_lock = read_base("uv.lock") new_project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) new_lock = tomllib.loads(pathlib.Path("uv.lock").read_text()) - old_direct, new_direct = direct(old_project), direct(new_project) + old_direct, old_contexts = direct(old_project) + new_direct, new_contexts = direct(new_project) old_versions, new_versions = versions(old_lock), versions(new_lock) for name, requirements in new_direct.items(): previous = old_direct.get(name, set()) - if previous != requirements: + previous_contexts = old_contexts.get(name, {}) + current_contexts = new_contexts.get(name, {}) + if previous != requirements or previous_contexts != current_contexts: previous_minimums = minimums(previous, allow_missing=True) if previous_minimums: updated_minimums = minimums(requirements) @@ -123,14 +178,28 @@ jobs: updated < previous for previous, updated in zip(previous_minimums, updated_minimums) ): raise SystemExit("Do not lower a published security-fixed minimum for " + name) + for context, prior_requirements in previous_contexts.items(): + prior_minimums = minimums(prior_requirements, allow_missing=True) + if not prior_minimums: + continue + context_minimums = minimums(current_contexts.get(context, set())) + if len(context_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, context_minimums) + ): + raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) if old_versions.get(name, set()) == new_versions.get(name, set()): continue if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) patched_versions = new_versions.get(name, set()) - old_versions.get(name, set()) - if not patched_versions: - raise SystemExit("Missing patched direct security dependency release for " + name) - patched_minimum = max(stable_version(version) for version in patched_versions) + removed_versions = old_versions.get(name, set()) - new_versions.get(name, set()) + patched_releases = sorted(stable_version(version) for version in patched_versions) + removed_releases = sorted(stable_version(version) for version in removed_versions) + if not patched_releases or len(patched_releases) != len(removed_releases) or any( + updated <= previous for previous, updated in zip(removed_releases, patched_releases) + ): + raise SystemExit("Missing upgraded direct security dependency release for " + name) + patched_minimum = patched_releases[-1] updated_minimums = minimums(requirements) previous_minimums = minimums(previous, allow_missing=True) if not updated_minimums or any(updated < patched_minimum for updated in updated_minimums) or ( diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 8f9299217f..acb52c4352 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -518,27 +518,30 @@ def run_security_dependency_floor_check( base_packages: list[tuple[str, str]], head_packages: list[tuple[str, str]], optional: bool = False, + base_optional_groups: dict[str, list[str]] | None = None, + head_optional_groups: dict[str, list[str]] | None = None, sha: str = "a" * 40, origin: str = "https://github.com/openai/openai-python", ) -> subprocess.CompletedProcess[str]: - def project(requirements: list[str]) -> str: + def project(requirements: list[str], groups: dict[str, list[str]] | None) -> str: if optional: - return ( - '[project]\nname = "openai"\nversion = "1.0"\ndependencies = []\n' - + "[project.optional-dependencies]\nfeature = " - + json.dumps(requirements) - + "\n" - ) - return '[project]\nname = "openai"\nversion = "1.0"\ndependencies = ' + json.dumps(requirements) + "\n" + groups = {"feature": requirements} + requirements = [] + result = '[project]\nname = "openai"\nversion = "1.0"\ndependencies = ' + json.dumps(requirements) + "\n" + if groups: + result += "[project.optional-dependencies]\n" + for group, dependencies in groups.items(): + result += group + " = " + json.dumps(dependencies) + "\n" + return result def lock(packages: list[tuple[str, str]]) -> str: return "\n".join( f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" for name, version in packages ) - (tmp_path / "pyproject.toml").write_text(project(head_requirements)) + (tmp_path / "pyproject.toml").write_text(project(head_requirements, head_optional_groups)) (tmp_path / "uv.lock").write_text(lock(head_packages)) - (tmp_path / "base-project.toml").write_text(project(base_requirements)) + (tmp_path / "base-project.toml").write_text(project(base_requirements, base_optional_groups)) (tmp_path / "base-lock.toml").write_text(lock(base_packages)) fake_git = tmp_path / "git" fake_git.write_text( @@ -979,6 +982,150 @@ def lock(packages: list[tuple[str, str]]) -> str: True, id="unchanged-lock-previously-unbounded-floor-added", ), + pytest.param( + ["other>=2; python_version < '3.11'", "other>=1; python_version >= '3.11'"], + ["other>=1; python_version < '3.11'", "other>=2; python_version >= '3.11'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-marker-context-floors-swapped", + ), + pytest.param( + ["other[secure]>=2", "other[compat]>=1"], + ["other[secure]>=1", "other[compat]>=2"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-requested-extra-context-floors-swapped", + ), + pytest.param( + ["other>=2; python_version >= '3.11' and sys_platform == 'Linux'"], + ["other>=2,<4; sys_platform == 'Linux' and python_version >= '3.11'"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-marker-conjunction-reordered", + ), + pytest.param( + ["other[B,A]>=2"], + ["other[a,b]>=2,<4"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-requested-extras-reordered", + ), + pytest.param( + ["other>=2; sys_platform == 'Linux'"], + ["other>=2; sys_platform == 'linux'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-marker-literal-case-preserved", + ), + pytest.param( + ["other>=2; python_version < '3.11' or sys_platform == 'linux'"], + ["other>=2; python_version < '3.11' or sys_platform == 'linux'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-ambiguous-or-marker-fails-closed", + ), + pytest.param( + ["other>=2; (python_version < '3.11')"], + ["other>=2; (python_version < '3.11')"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-parenthesized-marker-fails-closed", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "2.0")], + [("danger-pkg", "1.5")], + False, + False, + id="downgraded-lock-cannot-be-security-patch", + ), + pytest.param( + ["danger-pkg>=0!1"], + ["danger-pkg>=0!9"], + [("danger-pkg", "1!1")], + [("danger-pkg", "0!9")], + False, + False, + id="downgraded-epoch-lock-cannot-be-security-patch", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=2.4"], + [("danger-pkg", "1.5"), ("danger-pkg", "2.5")], + [("danger-pkg", "1.6"), ("danger-pkg", "2.4")], + False, + False, + id="downgraded-alternate-lock-branch-fails-closed", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=3"], + [("danger-pkg", "2")], + [("danger-pkg", "2"), ("danger-pkg", "3")], + False, + False, + id="unpaired-added-lock-release-fails-closed", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=2"], + [("danger-pkg", "1"), ("danger-pkg", "2")], + [("danger-pkg", "2")], + False, + False, + id="unpaired-removed-lock-release-fails-closed", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=2.0.0"], + [("danger-pkg", "2.0")], + [("danger-pkg", "2.0.0")], + False, + False, + id="equivalent-lock-release-is-not-security-upgrade", + ), + pytest.param( + ["danger-pkg>=1.9"], + ["danger-pkg>=2.0"], + [("danger-pkg", "1.9")], + [("danger-pkg", "2.0")], + False, + True, + id="single-lock-major-upgrade-remains-valid", + ), + pytest.param( + ["pydantic>=1.10.13,<3"], + ["pydantic>=1.10.27,<3"], + [("pydantic", "1.10.26"), ("pydantic", "2.12.5")], + [("pydantic", "1.10.27"), ("pydantic", "2.12.5")], + False, + True, + id="independent-pydantic-v1-lock-upgrade-preserves-v2", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=3"], + [("danger-pkg", "1.5rc1")], + [("danger-pkg", "3")], + False, + False, + id="prerelease-removed-lock-fails-closed", + ), pytest.param( ["safe-direct>=1.0"], ["safe-direct>=1.0"], @@ -1054,6 +1201,71 @@ def test_only_direct_security_updates_must_raise_published_minimums( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("base_groups", "head_groups", "accepted"), + [ + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=2"], "voice_helpers": ["numpy>=1"]}, + False, + id="actual-numpy-extra-floors-swapped", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=1"]}, + False, + id="actual-numpy-bounded-extra-removed", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"voice_helpers": ["numpy>=2"], "datalib": ["numpy>=1"]}, + True, + id="actual-numpy-extra-groups-reordered", + ), + pytest.param( + {"voice_helpers": ["numpy>=2"]}, + {"voice-helpers": ["numpy>=2,<4"]}, + True, + id="canonical-optional-group-spelling-preserved", + ), + pytest.param( + {"voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=2"]}, + False, + id="bounded-optional-context-replaced", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=1.1"], "voice_helpers": ["numpy>=2"]}, + True, + id="actual-numpy-extra-floor-raised-in-place", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=1.9"]}, + False, + id="actual-numpy-extra-floor-lowered-in-place", + ), + ], +) +def test_security_floors_preserve_original_optional_contexts( + tmp_path: Path, + base_groups: dict[str, list[str]], + head_groups: dict[str, list[str]], + accepted: bool, +) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[("numpy", "2")], + head_packages=[("numpy", "2")], + base_optional_groups=base_groups, + head_optional_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def test_security_floor_parser_strips_requirement_whitespace() -> None: assert "stable_version(matches[0].strip())" in security_dependency_floor_program() From 2a4d89e773b47988cfdf30aa75d5670eb4e8d327 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 18:24:36 +0000 Subject: [PATCH 12/26] fix: validate root build provenance and preserve security constraints --- .github/workflows/ci.yml | 57 +++- .github/workflows/detect-breaking-changes.yml | 4 +- tests/test_uv_workflows.py | 271 +++++++++++++++++- 3 files changed, 315 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf5c9abbfa..bf0b050204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]), "Use only the public PyPI registry, reviewed artifacts, and the exact editable root project in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Require published minimums for direct security updates if: >- @@ -103,12 +103,23 @@ jobs: result.append((part.left.id.lower(), type(part.ops[0]).__name__, part.comparators[0].value)) return tuple(sorted(result)) - def direct(project): - groups = [("runtime", "", project["project"].get("dependencies", []))] - groups.extend( - ("optional", canonical(group), requirements) - for group, requirements in project["project"].get("optional-dependencies", {}).items() - ) + def direct(project, *, protected=False): + if protected: + uv = project.get("tool", {}).get("uv", {}) + groups = [ + ("uv-constraint", "", uv.get("constraint-dependencies", [])), + ("uv-build-constraint", "", uv.get("build-constraint-dependencies", [])), + ] + groups.extend( + ("dependency-group", canonical(group), requirements) + for group, requirements in project.get("dependency-groups", {}).items() + ) + else: + groups = [("runtime", "", project["project"].get("dependencies", []))] + groups.extend( + ("optional", canonical(group), requirements) + for group, requirements in project["project"].get("optional-dependencies", {}).items() + ) result, contexts = {}, {} for scope, group, requirements in groups: for requirement in requirements: @@ -147,11 +158,14 @@ jobs: release = release[:-1] return int(match.group(1) or 0), release - def minimums(requirements, *, allow_missing=False): + def minimums(requirements, *, allow_missing=False, exact=False): result = [] for requirement in requirements: specifier = requirement.split(";", 1)[0] - matches = re.findall(r"(?=!~])>=([^,;]+)", specifier) + pattern = ( + r"(?=!~])(?:>=|==)([^,;]+)" if exact else r"(?=!~])>=([^,;]+)" + ) + matches = re.findall(pattern, specifier) if len(matches) != 1: if allow_missing and not matches: continue @@ -166,6 +180,31 @@ jobs: old_direct, old_contexts = direct(old_project) new_direct, new_contexts = direct(new_project) old_versions, new_versions = versions(old_lock), versions(new_lock) + old_protected, old_protected_contexts = direct(old_project, protected=True) + new_protected, new_protected_contexts = direct(new_project, protected=True) + for name, previous in old_protected.items(): + requirements = new_protected.get(name, set()) + previous_contexts = old_protected_contexts.get(name, {}) + current_contexts = new_protected_contexts.get(name, {}) + if previous == requirements and previous_contexts == current_contexts: + continue + prior_minimums = minimums(previous, allow_missing=True, exact=True) + if not prior_minimums: + continue + updated_minimums = minimums(requirements, exact=True) + if len(updated_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, updated_minimums) + ): + raise SystemExit("Do not lower a protected dependency security minimum for " + name) + for context, prior_requirements in previous_contexts.items(): + context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) + if not context_minimums: + continue + updated_context_minimums = minimums(current_contexts.get(context, set()), exact=True) + if len(updated_context_minimums) != len(context_minimums) or any( + updated < previous for previous, updated in zip(context_minimums, updated_context_minimums) + ): + raise SystemExit("Do not lower a contextual protected security minimum for " + name) for name, requirements in new_direct.items(): previous = old_direct.get(name, set()) previous_contexts = old_contexts.get(name, {}) diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index 2dd7084bf5..1b790d90cd 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -26,7 +26,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]), "Use only the public PyPI registry, reviewed artifacts, and the exact editable root project in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -64,7 +64,7 @@ jobs: - name: Verify dependency source provenance before installing tools working-directory: openai-python run: | - python -c 'import pathlib, re, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]), "Use only the public PyPI registry, reviewed artifacts, and the exact editable root project in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index acb52c4352..94ccf8c449 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -88,15 +88,51 @@ def dependency_lock_source_command() -> str: def run_dependency_lock_source_check( - tmp_path: Path, packages: list[dict[str, object]] + tmp_path: Path, + packages: list[dict[str, object]], + *, + build_requires: list[str] | None = None, + build_group: list[str] | None = None, + build_constraints: list[str] | None = None, + backend: str = "hatchling.build", + backend_path: list[str] | None = None, + uv_sources: dict[str, dict[str, str]] | None = None, + uv_index_url: str | None = None, ) -> subprocess.CompletedProcess[str]: project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] - (tmp_path / "pyproject.toml").write_text( + requires = ["hatchling==1.27.0"] if build_requires is None else build_requires + group = ["hatchling==1.27.0"] if build_group is None else build_group + constraints = ["hatchling==1.27.0"] if build_constraints is None else build_constraints + configuration = ( f"[project]\nname = {json.dumps(project['name'])}\nversion = {json.dumps(project['version'])}\n" + + f"[build-system]\nrequires = {json.dumps(requires)}\nbuild-backend = {json.dumps(backend)}\n" ) + if backend_path is not None: + configuration += "backend-path = " + json.dumps(backend_path) + "\n" + configuration += ( + "[dependency-groups]\nbuild = " + + json.dumps(group) + + "\n[tool.uv]\nbuild-constraint-dependencies = " + + json.dumps(constraints) + + "\n" + ) + if uv_index_url is not None: + configuration += "index-url = " + json.dumps(uv_index_url) + "\n" + if uv_sources is not None: + configuration += "[tool.uv.sources]\n" + for name, source in uv_sources.items(): + values = ", ".join(key + " = " + json.dumps(value) for key, value in source.items()) + configuration += name + " = { " + values + " }\n" + (tmp_path / "pyproject.toml").write_text(configuration) lines: list[str] = [] - for package in packages: + reviewed: dict[str, object] = { + "name": "hatchling", + "version": "1.27.0", + "source": {"registry": "https://pypi.org/simple"}, + } + fixtures = packages if any(package.get("name") == "hatchling" for package in packages) else [*packages, reviewed] + for package in fixtures: lines.extend( [ "[[package]]", @@ -297,6 +333,96 @@ def test_dependency_lock_source_check_accepts_the_committed_lock() -> None: assert result.returncode == 0, result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed-pinned-backend", True, id="reviewed-pinned-backend"), + pytest.param("safe-python-marker", True, id="reviewed-pinned-python-marker"), + pytest.param("empty-sources", True, id="benign-empty-source-overrides"), + pytest.param("direct-url", False, id="root-build-direct-url"), + pytest.param("git", False, id="root-build-git-source"), + pytest.param("path", False, id="root-build-local-path"), + pytest.param("private-index", False, id="root-build-private-index"), + pytest.param("unpinned", False, id="root-build-unpinned-requirement"), + pytest.param("missing-pin", False, id="root-build-missing-reviewed-pin"), + pytest.param("unlocked-version", False, id="root-build-version-missing-from-lock"), + pytest.param("constraint-url", False, id="build-constraint-direct-url"), + pytest.param("group-mismatch", False, id="reviewed-build-group-mismatch"), + pytest.param("unlocked-group", False, id="reviewed-build-group-unlocked-dependency"), + pytest.param("backend", False, id="unreviewed-build-backend"), + pytest.param("backend-path", False, id="local-build-backend-path"), + pytest.param("source-git", False, id="pinned-hatchling-git-override"), + pytest.param("source-path", False, id="pinned-hatchling-path-override"), + pytest.param("index-override", False, id="pinned-hatchling-private-index-override"), + pytest.param("marker-code", False, id="untrusted-build-marker-expression"), + ], +) +def test_root_build_requirements_must_be_public_locked_and_reviewed( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + requires = ["hatchling==1.27.0"] + group = ["hatchling==1.27.0"] + constraints = ["hatchling==1.27.0"] + backend = "hatchling.build" + backend_path: list[str] | None = None + sources: dict[str, dict[str, str]] | None = None + index: str | None = None + if variant == "safe-python-marker": + requires = group = constraints = ["hatchling==1.27.0; python_version < '3.11'"] + elif variant == "empty-sources": + sources = {} + elif variant == "direct-url": + requires = ["hatchling @ https://unreviewed.example/hatchling.whl"] + elif variant == "git": + requires = ["hatchling @ git+https://github.com/unreviewed/hatchling"] + elif variant == "path": + requires = ["hatchling @ file:///tmp/unreviewed"] + elif variant == "private-index": + requires = ["hatchling==1.27.0 --index-url https://private.example/simple"] + elif variant == "unpinned": + requires = ["hatchling>=1.27.0"] + elif variant == "missing-pin": + requires = [] + elif variant == "unlocked-version": + requires = group = constraints = ["hatchling==9.9.9"] + elif variant == "constraint-url": + group = constraints = ["hatchling @ https://unreviewed.example/hatchling.whl"] + elif variant == "group-mismatch": + group = ["hatchling==1.26.0"] + elif variant == "unlocked-group": + group = constraints = ["hatchling==1.27.0", "packaging==26.3"] + elif variant == "backend": + backend = "unreviewed.build" + elif variant == "backend-path": + backend_path = ["."] + elif variant == "source-git": + sources = {"hatchling": {"git": "https://github.com/unreviewed/hatchling"}} + elif variant == "source-path": + sources = {"hatchling": {"path": "../unreviewed"}} + elif variant == "index-override": + index = "https://private.example/simple" + elif variant == "marker-code": + requires = group = constraints = ['hatchling==1.27.0; __import__("os")'] + result = run_dependency_lock_source_check( + tmp_path, + [root], + build_requires=requires, + build_group=group, + build_constraints=constraints, + backend=backend, + backend_path=backend_path, + uv_sources=sources, + uv_index_url=index, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def dependency_workflow_jobs() -> dict[str, str]: path = ROOT / ".github/workflows/ci.yml" if not path.exists(): @@ -521,9 +647,21 @@ def run_security_dependency_floor_check( base_optional_groups: dict[str, list[str]] | None = None, head_optional_groups: dict[str, list[str]] | None = None, sha: str = "a" * 40, + base_constraints: list[str] | None = None, + head_constraints: list[str] | None = None, + base_build_constraints: list[str] | None = None, + head_build_constraints: list[str] | None = None, + base_dependency_groups: dict[str, list[str]] | None = None, + head_dependency_groups: dict[str, list[str]] | None = None, origin: str = "https://github.com/openai/openai-python", ) -> subprocess.CompletedProcess[str]: - def project(requirements: list[str], groups: dict[str, list[str]] | None) -> str: + def project( + requirements: list[str], + groups: dict[str, list[str]] | None, + constraints: list[str] | None, + build_constraints: list[str] | None, + dependency_groups: dict[str, list[str]] | None, + ) -> str: if optional: groups = {"feature": requirements} requirements = [] @@ -532,6 +670,16 @@ def project(requirements: list[str], groups: dict[str, list[str]] | None) -> str result += "[project.optional-dependencies]\n" for group, dependencies in groups.items(): result += group + " = " + json.dumps(dependencies) + "\n" + if dependency_groups: + result += "[dependency-groups]\n" + for group, dependencies in dependency_groups.items(): + result += group + " = " + json.dumps(dependencies) + "\n" + if constraints is not None or build_constraints is not None: + result += "[tool.uv]\n" + if constraints is not None: + result += "constraint-dependencies = " + json.dumps(constraints) + "\n" + if build_constraints is not None: + result += "build-constraint-dependencies = " + json.dumps(build_constraints) + "\n" return result def lock(packages: list[tuple[str, str]]) -> str: @@ -539,9 +687,17 @@ def lock(packages: list[tuple[str, str]]) -> str: f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" for name, version in packages ) - (tmp_path / "pyproject.toml").write_text(project(head_requirements, head_optional_groups)) + (tmp_path / "pyproject.toml").write_text( + project( + head_requirements, head_optional_groups, head_constraints, head_build_constraints, head_dependency_groups + ) + ) (tmp_path / "uv.lock").write_text(lock(head_packages)) - (tmp_path / "base-project.toml").write_text(project(base_requirements, base_optional_groups)) + (tmp_path / "base-project.toml").write_text( + project( + base_requirements, base_optional_groups, base_constraints, base_build_constraints, base_dependency_groups + ) + ) (tmp_path / "base-lock.toml").write_text(lock(base_packages)) fake_git = tmp_path / "git" fake_git.write_text( @@ -1266,6 +1422,109 @@ def test_security_floors_preserve_original_optional_contexts( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("constraint-equal", True, id="uv-security-constraint-preserved"), + pytest.param("constraint-higher", True, id="uv-security-constraint-raised"), + pytest.param("constraint-reordered", True, id="uv-security-constraints-reordered"), + pytest.param("constraint-lowered", False, id="uv-security-constraint-lowered"), + pytest.param("constraint-removed", False, id="uv-security-constraint-removed"), + pytest.param("constraint-section-removed", False, id="uv-security-constraint-section-removed"), + pytest.param("constraint-marker-swap", False, id="uv-security-constraint-markers-swapped"), + pytest.param("build-pin-preserved", True, id="uv-build-security-pin-preserved"), + pytest.param("build-pin-lowered", False, id="uv-build-security-pin-lowered"), + pytest.param("build-pin-removed", False, id="uv-build-security-pin-removed"), + pytest.param("group-floor-raised", True, id="dependency-group-security-floor-raised"), + pytest.param("group-floor-lowered", False, id="dependency-group-security-floor-lowered"), + pytest.param("group-floor-removed", False, id="dependency-group-security-floor-removed"), + pytest.param("group-pin-lowered", False, id="dependency-group-exact-security-pin-lowered"), + pytest.param("group-context-swap", False, id="pydantic-dependency-group-contexts-swapped"), + pytest.param("unbounded-group-preserved", True, id="unbounded-dependency-group-preserved"), + ], +) +def test_security_updates_preserve_uv_and_dependency_group_floors(tmp_path: Path, variant: str, accepted: bool) -> None: + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + base_build_constraints: list[str] | None = None + head_build_constraints: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + if variant == "constraint-equal": + base_constraints = head_constraints = ["cryptography>=50.0.0"] + elif variant == "constraint-higher": + base_constraints, head_constraints = ["cryptography>=50.0.0"], ["cryptography>=51.0.0"] + elif variant == "constraint-reordered": + base_constraints = ["cryptography>=50.0.0", "requests>=2.33.0"] + head_constraints = ["requests>=2.33.0", "cryptography>=50.0.0"] + elif variant == "constraint-lowered": + base_constraints, head_constraints = ["cryptography>=50.0.0"], ["cryptography>=49.0.0"] + elif variant == "constraint-removed": + base_constraints = ["cryptography>=50.0.0", "requests>=2.33.0"] + head_constraints = ["requests>=2.33.0"] + elif variant == "constraint-section-removed": + base_constraints = ["cryptography>=50.0.0"] + elif variant == "constraint-marker-swap": + base_constraints = [ + "cryptography>=50; python_version < '3.11'", + "cryptography>=49; python_version >= '3.11'", + ] + head_constraints = [ + "cryptography>=49; python_version < '3.11'", + "cryptography>=50; python_version >= '3.11'", + ] + elif variant == "build-pin-preserved": + base_build_constraints = head_build_constraints = ["hatchling==1.27.0"] + elif variant == "build-pin-lowered": + base_build_constraints, head_build_constraints = ["hatchling==1.27.0"], ["hatchling==1.26.0"] + elif variant == "build-pin-removed": + base_build_constraints, head_build_constraints = ["hatchling==1.27.0"], [] + elif variant == "group-floor-raised": + base_groups, head_groups = {"dev": ["pytest>=9.0.3"]}, {"dev": ["pytest>=9.0.4"]} + elif variant == "group-floor-lowered": + base_groups, head_groups = {"dev": ["pytest>=9.0.3"]}, {"dev": ["pytest>=9.0.2"]} + elif variant == "group-floor-removed": + base_groups = {"dev": ["pytest>=9.0.3"]} + head_groups = {"dev": []} + elif variant == "group-pin-lowered": + base_groups, head_groups = {"build": ["hatchling==1.27.0"]}, {"build": ["hatchling==1.26.0"]} + elif variant == "group-context-swap": + base_groups = {"pydantic-v1": ["pydantic>=1.10"], "pydantic-v2": ["pydantic>=2"]} + head_groups = {"pydantic-v1": ["pydantic>=2"], "pydantic-v2": ["pydantic>=1.10"]} + elif variant == "unbounded-group-preserved": + base_groups = head_groups = {"dev": ["ruff"]} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[ + ("cryptography", "50.0.0"), + ("requests", "2.33.0"), + ("hatchling", "1.27.0"), + ("pytest", "9.0.3"), + ("pydantic", "1.10"), + ("pydantic", "2"), + ("ruff", "1"), + ], + head_packages=[ + ("cryptography", "50.0.0"), + ("requests", "2.33.0"), + ("hatchling", "1.27.0"), + ("pytest", "9.0.3"), + ("pydantic", "1.10"), + ("pydantic", "2"), + ("ruff", "1"), + ], + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build_constraints, + head_build_constraints=head_build_constraints, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def test_security_floor_parser_strips_requirement_whitespace() -> None: assert "stable_version(matches[0].strip())" in security_dependency_floor_program() From dd2e76992153806d9249c610f2ef69edfd711cfe Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 18:49:55 +0000 Subject: [PATCH 13/26] fix: block dependency source builds and preserve marker-specific security fixes --- .github/workflows/ci.yml | 170 +++++++++-- .github/workflows/detect-breaking-changes.yml | 7 +- tests/test_uv_workflows.py | 289 +++++++++++++++++- 3 files changed, 429 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf0b050204..3a32cd55a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ on: permissions: contents: read +env: + UV_NO_BUILD: '1' + jobs: dependency-locks: timeout-minutes: 10 @@ -30,7 +33,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Require published minimums for direct security updates if: >- @@ -144,19 +147,106 @@ jobs: return result, contexts def versions(lock): - result = {} + result, contexts = {}, {} for package in lock["package"]: - result.setdefault(canonical(package["name"]), set()).add(package["version"]) - return result + name = canonical(package["name"]) + version = package["version"] + result.setdefault(name, set()).add(version) + markers = package.get("resolution-markers") + if markers is None: + domains = [()] + else: + if not isinstance(markers, list) or not markers or any( + not isinstance(marker, str) for marker in markers + ): + raise SystemExit("Ambiguous locked security dependency resolution marker") + domains = [marker_context(marker) for marker in markers] + for domain in domains: + contexts.setdefault(name, {}).setdefault(domain, set()).add(version) + return result, contexts def stable_version(value): - match = re.fullmatch(r"(?:(\d+)!)?(\d+(?:\.\d+)*)", value) + match = re.fullmatch(r"(?:(\d+)!)?(\d+(?:\.\d+)*)(?:\.post(\d+))?", value) if match is None: raise SystemExit("Unsupported direct security dependency minimum") release = tuple(int(part) for part in match.group(2).split(".")) while release and release[-1] == 0: release = release[:-1] - return int(match.group(1) or 0), release + post = -1 if match.group(3) is None else int(match.group(3)) + return int(match.group(1) or 0), release, post + + def marker_overlap(requirement, resolution): + clauses = {} + for variable, operator, value in requirement + resolution: + family = "python" if variable in {"python_version", "python_full_version"} else variable + clauses.setdefault(family, []).append((variable, operator, value)) + for family, constraints in clauses.items(): + if family == "python": + lower, upper, excluded = (0, 0, 0), None, [] + for variable, operator, value in constraints: + match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?(\.\*)?", value) + if match is None or ( + variable == "python_version" and match.group(3) is not None + ): + raise SystemExit("Ambiguous Python security dependency marker") + major, minor = int(match.group(1)), int(match.group(2)) + patch = int(match.group(3) or 0) + wildcard = match.group(4) is not None + if wildcard and operator not in {"Eq", "NotEq"}: + raise SystemExit("Ambiguous wildcard security dependency marker") + start = (major, minor, patch) + stop = ( + (major, minor + 1, 0) + if variable == "python_version" or wildcard + else (major, minor, patch + 1) + ) + if operator == "Eq": + lower = max(lower, start) + upper = stop if upper is None else min(upper, stop) + elif operator == "NotEq": + excluded.append((start, stop)) + elif operator == "Lt": + upper = start if upper is None else min(upper, start) + elif operator == "LtE": + upper = stop if upper is None else min(upper, stop) + elif operator == "Gt": + lower = max(lower, stop) + elif operator == "GtE": + lower = max(lower, start) + else: + raise SystemExit("Ambiguous Python security dependency marker") + if upper is not None and lower >= upper: + return False + candidate = lower + for start, stop in sorted(excluded): + if start <= candidate < stop: + candidate = stop + if upper is not None and candidate >= upper: + return False + else: + if family not in { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "extra", + }: + raise SystemExit("Unsupported security dependency marker variable") + equality, exclusions = None, set() + for _, operator, value in constraints: + if operator == "Eq": + if equality is not None and equality != value: + return False + equality = value + elif operator == "NotEq": + exclusions.add(value) + else: + raise SystemExit("Ambiguous platform security dependency marker") + if equality is not None and equality in exclusions: + return False + return True def minimums(requirements, *, allow_missing=False, exact=False): result = [] @@ -179,7 +269,8 @@ jobs: new_lock = tomllib.loads(pathlib.Path("uv.lock").read_text()) old_direct, old_contexts = direct(old_project) new_direct, new_contexts = direct(new_project) - old_versions, new_versions = versions(old_lock), versions(new_lock) + old_versions, old_resolution_contexts = versions(old_lock) + new_versions, new_resolution_contexts = versions(new_lock) old_protected, old_protected_contexts = direct(old_project, protected=True) new_protected, new_protected_contexts = direct(new_project, protected=True) for name, previous in old_protected.items(): @@ -226,29 +317,54 @@ jobs: updated < previous for previous, updated in zip(prior_minimums, context_minimums) ): raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) - if old_versions.get(name, set()) == new_versions.get(name, set()): + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + if ( + old_versions.get(name, set()) == new_versions.get(name, set()) + and previous_domains == current_domains + ): continue if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) - patched_versions = new_versions.get(name, set()) - old_versions.get(name, set()) - removed_versions = old_versions.get(name, set()) - new_versions.get(name, set()) - patched_releases = sorted(stable_version(version) for version in patched_versions) - removed_releases = sorted(stable_version(version) for version in removed_versions) - if not patched_releases or len(patched_releases) != len(removed_releases) or any( - updated <= previous for previous, updated in zip(removed_releases, patched_releases) - ): + patched_domains = {} + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + if not introduced or len(introduced) != len(removed) or any( + updated <= previous for previous, updated in zip(removed, introduced) + ): + raise SystemExit("Missing contextual upgraded security dependency release for " + name) + patched_domains[domain] = introduced[-1] + if not patched_domains: raise SystemExit("Missing upgraded direct security dependency release for " + name) - patched_minimum = patched_releases[-1] - updated_minimums = minimums(requirements) - previous_minimums = minimums(previous, allow_missing=True) - if not updated_minimums or any(updated < patched_minimum for updated in updated_minimums) or ( - previous_minimums - and ( - len(updated_minimums) != len(previous_minimums) - or any(updated < previous for previous, updated in zip(previous_minimums, updated_minimums)) - or updated_minimums[0] <= previous_minimums[0] - ) - ): + covered = set() + for context, context_requirements in current_contexts.items(): + domains = { + domain + for domain in patched_domains + if marker_overlap(context[3], domain) + } + if not domains: + continue + covered.update(domains) + patched_minimum = max(patched_domains[domain] for domain in domains) + updated_minimums = minimums(context_requirements) + previous_minimums = minimums(previous_contexts.get(context, set()), allow_missing=True) + if ( + not updated_minimums + or any(updated < patched_minimum for updated in updated_minimums) + or previous_minimums + and ( + len(updated_minimums) != len(previous_minimums) + or updated_minimums[0] <= previous_minimums[0] + ) + ): + raise SystemExit("Raise the contextual security-fixed minimum for " + name) + if covered != set(patched_domains): raise SystemExit("Raise the published security-fixed minimum for " + name) PY @@ -462,6 +578,8 @@ jobs: needs: dependency-locks if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' continue-on-error: ${{ matrix.experimental }} + env: + UV_NO_BUILD: ${{ matrix.experimental && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '0' || '1' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index 1b790d90cd..140401e213 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -11,6 +11,9 @@ on: permissions: contents: read +env: + UV_NO_BUILD: '1' + jobs: detect_breaking_changes: runs-on: 'ubuntu-latest' @@ -26,7 +29,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -64,7 +67,7 @@ jobs: - name: Verify dependency source provenance before installing tools working-directory: openai-python run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels", []), list) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 94ccf8c449..f1572b3043 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -98,6 +98,8 @@ def run_dependency_lock_source_check( backend_path: list[str] | None = None, uv_sources: dict[str, dict[str, str]] | None = None, uv_index_url: str | None = None, + uv_overrides: dict[str, object] | None = None, + extra_uv_config: str | None = None, ) -> subprocess.CompletedProcess[str]: project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] requires = ["hatchling==1.27.0"] if build_requires is None else build_requires @@ -118,12 +120,17 @@ def run_dependency_lock_source_check( ) if uv_index_url is not None: configuration += "index-url = " + json.dumps(uv_index_url) + "\n" + if uv_overrides is not None: + for name, value in uv_overrides.items(): + configuration += name + " = " + json.dumps(value) + "\n" if uv_sources is not None: configuration += "[tool.uv.sources]\n" for name, source in uv_sources.items(): values = ", ".join(key + " = " + json.dumps(value) for key, value in source.items()) configuration += name + " = { " + values + " }\n" (tmp_path / "pyproject.toml").write_text(configuration) + if extra_uv_config is not None: + (tmp_path / extra_uv_config).write_text('no-binary-package = ["reviewed-dependency"]\n') lines: list[str] = [] reviewed: dict[str, object] = { @@ -157,8 +164,19 @@ def run_dependency_lock_source_check( artifact_values = cast(dict[str, object], artifact) values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in artifact_values.items()) lines.append("sdist = { " + values + " }") - if "wheels" in package: - wheels = package["wheels"] + wheels = package.get("wheels") + if "wheels" not in package and source == {"registry": "https://pypi.org/simple"}: + wheels = ( + [] + if package.get("sdist_only") or "sdist" in package and package["sdist"] is None + else [ + { + "url": "https://files.pythonhosted.org/packages/reviewed-1.0.0-py3-none-any.whl", + "hash": "sha256:" + "b" * 64, + } + ] + ) + if wheels is not None: assert isinstance(wheels, list) typed_wheels = cast(list[dict[str, object]], wheels) wheel_values = [ @@ -423,6 +441,55 @@ def test_root_build_requirements_must_be_public_locked_and_reviewed( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed-wheel", True, id="reviewed-public-wheel"), + pytest.param("sdist-only", False, id="unreviewed-source-distribution-backend"), + pytest.param("empty-wheels", False, id="empty-wheel-list"), + pytest.param("no-binary", False, id="force-all-source-builds"), + pytest.param("no-binary-package", False, id="force-package-source-build"), + pytest.param("no_binary", False, id="force-all-source-builds-alias"), + pytest.param("no_binary_package", False, id="force-package-source-build-alias"), + pytest.param("uv.toml", False, id="standalone-uv-config-source-override"), + pytest.param(".uv.toml", False, id="hidden-uv-config-source-override"), + ], +) +def test_public_dependencies_require_reviewed_wheels_without_build_overrides( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + overrides: dict[str, object] | None = None + config: str | None = None + if variant == "sdist-only": + dependency["sdist_only"] = True + elif variant == "empty-wheels": + dependency["wheels"] = [] + elif variant in {"no-binary", "no_binary"}: + overrides = {variant: True} + elif variant in {"no-binary-package", "no_binary_package"}: + overrides = {variant: ["reviewed-dependency"]} + elif variant in {"uv.toml", ".uv.toml"}: + config = variant + result = run_dependency_lock_source_check( + tmp_path, + [root, dependency], + uv_overrides=overrides, + extra_uv_config=config, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def dependency_workflow_jobs() -> dict[str, str]: path = ROOT / ".github/workflows/ci.yml" if not path.exists(): @@ -523,6 +590,33 @@ def test_scheduled_compatibility_keeps_dependency_provenance_gate() -> None: assert "github.event_name == 'workflow_dispatch'" in jobs["compatibility"] +@pytest.mark.parametrize( + ("event", "experimental", "allowed"), + [ + pytest.param("pull_request", False, False, id="fork-pr-supported-python"), + pytest.param("pull_request", True, False, id="fork-pr-never-gets-prerelease-exception"), + pytest.param("push", True, False, id="push-never-gets-prerelease-exception"), + pytest.param("merge_group", True, False, id="merge-queue-never-gets-prerelease-exception"), + pytest.param("schedule", False, False, id="scheduled-supported-python-wheels-only"), + pytest.param("schedule", True, True, id="trusted-scheduled-prerelease-preserved"), + pytest.param("workflow_dispatch", True, True, id="trusted-manual-prerelease-preserved"), + ], +) +def test_source_builds_only_allowed_in_trusted_experimental_compatibility( + event: str, experimental: bool, allowed: bool +) -> None: + for name in ("ci.yml", "detect-breaking-changes.yml"): + workflow = (ROOT / ".github/workflows" / name).read_text() + assert re.search(r"^env:\n UV_NO_BUILD: ['\"]?1['\"]?\s*$", workflow, re.MULTILINE) + compatibility = dependency_workflow_jobs()["compatibility"] + assert "matrix.experimental" in compatibility + assert "(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" in compatibility + assert "&& '0' || '1'" in compatibility + assert "environment:" not in compatibility + assert "id-token:" not in compatibility + assert (experimental and event in {"schedule", "workflow_dispatch"}) is allowed + + @pytest.mark.parametrize("name", ["detect_breaking_changes", "agents_sdk"]) def test_breaking_change_installers_validate_provenance_first(name: str) -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" @@ -653,6 +747,8 @@ def run_security_dependency_floor_check( head_build_constraints: list[str] | None = None, base_dependency_groups: dict[str, list[str]] | None = None, head_dependency_groups: dict[str, list[str]] | None = None, + base_resolution_markers: dict[tuple[str, str], list[str]] | None = None, + head_resolution_markers: dict[tuple[str, str], list[str]] | None = None, origin: str = "https://github.com/openai/openai-python", ) -> subprocess.CompletedProcess[str]: def project( @@ -682,23 +778,27 @@ def project( result += "build-constraint-dependencies = " + json.dumps(build_constraints) + "\n" return result - def lock(packages: list[tuple[str, str]]) -> str: - return "\n".join( - f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" for name, version in packages - ) + def lock(packages: list[tuple[str, str]], resolutions: dict[tuple[str, str], list[str]] | None) -> str: + result: list[str] = [] + for name, version in packages: + entry = f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" + if resolutions is not None and (name, version) in resolutions: + entry += "resolution-markers = " + json.dumps(resolutions[(name, version)]) + "\n" + result.append(entry) + return "\n".join(result) (tmp_path / "pyproject.toml").write_text( project( head_requirements, head_optional_groups, head_constraints, head_build_constraints, head_dependency_groups ) ) - (tmp_path / "uv.lock").write_text(lock(head_packages)) + (tmp_path / "uv.lock").write_text(lock(head_packages, head_resolution_markers)) (tmp_path / "base-project.toml").write_text( project( base_requirements, base_optional_groups, base_constraints, base_build_constraints, base_dependency_groups ) ) - (tmp_path / "base-lock.toml").write_text(lock(base_packages)) + (tmp_path / "base-lock.toml").write_text(lock(base_packages, base_resolution_markers)) fake_git = tmp_path / "git" fake_git.write_text( f"#!{sys.executable}\n" @@ -1282,6 +1382,78 @@ def lock(packages: list[tuple[str, str]]) -> str: False, id="prerelease-removed-lock-fails-closed", ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.post1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.0.post1")], + False, + True, + id="stable-post-release-security-fix", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.post0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.0.post0")], + False, + True, + id="stable-post-zero-above-base-release", + ), + pytest.param( + ["danger-pkg>=1.0.post0"], + ["danger-pkg>=1.0.post1"], + [("danger-pkg", "1.0.post0")], + [("danger-pkg", "1.0.post1")], + False, + True, + id="stable-post-release-increases-monotonically", + ), + pytest.param( + ["danger-pkg>=1.0.post9"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0.post9")], + [("danger-pkg", "1.1")], + False, + True, + id="stable-next-release-above-post-release", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.0.post1")], + False, + False, + id="base-floor-does-not-cover-post-security-fix", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.post9"], + [("danger-pkg", "1.1")], + [("danger-pkg", "1.0.post9")], + False, + False, + id="post-release-cannot-downgrade-next-release", + ), + pytest.param( + ["danger-pkg>=0!9.0"], + ["danger-pkg>=1!1.0.post1"], + [("danger-pkg", "9.0")], + [("danger-pkg", "1!1.0.post1")], + False, + True, + id="epoch-stable-post-security-fix", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.0.post1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.post1")], + False, + True, + id="post-release-normalizes-trailing-zeroes", + ), pytest.param( ["safe-direct>=1.0"], ["safe-direct>=1.0"], @@ -1321,7 +1493,6 @@ def lock(packages: list[tuple[str, str]]) -> str: ) for label, version in ( ("prerelease", "1.1rc1"), - ("postrelease", "1.1.post1"), ("development", "1.1.dev1"), ("local", "1.1+local"), ) @@ -1357,6 +1528,93 @@ def test_only_direct_security_updates_must_raise_published_minimums( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("independent-upgrades", True, id="numpy-python-marker-lines-upgrade-independently"), + pytest.param("reordered-marker", True, id="resolution-marker-conjunction-order-preserved"), + pytest.param("high-line-only", True, id="unchanged-old-python-floor-does-not-require-new-line"), + pytest.param("old-line-only", True, id="unchanged-new-python-floor-does-not-require-old-line"), + pytest.param("swapped-lines", False, id="marker-domains-cannot-swap-locked-versions"), + pytest.param("dropped-domain", False, id="resolution-marker-domain-cannot-disappear"), + pytest.param("unmarked-low-floor", False, id="unmarked-floor-must-cover-every-patched-domain"), + pytest.param("ambiguous-or", False, id="ambiguous-resolution-marker-fails-closed"), + ], +) +def test_security_patches_follow_their_original_resolution_marker_domains( + tmp_path: Path, variant: str, accepted: bool +) -> None: + old_requirement = "numpy>=2.2.6; python_version < '3.11'" + new_requirement = "numpy>=2.4.6; python_version >= '3.11'" + old_marker = "python_full_version < '3.11'" + new_markers = [ + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform != 'linux'", + ] + base_requirements = [old_requirement, new_requirement] + head_requirements = [ + "numpy>=2.2.7; python_version < '3.11'", + "numpy>=2.4.7; python_version >= '3.11'", + ] + base_packages = [("numpy", "2.2.6"), ("numpy", "2.4.6")] + head_packages = [("numpy", "2.2.7"), ("numpy", "2.4.7")] + base_markers = { + ("numpy", "2.2.6"): [old_marker], + ("numpy", "2.4.6"): new_markers, + } + head_markers = { + ("numpy", "2.2.7"): [old_marker], + ("numpy", "2.4.7"): list(new_markers), + } + if variant == "reordered-marker": + head_markers[("numpy", "2.4.7")] = [ + "sys_platform == 'linux' and python_full_version >= '3.11'", + "sys_platform != 'linux' and python_full_version >= '3.11'", + ] + elif variant == "high-line-only": + head_requirements[0] = old_requirement + head_packages[0] = ("numpy", "2.2.6") + head_markers.pop(("numpy", "2.2.7")) + head_markers[("numpy", "2.2.6")] = [old_marker] + elif variant == "old-line-only": + head_requirements[1] = new_requirement + head_packages[1] = ("numpy", "2.4.6") + head_markers.pop(("numpy", "2.4.7")) + head_markers[("numpy", "2.4.6")] = list(new_markers) + elif variant == "swapped-lines": + head_requirements = [ + "numpy>=2.4.7; python_version < '3.11'", + "numpy>=2.4.7; python_version >= '3.11'", + ] + head_markers = { + ("numpy", "2.2.7"): list(new_markers), + ("numpy", "2.4.7"): [old_marker], + } + elif variant == "dropped-domain": + head_requirements = [ + "numpy>=2.4.7; python_version < '3.11'", + "numpy>=2.4.7; python_version >= '3.11'", + ] + head_markers[("numpy", "2.4.7")] = [] + elif variant == "unmarked-low-floor": + base_requirements = ["numpy>=2.2.6"] + head_requirements = ["numpy>=2.2.7"] + elif variant == "ambiguous-or": + head_markers[("numpy", "2.4.7")] = [ + "python_full_version >= '3.11' or sys_platform == 'linux'", + ] + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=base_packages, + head_packages=head_packages, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("base_groups", "head_groups", "accepted"), [ @@ -1674,3 +1932,16 @@ def test_build_uses_hashed_locked_build_group(tmp_path: Path, monkeypatch: pytes "--out-dir", str(tmp_path / "dist"), ] + + +def test_reviewed_root_build_still_runs_with_source_distribution_builds_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + log = fake_uv(tmp_path, monkeypatch) + monkeypatch.setenv("UV_NO_BUILD", "1") + result = subprocess.run([str(ROOT / "scripts/build"), "--out-dir", str(tmp_path / "dist")], check=False) + calls = [json.loads(line)["args"] for line in log.read_text().splitlines()] + assert result.returncode == 0 + assert calls[0][0] == "export" + assert calls[1][0] == "build" + assert "--no-sources" in calls[1] From 386075362f064730ab2400515159e063c1223d46 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 19:29:55 +0000 Subject: [PATCH 14/26] fix(ci): permit reviewed editable roots without building dependencies --- .github/workflows/ci.yml | 3 +- .github/workflows/detect-breaking-changes.yml | 11 +- tests/test_uv_workflows.py | 102 +++++++++++++++++- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a32cd55a7..3a20683a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ permissions: env: UV_NO_BUILD: '1' + UV_NO_BINARY_PACKAGE: 'openai' jobs: dependency-locks: @@ -33,7 +34,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Require published minimums for direct security updates if: >- diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index 140401e213..c7ebe1db4b 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -13,6 +13,7 @@ permissions: env: UV_NO_BUILD: '1' + UV_NO_BINARY_PACKAGE: 'openai' jobs: detect_breaking_changes: @@ -29,7 +30,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -67,7 +68,7 @@ jobs: - name: Verify dependency source provenance before installing tools working-directory: openai-python run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -92,12 +93,18 @@ jobs: - name: Link to local SDK working-directory: openai-agents-python + env: + UV_NO_BINARY_PACKAGE: 'openai openai-agents' run: uv add ../openai-python - name: Install dependencies working-directory: openai-agents-python + env: + UV_NO_BINARY_PACKAGE: 'openai openai-agents' run: make sync - name: Run integration type checks + env: + UV_NO_BINARY_PACKAGE: 'openai openai-agents' working-directory: openai-agents-python run: make mypy diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index f1572b3043..5cc4d051c1 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -4,6 +4,7 @@ import re import sys import json +import shutil import subprocess from typing import Any, cast from pathlib import Path @@ -100,13 +101,16 @@ def run_dependency_lock_source_check( uv_index_url: str | None = None, uv_overrides: dict[str, object] | None = None, extra_uv_config: str | None = None, + project_name: str | None = None, ) -> subprocess.CompletedProcess[str]: project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] requires = ["hatchling==1.27.0"] if build_requires is None else build_requires group = ["hatchling==1.27.0"] if build_group is None else build_group constraints = ["hatchling==1.27.0"] if build_constraints is None else build_constraints + if project_name is None: + project_name = project["name"] configuration = ( - f"[project]\nname = {json.dumps(project['name'])}\nversion = {json.dumps(project['version'])}\n" + f"[project]\nname = {json.dumps(project_name)}\nversion = {json.dumps(project['version'])}\n" + f"[build-system]\nrequires = {json.dumps(requires)}\nbuild-backend = {json.dumps(backend)}\n" ) if backend_path is not None: @@ -339,6 +343,39 @@ def test_dependency_lock_requires_one_exact_editable_root(tmp_path: Path, varian assert "Use only the public PyPI registry" in result.stderr +@pytest.mark.parametrize( + ("project_name", "registry_name", "accepted"), + [ + pytest.param("openai", "reviewed-dependency", True, id="only-reviewed-editable-root"), + pytest.param("openai", "openai", False, id="public-registry-root-name-collision"), + pytest.param("openai", "OpenAI", False, id="public-registry-root-case-alias"), + pytest.param("openai", "OPENAI", False, id="public-registry-root-uppercase-alias"), + pytest.param("renamed-root", "openai", False, id="renamed-root-exempts-public-openai"), + pytest.param("open_ai", "openai", False, id="root-normalization-cannot-change-exemption"), + ], +) +def test_source_build_exemption_only_covers_the_unique_reviewed_editable_root( + tmp_path: Path, project_name: str, registry_name: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project_name, + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": registry_name, + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + + result = run_dependency_lock_source_check(tmp_path, [root, dependency], project_name=project_name) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if not accepted: + assert "Use only the public PyPI registry" in result.stderr + + def test_dependency_lock_source_check_accepts_the_committed_lock() -> None: result = subprocess.run( [sys.executable, "-c", dependency_lock_source_command()], @@ -607,7 +644,10 @@ def test_source_builds_only_allowed_in_trusted_experimental_compatibility( ) -> None: for name in ("ci.yml", "detect-breaking-changes.yml"): workflow = (ROOT / ".github/workflows" / name).read_text() - assert re.search(r"^env:\n UV_NO_BUILD: ['\"]?1['\"]?\s*$", workflow, re.MULTILINE) + global_environment = workflow.split("\njobs:\n", 1)[0].rsplit("\nenv:\n", 1)[1] + assert re.search(r"^ UV_NO_BUILD: ['\"]?1['\"]?\s*$", global_environment, re.MULTILINE) + assert re.search(r"^ UV_NO_BINARY_PACKAGE: ['\"]?openai['\"]?\s*$", global_environment, re.MULTILINE) + assert "openai-agents" not in global_environment compatibility = dependency_workflow_jobs()["compatibility"] assert "matrix.experimental" in compatibility assert "(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" in compatibility @@ -617,6 +657,64 @@ def test_source_builds_only_allowed_in_trusted_experimental_compatibility( assert (experimental and event in {"schedule", "workflow_dispatch"}) is allowed +def test_editable_project_sync_requires_only_the_reviewed_root_build_exemption() -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is not installed") + + command = [uv, "--no-config", "sync", "--frozen", "--all-extras", "--offline", "--dry-run"] + environment = dict(os.environ) + environment["UV_NO_BUILD"] = "1" + environment.pop("UV_NO_BINARY_PACKAGE", None) + + rejected = subprocess.run( + command, + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert rejected.returncode != 0 + assert "openai" in rejected.stderr + assert "--no-build" in rejected.stderr + + environment["UV_NO_BINARY_PACKAGE"] = "openai" + accepted = subprocess.run( + command, + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert accepted.returncode == 0, accepted.stdout + accepted.stderr + assert "openai @" in accepted.stderr + + +def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + match = re.search(r"^ agents_sdk:\n(?P.*?)(?=^ [\w-]+:\n|\Z)", workflow, re.MULTILINE | re.DOTALL) + assert match is not None + job = match.group("body") + trusted_checkout = job.index("repository: openai/openai-agents-python") + exception = "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" + assert job.count(exception) == 3 + + for command in ("uv add ../openai-python", "make sync", "make mypy"): + command_index = job.index(command) + assert command_index > trusted_checkout + step_start = job.rfind("\n - ", 0, command_index) + step_end = job.find("\n - ", command_index) + if step_end < 0: + step_end = len(job) + step = job[step_start:step_end] + assert "working-directory: openai-agents-python" in step + assert exception in step + + assert exception not in job[:trusted_checkout] + + @pytest.mark.parametrize("name", ["detect_breaking_changes", "agents_sdk"]) def test_breaking_change_installers_validate_provenance_first(name: str) -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" From 27b0b83dd2a05047ce0249277bb75bb1b15e1ea9 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 19:42:34 +0000 Subject: [PATCH 15/26] fix(ci): reject direct dependency removals in security updates --- .github/workflows/ci.yml | 7 ++++ tests/test_uv_workflows.py | 77 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a20683a3e..2ded64ddc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,6 +297,13 @@ jobs: updated < previous for previous, updated in zip(context_minimums, updated_context_minimums) ): raise SystemExit("Do not lower a contextual protected security minimum for " + name) + for name, previous_contexts in old_contexts.items(): + current_contexts = new_contexts.get(name, {}) + if any( + len(current_contexts.get(context, set())) < len(previous) + for context, previous in previous_contexts.items() + ): + raise SystemExit("Do not remove a published direct dependency or its original context for " + name) for name, requirements in new_direct.items(): previous = old_direct.get(name, set()) previous_contexts = old_contexts.get(name, {}) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 5cc4d051c1..666a623e2f 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -1626,6 +1626,83 @@ def test_only_direct_security_updates_must_raise_published_minimums( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-removed", False, id="runtime-dependency-and-lock-removed"), + pytest.param("runtime-unbounded-removed", False, id="unbounded-runtime-dependency-removed"), + pytest.param("optional-group-removed", False, id="optional-dependency-group-and-lock-removed"), + pytest.param("optional-unbounded-removed", False, id="unbounded-optional-dependency-removed"), + pytest.param("marker-context-removed", False, id="unbounded-marker-context-removed"), + pytest.param("requested-extra-context-removed", False, id="requested-extra-context-removed"), + pytest.param("declaration-removed", False, id="unbounded-same-context-declaration-removed"), + pytest.param("unchanged", True, id="runtime-and-optional-dependencies-preserved"), + pytest.param("transitive-only", True, id="transitive-only-security-update-preserved"), + pytest.param("canonical-group", True, id="canonical-optional-group-spelling-preserved"), + ], +) +def test_security_updates_cannot_remove_published_direct_dependencies( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_requirements = ["safe-direct>=1", "danger-pkg>=1"] + head_requirements = ["safe-direct>=1"] + base_packages = [("safe-direct", "1"), ("danger-pkg", "1")] + head_packages = [("safe-direct", "1")] + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + + if variant == "runtime-unbounded-removed": + base_requirements = ["safe-direct>=1", "danger-pkg"] + elif variant in {"optional-group-removed", "optional-unbounded-removed"}: + base_requirements = head_requirements = ["safe-direct>=1"] + requirement = "danger-pkg" if variant == "optional-unbounded-removed" else "danger-pkg>=1" + base_groups = {"feature": [requirement]} + head_groups = {} + elif variant == "marker-context-removed": + base_requirements = [ + "safe-direct>=1", + "danger-pkg; python_version < '3.11'", + "danger-pkg; python_version >= '3.11'", + ] + head_requirements = ["safe-direct>=1", "danger-pkg; python_version >= '3.11'"] + head_packages = list(base_packages) + elif variant == "requested-extra-context-removed": + base_requirements = ["safe-direct>=1", "danger-pkg[first]", "danger-pkg[second]"] + head_requirements = ["safe-direct>=1", "danger-pkg[first]"] + head_packages = list(base_packages) + elif variant == "declaration-removed": + base_requirements = ["safe-direct>=1", "danger-pkg", "danger-pkg<3"] + head_requirements = ["safe-direct>=1", "danger-pkg<3"] + head_packages = list(base_packages) + elif variant == "unchanged": + head_requirements = list(base_requirements) + head_packages = list(base_packages) + base_groups = head_groups = {"feature": ["danger-pkg>=1"]} + elif variant == "transitive-only": + head_requirements = list(base_requirements) + base_packages = [*base_packages, ("transitive", "1")] + head_packages = [("safe-direct", "1"), ("danger-pkg", "1"), ("transitive", "1.1")] + elif variant == "canonical-group": + base_requirements = head_requirements = ["safe-direct>=1"] + head_packages = list(base_packages) + base_groups = {"voice_helpers": ["danger-pkg>=1"]} + head_groups = {"voice-helpers": ["danger-pkg>=1"]} + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=base_packages, + head_packages=head_packages, + base_optional_groups=base_groups, + head_optional_groups=head_groups, + ) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if not accepted: + assert "Do not remove a published direct dependency" in result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From 2b1612016f87aa473e6ee52180d6b6cc9dba81d7 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 20:04:56 +0000 Subject: [PATCH 16/26] fix(ci): anchor fork build backends to the trusted base --- .github/workflows/ci.yml | 8 +- .github/workflows/detect-breaking-changes.yml | 12 +- tests/test_uv_workflows.py | 347 +++++++++++++++++- 3 files changed, 358 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ded64ddc4..d6bb004cdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ permissions: env: UV_NO_BUILD: '1' UV_NO_BINARY_PACKAGE: 'openai' + UNTRUSTED_BUILD_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id && '1' || '0' }} + TRUSTED_BUILD_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} jobs: dependency-locks: @@ -34,7 +36,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Require published minimums for direct security updates if: >- @@ -436,7 +438,9 @@ jobs: run: uv sync --locked --all-extras - name: Run build - run: ./scripts/build + run: | + external_packages="$(python -c 'import importlib, re, sys; parser = importlib.import_module("tomllib" if sys.version_info >= (3, 11) else "tomli"); packages = parser.loads(open("uv.lock").read())["package"]; roots = [package for package in packages if package.get("source") == {"editable": "."}]; external = [package for package in packages if package not in roots]; assert len(roots) == 1 and roots[0].get("name") == "openai" and external and all(package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("name"), str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", package["name"]) for package in external), "Refuse malformed external build package restrictions"; denied = sorted({re.sub(r"[-_.]+", "-", package["name"]).lower() for package in external}); assert denied and "openai" not in denied, "Refuse incomplete external build package restrictions"; print(" ".join(denied))')" + env -u UV_NO_BUILD UV_NO_BUILD_PACKAGE="$external_packages" ./scripts/build - name: Validate Python version metadata run: uv run --locked --all-extras python scripts/utils/validate-python-version-wheel.py diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index c7ebe1db4b..f446b530a4 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -14,6 +14,8 @@ permissions: env: UV_NO_BUILD: '1' UV_NO_BINARY_PACKAGE: 'openai' + UNTRUSTED_BUILD_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id && '1' || '0' }} + TRUSTED_BUILD_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} jobs: detect_breaking_changes: @@ -30,7 +32,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -68,7 +70,7 @@ jobs: - name: Verify dependency source provenance before installing tools working-directory: openai-python run: | - python -c 'import ast, pathlib, re, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires), "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -100,8 +102,10 @@ jobs: - name: Install dependencies working-directory: openai-agents-python env: - UV_NO_BINARY_PACKAGE: 'openai openai-agents' - run: make sync + UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp' + run: | + python -c 'import pathlib, re, subprocess, tomllib; origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip(); assert origin in {"https://github.com/openai/openai-agents-python", "https://github.com/openai/openai-agents-python.git"}, "Use only the immutable reviewed Agents aiohttp source"; trusted = tomllib.loads(subprocess.check_output(["git", "show", "HEAD:uv.lock"], text=True)); current = tomllib.loads(pathlib.Path("uv.lock").read_text()); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); approved = [package for package in trusted["package"] if canonical(package.get("name", "")) == "aiohttp"]; actual = [package for package in current["package"] if canonical(package.get("name", "")) == "aiohttp"]; assert len(approved) == len(actual) == 1 and approved[0].get("version") == actual[0].get("version") == "3.12.15" and approved[0].get("source") == actual[0].get("source") == {"registry": "https://pypi.org/simple"} and approved[0].get("sdist") == actual[0].get("sdist") and isinstance(approved[0].get("sdist"), dict) and approved[0]["sdist"].get("url") == "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz" and approved[0]["sdist"].get("hash") == "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", "Use only the immutable reviewed Agents aiohttp source"' + make sync - name: Run integration type checks env: diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 666a623e2f..667f9dd6f0 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -102,6 +102,13 @@ def run_dependency_lock_source_check( uv_overrides: dict[str, object] | None = None, extra_uv_config: str | None = None, project_name: str | None = None, + trusted_fork: bool = False, + trusted_base_requires: list[str] | None = None, + trusted_base_group: list[str] | None = None, + trusted_base_constraints: list[str] | None = None, + trusted_base_backend: str = "hatchling.build", + trusted_base_sha: str = "a" * 40, + trusted_origin: str = "https://github.com/openai/openai-python.git", ) -> subprocess.CompletedProcess[str]: project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] requires = ["hatchling==1.27.0"] if build_requires is None else build_requires @@ -191,9 +198,50 @@ def run_dependency_lock_source_check( lines.append("") (tmp_path / "uv.lock").write_text("\n".join(lines)) + environment = dict( + os.environ, + UNTRUSTED_BUILD_FORK="1" if trusted_fork else "0", + TRUSTED_BUILD_BASE_SHA=trusted_base_sha, + ) + if trusted_fork: + reviewed_requires = ["hatchling==1.27.0"] if trusted_base_requires is None else trusted_base_requires + reviewed_group = ["hatchling==1.27.0"] if trusted_base_group is None else trusted_base_group + reviewed_constraints = ["hatchling==1.27.0"] if trusted_base_constraints is None else trusted_base_constraints + trusted_configuration = ( + "[build-system]\nrequires = " + + json.dumps(reviewed_requires) + + "\nbuild-backend = " + + json.dumps(trusted_base_backend) + + "\n[dependency-groups]\nbuild = " + + json.dumps(reviewed_group) + + "\n[tool.uv]\nbuild-constraint-dependencies = " + + json.dumps(reviewed_constraints) + + "\n" + ) + (tmp_path / "trusted-base.toml").write_text(trusted_configuration) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + f"root = pathlib.Path({str(tmp_path)!r})\n" + f"origin = {trusted_origin!r}\n" + f"sha = {trusted_base_sha!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['fetch', '--no-tags', '--depth=1', 'origin', sha]:\n" + " pass\n" + "elif arguments == ['show', sha + ':pyproject.toml']:\n" + " print((root / 'trusted-base.toml').read_text(), end='')\n" + "else:\n" + " raise SystemExit('Unexpected or unsafe git operation')\n" + ) + fake_git.chmod(0o755) + environment["PATH"] = str(tmp_path) + os.pathsep + environment["PATH"] return subprocess.run( [sys.executable, "-c", dependency_lock_source_command()], cwd=tmp_path, + env=environment, capture_output=True, text=True, check=False, @@ -478,6 +526,130 @@ def test_root_build_requirements_must_be_public_locked_and_reviewed( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed-fork", True, id="fork-keeps-trusted-base-build-pins"), + pytest.param("fork-hatchling-downgrade", False, id="fork-cannot-downgrade-public-hatchling"), + pytest.param("fork-hatchling-upgrade", False, id="fork-cannot-swap-public-hatchling-release"), + pytest.param("fork-build-pin-downgrade", False, id="fork-cannot-change-transitive-backend-pin"), + pytest.param("fork-extra-build-pin", False, id="fork-cannot-add-unreviewed-backend-package"), + pytest.param("fork-marker-change", False, id="fork-cannot-change-reviewed-build-marker"), + pytest.param("fork-marker-literal-case", False, id="fork-cannot-change-case-sensitive-marker"), + pytest.param("fork-backend-change", False, id="fork-backend-must-match-trusted-base"), + pytest.param("fork-invalid-base-sha", False, id="fork-rejects-untrusted-base-sha"), + pytest.param("fork-foreign-origin", False, id="fork-rejects-untrusted-git-origin"), + pytest.param("fork-credential-origin", False, id="fork-rejects-credential-bearing-origin"), + pytest.param("fork-canonical-reorder", True, id="fork-allows-canonical-pins-and-reordering"), + pytest.param("fork-reviewed-base-update", True, id="fork-allows-already-reviewed-base-update"), + pytest.param("trusted-maintainer-update", True, id="same-repo-maintainer-can-update-build-pins"), + pytest.param("trusted-dependabot-update", True, id="same-repo-security-bot-can-update-build-pins"), + ], +) +def test_fork_build_backend_must_match_immutable_reviewed_base(tmp_path: Path, variant: str, accepted: bool) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + hatchling: dict[str, object] = { + "name": "hatchling", + "version": "1.27.0", + "source": {"registry": "https://pypi.org/simple"}, + } + packages: list[dict[str, object]] = [root, hatchling] + requires = ["hatchling==1.27.0"] + group = ["hatchling==1.27.0"] + constraints = ["hatchling==1.27.0"] + base_requires = ["hatchling==1.27.0"] + base_group = ["hatchling==1.27.0"] + base_constraints = ["hatchling==1.27.0"] + base_backend = "hatchling.build" + base_sha = "a" * 40 + origin = "https://github.com/openai/openai-python.git" + fork = not variant.startswith("trusted-") + + if variant in {"fork-hatchling-downgrade", "fork-hatchling-upgrade"}: + version = "1.26.0" if variant == "fork-hatchling-downgrade" else "1.28.0" + hatchling["version"] = version + requires = group = constraints = ["hatchling==" + version] + elif variant in {"fork-build-pin-downgrade", "fork-extra-build-pin", "fork-canonical-reorder"}: + version = "25.0" if variant == "fork-build-pin-downgrade" else "26.3" + packages.append( + { + "name": "packaging", + "version": version, + "source": {"registry": "https://pypi.org/simple"}, + } + ) + if variant == "fork-canonical-reorder": + group = constraints = ["PACKAGING == 26.3", "hatchling==1.27.0"] + else: + group = constraints = ["hatchling==1.27.0", "packaging==" + version] + if variant != "fork-extra-build-pin": + base_group = base_constraints = ["hatchling==1.27.0", "packaging==26.3"] + elif variant in {"fork-marker-change", "fork-marker-literal-case"}: + packages.append( + { + "name": "tomli", + "version": "2.4.1", + "source": {"registry": "https://pypi.org/simple"}, + } + ) + if variant == "fork-marker-change": + head_marker = "python_version < '3.12'" + base_marker = "python_version < '3.11'" + else: + head_marker = "sys_platform == 'linux'" + base_marker = "sys_platform == 'Linux'" + group = constraints = ["hatchling==1.27.0", "tomli==2.4.1; " + head_marker] + base_group = base_constraints = ["hatchling==1.27.0", "tomli==2.4.1; " + base_marker] + elif variant == "fork-backend-change": + base_backend = "reviewed.backend" + elif variant == "fork-invalid-base-sha": + base_sha = "a" * 39 + "Z" + elif variant == "fork-foreign-origin": + origin = "https://github.com/unreviewed/openai-python.git" + elif variant == "fork-credential-origin": + origin = "https://token@github.com/openai/openai-python.git" + elif variant in {"fork-reviewed-base-update", "trusted-maintainer-update", "trusted-dependabot-update"}: + hatchling["version"] = "1.28.0" + requires = group = constraints = ["hatchling==1.28.0"] + if variant == "fork-reviewed-base-update": + base_requires = base_group = base_constraints = ["hatchling==1.28.0"] + + result = run_dependency_lock_source_check( + tmp_path, + packages, + build_requires=requires, + build_group=group, + build_constraints=constraints, + trusted_fork=fork, + trusted_base_requires=base_requires, + trusted_base_group=base_group, + trusted_base_constraints=base_constraints, + trusted_base_backend=base_backend, + trusted_base_sha=base_sha, + trusted_origin=origin, + ) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +def test_fork_build_provenance_uses_immutable_pull_request_identity() -> None: + for name in ("ci.yml", "detect-breaking-changes.yml"): + workflow = (ROOT / ".github/workflows" / name).read_text() + environment = workflow.split("\njobs:\n", 1)[0].rsplit("\nenv:\n", 1)[1] + fork = next(line for line in environment.splitlines() if "UNTRUSTED_BUILD_FORK:" in line) + base = next(line for line in environment.splitlines() if "TRUSTED_BUILD_BASE_SHA:" in line) + assert "github.event_name == 'pull_request'" in fork + assert "github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id" in fork + assert "github.event_name == 'pull_request'" in base + assert "github.event.pull_request.base.sha" in base + assert "github.sha" not in base + + @pytest.mark.parametrize( ("variant", "accepted"), [ @@ -689,7 +861,7 @@ def test_editable_project_sync_requires_only_the_reviewed_root_build_exemption() check=False, ) assert accepted.returncode == 0, accepted.stdout + accepted.stderr - assert "openai @" in accepted.stderr + assert "openai @" in accepted.stderr or "Would make no changes" in accepted.stderr def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() -> None: @@ -699,7 +871,9 @@ def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() - job = match.group("body") trusted_checkout = job.index("repository: openai/openai-agents-python") exception = "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" - assert job.count(exception) == 3 + reviewed_aiohttp = "UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp'" + assert job.count(exception) == 2 + assert job.count(reviewed_aiohttp) == 1 for command in ("uv add ../openai-python", "make sync", "make mypy"): command_index = job.index(command) @@ -710,9 +884,176 @@ def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() - step_end = len(job) step = job[step_start:step_end] assert "working-directory: openai-agents-python" in step - assert exception in step + assert (reviewed_aiohttp if command == "make sync" else exception) in step assert exception not in job[:trusted_checkout] + assert reviewed_aiohttp not in job[:trusted_checkout] + + +def test_explicit_root_build_keeps_every_public_dependency_source_build_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + build = dependency_workflow_jobs()["build"] + match = re.search( + r" - name: Run build\n run: \|\n(?P(?: [^\n]*\n)+)", + build, + ) + assert match is not None + script = "\n".join(line[10:] for line in match.group("body").splitlines()) + assert "env -u UV_NO_BUILD UV_NO_BUILD_PACKAGE=" in script + assert "UV_NO_BUILD=0" not in script + + log = tmp_path / "calls.jsonl" + executable = tmp_path / "uv" + executable.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "entry = {'args': sys.argv[1:], 'no_build': os.environ.get('UV_NO_BUILD'), " + "'no_build_packages': os.environ.get('UV_NO_BUILD_PACKAGE')}\n" + "with open(os.environ['UV_TEST_LOG'], 'a') as output:\n" + " output.write(json.dumps(entry) + '\\n')\n" + "if sys.argv[1] == 'export':\n" + " pathlib.Path(sys.argv[sys.argv.index('--output-file') + 1]).write_text('reviewed\\n')\n" + "if sys.argv[1] == 'build':\n" + " assert '--no-sources' in sys.argv and '--require-hashes' in sys.argv\n" + " assert pathlib.Path(sys.argv[sys.argv.index('--build-constraints') + 1]).read_text() == 'reviewed\\n'\n" + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + monkeypatch.setenv("UV_TEST_LOG", str(log)) + monkeypatch.setenv("UV_NO_BUILD", "1") + + result = subprocess.run(["bash", "-e", "-c", script], cwd=ROOT, capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stdout + result.stderr + calls: list[dict[str, Any]] = [json.loads(line) for line in log.read_text().splitlines()] + assert [call["args"][0] for call in calls] == ["export", "build"] + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + expected = { + re.sub(r"[-_.]+", "-", cast(str, package["name"])).lower() + for package in cast(list[dict[str, object]], lock["package"]) + if package["source"] == {"registry": "https://pypi.org/simple"} + } + assert expected + assert "openai" not in expected + for call in calls: + assert call["no_build"] is None + assert set(cast(str, call["no_build_packages"]).split()) == expected + + +def test_package_scoped_root_build_policy_rejects_real_external_source_distribution(tmp_path: Path) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is not installed") + + environment = dict(os.environ) + environment.pop("UV_NO_BUILD", None) + environment["UV_NO_BUILD_PACKAGE"] = "aiohttp" + environment["UV_NO_BINARY_PACKAGE"] = "openai aiohttp" + environment["UV_PROJECT_ENVIRONMENT"] = str(tmp_path / "isolated") + result = subprocess.run( + [uv, "--no-config", "sync", "--frozen", "--all-extras", "--offline", "--dry-run"], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 + assert "aiohttp" in result.stderr + assert "--no-build" in result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed", True, id="trusted-agents-aiohttp-source"), + pytest.param("version", False, id="agents-aiohttp-version-swapped"), + pytest.param("source", False, id="agents-aiohttp-private-registry"), + pytest.param("url", False, id="agents-aiohttp-source-url-swapped"), + pytest.param("hash", False, id="agents-aiohttp-source-hash-swapped"), + pytest.param("duplicate", False, id="agents-aiohttp-canonical-name-collision"), + pytest.param("trusted-hash", False, id="upstream-aiohttp-source-must-be-reviewed"), + pytest.param("origin", False, id="agents-checkout-origin-must-be-trusted"), + ], +) +def test_agents_aiohttp_source_must_match_immutable_trusted_upstream( + tmp_path: Path, variant: str, accepted: bool +) -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + line = next( + entry + for entry in workflow.splitlines() + if "python -c '" in entry and "Use only the immutable reviewed Agents aiohttp source" in entry + ) + program = line.split("python -c '", 1)[1].rsplit("'", 1)[0] + if sys.version_info < (3, 11): + program = "import sys, tomli; sys.modules['tomllib'] = tomli; " + program + + url = ( + "https://files.pythonhosted.org/packages/9b/e7/" + "d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz" + ) + digest = "4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2" + current_version = "3.12.15" + current_url = url + current_digest = digest + current_registry = "https://pypi.org/simple" + trusted_digest = digest + origin = "https://github.com/openai/openai-agents-python.git" + if variant == "version": + current_version = "3.12.14" + elif variant == "source": + current_registry = "https://private.example/simple" + elif variant == "url": + current_url = "https://unreviewed.example/packages/aiohttp.tar.gz" + elif variant == "hash": + current_digest = "b" * 64 + elif variant == "trusted-hash": + current_digest = trusted_digest = "b" * 64 + elif variant == "origin": + origin = "https://github.com/unreviewed/openai-agents-python.git" + + def lock(name: str, version: str, artifact_url: str, artifact_digest: str, registry: str) -> str: + return ( + "[[package]]\nname = " + + json.dumps(name) + + "\nversion = " + + json.dumps(version) + + "\nsource = { registry = " + + json.dumps(registry) + + " }\nsdist = { url = " + + json.dumps(artifact_url) + + ', hash = "sha256:' + + artifact_digest + + '" }\n' + ) + + current = lock("aiohttp", current_version, current_url, current_digest, current_registry) + if variant == "duplicate": + current += "\n" + lock("AIOHTTP", current_version, current_url, current_digest, current_registry) + (tmp_path / "uv.lock").write_text(current) + trusted = lock("aiohttp", "3.12.15", url, trusted_digest, "https://pypi.org/simple") + (tmp_path / "upstream.lock").write_text(trusted) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + f"root = pathlib.Path({str(tmp_path)!r})\n" + f"origin = {origin!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['show', 'HEAD:uv.lock']:\n" + " print((root / 'upstream.lock').read_text(), end='')\n" + "else:\n" + " raise SystemExit('Unexpected Agents checkout operation')\n" + ) + fake_git.chmod(0o755) + environment = dict(os.environ, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"]) + result = subprocess.run( + [sys.executable, "-c", program], cwd=tmp_path, env=environment, capture_output=True, text=True, check=False + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr @pytest.mark.parametrize("name", ["detect_breaking_changes", "agents_sdk"]) From 4d25ed82e7fd02fa1c9992f32242bbcf37562035 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 21:21:29 +0000 Subject: [PATCH 17/26] fix(ci): require protected floors to reach patched releases --- .github/workflows/ci.yml | 74 +++++++++++++++++- tests/test_uv_workflows.py | 149 +++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6bb004cdd..7b75af0d45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,6 +266,37 @@ jobs: result.append(stable_version(matches[0].strip())) return sorted(result) + def matches_protected_release(requirements, release, *, upper_only=False): + for requirement in requirements: + expression = requirement.split(";", 1)[0] + match = re.fullmatch( + r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression + ) + if match is None: + raise SystemExit("Ambiguous protected security dependency requirement") + accepted = True + for clause in match.group(3).split(","): + if not clause.strip(): + continue + bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) + if bound is None: + raise SystemExit("Ambiguous protected security dependency bound") + operator, value = bound.group(1), stable_version(bound.group(2)) + if upper_only and operator in {">=", ">"}: + continue + if ( + operator == ">=" and release < value + or operator == ">" and release <= value + or operator == "<=" and release > value + or operator == "<" and release >= value + or operator == "==" and release != value + or operator == "!=" and release == value + ): + accepted = False + if accepted: + return True + return False + old_project = read_base("pyproject.toml") old_lock = read_base("uv.lock") new_project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) @@ -280,7 +311,9 @@ jobs: requirements = new_protected.get(name, set()) previous_contexts = old_protected_contexts.get(name, {}) current_contexts = new_protected_contexts.get(name, {}) - if previous == requirements and previous_contexts == current_contexts: + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + if previous == requirements and previous_contexts == current_contexts and previous_domains == current_domains: continue prior_minimums = minimums(previous, allow_missing=True, exact=True) if not prior_minimums: @@ -299,6 +332,45 @@ jobs: updated < previous for previous, updated in zip(context_minimums, updated_context_minimums) ): raise SystemExit("Do not lower a contextual protected security minimum for " + name) + if previous_domains == current_domains: + continue + patched_domains = {} + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + if not introduced or len(introduced) != len(removed) or any( + updated <= previous for previous, updated in zip(removed, introduced) + ): + raise SystemExit("Missing contextual upgraded protected security dependency release for " + name) + patched_domains[domain] = list(zip(removed, introduced)) + for context, prior_requirements in previous_contexts.items(): + prior_context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) + if not prior_context_minimums: + continue + patched = [ + introduced + for domain, upgrades in patched_domains.items() + if marker_overlap(context[3], domain) + for removed, introduced in upgrades + if matches_protected_release(prior_requirements, removed) + ] + if not patched: + continue + context_requirements = current_contexts.get(context, set()) + updated_context_minimums = minimums(context_requirements, exact=True) + if ( + not updated_context_minimums + or any(updated < max(patched) for updated in updated_context_minimums) + or any( + not matches_protected_release(context_requirements, release, upper_only=True) + for release in patched + ) + ): + raise SystemExit("Raise the contextual protected security minimum to the patched release for " + name) for name, previous_contexts in old_contexts.items(): current_contexts = new_contexts.get(name, {}) if any( diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 667f9dd6f0..505da1ccd1 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -2299,6 +2299,155 @@ def test_security_updates_preserve_uv_and_dependency_group_floors(tmp_path: Path assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("constraint-lock-only", False, id="protected-transitive-lock-only-security-update"), + pytest.param("constraint-insufficient", False, id="protected-transitive-floor-below-patched-release"), + pytest.param("constraint-patched", True, id="protected-transitive-floor-reaches-patched-release"), + pytest.param("constraint-higher", True, id="protected-transitive-floor-exceeds-patched-release"), + pytest.param("constraint-upper-blocks", False, id="protected-transitive-upper-bound-excludes-patch"), + pytest.param("constraint-unchanged-lock", True, id="protected-transitive-unchanged-lock-preserved"), + pytest.param("unrelated-transitive", True, id="unrelated-transitive-lock-only-update-preserved"), + pytest.param("unbounded-group", True, id="unbounded-development-group-lock-update-preserved"), + pytest.param("group-lock-only", False, id="development-group-floor-must-reach-patched-release"), + pytest.param("group-patched", True, id="development-group-floor-reaches-patched-release"), + pytest.param("build-pin-lock-only", False, id="build-constraint-pin-must-reach-patched-release"), + pytest.param("build-pin-patched", True, id="build-constraint-pin-reaches-patched-release"), + pytest.param("post-lock-only", False, id="protected-floor-must-reach-stable-post-release"), + pytest.param("post-patched", True, id="protected-floor-reaches-stable-post-release"), + pytest.param("epoch-lock-only", False, id="protected-floor-must-reach-new-epoch-release"), + pytest.param("epoch-patched", True, id="protected-floor-reaches-new-epoch-release"), + pytest.param("downgrade", False, id="protected-locked-release-cannot-downgrade"), + pytest.param("added-release", False, id="protected-added-release-without-prior-line-fails-closed"), + pytest.param("removed-release", False, id="protected-removed-release-without-patch-fails-closed"), + pytest.param("prerelease", False, id="protected-prerelease-patch-fails-closed"), + pytest.param("marker-low-unaffected", True, id="protected-unrelated-marker-line-remains-unchanged"), + pytest.param("marker-high-lock-only", False, id="protected-marker-context-floor-must-reach-patch"), + pytest.param("marker-high-patched", True, id="protected-marker-context-floor-reaches-patch"), + pytest.param("pydantic-v1-lock-only", False, id="protected-pydantic-v1-floor-must-reach-patch"), + pytest.param("pydantic-v1-patched", True, id="protected-pydantic-v1-patch-preserves-v2-line"), + pytest.param("pydantic-v2-patched", True, id="protected-pydantic-v2-patch-preserves-v1-line"), + ], +) +def test_protected_security_floors_must_reach_their_patched_release( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_constraints: list[str] | None = ["cryptography>=50"] + head_constraints: list[str] | None = ["cryptography>=50"] + base_build_constraints: list[str] | None = None + head_build_constraints: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + base_packages = [("cryptography", "50")] + head_packages = [("cryptography", "51")] + base_markers: dict[tuple[str, str], list[str]] | None = None + head_markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "constraint-insufficient": + head_constraints = ["cryptography>=50.1"] + elif variant == "constraint-patched": + head_constraints = ["cryptography>=51"] + elif variant == "constraint-higher": + head_constraints = ["cryptography>=52"] + elif variant == "constraint-upper-blocks": + base_constraints = ["cryptography>=50,<52"] + head_constraints = ["cryptography>=51,<51"] + elif variant == "constraint-unchanged-lock": + head_packages = list(base_packages) + elif variant == "unrelated-transitive": + base_packages.append(("unrelated", "1")) + head_packages = [("cryptography", "50"), ("unrelated", "2")] + elif variant == "unbounded-group": + base_constraints = head_constraints = None + base_groups = head_groups = {"dev": ["ruff"]} + base_packages, head_packages = [("ruff", "1")], [("ruff", "2")] + elif variant in {"group-lock-only", "group-patched"}: + base_constraints = head_constraints = None + base_groups = {"dev": ["pytest>=9"]} + head_groups = {"dev": ["pytest>=10" if variant == "group-patched" else "pytest>=9"]} + base_packages, head_packages = [("pytest", "9")], [("pytest", "10")] + elif variant in {"build-pin-lock-only", "build-pin-patched"}: + base_constraints = head_constraints = None + base_build_constraints = ["hatchling==1.27"] + head_build_constraints = ["hatchling==1.28" if variant == "build-pin-patched" else "hatchling==1.27"] + base_packages, head_packages = [("hatchling", "1.27")], [("hatchling", "1.28")] + elif variant in {"post-lock-only", "post-patched"}: + base_constraints = ["cryptography>=50"] + head_constraints = ["cryptography>=50.post1" if variant == "post-patched" else "cryptography>=50"] + base_packages, head_packages = [("cryptography", "50")], [("cryptography", "50.post1")] + elif variant in {"epoch-lock-only", "epoch-patched"}: + base_constraints = ["cryptography>=0!50"] + head_constraints = ["cryptography>=1!1" if variant == "epoch-patched" else "cryptography>=0!50"] + base_packages, head_packages = [("cryptography", "50")], [("cryptography", "1!1")] + elif variant == "downgrade": + base_constraints, head_constraints = ["cryptography>=50"], ["cryptography>=51"] + base_packages, head_packages = [("cryptography", "52")], [("cryptography", "51")] + elif variant == "added-release": + head_constraints = ["cryptography>=51"] + head_packages = [("cryptography", "50"), ("cryptography", "51")] + elif variant == "removed-release": + head_constraints = ["cryptography>=51"] + base_packages = [("cryptography", "50"), ("cryptography", "51")] + head_packages = [("cryptography", "51")] + elif variant == "prerelease": + head_constraints = ["cryptography>=51"] + head_packages = [("cryptography", "51rc1")] + elif variant.startswith("marker-"): + old_requirement = "cryptography>=50; python_version < '3.11'" + new_requirement = "cryptography>=60; python_version >= '3.11'" + base_constraints = [old_requirement] + head_constraints = [old_requirement] + if variant != "marker-low-unaffected": + base_constraints.append(new_requirement) + head_constraints.append( + "cryptography>=61; python_version >= '3.11'" if variant == "marker-high-patched" else new_requirement + ) + base_packages = [("cryptography", "50"), ("cryptography", "60")] + head_packages = [("cryptography", "50"), ("cryptography", "61")] + base_markers = { + ("cryptography", "50"): ["python_full_version < '3.11'"], + ("cryptography", "60"): ["python_full_version >= '3.11'"], + } + head_markers = { + ("cryptography", "50"): ["python_full_version < '3.11'"], + ("cryptography", "61"): ["python_full_version >= '3.11'"], + } + elif variant.startswith("pydantic-"): + base_constraints = head_constraints = None + base_groups = { + "pydantic-v1": ["pydantic>=1.10,<2"], + "pydantic-v2": ["pydantic>=2,<3"], + } + head_groups = { + "pydantic-v1": ["pydantic>=1.11,<2" if variant == "pydantic-v1-patched" else "pydantic>=1.10,<2"], + "pydantic-v2": ["pydantic>=2.13,<3" if variant == "pydantic-v2-patched" else "pydantic>=2,<3"], + } + base_packages = [("pydantic", "1.10"), ("pydantic", "2.12")] + head_packages = ( + [("pydantic", "1.10"), ("pydantic", "2.13")] + if variant == "pydantic-v2-patched" + else [("pydantic", "1.11"), ("pydantic", "2.12")] + ) + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=base_packages, + head_packages=head_packages, + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build_constraints, + head_build_constraints=head_build_constraints, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def test_security_floor_parser_strips_requirement_whitespace() -> None: assert "stable_version(matches[0].strip())" in security_dependency_floor_program() From f1f729e89600c181b12b68618c5b75218f692cfa Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 21:39:52 +0000 Subject: [PATCH 18/26] fix(ci): preserve marker-specific security floors and trusted source builds --- .github/workflows/ci.yml | 180 ++++++++++++++---- .github/workflows/detect-breaking-changes.yml | 4 +- tests/test_uv_workflows.py | 180 ++++++++++++++++-- 3 files changed, 306 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b75af0d45..52cd1300a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,6 +297,70 @@ jobs: return True return False + def unchanged_nonfloor_bounds(requirement): + expression = requirement.split(";", 1)[0] + match = re.fullmatch( + r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression + ) + if match is None: + raise SystemExit("Ambiguous split security dependency requirement") + bounds = [] + for clause in match.group(3).split(","): + if not clause.strip(): + continue + bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) + if bound is None: + raise SystemExit("Ambiguous split security dependency bound") + operator, value = bound.group(1), stable_version(bound.group(2)) + if operator not in {">=", "=="}: + bounds.append((operator, value)) + return tuple(sorted(bounds)) + + def replacement_contexts(previous_context, previous_requirements, current_contexts, domains, *, exact=False): + current = current_contexts.get(previous_context, set()) + if len(current) >= len(previous_requirements): + return {previous_context: current} + if current or previous_context[3] or len(previous_requirements) != 1: + return {} + original = next(iter(previous_requirements)) + original_minimums = minimums(previous_requirements, allow_missing=True, exact=exact) + if len(original_minimums) != 1: + return {} + replacements = { + context: requirements + for context, requirements in current_contexts.items() + if context[:3] == previous_context[:3] and context[3] + } + if len(replacements) < 2: + return {} + for context, requirements in replacements.items(): + if len(requirements) != 1: + return {} + replacement = next(iter(requirements)) + if unchanged_nonfloor_bounds(replacement) != unchanged_nonfloor_bounds(original): + return {} + replacement_minimums = minimums(requirements, exact=exact) + if len(replacement_minimums) != 1 or replacement_minimums[0] < original_minimums[0]: + return {} + relevant = { + domain + for domain, versions in domains.items() + if any(matches_protected_release(previous_requirements, stable_version(version)) for version in versions) + } + if len(relevant) < 2: + return {} + covered = set() + opposite = {"Eq": "NotEq", "NotEq": "Eq", "Lt": "GtE", "LtE": "Gt", "Gt": "LtE", "GtE": "Lt"} + for domain in relevant: + matched = [context for context in replacements if marker_overlap(context[3], domain)] + if len(matched) != 1: + return {} + for variable, operator, value in matched[0][3]: + if operator not in opposite or marker_overlap(domain, ((variable, opposite[operator], value),)): + return {} + covered.add(matched[0]) + return replacements if covered == set(replacements) else {} + old_project = read_base("pyproject.toml") old_lock = read_base("uv.lock") new_project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) @@ -319,19 +383,33 @@ jobs: if not prior_minimums: continue updated_minimums = minimums(requirements, exact=True) - if len(updated_minimums) != len(prior_minimums) or any( - updated < previous for previous, updated in zip(prior_minimums, updated_minimums) - ): - raise SystemExit("Do not lower a protected dependency security minimum for " + name) + mapped_contexts = {} for context, prior_requirements in previous_contexts.items(): context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) if not context_minimums: continue - updated_context_minimums = minimums(current_contexts.get(context, set()), exact=True) - if len(updated_context_minimums) != len(context_minimums) or any( - updated < previous for previous, updated in zip(context_minimums, updated_context_minimums) - ): + replacements = replacement_contexts( + context, prior_requirements, current_contexts, previous_domains, exact=True + ) + if not replacements: raise SystemExit("Do not lower a contextual protected security minimum for " + name) + mapped_contexts[context] = replacements + for context_requirements in replacements.values(): + updated_context_minimums = minimums(context_requirements, exact=True) + if len(updated_context_minimums) != len(context_minimums) or any( + updated < previous for previous, updated in zip(context_minimums, updated_context_minimums) + ): + raise SystemExit("Do not lower a contextual protected security minimum for " + name) + split = any(context not in replacements for context, replacements in mapped_contexts.items()) + if split: + if set(current_contexts) != { + replacement for replacements in mapped_contexts.values() for replacement in replacements + }: + raise SystemExit("Do not replace a protected security dependency context for " + name) + elif len(updated_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, updated_minimums) + ): + raise SystemExit("Do not lower a protected dependency security minimum for " + name) if previous_domains == current_domains: continue patched_domains = {} @@ -351,33 +429,44 @@ jobs: prior_context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) if not prior_context_minimums: continue - patched = [ - introduced - for domain, upgrades in patched_domains.items() - if marker_overlap(context[3], domain) - for removed, introduced in upgrades - if matches_protected_release(prior_requirements, removed) - ] - if not patched: - continue - context_requirements = current_contexts.get(context, set()) - updated_context_minimums = minimums(context_requirements, exact=True) - if ( - not updated_context_minimums - or any(updated < max(patched) for updated in updated_context_minimums) - or any( - not matches_protected_release(context_requirements, release, upper_only=True) - for release in patched - ) - ): - raise SystemExit("Raise the contextual protected security minimum to the patched release for " + name) + for replacement, context_requirements in mapped_contexts[context].items(): + patched = [ + introduced + for domain, upgrades in patched_domains.items() + if marker_overlap(replacement[3], domain) + for removed, introduced in upgrades + if matches_protected_release(prior_requirements, removed) + ] + if not patched: + continue + updated_context_minimums = minimums(context_requirements, exact=True) + if ( + not updated_context_minimums + or any(updated < max(patched) for updated in updated_context_minimums) + or any( + not matches_protected_release(context_requirements, release, upper_only=True) + for release in patched + ) + ): + raise SystemExit( + "Raise the contextual protected security minimum to the patched release for " + name + ) + direct_replacements = {} for name, previous_contexts in old_contexts.items(): current_contexts = new_contexts.get(name, {}) - if any( - len(current_contexts.get(context, set())) < len(previous) - for context, previous in previous_contexts.items() - ): - raise SystemExit("Do not remove a published direct dependency or its original context for " + name) + mapped_contexts = {} + for context, previous_requirements in previous_contexts.items(): + replacements = replacement_contexts( + context, previous_requirements, current_contexts, old_resolution_contexts.get(name, {}) + ) + if not replacements: + raise SystemExit("Do not remove a published direct dependency or its original context for " + name) + mapped_contexts[context] = replacements + if any(context not in replacements for context, replacements in mapped_contexts.items()) and set( + current_contexts + ) != {replacement for replacements in mapped_contexts.values() for replacement in replacements}: + raise SystemExit("Do not replace a published direct dependency context for " + name) + direct_replacements[name] = mapped_contexts for name, requirements in new_direct.items(): previous = old_direct.get(name, set()) previous_contexts = old_contexts.get(name, {}) @@ -386,19 +475,23 @@ jobs: previous_minimums = minimums(previous, allow_missing=True) if previous_minimums: updated_minimums = minimums(requirements) - if len(updated_minimums) != len(previous_minimums) or any( - updated < previous for previous, updated in zip(previous_minimums, updated_minimums) + mapped_contexts = direct_replacements.get(name, {}) + split = any(context not in replacements for context, replacements in mapped_contexts.items()) + if not split and ( + len(updated_minimums) != len(previous_minimums) + or any(updated < previous for previous, updated in zip(previous_minimums, updated_minimums)) ): raise SystemExit("Do not lower a published security-fixed minimum for " + name) for context, prior_requirements in previous_contexts.items(): prior_minimums = minimums(prior_requirements, allow_missing=True) if not prior_minimums: continue - context_minimums = minimums(current_contexts.get(context, set())) - if len(context_minimums) != len(prior_minimums) or any( - updated < previous for previous, updated in zip(prior_minimums, context_minimums) - ): - raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) + for context_requirements in mapped_contexts[context].values(): + context_minimums = minimums(context_requirements) + if len(context_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, context_minimums) + ): + raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) previous_domains = old_resolution_contexts.get(name, {}) current_domains = new_resolution_contexts.get(name, {}) if ( @@ -435,7 +528,12 @@ jobs: covered.update(domains) patched_minimum = max(patched_domains[domain] for domain in domains) updated_minimums = minimums(context_requirements) - previous_minimums = minimums(previous_contexts.get(context, set()), allow_missing=True) + original_context = next( + (original for original, replacements in direct_replacements.get(name, {}).items() + if context in replacements), + context, + ) + previous_minimums = minimums(previous_contexts.get(original_context, set()), allow_missing=True) if ( not updated_minimums or any(updated < patched_minimum for updated in updated_minimums) diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index f446b530a4..bac470e31a 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -102,9 +102,9 @@ jobs: - name: Install dependencies working-directory: openai-agents-python env: - UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp' + UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp markupsafe pyyaml evdev' run: | - python -c 'import pathlib, re, subprocess, tomllib; origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip(); assert origin in {"https://github.com/openai/openai-agents-python", "https://github.com/openai/openai-agents-python.git"}, "Use only the immutable reviewed Agents aiohttp source"; trusted = tomllib.loads(subprocess.check_output(["git", "show", "HEAD:uv.lock"], text=True)); current = tomllib.loads(pathlib.Path("uv.lock").read_text()); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); approved = [package for package in trusted["package"] if canonical(package.get("name", "")) == "aiohttp"]; actual = [package for package in current["package"] if canonical(package.get("name", "")) == "aiohttp"]; assert len(approved) == len(actual) == 1 and approved[0].get("version") == actual[0].get("version") == "3.12.15" and approved[0].get("source") == actual[0].get("source") == {"registry": "https://pypi.org/simple"} and approved[0].get("sdist") == actual[0].get("sdist") and isinstance(approved[0].get("sdist"), dict) and approved[0]["sdist"].get("url") == "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz" and approved[0]["sdist"].get("hash") == "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", "Use only the immutable reviewed Agents aiohttp source"' + python -c 'import pathlib, re, subprocess, tomllib; origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip(); assert origin in {"https://github.com/openai/openai-agents-python", "https://github.com/openai/openai-agents-python.git"}, "Use only the immutable reviewed Agents source distributions"; trusted = tomllib.loads(subprocess.check_output(["git", "show", "HEAD:uv.lock"], text=True)); current = tomllib.loads(pathlib.Path("uv.lock").read_text()); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); reviewed = {"aiohttp": ("3.12.15", "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"), "markupsafe": ("3.0.2", "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"), "pyyaml": ("6.0.2", "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"), "evdev": ("1.9.2", "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069")}; assert all(len(approved := [entry for entry in trusted["package"] if canonical(entry.get("name", "")) == name]) == 1 and len(actual := [entry for entry in current["package"] if canonical(entry.get("name", "")) == name]) == 1 and approved[0].get("version") == actual[0].get("version") == version and approved[0].get("source") == actual[0].get("source") == {"registry": "https://pypi.org/simple"} and isinstance(approved[0].get("sdist"), dict) and approved[0].get("sdist") == actual[0].get("sdist") and approved[0]["sdist"].get("url") == url and approved[0]["sdist"].get("hash") == digest for name, (version, url, digest) in reviewed.items()), "Use only the immutable reviewed Agents source distributions"' make sync - name: Run integration type checks diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 505da1ccd1..8bd6b5a26a 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -861,7 +861,6 @@ def test_editable_project_sync_requires_only_the_reviewed_root_build_exemption() check=False, ) assert accepted.returncode == 0, accepted.stdout + accepted.stderr - assert "openai @" in accepted.stderr or "Would make no changes" in accepted.stderr def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() -> None: @@ -871,7 +870,7 @@ def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() - job = match.group("body") trusted_checkout = job.index("repository: openai/openai-agents-python") exception = "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" - reviewed_aiohttp = "UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp'" + reviewed_aiohttp = "UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp markupsafe pyyaml evdev'" assert job.count(exception) == 2 assert job.count(reviewed_aiohttp) == 1 @@ -976,32 +975,52 @@ def test_package_scoped_root_build_policy_rejects_real_external_source_distribut pytest.param("origin", False, id="agents-checkout-origin-must-be-trusted"), ], ) +@pytest.mark.parametrize("package", ["aiohttp", "markupsafe", "pyyaml", "evdev"]) def test_agents_aiohttp_source_must_match_immutable_trusted_upstream( - tmp_path: Path, variant: str, accepted: bool + tmp_path: Path, variant: str, accepted: bool, package: str ) -> None: workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() line = next( entry for entry in workflow.splitlines() - if "python -c '" in entry and "Use only the immutable reviewed Agents aiohttp source" in entry + if "python -c '" in entry and "Use only the immutable reviewed Agents source distributions" in entry ) program = line.split("python -c '", 1)[1].rsplit("'", 1)[0] if sys.version_info < (3, 11): program = "import sys, tomli; sys.modules['tomllib'] = tomli; " + program - url = ( - "https://files.pythonhosted.org/packages/9b/e7/" - "d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz" - ) - digest = "4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2" - current_version = "3.12.15" + reviewed = { + "aiohttp": ( + "3.12.15", + "9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", + "4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", + ), + "markupsafe": ( + "3.0.2", + "b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", + "ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", + ), + "pyyaml": ( + "6.0.2", + "54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", + "d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", + ), + "evdev": ( + "1.9.2", + "63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", + "5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069", + ), + } + version, artifact_path, digest = reviewed[package] + url = "https://files.pythonhosted.org/packages/" + artifact_path + current_version = version current_url = url current_digest = digest current_registry = "https://pypi.org/simple" trusted_digest = digest origin = "https://github.com/openai/openai-agents-python.git" if variant == "version": - current_version = "3.12.14" + current_version = "0.0.1" elif variant == "source": current_registry = "https://private.example/simple" elif variant == "url": @@ -1028,12 +1047,27 @@ def lock(name: str, version: str, artifact_url: str, artifact_digest: str, regis + '" }\n' ) - current = lock("aiohttp", current_version, current_url, current_digest, current_registry) + current_packages: list[str] = [] + trusted_packages: list[str] = [] + for name, (reviewed_version, reviewed_path, reviewed_digest) in reviewed.items(): + reviewed_url = "https://files.pythonhosted.org/packages/" + reviewed_path + if name == package: + current_packages.append(lock(name, current_version, current_url, current_digest, current_registry)) + trusted_packages.append( + lock(name, reviewed_version, reviewed_url, trusted_digest, "https://pypi.org/simple") + ) + else: + current_packages.append( + lock(name, reviewed_version, reviewed_url, reviewed_digest, "https://pypi.org/simple") + ) + trusted_packages.append( + lock(name, reviewed_version, reviewed_url, reviewed_digest, "https://pypi.org/simple") + ) + current = "\n".join(current_packages) if variant == "duplicate": - current += "\n" + lock("AIOHTTP", current_version, current_url, current_digest, current_registry) + current += "\n" + lock(package.upper(), current_version, current_url, current_digest, current_registry) (tmp_path / "uv.lock").write_text(current) - trusted = lock("aiohttp", "3.12.15", url, trusted_digest, "https://pypi.org/simple") - (tmp_path / "upstream.lock").write_text(trusted) + (tmp_path / "upstream.lock").write_text("\n".join(trusted_packages)) fake_git = tmp_path / "git" fake_git.write_text( f"#!{sys.executable}\n" @@ -2196,6 +2230,122 @@ def test_security_floors_preserve_original_optional_contexts( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("direct-both", True, id="both-actual-numpy-extra-groups-split-by-python-version"), + pytest.param("direct-high-only", True, id="unchanged-python310-line-does-not-need-artificial-bump"), + pytest.param("direct-low-insufficient", False, id="python310-split-floor-must-reach-its-own-patch"), + pytest.param("direct-high-insufficient", False, id="newer-python-split-floor-must-reach-its-own-patch"), + pytest.param("direct-gap", False, id="split-cannot-drop-python310-resolution-domain"), + pytest.param("direct-overlap", False, id="split-cannot-overlap-one-resolution-domain"), + pytest.param("direct-partial-domain", False, id="split-cannot-cover-only-part-of-original-domain"), + pytest.param("direct-moved-group", False, id="split-cannot-move-published-optional-group"), + pytest.param("direct-upper-removed", False, id="split-cannot-remove-original-upper-bound"), + pytest.param("direct-exclusion-removed", False, id="split-cannot-remove-original-excluded-release"), + pytest.param("direct-original-lowered", False, id="split-cannot-lower-original-unchanged-branch"), + pytest.param("protected-constraint", True, id="protected-constraint-splits-by-resolution-domain"), + pytest.param("protected-group", True, id="protected-development-group-splits-by-resolution-domain"), + pytest.param("protected-insufficient", False, id="protected-split-floor-must-reach-its-own-patch"), + pytest.param("protected-gap", False, id="protected-split-cannot-drop-python310-domain"), + pytest.param("protected-upper-removed", False, id="protected-split-preserves-original-upper-bound"), + ], +) +def test_security_floors_can_safely_split_original_unmarked_resolution_domains( + tmp_path: Path, variant: str, accepted: bool +) -> None: + low_marker = "python_version < '3.11'" + high_marker = "python_version >= '3.11'" + base_packages = [("numpy", "2.2.6"), ("numpy", "2.4.6")] + head_packages = [("numpy", "2.2.7"), ("numpy", "2.4.7")] + base_markers = { + ("numpy", "2.2.6"): ["python_full_version < '3.11'"], + ("numpy", "2.4.6"): [ + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform != 'linux'", + ], + } + head_markers = { + ("numpy", "2.2.7"): ["python_full_version < '3.11'"], + ("numpy", "2.4.7"): [ + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform != 'linux'", + ], + } + original = "numpy>=1,<3" + low = "numpy>=2.2.7,<3; " + low_marker + high = "numpy>=2.4.7,<3; " + high_marker + base_optional = { + "datalib": [original], + "voice_helpers": ["numpy>=2.0.2,<3"], + } + head_optional = { + "datalib": [low, high], + "voice_helpers": [low, high], + } + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + + if variant == "direct-high-only": + head_packages[0] = ("numpy", "2.2.6") + head_markers.pop(("numpy", "2.2.7")) + head_markers[("numpy", "2.2.6")] = ["python_full_version < '3.11'"] + head_optional["datalib"][0] = "numpy>=1,<3; " + low_marker + head_optional["voice_helpers"][0] = "numpy>=2.0.2,<3; " + low_marker + elif variant == "direct-low-insufficient": + head_optional["datalib"][0] = "numpy>=2.2.6,<3; " + low_marker + elif variant == "direct-high-insufficient": + head_optional["voice_helpers"][1] = "numpy>=2.4.6,<3; " + high_marker + elif variant == "direct-gap": + head_optional["datalib"] = [high] + elif variant == "direct-overlap": + head_optional["datalib"][0] = "numpy>=2.4.7,<3; python_version < '3.12'" + elif variant == "direct-partial-domain": + head_optional["datalib"][0] = "numpy>=2.2.7,<3; python_version < '3.11' and sys_platform == 'linux'" + elif variant == "direct-moved-group": + head_optional["moved"] = head_optional.pop("datalib") + elif variant == "direct-upper-removed": + head_optional["datalib"][0] = "numpy>=2.2.7; " + low_marker + elif variant == "direct-exclusion-removed": + base_optional["datalib"] = ["numpy>=1,<3,!=2.3"] + elif variant == "direct-original-lowered": + head_packages[0] = ("numpy", "2.2.6") + head_markers.pop(("numpy", "2.2.7")) + head_markers[("numpy", "2.2.6")] = ["python_full_version < '3.11'"] + head_optional["voice_helpers"][0] = "numpy>=1,<3; " + low_marker + elif variant.startswith("protected-"): + base_optional = head_optional = {} + if variant == "protected-group": + base_groups, head_groups = {"dev": [original]}, {"dev": [low, high]} + else: + base_constraints, head_constraints = [original], [low, high] + if variant == "protected-insufficient": + head_constraints[0] = "numpy>=2.2.6,<3; " + low_marker + elif variant == "protected-gap": + head_constraints = [high] + elif variant == "protected-upper-removed": + head_constraints[0] = "numpy>=2.2.7; " + low_marker + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=base_packages, + head_packages=head_packages, + base_optional_groups=base_optional, + head_optional_groups=head_optional, + base_constraints=base_constraints, + head_constraints=head_constraints, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From eb134b8c2feade129bda9a8b6e646bf9a091bce6 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 22:07:47 +0000 Subject: [PATCH 19/26] fix(ci): defer source builds and support dependency marker membership --- .github/workflows/ci.yml | 68 ++++- .github/workflows/detect-breaking-changes.yml | 8 +- tests/test_uv_workflows.py | 267 +++++++++++++++++- 3 files changed, 331 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52cd1300a9..693e256d92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,7 +178,7 @@ jobs: post = -1 if match.group(3) is None else int(match.group(3)) return int(match.group(1) or 0), release, post - def marker_overlap(requirement, resolution): + def simple_marker_overlap(requirement, resolution): clauses = {} for variable, operator, value in requirement + resolution: family = "python" if variable in {"python_version", "python_full_version"} else variable @@ -251,6 +251,61 @@ jobs: return False return True + def marker_options(context): + options = [()] + allowed_platforms = { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "extra", + } + for variable, operator, value in context: + if operator not in {"In", "NotIn"}: + options = [option + ((variable, operator, value),) for option in options] + continue + if variable == "python_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable == "python_full_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable in allowed_platforms: + pattern = r"[A-Za-z0-9][A-Za-z0-9_.-]*" + else: + raise SystemExit("Unsupported security dependency membership marker variable") + values = tuple(item.strip() for item in value.split(",")) + if ( + not values + or len(values) > 16 + or len(set(values)) != len(values) + or any(not re.fullmatch(pattern, item) for item in values) + or any( + first in second or second in first + for index, first in enumerate(values) + for second in values[index + 1:] + ) + ): + raise SystemExit("Ambiguous security dependency membership marker") + if operator == "In": + options = [ + option + ((variable, "Eq", member),) + for option in options + for member in values + ] + else: + exclusions = tuple((variable, "NotEq", member) for member in values) + options = [option + exclusions for option in options] + if len(options) > 64: + raise SystemExit("Ambiguous security dependency membership marker") + return options + + def marker_overlap(requirement, resolution): + requirements, resolutions = marker_options(requirement), marker_options(resolution) + if len(requirements) * len(resolutions) > 128: + raise SystemExit("Ambiguous security dependency membership marker") + return any(simple_marker_overlap(left, right) for left in requirements for right in resolutions) + def minimums(requirements, *, allow_missing=False, exact=False): result = [] for requirement in requirements: @@ -350,7 +405,16 @@ jobs: if len(relevant) < 2: return {} covered = set() - opposite = {"Eq": "NotEq", "NotEq": "Eq", "Lt": "GtE", "LtE": "Gt", "Gt": "LtE", "GtE": "Lt"} + opposite = { + "Eq": "NotEq", + "NotEq": "Eq", + "Lt": "GtE", + "LtE": "Gt", + "Gt": "LtE", + "GtE": "Lt", + "In": "NotIn", + "NotIn": "In", + } for domain in relevant: matched = [context for context in replacements if marker_overlap(context[3], domain)] if len(matched) != 1: diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index bac470e31a..f93d7ef3a1 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -97,15 +97,15 @@ jobs: working-directory: openai-agents-python env: UV_NO_BINARY_PACKAGE: 'openai openai-agents' - run: uv add ../openai-python + run: uv add --no-sync ../openai-python - name: Install dependencies working-directory: openai-agents-python env: - UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp markupsafe pyyaml evdev' + UV_NO_BINARY_PACKAGE: 'openai openai-agents' run: | - python -c 'import pathlib, re, subprocess, tomllib; origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip(); assert origin in {"https://github.com/openai/openai-agents-python", "https://github.com/openai/openai-agents-python.git"}, "Use only the immutable reviewed Agents source distributions"; trusted = tomllib.loads(subprocess.check_output(["git", "show", "HEAD:uv.lock"], text=True)); current = tomllib.loads(pathlib.Path("uv.lock").read_text()); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); reviewed = {"aiohttp": ("3.12.15", "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"), "markupsafe": ("3.0.2", "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"), "pyyaml": ("6.0.2", "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"), "evdev": ("1.9.2", "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069")}; assert all(len(approved := [entry for entry in trusted["package"] if canonical(entry.get("name", "")) == name]) == 1 and len(actual := [entry for entry in current["package"] if canonical(entry.get("name", "")) == name]) == 1 and approved[0].get("version") == actual[0].get("version") == version and approved[0].get("source") == actual[0].get("source") == {"registry": "https://pypi.org/simple"} and isinstance(approved[0].get("sdist"), dict) and approved[0].get("sdist") == actual[0].get("sdist") and approved[0]["sdist"].get("url") == url and approved[0]["sdist"].get("hash") == digest for name, (version, url, digest) in reviewed.items()), "Use only the immutable reviewed Agents source distributions"' - make sync + reviewed_sources="$(python -c 'import pathlib, re, subprocess, tomllib; origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip(); assert origin in {"https://github.com/openai/openai-agents-python", "https://github.com/openai/openai-agents-python.git"}, "Use only the immutable reviewed Agents source distributions"; trusted = tomllib.loads(subprocess.check_output(["git", "show", "HEAD:uv.lock"], text=True)); current = tomllib.loads(pathlib.Path("uv.lock").read_text()); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); reviewed = {"aiohttp": ("3.12.15", "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"), "markupsafe": ("3.0.2", "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"), "pyyaml": ("6.0.2", "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"), "evdev": ("1.9.2", "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069")}; public = {"registry": "https://pypi.org/simple"}; approved = {name: [entry for entry in trusted["package"] if canonical(entry.get("name", "")) == name] for name in reviewed}; actual = {name: [entry for entry in current["package"] if canonical(entry.get("name", "")) == name] for name in reviewed}; safe = lambda artifact: isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")); assert all(len(approved[name]) == 1 and approved[name][0].get("version") == version and approved[name][0].get("source") == public and isinstance(approved[name][0].get("sdist"), dict) and approved[name][0]["sdist"].get("url") == url and approved[name][0]["sdist"].get("hash") == digest for name, (version, url, digest) in reviewed.items()), "Use only the immutable reviewed Agents source distributions"; assert all(len(actual[name]) <= 1 and (not actual[name] or actual[name][0].get("source") == public and (actual[name][0].get("version") == version and actual[name][0].get("sdist") == approved[name][0].get("sdist") or actual[name][0].get("version") != version and isinstance(wheels := actual[name][0].get("wheels"), list) and bool(wheels) and all(safe(artifact) for artifact in ([actual[name][0]["sdist"]] if "sdist" in actual[name][0] else []) + wheels) and all(wheel["url"].endswith(".whl") for wheel in wheels))) for name, (version, _, _) in reviewed.items()), "Use only the immutable reviewed Agents source distributions"; print(" ".join(name for name, (version, _, _) in reviewed.items() if actual[name] and actual[name][0].get("version") == version and actual[name][0].get("sdist") == approved[name][0].get("sdist")))')" + UV_NO_BINARY_PACKAGE="openai openai-agents ${reviewed_sources}" make sync - name: Run integration type checks env: diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 8bd6b5a26a..b29593171c 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -870,11 +870,11 @@ def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() - job = match.group("body") trusted_checkout = job.index("repository: openai/openai-agents-python") exception = "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" - reviewed_aiohttp = "UV_NO_BINARY_PACKAGE: 'openai openai-agents aiohttp markupsafe pyyaml evdev'" - assert job.count(exception) == 2 + reviewed_aiohttp = 'UV_NO_BINARY_PACKAGE="openai openai-agents ${reviewed_sources}" make sync' + assert job.count(exception) == 3 assert job.count(reviewed_aiohttp) == 1 - for command in ("uv add ../openai-python", "make sync", "make mypy"): + for command in ("uv add --no-sync ../openai-python", "make sync", "make mypy"): command_index = job.index(command) assert command_index > trusted_checkout step_start = job.rfind("\n - ", 0, command_index) @@ -883,12 +883,62 @@ def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() - step_end = len(job) step = job[step_start:step_end] assert "working-directory: openai-agents-python" in step - assert (reviewed_aiohttp if command == "make sync" else exception) in step + assert exception in step + if command == "make sync": + assert reviewed_aiohttp in step assert exception not in job[:trusted_checkout] assert reviewed_aiohttp not in job[:trusted_checkout] +def test_agents_link_only_relocks_before_reviewed_source_distributions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + job = workflow.split("\n agents_sdk:\n", 1)[1] + link = job.split(" - name: Link to local SDK\n", 1)[1].split("\n - name:", 1)[0] + match = re.search(r"^ run: (.+)$", link, re.MULTILINE) + assert match is not None + command = match.group(1) + assert command == "uv add --no-sync ../openai-python" + assert "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" in link + assert "aiohttp" not in link + assert job.index(command) < job.index("Use only the immutable reviewed Agents source distributions") + assert job.index("Use only the immutable reviewed Agents source distributions") < job.index("make sync") + + executable = tmp_path / "uv" + uv = shutil.which("uv") + if uv is not None: + supported = subprocess.run([uv, "add", "--help"], capture_output=True, text=True, check=False) + assert supported.returncode == 0, supported.stdout + supported.stderr + assert "--no-sync" in supported.stdout + assert "Avoid syncing the virtual environment" in supported.stdout + + executable.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "root = pathlib.Path(os.environ['UV_TEST_ROOT'])\n" + "if '--no-sync' not in sys.argv:\n" + " (root / 'environment-synced').write_text('unreviewed install')\n" + " raise SystemExit('unsafe environment sync before source validation')\n" + "(root / 'relocked.json').write_text(json.dumps({'args': sys.argv[1:], " + "'no_build': os.environ.get('UV_NO_BUILD'), " + "'no_binary': os.environ.get('UV_NO_BINARY_PACKAGE')}))\n" + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + monkeypatch.setenv("UV_TEST_ROOT", str(tmp_path)) + monkeypatch.setenv("UV_NO_BUILD", "1") + monkeypatch.setenv("UV_NO_BINARY_PACKAGE", "openai openai-agents") + result = subprocess.run(["bash", "-e", "-c", command], cwd=tmp_path, capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stdout + result.stderr + assert not (tmp_path / "environment-synced").exists() + relocked = cast(dict[str, object], json.loads((tmp_path / "relocked.json").read_text())) + assert relocked["args"] == ["add", "--no-sync", "../openai-python"] + assert relocked["no_build"] == "1" + assert relocked["no_binary"] == "openai openai-agents" + + def test_explicit_root_build_keeps_every_public_dependency_source_build_disabled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -967,6 +1017,12 @@ def test_package_scoped_root_build_policy_rejects_real_external_source_distribut [ pytest.param("reviewed", True, id="trusted-agents-aiohttp-source"), pytest.param("version", False, id="agents-aiohttp-version-swapped"), + pytest.param("wheel-upgrade", True, id="changed-reviewed-name-is-wheel-only-without-source-exemption"), + pytest.param("wheel-url", False, id="changed-reviewed-name-rejects-nonpublic-wheel"), + pytest.param("wheel-hash", False, id="changed-reviewed-name-rejects-invalid-wheel-hash"), + pytest.param("wheel-sdist-url", False, id="changed-reviewed-name-rejects-nonpublic-source-artifact"), + pytest.param("wheel-sdist-hash", False, id="changed-reviewed-name-rejects-invalid-source-hash"), + pytest.param("removed", True, id="removed-reviewed-name-receives-no-source-exemption"), pytest.param("source", False, id="agents-aiohttp-private-registry"), pytest.param("url", False, id="agents-aiohttp-source-url-swapped"), pytest.param("hash", False, id="agents-aiohttp-source-hash-swapped"), @@ -1019,10 +1075,27 @@ def test_agents_aiohttp_source_must_match_immutable_trusted_upstream( current_registry = "https://pypi.org/simple" trusted_digest = digest origin = "https://github.com/openai/openai-agents-python.git" + current_wheel_url: str | None = None + current_wheel_digest = "c" * 64 if variant == "version": current_version = "0.0.1" elif variant == "source": current_registry = "https://private.example/simple" + elif variant in {"wheel-upgrade", "wheel-url", "wheel-hash", "wheel-sdist-url", "wheel-sdist-hash"}: + current_version = "3.14.3" if package == "aiohttp" else "9.0.0" + current_url = "https://files.pythonhosted.org/packages/aa/bb/" + package + "-" + current_version + ".tar.gz" + current_digest = "a" * 64 + current_wheel_url = ( + "https://files.pythonhosted.org/packages/aa/bb/" + package + "-" + current_version + "-py3-none-any.whl" + ) + if variant == "wheel-url": + current_wheel_url = "https://private.example/packages/" + package + ".whl" + if variant == "wheel-hash": + current_wheel_digest = "invalid" + if variant == "wheel-sdist-url": + current_url = "https://private.example/packages/" + package + ".tar.gz" + if variant == "wheel-sdist-hash": + current_digest = "invalid" elif variant == "url": current_url = "https://unreviewed.example/packages/aiohttp.tar.gz" elif variant == "hash": @@ -1032,7 +1105,14 @@ def test_agents_aiohttp_source_must_match_immutable_trusted_upstream( elif variant == "origin": origin = "https://github.com/unreviewed/openai-agents-python.git" - def lock(name: str, version: str, artifact_url: str, artifact_digest: str, registry: str) -> str: + def lock( + name: str, + version: str, + artifact_url: str, + artifact_digest: str, + registry: str, + wheel_url: str | None = None, + ) -> str: return ( "[[package]]\nname = " + json.dumps(name) @@ -1045,6 +1125,11 @@ def lock(name: str, version: str, artifact_url: str, artifact_digest: str, regis + ', hash = "sha256:' + artifact_digest + '" }\n' + + ( + "wheels = [{ url = " + json.dumps(wheel_url) + ', hash = "sha256:' + current_wheel_digest + '" }]\n' + if wheel_url is not None + else "" + ) ) current_packages: list[str] = [] @@ -1052,7 +1137,10 @@ def lock(name: str, version: str, artifact_url: str, artifact_digest: str, regis for name, (reviewed_version, reviewed_path, reviewed_digest) in reviewed.items(): reviewed_url = "https://files.pythonhosted.org/packages/" + reviewed_path if name == package: - current_packages.append(lock(name, current_version, current_url, current_digest, current_registry)) + if variant != "removed": + current_packages.append( + lock(name, current_version, current_url, current_digest, current_registry, current_wheel_url) + ) trusted_packages.append( lock(name, reviewed_version, reviewed_url, trusted_digest, "https://pypi.org/simple") ) @@ -1088,6 +1176,11 @@ def lock(name: str, version: str, artifact_url: str, artifact_digest: str, regis [sys.executable, "-c", program], cwd=tmp_path, env=environment, capture_output=True, text=True, check=False ) assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if accepted: + expected = set(reviewed) + if variant in {"wheel-upgrade", "removed"}: + expected.remove(package) + assert set(result.stdout.split()) == expected @pytest.mark.parametrize("name", ["detect_breaking_changes", "agents_sdk"]) @@ -2230,11 +2323,162 @@ def test_security_floors_preserve_original_optional_contexts( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "direct_accepted", "protected_accepted"), + [ + pytest.param( + "python_version in '3.10, 3.11'", + "python_full_version == '3.10.*'", + True, + True, + id="python-membership-first-release", + ), + pytest.param( + "python_version in '3.10, 3.11'", + "python_full_version == '3.11.*'", + True, + True, + id="python-membership-second-release", + ), + pytest.param( + "python_version in '3.10, 3.11'", + "python_full_version >= '3.12'", + False, + True, + id="python-membership-excludes-other-release", + ), + pytest.param( + "python_version not in '3.10, 3.11'", + "python_full_version == '3.12.*'", + True, + True, + id="python-negative-membership-allows-other-release", + ), + pytest.param( + "python_version not in '3.10, 3.11'", + "python_full_version == '3.10.*'", + False, + True, + id="python-negative-membership-excludes-listed-release", + ), + pytest.param( + "python_full_version in '3.10.4, 3.11.2'", + "python_full_version == '3.10.4'", + True, + True, + id="full-python-version-membership", + ), + pytest.param( + "sys_platform in 'linux, darwin'", + "sys_platform == 'linux'", + True, + True, + id="platform-membership-matches-reviewed-linux", + ), + pytest.param( + "sys_platform not in 'win32, darwin'", + "sys_platform == 'linux'", + True, + True, + id="negative-platform-membership-matches-reviewed-linux", + ), + pytest.param( + "platform_system in 'Linux, Darwin'", + "platform_system == 'linux'", + False, + True, + id="platform-membership-preserves-quoted-case", + ), + pytest.param( + "python_version >= '3.10'", + "python_version in '3.10, 3.11'", + True, + True, + id="resolution-domain-membership-also-supported", + ), + pytest.param( + "python_version in '3.10,,3.11'", + "python_full_version == '3.10.*'", + False, + False, + id="empty-membership-token-fails-closed", + ), + pytest.param( + "python_version in '3.10, 3.10'", + "python_full_version == '3.10.*'", + False, + False, + id="duplicate-membership-token-fails-closed", + ), + pytest.param( + "python_version in '3.1, 3.10'", + "python_full_version == '3.10.*'", + False, + False, + id="substring-ambiguous-membership-token-fails-closed", + ), + pytest.param( + "python_version in '3.10, beta'", + "python_full_version == '3.10.*'", + False, + False, + id="noncanonical-python-membership-token-fails-closed", + ), + pytest.param( + "sys_platform in 'win, win32'", + "sys_platform == 'win32'", + False, + False, + id="substring-ambiguous-platform-membership-fails-closed", + ), + pytest.param( + "unsupported_platform in 'linux'", + "sys_platform == 'linux'", + False, + False, + id="unknown-membership-variable-fails-closed", + ), + pytest.param( + "python_version in '3.10' or sys_platform == 'linux'", + "python_full_version == '3.10.*'", + False, + False, + id="source-level-or-membership-fails-closed", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_security_marker_membership_overlaps_are_safe_and_precise( + tmp_path: Path, + requirement_marker: str, + resolution_marker: str, + direct_accepted: bool, + protected_accepted: bool, + protected: bool, +) -> None: + base = "danger>=1; " + requirement_marker + head = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [base], + head_requirements=[] if protected else [head], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[base] if protected else None, + head_constraints=[head] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + accepted = protected_accepted if protected else direct_accepted + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ pytest.param("direct-both", True, id="both-actual-numpy-extra-groups-split-by-python-version"), pytest.param("direct-high-only", True, id="unchanged-python310-line-does-not-need-artificial-bump"), + pytest.param("direct-membership-split", True, id="published-split-membership-complements-cover-all-domains"), pytest.param("direct-low-insufficient", False, id="python310-split-floor-must-reach-its-own-patch"), pytest.param("direct-high-insufficient", False, id="newer-python-split-floor-must-reach-its-own-patch"), pytest.param("direct-gap", False, id="split-cannot-drop-python310-resolution-domain"), @@ -2246,6 +2490,7 @@ def test_security_floors_preserve_original_optional_contexts( pytest.param("direct-original-lowered", False, id="split-cannot-lower-original-unchanged-branch"), pytest.param("protected-constraint", True, id="protected-constraint-splits-by-resolution-domain"), pytest.param("protected-group", True, id="protected-development-group-splits-by-resolution-domain"), + pytest.param("protected-membership-split", True, id="protected-split-membership-complements-cover-all-domains"), pytest.param("protected-insufficient", False, id="protected-split-floor-must-reach-its-own-patch"), pytest.param("protected-gap", False, id="protected-split-cannot-drop-python310-domain"), pytest.param("protected-upper-removed", False, id="protected-split-preserves-original-upper-bound"), @@ -2288,6 +2533,16 @@ def test_security_floors_can_safely_split_original_unmarked_resolution_domains( base_groups: dict[str, list[str]] | None = None head_groups: dict[str, list[str]] | None = None + if variant in {"direct-membership-split", "protected-membership-split"}: + base_markers[("numpy", "2.2.6")] = ["python_full_version == '3.10.*'"] + head_markers[("numpy", "2.2.7")] = ["python_full_version == '3.10.*'"] + low = "numpy>=2.2.7,<3; python_version in '3.10'" + high = "numpy>=2.4.7,<3; python_version not in '3.10'" + head_optional = { + "datalib": [low, high], + "voice_helpers": [low, high], + } + if variant == "direct-high-only": head_packages[0] = ("numpy", "2.2.6") head_markers.pop(("numpy", "2.2.7")) From 9071f2b5c9bca890f28c28c489a9fa593ce69810 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 23:06:36 +0000 Subject: [PATCH 20/26] fix: preserve supported dependency security branches --- .github/workflows/ci.yml | 567 +-------------- .github/workflows/detect-breaking-changes.yml | 2 + scripts/check-dependency-security.py | 652 ++++++++++++++++++ tests/test_uv_workflows.py | 108 ++- 4 files changed, 759 insertions(+), 570 deletions(-) create mode 100644 scripts/check-dependency-security.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 693e256d92..daf8c38351 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,572 +45,7 @@ jobs: && contains(github.event.pull_request.head.ref, 'python-security') env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - python - <<'PY' - import ast - import io - import os - import pathlib - import re - import subprocess - import tomllib - import tokenize - - base = os.environ.get("BASE_SHA", "") - if not re.fullmatch(r"[0-9a-f]{40}", base): - raise SystemExit("Untrusted security-update base commit") - origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() - if origin not in { - "https://github.com/openai/openai-python", - "https://github.com/openai/openai-python.git", - "git@github.com:openai/openai-python.git", - "ssh://git@github.com/openai/openai-python.git", - }: - raise SystemExit("Untrusted security-update comparison origin") - subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) - - def read_base(path): - return tomllib.loads(subprocess.check_output(["git", "show", base + ":" + path], text=True)) - - def canonical(name): - return re.sub(r"[-_.]+", "-", name).lower() - - def marker_context(marker): - if not marker.strip(): - return () - try: - if any( - token.type == tokenize.OP and token.string in {"(", ")"} - for token in tokenize.generate_tokens(io.StringIO(marker).readline) - ): - raise ValueError("Parenthesized security dependency marker") - expression = ast.parse(marker.strip(), mode="eval").body - except (SyntaxError, tokenize.TokenError, ValueError): - raise SystemExit("Ambiguous direct security dependency marker") - if isinstance(expression, ast.BoolOp): - if not isinstance(expression.op, ast.And): - raise SystemExit("Ambiguous direct security dependency marker") - parts = expression.values - else: - parts = [expression] - result = [] - for part in parts: - if ( - not isinstance(part, ast.Compare) - or not isinstance(part.left, ast.Name) - or len(part.ops) != 1 - or len(part.comparators) != 1 - or not isinstance(part.comparators[0], ast.Constant) - or not isinstance(part.comparators[0].value, str) - or type(part.ops[0]) - not in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} - ): - raise SystemExit("Ambiguous direct security dependency marker") - result.append((part.left.id.lower(), type(part.ops[0]).__name__, part.comparators[0].value)) - return tuple(sorted(result)) - - def direct(project, *, protected=False): - if protected: - uv = project.get("tool", {}).get("uv", {}) - groups = [ - ("uv-constraint", "", uv.get("constraint-dependencies", [])), - ("uv-build-constraint", "", uv.get("build-constraint-dependencies", [])), - ] - groups.extend( - ("dependency-group", canonical(group), requirements) - for group, requirements in project.get("dependency-groups", {}).items() - ) - else: - groups = [("runtime", "", project["project"].get("dependencies", []))] - groups.extend( - ("optional", canonical(group), requirements) - for group, requirements in project["project"].get("optional-dependencies", {}).items() - ) - result, contexts = {}, {} - for scope, group, requirements in groups: - for requirement in requirements: - match = re.fullmatch( - r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", requirement - ) - if match is None: - raise SystemExit("Ambiguous direct security dependency requirement") - name = canonical(match.group(1)) - extra = match.group(2) - requested = () - if extra: - requested = tuple(sorted(canonical(value.strip()) for value in extra[1:-1].split(","))) - if any(not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value) for value in requested): - raise SystemExit("Ambiguous direct security dependency extras") - context = (scope, group, requested, marker_context(match.group(3).partition(";")[2])) - normalized = ( - name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() - ) - result.setdefault(name, set()).add(normalized) - contexts.setdefault(name, {}).setdefault(context, set()).add(normalized) - return result, contexts - - def versions(lock): - result, contexts = {}, {} - for package in lock["package"]: - name = canonical(package["name"]) - version = package["version"] - result.setdefault(name, set()).add(version) - markers = package.get("resolution-markers") - if markers is None: - domains = [()] - else: - if not isinstance(markers, list) or not markers or any( - not isinstance(marker, str) for marker in markers - ): - raise SystemExit("Ambiguous locked security dependency resolution marker") - domains = [marker_context(marker) for marker in markers] - for domain in domains: - contexts.setdefault(name, {}).setdefault(domain, set()).add(version) - return result, contexts - - def stable_version(value): - match = re.fullmatch(r"(?:(\d+)!)?(\d+(?:\.\d+)*)(?:\.post(\d+))?", value) - if match is None: - raise SystemExit("Unsupported direct security dependency minimum") - release = tuple(int(part) for part in match.group(2).split(".")) - while release and release[-1] == 0: - release = release[:-1] - post = -1 if match.group(3) is None else int(match.group(3)) - return int(match.group(1) or 0), release, post - - def simple_marker_overlap(requirement, resolution): - clauses = {} - for variable, operator, value in requirement + resolution: - family = "python" if variable in {"python_version", "python_full_version"} else variable - clauses.setdefault(family, []).append((variable, operator, value)) - for family, constraints in clauses.items(): - if family == "python": - lower, upper, excluded = (0, 0, 0), None, [] - for variable, operator, value in constraints: - match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?(\.\*)?", value) - if match is None or ( - variable == "python_version" and match.group(3) is not None - ): - raise SystemExit("Ambiguous Python security dependency marker") - major, minor = int(match.group(1)), int(match.group(2)) - patch = int(match.group(3) or 0) - wildcard = match.group(4) is not None - if wildcard and operator not in {"Eq", "NotEq"}: - raise SystemExit("Ambiguous wildcard security dependency marker") - start = (major, minor, patch) - stop = ( - (major, minor + 1, 0) - if variable == "python_version" or wildcard - else (major, minor, patch + 1) - ) - if operator == "Eq": - lower = max(lower, start) - upper = stop if upper is None else min(upper, stop) - elif operator == "NotEq": - excluded.append((start, stop)) - elif operator == "Lt": - upper = start if upper is None else min(upper, start) - elif operator == "LtE": - upper = stop if upper is None else min(upper, stop) - elif operator == "Gt": - lower = max(lower, stop) - elif operator == "GtE": - lower = max(lower, start) - else: - raise SystemExit("Ambiguous Python security dependency marker") - if upper is not None and lower >= upper: - return False - candidate = lower - for start, stop in sorted(excluded): - if start <= candidate < stop: - candidate = stop - if upper is not None and candidate >= upper: - return False - else: - if family not in { - "sys_platform", - "os_name", - "platform_system", - "platform_machine", - "platform_python_implementation", - "implementation_name", - "extra", - }: - raise SystemExit("Unsupported security dependency marker variable") - equality, exclusions = None, set() - for _, operator, value in constraints: - if operator == "Eq": - if equality is not None and equality != value: - return False - equality = value - elif operator == "NotEq": - exclusions.add(value) - else: - raise SystemExit("Ambiguous platform security dependency marker") - if equality is not None and equality in exclusions: - return False - return True - - def marker_options(context): - options = [()] - allowed_platforms = { - "sys_platform", - "os_name", - "platform_system", - "platform_machine", - "platform_python_implementation", - "implementation_name", - "extra", - } - for variable, operator, value in context: - if operator not in {"In", "NotIn"}: - options = [option + ((variable, operator, value),) for option in options] - continue - if variable == "python_version": - pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" - elif variable == "python_full_version": - pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" - elif variable in allowed_platforms: - pattern = r"[A-Za-z0-9][A-Za-z0-9_.-]*" - else: - raise SystemExit("Unsupported security dependency membership marker variable") - values = tuple(item.strip() for item in value.split(",")) - if ( - not values - or len(values) > 16 - or len(set(values)) != len(values) - or any(not re.fullmatch(pattern, item) for item in values) - or any( - first in second or second in first - for index, first in enumerate(values) - for second in values[index + 1:] - ) - ): - raise SystemExit("Ambiguous security dependency membership marker") - if operator == "In": - options = [ - option + ((variable, "Eq", member),) - for option in options - for member in values - ] - else: - exclusions = tuple((variable, "NotEq", member) for member in values) - options = [option + exclusions for option in options] - if len(options) > 64: - raise SystemExit("Ambiguous security dependency membership marker") - return options - - def marker_overlap(requirement, resolution): - requirements, resolutions = marker_options(requirement), marker_options(resolution) - if len(requirements) * len(resolutions) > 128: - raise SystemExit("Ambiguous security dependency membership marker") - return any(simple_marker_overlap(left, right) for left in requirements for right in resolutions) - - def minimums(requirements, *, allow_missing=False, exact=False): - result = [] - for requirement in requirements: - specifier = requirement.split(";", 1)[0] - pattern = ( - r"(?=!~])(?:>=|==)([^,;]+)" if exact else r"(?=!~])>=([^,;]+)" - ) - matches = re.findall(pattern, specifier) - if len(matches) != 1: - if allow_missing and not matches: - continue - raise SystemExit("Missing or ambiguous direct security dependency minimum") - result.append(stable_version(matches[0].strip())) - return sorted(result) - - def matches_protected_release(requirements, release, *, upper_only=False): - for requirement in requirements: - expression = requirement.split(";", 1)[0] - match = re.fullmatch( - r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression - ) - if match is None: - raise SystemExit("Ambiguous protected security dependency requirement") - accepted = True - for clause in match.group(3).split(","): - if not clause.strip(): - continue - bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) - if bound is None: - raise SystemExit("Ambiguous protected security dependency bound") - operator, value = bound.group(1), stable_version(bound.group(2)) - if upper_only and operator in {">=", ">"}: - continue - if ( - operator == ">=" and release < value - or operator == ">" and release <= value - or operator == "<=" and release > value - or operator == "<" and release >= value - or operator == "==" and release != value - or operator == "!=" and release == value - ): - accepted = False - if accepted: - return True - return False - - def unchanged_nonfloor_bounds(requirement): - expression = requirement.split(";", 1)[0] - match = re.fullmatch( - r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression - ) - if match is None: - raise SystemExit("Ambiguous split security dependency requirement") - bounds = [] - for clause in match.group(3).split(","): - if not clause.strip(): - continue - bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) - if bound is None: - raise SystemExit("Ambiguous split security dependency bound") - operator, value = bound.group(1), stable_version(bound.group(2)) - if operator not in {">=", "=="}: - bounds.append((operator, value)) - return tuple(sorted(bounds)) - - def replacement_contexts(previous_context, previous_requirements, current_contexts, domains, *, exact=False): - current = current_contexts.get(previous_context, set()) - if len(current) >= len(previous_requirements): - return {previous_context: current} - if current or previous_context[3] or len(previous_requirements) != 1: - return {} - original = next(iter(previous_requirements)) - original_minimums = minimums(previous_requirements, allow_missing=True, exact=exact) - if len(original_minimums) != 1: - return {} - replacements = { - context: requirements - for context, requirements in current_contexts.items() - if context[:3] == previous_context[:3] and context[3] - } - if len(replacements) < 2: - return {} - for context, requirements in replacements.items(): - if len(requirements) != 1: - return {} - replacement = next(iter(requirements)) - if unchanged_nonfloor_bounds(replacement) != unchanged_nonfloor_bounds(original): - return {} - replacement_minimums = minimums(requirements, exact=exact) - if len(replacement_minimums) != 1 or replacement_minimums[0] < original_minimums[0]: - return {} - relevant = { - domain - for domain, versions in domains.items() - if any(matches_protected_release(previous_requirements, stable_version(version)) for version in versions) - } - if len(relevant) < 2: - return {} - covered = set() - opposite = { - "Eq": "NotEq", - "NotEq": "Eq", - "Lt": "GtE", - "LtE": "Gt", - "Gt": "LtE", - "GtE": "Lt", - "In": "NotIn", - "NotIn": "In", - } - for domain in relevant: - matched = [context for context in replacements if marker_overlap(context[3], domain)] - if len(matched) != 1: - return {} - for variable, operator, value in matched[0][3]: - if operator not in opposite or marker_overlap(domain, ((variable, opposite[operator], value),)): - return {} - covered.add(matched[0]) - return replacements if covered == set(replacements) else {} - - old_project = read_base("pyproject.toml") - old_lock = read_base("uv.lock") - new_project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) - new_lock = tomllib.loads(pathlib.Path("uv.lock").read_text()) - old_direct, old_contexts = direct(old_project) - new_direct, new_contexts = direct(new_project) - old_versions, old_resolution_contexts = versions(old_lock) - new_versions, new_resolution_contexts = versions(new_lock) - old_protected, old_protected_contexts = direct(old_project, protected=True) - new_protected, new_protected_contexts = direct(new_project, protected=True) - for name, previous in old_protected.items(): - requirements = new_protected.get(name, set()) - previous_contexts = old_protected_contexts.get(name, {}) - current_contexts = new_protected_contexts.get(name, {}) - previous_domains = old_resolution_contexts.get(name, {}) - current_domains = new_resolution_contexts.get(name, {}) - if previous == requirements and previous_contexts == current_contexts and previous_domains == current_domains: - continue - prior_minimums = minimums(previous, allow_missing=True, exact=True) - if not prior_minimums: - continue - updated_minimums = minimums(requirements, exact=True) - mapped_contexts = {} - for context, prior_requirements in previous_contexts.items(): - context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) - if not context_minimums: - continue - replacements = replacement_contexts( - context, prior_requirements, current_contexts, previous_domains, exact=True - ) - if not replacements: - raise SystemExit("Do not lower a contextual protected security minimum for " + name) - mapped_contexts[context] = replacements - for context_requirements in replacements.values(): - updated_context_minimums = minimums(context_requirements, exact=True) - if len(updated_context_minimums) != len(context_minimums) or any( - updated < previous for previous, updated in zip(context_minimums, updated_context_minimums) - ): - raise SystemExit("Do not lower a contextual protected security minimum for " + name) - split = any(context not in replacements for context, replacements in mapped_contexts.items()) - if split: - if set(current_contexts) != { - replacement for replacements in mapped_contexts.values() for replacement in replacements - }: - raise SystemExit("Do not replace a protected security dependency context for " + name) - elif len(updated_minimums) != len(prior_minimums) or any( - updated < previous for previous, updated in zip(prior_minimums, updated_minimums) - ): - raise SystemExit("Do not lower a protected dependency security minimum for " + name) - if previous_domains == current_domains: - continue - patched_domains = {} - for domain in previous_domains.keys() | current_domains.keys(): - prior_versions = previous_domains.get(domain, set()) - updated_versions = current_domains.get(domain, set()) - if prior_versions == updated_versions: - continue - introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) - removed = sorted(stable_version(version) for version in prior_versions - updated_versions) - if not introduced or len(introduced) != len(removed) or any( - updated <= previous for previous, updated in zip(removed, introduced) - ): - raise SystemExit("Missing contextual upgraded protected security dependency release for " + name) - patched_domains[domain] = list(zip(removed, introduced)) - for context, prior_requirements in previous_contexts.items(): - prior_context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) - if not prior_context_minimums: - continue - for replacement, context_requirements in mapped_contexts[context].items(): - patched = [ - introduced - for domain, upgrades in patched_domains.items() - if marker_overlap(replacement[3], domain) - for removed, introduced in upgrades - if matches_protected_release(prior_requirements, removed) - ] - if not patched: - continue - updated_context_minimums = minimums(context_requirements, exact=True) - if ( - not updated_context_minimums - or any(updated < max(patched) for updated in updated_context_minimums) - or any( - not matches_protected_release(context_requirements, release, upper_only=True) - for release in patched - ) - ): - raise SystemExit( - "Raise the contextual protected security minimum to the patched release for " + name - ) - direct_replacements = {} - for name, previous_contexts in old_contexts.items(): - current_contexts = new_contexts.get(name, {}) - mapped_contexts = {} - for context, previous_requirements in previous_contexts.items(): - replacements = replacement_contexts( - context, previous_requirements, current_contexts, old_resolution_contexts.get(name, {}) - ) - if not replacements: - raise SystemExit("Do not remove a published direct dependency or its original context for " + name) - mapped_contexts[context] = replacements - if any(context not in replacements for context, replacements in mapped_contexts.items()) and set( - current_contexts - ) != {replacement for replacements in mapped_contexts.values() for replacement in replacements}: - raise SystemExit("Do not replace a published direct dependency context for " + name) - direct_replacements[name] = mapped_contexts - for name, requirements in new_direct.items(): - previous = old_direct.get(name, set()) - previous_contexts = old_contexts.get(name, {}) - current_contexts = new_contexts.get(name, {}) - if previous != requirements or previous_contexts != current_contexts: - previous_minimums = minimums(previous, allow_missing=True) - if previous_minimums: - updated_minimums = minimums(requirements) - mapped_contexts = direct_replacements.get(name, {}) - split = any(context not in replacements for context, replacements in mapped_contexts.items()) - if not split and ( - len(updated_minimums) != len(previous_minimums) - or any(updated < previous for previous, updated in zip(previous_minimums, updated_minimums)) - ): - raise SystemExit("Do not lower a published security-fixed minimum for " + name) - for context, prior_requirements in previous_contexts.items(): - prior_minimums = minimums(prior_requirements, allow_missing=True) - if not prior_minimums: - continue - for context_requirements in mapped_contexts[context].values(): - context_minimums = minimums(context_requirements) - if len(context_minimums) != len(prior_minimums) or any( - updated < previous for previous, updated in zip(prior_minimums, context_minimums) - ): - raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) - previous_domains = old_resolution_contexts.get(name, {}) - current_domains = new_resolution_contexts.get(name, {}) - if ( - old_versions.get(name, set()) == new_versions.get(name, set()) - and previous_domains == current_domains - ): - continue - if previous == requirements: - raise SystemExit("Raise the published security-fixed minimum for " + name) - patched_domains = {} - for domain in previous_domains.keys() | current_domains.keys(): - prior_versions = previous_domains.get(domain, set()) - updated_versions = current_domains.get(domain, set()) - if prior_versions == updated_versions: - continue - introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) - removed = sorted(stable_version(version) for version in prior_versions - updated_versions) - if not introduced or len(introduced) != len(removed) or any( - updated <= previous for previous, updated in zip(removed, introduced) - ): - raise SystemExit("Missing contextual upgraded security dependency release for " + name) - patched_domains[domain] = introduced[-1] - if not patched_domains: - raise SystemExit("Missing upgraded direct security dependency release for " + name) - covered = set() - for context, context_requirements in current_contexts.items(): - domains = { - domain - for domain in patched_domains - if marker_overlap(context[3], domain) - } - if not domains: - continue - covered.update(domains) - patched_minimum = max(patched_domains[domain] for domain in domains) - updated_minimums = minimums(context_requirements) - original_context = next( - (original for original, replacements in direct_replacements.get(name, {}).items() - if context in replacements), - context, - ) - previous_minimums = minimums(previous_contexts.get(original_context, set()), allow_missing=True) - if ( - not updated_minimums - or any(updated < patched_minimum for updated in updated_minimums) - or previous_minimums - and ( - len(updated_minimums) != len(previous_minimums) - or updated_minimums[0] <= previous_minimums[0] - ) - ): - raise SystemExit("Raise the contextual security-fixed minimum for " + name) - if covered != set(patched_domains): - raise SystemExit("Raise the published security-fixed minimum for " + name) - PY + run: python scripts/check-dependency-security.py - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index f93d7ef3a1..e57e332a90 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -91,6 +91,7 @@ jobs: with: persist-credentials: false repository: openai/openai-agents-python + ref: 7e55afc9500d12937687988f1e91e900dcb4ad09 path: openai-agents-python - name: Link to local SDK @@ -110,5 +111,6 @@ jobs: - name: Run integration type checks env: UV_NO_BINARY_PACKAGE: 'openai openai-agents' + UV_NO_SYNC: '1' working-directory: openai-agents-python run: make mypy diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py new file mode 100644 index 0000000000..aada8853da --- /dev/null +++ b/scripts/check-dependency-security.py @@ -0,0 +1,652 @@ +from __future__ import annotations + +import io +import os +import re +import ast +import pathlib +import tokenize +import importlib +import subprocess +from typing import Any, TypeAlias, cast + +MarkerClause: TypeAlias = tuple[str, str, str] +MarkerContext: TypeAlias = tuple[MarkerClause, ...] +StableRelease: TypeAlias = tuple[int, tuple[int, ...], int] +DependencyContext: TypeAlias = tuple[str, str, tuple[str, ...], MarkerContext] +RequirementMap: TypeAlias = dict[str, set[str]] +ContextRequirements: TypeAlias = dict[DependencyContext, set[str]] +ContextsByName: TypeAlias = dict[str, ContextRequirements] +ContextReplacements: TypeAlias = dict[DependencyContext, ContextRequirements] +ResolutionDomains: TypeAlias = dict[MarkerContext, set[str]] +ResolutionsByName: TypeAlias = dict[str, ResolutionDomains] +tomllib: Any = importlib.import_module("tomllib") + +base = os.environ.get("BASE_SHA", "") +if not re.fullmatch(r"[0-9a-f]{40}", base): + raise SystemExit("Untrusted security-update base commit") +origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() +if origin not in { + "https://github.com/openai/openai-python", + "https://github.com/openai/openai-python.git", + "git@github.com:openai/openai-python.git", + "ssh://git@github.com/openai/openai-python.git", +}: + raise SystemExit("Untrusted security-update comparison origin") +subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) + + +def read_base(path: str) -> dict[str, Any]: + return cast(dict[str, Any], tomllib.loads(subprocess.check_output(["git", "show", base + ":" + path], text=True))) + + +def canonical(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def marker_context(marker: str) -> MarkerContext: + if not marker.strip(): + return () + try: + if any( + token.type == tokenize.OP and token.string in {"(", ")"} + for token in tokenize.generate_tokens(io.StringIO(marker).readline) + ): + raise ValueError("Parenthesized security dependency marker") + expression = ast.parse(marker.strip(), mode="eval").body + except (SyntaxError, tokenize.TokenError, ValueError): + raise SystemExit("Ambiguous direct security dependency marker") from None + if isinstance(expression, ast.BoolOp): + if not isinstance(expression.op, ast.And): + raise SystemExit("Ambiguous direct security dependency marker") + parts = expression.values + else: + parts = [expression] + result: list[MarkerClause] = [] + for part in parts: + if ( + not isinstance(part, ast.Compare) + or not isinstance(part.left, ast.Name) + or len(part.ops) != 1 + or len(part.comparators) != 1 + or not isinstance(part.comparators[0], ast.Constant) + or not isinstance(part.comparators[0].value, str) + or type(part.ops[0]) not in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} + ): + raise SystemExit("Ambiguous direct security dependency marker") + result.append((part.left.id.lower(), type(part.ops[0]).__name__, part.comparators[0].value)) + return tuple(sorted(result)) + + +def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[RequirementMap, ContextsByName]: + if protected: + uv = project.get("tool", {}).get("uv", {}) + groups = [ + ("uv-constraint", "", uv.get("constraint-dependencies", [])), + ("uv-build-constraint", "", uv.get("build-constraint-dependencies", [])), + ] + groups.extend( + ("dependency-group", canonical(group), requirements) + for group, requirements in project.get("dependency-groups", {}).items() + ) + else: + groups = [("runtime", "", project["project"].get("dependencies", []))] + groups.extend( + ("optional", canonical(group), requirements) + for group, requirements in project["project"].get("optional-dependencies", {}).items() + ) + result: RequirementMap = {} + contexts: ContextsByName = {} + for scope, group, requirements in groups: + for requirement in requirements: + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", requirement) + if match is None: + raise SystemExit("Ambiguous direct security dependency requirement") + name = canonical(match.group(1)) + extra = match.group(2) + requested = () + if extra: + requested = tuple(sorted(canonical(value.strip()) for value in extra[1:-1].split(","))) + if any(not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value) for value in requested): + raise SystemExit("Ambiguous direct security dependency extras") + context = (scope, group, requested, marker_context(match.group(3).partition(";")[2])) + normalized = name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() + result.setdefault(name, set()).add(normalized) + contexts.setdefault(name, {}).setdefault(context, set()).add(normalized) + return result, contexts + + +def versions(lock: dict[str, Any]) -> tuple[RequirementMap, ResolutionsByName]: + result: RequirementMap = {} + contexts: ResolutionsByName = {} + for package in lock["package"]: + name = canonical(package["name"]) + version = package["version"] + result.setdefault(name, set()).add(version) + markers = package.get("resolution-markers") + if markers is None: + domains = [()] + else: + if ( + not isinstance(markers, list) + or not markers + or any(not isinstance(marker, str) for marker in cast(list[object], markers)) + ): + raise SystemExit("Ambiguous locked security dependency resolution marker") + domains = [marker_context(marker) for marker in cast(list[str], markers)] + for domain in domains: + contexts.setdefault(name, {}).setdefault(domain, set()).add(version) + return result, contexts + + +def stable_version(value: str) -> StableRelease: + match = re.fullmatch(r"(?:(\d+)!)?(\d+(?:\.\d+)*)(?:\.post(\d+))?", value) + if match is None: + raise SystemExit("Unsupported direct security dependency minimum") + release = tuple(int(part) for part in match.group(2).split(".")) + while release and release[-1] == 0: + release = release[:-1] + post = -1 if match.group(3) is None else int(match.group(3)) + return int(match.group(1) or 0), release, post + + +def simple_marker_overlap(requirement: MarkerContext, resolution: MarkerContext) -> bool: + clauses: dict[str, list[MarkerClause]] = {} + for variable, operator, value in requirement + resolution: + family = "python" if variable in {"python_version", "python_full_version"} else variable + clauses.setdefault(family, []).append((variable, operator, value)) + for family, constraints in clauses.items(): + if family == "python": + lower: tuple[int, int, int] = (0, 0, 0) + upper: tuple[int, int, int] | None = None + excluded: list[tuple[tuple[int, int, int], tuple[int, int, int]]] = [] + for variable, operator, value in constraints: + match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?(\.\*)?", value) + if match is None or (variable == "python_version" and match.group(3) is not None): + raise SystemExit("Ambiguous Python security dependency marker") + major, minor = int(match.group(1)), int(match.group(2)) + patch = int(match.group(3) or 0) + wildcard = match.group(4) is not None + if wildcard and operator not in {"Eq", "NotEq"}: + raise SystemExit("Ambiguous wildcard security dependency marker") + start = (major, minor, patch) + stop = (major, minor + 1, 0) if variable == "python_version" or wildcard else (major, minor, patch + 1) + if operator == "Eq": + lower = max(lower, start) + upper = stop if upper is None else min(upper, stop) + elif operator == "NotEq": + excluded.append((start, stop)) + elif operator == "Lt": + upper = start if upper is None else min(upper, start) + elif operator == "LtE": + upper = stop if upper is None else min(upper, stop) + elif operator == "Gt": + lower = max(lower, stop) + elif operator == "GtE": + lower = max(lower, start) + else: + raise SystemExit("Ambiguous Python security dependency marker") + if upper is not None and lower >= upper: + return False + candidate = lower + for start, stop in sorted(excluded): + if start <= candidate < stop: + candidate = stop + if upper is not None and candidate >= upper: + return False + else: + if family not in { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "extra", + }: + raise SystemExit("Unsupported security dependency marker variable") + equality: str | None = None + exclusions: set[str] = set() + for _, operator, value in constraints: + if operator == "Eq": + if equality is not None and equality != value: + return False + equality = value + elif operator == "NotEq": + exclusions.add(value) + else: + raise SystemExit("Ambiguous platform security dependency marker") + if equality is not None and equality in exclusions: + return False + return True + + +def marker_options(context: MarkerContext) -> list[MarkerContext]: + options: list[MarkerContext] = [()] + allowed_platforms = { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "extra", + } + for variable, operator, value in context: + if operator not in {"In", "NotIn"}: + options = [option + ((variable, operator, value),) for option in options] + continue + if variable == "python_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable == "python_full_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable in allowed_platforms: + pattern = r"[A-Za-z0-9][A-Za-z0-9_.-]*" + else: + raise SystemExit("Unsupported security dependency membership marker variable") + values = tuple(item.strip() for item in value.split(",")) + if ( + not values + or len(values) > 16 + or len(set(values)) != len(values) + or any(not re.fullmatch(pattern, item) for item in values) + or any( + first in second or second in first + for index, first in enumerate(values) + for second in values[index + 1 :] + ) + ): + raise SystemExit("Ambiguous security dependency membership marker") + if operator == "In": + options = [option + ((variable, "Eq", member),) for option in options for member in values] + else: + exclusions = tuple((variable, "NotEq", member) for member in values) + options = [option + exclusions for option in options] + if len(options) > 64: + raise SystemExit("Ambiguous security dependency membership marker") + return options + + +def marker_overlap(requirement: MarkerContext, resolution: MarkerContext) -> bool: + requirements, resolutions = marker_options(requirement), marker_options(resolution) + if len(requirements) * len(resolutions) > 128: + raise SystemExit("Ambiguous security dependency membership marker") + return any(simple_marker_overlap(left, right) for left in requirements for right in resolutions) + + +def minimums(requirements: set[str], *, allow_missing: bool = False, exact: bool = False) -> list[StableRelease]: + result: list[StableRelease] = [] + for requirement in requirements: + specifier = requirement.split(";", 1)[0] + pattern = r"(?=!~])(?:>=|==)([^,;]+)" if exact else r"(?=!~])>=([^,;]+)" + matches = re.findall(pattern, specifier) + if len(matches) != 1: + if allow_missing and not matches: + continue + raise SystemExit("Missing or ambiguous direct security dependency minimum") + result.append(stable_version(matches[0].strip())) + return sorted(result) + + +def matches_protected_release(requirements: set[str], release: StableRelease, *, upper_only: bool = False) -> bool: + for requirement in requirements: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous protected security dependency requirement") + accepted = True + for clause in match.group(3).split(","): + if not clause.strip(): + continue + bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) + if bound is None: + raise SystemExit("Ambiguous protected security dependency bound") + operator, value = bound.group(1), stable_version(bound.group(2)) + if upper_only and operator in {">=", ">"}: + continue + if ( + operator == ">=" + and release < value + or operator == ">" + and release <= value + or operator == "<=" + and release > value + or operator == "<" + and release >= value + or operator == "==" + and release != value + or operator == "!=" + and release == value + ): + accepted = False + if accepted: + return True + return False + + +def unchanged_nonfloor_bounds(requirement: str) -> tuple[tuple[str, StableRelease], ...]: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous split security dependency requirement") + bounds: list[tuple[str, StableRelease]] = [] + for clause in match.group(3).split(","): + if not clause.strip(): + continue + bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) + if bound is None: + raise SystemExit("Ambiguous split security dependency bound") + operator, value = bound.group(1), stable_version(bound.group(2)) + if operator not in {">=", "=="}: + bounds.append((operator, value)) + return tuple(sorted(bounds)) + + +def replacement_contexts( + previous_context: DependencyContext, + previous_requirements: set[str], + current_contexts: ContextRequirements, + domains: ResolutionDomains, + *, + exact: bool = False, +) -> ContextRequirements: + current = current_contexts.get(previous_context, set()) + if len(current) >= len(previous_requirements): + return {previous_context: current} + if current or previous_context[3] or len(previous_requirements) != 1: + return {} + original = next(iter(previous_requirements)) + original_minimums = minimums(previous_requirements, allow_missing=True, exact=exact) + if len(original_minimums) != 1: + return {} + replacements = { + context: requirements + for context, requirements in current_contexts.items() + if context[:3] == previous_context[:3] and context[3] + } + if len(replacements) < 2: + return {} + for requirements in replacements.values(): + if len(requirements) != 1: + return {} + replacement = next(iter(requirements)) + if unchanged_nonfloor_bounds(replacement) != unchanged_nonfloor_bounds(original): + return {} + replacement_minimums = minimums(requirements, exact=exact) + if len(replacement_minimums) != 1 or replacement_minimums[0] < original_minimums[0]: + return {} + relevant = { + domain + for domain, versions in domains.items() + if any(matches_protected_release(previous_requirements, stable_version(version)) for version in versions) + } + if len(relevant) < 2: + return {} + covered: set[DependencyContext] = set() + opposite = { + "Eq": "NotEq", + "NotEq": "Eq", + "Lt": "GtE", + "LtE": "Gt", + "Gt": "LtE", + "GtE": "Lt", + "In": "NotIn", + "NotIn": "In", + } + for domain in relevant: + matched = [context for context in replacements if marker_overlap(context[3], domain)] + if len(matched) != 1: + return {} + for variable, operator, value in matched[0][3]: + if operator not in opposite or marker_overlap(domain, ((variable, opposite[operator], value),)): + return {} + covered.add(matched[0]) + return replacements if covered == set(replacements) else {} + + +def preserves_supported_security_branches( + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, + previous_contexts: ContextRequirements, + current_contexts: ContextRequirements, +) -> bool: + observed = False + for domain in previous_domains.keys() | current_domains.keys(): + previous_versions = previous_domains.get(domain, set()) + current_versions = current_domains.get(domain, set()) + if previous_versions == current_versions: + continue + removed = sorted(stable_version(version) for version in previous_versions - current_versions) + introduced = sorted(stable_version(version) for version in current_versions - previous_versions) + unchanged = {stable_version(version) for version in previous_versions & current_versions} + if not unchanged or not removed or len(removed) != len(introduced): + return False + observed = True + for previous_release, patched_release in zip(removed, introduced, strict=True): + if patched_release <= previous_release: + return False + supported = False + for context, previous_requirements in previous_contexts.items(): + requirements = current_contexts.get(context, set()) + if len(previous_requirements) != 1 or len(requirements) != 1 or not marker_overlap(context[3], domain): + continue + original = next(iter(previous_requirements)) + replacement = next(iter(requirements)) + bounds = unchanged_nonfloor_bounds(original) + if ( + not any(operator in {"<", "<="} for operator, _ in bounds) + or unchanged_nonfloor_bounds(replacement) != bounds + or not matches_protected_release(previous_requirements, previous_release) + or not matches_protected_release(requirements, patched_release) + ): + continue + before = minimums(previous_requirements, allow_missing=True, exact=True) + after = minimums(requirements, exact=True) + if len(before) != 1 or len(after) != 1 or after[0] < patched_release or after[0] <= before[0]: + continue + if not any( + not matches_protected_release(previous_requirements, preserved) + and any( + other != context + and marker_overlap(other[3], domain) + and other in current_contexts + and matches_protected_release(protected, preserved) + and matches_protected_release(current_contexts[other], preserved) + for other, protected in previous_contexts.items() + ) + for preserved in unchanged + ): + continue + supported = True + break + if not supported: + return False + return observed + + +old_project = read_base("pyproject.toml") +old_lock = read_base("uv.lock") +new_project = cast(dict[str, Any], tomllib.loads(pathlib.Path("pyproject.toml").read_text())) +new_lock = cast(dict[str, Any], tomllib.loads(pathlib.Path("uv.lock").read_text())) +old_direct, old_contexts = direct(old_project) +new_direct, new_contexts = direct(new_project) +old_versions, old_resolution_contexts = versions(old_lock) +new_versions, new_resolution_contexts = versions(new_lock) +old_protected, old_protected_contexts = direct(old_project, protected=True) +new_protected, new_protected_contexts = direct(new_project, protected=True) +for name, previous in old_protected.items(): + requirements = new_protected.get(name, set()) + previous_contexts = old_protected_contexts.get(name, {}) + current_contexts = new_protected_contexts.get(name, {}) + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + if previous == requirements and previous_contexts == current_contexts and previous_domains == current_domains: + continue + prior_minimums = minimums(previous, allow_missing=True, exact=True) + if not prior_minimums: + continue + updated_minimums = minimums(requirements, exact=True) + mapped_contexts: ContextReplacements = {} + for context, prior_requirements in previous_contexts.items(): + context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) + if not context_minimums: + continue + replacements = replacement_contexts(context, prior_requirements, current_contexts, previous_domains, exact=True) + if not replacements: + raise SystemExit("Do not lower a contextual protected security minimum for " + name) + mapped_contexts[context] = replacements + for context_requirements in replacements.values(): + updated_context_minimums = minimums(context_requirements, exact=True) + if len(updated_context_minimums) != len(context_minimums) or any( + updated < previous for previous, updated in zip(context_minimums, updated_context_minimums, strict=True) + ): + raise SystemExit("Do not lower a contextual protected security minimum for " + name) + split = any(context not in replacements for context, replacements in mapped_contexts.items()) + if split: + if set(current_contexts) != { + replacement for replacements in mapped_contexts.values() for replacement in replacements + }: + raise SystemExit("Do not replace a protected security dependency context for " + name) + elif len(updated_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, updated_minimums, strict=True) + ): + raise SystemExit("Do not lower a protected dependency security minimum for " + name) + if previous_domains == current_domains: + continue + protected_patched_domains: dict[MarkerContext, list[tuple[StableRelease, StableRelease]]] = {} + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + if ( + not introduced + or len(introduced) != len(removed) + or any(updated <= previous for previous, updated in zip(removed, introduced, strict=True)) + ): + raise SystemExit("Missing contextual upgraded protected security dependency release for " + name) + protected_patched_domains[domain] = list(zip(removed, introduced, strict=True)) + for context, prior_requirements in previous_contexts.items(): + prior_context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) + if not prior_context_minimums: + continue + for replacement, context_requirements in mapped_contexts[context].items(): + patched = [ + introduced + for domain, upgrades in protected_patched_domains.items() + if marker_overlap(replacement[3], domain) + for removed, introduced in upgrades + if matches_protected_release(prior_requirements, removed) + ] + if not patched: + continue + updated_context_minimums = minimums(context_requirements, exact=True) + if ( + not updated_context_minimums + or any(updated < max(patched) for updated in updated_context_minimums) + or any( + not matches_protected_release(context_requirements, release, upper_only=True) for release in patched + ) + ): + raise SystemExit("Raise the contextual protected security minimum to the patched release for " + name) +direct_replacements: dict[str, ContextReplacements] = {} +for name, previous_contexts in old_contexts.items(): + current_contexts = new_contexts.get(name, {}) + mapped_contexts = {} + for context, previous_requirements in previous_contexts.items(): + replacements = replacement_contexts( + context, previous_requirements, current_contexts, old_resolution_contexts.get(name, {}) + ) + if not replacements: + raise SystemExit("Do not remove a published direct dependency or its original context for " + name) + mapped_contexts[context] = replacements + if any(context not in replacements for context, replacements in mapped_contexts.items()) and set( + current_contexts + ) != {replacement for replacements in mapped_contexts.values() for replacement in replacements}: + raise SystemExit("Do not replace a published direct dependency context for " + name) + direct_replacements[name] = mapped_contexts +for name, requirements in new_direct.items(): + previous = old_direct.get(name, set()) + previous_contexts = old_contexts.get(name, {}) + current_contexts = new_contexts.get(name, {}) + if previous != requirements or previous_contexts != current_contexts: + previous_minimums = minimums(previous, allow_missing=True) + if previous_minimums: + updated_minimums = minimums(requirements) + mapped_contexts = direct_replacements.get(name, {}) + split = any(context not in replacements for context, replacements in mapped_contexts.items()) + if not split and ( + len(updated_minimums) != len(previous_minimums) + or any( + updated < previous for previous, updated in zip(previous_minimums, updated_minimums, strict=True) + ) + ): + raise SystemExit("Do not lower a published security-fixed minimum for " + name) + for context, prior_requirements in previous_contexts.items(): + prior_minimums = minimums(prior_requirements, allow_missing=True) + if not prior_minimums: + continue + for context_requirements in mapped_contexts[context].values(): + context_minimums = minimums(context_requirements) + if len(context_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, context_minimums, strict=True) + ): + raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + if old_versions.get(name, set()) == new_versions.get(name, set()) and previous_domains == current_domains: + continue + if previous == requirements: + if preserves_supported_security_branches( + previous_domains, + current_domains, + old_protected_contexts.get(name, {}), + new_protected_contexts.get(name, {}), + ): + continue + raise SystemExit("Raise the published security-fixed minimum for " + name) + patched_domains: dict[MarkerContext, StableRelease] = {} + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + if ( + not introduced + or len(introduced) != len(removed) + or any(updated <= previous for previous, updated in zip(removed, introduced, strict=True)) + ): + raise SystemExit("Missing contextual upgraded security dependency release for " + name) + patched_domains[domain] = introduced[-1] + if not patched_domains: + raise SystemExit("Missing upgraded direct security dependency release for " + name) + covered: set[MarkerContext] = set() + for context, context_requirements in current_contexts.items(): + domains = {domain for domain in patched_domains if marker_overlap(context[3], domain)} + if not domains: + continue + covered.update(domains) + patched_minimum = max(patched_domains[domain] for domain in domains) + updated_minimums = minimums(context_requirements) + original_context = next( + ( + original + for original, replacements in direct_replacements.get(name, {}).items() + if context in replacements + ), + context, + ) + previous_minimums = minimums(previous_contexts.get(original_context, set()), allow_missing=True) + if ( + not updated_minimums + or any(updated < patched_minimum for updated in updated_minimums) + or previous_minimums + and (len(updated_minimums) != len(previous_minimums) or updated_minimums[0] <= previous_minimums[0]) + ): + raise SystemExit("Raise the contextual security-fixed minimum for " + name) + if covered != set(patched_domains): + raise SystemExit("Raise the published security-fixed minimum for " + name) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index b29593171c..a6b0b27b3b 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -891,6 +891,30 @@ def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() - assert reviewed_aiohttp not in job[:trusted_checkout] +def test_agents_source_allowlist_uses_its_immutable_reviewed_checkout() -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + job = workflow.split("\n agents_sdk:\n", 1)[1] + checkout = re.search( + r"repository: openai/openai-agents-python\n(?P(?: [^\n]+\n)+)", + job, + ) + assert checkout is not None + assert re.search( + r"^ ref: 7e55afc9500d12937687988f1e91e900dcb4ad09$", checkout.group("inputs"), re.MULTILINE + ) + + +def test_agents_type_checks_reuse_only_the_validated_preinstalled_environment() -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + job = workflow.split("\n agents_sdk:\n", 1)[1] + checks = job.split(" - name: Run integration type checks\n", 1)[1] + assert "UV_NO_SYNC: '1'" in checks + assert "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" in checks + assert "reviewed_sources" not in checks + assert checks.index("UV_NO_SYNC") < checks.index("run: make mypy") + assert job.index('${reviewed_sources}" make sync') < job.index("UV_NO_SYNC") + + def test_agents_link_only_relocks_before_reviewed_source_distributions( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1210,6 +1234,19 @@ def test_breaking_change_installers_validate_provenance_first(name: str) -> None assert "working-directory: openai-python" in gate +def test_security_dependency_policy_is_directly_testable_after_the_trusted_gate() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + job = dependency_workflow_jobs()["dependency-locks"] + gate = job.index("Verify dependency source provenance before installing tools") + policy = job.index("python scripts/check-dependency-security.py") + assert gate < policy + assert "python - <<'PY'" not in job + script = ROOT / "scripts/check-dependency-security.py" + assert script.is_file() + assert 'import_module("tomllib")' in script.read_text() + assert workflow.count("Use only the public PyPI registry") == 1 + + def security_dependency_floor_program() -> str: gate = dependency_workflow_jobs()["dependency-locks"] match = re.search( @@ -1226,11 +1263,14 @@ def security_dependency_floor_program() -> str: "contains(github.event.pull_request.head.ref, 'python-security')", ): assert condition in body - script = re.search(r" python - <<'PY'\n(?P.*?)(?=\n PY)", body, re.DOTALL) - assert script is not None - program = "\n".join(line[10:] for line in script.group("source").splitlines()) + assert "run: python scripts/check-dependency-security.py" in body + program = (ROOT / "scripts/check-dependency-security.py").read_text() if sys.version_info < (3, 11): - program = "import sys, tomli; sys.modules['tomllib'] = tomli\n" + program + program = program.replace( + "from __future__ import annotations", + "from __future__ import annotations\nimport sys, tomli; sys.modules['tomllib'] = tomli", + 1, + ) return program @@ -2094,6 +2134,66 @@ def test_only_direct_security_updates_must_raise_published_minimums( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("pydantic-v2", True, id="published-v1-support-survives-protected-v2-patch"), + pytest.param("pydantic-v1", True, id="published-v2-support-survives-protected-v1-patch"), + pytest.param("unchanged-protected-floor", False, id="unchanged-v2-protected-floor-rejected"), + pytest.param("below-patched-release", False, id="protected-v2-floor-must-reach-lock-patch"), + pytest.param("unbounded-branch", False, id="protected-branch-must-retain-upper-bound"), + pytest.param("weakened-unaffected-floor", False, id="unaffected-v1-security-floor-cannot-drop"), + pytest.param("removed-unaffected-group", False, id="supported-v1-protected-context-cannot-disappear"), + pytest.param("removed-unaffected-lock", False, id="supported-v1-locked-branch-cannot-disappear"), + pytest.param("new-protected-context", False, id="protected-v2-context-must-exist-in-immutable-base"), + ], +) +def test_security_updates_preserve_independent_supported_major_branches( + tmp_path: Path, variant: str, accepted: bool +) -> None: + published = "pydantic>=1.10.13,<3" + base_groups = { + "pydantic-v1": ["pydantic>=1.10.26,<2"], + "pydantic-v2": ["pydantic>=2,<3"], + } + head_groups = { + "pydantic-v1": ["pydantic>=1.10.26,<2"], + "pydantic-v2": ["pydantic>=2.12.6,<3"], + } + base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5")] + head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.6")] + + if variant == "pydantic-v1": + head_groups["pydantic-v1"] = ["pydantic>=1.10.27,<2"] + head_groups["pydantic-v2"] = ["pydantic>=2,<3"] + head_packages = [("pydantic", "1.10.27"), ("pydantic", "2.12.5")] + elif variant == "unchanged-protected-floor": + head_groups["pydantic-v2"] = ["pydantic>=2,<3"] + elif variant == "below-patched-release": + head_groups["pydantic-v2"] = ["pydantic>=2.12.5,<3"] + elif variant == "unbounded-branch": + head_groups["pydantic-v2"] = ["pydantic>=2.12.6"] + elif variant == "weakened-unaffected-floor": + head_groups["pydantic-v1"] = ["pydantic>=1.10.13,<2"] + elif variant == "removed-unaffected-group": + head_groups.pop("pydantic-v1") + elif variant == "removed-unaffected-lock": + head_packages = [("pydantic", "2.12.6")] + elif variant == "new-protected-context": + base_groups.pop("pydantic-v2") + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[published], + head_requirements=[published], + base_packages=base_packages, + head_packages=head_packages, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From 05c414fc21fc7d743b224e0bde05e46423b821ad Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 23:36:14 +0000 Subject: [PATCH 21/26] fix(security): require safe published dependency branches --- scripts/check-dependency-security.py | 184 +++++++++++++++++++++++++-- tests/test_uv_workflows.py | 86 ++++++++++++- 2 files changed, 259 insertions(+), 11 deletions(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index aada8853da..c69713888a 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -13,6 +13,7 @@ MarkerClause: TypeAlias = tuple[str, str, str] MarkerContext: TypeAlias = tuple[MarkerClause, ...] StableRelease: TypeAlias = tuple[int, tuple[int, ...], int] +PublishedBound: TypeAlias = tuple[str, int, tuple[int, ...], int, bool] DependencyContext: TypeAlias = tuple[str, str, tuple[str, ...], MarkerContext] RequirementMap: TypeAlias = dict[str, set[str]] ContextRequirements: TypeAlias = dict[DependencyContext, set[str]] @@ -104,7 +105,7 @@ def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[Require raise SystemExit("Ambiguous direct security dependency requirement") name = canonical(match.group(1)) extra = match.group(2) - requested = () + requested: tuple[str, ...] = () if extra: requested = tuple(sorted(canonical(value.strip()) for value in extra[1:-1].split(","))) if any(not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value) for value in requested): @@ -124,6 +125,7 @@ def versions(lock: dict[str, Any]) -> tuple[RequirementMap, ResolutionsByName]: version = package["version"] result.setdefault(name, set()).add(version) markers = package.get("resolution-markers") + domains: list[MarkerContext] if markers is None: domains = [()] else: @@ -464,6 +466,170 @@ def preserves_supported_security_branches( return observed +def published_bounds(requirement: str) -> tuple[PublishedBound, ...]: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous published security dependency requirement") + clauses = match.group(3).split(",") + if len(clauses) > 256: + raise SystemExit("Unbounded published security dependency exclusions") + result: list[PublishedBound] = [] + for clause in clauses: + match = re.fullmatch( + r"(>=|<=|==|!=|>|<)\s*((?:(\d+)!)?(\d+(?:\.\d+)*)(?:\.post(\d+))?)(\.\*)?", + clause.strip(), + ) + if match is None or len(match.group(2)) > 128: + raise SystemExit("Ambiguous published security dependency bound") + components = match.group(4).split(".") + if len(components) > 16 or any(len(component) > 9 for component in components): + raise SystemExit("Unbounded published security dependency release") + wildcard = match.group(6) is not None + if wildcard and (match.group(1) != "!=" or match.group(5) is not None): + raise SystemExit("Ambiguous published security dependency wildcard") + epoch, release, post = stable_version(match.group(2)) + prefix = tuple(int(component) for component in components) + result.append((match.group(1), epoch, prefix if wildcard else release, post, wildcard)) + if len(set(result)) != len(result): + raise SystemExit("Ambiguous duplicate published security dependency bound") + return tuple(result) + + +def allows_published_release(bounds: tuple[PublishedBound, ...], release: StableRelease) -> bool: + for operator, epoch, components, post, wildcard in bounds: + if wildcard: + candidate = release[1] + (0,) * max(0, len(components) - len(release[1])) + if release[0] == epoch and candidate[: len(components)] == components: + return False + continue + bound = epoch, components, post + if ( + operator == ">=" + and release < bound + or operator == ">" + and release <= bound + or operator == "<=" + and release > bound + or operator == "<" + and release >= bound + or operator == "==" + and release != bound + or operator == "!=" + and release == bound + ): + return False + return True + + +def published_lower_bound_excludes(bounds: tuple[PublishedBound, ...], epoch: int, prefix: tuple[int, ...]) -> bool: + for operator, bound_epoch, components, _post, wildcard in bounds: + if wildcard or operator not in {">=", ">"}: + continue + if epoch < bound_epoch: + return True + if epoch > bound_epoch: + continue + boundary = components + (0,) * max(0, len(prefix) - len(components)) + if prefix < boundary[: len(prefix)]: + return True + return False + + +def excludes_affected_published_branch( + previous_bounds: tuple[PublishedBound, ...], + current_bounds: tuple[PublishedBound, ...], + removed: StableRelease, + patched: StableRelease, + preserved: set[StableRelease], +) -> bool: + if ( + removed[0] != patched[0] + or not removed[1] + or not patched[1] + or removed[1][0] != patched[1][0] + or not set(previous_bounds).issubset(current_bounds) + or any(bound[0] != "!=" for bound in set(current_bounds) - set(previous_bounds)) + or not allows_published_release(previous_bounds, removed) + or allows_published_release(current_bounds, removed) + or not allows_published_release(current_bounds, patched) + ): + return False + retained = {release for release in preserved if allows_published_release(previous_bounds, release)} + if not retained or any(not allows_published_release(current_bounds, release) for release in retained): + return False + epoch, components, post = patched + work = 0 + exclusions = { + value + for operator, bound_epoch, value, _, wildcard in current_bounds + if operator == "!=" and wildcard and bound_epoch == epoch + } + for index in range(1, len(components)): + if components[index] > 256 - work: + return False + for component in range(components[index]): + work += 1 + prefix = components[:index] + (component,) + if published_lower_bound_excludes(previous_bounds, epoch, prefix): + continue + if not any( + len(exclusion) <= len(prefix) and prefix[: len(exclusion)] == exclusion for exclusion in exclusions + ): + return False + if post >= 0: + if post + 1 > 256 - work: + return False + if allows_published_release(current_bounds, (epoch, components, -1)): + return False + for earlier in range(post): + if allows_published_release(current_bounds, (epoch, components, earlier)): + return False + return True + + +def secures_supported_published_branches( + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, + previous_published: ContextRequirements, + current_published: ContextRequirements, + previous_protected: ContextRequirements, + current_protected: ContextRequirements, +) -> bool: + if not preserves_supported_security_branches( + previous_domains, current_domains, previous_protected, current_protected + ): + return False + observed = False + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + removed = sorted(stable_version(value) for value in prior_versions - updated_versions) + patched = sorted(stable_version(value) for value in updated_versions - prior_versions) + retained = {stable_version(value) for value in prior_versions & updated_versions} + if not retained or len(removed) != len(patched): + return False + for old, new in zip(removed, patched, strict=True): + covered = False + for context, previous in previous_published.items(): + current = current_published.get(context, set()) + if len(previous) != 1 or len(current) != 1 or not marker_overlap(context[3], domain): + continue + before = published_bounds(next(iter(previous))) + if not allows_published_release(before, old): + continue + after = published_bounds(next(iter(current))) + if not excludes_affected_published_branch(before, after, old, new, retained): + return False + covered = True + if not covered: + return False + observed = True + return observed + + old_project = read_base("pyproject.toml") old_lock = read_base("uv.lock") new_project = cast(dict[str, Any], tomllib.loads(pathlib.Path("pyproject.toml").read_text())) @@ -599,14 +765,16 @@ def preserves_supported_security_branches( if old_versions.get(name, set()) == new_versions.get(name, set()) and previous_domains == current_domains: continue if previous == requirements: - if preserves_supported_security_branches( - previous_domains, - current_domains, - old_protected_contexts.get(name, {}), - new_protected_contexts.get(name, {}), - ): - continue raise SystemExit("Raise the published security-fixed minimum for " + name) + if secures_supported_published_branches( + previous_domains, + current_domains, + previous_contexts, + current_contexts, + old_protected_contexts.get(name, {}), + new_protected_contexts.get(name, {}), + ): + continue patched_domains: dict[MarkerContext, StableRelease] = {} for domain in previous_domains.keys() | current_domains.keys(): prior_versions = previous_domains.get(domain, set()) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index a6b0b27b3b..5d8e3bf38e 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -2139,6 +2139,24 @@ def test_only_direct_security_updates_must_raise_published_minimums( [ pytest.param("pydantic-v2", True, id="published-v1-support-survives-protected-v2-patch"), pytest.param("pydantic-v1", True, id="published-v2-support-survives-protected-v1-patch"), + pytest.param("unchanged-published", False, id="private-v2-floor-cannot-leave-published-range-vulnerable"), + pytest.param("unchanged-published-v1", False, id="private-v1-floor-cannot-leave-published-range-vulnerable"), + pytest.param("missing-earlier-minor", False, id="published-range-must-exclude-entire-affected-major"), + pytest.param("missing-earlier-patch", False, id="published-range-must-exclude-earlier-minor-patches"), + pytest.param("exact-old-only", False, id="excluding-only-old-lock-does-not-protect-whole-branch"), + pytest.param("removed-still-accepted", False, id="published-range-must-exclude-removed-lock"), + pytest.param("patched-excluded", False, id="published-range-must-accept-patched-lock"), + pytest.param("unaffected-excluded", False, id="published-range-must-preserve-unaffected-major"), + pytest.param("dropped-original-exclusion", False, id="published-original-exclusions-cannot-be-weakened"), + pytest.param("marked-published", True, id="published-exclusions-preserve-original-marker"), + pytest.param("moved-published-marker", False, id="published-exclusions-cannot-move-original-marker"), + pytest.param("epoch-patched", True, id="published-exclusions-match-security-release-epoch"), + pytest.param("wrong-epoch", False, id="other-epoch-exclusions-do-not-secure-published-branch"), + pytest.param("post-patched", True, id="published-exclusions-cover-earlier-stable-post-releases"), + pytest.param("post-missing-intermediate", False, id="published-exclusions-cannot-skip-earlier-post"), + pytest.param("post-wildcard", False, id="published-post-wildcard-cannot-exclude-patched-release"), + pytest.param("unsupported-wildcard", False, id="ambiguous-published-exclusions-fail-closed"), + pytest.param("unbounded-expansion", False, id="published-branch-proof-has-bounded-expansion"), pytest.param("unchanged-protected-floor", False, id="unchanged-v2-protected-floor-rejected"), pytest.param("below-patched-release", False, id="protected-v2-floor-must-reach-lock-patch"), pytest.param("unbounded-branch", False, id="protected-branch-must-retain-upper-bound"), @@ -2151,7 +2169,10 @@ def test_only_direct_security_updates_must_raise_published_minimums( def test_security_updates_preserve_independent_supported_major_branches( tmp_path: Path, variant: str, accepted: bool ) -> None: - published = "pydantic>=1.10.13,<3" + old_exclusions = [f"!=2.{minor}.*" for minor in range(4)] + published = "pydantic>=1.10.13,<3," + ",".join(old_exclusions) + v2_exclusions = [f"!=2.{minor}.*" for minor in range(4, 12)] + [f"!=2.12.{patch}.*" for patch in range(6)] + head_published = published + "," + ",".join(v2_exclusions) base_groups = { "pydantic-v1": ["pydantic>=1.10.26,<2"], "pydantic-v2": ["pydantic>=2,<3"], @@ -2163,10 +2184,69 @@ def test_security_updates_preserve_independent_supported_major_branches( base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5")] head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.6")] - if variant == "pydantic-v1": + if variant in {"pydantic-v1", "unchanged-published-v1"}: head_groups["pydantic-v1"] = ["pydantic>=1.10.27,<2"] head_groups["pydantic-v2"] = ["pydantic>=2,<3"] head_packages = [("pydantic", "1.10.27"), ("pydantic", "2.12.5")] + head_published = published + "," + ",".join(f"!=1.10.{patch}.*" for patch in range(13, 27)) + if variant == "unchanged-published-v1": + head_published = published + elif variant == "unchanged-published": + head_published = published + elif variant == "missing-earlier-minor": + head_published = head_published.replace(",!=2.11.*", "") + elif variant == "missing-earlier-patch": + head_published = head_published.replace(",!=2.12.4.*", "") + elif variant == "exact-old-only": + head_published = published + ",!=2.12.5" + elif variant == "removed-still-accepted": + head_published = head_published.replace(",!=2.12.5.*", "") + elif variant == "patched-excluded": + head_published += ",!=2.12.6.*" + elif variant == "unaffected-excluded": + head_published += ",!=1.10.26.*" + elif variant == "dropped-original-exclusion": + head_published = head_published.replace(",!=2.0.*", "") + elif variant in {"marked-published", "moved-published-marker"}: + published += "; python_version >= '3.10'" + head_published += ( + "; python_version >= '3.11'" if variant == "moved-published-marker" else "; python_version >= '3.10'" + ) + elif variant in {"epoch-patched", "wrong-epoch"}: + existing = [f"!=1!2.{minor}.*" for minor in range(4)] + remaining = [f"!=1!2.{minor}.*" for minor in range(4, 12)] + [f"!=1!2.12.{patch}.*" for patch in range(6)] + published = "pydantic>=1!1.10.13,<1!3," + ",".join(existing) + head_published = published + "," + ",".join(remaining) + if variant == "wrong-epoch": + head_published = published + "," + ",".join(value.replace("1!", "0!") for value in remaining) + base_groups = { + "pydantic-v1": ["pydantic>=1!1.10.26,<1!2"], + "pydantic-v2": ["pydantic>=1!2,<1!3"], + } + head_groups = { + "pydantic-v1": ["pydantic>=1!1.10.26,<1!2"], + "pydantic-v2": ["pydantic>=1!2.12.6,<1!3"], + } + base_packages = [("pydantic", "1!1.10.26"), ("pydantic", "1!2.12.5")] + head_packages = [("pydantic", "1!1.10.26"), ("pydantic", "1!2.12.6")] + elif variant in {"post-patched", "post-missing-intermediate", "post-wildcard"}: + head_groups["pydantic-v2"] = ["pydantic>=2.12.5.post3,<3"] + base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5.post1")] + head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5.post3")] + lower = [f"!=2.{minor}.*" for minor in range(4, 12)] + lower += [f"!=2.12.{patch}.*" for patch in range(5)] + lower += ["!=2.12.5", "!=2.12.5.post0", "!=2.12.5.post1", "!=2.12.5.post2"] + head_published = published + "," + ",".join(lower) + if variant == "post-missing-intermediate": + head_published = head_published.replace(",!=2.12.5.post2", "") + elif variant == "post-wildcard": + head_published += ",!=2.12.5.*" + elif variant == "unsupported-wildcard": + head_published += ",!=2.12.5.post1.*" + elif variant == "unbounded-expansion": + head_groups["pydantic-v2"] = ["pydantic>=2.513.1,<3"] + base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.513.0")] + head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.513.1")] elif variant == "unchanged-protected-floor": head_groups["pydantic-v2"] = ["pydantic>=2,<3"] elif variant == "below-patched-release": @@ -2185,7 +2265,7 @@ def test_security_updates_preserve_independent_supported_major_branches( result = run_security_dependency_floor_check( tmp_path, base_requirements=[published], - head_requirements=[published], + head_requirements=[head_published], base_packages=base_packages, head_packages=head_packages, base_dependency_groups=base_groups, From f01257095ed392ad76109a3987373e160d253955 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 23:53:00 +0000 Subject: [PATCH 22/26] fix(security): preserve bounds when dependency locks are unchanged --- scripts/check-dependency-security.py | 88 +++++++++++++++++++++ tests/test_uv_workflows.py | 110 ++++++++++++++++++++++++++- 2 files changed, 197 insertions(+), 1 deletion(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index c69713888a..71820e10be 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -522,6 +522,88 @@ def allows_published_release(bounds: tuple[PublishedBound, ...], release: Stable return True +def preserves_published_security_bound(previous: PublishedBound, current: tuple[PublishedBound, ...]) -> bool: + operator, epoch, components, post, wildcard = previous + if operator in {"<", "<="}: + limit = epoch, components, post + for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: + if candidate_wildcard or updated not in {"<", "<=", "=="}: + continue + bound = candidate_epoch, candidate, candidate_post + if bound < limit or bound == limit and (operator == "<=" or updated == "<"): + return True + return False + if operator == "==": + return any(bound == previous for bound in current) + if operator != "!=": + return True + if not wildcard: + return not allows_published_release(current, (epoch, components, post)) + + start_text = str(epoch) + "!" + ".".join(str(part) for part in components) + start = stable_version(start_text) + next_components = components[:-1] + (components[-1] + 1,) + stop = stable_version(str(epoch) + "!" + ".".join(str(part) for part in next_components)) + for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: + if ( + updated == "!=" + and candidate_wildcard + and candidate_epoch == epoch + and len(candidate) <= len(components) + and components[: len(candidate)] == candidate + ): + return True + if candidate_wildcard: + continue + bound = candidate_epoch, candidate, candidate_post + if updated in {"<", "<="} and (bound < start or bound == start and updated == "<"): + return True + if updated in {">=", ">"} and bound >= stop: + return True + if updated == "==": + padded = candidate + (0,) * max(0, len(components) - len(candidate)) + if candidate_epoch != epoch or padded[: len(components)] != components: + return True + return False + + +def preserves_unchanged_published_bounds( + previous_contexts: ContextRequirements, + replacements: ContextReplacements, + domains: ResolutionDomains, +) -> bool: + for previous_context, previous_requirements in previous_contexts.items(): + for requirement in previous_requirements: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous unchanged published security dependency requirement") + clauses = match.group(3).split(",") + if not any(re.match(r"(?:!=|<=|<|==)", clause.strip()) for clause in clauses): + continue + before = published_bounds(requirement) + protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!=", "=="}) + for replacement_context, candidates in replacements.get(previous_context, {}).items(): + retained = { + stable_version(version) + for domain, versions in domains.items() + if marker_overlap(replacement_context[3], domain) + for version in versions + if allows_published_release(before, stable_version(version)) + } + preserved = False + for candidate in candidates: + after = published_bounds(candidate) + if all(preserves_published_security_bound(bound, after) for bound in protected) and all( + allows_published_release(after, release) for release in retained + ): + preserved = True + break + if not preserved: + return False + return True + + def published_lower_bound_excludes(bounds: tuple[PublishedBound, ...], epoch: int, prefix: tuple[int, ...]) -> bool: for operator, bound_epoch, components, _post, wildcard in bounds: if wildcard or operator not in {">=", ">"}: @@ -763,6 +845,12 @@ def secures_supported_published_branches( previous_domains = old_resolution_contexts.get(name, {}) current_domains = new_resolution_contexts.get(name, {}) if old_versions.get(name, set()) == new_versions.get(name, set()) and previous_domains == current_domains: + if ( + previous != requirements or previous_contexts != current_contexts + ) and not preserves_unchanged_published_bounds( + previous_contexts, direct_replacements.get(name, {}), previous_domains + ): + raise SystemExit("Do not weaken an unchanged published security exclusion or upper bound for " + name) continue if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 5d8e3bf38e..dc209f1420 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -1729,7 +1729,7 @@ def lock(packages: list[tuple[str, str]], resolutions: dict[tuple[str, str], lis ), pytest.param( ["other>=2,<4"], - ["other>=2,<5"], + ["other>=2,<3"], [("other", "2")], [("other", "2")], False, @@ -2134,6 +2134,114 @@ def test_only_direct_security_updates_must_raise_published_minimums( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("removed-exclusion", False, id="grouped-update-cannot-remove-runtime-wildcard-exclusion"), + pytest.param("narrowed-exclusion", False, id="exact-exclusion-cannot-replace-entire-vulnerable-prefix"), + pytest.param("removed-upper", False, id="grouped-update-cannot-remove-runtime-upper-bound"), + pytest.param("widened-upper", False, id="grouped-update-cannot-widen-runtime-upper-bound"), + pytest.param("inclusive-upper", False, id="inclusive-bound-cannot-weaken-exclusive-upper"), + pytest.param("strengthened-upper", True, id="narrower-upper-bound-preserves-supported-locks"), + pytest.param("exclusive-upper", True, id="exclusive-upper-may-strengthen-inclusive-bound"), + pytest.param("canonical-reordered", True, id="canonical-reordered-security-bounds-remain-equivalent"), + pytest.param("stronger-wildcard", True, id="broader-exclusion-prefix-may-strengthen-security"), + pytest.param("wildcard-drops-v2", False, id="stronger-wildcard-cannot-drop-supported-v2-lock"), + pytest.param("exact-to-wildcard", True, id="wildcard-may-strengthen-exact-release-exclusion"), + pytest.param("post-removed", False, id="unchanged-lock-must-retain-exact-stable-post-exclusion"), + pytest.param("post-covered", True, id="release-prefix-may-strengthen-stable-post-exclusion"), + pytest.param("floor-covers-exact", True, id="stronger-floor-may-imply-prior-exact-exclusion"), + pytest.param("upper-covers-wildcard", True, id="stronger-upper-may-imply-prior-prefix-exclusion"), + pytest.param("wrong-epoch", False, id="other-epoch-prefix-does-not-preserve-existing-exclusion"), + pytest.param("epoch-canonical", True, id="canonical-same-epoch-prefix-preserves-existing-exclusion"), + pytest.param("marker-preserved", True, id="unchanged-marker-retains-runtime-security-bounds"), + pytest.param("marker-moved", False, id="security-bounds-cannot-move-to-different-marker-context"), + pytest.param("optional-removed", False, id="grouped-update-cannot-remove-optional-security-exclusion"), + pytest.param("optional-preserved", True, id="optional-security-context-and-bounds-remain-supported"), + pytest.param("unaffected-v1-dropped", False, id="security-bounds-cannot-drop-supported-unchanged-v1"), + pytest.param("unaffected-v2-dropped", False, id="security-bounds-cannot-drop-supported-unchanged-v2"), + pytest.param("malformed-exclusion", False, id="ambiguous-unchanged-lock-exclusion-fails-closed"), + pytest.param("no-old-security-bounds", True, id="unbounded-unchanged-dependency-still-accepts-new-floor"), + ], +) +def test_grouped_security_updates_preserve_unchanged_published_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous = "pydantic>=1,<3,!=2.12.5.*" + current = previous + v1, v2 = "1.10.26", "2.12.6" + optional = variant.startswith("optional-") + + if variant in {"removed-exclusion", "optional-removed"}: + current = "pydantic>=1,<3" + elif variant == "narrowed-exclusion": + current = "pydantic>=1,<3,!=2.12.5" + elif variant == "removed-upper": + current = "pydantic>=1,!=2.12.5.*" + elif variant == "widened-upper": + current = "pydantic>=1,<4,!=2.12.5.*" + elif variant == "inclusive-upper": + current = "pydantic>=1,<=3,!=2.12.5.*" + elif variant == "strengthened-upper": + current = "pydantic>=1,<2.13,!=2.12.5.*" + elif variant == "exclusive-upper": + previous = "pydantic>=1,<=3,!=2.12.5.*" + current = "pydantic>=1,<3,!=2.12.5.*" + elif variant == "canonical-reordered": + current = "pydantic!=0!2.12.5.*,<3.0,>=1.0.0" + elif variant in {"stronger-wildcard", "wildcard-drops-v2"}: + current = "pydantic>=1,<3,!=2.12.*" + if variant == "stronger-wildcard": + v2 = "2.13.0" + elif variant == "exact-to-wildcard": + previous = "pydantic>=1,<3,!=2.12.5" + elif variant in {"post-removed", "post-covered"}: + previous = "pydantic>=1,<3,!=2.12.5.post1" + current = "pydantic>=1,<3" if variant == "post-removed" else "pydantic>=1,<3,!=2.12.5.*" + elif variant == "floor-covers-exact": + previous = "pydantic>=1,<3,!=1.10.25" + current = "pydantic>=1.10.26,<3" + elif variant == "upper-covers-wildcard": + current = "pydantic>=1,<2.12.5" + v2 = "2.11.0" + elif variant in {"wrong-epoch", "epoch-canonical"}: + previous = "pydantic>=1!1,<1!3,!=1!2.12.5.*" + current = ( + "pydantic>=1!1,<1!3,!=0!2.12.5.*" if variant == "wrong-epoch" else "pydantic>=1!1,<1!3.0,!=01!02.012.005.*" + ) + v1, v2 = "1!1.10.26", "1!2.12.6" + elif variant in {"marker-preserved", "marker-moved"}: + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.12'" if variant == "marker-moved" else "; python_version >= '3.11'" + elif variant == "unaffected-v1-dropped": + current += ",!=1.10.26.*" + elif variant == "unaffected-v2-dropped": + current += ",!=2.12.6.*" + elif variant == "malformed-exclusion": + current = "pydantic>=1,<3,!=2.12.5.post1.*" + elif variant == "no-old-security-bounds": + previous, current = "pydantic", "pydantic>=1" + + base_requirements = ["danger-pkg>=1"] + head_requirements = ["danger-pkg>=2"] + base_optional_groups = {"feature": [previous]} if optional else None + head_optional_groups = {"feature": [current]} if optional else None + if not optional: + base_requirements.append(previous) + head_requirements.append(current) + unchanged = [("pydantic", v1), ("pydantic", v2)] + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("danger-pkg", "1"), *unchanged], + head_packages=[("danger-pkg", "2"), *unchanged], + base_optional_groups=base_optional_groups, + head_optional_groups=head_optional_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From 1ef8c694a2c5b0f0d6ec5343c13d758b13404f06 Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 00:09:39 +0000 Subject: [PATCH 23/26] fix(security): preserve all reviewed dependency bounds --- scripts/check-dependency-security.py | 28 +++-- tests/test_uv_workflows.py | 157 +++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index 71820e10be..28de13bea4 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -567,7 +567,7 @@ def preserves_published_security_bound(previous: PublishedBound, current: tuple[ return False -def preserves_unchanged_published_bounds( +def preserves_dependency_security_bounds( previous_contexts: ContextRequirements, replacements: ContextReplacements, domains: ResolutionDomains, @@ -579,11 +579,14 @@ def preserves_unchanged_published_bounds( if match is None: raise SystemExit("Ambiguous unchanged published security dependency requirement") clauses = match.group(3).split(",") - if not any(re.match(r"(?:!=|<=|<|==)", clause.strip()) for clause in clauses): + if not any(re.match(r"(?:!=|<=|<)", clause.strip()) for clause in clauses): continue before = published_bounds(requirement) - protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!=", "=="}) - for replacement_context, candidates in replacements.get(previous_context, {}).items(): + protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!="}) + context_replacements = replacements.get(previous_context, {}) + if not context_replacements: + return False + for replacement_context, candidates in context_replacements.items(): retained = { stable_version(version) for domain, versions in domains.items() @@ -730,6 +733,13 @@ def secures_supported_published_branches( current_domains = new_resolution_contexts.get(name, {}) if previous == requirements and previous_contexts == current_contexts and previous_domains == current_domains: continue + if previous != requirements or previous_contexts != current_contexts: + security_replacements: ContextReplacements = { + context: replacement_contexts(context, prior_requirements, current_contexts, previous_domains, exact=True) + for context, prior_requirements in previous_contexts.items() + } + if not preserves_dependency_security_bounds(previous_contexts, security_replacements, current_domains): + raise SystemExit("Do not weaken a protected dependency security exclusion or upper bound for " + name) prior_minimums = minimums(previous, allow_missing=True, exact=True) if not prior_minimums: continue @@ -844,13 +854,11 @@ def secures_supported_published_branches( raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) previous_domains = old_resolution_contexts.get(name, {}) current_domains = new_resolution_contexts.get(name, {}) + if (previous != requirements or previous_contexts != current_contexts) and not preserves_dependency_security_bounds( + previous_contexts, direct_replacements.get(name, {}), current_domains + ): + raise SystemExit("Do not weaken a published security exclusion or upper bound for " + name) if old_versions.get(name, set()) == new_versions.get(name, set()) and previous_domains == current_domains: - if ( - previous != requirements or previous_contexts != current_contexts - ) and not preserves_unchanged_published_bounds( - previous_contexts, direct_replacements.get(name, {}), previous_domains - ): - raise SystemExit("Do not weaken an unchanged published security exclusion or upper bound for " + name) continue if previous == requirements: raise SystemExit("Raise the published security-fixed minimum for " + name) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index dc209f1420..25a38fc204 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -2242,6 +2242,163 @@ def test_grouped_security_updates_preserve_unchanged_published_bounds( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("constraint-exclusion", False, id="unchanged-uv-constraint-cannot-lose-exact-exclusion"), + pytest.param("constraint-wildcard", False, id="unchanged-uv-constraint-cannot-lose-prefix-exclusion"), + pytest.param("constraint-upper", False, id="unchanged-uv-constraint-cannot-widen-upper-bound"), + pytest.param("build-exclusion", False, id="unchanged-build-constraint-cannot-lose-security-exclusion"), + pytest.param("build-wildcard", False, id="unchanged-build-constraint-cannot-lose-prefix-exclusion"), + pytest.param("group-exclusion", False, id="unchanged-dependency-group-cannot-lose-security-exclusion"), + pytest.param("group-wildcard", False, id="unchanged-dependency-group-cannot-lose-prefix-exclusion"), + pytest.param("group-no-floor", False, id="protected-exclusion-is-checked-before-no-minimum-skip"), + pytest.param("changed-lock-exclusion", False, id="protected-patch-cannot-erase-other-security-exclusion"), + pytest.param("post-removed", False, id="protected-stable-post-exclusion-remains-immutable"), + pytest.param("wrong-epoch", False, id="protected-exclusion-cannot-move-to-different-epoch"), + pytest.param("marker-exclusion", False, id="protected-marker-context-retains-existing-exclusion"), + pytest.param("marker-moved", False, id="protected-exclusion-cannot-move-marker-context"), + pytest.param("drops-supported-lock", False, id="stronger-protected-bound-cannot-drop-current-locked-line"), + pytest.param("stronger-exclusion", True, id="protected-prefix-may-strengthen-exact-exclusion"), + pytest.param("stronger-upper", True, id="protected-upper-may-strengthen-without-dropping-lock"), + pytest.param("canonical-order", True, id="canonical-reordered-protected-bounds-remain-equivalent"), + pytest.param("build-pin-upgrade", True, id="immutable-exact-build-pins-may-take-reviewed-security-patch"), + pytest.param("unbounded-group", True, id="unchanged-unbounded-development-group-remains-supported"), + ], +) +def test_grouped_security_updates_preserve_all_protected_dependency_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous = "danger>=1,<3,!=1.5" + current = "danger>=1,<3" + before, after = "2", "2" + scope = "constraint" + + if variant in {"constraint-wildcard", "build-wildcard", "group-wildcard"}: + previous = "danger>=1,<3,!=1.5.*" + elif variant == "constraint-upper": + current = "danger>=1,<4,!=1.5" + elif variant == "group-no-floor": + previous, current = "danger<3,!=1.5", "danger<3" + elif variant == "changed-lock-exclusion": + previous = "danger>=1,<3,!=2.5" + current = "danger>=1.6,<3" + before, after = "1.4", "1.6" + elif variant == "post-removed": + previous = "danger>=1,<3,!=1.5.post2" + elif variant == "wrong-epoch": + previous = "danger>=1!1,<1!3,!=1!1.5.*" + current = "danger>=1!1,<1!3,!=0!1.5.*" + before = after = "1!2" + elif variant in {"marker-exclusion", "marker-moved"}: + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.12'" if variant == "marker-moved" else "; python_version >= '3.11'" + elif variant == "drops-supported-lock": + current = "danger>=1,<2,!=1.5" + elif variant == "stronger-exclusion": + current = "danger>=1,<3,!=1.5.*" + elif variant == "stronger-upper": + current = "danger>=1,<2.5,!=1.5" + elif variant == "canonical-order": + current = "danger!=0!1.5,<3.0,>=1.0" + elif variant == "build-pin-upgrade": + previous, current = "danger==1.5", "danger==1.6" + before, after = "1.5", "1.6" + elif variant == "unbounded-group": + previous = current = "danger" + + if variant.startswith("build-"): + scope = "build" + elif variant.startswith("group-") or variant == "unbounded-group": + scope = "group" + + base_constraints = [previous] if scope == "constraint" else None + head_constraints = [current] if scope == "constraint" else None + base_build = [previous] if scope == "build" else None + head_build = [current] if scope == "build" else None + base_groups = {"reviewed": [previous]} if scope == "group" else None + head_groups = {"reviewed": [current]} if scope == "group" else None + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1"], + head_requirements=["patch-me>=1.1"], + base_packages=[("patch-me", "1"), ("danger", before)], + head_packages=[("patch-me", "1.1"), ("danger", after)], + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build, + head_build_constraints=head_build, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-wildcard", False, id="patched-runtime-lock-cannot-remove-prior-prefix-exclusion"), + pytest.param("runtime-exact", False, id="patched-runtime-lock-cannot-remove-prior-exact-exclusion"), + pytest.param("runtime-post", False, id="patched-runtime-lock-cannot-remove-stable-post-exclusion"), + pytest.param("runtime-upper", False, id="patched-runtime-lock-cannot-widen-existing-upper-bound"), + pytest.param("optional-wildcard", False, id="patched-optional-lock-cannot-remove-prior-prefix-exclusion"), + pytest.param("optional-upper", False, id="patched-optional-lock-cannot-remove-existing-upper-bound"), + pytest.param("epoch-wrong", False, id="patched-lock-cannot-move-exclusion-into-another-epoch"), + pytest.param("marker-exclusion", False, id="patched-marker-context-cannot-drop-security-exclusion"), + pytest.param("marker-moved", False, id="patched-security-exclusion-cannot-move-marker-context"), + pytest.param("malformed-exclusion", False, id="patched-lock-security-exclusion-must-remain-unambiguous"), + pytest.param("preserved-wildcard", True, id="patched-lock-preserves-existing-prefix-exclusion"), + pytest.param("stronger-wildcard", True, id="patched-lock-may-strengthen-exact-into-prefix-exclusion"), + pytest.param("stronger-upper", True, id="patched-lock-may-strengthen-upper-without-dropping-support"), + pytest.param("canonical-order", True, id="patched-lock-preserves-canonical-reordered-security-bounds"), + pytest.param("floor-implies-exclusion", True, id="patched-floor-may-safely-imply-prior-exact-exclusion"), + pytest.param("optional-preserved", True, id="patched-optional-context-preserves-existing-security-bounds"), + ], +) +def test_patched_locks_preserve_existing_published_security_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous = "danger>=1,<3,!=2.0.*" + current = "danger>=1.1,<3" + before, after = "1", "1.1" + optional = variant.startswith("optional-") + + if variant in {"runtime-exact", "runtime-post"}: + previous = "danger>=1,<3,!=2.0.post1" if variant == "runtime-post" else "danger>=1,<3,!=2.0" + elif variant in {"runtime-upper", "optional-upper"}: + current = "danger>=1.1,<4,!=2.0.*" + elif variant == "epoch-wrong": + previous = "danger>=1!1,<1!3,!=1!2.0.*" + current = "danger>=1!1.1,<1!3,!=0!2.0.*" + before, after = "1!1", "1!1.1" + elif variant in {"marker-exclusion", "marker-moved"}: + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.12'" if variant == "marker-moved" else "; python_version >= '3.11'" + elif variant == "malformed-exclusion": + current += ",!=2.0.post1.*" + elif variant in {"preserved-wildcard", "optional-preserved"}: + current += ",!=2.0.*" + elif variant == "stronger-wildcard": + previous = "danger>=1,<3,!=2.0" + current += ",!=2.0.*" + elif variant == "stronger-upper": + current = "danger>=1.1,<2,!=2.0.*" + elif variant == "canonical-order": + current = "danger!=0!2.0.*,<3.0,>=1.1.0" + elif variant == "floor-implies-exclusion": + previous = "danger>=0,<3,!=0.5" + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[previous], + head_requirements=[current], + base_packages=[("danger", before)], + head_packages=[("danger", after)], + optional=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From a0049ec4be55b1b081b9924337a1e43b8ac9ba5b Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 00:31:04 +0000 Subject: [PATCH 24/26] fix(security): anchor dependency checks to immutable trusted bases --- .github/workflows/ci.yml | 10 +- .github/workflows/detect-breaking-changes.yml | 4 +- scripts/check-dependency-security.py | 22 +- tests/test_uv_workflows.py | 287 +++++++++++++++++- 4 files changed, 306 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daf8c38351..72ea297200 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -I -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); trusted_lock = tomllib.loads(subprocess.check_output(["git", "show", base + ":uv.lock"], text=True)) if fork else lock; roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); package_identity = lambda package: (canonical(package["name"]), package["version"], package.get("source", {}).get("registry", ""), tuple(sorted((artifact["url"], artifact["hash"]) for artifact in (([package["sdist"]] if "sdist" in package else []) + package.get("wheels", []))))); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and (not fork or sorted(package_identity(package) for package in lock["package"] if package is not roots[0]) == sorted(package_identity(package) for package in trusted_lock["package"] if package.get("source") != {"editable": "."})) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Require published minimums for direct security updates if: >- @@ -45,7 +45,13 @@ jobs: && contains(github.event.pull_request.head.ref, 'python-security') env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: python scripts/check-dependency-security.py + run: | + set -euo pipefail + [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] + origin="$(git remote get-url origin)" + [[ "$origin" == "https://github.com/openai/openai-python" || "$origin" == "https://github.com/openai/openai-python.git" ]] + git fetch --no-tags --depth=1 origin "$BASE_SHA" + git show "$BASE_SHA:scripts/check-dependency-security.py" | python -I - - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index e57e332a90..8aab6c702a 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -32,7 +32,7 @@ jobs: - name: Verify dependency source provenance before installing tools run: | - python -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -I -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); trusted_lock = tomllib.loads(subprocess.check_output(["git", "show", base + ":uv.lock"], text=True)) if fork else lock; roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); package_identity = lambda package: (canonical(package["name"]), package["version"], package.get("source", {}).get("registry", ""), tuple(sorted((artifact["url"], artifact["hash"]) for artifact in (([package["sdist"]] if "sdist" in package else []) + package.get("wheels", []))))); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and (not fork or sorted(package_identity(package) for package in lock["package"] if package is not roots[0]) == sorted(package_identity(package) for package in trusted_lock["package"] if package.get("source") != {"editable": "."})) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -70,7 +70,7 @@ jobs: - name: Verify dependency source provenance before installing tools working-directory: openai-python run: | - python -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + python -I -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); trusted_lock = tomllib.loads(subprocess.check_output(["git", "show", base + ":uv.lock"], text=True)) if fork else lock; roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); package_identity = lambda package: (canonical(package["name"]), package["version"], package.get("source", {}).get("registry", ""), tuple(sorted((artifact["url"], artifact["hash"]) for artifact in (([package["sdist"]] if "sdist" in package else []) + package.get("wheels", []))))); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and (not fork or sorted(package_identity(package) for package in lock["package"] if package is not roots[0]) == sorted(package_identity(package) for package in trusted_lock["package"] if package.get("source") != {"editable": "."})) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index 28de13bea4..1257c1d123 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -280,7 +280,7 @@ def minimums(requirements: set[str], *, allow_missing: bool = False, exact: bool result: list[StableRelease] = [] for requirement in requirements: specifier = requirement.split(";", 1)[0] - pattern = r"(?=!~])(?:>=|==)([^,;]+)" if exact else r"(?=!~])>=([^,;]+)" + pattern = r"(?=!~])(?:>=|>|==)([^,;]+)" if exact else r"(?=!~])(?:>=|>)([^,;]+)" matches = re.findall(pattern, specifier) if len(matches) != 1: if allow_missing and not matches: @@ -339,7 +339,7 @@ def unchanged_nonfloor_bounds(requirement: str) -> tuple[tuple[str, StableReleas if bound is None: raise SystemExit("Ambiguous split security dependency bound") operator, value = bound.group(1), stable_version(bound.group(2)) - if operator not in {">=", "=="}: + if operator not in {">=", ">", "=="}: bounds.append((operator, value)) return tuple(sorted(bounds)) @@ -524,6 +524,15 @@ def allows_published_release(bounds: tuple[PublishedBound, ...], release: Stable def preserves_published_security_bound(previous: PublishedBound, current: tuple[PublishedBound, ...]) -> bool: operator, epoch, components, post, wildcard = previous + if operator in {">", ">="}: + limit = epoch, components, post + for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: + if candidate_wildcard or updated not in {">", ">=", "=="}: + continue + bound = candidate_epoch, candidate, candidate_post + if bound > limit or bound == limit and (operator == ">=" or updated == ">"): + return True + return False if operator in {"<", "<="}: limit = epoch, components, post for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: @@ -579,10 +588,11 @@ def preserves_dependency_security_bounds( if match is None: raise SystemExit("Ambiguous unchanged published security dependency requirement") clauses = match.group(3).split(",") - if not any(re.match(r"(?:!=|<=|<)", clause.strip()) for clause in clauses): + if not any(re.match(r"(?:!=|<=|<|>=|>)", clause.strip()) for clause in clauses): continue before = published_bounds(requirement) - protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!="}) + protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!=", ">", ">="}) + preserve_releases = any(bound[0] in {"<", "<=", "!="} for bound in protected) context_replacements = replacements.get(previous_context, {}) if not context_replacements: return False @@ -597,8 +607,8 @@ def preserves_dependency_security_bounds( preserved = False for candidate in candidates: after = published_bounds(candidate) - if all(preserves_published_security_bound(bound, after) for bound in protected) and all( - allows_published_release(after, release) for release in retained + if all(preserves_published_security_bound(bound, after) for bound in protected) and ( + not preserve_releases or all(allows_published_release(after, release) for release in retained) ): preserved = True break diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 25a38fc204..5f877dae78 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -80,9 +80,9 @@ def dependency_lock_source_command() -> str: line = next( entry for entry in path.read_text().splitlines() - if "python -c '" in entry and "Use only the public PyPI registry" in entry + if "python -I -c '" in entry and "Use only the public PyPI registry" in entry ) - command = line.split("python -c '", 1)[1].rsplit("'", 1)[0] + command = line.split("python -I -c '", 1)[1].rsplit("'", 1)[0] if sys.version_info < (3, 11): command = "import sys, tomli; sys.modules['tomllib'] = tomli; " + command return command @@ -108,6 +108,7 @@ def run_dependency_lock_source_check( trusted_base_constraints: list[str] | None = None, trusted_base_backend: str = "hatchling.build", trusted_base_sha: str = "a" * 40, + trusted_base_lock: str | None = None, trusted_origin: str = "https://github.com/openai/openai-python.git", ) -> subprocess.CompletedProcess[str]: project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] @@ -219,6 +220,9 @@ def run_dependency_lock_source_check( + "\n" ) (tmp_path / "trusted-base.toml").write_text(trusted_configuration) + (tmp_path / "trusted-base.lock").write_text( + (tmp_path / "uv.lock").read_text() if trusted_base_lock is None else trusted_base_lock + ) fake_git = tmp_path / "git" fake_git.write_text( f"#!{sys.executable}\n" @@ -233,6 +237,8 @@ def run_dependency_lock_source_check( " pass\n" "elif arguments == ['show', sha + ':pyproject.toml']:\n" " print((root / 'trusted-base.toml').read_text(), end='')\n" + "elif arguments == ['show', sha + ':uv.lock']:\n" + " print((root / 'trusted-base.lock').read_text(), end='')\n" "else:\n" " raise SystemExit('Unexpected or unsafe git operation')\n" ) @@ -637,6 +643,112 @@ def test_fork_build_backend_must_match_immutable_reviewed_base(tmp_path: Path, v assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("added-default", False, id="fork-cannot-add-unreviewed-default-wheel"), + pytest.param("added-optional", False, id="fork-cannot-add-unreviewed-optional-wheel"), + pytest.param("removed-package", False, id="fork-cannot-remove-immutable-reviewed-identity"), + pytest.param("replaced-name", False, id="fork-cannot-swap-reviewed-package-name"), + pytest.param("replaced-version", False, id="fork-cannot-swap-reviewed-package-version"), + pytest.param("replaced-wheel-url", False, id="fork-cannot-swap-reviewed-wheel-url"), + pytest.param("replaced-wheel-hash", False, id="fork-cannot-swap-reviewed-wheel-hash"), + pytest.param("replaced-sdist-url", False, id="fork-cannot-swap-reviewed-source-url"), + pytest.param("replaced-sdist-hash", False, id="fork-cannot-swap-reviewed-source-hash"), + pytest.param("added-wheel", False, id="fork-cannot-add-an-unreviewed-wheel"), + pytest.param("removed-wheel", False, id="fork-cannot-drop-an-immutable-reviewed-wheel"), + pytest.param("duplicate-package", False, id="fork-cannot-hide-an-extra-identity-in-a-set"), + pytest.param("reordered-wheels", True, id="fork-may-reorder-identical-reviewed-artifacts"), + pytest.param("canonical-name", True, id="fork-may-canonicalize-identical-reviewed-name"), + pytest.param("same-repo-maintainer", True, id="same-repo-maintainer-may-update-wheel"), + pytest.param("same-repo-dependabot", True, id="same-repo-security-bot-may-add-wheel"), + ], +) +def test_fork_dependency_identities_must_match_immutable_reviewed_lock( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + reviewed: dict[str, object] = { + "name": "reviewed_dependency", + "version": "2.0", + "source": {"registry": "https://pypi.org/simple"}, + "sdist": { + "url": "https://files.pythonhosted.org/packages/reviewed-2.0.tar.gz", + "hash": "sha256:" + "a" * 64, + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/reviewed-2.0-py3-none-any.whl", + "hash": "sha256:" + "b" * 64, + }, + { + "url": "https://files.pythonhosted.org/packages/reviewed-2.0-linux.whl", + "hash": "sha256:" + "c" * 64, + }, + ], + } + baseline = run_dependency_lock_source_check(tmp_path, [root, reviewed], trusted_fork=True) + assert baseline.returncode == 0, baseline.stdout + baseline.stderr + trusted_lock = (tmp_path / "uv.lock").read_text() + packages = cast(list[dict[str, object]], json.loads(json.dumps([root, reviewed]))) + package = packages[1] + wheels = cast(list[dict[str, str]], package["wheels"]) + sdist = cast(dict[str, str], package["sdist"]) + fork = not variant.startswith("same-repo-") + + if variant in {"added-default", "added-optional", "same-repo-dependabot"}: + packages.append( + { + "name": "attacker-owned-plugin", + "version": "1.0", + "source": {"registry": "https://pypi.org/simple"}, + } + ) + elif variant == "removed-package": + packages.pop() + elif variant == "replaced-name": + package["name"] = "attacker-owned-plugin" + elif variant in {"replaced-version", "same-repo-maintainer"}: + package["version"] = "2.1" + elif variant == "replaced-wheel-url": + wheels[0]["url"] = "https://files.pythonhosted.org/packages/attacker-2.0-py3-none-any.whl" + elif variant == "replaced-wheel-hash": + wheels[0]["hash"] = "sha256:" + "d" * 64 + elif variant == "replaced-sdist-url": + sdist["url"] = "https://files.pythonhosted.org/packages/attacker-2.0.tar.gz" + elif variant == "replaced-sdist-hash": + sdist["hash"] = "sha256:" + "d" * 64 + elif variant == "added-wheel": + wheels.append( + { + "url": "https://files.pythonhosted.org/packages/attacker-2.0-linux.whl", + "hash": "sha256:" + "d" * 64, + } + ) + elif variant == "removed-wheel": + wheels.pop() + elif variant == "duplicate-package": + packages.append(cast(dict[str, object], json.loads(json.dumps(package)))) + elif variant == "reordered-wheels": + wheels.reverse() + packages.reverse() + elif variant == "canonical-name": + package["name"] = "Reviewed.Dependency" + + result = run_dependency_lock_source_check( + tmp_path, + packages, + trusted_fork=fork, + trusted_base_lock=trusted_lock, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def test_fork_build_provenance_uses_immutable_pull_request_identity() -> None: for name in ("ci.yml", "detect-breaking-changes.yml"): workflow = (ROOT / ".github/workflows" / name).read_text() @@ -735,7 +847,7 @@ def dependency_installer_jobs(jobs: dict[str, str]) -> set[str]: def test_dependency_provenance_runs_before_tool_setup() -> None: gate = dependency_workflow_jobs()["dependency-locks"] source = next(line for line in gate.splitlines() if "Use only the public PyPI registry" in line) - assert source.strip().startswith("python -c '") + assert source.strip().startswith("python -I -c '") before = gate.split(source, 1)[0] actions = re.findall(r"^ - uses:\s*(\S+)", before, re.MULTILINE) @@ -1224,10 +1336,10 @@ def test_breaking_change_installers_validate_provenance_first(name: str) -> None assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}.*", steps[0]) assert steps[1] == "Verify dependency source provenance before installing tools" source = next(line for line in job.splitlines() if "Use only the public PyPI registry" in line) - command = source.split("python -c '", 1)[1].rsplit("'", 1)[0] + command = source.split("python -I -c '", 1)[1].rsplit("'", 1)[0] workflow = (ROOT / ".github/workflows/ci.yml").read_text() expected = next(line for line in workflow.splitlines() if "Use only the public PyPI registry" in line) - assert command == expected.split("python -c '", 1)[1].rsplit("'", 1)[0] + assert command == expected.split("python -I -c '", 1)[1].rsplit("'", 1)[0] if name == "agents_sdk": gate = job.split(" - name: Verify dependency source provenance before installing tools\n", 1)[1] gate = gate.split("\n - name:", 1)[0] @@ -1238,7 +1350,7 @@ def test_security_dependency_policy_is_directly_testable_after_the_trusted_gate( workflow = (ROOT / ".github/workflows/ci.yml").read_text() job = dependency_workflow_jobs()["dependency-locks"] gate = job.index("Verify dependency source provenance before installing tools") - policy = job.index("python scripts/check-dependency-security.py") + policy = job.index('git show "$BASE_SHA:scripts/check-dependency-security.py" | python -I -') assert gate < policy assert "python - <<'PY'" not in job script = ROOT / "scripts/check-dependency-security.py" @@ -1263,7 +1375,9 @@ def security_dependency_floor_program() -> str: "contains(github.event.pull_request.head.ref, 'python-security')", ): assert condition in body - assert "run: python scripts/check-dependency-security.py" in body + assert "set -euo pipefail" in body + assert 'git fetch --no-tags --depth=1 origin "$BASE_SHA"' in body + assert 'git show "$BASE_SHA:scripts/check-dependency-security.py" | python -I -' in body program = (ROOT / "scripts/check-dependency-security.py").read_text() if sys.version_info < (3, 11): program = program.replace( @@ -1274,6 +1388,87 @@ def security_dependency_floor_program() -> str: return program +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("trusted-base", True, id="security-checker-runs-only-immutable-base-script"), + pytest.param("tampered-head", True, id="submitted-no-op-cannot-replace-trusted-checker"), + pytest.param("missing-base-script", False, id="missing-trusted-checker-never-falls-back-to-head"), + pytest.param("invalid-base", False, id="checker-rejects-noncanonical-event-base-sha"), + pytest.param("foreign-origin", False, id="checker-rejects-an-attacker-controlled-origin"), + pytest.param("credential-origin", False, id="checker-rejects-credential-bearing-origin"), + pytest.param("stdlib-shadow", True, id="isolated-trusted-checker-ignores-checkout-module-shadow"), + ], +) +def test_security_floor_checker_executes_only_authenticated_base(tmp_path: Path, variant: str, accepted: bool) -> None: + gate = dependency_workflow_jobs()["dependency-locks"] + step = gate.split(" - name: Require published minimums for direct security updates\n", 1)[1] + step = step.split("\n - name:", 1)[0] + match = re.search( + r" run: (?:(?P[^|\n][^\n]*)|\|\n(?P(?: [^\n]*(?:\n|$))+))", + step, + ) + assert match is not None + program = ( + match.group("inline") + if match.group("inline") is not None + else "\n".join(line[10:] for line in match.group("block").splitlines()) + ) + sha = "a" * 40 if variant != "invalid-base" else "a" * 39 + "Z" + origin = "https://github.com/openai/openai-python.git" + if variant == "foreign-origin": + origin = "https://github.com/attacker/openai-python.git" + elif variant == "credential-origin": + origin = "https://token@github.com/openai/openai-python.git" + + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "check-dependency-security.py").write_text( + "import pathlib; pathlib.Path('executed-head').write_text('attacker')\n" + ) + if variant == "stdlib-shadow": + (tmp_path / "subprocess.py").write_text( + "import pathlib; pathlib.Path('shadow-imported').write_text('attacker')\n" + ) + trusted_program = ( + "import subprocess, pathlib\n" + "assert pathlib.Path(subprocess.__file__).resolve().parent != pathlib.Path.cwd()\n" + "pathlib.Path('executed-base').write_text('trusted')\n" + ) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import sys\n" + f"sha = {sha!r}\n" + f"origin = {origin!r}\n" + f"missing = {variant == 'missing-base-script'!r}\n" + f"source = {trusted_program!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['fetch', '--no-tags', '--depth=1', 'origin', sha]:\n" + " pass\n" + "elif arguments == ['show', sha + ':scripts/check-dependency-security.py'] and not missing:\n" + " print(source, end='')\n" + "else:\n" + " raise SystemExit('Unexpected or unsafe git operation')\n" + ) + fake_git.chmod(0o755) + environment = dict(os.environ, BASE_SHA=sha, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"]) + result = subprocess.run( + ["/bin/bash", "-euo", "pipefail", "-c", program], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + assert (tmp_path / "executed-base").exists() is accepted + assert not (tmp_path / "executed-head").exists() + assert not (tmp_path / "shadow-imported").exists() + + @pytest.mark.parametrize( ("actor", "author", "reference", "event", "accepted"), [ @@ -2399,6 +2594,84 @@ def test_patched_locks_preserve_existing_published_security_bounds( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-inclusive", False, id="strict-published-runtime-floor-cannot-become-inclusive"), + pytest.param("optional-inclusive", False, id="strict-optional-floor-cannot-become-inclusive"), + pytest.param("constraint-inclusive", False, id="strict-uv-constraint-cannot-become-inclusive"), + pytest.param("build-inclusive", False, id="strict-build-constraint-cannot-become-inclusive"), + pytest.param("group-inclusive", False, id="strict-development-floor-cannot-become-inclusive"), + pytest.param("strict-lowered", False, id="strict-lower-version-cannot-weaken"), + pytest.param("strict-dropped", False, id="strict-floor-cannot-disappear"), + pytest.param("epoch-inclusive", False, id="strict-epoch-floor-cannot-become-inclusive"), + pytest.param("post-inclusive", False, id="strict-post-release-floor-cannot-become-inclusive"), + pytest.param("marker-inclusive", False, id="strict-contextual-floor-cannot-become-inclusive"), + pytest.param("strict-preserved", True, id="unchanged-strict-floor-remains-supported"), + pytest.param("strict-raised", True, id="strict-floor-may-increase"), + pytest.param("inclusive-higher", True, id="higher-inclusive-floor-may-replace-strict-floor"), + pytest.param("inclusive-to-strict", True, id="inclusive-floor-may-strengthen-to-strict"), + pytest.param("canonical-strict", True, id="canonical-equivalent-strict-floor-remains-supported"), + ], +) +def test_grouped_security_updates_preserve_strict_dependency_floors( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous, current = "danger>1", "danger>=1" + locked = "2" + optional = variant == "optional-inclusive" + constraints: tuple[list[str], list[str]] | None = None + build: tuple[list[str], list[str]] | None = None + groups: tuple[dict[str, list[str]], dict[str, list[str]]] | None = None + + if variant == "constraint-inclusive": + constraints = ([previous], [current]) + elif variant == "build-inclusive": + build = ([previous], [current]) + elif variant == "group-inclusive": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + elif variant == "strict-lowered": + previous, current, locked = "danger>2", "danger>1", "3" + elif variant == "strict-dropped": + current = "danger" + elif variant == "epoch-inclusive": + previous, current, locked = "danger>1!1", "danger>=1!1", "1!2" + elif variant == "post-inclusive": + previous, current, locked = "danger>1.post2", "danger>=1.post2", "1.post3" + elif variant == "marker-inclusive": + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.11'" + elif variant == "strict-preserved": + current = previous + elif variant == "strict-raised": + current = "danger>1.5" + elif variant == "inclusive-higher": + current = "danger>=1.5" + elif variant == "inclusive-to-strict": + previous, current = "danger>=1", "danger>1" + elif variant == "canonical-strict": + previous, current = "danger>1.0", "danger>1" + + protected = constraints is not None or build is not None or groups is not None + base_requirements = ["patch-me>=1"] + ([] if protected else [previous]) + head_requirements = ["patch-me>=1.1"] + ([] if protected else [current]) + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), ("danger", locked)], + head_packages=[("patch-me", "1.1"), ("danger", locked)], + optional=optional, + base_constraints=None if constraints is None else constraints[0], + head_constraints=None if constraints is None else constraints[1], + base_build_constraints=None if build is None else build[0], + head_build_constraints=None if build is None else build[1], + base_dependency_groups=None if groups is None else groups[0], + head_dependency_groups=None if groups is None else groups[1], + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From 7a3b31a3d8d6f0cadf6d31becf89c3e757a3ae5a Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 00:42:51 +0000 Subject: [PATCH 25/26] fix(security): preserve reviewed exact dependency pins --- scripts/check-dependency-security.py | 62 ++++++++++++-- tests/test_uv_workflows.py | 119 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index 1257c1d123..6854bb3ec9 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -576,9 +576,48 @@ def preserves_published_security_bound(previous: PublishedBound, current: tuple[ return False +def preserves_exact_pinned_release( + previous: PublishedBound, + current: tuple[PublishedBound, ...], + context: MarkerContext, + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, +) -> bool: + if preserves_published_security_bound(previous, current): + return True + operator, epoch, components, post, wildcard = previous + if operator != "==" or wildcard: + return False + pinned = epoch, components, post + replacements: list[StableRelease] = [] + for domain, versions in previous_domains.items(): + if not marker_overlap(context, domain): + continue + prior = {stable_version(version) for version in versions} + if pinned not in prior: + continue + updated = {stable_version(version) for version in current_domains.get(domain, set())} + removed = prior - updated + introduced = updated - prior + if removed != {pinned} or len(introduced) != 1: + return False + patched = next(iter(introduced)) + if patched <= pinned: + return False + replacements.append(patched) + if len(set(replacements)) != 1: + return False + patched = replacements[0] + return any( + operator == "==" and not wildcard and (epoch, components, post) == patched + for operator, epoch, components, post, wildcard in current + ) + + def preserves_dependency_security_bounds( previous_contexts: ContextRequirements, replacements: ContextReplacements, + previous_domains: ResolutionDomains, domains: ResolutionDomains, ) -> bool: for previous_context, previous_requirements in previous_contexts.items(): @@ -588,11 +627,11 @@ def preserves_dependency_security_bounds( if match is None: raise SystemExit("Ambiguous unchanged published security dependency requirement") clauses = match.group(3).split(",") - if not any(re.match(r"(?:!=|<=|<|>=|>)", clause.strip()) for clause in clauses): + if not any(re.match(r"(?:!=|<=|<|>=|>|==)", clause.strip()) for clause in clauses): continue before = published_bounds(requirement) - protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!=", ">", ">="}) - preserve_releases = any(bound[0] in {"<", "<=", "!="} for bound in protected) + protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!=", ">", ">=", "=="}) + preserve_releases = any(bound[0] in {"<", "<=", "!=", "=="} for bound in protected) context_replacements = replacements.get(previous_context, {}) if not context_replacements: return False @@ -607,7 +646,12 @@ def preserves_dependency_security_bounds( preserved = False for candidate in candidates: after = published_bounds(candidate) - if all(preserves_published_security_bound(bound, after) for bound in protected) and ( + if all( + preserves_exact_pinned_release(bound, after, replacement_context[3], previous_domains, domains) + if bound[0] == "==" + else preserves_published_security_bound(bound, after) + for bound in protected + ) and ( not preserve_releases or all(allows_published_release(after, release) for release in retained) ): preserved = True @@ -748,7 +792,9 @@ def secures_supported_published_branches( context: replacement_contexts(context, prior_requirements, current_contexts, previous_domains, exact=True) for context, prior_requirements in previous_contexts.items() } - if not preserves_dependency_security_bounds(previous_contexts, security_replacements, current_domains): + if not preserves_dependency_security_bounds( + previous_contexts, security_replacements, previous_domains, current_domains + ): raise SystemExit("Do not weaken a protected dependency security exclusion or upper bound for " + name) prior_minimums = minimums(previous, allow_missing=True, exact=True) if not prior_minimums: @@ -865,7 +911,7 @@ def secures_supported_published_branches( previous_domains = old_resolution_contexts.get(name, {}) current_domains = new_resolution_contexts.get(name, {}) if (previous != requirements or previous_contexts != current_contexts) and not preserves_dependency_security_bounds( - previous_contexts, direct_replacements.get(name, {}), current_domains + previous_contexts, direct_replacements.get(name, {}), previous_domains, current_domains ): raise SystemExit("Do not weaken a published security exclusion or upper bound for " + name) if old_versions.get(name, set()) == new_versions.get(name, set()) and previous_domains == current_domains: @@ -905,7 +951,7 @@ def secures_supported_published_branches( continue covered.update(domains) patched_minimum = max(patched_domains[domain] for domain in domains) - updated_minimums = minimums(context_requirements) + updated_minimums = minimums(context_requirements, exact=True) original_context = next( ( original @@ -914,7 +960,7 @@ def secures_supported_published_branches( ), context, ) - previous_minimums = minimums(previous_contexts.get(original_context, set()), allow_missing=True) + previous_minimums = minimums(previous_contexts.get(original_context, set()), allow_missing=True, exact=True) if ( not updated_minimums or any(updated < patched_minimum for updated in updated_minimums) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 5f877dae78..0ef04e6df7 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -2594,6 +2594,125 @@ def test_patched_locks_preserve_existing_published_security_bounds( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-lower", False, id="unchanged-runtime-exact-pin-cannot-widen-to-floor"), + pytest.param("runtime-upper", False, id="unchanged-runtime-exact-pin-cannot-widen-to-ceiling"), + pytest.param("runtime-range", False, id="unchanged-runtime-exact-pin-cannot-widen-to-range"), + pytest.param("runtime-removed", False, id="unchanged-runtime-exact-pin-cannot-be-removed"), + pytest.param("runtime-replaced", False, id="unchanged-runtime-lock-cannot-swap-exact-pin"), + pytest.param("optional-lower", False, id="unchanged-optional-exact-pin-cannot-widen"), + pytest.param("constraint-lower", False, id="unchanged-uv-exact-pin-cannot-widen"), + pytest.param("build-lower", False, id="unchanged-build-exact-pin-cannot-widen"), + pytest.param("group-lower", False, id="unchanged-development-exact-pin-cannot-widen"), + pytest.param("marker-lower", False, id="unchanged-marker-scoped-exact-pin-cannot-widen"), + pytest.param("epoch-lower", False, id="unchanged-epoch-exact-pin-cannot-widen"), + pytest.param("post-lower", False, id="unchanged-stable-post-exact-pin-cannot-widen"), + pytest.param("upgrade-widen", False, id="patched-release-cannot-replace-exact-pin-with-range"), + pytest.param("upgrade-wrong-pin", False, id="patched-release-must-match-replacement-exact-pin"), + pytest.param("upgrade-retained-old", False, id="replacement-pin-cannot-leave-original-release-live"), + pytest.param("upgrade-ambiguous", False, id="replacement-pin-requires-one-for-one-locked-upgrade"), + pytest.param("upgrade-downgrade", False, id="replacement-pin-cannot-follow-a-downgraded-release"), + pytest.param("exact-preserved", True, id="unchanged-original-exact-pin-remains-supported"), + pytest.param("canonical-preserved", True, id="canonical-equivalent-exact-pin-remains-supported"), + pytest.param("redundant-preserved", True, id="exact-pin-with-redundant-safe-bounds-remains-supported"), + pytest.param("runtime-upgrade", True, id="published-exact-pin-may-track-real-security-upgrade"), + pytest.param("optional-upgrade", True, id="optional-exact-pin-may-track-real-security-upgrade"), + pytest.param("constraint-upgrade", True, id="uv-exact-pin-may-track-real-security-upgrade"), + pytest.param("build-upgrade", True, id="reviewed-build-exact-pin-may-track-real-security-upgrade"), + pytest.param("group-upgrade", True, id="development-exact-pin-may-track-real-security-upgrade"), + pytest.param("epoch-upgrade", True, id="epoch-exact-pin-may-track-matching-security-upgrade"), + pytest.param("post-upgrade", True, id="stable-post-exact-pin-may-track-matching-security-upgrade"), + pytest.param("marker-upgrade", True, id="contextual-exact-pin-may-track-matching-security-upgrade"), + ], +) +def test_grouped_security_updates_preserve_exact_dependency_pins(tmp_path: Path, variant: str, accepted: bool) -> None: + previous, current = "danger==1", "danger>=1" + before, after = ["1"], ["1"] + optional = variant.startswith("optional-") + constraints: tuple[list[str], list[str]] | None = None + build: tuple[list[str], list[str]] | None = None + groups: tuple[dict[str, list[str]], dict[str, list[str]]] | None = None + + if variant == "runtime-upper": + current = "danger<=1" + elif variant == "runtime-range": + current = "danger>=1,<2" + elif variant == "runtime-removed": + current = "danger" + elif variant == "runtime-replaced": + current = "danger==2" + elif variant == "constraint-lower": + constraints = ([previous], [current]) + elif variant == "build-lower": + build = ([previous], [current]) + elif variant == "group-lower": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + elif variant in {"marker-lower", "marker-upgrade"}: + previous += "; python_version >= '3.11'" + if variant == "marker-upgrade": + current = "danger==2; python_version >= '3.11'" + after = ["2"] + else: + current += "; python_version >= '3.11'" + elif variant in {"epoch-lower", "epoch-upgrade"}: + previous = "danger==1!1" + before = ["1!1"] + if variant == "epoch-upgrade": + current, after = "danger==1!2", ["1!2"] + else: + current, after = "danger>=1!1", ["1!1"] + elif variant in {"post-lower", "post-upgrade"}: + previous = "danger==1.post1" + before = ["1.post1"] + if variant == "post-upgrade": + current, after = "danger==1.post2", ["1.post2"] + else: + current, after = "danger>=1.post1", ["1.post1"] + elif variant == "upgrade-widen": + current, after = "danger>=2", ["2"] + elif variant == "upgrade-wrong-pin": + current, after = "danger==3", ["2"] + elif variant == "upgrade-retained-old": + current, after = "danger==2", ["1", "2"] + elif variant == "upgrade-ambiguous": + current, after = "danger==2", ["2", "3"] + elif variant == "upgrade-downgrade": + previous, current, before, after = "danger==2", "danger==1", ["2"], ["1"] + elif variant == "exact-preserved": + current = previous + elif variant == "canonical-preserved": + previous, current = "danger==1.0", "danger==1" + elif variant == "redundant-preserved": + current = "danger==1,>=1" + elif variant.endswith("-upgrade"): + current, after = "danger==2", ["2"] + if variant == "constraint-upgrade": + constraints = ([previous], [current]) + elif variant == "build-upgrade": + build = ([previous], [current]) + elif variant == "group-upgrade": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + + protected = constraints is not None or build is not None or groups is not None + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1"] + ([] if protected else [previous]), + head_requirements=["patch-me>=1.1"] + ([] if protected else [current]), + base_packages=[("patch-me", "1"), *[("danger", version) for version in before]], + head_packages=[("patch-me", "1.1"), *[("danger", version) for version in after]], + optional=optional, + base_constraints=None if constraints is None else constraints[0], + head_constraints=None if constraints is None else constraints[1], + base_build_constraints=None if build is None else build[0], + head_build_constraints=None if build is None else build[1], + base_dependency_groups=None if groups is None else groups[0], + head_dependency_groups=None if groups is None else groups[1], + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + @pytest.mark.parametrize( ("variant", "accepted"), [ From 95769f4bc6634497e050c3e75f67b23a4a7bbd4e Mon Sep 17 00:00:00 2001 From: Hayden Date: Thu, 20 Aug 2026 00:55:18 +0000 Subject: [PATCH 26/26] fix(security): require boundaries for transitive dependency patches --- scripts/check-dependency-security.py | 58 +++++++++++ tests/test_uv_workflows.py | 147 ++++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index 6854bb3ec9..11bb7a936d 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -769,6 +769,34 @@ def secures_supported_published_branches( return observed +def covers_transitive_security_release( + requirements: ContextRequirements, + domain: MarkerContext, + removed: StableRelease, + patched: StableRelease, + current_domains: ResolutionDomains, +) -> bool: + for context, declarations in requirements.items(): + if not marker_overlap(context[3], domain): + continue + for requirement in declarations: + floors = minimums({requirement}, allow_missing=True, exact=True) + if len(floors) != 1 or floors[0] < patched: + continue + bounds = published_bounds(requirement) + if not allows_published_release(bounds, patched) or allows_published_release(bounds, removed): + continue + if any( + other != domain + and marker_overlap(context[3], other) + and any(not allows_published_release(bounds, stable_version(version)) for version in versions) + for other, versions in current_domains.items() + ): + continue + return True + return False + + old_project = read_base("pyproject.toml") old_lock = read_base("uv.lock") new_project = cast(dict[str, Any], tomllib.loads(pathlib.Path("pyproject.toml").read_text())) @@ -865,6 +893,36 @@ def secures_supported_published_branches( ) ): raise SystemExit("Raise the contextual protected security minimum to the patched release for " + name) + +for name in old_versions.keys() & new_versions.keys(): + if name in old_direct or name in new_direct: + continue + previous_contexts = old_protected_contexts.get(name, {}) + if any(minimums(requirements, allow_missing=True, exact=True) for requirements in previous_contexts.values()): + continue + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions or not updated_versions: + continue + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + if not introduced: + continue + if ( + not removed + or len(introduced) != len(removed) + or any(updated <= previous for previous, updated in zip(removed, introduced, strict=True)) + ): + raise SystemExit("Missing contextual upgraded transitive security dependency release for " + name) + for removed_release, patched_release in zip(removed, introduced, strict=True): + if not covers_transitive_security_release( + new_protected_contexts.get(name, {}), domain, removed_release, patched_release, current_domains + ): + raise SystemExit("Add a reviewed contextual transitive security dependency boundary for " + name) + direct_replacements: dict[str, ContextReplacements] = {} for name, previous_contexts in old_contexts.items(): current_contexts = new_contexts.get(name, {}) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 0ef04e6df7..ae2b33bf25 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -2261,8 +2261,8 @@ def lock(packages: list[tuple[str, str]], resolutions: dict[tuple[str, str], lis [("safe-direct", "1.0"), ("transitive", "1.0")], [("safe-direct", "1.0"), ("transitive", "1.1")], False, - True, - id="transitive-only", + False, + id="unbounded-transitive-security-update-rejected", ), pytest.param( ["danger-pkg>=1.0"], @@ -2955,6 +2955,7 @@ def test_security_updates_cannot_remove_published_direct_dependencies( head_packages = [("safe-direct", "1")] base_groups: dict[str, list[str]] | None = None head_groups: dict[str, list[str]] | None = None + head_constraints: list[str] | None = None if variant == "runtime-unbounded-removed": base_requirements = ["safe-direct>=1", "danger-pkg"] @@ -2987,6 +2988,7 @@ def test_security_updates_cannot_remove_published_direct_dependencies( head_requirements = list(base_requirements) base_packages = [*base_packages, ("transitive", "1")] head_packages = [("safe-direct", "1"), ("danger-pkg", "1"), ("transitive", "1.1")] + head_constraints = ["transitive>=1.1"] elif variant == "canonical-group": base_requirements = head_requirements = ["safe-direct>=1"] head_packages = list(base_packages) @@ -3001,6 +3003,7 @@ def test_security_updates_cannot_remove_published_direct_dependencies( head_packages=head_packages, base_optional_groups=base_groups, head_optional_groups=head_groups, + head_constraints=head_constraints, ) assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr @@ -3600,9 +3603,11 @@ def test_protected_security_floors_must_reach_their_patched_release( elif variant == "unrelated-transitive": base_packages.append(("unrelated", "1")) head_packages = [("cryptography", "50"), ("unrelated", "2")] + head_constraints = ["cryptography>=50", "unrelated>=2"] elif variant == "unbounded-group": base_constraints = head_constraints = None - base_groups = head_groups = {"dev": ["ruff"]} + base_groups = {"dev": ["ruff"]} + head_groups = {"dev": ["ruff>=2"]} base_packages, head_packages = [("ruff", "1")], [("ruff", "2")] elif variant in {"group-lock-only", "group-patched"}: base_constraints = head_constraints = None @@ -3690,6 +3695,142 @@ def test_protected_security_floors_must_reach_their_patched_release( assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("lock-only", False, id="newly-patched-transitive-lock-needs-security-boundary"), + pytest.param("grouped-lock-only", False, id="grouped-direct-patch-cannot-hide-transitive-lock-only"), + pytest.param("unbounded-group", False, id="unbounded-development-entry-is-not-a-security-boundary"), + pytest.param("constraint-missing-floor", False, id="new-uv-constraint-without-floor-does-not-protect"), + pytest.param("constraint-too-low", False, id="new-uv-constraint-must-reach-patched-release"), + pytest.param("constraint-old-inclusive", False, id="new-boundary-must-exclude-vulnerable-old-release"), + pytest.param("constraint-excludes-patch", False, id="new-boundary-must-admit-actual-patched-release"), + pytest.param("strict-excludes-patch", False, id="strict-boundary-cannot-exclude-patched-release"), + pytest.param("wrong-marker", False, id="transitive-security-floor-cannot-move-marker-domain"), + pytest.param("widened-marker", False, id="transitive-security-floor-cannot-widen-over-supported-line"), + pytest.param("added-without-removal", False, id="shared-transitive-upgrade-cannot-retain-vulnerable-release"), + pytest.param("ambiguous-upgrade", False, id="shared-transitive-upgrade-must-pair-one-for-one"), + pytest.param("downgrade", False, id="shared-transitive-security-release-cannot-downgrade"), + pytest.param("uv-floor", True, id="new-reviewed-uv-security-floor-covers-transitive-patch"), + pytest.param("uv-exact", True, id="new-reviewed-exact-uv-pin-covers-transitive-patch"), + pytest.param("build-exact", True, id="new-reviewed-build-pin-covers-transitive-patch"), + pytest.param("group-floor", True, id="new-reviewed-development-floor-covers-transitive-patch"), + pytest.param("published-floor", True, id="new-published-floor-covers-former-transitive-patch"), + pytest.param("unchanged", True, id="unchanged-unprotected-transitive-release-remains-supported"), + pytest.param("new-package", True, id="genuinely-new-package-introduction-remains-supported"), + pytest.param("removed-package", True, id="fully-removed-transitive-package-remains-supported"), + pytest.param("canonical-name", True, id="canonical-equivalent-transitive-name-remains-supported"), + pytest.param("marker-floor", True, id="matching-marker-security-floor-preserves-unaffected-line"), + pytest.param("epoch-floor", True, id="matching-epoch-security-floor-covers-transitive-patch"), + pytest.param("post-floor", True, id="matching-stable-post-floor-covers-transitive-patch"), + pytest.param("independent-majors", True, id="new-reviewed-v2-floor-preserves-independent-v1-lock"), + ], +) +def test_newly_patched_transitive_dependencies_require_security_boundaries( + tmp_path: Path, variant: str, accepted: bool +) -> None: + name, old, patched = "transitive", "1", "2" + base_requirements, head_requirements = ["patch-me>=1"], ["patch-me>=1.1"] + before: list[tuple[str, str]] = [(name, old)] + after: list[tuple[str, str]] = [(name, patched)] + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + base_build: list[str] | None = None + head_build: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + base_markers: dict[tuple[str, str], list[str]] | None = None + head_markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "lock-only": + head_requirements = list(base_requirements) + elif variant == "unbounded-group": + base_groups = head_groups = {"dev": [name]} + elif variant == "constraint-missing-floor": + head_constraints = [name] + elif variant == "constraint-too-low": + head_constraints = [name + ">=1.5"] + elif variant == "constraint-old-inclusive": + head_constraints = [name + ">=1"] + elif variant == "constraint-excludes-patch": + head_constraints = [name + ">=2,<2"] + elif variant == "strict-excludes-patch": + head_constraints = [name + ">2"] + elif variant in {"wrong-marker", "widened-marker", "marker-floor"}: + before = [(name, "1"), (name, "2")] + after = [(name, "1"), (name, "3")] + base_markers = { + (name, "1"): ["python_full_version < '3.11'"], + (name, "2"): ["python_full_version >= '3.11'"], + } + head_markers = { + (name, "1"): ["python_full_version < '3.11'"], + (name, "3"): ["python_full_version >= '3.11'"], + } + if variant == "wrong-marker": + head_constraints = [name + ">=3; python_version < '3.11'"] + elif variant == "widened-marker": + head_constraints = [name + ">=3"] + else: + head_constraints = [name + ">=3; python_version >= '3.11'"] + elif variant == "added-without-removal": + after = [(name, old), (name, patched)] + head_constraints = [name + ">=2"] + elif variant == "ambiguous-upgrade": + after = [(name, "2"), (name, "3")] + head_constraints = [name + ">=2"] + elif variant == "downgrade": + before, after = [(name, "2")], [(name, "1")] + head_constraints = [name + ">=1"] + elif variant == "uv-floor": + head_constraints = [name + ">=2"] + elif variant == "uv-exact": + head_constraints = [name + "==2"] + elif variant == "build-exact": + head_build = [name + "==2"] + elif variant == "group-floor": + head_groups = {"reviewed": [name + ">=2"]} + elif variant == "published-floor": + head_requirements.append(name + ">=2") + elif variant == "unchanged": + after = list(before) + elif variant == "new-package": + before = [] + elif variant == "removed-package": + after = [] + elif variant == "canonical-name": + before = [("Transitive_Pkg", old)] + after = [("transitive-pkg", patched)] + head_constraints = ["transitive.pkg>=2"] + elif variant == "epoch-floor": + before, after = [(name, "1!1")], [(name, "1!2")] + head_constraints = [name + ">=1!2"] + elif variant == "post-floor": + before, after = [(name, "1.post1")], [(name, "1.post2")] + head_constraints = [name + ">=1.post2"] + elif variant == "independent-majors": + name = "pydantic" + before, after = [(name, "1.10"), (name, "2.12")], [(name, "1.10"), (name, "2.13")] + head_groups = {"pydantic-v2": [name + ">=2.13,<3"]} + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), *before], + head_packages=[("patch-me", "1" if variant == "lock-only" else "1.1"), *after], + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build, + head_build_constraints=head_build, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + def test_security_floor_parser_strips_requirement_whitespace() -> None: assert "stable_version(matches[0].strip())" in security_dependency_floor_program()