From e525fdfaa25f64f3931bade09138881e4825be5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 02:59:36 +0000 Subject: [PATCH 1/2] test: normalize Union rendering across the Python matrix Python 3.14 formats typing.Union[a, b] as "a | b" while Python 3.10-3.13 format "Union[a, b]", so the get_persons signature manifest entry failed only on 3.14. The annotation object is unchanged; normalize the legacy rendering so one manifest stays valid on every supported interpreter. Co-authored-by: Matthew Spah --- tests/test_public_api.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index e0c1d45..6d2dc85 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -74,6 +74,22 @@ ) +# Python 3.14 renders typing.Union[a, b] as "a | b" while Python 3.10-3.13 +# render "Union[a, b]". The annotation object itself is unchanged, so the legacy +# spelling is rewritten here and one manifest stays valid across the whole +# supported interpreter matrix. +LEGACY_UNION_RENDERINGS: dict[str, str] = { + "Union[str, List[int]]": "str | List[int]", +} + + +def _normalize_annotation(annotation: Any) -> str: + rendered = inspect.formatannotation(annotation) + for legacy, pep604 in LEGACY_UNION_RENDERINGS.items(): + rendered = rendered.replace(legacy, pep604) + return rendered + + def _normalize_signature(fn: Any) -> str: """Return a stable, readable signature string without the ``self`` parameter.""" sig = inspect.signature(fn) @@ -84,7 +100,7 @@ def _normalize_signature(fn: Any) -> str: if parameter.kind is inspect.Parameter.VAR_KEYWORD: annotation = "" if parameter.annotation is not inspect.Parameter.empty: - annotation = f": {inspect.formatannotation(parameter.annotation)}" + annotation = f": {_normalize_annotation(parameter.annotation)}" parts.append(f"**{name}{annotation}") continue if parameter.kind is inspect.Parameter.VAR_POSITIONAL: @@ -92,7 +108,7 @@ def _normalize_signature(fn: Any) -> str: continue piece = name if parameter.annotation is not inspect.Parameter.empty: - piece += f": {inspect.formatannotation(parameter.annotation)}" + piece += f": {_normalize_annotation(parameter.annotation)}" if parameter.default is not inspect.Parameter.empty: piece += f"={parameter.default!r}" parts.append(piece) @@ -107,7 +123,7 @@ def _normalize_signature(fn: Any) -> str: "__exit__": "(exc_type, exc, traceback)", "get_people": "(sport_id: int=1, **params)", "get_person": "(player_id: int, **params)", - "get_persons": "(person_ids: Union[str, List[int]], **params)", + "get_persons": "(person_ids: str | List[int], **params)", "get_people_id": ( "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" ), From c5fa39db0e51d5b88d06eb1573df5b4fa5638ec2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 02:59:53 +0000 Subject: [PATCH 2/2] build: harden 1.0 release validation The release validator only clean-installed the wheel and only proved the strict HTTP default through constructor signatures, so a broken source distribution or a behavioral regression in strict handling could pass. Offline CI also still watched the stale release/0.9.0 branch and tested only Python 3.10-3.12. Validator: - clean-install the wheel and the source distribution in separate throwaway virtual environments and run the same smoke test against both - generalize wheel-only terminology to cover both distribution artifacts - verify a final fake 403 raises MlbHttpError under the default and under explicit strict_http=True, for Mlb and for MlbDataAdapter on v1 and v1.1 - verify strict_http=False returns the historical empty result and emits exactly one MlbHttpCompatibilityWarning mentioning strict_http=False - assert MlbHttpError status_code, reason, method, url, and response_data without freezing the exception or warning strings - verify an injected requests.Session keeps its headers and its exact adapter objects, never receives the library retry policy, and is never closed by the library - verify a library-created Session carries the installed-metadata User-Agent and the documented retry policy - label reverted strict defaults explicitly instead of raising a bare AssertionError - name the failing artifact, field, or path with expected and actual values in every validation error - expand the required source-distribution paths to the files the archive intentionally carries Tests: - unit-cover the validator helpers with synthetic wheel ZIPs and sdist tarballs, including every required failure mode - prove validate() clean-installs both artifacts in separate environments - lock the documentation Python-example checks to every release-notes file while keeping current-version checks off historical notes - require the release/1.0.0 CI trigger literally instead of deriving it from the still-unbumped package version - cover the Python matrix, twine check, and publishing-safety contract CI and docs: - watch main and release/1.0.0; drop release/0.9.0 - test Python 3.10 through 3.14 and build on 3.14 - add twine as a development dependency and run twine check on both artifacts; nothing is uploaded - state the 3.10 minimum and 3.10-3.14 CI coverage in the current docs The package version stays 0.9.0; the 1.0.0 bump belongs to a separate issue and the validator keeps reading the expected version from pyproject.toml. Co-authored-by: Matthew Spah --- .github/workflows/build-and-test.yml | 25 +- README.md | 14 +- docs/public-api.md | 14 +- docs/releases/1.0.0.md | 36 +- poetry.lock | 616 +++++++++++++++++- pyproject.toml | 1 + scripts/validate_release.py | 455 +++++++++++-- tests/test_release_validation.py | 936 ++++++++++++++++++++++++++- 8 files changed, 1997 insertions(+), 100 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 4842bd3..b4efbd6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -4,11 +4,11 @@ on: pull_request: branches: - main - - release/0.9.0 + - release/1.0.0 push: branches: - main - - release/0.9.0 + - release/1.0.0 workflow_dispatch: permissions: @@ -23,11 +23,18 @@ jobs: name: Offline tests - Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: + # Report every interpreter result instead of cancelling the matrix on the + # first failure, so a single-version incompatibility is easy to isolate. + fail-fast: false + # Python 3.15 is intentionally absent: it is still a prerelease during the + # 1.0 release work and is not claimed as a supported version. matrix: python-version: - "3.10" - "3.11" - "3.12" + - "3.13" + - "3.14" steps: - uses: actions/checkout@v4 @@ -55,10 +62,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.14 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - name: Install Poetry uses: snok/install-poetry@v1 with: @@ -70,8 +77,12 @@ jobs: run: | rm -rf dist poetry build - # Validates the built artifacts and a clean wheel installation. - # Runs outside the Poetry environment so the smoke test cannot import - # the repository checkout instead of the installed distribution. + # Validates artifact metadata, source-distribution contents, and a clean + # install of both the wheel and the source distribution. Runs outside the + # Poetry environment so the smoke tests cannot import the repository + # checkout instead of the installed distribution artifact. - name: Validate release artifacts run: python scripts/validate_release.py + # Metadata rendering check only; nothing is ever uploaded from CI. + - name: Twine check artifacts + run: poetry run twine check dist/* diff --git a/README.md b/README.md index 638c0e9..9a414a4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,17 @@ For detailed documentation, check out the [Wiki](https://github.com/zero-sum-sea python3 -m pip install python-mlb-statsapi ``` +### Python support + +| Claim | Value | +| --- | --- | +| Minimum declared Python version (`Requires-Python`) | `>=3.10` | +| CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | + +The minimum declared Python version is 3.10 and the CI-validated versions are +3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are +excluded from the required test matrix and are not claimed as supported. + ## Quick Start ```python >>> import mlbstatsapi @@ -480,9 +491,10 @@ poetry run pytest tests/ rm -rf dist poetry build python3 scripts/validate_release.py +poetry run twine check dist/* ``` -`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, installs the wheel into a temporary virtual environment, and runs a public-import smoke test against the installed package. It never contacts the MLB API. +`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API. Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. diff --git a/docs/public-api.md b/docs/public-api.md index 235268a..12ee217 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -332,12 +332,18 @@ Caller-injected Session | Claim | Value | | --- | --- | | Minimum declared Python version (`Requires-Python`) | `>=3.10` | -| Actively validated CI versions on this release branch | 3.10, 3.11, 3.12 | +| CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | | Later Python versions | May work, but are not claimed as CI-validated unless added to the matrix | -Version 1.0 does not add an upper Python bound. Absence of Python 3.13 (or -newer) CI coverage should be tracked under the release-validator / CI issue -stream rather than silently claimed here. +The minimum declared Python version is 3.10 and the CI-validated versions are +3.10 through 3.14. Every version in that range runs the deterministic offline +suite on each pull request and push to a watched branch. Prerelease +interpreters are deliberately excluded from the required matrix and are not +claimed as supported until they reach a stable release. + +Version 1.0 does not add an upper Python bound, and the declared runtime +requirement stays `>=3.10`. Adding a new interpreter is a compatible change: +extend the CI matrix and update this table in the same pull request. ## Mlb endpoint methods diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md index 21413e5..7a6ae61 100644 --- a/docs/releases/1.0.0.md +++ b/docs/releases/1.0.0.md @@ -186,8 +186,40 @@ Deterministic offline coverage documents the version 1.0 HTTP contract, including the strict default, explicit compatibility mode, warning behavior, 404 return shapes, Session ownership, and retry exhaustion. -`scripts/validate_release.py` remains the packaging smoke check for the built -wheel and source distribution. It never contacts the MLB API. +`scripts/validate_release.py` is the packaging check for the built artifacts. It +clean-installs the wheel and the source distribution into separate throwaway +virtual environments and runs the same installed-package smoke test against +each, so a broken sdist build, a missing runtime dependency, or an omitted +package file cannot hide behind a working wheel. + +Against the installed artifact the smoke test verifies: + +```text +Declared metadata matches the built version +Supported package-root imports resolve +Strict HTTP handling is the default for Mlb() and MlbDataAdapter() +A final 403 raises MlbHttpError with status, reason, method, URL, and payload +strict_http=False returns the historical empty result and warns exactly once +A library-created Session carries the versioned User-Agent and retry policy +A caller-injected Session keeps its headers, adapters, and ownership +``` + +Every response the smoke test observes is produced by an injected fake Session, +so release validation never contacts the MLB API. Continuous integration builds +the artifacts, runs the validator, and runs `twine check` on both artifacts. No +ordinary pull request or push path publishes anything. + +### Python support + +| Claim | Value | +| --- | --- | +| Minimum declared Python version (`Requires-Python`) | `>=3.10` | +| CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | + +The minimum declared Python version is 3.10 and the CI-validated versions are +3.10 through 3.14. Version 1.0.0 adds no upper Python bound and does not change +the declared runtime requirement. Prerelease interpreters are excluded from the +required matrix and are not claimed as supported. ## Migration guidance diff --git a/poetry.lock b/poetry.lock index 52de79e..4e15684 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -6,17 +6,36 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] + [[package]] name = "build" version = "1.4.0" description = "A simple, correct Python build frontend" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596"}, {file = "build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936"}, @@ -31,7 +50,7 @@ tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [package.extras] uv = ["uv (>=0.1.18)"] -virtualenv = ["virtualenv (>=20.11)", "virtualenv (>=20.17)", "virtualenv (>=20.31)"] +virtualenv = ["virtualenv (>=20.11) ; python_version < \"3.10\"", "virtualenv (>=20.17) ; python_version >= \"3.10\" and python_version < \"3.14\"", "virtualenv (>=20.31) ; python_version >= \"3.14\""] [[package]] name = "certifi" @@ -39,17 +58,133 @@ version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -172,17 +307,97 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\" or os_name == \"nt\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "50.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" +files = [ + {file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"}, + {file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"}, + {file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"}, + {file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"}, + {file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"}, + {file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, + {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + +[[package]] +name = "docutils" +version = "0.23" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea"}, + {file = "docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e"}, +] + [[package]] name = "exceptiongroup" version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -194,12 +409,33 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "id" +version = "1.6.1" +description = "A tool for generating OIDC identities" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca"}, + {file = "id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069"}, +] + +[package.dependencies] +urllib3 = ">=2,<3" + +[package.extras] +dev = ["build", "bump (>=1.3.2)", "id[lint,test]"] +lint = ["bandit", "interrogate", "mypy", "ruff (<0.14.15)"] +test = ["coverage[toml]", "pretend", "pytest", "pytest-cov"] + [[package]] name = "idna" version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -214,6 +450,8 @@ version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\" or python_full_version < \"3.10.2\"" files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -223,13 +461,13 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] [[package]] name = "iniconfig" @@ -237,17 +475,221 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, + {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30"}, + {file = "jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, + {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, +] + +[[package]] +name = "nh3" +version = "0.3.6" +description = "Python binding to Ammonia HTML sanitizer Rust crate" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2"}, + {file = "nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d"}, + {file = "nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e"}, + {file = "nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917"}, + {file = "nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4"}, + {file = "nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9"}, + {file = "nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6"}, + {file = "nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796"}, + {file = "nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6"}, + {file = "nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41"}, + {file = "nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec"}, + {file = "nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0"}, + {file = "nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78"}, + {file = "nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438"}, + {file = "nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56"}, + {file = "nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f"}, + {file = "nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da"}, + {file = "nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10"}, + {file = "nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21"}, + {file = "nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7"}, +] + [[package]] name = "packaging" version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -259,6 +701,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -268,12 +711,26 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -287,7 +744,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -295,6 +752,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -428,6 +886,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -442,6 +901,7 @@ version = "1.2.0" description = "Wrappers to call pyproject.toml-based build backend hooks." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, @@ -453,6 +913,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -470,12 +931,46 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + +[[package]] +name = "readme-renderer" +version = "45.0" +description = "readme_renderer is a library for rendering readme descriptions for Warehouse" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "readme_renderer-45.0-py3-none-any.whl", hash = "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f"}, + {file = "readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1"}, +] + +[package.dependencies] +docutils = ">=0.21.2" +nh3 = ">=0.2.14" +Pygments = ">=2.5.1" + +[package.extras] +md = ["comrak (>=0.0.11)"] + [[package]] name = "requests" version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -497,6 +992,7 @@ version = "1.12.1" description = "Mock out responses from the requests package" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, @@ -508,12 +1004,80 @@ requests = ">=2.22,<3" [package.extras] fixture = ["fixtures"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rfc3986" +version = "2.0.0" +description = "Validating URI References per RFC 3986" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, + {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, +] + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "15.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" +files = [ + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + [[package]] name = "tomli" version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, @@ -564,16 +1128,44 @@ files = [ {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] +[[package]] +name = "twine" +version = "6.2.0" +description = "Collection of utilities for publishing packages on PyPI" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8"}, + {file = "twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf"}, +] + +[package.dependencies] +id = "*" +keyring = {version = ">=21.2.0", markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""} +packaging = ">=24.0" +readme-renderer = ">=35.0" +requests = ">=2.20" +requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" +rfc3986 = ">=1.4.0" +rich = ">=12.0.0" +urllib3 = ">=1.26.0" + +[package.extras] +keyring = ["keyring (>=21.2.0)"] + [[package]] name = "typing-extensions" version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {dev = "python_version == \"3.10\""} [[package]] name = "typing-inspection" @@ -581,6 +1173,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -595,16 +1188,17 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "zipp" @@ -612,13 +1206,15 @@ version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\" or python_full_version < \"3.10.2\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -626,6 +1222,6 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.10" -content-hash = "7413638efb3c23e0e04b32ca1cf4a49cb1838cd5c30a00ac987f79c2e688b77f" +content-hash = "a010df85afbd7110b3c9a8eef0bee07e69eaf5828d35fe29e1c7d71b161d4885" diff --git a/pyproject.toml b/pyproject.toml index ef962dc..7eceba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ pydantic = "^2.0" pytest = "^8.0" requests-mock = "^1.10.0" build = "^1.0" +twine = "^6.2" [build-system] requires = ["poetry-core"] diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 58befb3..956d8ae 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -1,18 +1,28 @@ """Validate the built python-mlb-statsapi distributions before a release. -Checks the artifacts in ``dist/``, then installs the wheel into a throwaway -virtual environment and runs a public-import smoke test against the *installed* -package. +Checks the artifacts in ``dist/``, then clean-installs each distribution +artifact into its own throwaway virtual environment and runs a public-API +smoke test against the *installed* package. + +Both the wheel and the source distribution are installed separately so a +broken sdist build, a missing runtime dependency, or an omitted package file +cannot hide behind a working wheel. The smoke test deliberately runs from a temporary directory so the repository -checkout cannot shadow the installed distribution. +checkout cannot shadow the installed distribution artifact. -Nothing here contacts the MLB API. +Nothing here contacts the MLB API. Every HTTP response exercised by the smoke +test is produced by an injected fake Session. Usage:: python scripts/validate_release.py - python scripts/validate_release.py --dist dist --expected-version 0.9.0 + python scripts/validate_release.py --expected-version 1.0.0 + python scripts/validate_release.py --dist dist + +Without ``--expected-version`` the expected artifact version is read from the +version declared in ``pyproject.toml``, so the same validator follows the +project through a version bump without being edited. """ from __future__ import annotations @@ -32,23 +42,56 @@ NORMALIZED_DISTRIBUTION_NAME = "python_mlb_statsapi" EXPECTED_REQUIRES_PYTHON = ">=3.10" -# Paths every source distribution must carry so the project can be rebuilt and -# read from the sdist alone. +WHEEL_LABEL = "wheel" +SDIST_LABEL = "source distribution" + +# Paths every source distribution must carry so the project can be rebuilt, +# installed, and read from the sdist alone. Each entry was confirmed present in +# the archive Poetry actually generates; tests, docs, and scripts are +# intentionally excluded from the sdist and must not be listed here. REQUIRED_SDIST_PATHS = ( + "PKG-INFO", + "LICENSE", "README.md", "pyproject.toml", "mlbstatsapi/__init__.py", + "mlbstatsapi/exceptions.py", + "mlbstatsapi/warnings.py", + "mlbstatsapi/mlb_api.py", + "mlbstatsapi/mlb_dataadapter.py", + "mlbstatsapi/mlb_module.py", + "mlbstatsapi/models/__init__.py", +) + +# Explicit failure messages for the version 1.0 strict defaults. They are +# module-level constants so the offline validator tests can assert that the +# smoke-test contract still reports a reverted default in an understandable way. +MLB_STRICT_DEFAULT_MESSAGE = "Mlb.strict_http must default to True for the 1.0 contract" +ADAPTER_STRICT_DEFAULT_MESSAGE = ( + "MlbDataAdapter.strict_http must default to True for the 1.0 contract" ) SMOKE_TEST_SOURCE = ''' -"""Public import smoke test for an installed python-mlb-statsapi wheel.""" +"""Public API smoke test for an installed python-mlb-statsapi artifact. + +Runs inside a throwaway virtual environment against the installed +distribution, never against a repository checkout. + +Every HTTP response comes from an injected fake Session, so this test performs +no network I/O and never reaches the MLB API. +""" import importlib.metadata import inspect +import json +import logging import sys +import sysconfig +import warnings from pathlib import Path import requests +from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import mlbstatsapi @@ -69,9 +112,38 @@ expected_version = sys.argv[1] +# The adapter logs an error for every fake 403, which is expected here. Silence +# it from the consumer side so the smoke-test output stays readable; the library +# itself must never configure logging for its callers. +package_logger = logging.getLogger("mlbstatsapi") +package_logger.addHandler(logging.NullHandler()) +package_logger.propagate = False + +MLB_STRICT_DEFAULT_MESSAGE = ( + "Mlb.strict_http must default to True for the 1.0 contract" +) +ADAPTER_STRICT_DEFAULT_MESSAGE = ( + "MlbDataAdapter.strict_http must default to True for the 1.0 contract" +) + +# Small deterministic error payload; MlbHttpError must expose it unchanged. +FORBIDDEN_PAYLOAD = {"messageNumber": 403, "message": "Forbidden"} +V1_SPORTS_URL = "https://statsapi.mlb.com/api/v1/sports" +V1_1_SPORTS_URL = "https://statsapi.mlb.com/api/v1.1/sports" +DOCUMENTED_RETRY_STATUSES = {429, 500, 502, 503, 504} + + +# --- The installed artifact, not the repository checkout --- + +assert sys.prefix != sys.base_prefix, ( + "the smoke test must run inside the throwaway virtual environment" +) + +site_packages = Path(sysconfig.get_paths()["purelib"]).resolve() package_file = Path(mlbstatsapi.__file__).resolve() -assert "site-packages" in package_file.parts, ( - f"mlbstatsapi was imported from {package_file}, not from the installed wheel" +assert package_file.is_relative_to(site_packages), ( + f"mlbstatsapi was imported from {package_file}, not from the installed " + f"distribution artifact under {site_packages}" ) installed_version = importlib.metadata.version("python-mlb-statsapi") @@ -79,6 +151,9 @@ f"installed metadata reports {installed_version}, expected {expected_version}" ) + +# --- Supported package-root surface --- + supported_symbols = ( "Mlb", "MlbDataAdapter", @@ -94,16 +169,37 @@ "return_splits", ) for name in supported_symbols: - assert hasattr(mlbstatsapi, name), name - assert getattr(mlbstatsapi, name) is not None + assert hasattr(mlbstatsapi, name), f"mlbstatsapi.{name} is not importable" + assert getattr(mlbstatsapi, name) is not None, f"mlbstatsapi.{name} is None" # Version 1.0 intentionally omits __all__; adding it would narrow star imports. -assert getattr(mlbstatsapi, "__all__", None) is None +assert getattr(mlbstatsapi, "__all__", None) is None, ( + "version 1.0 must not define mlbstatsapi.__all__" +) + + +def assert_documented_retry_policy(retry, *, label): + """Assert the documented retry values without freezing Requests internals.""" + assert isinstance(retry, Retry), f"{label}: {type(retry)!r} is not a Retry" + assert retry.total == 3, f"{label}: total={retry.total}" + assert retry.connect == 3, f"{label}: connect={retry.connect}" + assert retry.read == 2, f"{label}: read={retry.read}" + assert retry.status == 3, f"{label}: status={retry.status}" + assert retry.backoff_factor == 0.5, f"{label}: backoff_factor={retry.backoff_factor}" + assert set(retry.status_forcelist) == DOCUMENTED_RETRY_STATUSES, ( + f"{label}: status_forcelist={sorted(retry.status_forcelist)}" + ) + assert retry.allowed_methods == frozenset({"GET"}), ( + f"{label}: allowed_methods={retry.allowed_methods}" + ) + assert retry.respect_retry_after_header is True, label + assert retry.raise_on_status is False, label + assert callable(create_retry_policy) assert inspect.signature(create_retry_policy).parameters == {} retry_policy = create_retry_policy() -assert isinstance(retry_policy, Retry), type(retry_policy) +assert_documented_retry_policy(retry_policy, label="create_retry_policy()") assert create_retry_policy() is not retry_policy, ( "create_retry_policy() must return a new Retry instance per call" ) @@ -131,7 +227,7 @@ assert mlb_init["logger"].default is None assert mlb_init["timeout"].default == (3.05, 30.0) assert mlb_init["session"].default is None -assert mlb_init["strict_http"].default is True +assert mlb_init["strict_http"].default is True, MLB_STRICT_DEFAULT_MESSAGE assert mlb_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY assert list(adapter_init) == [ @@ -148,7 +244,7 @@ assert adapter_init["logger"].default is None assert adapter_init["timeout"].default == (3.05, 30.0) assert adapter_init["session"].default is None -assert adapter_init["strict_http"].default is True +assert adapter_init["strict_http"].default is True, ADAPTER_STRICT_DEFAULT_MESSAGE assert adapter_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY assert list(result_init) == ["self", "status_code", "message", "data"] @@ -164,30 +260,246 @@ assert return_splits is mlbstatsapi.return_splits assert get_stat_attributes is mlbstatsapi.get_stat_attributes -# A library-created Session is library-owned, so reading its User-Agent through -# the private attribute is acceptable for internal release validation only. + +# --- Offline HTTP behavior --- + + +class ForbiddenSession: + """Injected Session stand-in that answers every GET with a final 403. + + A realistic requests.Response is built for the requested URL so the + installed adapter runs its real status handling. No network I/O happens, + so the smoke test never reaches the MLB API. + """ + + def __init__(self): + self.requested_urls = [] + + def get(self, url, params=None, timeout=None, **kwargs): + self.requested_urls.append(url) + response = requests.Response() + response.status_code = 403 + response.reason = "Forbidden" + response.url = url + response.headers["Content-Type"] = "application/json" + response.encoding = "utf-8" + # requests only exposes a body through Response._content; building it + # directly is the way to produce a realistic offline Response. + response._content = json.dumps(FORBIDDEN_PAYLOAD).encode("utf-8") + return response + + def close(self): + pass + + +def assert_forbidden_error(exc, *, expected_url, label): + assert exc.status_code == 403, f"{label}: status_code={exc.status_code}" + assert exc.reason == "Forbidden", f"{label}: reason={exc.reason!r}" + assert exc.method == "GET", f"{label}: method={exc.method!r}" + assert exc.url == expected_url, f"{label}: url={exc.url!r}" + assert isinstance(exc.response_data, dict), ( + f"{label}: response_data={exc.response_data!r}" + ) + for key, value in FORBIDDEN_PAYLOAD.items(): + assert exc.response_data.get(key) == value, ( + f"{label}: response_data={exc.response_data!r}" + ) + + +def assert_raises_forbidden(call, *, expected_url, label): + try: + call() + except MlbHttpError as exc: + assert_forbidden_error(exc, expected_url=expected_url, label=label) + return + raise AssertionError(f"{label}: a final 403 did not raise MlbHttpError") + + +def capture_compatibility_warnings(call): + """Run *call* and return (result, captured MlbHttpCompatibilityWarnings).""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = call() + compatibility = [ + record + for record in caught + if issubclass(record.category, MlbHttpCompatibilityWarning) + ] + return result, compatibility + + +def assert_single_compatibility_warning(captured, *, label): + assert len(captured) == 1, ( + f"{label}: expected exactly one MlbHttpCompatibilityWarning, " + f"captured {[str(record.message) for record in captured]}" + ) + record = captured[0] + assert record.category is MlbHttpCompatibilityWarning, ( + f"{label}: warning category is {record.category!r}" + ) + assert "strict_http=False" in str(record.message), ( + f"{label}: warning message does not mention strict_http=False: " + f"{str(record.message)!r}" + ) + + +# Constructed without strict_http so the real constructor default is exercised. +session = ForbiddenSession() +with Mlb(session=session) as mlb: + assert_raises_forbidden( + mlb.get_sports, + expected_url=V1_SPORTS_URL, + label=MLB_STRICT_DEFAULT_MESSAGE, + ) + +session = ForbiddenSession() +with Mlb(session=session, strict_http=True) as mlb: + assert_raises_forbidden( + mlb.get_sports, + expected_url=V1_SPORTS_URL, + label="Mlb(strict_http=True).get_sports()", + ) + +session = ForbiddenSession() +with Mlb(session=session, strict_http=False) as mlb: + sports, captured = capture_compatibility_warnings(mlb.get_sports) + +assert sports == [], f"Mlb(strict_http=False).get_sports() returned {sports!r}" +assert_single_compatibility_warning( + captured, + label="Mlb(strict_http=False).get_sports()", +) + + +# --- Direct adapter construction, both documented API versions --- + +for api_version, sports_url in (("v1", V1_SPORTS_URL), ("v1.1", V1_1_SPORTS_URL)): + # Omitting strict_http exercises the real adapter default. + adapter = MlbDataAdapter(ver=api_version, session=ForbiddenSession()) + try: + assert_raises_forbidden( + lambda: adapter.get(endpoint="sports"), + expected_url=sports_url, + label=f"{ADAPTER_STRICT_DEFAULT_MESSAGE} (ver={api_version})", + ) + finally: + adapter.close() + + adapter = MlbDataAdapter( + ver=api_version, + session=ForbiddenSession(), + strict_http=True, + ) + try: + assert_raises_forbidden( + lambda: adapter.get(endpoint="sports"), + expected_url=sports_url, + label=f"MlbDataAdapter(ver={api_version}, strict_http=True).get()", + ) + finally: + adapter.close() + + adapter = MlbDataAdapter( + ver=api_version, + session=ForbiddenSession(), + strict_http=False, + ) + label = f"MlbDataAdapter(ver={api_version}, strict_http=False).get()" + try: + result, captured = capture_compatibility_warnings( + lambda: adapter.get(endpoint="sports"), + ) + finally: + adapter.close() + + assert isinstance(result, MlbResult), f"{label}: {type(result)!r}" + assert result.status_code == 403, f"{label}: status_code={result.status_code}" + assert result.message == "Forbidden", f"{label}: message={result.message!r}" + assert result.data == {}, f"{label}: data={result.data!r}" + assert_single_compatibility_warning(captured, label=label) + + +# --- Library-created Session --- + +# A library-created Session is library-owned, so reading its headers and +# adapters through private attributes is acceptable for release validation only. expected_user_agent = f"python-mlb-statsapi/{expected_version}" with Mlb() as mlb: user_agent = mlb._session.headers["User-Agent"] - assert user_agent == expected_user_agent, user_agent - assert mlb._strict_http is True - -# Injected Session headers stay untouched; library close does not close them. -session = requests.Session() -session.headers.update( - { - "User-Agent": "release-smoke-test/1.0", - "X-Release-Test": "preserved", - } -) + assert user_agent == expected_user_agent, ( + f"library-created Session sends User-Agent {user_agent!r}, " + f"expected {expected_user_agent!r}" + ) + assert mlb._strict_http is True, MLB_STRICT_DEFAULT_MESSAGE + for scheme in ("https://", "http://"): + assert_documented_retry_policy( + mlb._session.get_adapter(scheme).max_retries, + label=f"library-created Session {scheme} adapter", + ) + +adapter = MlbDataAdapter() try: - with Mlb(session=session, strict_http=False) as mlb: - assert mlb._strict_http is False + assert adapter._session.headers["User-Agent"] == expected_user_agent + assert adapter._strict_http is True, ADAPTER_STRICT_DEFAULT_MESSAGE +finally: + adapter.close() + + +# --- Injected Session stays caller-owned and unmodified --- + + +class OwnershipSession(requests.Session): + """Real Session that records close() so caller ownership is observable.""" + + def __init__(self): + super().__init__() + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + super().close() + + +session = OwnershipSession() +session.headers["User-Agent"] = "release-smoke-test/1.0" +session.headers["X-Release-Test"] = "preserved" +# max_retries=0 so a library retry policy mounted here would be detectable. +injected_https_adapter = HTTPAdapter(max_retries=0) +injected_http_adapter = HTTPAdapter(max_retries=0) +session.mount("https://", injected_https_adapter) +session.mount("http://", injected_http_adapter) +headers_before = dict(session.headers) + +try: + with Mlb(session=session) as mlb: + assert mlb._session is session + + assert session.close_calls == 0, ( + "the library must not close a caller-injected Session" + ) + assert dict(session.headers) == headers_before, dict(session.headers) assert session.headers["User-Agent"] == "release-smoke-test/1.0" assert session.headers["X-Release-Test"] == "preserved" + assert session.get_adapter("https://") is injected_https_adapter, ( + "the injected https:// adapter was replaced" + ) + assert session.get_adapter("http://") is injected_http_adapter, ( + "the injected http:// adapter was replaced" + ) + for scheme in ("https://", "http://"): + mounted_retries = session.get_adapter(scheme).max_retries + assert mounted_retries.total == 0, ( + "the library must not mount its retry policy on an injected " + f"Session: {scheme} total={mounted_retries.total}" + ) finally: session.close() +assert session.close_calls == 1, ( + f"the smoke test must close its own Session exactly once, " + f"saw {session.close_calls}" +) + print(f"smoke test passed for python-mlb-statsapi {installed_version}") ''' @@ -233,13 +545,16 @@ def _read_expected_version(project_root: Path) -> str: def _find_single(dist_dir: Path, pattern: str, label: str) -> Path: matches = sorted(dist_dir.glob(pattern)) if not matches: + present = ", ".join(sorted(path.name for path in dist_dir.iterdir())) or "nothing" raise ValidationError( - f"no {label} matching {pattern!r} in {dist_dir}; run `poetry build` first" + f"{label}: no artifact matching {pattern!r} in {dist_dir}; " + f"found {present}. Run `poetry build` first." ) if len(matches) > 1: names = ", ".join(path.name for path in matches) raise ValidationError( - f"expected exactly one {label} in {dist_dir}, found: {names}. " + f"{label}: expected exactly one artifact matching {pattern!r} in " + f"{dist_dir}, found {len(matches)}: {names}. " "Remove stale artifacts and rebuild." ) return matches[0] @@ -254,7 +569,8 @@ def _check_wheel_metadata(wheel: Path, expected_version: str) -> None: ] if len(metadata_names) != 1: raise ValidationError( - f"expected one METADATA file in {wheel.name}, found {metadata_names}" + f"{WHEEL_LABEL} {wheel.name}: expected exactly one " + f".dist-info/METADATA file, found {metadata_names}" ) raw_metadata = archive.read(metadata_names[0]).decode("utf-8") @@ -262,19 +578,23 @@ def _check_wheel_metadata(wheel: Path, expected_version: str) -> None: name = metadata.get("Name") if name != DISTRIBUTION_NAME: - raise ValidationError(f"wheel Name is {name!r}, expected {DISTRIBUTION_NAME!r}") + raise ValidationError( + f"{WHEEL_LABEL} {wheel.name}: metadata Name is {name!r}, " + f"expected {DISTRIBUTION_NAME!r}" + ) version = metadata.get("Version") if version != expected_version: raise ValidationError( - f"wheel Version is {version!r}, expected {expected_version!r}" + f"{WHEEL_LABEL} {wheel.name}: metadata Version is {version!r}, " + f"expected {expected_version!r}" ) requires_python = metadata.get("Requires-Python") if requires_python != EXPECTED_REQUIRES_PYTHON: raise ValidationError( - f"wheel Requires-Python is {requires_python!r}, " - f"expected {EXPECTED_REQUIRES_PYTHON!r}" + f"{WHEEL_LABEL} {wheel.name}: metadata Requires-Python is " + f"{requires_python!r}, expected {EXPECTED_REQUIRES_PYTHON!r}" ) _log( @@ -293,7 +613,9 @@ def _check_sdist_contents(sdist: Path) -> None: missing = [path for path in REQUIRED_SDIST_PATHS if path not in relative_paths] if missing: raise ValidationError( - f"source distribution {sdist.name} is missing: {', '.join(missing)}" + f"{SDIST_LABEL} {sdist.name}: missing required path(s) " + f"{', '.join(missing)}; expected every path in " + f"{', '.join(REQUIRED_SDIST_PATHS)}" ) _log(f" sdist contains: {', '.join(REQUIRED_SDIST_PATHS)}") @@ -310,39 +632,58 @@ def _venv_python(venv_dir: Path) -> Path: raise ValidationError(f"no interpreter found in {venv_dir}") -def _run(command: list[str], *, cwd: Path) -> None: +def _run(command: list[str], *, cwd: Path, label: str) -> None: result = subprocess.run(command, cwd=cwd, check=False) if result.returncode != 0: printable = " ".join(command) - raise ValidationError(f"command failed ({result.returncode}): {printable}") + raise ValidationError( + f"{label} failed (exit code {result.returncode}): {printable}" + ) + + +def _create_clean_environment(venv_dir: Path) -> Path: + """Create an empty virtual environment and return its interpreter.""" + venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) + return _venv_python(venv_dir) + +def _check_clean_install(artifact: Path, expected_version: str, *, label: str) -> None: + """Clean-install one distribution artifact and smoke test the result. -def _check_clean_install(wheel: Path, expected_version: str) -> None: + Each artifact gets its own virtual environment so the wheel and the source + distribution are never validated against a shared install. + """ with tempfile.TemporaryDirectory(prefix="python-mlb-statsapi-release-") as tmp: workspace = Path(tmp) venv_dir = workspace / "venv" - _log(f" creating clean virtual environment in {venv_dir}") - venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) - python = _venv_python(venv_dir) + _log(f" creating clean virtual environment for the {label} in {venv_dir}") + python = _create_clean_environment(venv_dir) _run( [str(python), "-m", "pip", "install", "--upgrade", "--quiet", "pip"], cwd=workspace, + label=f"pip upgrade for the {label} environment", ) - _log(f" installing {wheel.name}") + + _log(f" installing {label}: {artifact.name}") _run( - [str(python), "-m", "pip", "install", "--quiet", str(wheel.resolve())], + [str(python), "-m", "pip", "install", "--quiet", str(artifact.resolve())], cwd=workspace, + label=f"{label} installation of {artifact.name}", ) smoke_test = workspace / "release_smoke_test.py" smoke_test.write_text(SMOKE_TEST_SOURCE, encoding="utf-8") - # Run from the temporary directory so the repository checkout is not on - # sys.path and cannot shadow the installed distribution. - _log(" running public import smoke test against the installed wheel") - _run([str(python), str(smoke_test), expected_version], cwd=workspace) + # Run from the temporary workspace so the repository checkout is not on + # sys.path and cannot shadow the installed distribution artifact. + _log(f" running {label} smoke test against the installed artifact") + _run( + [str(python), str(smoke_test), expected_version], + cwd=workspace, + label=f"{label} smoke test", + ) def validate(dist_dir: Path, expected_version: str) -> None: @@ -351,20 +692,24 @@ def validate(dist_dir: Path, expected_version: str) -> None: wheel = _find_single( dist_dir, f"{NORMALIZED_DISTRIBUTION_NAME}-{expected_version}-*.whl", - "wheel", + WHEEL_LABEL, ) _log(f" wheel: {wheel.name}") sdist = _find_single( dist_dir, f"{NORMALIZED_DISTRIBUTION_NAME}-{expected_version}.tar.gz", - "source distribution", + SDIST_LABEL, ) _log(f" source distribution: {sdist.name}") _check_wheel_metadata(wheel, expected_version) _check_sdist_contents(sdist) - _check_clean_install(wheel, expected_version) + + # Separate environments: an sdist that cannot build, omits package files, or + # loses a runtime dependency must not be masked by the wheel install. + _check_clean_install(wheel, expected_version, label=WHEEL_LABEL) + _check_clean_install(sdist, expected_version, label=SDIST_LABEL) _log(f"Release validation passed for {DISTRIBUTION_NAME} {expected_version}") diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a2e70ed..a4533bb 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -1,11 +1,27 @@ -"""Offline checks that the release documentation stays consistent with the package. +"""Offline checks for the release validator, deterministic CI, and release docs. -These tests do not build or install anything. Packaging itself is validated by -``scripts/validate_release.py``, which runs against ``dist/`` and a clean -virtual environment. +Three groups of checks live here: + +* documentation consistency for the current release +* unit coverage for ``scripts/validate_release.py`` helpers and failure messages +* the deterministic CI contract (release branch triggers, Python matrix, twine) + +Nothing here builds the real package, creates a virtual environment, installs +an artifact, or makes a network request. Synthetic wheel ZIPs and +source-distribution tarballs stand in for real artifacts, and clean-install +steps are stubbed. Real packaging is validated by running +``scripts/validate_release.py`` against ``dist/``. """ +from __future__ import annotations + +import importlib.util +import io import re +import sys +import tarfile +import types +import zipfile from pathlib import Path import pytest @@ -14,22 +30,75 @@ README = PROJECT_ROOT / "README.md" TRANSPORT_DOC = PROJECT_ROOT / "docs" / "http-transport.md" PUBLIC_API_DOC = PROJECT_ROOT / "docs" / "public-api.md" -RELEASE_NOTES = PROJECT_ROOT / "docs" / "releases" / "0.9.0.md" +RELEASE_NOTES_DIR = PROJECT_ROOT / "docs" / "releases" +PYPROJECT = PROJECT_ROOT / "pyproject.toml" +POETRY_LOCK = PROJECT_ROOT / "poetry.lock" VALIDATE_RELEASE = PROJECT_ROOT / "scripts" / "validate_release.py" +OFFLINE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "build-and-test.yml" +EXTERNAL_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "external-tests.yml" + +# Release notes for the version this branch is preparing. Kept explicit so the +# current-document checks do not depend on the pyproject version bump, which is +# owned by a separate issue. +CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.0.0.md" + +# Historical notes keep their own version-specific statements and must not be +# rewritten to match the current release. +HISTORICAL_RELEASE_NOTES = ( + RELEASE_NOTES_DIR / "0.7.1.md", + RELEASE_NOTES_DIR / "0.8.0.md", + RELEASE_NOTES_DIR / "0.9.0.md", +) + +# Deterministic CI contract for the 1.0 release. +RELEASE_BRANCH = "release/1.0.0" +STALE_RELEASE_BRANCH = "release/0.9.0" +SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") +CI_VALIDATED_PYTHON_RANGE = "3.10 through 3.14" +# Prerelease during this work, so it is deliberately excluded from the matrix. +UNSUPPORTED_PRERELEASE_PYTHON = "3.15" +BUILD_JOB_PYTHON = "3.14" +DECLARED_PYTHON_REQUIREMENT = ">=3.10" PYTHON_BLOCK_PATTERN = re.compile( r"^```python\n(.*?)^```", re.MULTILINE | re.DOTALL, ) +USER_AGENT_PATTERN = re.compile(r"python-mlb-statsapi/[0-9][^\s`\"']*") + + +def _load_validator() -> types.ModuleType: + """Import scripts/validate_release.py, which is not an installable package.""" + spec = importlib.util.spec_from_file_location( + "validate_release_under_test", + VALIDATE_RELEASE, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = _load_validator() + +# Synthetic version used only as an expected-version fixture. The validator +# itself must keep reading the real expected version from pyproject.toml. +SYNTHETIC_VERSION = "1.0.0" + def _project_version() -> str: - text = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + text = PYPROJECT.read_text(encoding="utf-8") match = re.search(r'^version\s*=\s*"([^"]+)"', text, flags=re.MULTILINE) assert match is not None, "no version found in pyproject.toml" return match.group(1) +# --------------------------------------------------------------------------- +# Documentation consistency +# --------------------------------------------------------------------------- + + def _python_blocks(path: Path) -> list[tuple[int, str]]: """Return (line number, source) for every non-REPL ```python block.""" text = path.read_text(encoding="utf-8") @@ -46,8 +115,28 @@ def _python_blocks(path: Path) -> list[tuple[int, str]]: return blocks +def _release_notes_paths() -> list[Path]: + return sorted(RELEASE_NOTES_DIR.glob("*.md")) + + +def _current_document_paths() -> list[Path]: + """Documents that must describe the current release, not history.""" + return [README, TRANSPORT_DOC, PUBLIC_API_DOC, CURRENT_RELEASE_NOTES] + + def _documented_paths() -> list[Path]: - return [README, TRANSPORT_DOC, PUBLIC_API_DOC, RELEASE_NOTES] + """Every Markdown document whose Python examples must stay valid.""" + return [README, TRANSPORT_DOC, PUBLIC_API_DOC, *_release_notes_paths()] + + +def _documented_user_agents(path: Path) -> set[str]: + return set(USER_AGENT_PATTERN.findall(path.read_text(encoding="utf-8"))) + + +def test_release_notes_directory_is_fully_covered() -> None: + """Every release-notes file is classified as current or historical.""" + classified = {CURRENT_RELEASE_NOTES, *HISTORICAL_RELEASE_NOTES} + assert set(_release_notes_paths()) == classified @pytest.mark.parametrize( @@ -85,7 +174,7 @@ def test_documentation_examples_use_public_api_only(path: Path) -> None: def test_release_notes_exist_for_the_declared_version() -> None: version = _project_version() - notes = PROJECT_ROOT / "docs" / "releases" / f"{version}.md" + notes = RELEASE_NOTES_DIR / f"{version}.md" assert notes.is_file(), f"missing release notes for version {version}" assert notes.read_text(encoding="utf-8").startswith( f"# python-mlb-statsapi {version}" @@ -93,18 +182,37 @@ def test_release_notes_exist_for_the_declared_version() -> None: def test_documented_user_agent_matches_the_declared_version() -> None: - """The documented User-Agent must track the version the build will produce.""" - version = _project_version() - expected = f"python-mlb-statsapi/{version}" + """Current docs must track the version the build will produce.""" + expected = f"python-mlb-statsapi/{_project_version()}" - for path in (README, TRANSPORT_DOC, RELEASE_NOTES): - text = path.read_text(encoding="utf-8") - documented = set(re.findall(r"python-mlb-statsapi/[0-9][^\s`\"']*", text)) + for path in (README, TRANSPORT_DOC): + documented = _documented_user_agents(path) assert documented == {expected}, ( f"{path.name} documents User-Agent versions {sorted(documented)}, " f"expected only {expected!r}" ) + # The current release notes need not repeat a User-Agent example, but any + # example they do carry must match the declared version. + documented = _documented_user_agents(CURRENT_RELEASE_NOTES) + assert documented <= {expected}, ( + f"{CURRENT_RELEASE_NOTES.name} documents User-Agent versions " + f"{sorted(documented)}, expected only {expected!r}" + ) + + +@pytest.mark.parametrize( + "path", + HISTORICAL_RELEASE_NOTES, + ids=lambda path: path.name, +) +def test_historical_release_notes_keep_their_own_user_agent(path: Path) -> None: + """Historical notes document the version they shipped, not the current one.""" + documented = _documented_user_agents(path) + assert documented <= {f"python-mlb-statsapi/{path.stem}"}, ( + f"{path.name} documents User-Agent versions {sorted(documented)}" + ) + def test_public_api_contract_document_exists() -> None: assert PUBLIC_API_DOC.is_file() @@ -115,18 +223,804 @@ def test_public_api_contract_document_exists() -> None: assert "Python support" in text -def test_release_smoke_test_asserts_strict_http_default() -> None: - """The installed-wheel smoke test must match the version 1.0 strict default.""" +@pytest.mark.parametrize( + "path", + (README, PUBLIC_API_DOC, CURRENT_RELEASE_NOTES), + ids=lambda path: path.name, +) +def test_current_documents_state_the_validated_python_versions(path: Path) -> None: + """Support wording must match the CI matrix this branch establishes.""" + text = path.read_text(encoding="utf-8") + + assert DECLARED_PYTHON_REQUIREMENT in text, ( + f"{path.name} does not state the declared Python requirement" + ) + assert CI_VALIDATED_PYTHON_RANGE in text, ( + f"{path.name} does not state the CI-validated Python range" + ) + for version in SUPPORTED_PYTHON_VERSIONS: + assert version in text, f"{path.name} does not mention Python {version}" + assert UNSUPPORTED_PRERELEASE_PYTHON not in text, ( + f"{path.name} must not mention Python {UNSUPPORTED_PRERELEASE_PYTHON}, " + "which is a prerelease and is not a supported version" + ) + + +# --------------------------------------------------------------------------- +# Synthetic artifacts +# --------------------------------------------------------------------------- + + +def _metadata_text( + *, + name: str = "python-mlb-statsapi", + version: str = SYNTHETIC_VERSION, + requires_python: str = DECLARED_PYTHON_REQUIREMENT, +) -> str: + return ( + "Metadata-Version: 2.1\n" + f"Name: {name}\n" + f"Version: {version}\n" + f"Requires-Python: {requires_python}\n" + "\n" + "Synthetic metadata for release-validator tests.\n" + ) + + +def _write_wheel( + dist_dir: Path, + *, + version: str = SYNTHETIC_VERSION, + tag: str = "py3-none-any", + metadata_name: str = "python-mlb-statsapi", + metadata_version: str | None = None, + requires_python: str = DECLARED_PYTHON_REQUIREMENT, + metadata_files: int = 1, +) -> Path: + """Write a synthetic wheel ZIP with controllable ``.dist-info`` metadata.""" + dist_dir.mkdir(parents=True, exist_ok=True) + wheel = dist_dir / f"python_mlb_statsapi-{version}-{tag}.whl" + raw_metadata = _metadata_text( + name=metadata_name, + version=metadata_version or version, + requires_python=requires_python, + ) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("mlbstatsapi/__init__.py", "") + for index in range(metadata_files): + suffix = "" if index == 0 else f".extra{index}" + dist_info = f"python_mlb_statsapi-{version}{suffix}.dist-info" + archive.writestr(f"{dist_info}/METADATA", raw_metadata) + archive.writestr(f"{dist_info}/WHEEL", "Wheel-Version: 1.0\n") + return wheel + + +def _write_sdist( + dist_dir: Path, + *, + version: str = SYNTHETIC_VERSION, + paths: tuple[str, ...] | None = None, +) -> Path: + """Write a synthetic source-distribution tarball with a versioned root.""" + dist_dir.mkdir(parents=True, exist_ok=True) + sdist = dist_dir / f"python_mlb_statsapi-{version}.tar.gz" + root = f"python_mlb_statsapi-{version}" + contents = validator.REQUIRED_SDIST_PATHS if paths is None else paths + with tarfile.open(sdist, "w:gz") as archive: + for relative in contents: + payload = b"synthetic\n" + info = tarfile.TarInfo(f"{root}/{relative}") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return sdist + + +def _classify_command(command) -> str: + parts = [str(part) for part in command] + joined = " ".join(parts) + if "release_smoke_test.py" in joined: + return "smoke" + if "--upgrade" in parts: + return "pip-upgrade" + if "install" in parts: + return "install" + return "other" + + +class _CompletedProcess: + def __init__(self, returncode: int): + self.returncode = returncode + + +def _stub_clean_install(monkeypatch, *, failing: str | None = None) -> list[list[str]]: + """Stub environment creation and subprocess execution for install tests. + + ``failing`` selects the step that returns a non-zero exit code: ``install`` + for the artifact installation or ``smoke`` for the installed-package smoke + test. Only the validator's own ``subprocess`` reference is replaced, so no + real interpreter, environment, or download is involved. + """ + commands: list[list[str]] = [] + + monkeypatch.setattr( + validator, + "_create_clean_environment", + lambda venv_dir: Path(sys.executable), + ) + + def fake_run(command, cwd=None, check=False, **kwargs): + commands.append([str(part) for part in command]) + returncode = 1 if _classify_command(command) == failing else 0 + return _CompletedProcess(returncode) + + monkeypatch.setattr(validator, "subprocess", types.SimpleNamespace(run=fake_run)) + return commands + + +# --------------------------------------------------------------------------- +# Expected-version handling +# --------------------------------------------------------------------------- + + +def test_expected_version_is_read_from_pyproject(tmp_path: Path) -> None: + """The validator stays version-aware instead of pinning one release.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.poetry]\nname = "python-mlb-statsapi"\nversion = "2.3.4"\n', + encoding="utf-8", + ) + + assert validator._read_expected_version(tmp_path) == "2.3.4" + + +def test_declared_project_version_is_the_default_expected_version() -> None: + assert validator._read_expected_version(PROJECT_ROOT) == _project_version() + + +def test_missing_project_version_is_reported(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + '[tool.poetry]\nname = "python-mlb-statsapi"\n', + encoding="utf-8", + ) + + with pytest.raises(validator.ValidationError, match="could not find a version"): + validator._read_expected_version(tmp_path) + + +def test_missing_dist_directory_is_reported(tmp_path: Path, capsys) -> None: + missing = tmp_path / "dist" + + exit_code = validator.main( + ["--dist", str(missing), "--expected-version", SYNTHETIC_VERSION] + ) + + assert exit_code == 1 + message = capsys.readouterr().err + assert str(missing) in message + assert "does not exist" in message + assert "poetry build" in message + + +# --------------------------------------------------------------------------- +# Artifact discovery failures +# --------------------------------------------------------------------------- + + +def test_missing_wheel_is_reported(tmp_path: Path) -> None: + _write_sdist(tmp_path) + + with pytest.raises(validator.ValidationError) as exc_info: + validator.validate(tmp_path, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.WHEEL_LABEL in message + assert f"python_mlb_statsapi-{SYNTHETIC_VERSION}-*.whl" in message + assert "poetry build" in message + + +def test_missing_source_distribution_is_reported(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path) + + with pytest.raises(validator.ValidationError) as exc_info: + validator.validate(tmp_path, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.SDIST_LABEL in message + assert f"python_mlb_statsapi-{SYNTHETIC_VERSION}.tar.gz" in message + # The wheel is present, so the actual directory contents are reported. + assert wheel.name in message + + +def test_multiple_stale_wheels_are_reported(tmp_path: Path) -> None: + first = _write_wheel(tmp_path, tag="py3-none-any") + second = _write_wheel(tmp_path, tag="py310-none-any") + _write_sdist(tmp_path) + + with pytest.raises(validator.ValidationError) as exc_info: + validator.validate(tmp_path, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.WHEEL_LABEL in message + assert first.name in message + assert second.name in message + assert "Remove stale artifacts" in message + + +def test_multiple_stale_source_distributions_are_reported(tmp_path: Path) -> None: + """Two sdists matching one lookup pattern must be rejected, not guessed. + + ``validate()`` looks the sdist up by its exact versioned filename, so this + exercises the shared discovery helper directly with a wildcard pattern. + """ + first = _write_sdist(tmp_path, version=SYNTHETIC_VERSION) + second = _write_sdist(tmp_path, version=f"{SYNTHETIC_VERSION}rc1") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._find_single( + tmp_path, + "python_mlb_statsapi-*.tar.gz", + validator.SDIST_LABEL, + ) + + message = str(exc_info.value) + assert validator.SDIST_LABEL in message + assert first.name in message + assert second.name in message + assert "Remove stale artifacts" in message + + +# --------------------------------------------------------------------------- +# Wheel metadata failures +# --------------------------------------------------------------------------- + + +def test_wheel_metadata_is_accepted_when_correct(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path) + + validator._check_wheel_metadata(wheel, SYNTHETIC_VERSION) + + +def test_incorrect_wheel_name_metadata_is_reported(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, metadata_name="mlb-statsapi") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_wheel_metadata(wheel, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.WHEEL_LABEL in message + assert wheel.name in message + assert "Name" in message + assert "'mlb-statsapi'" in message + assert "'python-mlb-statsapi'" in message + + +def test_incorrect_wheel_version_metadata_is_reported(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, metadata_version="0.9.0") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_wheel_metadata(wheel, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.WHEEL_LABEL in message + assert wheel.name in message + assert "Version" in message + assert "'0.9.0'" in message + assert f"'{SYNTHETIC_VERSION}'" in message + + +def test_incorrect_requires_python_metadata_is_reported(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, requires_python=">=3.8") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_wheel_metadata(wheel, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.WHEEL_LABEL in message + assert wheel.name in message + assert "Requires-Python" in message + assert "'>=3.8'" in message + assert f"'{DECLARED_PYTHON_REQUIREMENT}'" in message + + +def test_ambiguous_wheel_metadata_is_reported(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, metadata_files=2) + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_wheel_metadata(wheel, SYNTHETIC_VERSION) + + message = str(exc_info.value) + assert validator.WHEEL_LABEL in message + assert wheel.name in message + assert "METADATA" in message + + +def test_expected_requires_python_matches_pyproject() -> None: + assert validator.EXPECTED_REQUIRES_PYTHON == DECLARED_PYTHON_REQUIREMENT + assert ( + f'python = "{DECLARED_PYTHON_REQUIREMENT}"' + in PYPROJECT.read_text(encoding="utf-8") + ) + + +# --------------------------------------------------------------------------- +# Source-distribution content failures +# --------------------------------------------------------------------------- + + +def test_source_distribution_contents_are_accepted_when_complete( + tmp_path: Path, +) -> None: + sdist = _write_sdist(tmp_path) + + validator._check_sdist_contents(sdist) + + +@pytest.mark.parametrize("omitted", validator.REQUIRED_SDIST_PATHS) +def test_missing_required_source_distribution_path_is_reported( + tmp_path: Path, + omitted: str, +) -> None: + remaining = tuple( + path for path in validator.REQUIRED_SDIST_PATHS if path != omitted + ) + sdist = _write_sdist(tmp_path, paths=remaining) + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_sdist_contents(sdist) + + message = str(exc_info.value) + assert validator.SDIST_LABEL in message + assert sdist.name in message + assert omitted in message + + +def test_required_source_distribution_paths_cover_the_package_entry_points() -> None: + """The required list must include the files needed to rebuild and import.""" + required = set(validator.REQUIRED_SDIST_PATHS) + + assert {"README.md", "pyproject.toml", "mlbstatsapi/__init__.py"} <= required + assert "mlbstatsapi/mlb_api.py" in required + assert "mlbstatsapi/mlb_dataadapter.py" in required + # Tests, docs, and scripts are intentionally absent from the sdist. + assert not any(path.startswith(("tests/", "docs/", "scripts/")) for path in required) + + +# --------------------------------------------------------------------------- +# Clean-install and smoke-test failures +# --------------------------------------------------------------------------- + + +def test_wheel_installation_failure_identifies_the_artifact( + monkeypatch, + tmp_path: Path, +) -> None: + wheel = _write_wheel(tmp_path) + _stub_clean_install(monkeypatch, failing="install") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_clean_install( + wheel, + SYNTHETIC_VERSION, + label=validator.WHEEL_LABEL, + ) + + message = str(exc_info.value) + assert f"{validator.WHEEL_LABEL} installation" in message + assert wheel.name in message + assert "exit code 1" in message + + +def test_source_distribution_installation_failure_identifies_the_artifact( + monkeypatch, + tmp_path: Path, +) -> None: + sdist = _write_sdist(tmp_path) + _stub_clean_install(monkeypatch, failing="install") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_clean_install( + sdist, + SYNTHETIC_VERSION, + label=validator.SDIST_LABEL, + ) + + message = str(exc_info.value) + assert f"{validator.SDIST_LABEL} installation" in message + assert sdist.name in message + assert "exit code 1" in message + + +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_smoke_test_failure_identifies_the_artifact( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + _stub_clean_install(monkeypatch, failing="smoke") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_clean_install(artifact, SYNTHETIC_VERSION, label=label) + + message = str(exc_info.value) + assert f"{label} smoke test" in message + assert "exit code 1" in message + + +def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( + monkeypatch, + tmp_path: Path, +) -> None: + wheel = _write_wheel(tmp_path) + commands = _stub_clean_install(monkeypatch) + + validator._check_clean_install( + wheel, + SYNTHETIC_VERSION, + label=validator.WHEEL_LABEL, + ) + + steps = [_classify_command(command) for command in commands] + assert steps == ["pip-upgrade", "install", "smoke"] + + install_command = commands[steps.index("install")] + assert str(wheel.resolve()) in install_command + + smoke_command = commands[steps.index("smoke")] + assert smoke_command[-1] == SYNTHETIC_VERSION + smoke_script = Path(smoke_command[-2]) + # The script is written into a throwaway workspace, never the checkout. + assert smoke_script.name == "release_smoke_test.py" + assert PROJECT_ROOT not in smoke_script.parents + + +def test_each_artifact_is_installed_into_its_own_environment( + monkeypatch, + tmp_path: Path, +) -> None: + wheel = _write_wheel(tmp_path) + sdist = _write_sdist(tmp_path) + created: list[Path] = [] + + def record_environment(venv_dir: Path) -> Path: + created.append(venv_dir) + return Path(sys.executable) + + monkeypatch.setattr(validator, "_create_clean_environment", record_environment) + monkeypatch.setattr( + validator, + "subprocess", + types.SimpleNamespace(run=lambda *args, **kwargs: _CompletedProcess(0)), + ) + + validator._check_clean_install( + wheel, + SYNTHETIC_VERSION, + label=validator.WHEEL_LABEL, + ) + validator._check_clean_install( + sdist, + SYNTHETIC_VERSION, + label=validator.SDIST_LABEL, + ) + + assert len(created) == 2 + assert created[0] != created[1] + + +def test_validate_clean_installs_both_artifacts(monkeypatch, tmp_path: Path) -> None: + """validate() must clean-install the wheel and the source distribution.""" + wheel = _write_wheel(tmp_path) + sdist = _write_sdist(tmp_path) + installs: list[tuple[Path, str, str]] = [] + + def record_install(artifact: Path, expected_version: str, *, label: str) -> None: + installs.append((artifact, expected_version, label)) + + monkeypatch.setattr(validator, "_check_clean_install", record_install) + + validator.validate(tmp_path, SYNTHETIC_VERSION) + + assert installs == [ + (wheel, SYNTHETIC_VERSION, validator.WHEEL_LABEL), + (sdist, SYNTHETIC_VERSION, validator.SDIST_LABEL), + ] + + +def test_validate_reports_success_for_both_artifacts( + monkeypatch, + tmp_path: Path, + capsys, +) -> None: + _write_wheel(tmp_path) + _write_sdist(tmp_path) + _stub_clean_install(monkeypatch) + + validator.validate(tmp_path, SYNTHETIC_VERSION) + + output = capsys.readouterr().out + assert f"installing {validator.WHEEL_LABEL}" in output + assert f"running {validator.WHEEL_LABEL} smoke test" in output + assert f"installing {validator.SDIST_LABEL}" in output + assert f"running {validator.SDIST_LABEL} smoke test" in output + assert "Release validation passed" in output + + +def test_missing_interpreter_in_environment_is_reported(tmp_path: Path) -> None: + with pytest.raises(validator.ValidationError, match="no interpreter found"): + validator._venv_python(tmp_path / "venv") + + +# --------------------------------------------------------------------------- +# Installed smoke-test contract +# --------------------------------------------------------------------------- + + +def test_smoke_test_source_is_valid_python() -> None: + compile(validator.SMOKE_TEST_SOURCE, "release_smoke_test.py", "exec") + + +def test_smoke_test_labels_reverted_strict_defaults() -> None: + """A reverted strict default must fail with an explanatory message. + + An unlabelled AssertionError would not tell a release engineer which + constructor regressed, so both messages are asserted here and in the + generated smoke test. + """ + assert validator.MLB_STRICT_DEFAULT_MESSAGE == ( + "Mlb.strict_http must default to True for the 1.0 contract" + ) + assert validator.ADAPTER_STRICT_DEFAULT_MESSAGE == ( + "MlbDataAdapter.strict_http must default to True for the 1.0 contract" + ) + + source = validator.SMOKE_TEST_SOURCE + assert ( + 'assert mlb_init["strict_http"].default is True, MLB_STRICT_DEFAULT_MESSAGE' + in source + ) + assert ( + 'assert adapter_init["strict_http"].default is True, ' + "ADAPTER_STRICT_DEFAULT_MESSAGE" in source + ) + for message in ( + validator.MLB_STRICT_DEFAULT_MESSAGE, + validator.ADAPTER_STRICT_DEFAULT_MESSAGE, + ): + assert message in source + + +def test_smoke_test_asserts_strict_http_default() -> None: + """The installed-artifact smoke test must match the 1.0 strict default.""" text = VALIDATE_RELEASE.read_text(encoding="utf-8") assert 'mlb_init["strict_http"].default is True' in text assert 'adapter_init["strict_http"].default is True' in text assert "Compatibility mode is the default in this release." not in text +def test_smoke_test_checks_strict_behavior_not_only_signatures() -> None: + """Signature defaults alone cannot prove a final 403 raises.""" + source = validator.SMOKE_TEST_SOURCE + + assert "status_code = 403" in source + assert 'reason = "Forbidden"' in source + assert "https://statsapi.mlb.com/api/v1/sports" in source + assert "https://statsapi.mlb.com/api/v1.1/sports" in source + assert "get_sports" in source + assert "Mlb(session=session, strict_http=True)" in source + assert "Mlb(session=session, strict_http=False)" in source + assert "strict_http=True," in source + assert "strict_http=False," in source + assert "MlbHttpCompatibilityWarning" in source + assert "strict_http=False" in source + + +def test_smoke_test_checks_injected_session_ownership_and_configuration() -> None: + source = validator.SMOKE_TEST_SOURCE + + assert "class OwnershipSession(requests.Session):" in source + assert "release-smoke-test/1.0" in source + assert "X-Release-Test" in source + assert "injected_https_adapter" in source + assert "injected_http_adapter" in source + assert "is injected_https_adapter" in source + assert "is injected_http_adapter" in source + assert "must not close a caller-injected Session" in source + assert "must not mount its retry policy on an injected" in source + assert "finally:\n session.close()" in source + + +def test_smoke_test_checks_library_created_session_configuration() -> None: + source = validator.SMOKE_TEST_SOURCE + + assert 'f"python-mlb-statsapi/{expected_version}"' in source + assert "assert_documented_retry_policy" in source + assert "create_retry_policy() must return a new Retry instance per call" in source + + +@pytest.mark.parametrize( + "symbol", + ( + "Mlb", + "MlbDataAdapter", + "MlbResult", + "create_retry_policy", + "TheMlbStatsApiException", + "MlbTransportError", + "MlbTimeoutError", + "MlbHttpError", + "MlbDecodeError", + "MlbHttpCompatibilityWarning", + "return_splits", + "get_stat_attributes", + ), +) +def test_smoke_test_imports_the_supported_public_symbol(symbol: str) -> None: + assert f" {symbol},\n" in validator.SMOKE_TEST_SOURCE + + +def test_smoke_test_does_not_promote_accidental_submodules() -> None: + """Accidentally exposed submodules stay outside the supported surface.""" + source = validator.SMOKE_TEST_SOURCE + + for submodule in ("mlb_api", "mlb_module", "models"): + assert f"from mlbstatsapi import {submodule}" not in source + assert f"import mlbstatsapi.{submodule}" not in source + + +def test_smoke_test_runs_against_the_installed_distribution() -> None: + source = validator.SMOKE_TEST_SOURCE + + assert "sys.prefix != sys.base_prefix" in source + assert 'sysconfig.get_paths()["purelib"]' in source + assert "is_relative_to(site_packages)" in source + + +def test_smoke_test_makes_no_live_mlb_request() -> None: + source = validator.SMOKE_TEST_SOURCE + + assert "requests.get(" not in source + assert "session.request(" not in source + assert "class ForbiddenSession:" in source + assert "never reaches the MLB API" in source + + +def test_validator_is_not_pinned_to_a_single_release_version() -> None: + """1.0.0 may appear as a usage example, never as the only accepted version.""" + source = VALIDATE_RELEASE.read_text(encoding="utf-8") + + assert 'EXPECTED_VERSION = "1.0.0"' not in source + assert "_read_expected_version" in source + assert "--expected-version" in source + # Terminology now covers both artifacts, not just the wheel. + assert "installed distribution artifact" in source + assert "clean wheel installation" not in source + assert "installed wheel" not in source + + +# --------------------------------------------------------------------------- +# Deterministic CI contract +# --------------------------------------------------------------------------- + + +def _matrix_python_versions() -> list[str]: + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + match = re.search( + r"^\s+python-version:\n((?:\s+- \"[^\"]+\"\n)+)", + text, + flags=re.MULTILINE, + ) + assert match is not None, "no python-version matrix found in the offline workflow" + return re.findall(r'- "([^"]+)"', match.group(1)) + + def test_ci_watches_the_current_release_branch() -> None: - workflow = PROJECT_ROOT / ".github" / "workflows" / "build-and-test.yml" - text = workflow.read_text(encoding="utf-8") - major, minor, _ = _project_version().split(".") + """Pull requests and pushes must watch main and release/1.0.0. + + The trigger is asserted literally instead of being derived from the package + version, which is still 0.9.0 until the release bump lands. + """ + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + + assert text.count(f"- {RELEASE_BRANCH}") == 2, text + assert text.count("- main") == 2, text + assert STALE_RELEASE_BRANCH not in text, ( + f"the stale {STALE_RELEASE_BRANCH} trigger must be removed" + ) + assert "workflow_dispatch:" in text + + +def test_ci_matrix_covers_every_supported_python_version() -> None: + assert _matrix_python_versions() == list(SUPPORTED_PYTHON_VERSIONS) + + +def test_ci_matrix_excludes_prerelease_python() -> None: + """No job may set up a prerelease interpreter, matrix or otherwise.""" + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + + assert UNSUPPORTED_PRERELEASE_PYTHON not in _matrix_python_versions() + assert f'- "{UNSUPPORTED_PRERELEASE_PYTHON}"' not in text + assert f'python-version: "{UNSUPPORTED_PRERELEASE_PYTHON}"' not in text + + +def test_ci_minimum_python_matches_the_declared_requirement() -> None: + versions = _matrix_python_versions() + + assert versions[0] == "3.10" + assert ( + f'python = "{DECLARED_PYTHON_REQUIREMENT}"' + in PYPROJECT.read_text(encoding="utf-8") + ) + + +def test_ci_build_job_validates_and_twine_checks_the_artifacts() -> None: + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + + assert "rm -rf dist" in text + assert "poetry build" in text + assert "python scripts/validate_release.py" in text + assert "poetry run twine check dist/*" in text + assert f'python-version: "{BUILD_JOB_PYTHON}"' in text + + +def test_ci_runs_offline_tests_without_the_live_suite() -> None: + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + + assert "--ignore=tests/external_tests" in text + assert "tests/external_tests/" not in text + + +def test_live_tests_stay_in_a_separate_workflow() -> None: + text = EXTERNAL_WORKFLOW.read_text(encoding="utf-8") + + assert "tests/external_tests/" in text + assert "workflow_dispatch:" in text + assert "schedule:" in text + # Live tests must not be attached to ordinary pushes or pull requests. + assert "pull_request:" not in text + assert "push:" not in text + + +@pytest.mark.parametrize( + "forbidden", + ( + "poetry publish", + "twine upload", + "PYPI_TOKEN", + "PYPI_API_TOKEN", + "POETRY_PYPI_TOKEN", + "TEST_PYPI", + "TESTPYPI", + "pypa/gh-action-pypi-publish", + "softprops/action-gh-release", + "gh release create", + "git tag", + ), +) +def test_no_workflow_publishes_or_tags(forbidden: str) -> None: + for workflow in (OFFLINE_WORKFLOW, EXTERNAL_WORKFLOW): + text = workflow.read_text(encoding="utf-8") + assert forbidden not in text, f"{workflow.name} contains {forbidden!r}" + + +def test_twine_is_a_development_dependency_only() -> None: + text = PYPROJECT.read_text(encoding="utf-8") + sections = dict( + re.findall(r"^\[([^\]]+)\]\n((?:(?!\[)[^\n]*\n)*)", text, flags=re.MULTILINE) + ) + + runtime = sections["tool.poetry.dependencies"] + development = sections["tool.poetry.group.dev.dependencies"] + + assert "twine" not in runtime, "twine must not become a runtime dependency" + assert re.search(r"^twine = ", development, flags=re.MULTILINE), development + - assert f"release/{major}.{minor}.0" in text - assert "- main" in text +def test_twine_is_locked() -> None: + assert 'name = "twine"' in POETRY_LOCK.read_text(encoding="utf-8")