From b08c1a2e0071580b1ed216583a171271114bee6f Mon Sep 17 00:00:00 2001 From: saadmk11 Date: Tue, 20 Oct 2026 12:16:42 +0600 Subject: [PATCH 1/3] Prepare 2.0.0 release --- .github/actions/check-state/action.yml | 6 + .github/actions/check-state/check_state.py | 26 + .github/actions/check-state/main.cjs | 1 + .github/actions/check-state/post.cjs | 1 + .github/actions/check-state/state.cjs | 13 + .github/dependabot.yaml | 2 +- .github/workflows/changelog-ci.yaml | 16 +- .github/workflows/pypi_publish.yaml | 116 ++- .github/workflows/showcase.yaml | 70 ++ .github/workflows/test.yaml | 92 ++- .gitignore | 149 +--- .python-version | 1 + CHANGELOG.md | 108 ++- CONTRIBUTING.md | 50 ++ README.md | 644 ++++++++--------- github_action_utils.py | 587 --------------- pyproject.toml | 125 ++++ requirements-dev.txt | 4 - scripts/__init__.py | 1 + scripts/check_artifacts.py | 42 ++ scripts/check_distribution.py | 376 ++++++++++ scripts/check_runner.py | 127 ++++ scripts/showcase.py | 548 ++++++++++++++ setup.cfg | 29 - setup.py | 3 - src/github_action_utils/__init__.py | 426 +++++++++++ src/github_action_utils/_artifacts.py | 119 ++++ src/github_action_utils/_commands.py | 93 +++ src/github_action_utils/_environment.py | 281 ++++++++ src/github_action_utils/_files.py | 165 +++++ src/github_action_utils/_inputs.py | 69 ++ src/github_action_utils/_serialization.py | 17 + src/github_action_utils/py.typed | 0 tests/_types.py | 24 + tests/command_helpers.py | 33 + tests/file_command_helpers.py | 18 + tests/fixtures/__init__.py | 1 + tests/fixtures/check_install.py | 15 + tests/fixtures/upgrade_after.py | 36 + tests/fixtures/upgrade_before.py | 29 + tests/support/__init__.py | 1 + tests/support/conftest.py | 69 ++ tests/support/test_distribution.py | 78 ++ tests/support/test_runner.py | 40 ++ tests/support/test_showcase.py | 114 +++ tests/test_artifacts.py | 195 +++++ tests/test_commands.py | 357 ++++++++++ tests/test_compatibility.py | 251 +++++++ tests/test_environment.py | 124 ++++ tests/test_file_commands.py | 290 ++++++++ tests/test_file_interoperability.py | 187 +++++ tests/test_github_action_utils.py | 351 +-------- tests/test_inputs.py | 109 +++ tests/typing/invalid.txt | 20 + tests/typing/valid.txt | 76 ++ tox.ini | 21 - uv.lock | 785 +++++++++++++++++++++ 57 files changed, 6030 insertions(+), 1501 deletions(-) create mode 100644 .github/actions/check-state/action.yml create mode 100644 .github/actions/check-state/check_state.py create mode 100644 .github/actions/check-state/main.cjs create mode 100644 .github/actions/check-state/post.cjs create mode 100644 .github/actions/check-state/state.cjs create mode 100644 .github/workflows/showcase.yaml create mode 100644 .python-version create mode 100644 CONTRIBUTING.md delete mode 100644 github_action_utils.py create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/check_artifacts.py create mode 100644 scripts/check_distribution.py create mode 100644 scripts/check_runner.py create mode 100644 scripts/showcase.py delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/github_action_utils/__init__.py create mode 100644 src/github_action_utils/_artifacts.py create mode 100644 src/github_action_utils/_commands.py create mode 100644 src/github_action_utils/_environment.py create mode 100644 src/github_action_utils/_files.py create mode 100644 src/github_action_utils/_inputs.py create mode 100644 src/github_action_utils/_serialization.py create mode 100644 src/github_action_utils/py.typed create mode 100644 tests/_types.py create mode 100644 tests/command_helpers.py create mode 100644 tests/file_command_helpers.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/check_install.py create mode 100644 tests/fixtures/upgrade_after.py create mode 100644 tests/fixtures/upgrade_before.py create mode 100644 tests/support/__init__.py create mode 100644 tests/support/conftest.py create mode 100644 tests/support/test_distribution.py create mode 100644 tests/support/test_runner.py create mode 100644 tests/support/test_showcase.py create mode 100644 tests/test_artifacts.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_compatibility.py create mode 100644 tests/test_environment.py create mode 100644 tests/test_file_commands.py create mode 100644 tests/test_file_interoperability.py create mode 100644 tests/test_inputs.py create mode 100644 tests/typing/invalid.txt create mode 100644 tests/typing/valid.txt delete mode 100644 tox.ini create mode 100644 uv.lock diff --git a/.github/actions/check-state/action.yml b/.github/actions/check-state/action.yml new file mode 100644 index 0000000..5821e2e --- /dev/null +++ b/.github/actions/check-state/action.yml @@ -0,0 +1,6 @@ +name: Check Python action state +description: Verify multiline state transfer from an action's main hook to its post hook. +runs: + using: node24 + main: main.cjs + post: post.cjs diff --git a/.github/actions/check-state/check_state.py b/.github/actions/check-state/check_state.py new file mode 100644 index 0000000..846762c --- /dev/null +++ b/.github/actions/check-state/check_state.py @@ -0,0 +1,26 @@ +"""Verify action state in main/post hooks; local actions do not support pre.""" + +import sys +from collections.abc import Sequence + +import github_action_utils as gha + +MAIN_VALUE = "from main\n100% %0A\nhéllo 世界\n" + + +def main(argv: Sequence[str] | None = None) -> None: + """Write main state or assert the exact state received by the post hook.""" + arguments = list(sys.argv[1:] if argv is None else argv) + match arguments: + case ["post"]: + assert gha.get_state("from_main") == MAIN_VALUE + print("Post hook received exact main state") + case ["main"]: + gha.save_state("from_main", MAIN_VALUE) + print("Main hook saved state") + case _: + raise ValueError("Expected main or post") + + +if __name__ == "__main__": + main() diff --git a/.github/actions/check-state/main.cjs b/.github/actions/check-state/main.cjs new file mode 100644 index 0000000..cbba6f1 --- /dev/null +++ b/.github/actions/check-state/main.cjs @@ -0,0 +1 @@ +require("./state.cjs")("main"); diff --git a/.github/actions/check-state/post.cjs b/.github/actions/check-state/post.cjs new file mode 100644 index 0000000..46ee5ee --- /dev/null +++ b/.github/actions/check-state/post.cjs @@ -0,0 +1 @@ +require("./state.cjs")("post"); diff --git a/.github/actions/check-state/state.cjs b/.github/actions/check-state/state.cjs new file mode 100644 index 0000000..c6ffdae --- /dev/null +++ b/.github/actions/check-state/state.cjs @@ -0,0 +1,13 @@ +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +// The runner supplies STATE_* values to each new hook process. +module.exports = (phase) => { + const result = spawnSync( + "python", + [path.join(__dirname, "check_state.py"), phase], + { stdio: "inherit" }, + ); + if (result.error) throw result.error; + process.exit(result.status === null ? 1 : result.status); +}; diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 65e3441..00948bd 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: pip + - package-ecosystem: uv directory: "/" schedule: interval: monthly diff --git a/.github/workflows/changelog-ci.yaml b/.github/workflows/changelog-ci.yaml index a295660..800e943 100644 --- a/.github/workflows/changelog-ci.yaml +++ b/.github/workflows/changelog-ci.yaml @@ -4,12 +4,22 @@ on: pull_request: types: [ opened ] +permissions: + contents: read + jobs: build: - runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.head_ref }} - name: Run Changelog CI - uses: saadmk11/changelog-ci@v1.1.1 + uses: saadmk11/changelog-ci@ed0c1a6b31e9b31d1baa74f66261d5d39c1f9705 # v2.0.2 diff --git a/.github/workflows/pypi_publish.yaml b/.github/workflows/pypi_publish.yaml index 49c985d..b6c2f9c 100644 --- a/.github/workflows/pypi_publish.yaml +++ b/.github/workflows/pypi_publish.yaml @@ -8,29 +8,101 @@ on: permissions: contents: read +concurrency: + group: pypi + cancel-in-progress: false + jobs: - deploy: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + outputs: + version: ${{ steps.release.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + - name: Resolve and validate an existing published release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + run: uv run --no-project --python 3.11 python scripts/check_distribution.py --github-release + + validate: + needs: release + uses: ./.github/workflows/test.yaml - runs-on: ubuntu-latest + showcase: + needs: release + uses: ./.github/workflows/showcase.yaml + + build: + needs: [release, validate, showcase] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + enable-cache: true + cache-dependency-glob: uv.lock + - name: Build and verify the exact release distributions + run: | + uv sync --locked + uv build --clear + uv run --locked python scripts/check_distribution.py + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-distributions + path: dist/ + if-no-files-found: error + + publish: + needs: [release, build] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-distributions + path: dist/ + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + - name: Publish the checked bytes with Trusted Publishing + run: | + cd dist + sha256sum --check SHA256SUMS + uv publish --trusted-publishing always *.whl *.tar.gz + verify-release: + needs: [release, publish] + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.10' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - - name: Build package - run: python -m build - - - name: Publish package - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + - name: Install and verify the published distribution in a clean environment + env: + RELEASE_VERSION: ${{ needs.release.outputs.version }} + run: | + uv venv --python 3.11 /tmp/published + uv pip install --refresh-package github-action-utils --python /tmp/published/bin/python "github-action-utils==$RELEASE_VERSION" + /tmp/published/bin/python scripts/check_distribution.py --published-version "$RELEASE_VERSION" diff --git a/.github/workflows/showcase.yaml b/.github/workflows/showcase.yaml new file mode 100644 index 0000000..ab8ce91 --- /dev/null +++ b/.github/workflows/showcase.yaml @@ -0,0 +1,70 @@ +name: Feature showcase + +on: + pull_request: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + showcase: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest, macos-latest] + defaults: + run: + shell: bash + env: + INPUT_SHOWCASE_TEXT: ' explicit fixture ' + INPUT_SHOWCASE_FLAG: 'true' + INPUT_SHOWCASE_LINES: | + first + 世界 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.11' + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + enable-cache: true + cache-dependency-glob: uv.lock + - name: Install built package into the action Python + run: | + uv build --clear + uv pip install --system dist/*.whl + - name: Summary append, replacement, and removal in a separate fixture + run: python -m scripts.showcase summary + - name: Console, annotations, groups, inputs, and producer records + id: producer + run: python -m scripts.showcase produce + - name: Assert subsequent-step outputs, environment, PATH, and conditional artifacts + if: always() + env: + OUTPUT_VALUE: ${{ steps.producer.outputs.multiline }} + OUTPUT_NUMBER: ${{ steps.producer.outputs.number }} + OUTPUT_JSON: ${{ steps.producer.outputs.json }} + OUTPUT_EMPTY: ${{ steps.producer.outputs.empty }} + OUTPUT_UNICODE: ${{ steps.producer.outputs.unicode }} + ARTIFACTS_ENABLED: ${{ steps.producer.outputs.enabled }} + run: python -m scripts.showcase verify + - name: Main/post action-scoped state + uses: ./.github/actions/check-state + - name: Feature results + if: always() + run: python -m scripts.showcase report + - name: Upload showcase reports + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: showcase-${{ matrix.os }} + path: reports/showcase/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 76e6025..97b9ecc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,51 +1,75 @@ name: Test on: + workflow_call: push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + workflow_dispatch: permissions: contents: read jobs: - linting: - runs-on: ubuntu-latest + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: "3.10" - - - name: Install Dependencies - run: python -m pip install --upgrade setuptools pip virtualenv tox - - - name: Check Black, isort, and mypy - run: python -m tox -e black-formatter,isort,mypy + ref: ${{ github.sha }} + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --locked + - run: uv run --locked tox run -e lint,type,coverage,package + - name: Lint workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: quality-reports + path: reports/*coverage.* test: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: + fail-fast: false matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] - - runs-on: ubuntu-20.04 - + os: [ubuntu-24.04, windows-latest, macos-latest] + python-version: ['3.11', '3.12', '3.13', '3.14', '3.15'] + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Dependencies - run: python -m pip install --upgrade setuptools pip virtualenv tox - - name: Run Tests - run: tox -e py - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: coverage.xml + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.13.1' + enable-cache: true + cache-dependency-glob: uv.lock + - name: Build and test the non-editable wheel + env: + PYTHON_VERSION: ${{ matrix.python-version }} + run: | + uv build --clear + uv run --locked --python "$PYTHON_VERSION" --only-group tox tox run -e "py${PYTHON_VERSION/./}" --installpkg dist/*.whl -- --cov=github_action_utils --cov-report=xml:reports/matrix-coverage.xml --cov-fail-under=0 + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: checks-${{ matrix.os }}-${{ matrix.python-version }} + path: reports/matrix-coverage.xml diff --git a/.gitignore b/.gitignore index 648c158..6cefd5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,143 +1,30 @@ -# Byte-compiled / optimized / DLL files +# Python and build output __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python +*.egg-info/ build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Local environments and secrets +.venv/ +venv/ +env/ +.env +.env.* -# Unit test / coverage reports -htmlcov/ +# Tool caches and reports .tox/ -.nox/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints +reports/ -# IPython -profile_default/ -ipython_config.py +# Runner integration fixtures +.runner-integration/ +github-action-utils-artifact-fixture.txt -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm +# Editor and OS files .idea/ - -t.py +.DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..93a848f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.15 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cda24..15f43f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,106 @@ -# Version: 1.1.0 +# Changelog -* [#13](https://github.com/saadmk11/github-action-utils/pull/13): Update `save_state` and `set_output` Implementation [Previous Implementation is Deprecated] +## 2.0.0 +### Breaking changes -# Version: 1.0.2 +- **Python 3.11 or newer is required.** Support for Python 3.6–3.10 has ended. +- Standard-library and typing names are no longer part of the public API. + Import them directly from the standard library. +- Output, state, and environment files now store raw values. Literal `%`, `%0A`, + carriage returns, line breaks, Unicode, and whitespace are preserved instead + of being percent-encoded. +- Job summaries preserve literal Markdown, including `%25`, `%0D`, and `%0A`. + These sequences are no longer decoded. +- Log commands escape message text so embedded line breaks cannot introduce + additional commands. `echo()` continues to print raw text. +- Invalid file-command names and stop tokens raise `ValueError` before writing. + File-command names must be nonempty and exclude `=`, `<<`, CR, LF, and NUL. + Malformed pending environment records raise `ValueError`. +- PATH entries containing NUL are rejected. Subprocess failures now propagate + as `subprocess.CalledProcessError`. -* [#9](https://github.com/saadmk11/github-action-utils/pull/9): Fix `group()` Context Manager `end_group()` function call not passing `use_subprocess` -* [#10](https://github.com/saadmk11/github-action-utils/pull/10): Rearrange Documentation Sections +### Added +- Live, read-only environment access through `vars`, `WorkflowVariables`, + `GitHubVariables`, and `RunnerVariables`. Text values remain strings; flags + have explicit boolean values. `is_github_action()` detects GitHub Actions. +- `get_boolean_input()` and `get_multiline_input()`, with required-input, + whitespace, and empty-line options. `get_user_input()` gains optional + `required` and `trim_whitespace` arguments; its defaults are unchanged. +- `set_command_echo()` for runner command diagnostics and `fail()` for an error + annotation followed by exit status `1`. +- `add_problem_matcher()` and `remove_problem_matcher()` for tool annotations. +- `declare_file_artifact()`, `declare_oci_artifact()`, + `get_workflow_artifacts()`, and immutable `ArtifactSubject` metadata. These + require runner support and record identities without uploading files or images. +- Text `PathLike` support and optional `strict=True` validation in + `add_system_path()`. +- Inline type annotations and `py.typed` for installed-package type checking. -# Version: 1.0.1 +### Fixed -* [#3](https://github.com/saadmk11/github-action-utils/pull/3): Do not Escape Data on Log Messages -* [#4](https://github.com/saadmk11/github-action-utils/pull/4): Add Option to Run Workflow Commands using Python Subprocess Module -* [#5](https://github.com/saadmk11/github-action-utils/pull/5): Use Changelog CI +- Multiline file records use unique, collision-checked delimiters and UTF-8 + without losing leading BOM characters or trailing newlines in values. +- Pending environment reads handle simple and multiline records, leading UTF-8 + BOMs, empty values, and duplicate names. The last value for a name wins. +- `get_env()` preserves an empty process value and returns `None` for missing + local values. `get_workflow_environment_variables()` returns an empty + dictionary when the `GITHUB_ENV` path is unset or empty. +- `group()` closes its log group and `stop_commands()` restores command + processing when the context body raises. Generated stop tokens use UUID4. +- Console output is flushed. Subprocess mode uses the current Python interpreter + and standard input, avoiding shell interpretation, command-line length limits, + and platform-specific `echo` behavior. +- PATH writes add missing record terminators while preserving existing batches. +- Input names containing spaces can use underscore-separated `INPUT_*` keys; + an existing literal key still takes precedence. Hyphens remain unchanged. + +### Development and releases + +- Move to a `src` package layout, `pyproject.toml`, uv builds and publishing, + and locked development dependencies. The package has no runtime dependencies. +- Use Python 3.15 for development. CI covers Python 3.11–3.15 on Linux, Windows, + and macOS, with Ruff, strict mypy, and full statement and branch coverage. +- Check clean wheel/source installs, installed typing, and upgrades from 1.1.0. + A feature showcase checks cross-step values and action main/post state. +- Publish with PyPI Trusted Publishing after validating the release tag and + distributions. Verify the published package in a clean environment. + +### Upgrading from 1.1.0 + +1. Update workflows to Python 3.11 or newer, then upgrade the package to 2.0.0. +2. Pass raw values to file writers and job-summary helpers. Remove workarounds + that manually encode or decode percent sequences and line breaks. +3. Use `echo()` inside `stop_commands()` when printing text that must remain + literal, including command-looking text. +4. Check custom file-command names and stop tokens against the validation rules + in the [README](README.md#configuration). +5. Remove the deprecated `use_subprocess` argument from `set_output()` and + `save_state()`. It remains accepted with a `DeprecationWarning`; both + functions always write runner files. + +Existing package functions, public call signatures, the mutable +`COMMANDS_USE_SUBPROCESS` setting, and event-payload caching remain available. +`set_env()` and PATH changes still apply to later steps; they do not change the +current Python process. + +## 1.1.0 + +- [#13](https://github.com/saadmk11/github-action-utils/pull/13): Write + `save_state()` and `set_output()` values through runner environment files. + +## 1.0.2 + +- [#9](https://github.com/saadmk11/github-action-utils/pull/9): Forward + `use_subprocess` when `group()` calls `end_group()`. +- [#10](https://github.com/saadmk11/github-action-utils/pull/10): Reorganize + the documentation. + +## 1.0.1 + +- [#3](https://github.com/saadmk11/github-action-utils/pull/3): Stop escaping + log messages. +- [#4](https://github.com/saadmk11/github-action-utils/pull/4): Add subprocess + mode for workflow commands. +- [#5](https://github.com/saadmk11/github-action-utils/pull/5): Add Changelog CI. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8f18a99 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing + +Use Python 3.15 and uv 0.13.1 for development. The package supports Python +3.11–3.15 and has no runtime dependencies. + +## Development + +```sh +uv sync --locked +uv run --locked ruff format . +uv run --locked tox run -e lint,type,coverage,package +uv run --locked tox run -e py311,py312,py313,py314,py315 +``` + +Select installed Python versions locally; CI runs all supported versions on +Linux, Windows, and macOS. Tox tests an installed wheel. Settings are in +`pyproject.toml`; `uv.lock` pins development dependencies. + +Test observable behavior and add regression tests for bug fixes. Package code +requires 100% statement and branch coverage. Source, tests, and scripts must pass +strict mypy. Check workflow changes with actionlint 1.7.12. + +The `package` check verifies source and wheel installs, installed typing, and +upgrades from the published 1.1.0 package. It needs network access. To use a local +1.1.0 wheel, run `python scripts/check_distribution.py --previous-wheel /path/to/wheel` +after building the distributions. + +## GitHub Actions integration + +The [feature showcase](.github/workflows/showcase.yaml) checks cross-step values, +PATH, and main/post action state on all three operating systems. Node.js launches +the state-test hooks. Inspect annotations, groups, masking, matchers, debug +output, post-hook logs, and the job summary. `MANUAL` needs visual inspection; +`SKIP` means the runner lacks the required feature. + +## Releases + +1. Update `pyproject.toml` and `CHANGELOG.md`, then run `uv lock` and the checks + above. Inspect the hosted showcase. +2. Configure the PyPI Trusted Publisher for `saadmk11/github-action-utils`, + workflow `pypi_publish.yaml`, and the protected `pypi` environment. +3. Publish a GitHub release with a matching tag, such as `v2.0.0`. For a manual + workflow run, select that tag. The workflow checks the tag, commit, release, + and package version; validates the distributions; publishes them with uv + Trusted Publishing; and checks the installed PyPI version. + +## Reporting issues + +Include the Python and package versions, a minimal example, relevant workflow +steps, and expected versus actual behavior. Remove secrets from logs and examples. diff --git a/README.md b/README.md index 2cbcec3..bf48bfe 100644 --- a/README.md +++ b/README.md @@ -1,469 +1,451 @@ # GitHub Action Utils -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/saadmk11/github-action-utils?style=flat-square)](https://github.com/saadmk11/github-action-utils/releases/latest) -![Django Tests](https://img.shields.io/github/actions/workflow/status/saadmk11/github-action-utils/test.yaml?style=flat-square) -![Codecov](https://img.shields.io/codecov/c/github/saadmk11/github-action-utils?style=flat-square&token=ugjHXbEKib) -[![GitHub](https://img.shields.io/github/license/saadmk11/github-action-utils?style=flat-square)](https://github.com/saadmk11/github-action-utils/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/saadmk11/github-action-utils?color=success&style=flat-square)](https://github.com/saadmk11/github-action-utils/stargazers) +[![PyPI](https://img.shields.io/pypi/v/github-action-utils?style=flat-square)](https://pypi.org/project/github-action-utils/) +[![Tests](https://img.shields.io/github/actions/workflow/status/saadmk11/github-action-utils/test.yaml?branch=main&style=flat-square)](https://github.com/saadmk11/github-action-utils/actions/workflows/test.yaml) +[![License: MIT](https://img.shields.io/github/license/saadmk11/github-action-utils?style=flat-square)](LICENSE) -![Actions Workflow Run](https://user-images.githubusercontent.com/24854406/180658147-9cfddcfe-ef51-40bc-8e0f-1949482e6a09.png) +Use GitHub Actions workflow commands from Python: annotate logs, share values +between steps, read inputs, and write job summaries. +**Python 3.11+ · Linux, Windows, macOS · No runtime dependencies · Typed** -This package is a collection of python functions that can be used to run [GitHub Action Workflow Commands](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions) from a python script inside an action workflow run. +> [!NOTE] +> See the [changelog](CHANGELOG.md) for breaking changes and upgrade instructions. -## Requirements +**Contents** -**Python:** 3.6, 3.7, 3.8, 3.9, 3.10, 3.11 +[Install](#install) · [Quick start](#quick-start) · [Logging](#logging) · +[Groups and command control](#groups-and-command-control) · [Step data](#step-data) · +[Inputs](#inputs) · [Environment and events](#environment-and-events) · +[Action state](#action-state) · [Artifact identities](#artifact-identities) · +[Configuration](#configuration) · [Development](#development) -## Installation +## Install -Install `github-action-utils` using pip: - -```console -pip install github-action-utils +```sh +python -m pip install github-action-utils ``` -## Example - -### Example Code - -```python -import github_action_utils as gha_utils - -with gha_utils.group("My Group"): - gha_utils.set_output("test_var", "test_value") - gha_utils.save_state("state", "val") - gha_utils.debug("Debug message") +Or add it to a uv project: - gha_utils.warning( - "Warning message", title="Warning Title", file="example.py", - col=1, end_column=2, line=5, end_line=6, - ) - gha_utils.warning("Another warning message") - - gha_utils.error( - "Error message", title="Error Title", file="example.py", - col=1, end_column=2, line=1, end_line=2, - ) - gha_utils.notice("Another notice message") - - gha_utils.append_job_summary("# Hello World") - gha_utils.append_job_summary("- Point 1") - gha_utils.append_job_summary("- Point 2") +```sh +uv add github-action-utils ``` -### Can be used inside a Workflow +## Quick start + +Create `.github/workflows/demo.yaml`: ```yaml -name: run-python-script +name: Python workflow commands +on: workflow_dispatch -on: - pull_request: - branches: [ "main" ] +permissions: + contents: read jobs: - build: - + demo: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - - name: Install dependencies - run: python -m pip install github-action-utils - - - name: Run Python Script - shell: python - run: | - import github_action_utils as gha_utils - - with gha_utils.group("My Group"): - gha_utils.error( - "Error message", title="Error Title", file="example.py", - col=1, end_column=2, line=1, end_line=2, - ) - gha_utils.notice("Another notice message") - gha_utils.append_job_summary("# Hello World") + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.15' + - run: python -m pip install github-action-utils + - name: Prepare a result + id: prepare + shell: python + run: | + import github_action_utils as gha + + with gha.group("Build"): + gha.notice("Build complete") + gha.set_output("version", "1.0.0") + gha.set_env("PACKAGE_NAME", "demo") + gha.append_job_summary("## Build\n\nVersion: **1.0.0**") + - name: Use the result in a later step + env: + BUILD_VERSION: ${{ steps.prepare.outputs.version }} + run: | + printf '%s %s\n' "$PACKAGE_NAME" "$BUILD_VERSION" ``` -#### Colorful Grouped Build Log Output -![s3](https://user-images.githubusercontent.com/24854406/180003937-5839856e-09f9-47e7-8b62-f5126a78cad6.png) +The examples below use `import github_action_utils as gha`. -#### Log Annotations and Build Summery -![s2](https://user-images.githubusercontent.com/24854406/180003153-99434824-d08c-4a54-9a89-4c6163def1b2.png) +## Logging -#### Log Annotations Associated with a File -![s](https://user-images.githubusercontent.com/24854406/180003164-12735d03-a452-4bef-96a6-f1dc4298756e.png) +| Function | Result | +| --- | --- | +| `echo(message)` | Print ordinary text. | +| `debug(message)` | Emit a debug message; enable [step debug logging](https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging) to see it. | +| `notice(message, ...)` | Create a notice annotation. | +| `warning(message, ...)` | Create a warning annotation. | +| `error(message, ...)` | Create an error annotation and return normally. | +| `fail(message)` | Emit an error and exit immediately with status `1`. | +| `set_command_echo(enabled)` | Enable or disable the runner's command diagnostics; accepts a `bool`. | - -## Available Functions - -This section documents all the functions provided by `github-action-utils`. The functions in the package should be used inside a workflow run. - -**Note:** You can run the commands using python's `subprocess` module by using `use_subprocess` function parameter or `COMMANDS_USE_SUBPROCESS` environment variable. - -### **`echo(message, use_subprocess=False)`** - -Prints specified message to the action workflow console. - -**example:** +`notice`, `warning`, and `error` accept the same optional annotation properties: +`title`, `file`, `col`, `end_column`, `line`, and `end_line`. ```python ->> from github_action_utils import echo - ->> echo("Hello World") - -# Output: -# Hello World +gha.echo("Starting build") +gha.debug("Checking dependencies") +gha.warning("Unused variable", title="Lint", file="src/app.py", line=12, col=5) +gha.notice("Review this block", file="src/app.py", line=20, end_line=24) ``` -### **`debug(message, use_subprocess=False)`** +Use columns for a single line; GitHub drops column properties for annotations +spanning multiple lines. Pass normal text—command helpers handle escaping. +See the [workflow command reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands). -Prints colorful debug message to the action workflow console. -GitHub Actions Docs: [debug](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-debug-message) +## Groups and command control -**example:** +### Group related logs ```python ->> from github_action_utils import debug - ->> debug("Hello World") - -# Output: -# ::debug ::Hello World +with gha.group("Run checks"): + gha.echo("Checking formatting") + gha.echo("Checking types") ``` -### **`notice(message, title=None, file=None, col=None, end_column=None, line=None, end_line=None, use_subprocess=False)`** - -Prints colorful notice message to the action workflow console. -GitHub Actions Docs: [notice](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-notice-message) +The group closes even if the body raises. For manual control, use +`start_group(title)` and `end_group()`. -**example:** +### Mask sensitive values ```python ->> from github_action_utils import notice - ->> notice( - "test message", - title="test title", - file="abc.py", - col=1, - end_column=2, - line=4, - end_line=5, -) - -# Output: -# ::notice title=test title,file=abc.py,col=1,endColumn=2,line=4,endLine=5::test message= +gha.add_mask("example-secret-value") +gha.echo("example-secret-value") # GitHub displays the registered value as ***. ``` -### **`warning(message, title=None, file=None, col=None, end_column=None, line=None, end_line=None, use_subprocess=False)`** +Register masks **before** printing sensitive values. -Prints colorful warning message to the action workflow console. -GitHub Actions Docs: [warning](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-warning-message) +### Suspend command processing -**example:** +`echo()` prints raw text, so command-looking text can be interpreted by GitHub. +Use `stop_commands()` when printing text that should stay literal: ```python ->> from github_action_utils import warning - ->> warning( - "test message", - title="test title", - file="abc.py", - col=1, - end_column=2, - line=4, - end_line=5, -) - -# Output: -# ::warning title=test title,file=abc.py,col=1,endColumn=2,line=4,endLine=5::test message +with gha.stop_commands(): + gha.echo("::warning::This remains plain text") ``` -### **`error(message, title=None, file=None, col=None, end_column=None, line=None, end_line=None, use_subprocess=False)`** - -Prints colorful error message to the action workflow console. -GitHub Actions Docs: [error](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-error-message) - -**example:** +Processing resumes even if the body raises. For manual control: ```python ->> from github_action_utils import error - ->> error( - "test message", - title="test title", - file="abc.py", - col=1, - end_column=2, - line=4, - end_line=5, -) - -# Output: -# ::error title=test title,file=abc.py,col=1,endColumn=2,line=4,endLine=5::test message +token = gha.begin_stop_commands() +try: + gha.echo("::error::Also plain text") +finally: + gha.end_stop_commands(token) ``` -### **`set_output(name, value)`** +`stop_commands(token=None)` and `begin_stop_commands(token=None)` generate random +UUID4 tokens by default. Custom tokens must be unique for the run; prefer the +generated token. -Sets a step's output parameter by writing to `GITHUB_OUTPUT` environment file. Note that the step will need an `id` to be defined to later retrieve the output value. -GitHub Actions Docs: [set_output](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter) +### Register problem matchers -**example:** +Problem matchers turn matching tool output into annotations. ```python ->> from github_action_utils import set_output +gha.add_problem_matcher(".github/matchers/compiler.json") +try: + gha.echo("src/app.py:12: Unused variable") +finally: + gha.remove_problem_matcher(owner="compiler") +``` ->> set_output("my_output", "test value") +`remove_problem_matcher(*, owner=None, path=None)` requires **exactly one** +selector. Use `path=".github/matchers/compiler.json"` to remove matchers from a file. +Relative matcher paths resolve against the workspace. + +
+Example matcher file: .github/matchers/compiler.json + +```json +{ + "problemMatcher": [{ + "owner": "compiler", + "severity": "warning", + "pattern": [{ + "regexp": "^(.+):([0-9]+): (.+)$", + "file": 1, + "line": 2, + "message": 3 + }] + }] +} ``` -### **`save_state(name, value)`** +See GitHub's [problem matcher guide](https://github.com/actions/toolkit/blob/main/docs/problem-matchers.md). -Creates an environment variable by writing this to the `GITHUB_STATE` environment file which is available to workflow's pre: or post: actions. -GitHub Actions Docs: [save_state](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#sending-values-to-the-pre-and-post-actions) +
-**example:** +## Step data -```python ->> from github_action_utils import save_state - ->> save_state("my_state", "test value") -``` +### Outputs and environment variables -### **`get_state(name)`** - -Gets state environment variable from running workflow. - -**example:** +| Function | Behavior | +| --- | --- | +| `set_output(name, value)` | Set an output for later steps using the producing step's `id`. | +| `set_env(name, value)` | Set an environment variable for later steps in the same job. | +| `get_env(name)` | Read the process environment first, then the current step's pending environment file. Missing values return `None`; an empty process value takes precedence. | +| `get_workflow_environment_variables()` | Read pending values from this step's `GITHUB_ENV` file. Returns a fresh dictionary, or `{}` when the path is unset/empty. | ```python ->> from github_action_utils import get_state +gha.set_output("files", ["app.whl", "app.tar.gz"]) +gha.set_env("BUILD_MESSAGE", "First line\nSecond line: 100%") ->> get_state("test_name") - -# Output: -# test_value +pending = gha.get_workflow_environment_variables() +message = gha.get_env("BUILD_MESSAGE") ``` -### **`get_user_input(name)`** +Lists, tuples, and dictionaries become JSON; other values use `str()`. File +values preserve Unicode, whitespace, percent signs, and line breaks. Use nonempty +string names without `=`, `<<`, CR, LF, or NUL. -Gets user input from running workflow. +**Scope:** `set_env()` does not change this Python process's `os.environ` or +`gha.vars`. The pending file contains only this step's queued variables. GitHub +protects `GITHUB_*`/`RUNNER_*` variables and blocks `NODE_OPTIONS` through `GITHUB_ENV`. -**example:** +### Add directories to PATH ```python ->> from github_action_utils import get_user_input - ->> get_user_input("my_input") +from pathlib import Path -# Output: -# my value +gha.add_system_path(Path.cwd() / "tools", strict=True) ``` -### **`begin_stop_commands(token=None, use_subprocess=False)` and `end_stop_commands(token, use_subprocess=False)`** +`add_system_path(path, *, strict=False)` accepts strings and text `PathLike` +objects. Directories become available in **later steps**; the current process's +PATH stays unchanged. A directory need not exist yet. `strict=True` requires one +nonempty entry without CR, LF, or NUL. The default preserves legacy path batches; +NUL is always rejected. -Stops processing any workflow commands. This special command allows you to log anything without accidentally running a workflow command. -GitHub Actions Docs: [stop_commands](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#stopping-and-starting-workflow-commands) +### Write job summaries -**example:** +| Function | Behavior | +| --- | --- | +| `append_job_summary(markdown_text)` | Append Markdown and a newline. | +| `overwrite_job_summary(markdown_text)` | Replace this step's summary with Markdown and a newline. | +| `remove_job_summary()` | Delete this step's summary; a missing file is harmless. | ```python ->> from github_action_utils import echo, begin_stop_commands, end_stop_commands, stop_commands - ->> begin_stop_commands(token="my_token") ->> echo("Hello World") ->> end_stop_commands("my_token") - -# Output: -# ::stop-commands ::my_token -# Hello World -# ::my_token:: - -# ==================== -# Using Stop Commands Context Manager -# ==================== - ->> with stop_commands(token="my_token"): -... echo("Hello World") - -# Output: -# ::stop-commands ::my_token -# Hello World -# ::my_token:: +gha.append_job_summary( + "## Results\n\n| Check | Result |\n| --- | --- |\n| Tests | Passed |" +) ``` -### **`start_group(title, use_subprocess=False)` and `end_group(use_subprocess=False)`** - -Creates an expandable group in the workflow log. -GitHub Actions Docs: [group](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#grouping-log-lines) +Markdown is written as raw UTF-8. Replacement and deletion affect the current +step's summary. [GitHub summary documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#adding-a-job-summary) -**example:** +## Inputs -```python ->> from github_action_utils import echo, start_group, end_group, group +These helpers read `INPUT_*` environment variables: ->> start_group("My Group Title") ->> echo("Hello World") ->> end_group() +| Function | Missing optional input | Defaults | +| --- | --- | --- | +| `get_user_input(name, ...)` | `None` | `required=False`, `trim_whitespace=False` | +| `get_boolean_input(name, ...)` | `None` | `required=False`, `trim_whitespace=True` | +| `get_multiline_input(name, ...)` | `[]` | `required=False`, `trim_whitespace=True`, `keep_empty_lines=False` | -# Output: -# ::group ::My Group Title -# Hello World -# ::endgroup:: +```python +target = gha.get_user_input("target", required=True, trim_whitespace=True) +enabled = gha.get_boolean_input("enabled") +files = gha.get_multiline_input("files", required=True) +raw_lines = gha.get_multiline_input( + "template", trim_whitespace=False, keep_empty_lines=True +) +``` -# ==================== -# Using Group Context Manager -# ==================== +- **Raw:** preserves empty strings and whitespace unless trimming is enabled. +- **Boolean:** accepts `true`/`True`/`TRUE` and `false`/`False`/`FALSE`. + Empty strings and other spellings raise `ValueError`. +- **Multiline:** splits LF/CRLF, optionally trims each line, and optionally keeps + empty lines—including a final empty line after a terminal newline. +- **Required:** raises `ValueError` for missing/empty input after the selected + trimming. Multiline input needs at least one nonempty line. ->> with group("My Group Title"): -... echo("Hello World") +Names are uppercased; spaces fall back to underscores and hyphens are preserved. +For composite actions and workflow inputs, wire values explicitly: -# Output: -# ::group ::My Group Title -# Hello World -# ::endgroup:: +```yaml +- name: Read inputs + env: + INPUT_TARGET: ${{ inputs.target }} + run: python script.py ``` -### **`add_mask(value, use_subprocess=False)`** +## Environment and events -Masking a value prevents a string or variable from being printed in the workflow console. -GitHub Actions Docs: [add_mask](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#masking-a-value-in-log) - -**example:** +### Detect GitHub Actions ```python ->> from github_action_utils import add_mask - ->> add_mask("test value") - -# Output: -# ::add-mask ::test value +if gha.is_github_action(): + gha.notice("Running in GitHub Actions") +else: + print("Running locally") ``` -### **`set_env(name, value)`** - -Creates an environment variable by writing this to the `GITHUB_ENV` environment file which is available to any subsequent steps in a workflow job. -GitHub Actions Docs: [set_env](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable) +Detection checks `GITHUB_ACTIONS == "true"` on every call. File-writing helpers +need their runner-provided file paths; missing paths raise `KeyError` and file +errors propagate. -**example:** +### Read live environment views ```python ->> from github_action_utils import set_env - ->> set_env("my_env", "test value") +print(gha.vars.github.repository) # str or None +print(gha.vars.github.run_number) # str or None; numbers stay strings +print(gha.vars.runner.os) # str or None +print(gha.vars.runner.debug) # bool ``` -### **`get_workflow_environment_variables()`** +`vars` reads `os.environ` on access. Properties are read-only; missing text is +`None` and empty text stays `""`. Boolean flags use exact values: -Gets all environment variables from the `GITHUB_ENV` environment file which is available to the workflow. -GitHub Actions Docs: [set_env](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable) +| Property | True when | +| --- | --- | +| `vars.ci`, `vars.github.actions`, `vars.github.ref_protected` | The corresponding environment value is `"true"`. | +| `vars.runner.debug` | `RUNNER_DEBUG` is `"1"`. | -**example:** +The public classes `WorkflowVariables`, `GitHubVariables`, and `RunnerVariables` +can also be used as type annotations or instantiated as separate views. -```python ->> from github_action_utils import get_workflow_environment_variables +
+All environment properties ->> get_workflow_environment_variables() +Property names map to their uppercase environment names: for example, +`vars.github.run_id` reads `GITHUB_RUN_ID` and `vars.runner.tool_cache` reads +`RUNNER_TOOL_CACHE`. -# Output: -# {"my_env": "test value"} -``` +| View | Properties | +| --- | --- | +| `vars` | `ci`, `github`, `runner` | +| `vars.github` — action | `action`, `action_path`, `action_repository`, `job` | +| `vars.github` — identity | `actor`, `actor_id`, `triggering_actor`, `repository`, `repository_id`, `repository_owner`, `repository_owner_id` | +| `vars.github` — run | `actions`, `workflow`, `workflow_ref`, `workflow_sha`, `run_id`, `run_number`, `run_attempt`, `retention_days` | +| `vars.github` — refs and events | `ref`, `ref_name`, `ref_type`, `ref_protected`, `base_ref`, `head_ref`, `sha`, `event_name`, `event_path` | +| `vars.github` — URLs and files | `api_url`, `graphql_url`, `server_url`, `workspace`, `env`, `output`, `path`, `step_summary`, `artifacts`, `artifacts_list` | +| `vars.runner` | `arch`, `environment`, `name`, `os`, `temp`, `tool_cache`, `debug` | -### **`get_env(name)`** +
-Gets all environment variables from `os.environ` or the `GITHUB_ENV` environment file which is available to the workflow. -This can also be used to get [environment variables set by GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables). -GitHub Actions Docs: [set_env](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable) +This Python view is separate from GitHub's workflow-expression +[`vars` context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#vars-context), +which holds configuration variables. Import it explicitly (`gha.vars` or +`from github_action_utils import vars`); wildcard imports preserve built-in `vars()`. -**example:** +### Read the event payload ```python ->> from github_action_utils import get_env - ->> get_env("my_env") ->> get_env("GITHUB_API_URL") +payload = gha.event_payload() +print(payload.get("action")) -# Output: -# test value -# https://api.github.com +gha.event_payload.cache_clear() # Reload on the next call. ``` -### **`append_job_summary(markdown_text)`** +`event_payload()` loads UTF-8 JSON from `GITHUB_EVENT_PATH` and caches the result. +Calls share the same mutable object. Use `.cache_info()` for cache statistics. -Sets some custom Markdown for each job so that it will be displayed on the summary page of a workflow run. -GitHub Actions Docs: [append_job_summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary) +## Action state -**example:** +| Function | Behavior | +| --- | --- | +| `save_state(name, value)` | Save a value for another lifecycle hook of the same action. | +| `get_state(name)` | Read `STATE_` in the receiving hook; returns `None` when absent. | -```python ->> from github_action_utils import append_job_summary +In the action's **main hook**: ->> append_job_summary("# test summary") +```python +gha.save_state("cleanup_path", "/tmp/build") ``` - -### **`overwrite_job_summary(markdown_text)`** - -Clears all content for the current step, and adds new job summary. -GitHub Actions Docs: [overwrite_job_summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#overwriting-job-summaries) - -**example:** +In its **post hook**: ```python ->> from github_action_utils import overwrite_job_summary - ->> overwrite_job_summary("# test summary") +cleanup_path = gha.get_state("cleanup_path") ``` -### **`remove_job_summary()`** - -completely removes job summary for the current step. -GitHub Actions Docs: [remove_job_summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#removing-job-summaries) +State uses the same value conversion as [step data](#step-data). It is scoped to +an action's pre/main/post lifecycle. Local actions support main/post hooks. +Use outputs or environment variables to share values between ordinary steps. +[GitHub state documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#sending-values-to-the-pre-and-post-actions) -**example:** +## Artifact identities -```python ->> from github_action_utils import remove_job_summary +> [!IMPORTANT] +> These APIs need the runner's conditional artifact-file feature. Exposed file +> paths alone do not prove support. Declarations record identities; they do not +> upload files, push images, or sign artifacts. ->> remove_job_summary() -``` +| Function or type | Behavior | +| --- | --- | +| `declare_file_artifact(path)` | Queue a plain file path. The runner resolves relative paths against the workspace and computes its identity after the step. | +| `declare_oci_artifact(reference)` | Queue an OCI reference with a full `sha256`, `sha384`, or `sha512` digest (64, 96, or 128 hex characters). An `oci://` prefix is optional; digest hex is normalized to lowercase. | +| `get_workflow_artifacts()` | Read fresh version-1 metadata as a tuple of immutable `ArtifactSubject` values. An empty subjects list returns `()`. | +| `ArtifactSubject(name, digest, kind)` | A named tuple with `name`, `digest` (`algorithm:hex`), and `kind` (`"file"` or `"oci"`). | -### **`add_system_path(path)`** +On a supported runner: -Prepends a directory to the system PATH variable (`GITHUB_PATH`) and automatically makes it available to all subsequent actions in the current job. -GitHub Actions Docs: [add_system_path](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path) +```python +gha.declare_file_artifact("dist/app.whl") +gha.declare_oci_artifact( + "ghcr.io/example/app@sha256:" + "a" * 64 +) # Use your image's real digest. +``` -**example:** +In a later step: ```python ->> from github_action_utils import add_system_path - ->> add_system_path("var/path/to/file") +for subject in gha.get_workflow_artifacts(): + print(subject.name, subject.digest, subject.kind) ``` -### **`event_payload()`** +Missing artifact-file configuration raises `RuntimeError`; empty/malformed or +unsupported metadata raises `ValueError`. File errors propagate. See GitHub's +[artifact command documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#declaring-workflow-artifacts). -Get GitHub Event payload that triggered the workflow. +## Configuration -More details: [GitHub Actions Event Payload](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads) - -**example:** +Console helpers accept `use_subprocess=False`. To emit a message through a child +Python process without invoking a shell: ```python ->> from github_action_utils import event_payload - ->> event_payload() - -# Output: -# {"action": "opened", "number": 1, "pull_request": {"url": "https://api.github.com/repos/octocat/Hello-World/pulls/1"}, "repository": {"url": "https://api.github.com/repos/octocat/Hello-World"}, "sender": {"login": "octocat"}...} +gha.echo("Hello", use_subprocess=True) ``` -# License - -The code in this project is released under the [MIT License](LICENSE). +Set `gha.COMMANDS_USE_SUBPROCESS = True` for all console helpers. The global flag +wins over a per-call `False`. The `COMMANDS_USE_SUBPROCESS` environment variable is +read once at import: **any nonempty string enables it**, including `"false"` and +`"0"`. Set the module flag to `False` to disable that global setting. + +
+Compatibility and validation details + +- Python 3.11+ is required. Existing 1.1.0 package functions and call signatures + remain available. See the [upgrade notes](CHANGELOG.md#upgrading-from-110) + for behavior changes. +- The old boolean `use_subprocess` argument on `set_output()` and `save_state()` + emits `DeprecationWarning`; both helpers always write runner files. +- Pending environment reads accept simple and multiline records, including a + leading UTF-8 BOM. Later duplicate names win; malformed records raise `ValueError`. +- File-command names are validated before writing. Artifact declarations also + reject CR/LF/NUL, `=`, and trailing whitespace. +- Custom stop tokens reject literal spaces, CR/LF/NUL, `::`, trailing `:`, and + reserved workflow-command names. Invalid tokens are rejected before emission. +- `CommandTypes` and `LogCommandTypes` are public typing aliases. + `COMMAND_MARKER` and the legacy `ACTION_ENV_DELIMITER` remain available; + file writers generate their own collision-checked delimiters. + +
+ +## Development + +Development uses **Python 3.15**, uv, Ruff, strict mypy, pytest, and tox. CI covers +Python 3.11–3.15 on Linux, Windows, and macOS. The +[feature showcase](.github/workflows/showcase.yaml) demonstrates every public +function, with automated checks, manual display checks, and conditional skips. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and releases; +[open an issue](https://github.com/saadmk11/github-action-utils/issues) for bugs. +Licensed under [MIT](LICENSE). diff --git a/github_action_utils.py b/github_action_utils.py deleted file mode 100644 index eaa0291..0000000 --- a/github_action_utils.py +++ /dev/null @@ -1,587 +0,0 @@ -import json -import os -import subprocess -import sys -import uuid -from contextlib import contextmanager -from functools import lru_cache -from typing import Any, Dict, Generator, Union -from warnings import warn - -if sys.version_info >= (3, 8): - from typing import Literal - - CommandTypes = Literal[ - "add-mask", - "debug", - "error", - "group", - "notice", - "save-state", - "set-output", - "stop-commands", - "warning", - ] - LogCommandTypes = Literal["debug", "error", "notice", "warning"] -else: - CommandTypes = str - LogCommandTypes = str - - -ACTION_ENV_DELIMITER: str = "__ENV_DELIMITER__" -COMMAND_MARKER: str = "::" - -COMMANDS_USE_SUBPROCESS: bool = bool(os.environ.get("COMMANDS_USE_SUBPROCESS", False)) - - -def _print_command( - command: CommandTypes, - command_message: str, - options_string: Union[str] = "", - use_subprocess: bool = False, - escape_message: bool = True, -) -> None: - """ - Helper function to print GitHub action commands to the shell. - Docs: https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions - - :param command: command name from `CommandTypes` - :param command_message: message string - :param options_string: string containing extra options - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - if escape_message: - command_message = _escape_data(command_message) - - full_command = ( - f"{COMMAND_MARKER}{command} " - f"{options_string or ''}" - f"{COMMAND_MARKER}{command_message}" - ) - - if use_subprocess or COMMANDS_USE_SUBPROCESS: - subprocess.run(["echo", full_command]) - else: - print(full_command) - - -def _make_string(data: Any) -> str: - """ - Converts a value to a string. - - :param data: data to convert - :returns: string representation of the value - """ - if isinstance(data, (list, tuple, dict)): - return json.dumps(data) - return str(data) - - -def _escape_data(data: Any) -> str: - """ - Removes `%, \r, \n` characters from a string. - - Copied from: https://github.com/actions/runner/blob/407a347f831483f85b88eea0f0ac12f7ddbab5a8/src/Runner.Common/ActionCommand.cs#L19-L24 - - :param data: Any type of data to be escaped e.g. string, number, list, dict - :returns: string after escaping - """ - return ( - _make_string(data).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - ) - - -def _escape_property(data: Any) -> str: - """ - Removes `%, \r, \n, :, ,` characters from a string. - - Copied from: https://github.com/actions/runner/blob/407a347f831483f85b88eea0f0ac12f7ddbab5a8/src/Runner.Common/ActionCommand.cs#L26-L33 - - :param data: Any type of data to be escaped e.g. string, number, list, dict - :returns: string after escaping - """ - return _escape_data(data).replace(":", "%3A").replace(",", "%2C") - - -def _clean_markdown_string(markdown_string: str) -> str: - """ - Removes `%25, %0D, %0A` characters from a string. - - :param markdown_string: string with markdown content - :returns: string after escaping - """ - return ( - str(markdown_string) - .replace("%25", "%") - .replace("%0D", "\r") - .replace("%0A", "\n") - ) - - -def _to_camel_case(text: str) -> str: - """ - Transforms a snake case string to camel case. - - :param text: snake cased string - :returns: camel cased string - """ - return f"{text[:1].lower()}{text.title().replace('_', '')[1:]}" - - -def _build_options_string(**kwargs: Any) -> str: - return ",".join( - f"{_to_camel_case(key)}={_escape_property(value)}" - for key, value in kwargs.items() - if value is not None - ) - - -def _build_file_input(name: str, value: Any) -> bytes: - return ( - f"{_escape_property(name)}" - f"<<{ACTION_ENV_DELIMITER}\n" - f"{_escape_data(value)}\n" - f"{ACTION_ENV_DELIMITER}\n".encode("utf-8") - ) - - -def set_output(name: str, value: Any, use_subprocess: Union[bool, None] = None) -> None: - """ - sets out for your workflow using GITHUB_OUTPUT file. - - :param name: name of the output - :param value: value of the output - :returns: None - """ - if use_subprocess is not None: - warn( - "Argument `use_subprocess` for `set_output()` is deprecated and " - "going to be removed in the next version.", - DeprecationWarning, - ) - - with open(os.environ["GITHUB_OUTPUT"], "ab") as f: - f.write(_build_file_input(name, value)) - - -def echo(message: Any, use_subprocess: bool = False) -> None: - """ - prints a message to the GitHub Actions shell. - - Template: {message} - Example: echo "info message" - - :param message: Any type of message e.g. string, number, list, dict - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - message = str(message) - - if use_subprocess or COMMANDS_USE_SUBPROCESS: - subprocess.run(["echo", message]) - else: - print(message) - - -def debug(message: str, use_subprocess: bool = False) -> None: - """ - prints a debug message in the GitHub Actions shell. - - Template: ::debug::{message} - Example: echo "::debug::Set the Octocat variable" - - :param message: message string - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - _print_command( - "debug", message, use_subprocess=use_subprocess, escape_message=False - ) - - -def notice( - message: str, - title: Union[str, None] = None, - file: Union[str, None] = None, - col: Union[int, None] = None, - end_column: Union[int, None] = None, - line: Union[int, None] = None, - end_line: Union[int, None] = None, - use_subprocess: bool = False, -) -> None: - """ - prints a notice message in the GitHub Actions shell. - - Template: ::notice file={name},line={line},endLine={endLine},title={title}::{message} - Example: echo "::notice file=app.js,line=1,col=5,endColumn=7::Missing semicolon" - - :param message: Message to display - :param title: Custom title - :param file: Filename in the repository - :param col: Column number, starting at 1 - :param end_column: End column number - :param line: Line number, starting at 1 - :param end_line: End line number - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - _print_command( - "notice", - message, - options_string=_build_options_string( - title=title, - file=file, - col=col, - end_column=end_column, - line=line, - end_line=end_line, - ), - use_subprocess=use_subprocess, - escape_message=False, - ) - - -def warning( - message: str, - title: Union[str, None] = None, - file: Union[str, None] = None, - col: Union[int, None] = None, - end_column: Union[int, None] = None, - line: Union[int, None] = None, - end_line: Union[int, None] = None, - use_subprocess: bool = False, -) -> None: - """ - prints a warning message in the GitHub Actions shell. - - Template: ::warning file={name},line={line},endLine={endLine},title={title}::{message} - Example: echo "::warning file=app.js,line=1,col=5,endColumn=7::Missing semicolon" - - :param message: Message to display - :param title: Custom title - :param file: Filename in the repository - :param col: Column number, starting at 1 - :param end_column: End column number - :param line: Line number, starting at 1 - :param end_line: End line number - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - _print_command( - "warning", - message, - options_string=_build_options_string( - title=title, - file=file, - col=col, - end_column=end_column, - line=line, - end_line=end_line, - ), - use_subprocess=use_subprocess, - escape_message=False, - ) - - -def error( - message: str, - title: Union[str, None] = None, - file: Union[str, None] = None, - col: Union[int, None] = None, - end_column: Union[int, None] = None, - line: Union[int, None] = None, - end_line: Union[int, None] = None, - use_subprocess: bool = False, -) -> None: - """ - prints an error message in the GitHub Actions shell. - - Template: ::error file={name},line={line},endLine={endLine},title={title}::{message} - Example: echo "::error file=app.js,line=1,col=5,endColumn=7::Missing semicolon" - - :param message: Message to display - :param title: Custom title - :param file: Filename in the repository - :param col: Column number, starting at 1 - :param end_column: End column number - :param line: Line number, starting at 1 - :param end_line: End line number - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - _print_command( - "error", - message, - options_string=_build_options_string( - title=title, - file=file, - col=col, - end_column=end_column, - line=line, - end_line=end_line, - ), - use_subprocess=use_subprocess, - escape_message=False, - ) - - -def save_state(name: str, value: Any, use_subprocess: Union[bool, None] = None) -> None: - """ - sets state for your workflow using $GITHUB_STATE file - for sharing it with your workflow's pre: or post: actions. - - :param name: Name of the state environment variable (e.g: STATE_{name}) - :param value: value of the state environment variable - :returns: None - """ - if use_subprocess is not None: - warn( - "Argument `use_subprocess` for `save_state()` is deprecated and " - "going to be removed in the next version.", - DeprecationWarning, - ) - - with open(os.environ["GITHUB_STATE"], "ab") as f: - f.write(_build_file_input(name, value)) - - -def get_state(name: str) -> Union[str, None]: - """ - gets environment variable value for the state. - - :param name: Name of the state environment variable (e.g: STATE_{name}) - :returns: state value or None - """ - return os.environ.get(f"STATE_{name}") - - -def get_user_input(name: str) -> Union[str, None]: - """ - gets user input from environment variables. - - :param name: Name of the user input - :returns: input value or None - """ - return os.environ.get(f"INPUT_{name.upper()}") - - -def start_group(title: str, use_subprocess: bool = False) -> None: - """ - creates an expandable group in GitHub Actions log. - - Template: ::group::{title} - Example: echo "::group::My title" - - :param title: title of the group - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - _print_command("group", title, use_subprocess=use_subprocess, escape_message=False) - - -def end_group(use_subprocess: bool = False) -> None: - """ - closes an expandable group in GitHub Actions log. - - Template: ::endgroup:: - Example: echo "::endgroup::" - - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - message = f"{COMMAND_MARKER}endgroup{COMMAND_MARKER}" - - if use_subprocess or COMMANDS_USE_SUBPROCESS: - subprocess.run(["echo", message]) - else: - print(message) - - -@contextmanager -def group(title: str, use_subprocess: bool = False) -> Generator[Any, None, None]: - """ - creates and closes an expandable group in GitHub Actions log. - - :param title: title of the group - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - start_group(title, use_subprocess=use_subprocess) - yield - end_group(use_subprocess=use_subprocess) - - -def add_mask(value: Any, use_subprocess: bool = False) -> None: - """ - masking a value prevents a string or variable from being printed in the log. - - Template: ::add-mask::{value} - Example: echo "::add-mask::Mona The Octocat" - - :param value: value to mask - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - _print_command("add-mask", value, use_subprocess=use_subprocess) - - -def begin_stop_commands( - token: Union[str, None] = None, use_subprocess: bool = False -) -> str: - """ - stops processing any workflow commands between this and `end_stop_commands()`. - - Template: ::stop-commands::{token} - Example: echo "::stop-commands::12345" - - :param token: token to use for the stop command - :param use_subprocess: use subprocess module to echo command - :returns: token - """ - if not token: - token = str(uuid.uuid1()) - - _print_command( - "stop-commands", token, escape_message=False, use_subprocess=use_subprocess - ) - - return token - - -def end_stop_commands(token: str, use_subprocess: bool = False) -> None: - """ - stops processing any workflow commands between this and `begin_stop_commands()` - - Template: ::{token}:: - Example: echo "::{12345}::" - - :param token: token used for the stop command - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - message = f"{COMMAND_MARKER}{token}{COMMAND_MARKER}" - - if use_subprocess or COMMANDS_USE_SUBPROCESS: - subprocess.run(["echo", message]) - else: - print(message) - - -@contextmanager -def stop_commands( - token: Union[str, None] = None, use_subprocess: bool = False -) -> Generator[Any, None, None]: - """ - stops processing GitHub action commands within this context manager. - - :param token: token to use for the stop command - :param use_subprocess: use subprocess module to echo command - :returns: None - """ - stop_token = begin_stop_commands(token=token, use_subprocess=use_subprocess) - yield - end_stop_commands(stop_token, use_subprocess=use_subprocess) - - -def set_env(name: str, value: Any) -> None: - """ - sets an environment variable for your workflows $GITHUB_ENV file. - - :param name: name of the environment variable - :param value: value of the environment variable - :returns: None - """ - with open(os.environ["GITHUB_ENV"], "ab") as f: - f.write(_build_file_input(name, value)) - - -def get_workflow_environment_variables() -> Dict[str, Any]: - """ - get a dictionary of all environment variables set in the GitHub Actions workflow. - - :returns: dictionary of all environment variables - """ - environment_variable_dict = {} - marker = f"<<{ACTION_ENV_DELIMITER}" - - with open(os.environ["GITHUB_ENV"], "rb") as file: - for line in file: - decoded_line: str = line.decode("utf-8") - - if marker in decoded_line: - name, *_ = decoded_line.strip().split("<<") - - try: - decoded_value = next(file).decode("utf-8").strip() - except StopIteration: - break - environment_variable_dict[name] = decoded_value - return environment_variable_dict - - -def get_env(name: str) -> Any: - """ - gets the value of an environment variable set in the GitHub Actions workflow. - - :param name: name of the environment variable - :returns: value of the environment variable or None - """ - return os.environ.get(name) or get_workflow_environment_variables().get(name) - - -def append_job_summary(markdown_text: str) -> None: - """ - appends summery of the job to the GitHub Action Summary page. - - :param markdown_text: string with Markdown text - :returns: None - """ - with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: - f.write(f"{_clean_markdown_string(markdown_text)}\n") - - -def overwrite_job_summary(markdown_text: str) -> None: - """ - overwrites summary of the job for the GitHub Action Summary page. - - :param markdown_text: string with Markdown text - :returns: None - """ - with open(os.environ["GITHUB_STEP_SUMMARY"], "w") as f: - f.write(f"{_clean_markdown_string(markdown_text)}\n") - - -def remove_job_summary() -> None: - """ - removes summary file for the job. - - :returns: None - """ - try: - os.remove(os.environ["GITHUB_STEP_SUMMARY"]) - except (KeyError, FileNotFoundError): - pass - - -def add_system_path(path: str) -> None: - """ - adds a path to the system path (`GITHUB_PATH`). - - :param path: path string to set - :returns: None - """ - with open(os.environ["GITHUB_PATH"], "a") as f: - f.write(f"{path}") - - -@lru_cache(maxsize=1) -def event_payload() -> Dict[str, Any]: - """ - gets GitHub event payload data. - - :returns: dictionary of event payload - """ - with open(os.environ["GITHUB_EVENT_PATH"]) as f: - data: Dict[str, Any] = json.load(f) - return data diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dd82520 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,125 @@ +[build-system] +requires = ["uv_build>=0.13.1,<0.14"] +build-backend = "uv_build" + +[project] +name = "github-action-utils" +version = "2.0.0" +description = "Python helpers for GitHub Actions workflow commands" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE"] +authors = [{name = "Maksudul Haque", email = "saad.mk112@gmail.com"}] +dependencies = [] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/saadmk11/github-action-utils" +Issues = "https://github.com/saadmk11/github-action-utils/issues" + +[dependency-groups] +test = ["pytest>=9.1.1", "pytest-cov>=7.1.0", "coverage>=7.16.2"] +lint = ["ruff>=0.17.1"] +type = ["mypy>=2.4.0", "pytest>=9.1.1"] +tox = ["tox>=4.67.1", "tox-uv>=1.37.0"] +dev = [{include-group = "test"}, {include-group = "lint"}, {include-group = "type"}, {include-group = "tox"}] + +[tool.uv] +required-version = ">=0.13.1,<0.14" +build-constraint-dependencies = ["uv_build==0.13.1"] + +[tool.uv.build-backend] +module-name = "github_action_utils" +module-root = "src" +source-include = ["CHANGELOG.md", "CONTRIBUTING.md", "tests/**", "scripts/**", ".github/actions/check-state/**", "uv.lock", ".python-version"] +source-exclude = ["**/__pycache__/**", "**/*.pyc"] + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "B", "ANN", "D", "C90", "UP"] +ignore = ["D203", "D213"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D"] +# Preserve the 1.1.0 public annotations. +"src/github_action_utils/__init__.py" = ["ANN401", "UP006", "UP035"] + +[tool.ruff.lint.mccabe] +max-complexity = 12 + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests", "scripts", ".github/actions/check-state/check_state.py"] +strict = true +disallow_any_explicit = true +disallow_any_generics = true +disallow_any_unimported = true +warn_return_any = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +no_implicit_reexport = true + +[[tool.mypy.overrides]] +module = ["github_action_utils._commands", "github_action_utils._serialization", "github_action_utils._files", "github_action_utils._inputs", "github_action_utils._environment", "github_action_utils._artifacts"] +disallow_any_expr = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--import-mode=importlib" + +[tool.coverage.run] +branch = true +source = ["github_action_utils"] +relative_files = true +patch = ["subprocess"] + +[tool.coverage.report] +fail_under = 100 +precision = 2 +show_missing = true +exclude_lines = [] + +[tool.coverage.json] +output = "reports/runtime-coverage.json" + +[tool.tox] +env_list = ["py311", "py312", "py313", "py314", "py315", "lint", "type", "coverage", "package"] +skip_missing_interpreters = false + +[tool.tox.env_run_base] +runner = "uv-venv-lock-runner" +package = "wheel" +dependency_groups = ["test"] +no_default_groups = true +pass_env = ["UV_*", "GITHUB_ACTIONS", "CI"] +commands = [["python", "-m", "pytest", {replace = "posargs", default = ["tests"], extend = true}]] + +[tool.tox.env.lint] +dependency_groups = ["lint"] +commands = [["ruff", "check", "."], ["ruff", "format", "--check", "."]] + +[tool.tox.env.type] +dependency_groups = ["type"] +commands = [["mypy"]] + +[tool.tox.env.coverage] +commands = [["python", "-m", "pytest", "--cov=github_action_utils", "--cov-branch", "--cov-report=term-missing", "--cov-report=xml:reports/runtime-coverage.xml", "--cov-report=json:reports/runtime-coverage.json", "tests", "--ignore=tests/support"]] + +[tool.tox.env.package] +dependency_groups = ["type", "test"] +commands = [["uv", "build", "--clear"], ["python", "scripts/check_distribution.py"]] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 3cbd166..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -coverage -mypy -pytest -pytest-cov diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..6fdd833 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Distribution checks and GitHub Actions integration examples.""" diff --git a/scripts/check_artifacts.py b/scripts/check_artifacts.py new file mode 100644 index 0000000..0a70f61 --- /dev/null +++ b/scripts/check_artifacts.py @@ -0,0 +1,42 @@ +"""Check artifact identities when the runner supplies populated metadata.""" + +import hashlib +import os +from pathlib import Path + +import github_action_utils as gha + +CONTENT = b"github-action-utils artifact integration\n" +FILE_NAME = "github-action-utils-artifact-fixture.txt" +OCI_NAME = "ghcr.io/github-action-utils/integration:fixture" +OCI_DIGEST = "sha256:" + "a" * 64 + + +def probe() -> bool: + """Check for populated, valid artifact metadata.""" + metadata = os.environ.get("GITHUB_ARTIFACTS_LIST") + if ( + not os.environ.get("GITHUB_ARTIFACTS") + or not metadata + or not Path(metadata).read_bytes().strip() + ): + return False + gha.get_workflow_artifacts() + return True + + +def write() -> None: + """Create a fixture and declare its file and OCI identities.""" + path = Path(os.environ["GITHUB_WORKSPACE"]) / FILE_NAME + path.write_bytes(CONTENT) + gha.declare_file_artifact(FILE_NAME) + gha.declare_oci_artifact(OCI_NAME + "@" + OCI_DIGEST) + + +def verify() -> None: + """Check the identities returned in a later step's runner metadata.""" + subjects = {subject.name: subject for subject in gha.get_workflow_artifacts()} + assert subjects[FILE_NAME] == gha.ArtifactSubject( + FILE_NAME, "sha256:" + hashlib.sha256(CONTENT).hexdigest(), "file" + ) + assert subjects[OCI_NAME] == gha.ArtifactSubject(OCI_NAME, OCI_DIGEST, "oci") diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py new file mode 100644 index 0000000..47ad09a --- /dev/null +++ b/scripts/check_distribution.py @@ -0,0 +1,376 @@ +"""Check clean distribution installs, external typing, upgrades, and release refs.""" + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import tomllib +import urllib.request +import venv +from collections.abc import Sequence +from importlib.metadata import version +from pathlib import Path + +BASELINE_SHA256 = "bc84bac22e8a25ebe86370b08ff2c174960e468e899ffd313cb09d19629acefb" +BASELINE_FILENAME = "github_action_utils-1.1.0-py2.py3-none-any.whl" + + +BASELINE_URL = "https://files.pythonhosted.org/packages/fd/c8/d2a1c4ac43c9194316b8802b08502712ef242e6f325435409688e760b03a/github_action_utils-1.1.0-py2.py3-none-any.whl" + + +def require(condition: bool, message: str) -> None: + """Keep validation enabled under python -O and PYTHONOPTIMIZE.""" + if not condition: + raise ValueError(message) + + +def project_version(repository: Path) -> str: + """Read the single static project version from its standard metadata.""" + value = tomllib.loads((repository / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ]["version"] + if not isinstance(value, str): + raise ValueError("Project version must be a string") + return value + + +def create_environment(workspace: Path) -> tuple[Path, dict[str, str]]: + """Create a pip-capable environment outside the source import path.""" + workspace.mkdir() + target = workspace / "venv" + venv.EnvBuilder(with_pip=True, symlinks=os.name != "nt").create(str(target)) + python = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + environment = dict(os.environ) + environment.pop("PYTHONPATH", None) + environment.pop("MYPYPATH", None) + environment["PYTHONIOENCODING"] = "utf-8" + return python, environment + + +def baseline_wheel(workspace: Path, previous_wheel: Path | None) -> Path: + """Download the published baseline and verify its PyPI-advertised hash.""" + if previous_wheel is None: + previous_wheel = workspace / BASELINE_FILENAME + with urllib.request.urlopen(BASELINE_URL, timeout=30) as response: + previous_wheel.write_bytes(response.read()) + require( + hashlib.sha256(previous_wheel.read_bytes()).hexdigest() == BASELINE_SHA256, + "Published baseline hash mismatch", + ) + return previous_wheel + + +def validate_tag(repository: Path, tag: str) -> None: + """Accept the static version with at most one leading v.""" + expected = project_version(repository) + require( + tag in (expected, "v" + expected), "Release tag does not match package version" + ) + + +def validate_github_release(repository: Path) -> str: + """Require the caller, tag, checkout, and published release to identify one commit.""" + require( + os.environ.get("GITHUB_REF_TYPE") == "tag", + "Run this workflow on a published release tag, not a branch", + ) + tag = os.environ["GITHUB_REF_NAME"] + validate_tag(repository, tag) + environment = dict(os.environ) + commit = run( + ["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], repository, environment + ).strip() + require( + commit == os.environ["GITHUB_SHA"], + "Release tag moved or differs from the workflow commit", + ) + release = json.loads( + run( + ["gh", "release", "view", tag, "--json", "isDraft,tagName"], + repository, + environment, + ) + ) + require( + isinstance(release, dict) + and release.get("isDraft") is False + and release.get("tagName") == tag, + "Expected a published release for the selected tag", + ) + return project_version(repository) + + +def check_upgrade(wheel: Path, workspace: Path, previous_wheel: Path | None) -> None: + """Upgrade a real 1.1.0 installation, checking names, calls, and old pickles.""" + python, environment = create_environment(workspace) + previous = str(baseline_wheel(workspace, previous_wheel)) + run( + [str(python), "-m", "pip", "install", "--no-deps", previous], + workspace, + environment, + ) + run( + [ + str(python), + str( + Path(__file__).resolve().parents[1] / "tests/fixtures/upgrade_before.py" + ), + ], + workspace, + environment, + ) + run( + [ + str(python), + "-m", + "pip", + "install", + "--no-deps", + "--no-index", + "--upgrade", + str(wheel), + ], + workspace, + environment, + ) + run( + [ + str(python), + str( + Path(__file__).resolve().parents[1] / "tests/fixtures/check_install.py" + ), + project_version(Path(__file__).resolve().parents[1]), + ], + workspace, + environment, + ) + run( + [ + str(python), + str( + Path(__file__).resolve().parents[1] / "tests/fixtures/upgrade_after.py" + ), + ], + workspace, + environment, + ) + print( + "Published 1.1.0 upgrade, public calls, old pickles, and module cleanup passed." + ) + + +def run(command: list[str], cwd: Path, environment: dict[str, str]) -> str: + """Run a required check and retain diagnostics if it fails.""" + result = subprocess.run( + command, + cwd=str(cwd), + env=environment, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=300, + ) + if result.returncode: + raise RuntimeError("{}\n{}".format(" ".join(command), result.stdout)) + return result.stdout + + +def check_source_install(source: Path, workspace: Path) -> None: + """Install the sdist with pip and an isolated build backend.""" + python, environment = create_environment(workspace) + run( + [str(python), "-m", "pip", "install", "--no-deps", str(source)], + workspace, + environment, + ) + run( + [ + str(python), + str( + Path(__file__).resolve().parents[1] / "tests/fixtures/check_install.py" + ), + project_version(Path(__file__).resolve().parents[1]), + ], + workspace, + environment, + ) + + +def check_wheel(wheel: Path, workspace: Path, repository: Path) -> None: + """Test a rebuilt wheel outside the checkout and check its public types.""" + python, environment = create_environment(workspace) + run( + [str(python), "-m", "pip", "install", "--no-deps", "--no-index", str(wheel)], + workspace, + environment, + ) + run( + [ + str(python), + str( + Path(__file__).resolve().parents[1] / "tests/fixtures/check_install.py" + ), + project_version(Path(__file__).resolve().parents[1]), + ], + workspace, + environment, + ) + requirements = workspace / "test-requirements.txt" + run( + [ + "uv", + "export", + "--locked", + "--only-group", + "test", + "--no-header", + "--output-file", + str(requirements), + ], + repository, + environment, + ) + run( + ["uv", "pip", "install", "--python", str(python), "-r", str(requirements)], + workspace, + environment, + ) + shutil.copytree(str(repository / "tests"), str(workspace / "tests")) + print( + run( + [ + str(python), + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + "tests", + "--ignore=tests/support", + ], + workspace, + environment, + ).strip() + ) + + check_consumers(python, workspace, repository, environment) + + +def check_consumers( + python: Path, workspace: Path, repository: Path, environment: dict[str, str] +) -> None: + """Check valid and invalid consumers against an installed wheel.""" + for kind in ("valid", "invalid"): + shutil.copyfile( + str(repository / "tests" / "typing" / (kind + ".txt")), + str(workspace / "consumer.py"), + ) + error_lines = [ + line_number + for line_number, line in enumerate( + (workspace / "consumer.py").read_text(encoding="utf-8").splitlines(), 1 + ) + if line.endswith("# error") + ] + result = subprocess.run( + [ + sys.executable, + "-m", + "mypy", + "--strict", + "--no-incremental", + "--python-executable", + str(python), + "consumer.py", + ], + cwd=str(workspace), + env=environment, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=300, + ) + if kind == "valid": + require(result.returncode == 0, result.stdout) + else: + # Reject every marked statement, not merely a failed import. + require(result.returncode != 0, "Invalid consumer unexpectedly passed") + require(bool(error_lines), "Invalid consumer has no marked type errors") + for line in error_lines: + require(f"consumer.py:{line}:" in result.stdout, result.stdout) + print(f"mypy: {kind} consumer checked") + + +def main(argv: Sequence[str] | None = None) -> None: + """Check built distributions or validate a release.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", nargs="?", default="dist") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--published-version") + mode.add_argument("--github-release", action="store_true") + parser.add_argument("--previous-wheel", type=Path) + arguments = parser.parse_args(argv) + repository = Path(__file__).resolve().parents[1] + if arguments.github_release: + release_version = validate_github_release(repository) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"version={release_version}\n") + return + if arguments.published_version: + import github_action_utils as gha + + require( + version("github-action-utils") == arguments.published_version, + "Installed version differs from the expected release", + ) + require(Path(gha.__file__).with_name("py.typed").is_file(), "Missing py.typed") + return + directory = Path(arguments.directory).resolve() + wheels = list(directory.glob("*.whl")) + sources = list(directory.glob("*.tar.gz")) + require(len(wheels) == len(sources) == 1, "Expected one wheel and one sdist") + with tempfile.TemporaryDirectory( + prefix="github-action-utils-install-" + ) as temporary: + workspace = Path(temporary) + check_source_install(sources[0], workspace / "pip-source") + unpacked = workspace / "source" + shutil.unpack_archive(str(sources[0]), str(unpacked), filter="data") + rebuilt = workspace / "rebuilt" + run( + [ + "uv", + "build", + "--wheel", + "--out-dir", + str(rebuilt), + str(next(unpacked.iterdir())), + ], + workspace, + dict(os.environ), + ) + check_wheel(next(rebuilt.glob("*.whl")), workspace / "sdist-wheel", repository) + previous = ( + arguments.previous_wheel.resolve() if arguments.previous_wheel else None + ) + check_upgrade(wheels[0], workspace / "upgrade", previous) + (directory / "SHA256SUMS").write_text( + "".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in (*wheels, *sources) + ), + encoding="utf-8", + ) + print("Distribution checks passed.") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_runner.py b/scripts/check_runner.py new file mode 100644 index 0000000..ee5f9f1 --- /dev/null +++ b/scripts/check_runner.py @@ -0,0 +1,127 @@ +"""Integration checks executed across actual GitHub Actions job steps.""" + +import json +import os +import shutil +import subprocess +import sys +from collections.abc import Sequence +from pathlib import Path + +import github_action_utils as gha + +VALUE = "héllo 世界\nsecond line: 100% %0A\n\ntrailing line\n" + + +def integration_paths() -> tuple[Path, Path]: + """Return the two distinct PATH fixtures used by producer and verifier.""" + root = Path(os.environ["GITHUB_WORKSPACE"]) / ".runner-integration" + return root / "first বাংলা", root / "second 世界" + + +def check_commands() -> None: + """Produce recognizable smoke-test logs; rendering is inspected in CI logs.""" + with gha.group("Protocol smoke: %0A / Unicode 世界"): + gha.set_command_echo(True) + gha.add_mask("GH_UTILS_DUMMY_MASK_VALUE") + subprocess.run( + [sys.executable, "-c", "print('GH_UTILS_DUMMY_MASK_VALUE')"], check=True + ) + with gha.stop_commands(): + gha.echo("::warning::GH_UTILS_PLAIN_TEXT_WHILE_STOPPED") + gha.notice("Commands resumed; literal %0A\nsecond annotation line") + gha.debug("Debug smoke; literal %0D %25") + matcher = Path(os.environ["RUNNER_TEMP"]) / "github-action-utils-matcher.json" + matcher.write_text( + json.dumps( + { + "problemMatcher": [ + { + "owner": "gh-utils-smoke", + "severity": "warning", + "pattern": [ + { + "regexp": "^GH_UTILS_MATCHER_SMOKE: (.*)$", + "message": 1, + } + ], + } + ] + } + ), + encoding="utf-8", + ) + gha.add_problem_matcher(str(matcher)) + gha.echo("GH_UTILS_MATCHER_SMOKE: matcher is active") + gha.remove_problem_matcher(owner="gh-utils-smoke") + gha.echo("GH_UTILS_MATCHER_SMOKE: matcher has been removed") + gha.add_problem_matcher(str(matcher)) + gha.remove_problem_matcher(path=str(matcher)) + gha.set_command_echo(False) + + +def main(argv: Sequence[str] | None = None) -> None: + """Produce or verify values across real runner steps.""" + arguments = list(sys.argv[1:] if argv is None else argv) + match arguments: + case ["write"]: + gha.set_output("multiline", VALUE) + gha.set_output("number", 42) + gha.set_output("empty", "") + gha.set_output("unicode", "বাংলা 世界") + gha.set_output("json", {"enabled": True}) + gha.set_env("GH_UTILS_INTEGRATION_VALUE", VALUE) + assert gha.get_env("GH_UTILS_INTEGRATION_VALUE") == VALUE + assert ( + gha.get_workflow_environment_variables()["GH_UTILS_INTEGRATION_VALUE"] + == VALUE + ) + for path in integration_paths(): + path.mkdir(parents=True, exist_ok=True) + executable = path / ( + "gh-utils-" + + ("first" if path == integration_paths()[0] else "second") + + (".cmd" if os.name == "nt" else "") + ) + executable.write_text( + "@echo fixture\n" + if os.name == "nt" + else "#!/bin/sh\necho fixture\n", + encoding="utf-8", + ) + executable.chmod(0o755) + gha.add_system_path(str(path)) + markdown = "# Python helper smoke\n\nবাংলা 世界 — literal %0A %0D %25\n" + gha.overwrite_job_summary(markdown) + assert Path(os.environ["GITHUB_STEP_SUMMARY"]).read_bytes() == ( + markdown + "\n" + ).encode("utf-8") + case ["verify"]: + assert gha.is_github_action() + assert gha.vars.github.actions + assert os.environ["OUTPUT_VALUE"] == VALUE + assert os.environ["GH_UTILS_INTEGRATION_VALUE"] == VALUE + assert os.environ["OUTPUT_NUMBER"] == "42" + assert os.environ["OUTPUT_EMPTY"] == "" + assert os.environ["OUTPUT_UNICODE"] == "বাংলা 世界" + assert shutil.which("gh-utils-first") + assert shutil.which("gh-utils-second") + assert json.loads(os.environ["OUTPUT_JSON"]) == {"enabled": True} + assert gha.get_env("GH_UTILS_INTEGRATION_VALUE") == VALUE + assert gha.vars.github.env == os.environ["GITHUB_ENV"] + first, second = ( + os.path.normcase(str(path)) for path in integration_paths() + ) + entries = [ + os.path.normcase(entry) + for entry in os.environ["PATH"].split(os.pathsep) + ] + assert entries.index(second) < entries.index(first) + case ["commands"]: + check_commands() + case _: + raise ValueError("Expected write, verify, or commands") + + +if __name__ == "__main__": + main() diff --git a/scripts/showcase.py b/scripts/showcase.py new file mode 100644 index 0000000..53d9157 --- /dev/null +++ b/scripts/showcase.py @@ -0,0 +1,548 @@ +"""Exercise the public API on GitHub Actions and write feature reports.""" + +import argparse +import json +import os +import platform +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import asdict, dataclass +from enum import StrEnum +from importlib.metadata import version +from pathlib import Path +from typing import cast + +import github_action_utils as gha +from github_action_utils._serialization import JSONValue +from scripts import check_artifacts, check_runner + + +class Status(StrEnum): + """Automated, manual, and unavailable showcase outcomes.""" + + PASS = "PASS" + FAIL = "FAIL" + SKIP = "SKIP" + MANUAL = "MANUAL" + + +Observation = tuple[Status, str] + + +@dataclass(frozen=True, slots=True) +class Case: + """A feature demonstration and its expected result.""" + + name: str + phase: str + apis: tuple[str, ...] + expected: str + inspect: str + run: Callable[[], Observation] + + +@dataclass(frozen=True, slots=True) +class Result: + """A feature result written to JSON and Markdown.""" + + name: str + apis: tuple[str, ...] + expected: str + inspect: str + status: Status + actual: str + + +def console() -> Observation: + """Show both stdout modes and the assignable public configuration flag.""" + original = gha.COMMANDS_USE_SUBPROCESS + try: + for enabled in (False, True): + gha.COMMANDS_USE_SUBPROCESS = enabled + gha.echo(f"Console mode subprocess={enabled}: বাংলা 世界, literal %0A") + gha.debug("Debug visibility requires runner debug logging") + gha.set_command_echo(True) + gha.set_command_echo(False) + finally: + gha.COMMANDS_USE_SUBPROCESS = original + return ( + Status.MANUAL, + "Both modes emitted; inspect Unicode, literal percent text, and debug visibility", + ) + + +def annotations() -> Observation: + """Emit intentional demonstration annotations with all position properties.""" + for emit in (gha.notice, gha.warning, gha.error): + emit( + "Intentional showcase annotation: 世界 100% %0A", + title="Helper showcase", + file="README.md", + line=1, + end_line=1, + col=1, + end_column=3, + ) + return ( + Status.MANUAL, + "Three intentional annotations emitted; no process failure requested", + ) + + +def failure() -> Observation: + """Check an expected failure in an isolated child and assert cleanup.""" + child = subprocess.run( + [ + sys.executable, + "-c", + "import github_action_utils as g\nwith g.group('expected failure cleanup'):\n g.fail('Intentional child failure')", + ], + capture_output=True, + text=True, + ) + assert child.returncode == 1, child.stdout + child.stderr + assert "::error" in child.stdout and child.stdout.rstrip().endswith("::endgroup::") + return (Status.PASS, "Child exited 1 after error annotation and group cleanup") + + +def commands() -> Observation: + """Demonstrate groups, masking, command suspension, and problem matchers.""" + gha.start_group("Manual group") + gha.echo("Inside manual group") + gha.end_group() + try: + with gha.group("Exception cleanup group"): + raise ValueError("expected cleanup fixture") + except ValueError: + pass + token = gha.begin_stop_commands() + gha.echo("::warning::PLAIN WHILE COMMANDS ARE SUSPENDED") + gha.end_stop_commands(token) + check_runner.check_commands() + return ( + Status.MANUAL, + "Groups, exception cleanup, stop/resume, dummy masking, owner/path matcher removal emitted", + ) + + +def inputs() -> Observation: + """Use explicit INPUT fixtures that work for fork PRs and manual runs.""" + assert gha.get_user_input("showcase text") == " explicit fixture " + assert gha.get_boolean_input("showcase flag") is True + assert gha.get_multiline_input("showcase lines") == ["first", "世界"] + assert gha.get_user_input("showcase missing") is None + try: + gha.get_user_input("showcase missing", required=True) + except ValueError: + return ( + Status.PASS, + "Raw, boolean, multiline, optional, and required missing inputs checked", + ) + raise AssertionError("Required missing input unexpectedly succeeded") + + +def environment() -> Observation: + """Inspect allowlisted event/environment values and live read-only properties.""" + assert gha.is_github_action() + assert isinstance(gha.vars, gha.WorkflowVariables) + assert isinstance(gha.vars.github, gha.GitHubVariables) + assert isinstance(gha.vars.runner, gha.RunnerVariables) + event = gha.event_payload() + assert event is gha.event_payload() + gha.event_payload.cache_clear() + assert gha.event_payload() == event + action = event.get("action") + event_action = action if isinstance(action, str) else "(none)" + original = os.environ.get("GITHUB_ACTOR") + try: + os.environ["GITHUB_ACTOR"] = "showcase-live-fixture" + assert gha.vars.github.actor == "showcase-live-fixture" + try: + gha.vars.github.actor = "replacement" # type: ignore[misc] + except AttributeError: + pass + else: + raise AssertionError("Environment view was writable") + finally: + if original is None: + del os.environ["GITHUB_ACTOR"] + else: + os.environ["GITHUB_ACTOR"] = original + return ( + Status.PASS, + f"Actions detected; event={os.environ.get('GITHUB_EVENT_NAME')}; action={event_action}; runner OS={gha.vars.runner.os}; cache and live/read-only views checked", + ) + + +def produce() -> Observation: + """Queue exact values and PATH fixtures for a subsequent workflow step.""" + check_runner.main(["write"]) + return ( + Status.PASS, + "Pending output/env/PATH records written; process lookup and pending environment verified", + ) + + +def verify() -> Observation: + """Assert runner-propagated outputs, environment, executable lookup, and order.""" + check_runner.main(["verify"]) + return ( + Status.PASS, + "Empty/scalar/JSON/Unicode/multiline outputs exact; both executables resolved; last PATH entry first", + ) + + +def summaries() -> Observation: + """Demonstrate replacement/deletion on a fixture without erasing the report.""" + original = os.environ.get("GITHUB_STEP_SUMMARY") + fixture = Path(os.environ["RUNNER_TEMP"]) / "showcase-summary-fixture.md" + try: + os.environ["GITHUB_STEP_SUMMARY"] = str(fixture) + gha.append_job_summary("append 世界 %0A") + gha.overwrite_job_summary("replace 世界 %0A") + assert fixture.read_text(encoding="utf-8") == "replace 世界 %0A\n" + gha.remove_job_summary() + assert not fixture.exists() + finally: + if original is None: + del os.environ["GITHUB_STEP_SUMMARY"] + else: + os.environ["GITHUB_STEP_SUMMARY"] = original + return ( + Status.PASS, + "Fixture append/overwrite/remove checked; final report has a separate summary", + ) + + +def artifacts_produce() -> Observation: + """Skip unavailable runner support and queue identities only after probing it.""" + available = check_artifacts.probe() + gha.set_output("enabled", str(available).lower()) + if not available: + return ( + Status.SKIP, + "Runner did not provide populated versioned artifact metadata", + ) + check_artifacts.write() + return ( + Status.PASS, + "Version-1 metadata parsed; file and OCI declarations queued for the next step", + ) + + +def artifacts_verify() -> Observation: + """Verify later-step digests only when the producer established runner support.""" + if os.environ.get("ARTIFACTS_ENABLED") != "true": + return (Status.SKIP, "Conditional artifact integration unavailable in producer") + check_artifacts.verify() + return ( + Status.PASS, + "File/OCI names, kinds, and digests verified from runner metadata", + ) + + +def state() -> Observation: + """Describe the separate action main/post state check.""" + return ( + Status.MANUAL, + "The separate check-state action saves in main and asserts exact get_state in post; a failed post hook fails this job", + ) + + +CASES = ( + Case( + "console", + "produce", + ("echo", "debug", "set_command_echo", "COMMANDS_USE_SUBPROCESS"), + "Both console modes, Unicode and literal percent text", + "Console log group; enable runner debug for debug lines", + console, + ), + Case( + "annotations", + "produce", + ("notice", "warning", "error"), + "Three titled annotations with file/line/column properties", + "Run annotations and logs", + annotations, + ), + Case( + "failure", + "produce", + ("fail",), + "Child exits 1 with group cleanup", + "Structured result", + failure, + ), + Case( + "commands", + "produce", + ( + "start_group", + "end_group", + "group", + "begin_stop_commands", + "end_stop_commands", + "stop_commands", + "add_mask", + "add_problem_matcher", + "remove_problem_matcher", + ), + "Groups collapse; suspended text stays plain; dummy masks; matcher removed", + "Groups and matcher/masking log lines", + commands, + ), + Case( + "inputs", + "produce", + ("get_user_input", "get_boolean_input", "get_multiline_input"), + "Explicit fixtures and required/missing handling", + "Structured result", + inputs, + ), + Case( + "environment", + "produce", + ( + "event_payload", + "is_github_action", + "vars", + "WorkflowVariables", + "GitHubVariables", + "RunnerVariables", + ), + "Cached event and live read-only environment properties", + "Allowlisted event/runner observations", + environment, + ), + Case( + "file commands", + "produce", + ( + "set_output", + "set_env", + "get_env", + "get_workflow_environment_variables", + "add_system_path", + ), + "Pending producer records exact", + "Producer and subsequent verifier steps", + produce, + ), + Case( + "cross-step values", + "verify", + (), + "Runner propagates exact values and ordered executable paths", + "Verifier step", + verify, + ), + Case( + "summary fixtures", + "summary", + ("append_job_summary", "overwrite_job_summary", "remove_job_summary"), + "Append, replace, remove on a separate fixture", + "Structured result; final Markdown rendering is manual", + summaries, + ), + Case( + "artifact declarations", + "produce", + ( + "declare_file_artifact", + "declare_oci_artifact", + "get_workflow_artifacts", + "ArtifactSubject", + ), + "Versioned feature detection and correctly framed declarations", + "Producer; SKIP if feature unavailable", + artifacts_produce, + ), + Case( + "artifact metadata", + "verify", + (), + "Subsequent metadata contains exact file/OCI digests", + "Verifier; SKIP if feature unavailable", + artifacts_verify, + ), + Case( + "action state", + "produce", + ("save_state", "get_state"), + "Exact main/post state propagation", + "Check-state main and post hook logs", + state, + ), +) + + +def write_reports(directory: Path, results: Sequence[Result], phase: str) -> None: + """Save reports, then append them to the job summary.""" + directory.mkdir(parents=True, exist_ok=True) + context = { + "python": platform.python_version(), + "os": platform.system(), + "package": version("github-action-utils"), + "commit": os.environ.get("GITHUB_SHA", "local"), + "run": f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/{os.environ.get('GITHUB_REPOSITORY', '')}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}", + } + (directory / f"results-{phase}.json").write_text( + json.dumps( + {"context": context, "results": [asdict(result) for result in results]}, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + lines = [ + "# Feature showcase", + "", + *(f"- {key}: {value}" for key, value in context.items()), + "", + "| Feature | Result | Expected | Observation | Inspect |", + "| --- | --- | --- | --- | --- |", + ] + for result in results: + lines.append( + "| " + + " | ".join( + text.replace("|", "\\|").replace("\n", " ") + for text in ( + result.name, + result.status.value, + result.expected, + result.actual, + result.inspect, + ) + ) + + " |" + ) + lines.extend( + ( + "", + "Manual review: groups, annotations, dummy masking, matcher registration/removal, debug visibility, state post-hook logs, and Markdown rendering. MANUAL needs visual inspection; SKIP means runner support was unavailable.", + "", + ) + ) + markdown = "\n".join(lines) + (directory / f"report-{phase}.md").write_text(markdown, encoding="utf-8") + if os.environ.get("GITHUB_STEP_SUMMARY"): + gha.append_job_summary(markdown) + + +def parse_result(record: JSONValue) -> Result: + """Parse and validate a saved result.""" + if not isinstance(record, dict): + raise ValueError("Showcase result must be an object") + fields = [] + for key in ("name", "expected", "inspect", "status", "actual"): + value = record.get(key) + if not isinstance(value, str): + raise ValueError("Showcase result text field is missing or invalid") + fields.append(value) + apis = record.get("apis") + if not isinstance(apis, list): + raise ValueError("Showcase result APIs must be an array") + names = [] + for value in apis: + if not isinstance(value, str): + raise ValueError("Showcase API names must be strings") + names.append(value) + return Result( + fields[0], tuple(names), fields[1], fields[2], Status(fields[3]), fields[4] + ) + + +def read_reports(directory: Path) -> tuple[Result, ...]: + """Combine every phase, recording omitted cases as failures in the final table.""" + results: list[Result] = [] + for phase in ("summary", "produce", "verify"): + path = directory / f"results-{phase}.json" + if not path.exists(): + continue + try: + data = cast(JSONValue, json.loads(path.read_text(encoding="utf-8"))) + if not isinstance(data, dict) or not isinstance( + records := data.get("results"), list + ): + raise ValueError("Showcase report must contain a results array") + phase_results = tuple(parse_result(record) for record in records) + expected = {case.name for case in CASES if case.phase == phase} + if ( + len(phase_results) != len(expected) + or {result.name for result in phase_results} != expected + ): + raise ValueError( + "Showcase report has missing, duplicate, or unknown cases" + ) + results.extend(phase_results) + except (OSError, ValueError) as error: + results.extend( + Result( + case.name, + case.apis, + case.expected, + case.inspect, + Status.FAIL, + f"Invalid {phase} report: {type(error).__name__}", + ) + for case in CASES + if case.phase == phase + ) + completed = {result.name for result in results} + results.extend( + Result( + case.name, + case.apis, + case.expected, + case.inspect, + Status.FAIL, + f"Missing {case.phase} result", + ) + for case in CASES + if case.name not in completed + ) + return tuple(results) + + +def main(argv: Sequence[str] | None = None) -> None: + """Run every case in a phase, recording failures before failing the process.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("phase", choices=("summary", "produce", "verify", "report")) + parser.add_argument("--directory", type=Path, default=Path("reports/showcase")) + arguments = parser.parse_args(argv) + results = ( + list(read_reports(arguments.directory)) if arguments.phase == "report" else [] + ) + for case in CASES: + if case.phase != arguments.phase: + continue + try: + with gha.group(f"Showcase: {case.name}"): + observed = case.run() + except Exception as error: + observed = (Status.FAIL, f"{type(error).__name__}: {error}") + results.append( + Result( + case.name, + case.apis, + case.expected, + case.inspect, + observed[0], + observed[1], + ) + ) + write_reports( + arguments.directory, + tuple(results), + "final" if arguments.phase == "report" else arguments.phase, + ) + if any(result.status is Status.FAIL for result in results): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 618dc93..0000000 --- a/setup.cfg +++ /dev/null @@ -1,29 +0,0 @@ -[metadata] -name = github-action-utils -version = 1.1.0 -description = Collection of python functions that can be used to run GitHub Action Workflow Commands -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/saadmk11/github-action-utils -author = Maksudul Haque -author_email = saad.mk112@gmail.com -license = MIT -license_file = LICENSE -classifiers = - License :: OSI Approved :: MIT License - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: Implementation :: CPython - -[options] -py_modules = github_action_utils -python_requires = >=3.6.0 - -[bdist_wheel] -universal = True diff --git a/setup.py b/setup.py deleted file mode 100644 index 6068493..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/src/github_action_utils/__init__.py b/src/github_action_utils/__init__.py new file mode 100644 index 0000000..051f55b --- /dev/null +++ b/src/github_action_utils/__init__.py @@ -0,0 +1,426 @@ +"""Python helpers for GitHub Actions workflow commands and environment values.""" + +import json +import os +import uuid +from contextlib import contextmanager +from functools import lru_cache +from typing import Any, Dict, Generator, Literal, NoReturn +from warnings import warn + +from . import _commands, _files, _serialization +from ._artifacts import ( + ArtifactSubject, + declare_file_artifact, + declare_oci_artifact, + get_workflow_artifacts, +) +from ._environment import ( + GitHubVariables, + RunnerVariables, + WorkflowVariables, + is_github_action, +) +from ._environment import vars as vars +from ._files import ( + add_system_path, + append_job_summary, + overwrite_job_summary, + remove_job_summary, +) +from ._inputs import get_boolean_input, get_multiline_input, get_user_input + +__all__ = [ + "ACTION_ENV_DELIMITER", + "COMMAND_MARKER", + "COMMANDS_USE_SUBPROCESS", + "ArtifactSubject", + "CommandTypes", + "LogCommandTypes", + "GitHubVariables", + "RunnerVariables", + "WorkflowVariables", + "add_mask", + "add_problem_matcher", + "add_system_path", + "append_job_summary", + "begin_stop_commands", + "debug", + "declare_file_artifact", + "declare_oci_artifact", + "echo", + "end_group", + "end_stop_commands", + "error", + "event_payload", + "fail", + "get_boolean_input", + "get_env", + "get_multiline_input", + "get_state", + "get_user_input", + "get_workflow_artifacts", + "get_workflow_environment_variables", + "group", + "is_github_action", + "notice", + "overwrite_job_summary", + "remove_job_summary", + "remove_problem_matcher", + "save_state", + "set_command_echo", + "set_env", + "set_output", + "start_group", + "stop_commands", + "warning", +] + +CommandTypes = Literal[ + "add-mask", + "add-matcher", + "debug", + "echo", + "error", + "group", + "notice", + "remove-matcher", + "save-state", + "set-output", + "stop-commands", + "warning", +] +LogCommandTypes = Literal["debug", "error", "notice", "warning"] +ACTION_ENV_DELIMITER = "__ENV_DELIMITER__" +COMMAND_MARKER = "::" +COMMANDS_USE_SUBPROCESS = bool(os.environ.get("COMMANDS_USE_SUBPROCESS", "")) + + +def _make_string(data: Any) -> str: # type: ignore[explicit-any] + """Convert a value to a string.""" + if isinstance(data, (list, tuple, dict)): + return json.dumps(data) + return str(data) + + +def _to_camel_case(text: str) -> str: + """Transform a snake case string to camel case.""" + return f"{text[:1].lower()}{text.title().replace('_', '')[1:]}" + + +def _build_options_string(**kwargs: Any) -> str: # type: ignore[explicit-any] + return ",".join( + f"{_to_camel_case(key)}={_escape_property(value)}" + for key, value in kwargs.items() + if value is not None + ) + + +def set_output(name: str, value: Any, use_subprocess: bool | None = None) -> None: # type: ignore[explicit-any] + """Set a step output through GITHUB_OUTPUT, preserving multiline text. + + Lists, tuples, and dictionaries become JSON; other values use str(). Invalid + names raise ValueError. The deprecated use_subprocess argument emits a warning. + """ + if use_subprocess is not None: + warn( + "Argument `use_subprocess` for `set_output()` is deprecated and " + "going to be removed in the next version.", + DeprecationWarning, + stacklevel=1, + ) + + _write_file_command("GITHUB_OUTPUT", name, value) + + +def echo(message: Any, use_subprocess: bool = False) -> None: # type: ignore[explicit-any] + """Print text to stdout without escaping it.""" + message = str(message) + + _emit(message, use_subprocess=use_subprocess) + + +def set_command_echo(enabled: bool, use_subprocess: bool = False) -> None: + """Enable or disable the runner's command echoing for the rest of this step.""" + if not isinstance(enabled, bool): + raise TypeError("Command echo setting must be a boolean") + _print_command( + "echo", + "on" if enabled else "off", + use_subprocess=use_subprocess, + ) + + +def debug(message: str, use_subprocess: bool = False) -> None: + """Print a debug message in the GitHub Actions shell.""" + _print_command("debug", str(message), use_subprocess=use_subprocess) + + +def notice( + message: str, + title: str | None = None, + file: str | None = None, + col: int | None = None, + end_column: int | None = None, + line: int | None = None, + end_line: int | None = None, + use_subprocess: bool = False, +) -> None: + """Print a notice message in the GitHub Actions shell.""" + _print_command( + "notice", + str(message), + options_string=_build_options_string( + title=title, + file=file, + col=col, + end_column=end_column, + line=line, + end_line=end_line, + ), + use_subprocess=use_subprocess, + ) + + +def warning( + message: str, + title: str | None = None, + file: str | None = None, + col: int | None = None, + end_column: int | None = None, + line: int | None = None, + end_line: int | None = None, + use_subprocess: bool = False, +) -> None: + """Print a warning message in the GitHub Actions shell.""" + _print_command( + "warning", + str(message), + options_string=_build_options_string( + title=title, + file=file, + col=col, + end_column=end_column, + line=line, + end_line=end_line, + ), + use_subprocess=use_subprocess, + ) + + +def error( + message: str, + title: str | None = None, + file: str | None = None, + col: int | None = None, + end_column: int | None = None, + line: int | None = None, + end_line: int | None = None, + use_subprocess: bool = False, +) -> None: + """Print an error annotation without changing the process exit status.""" + _print_command( + "error", + str(message), + options_string=_build_options_string( + title=title, + file=file, + col=col, + end_column=end_column, + line=line, + end_line=end_line, + ), + use_subprocess=use_subprocess, + ) + + +def fail(message: str, use_subprocess: bool = False) -> NoReturn: + """Emit an error annotation and raise SystemExit(1).""" + error(message, use_subprocess=use_subprocess) + raise SystemExit(1) + + +def save_state(name: str, value: Any, use_subprocess: bool | None = None) -> None: # type: ignore[explicit-any] + """Save state through GITHUB_STATE for a later hook of the same action. + + Value conversion and validation match set_output(). + """ + if use_subprocess is not None: + warn( + "Argument `use_subprocess` for `save_state()` is deprecated and " + "going to be removed in the next version.", + DeprecationWarning, + stacklevel=1, + ) + + _write_file_command("GITHUB_STATE", name, value) + + +def get_state(name: str) -> str | None: + """Read STATE_name in a later action hook, or return None if it is absent.""" + return os.environ.get(f"STATE_{name}") + + +def start_group(title: str, use_subprocess: bool = False) -> None: + """Create an expandable group in GitHub Actions log.""" + _print_command("group", str(title), use_subprocess=use_subprocess) + + +def end_group(use_subprocess: bool = False) -> None: + """Close an expandable group in GitHub Actions log.""" + message = f"{COMMAND_MARKER}endgroup{COMMAND_MARKER}" + + _emit(message, use_subprocess=use_subprocess) + + +@contextmanager +def group(title: str, use_subprocess: bool = False) -> Generator[Any, None, None]: # type: ignore[explicit-any] + """Open a titled log group and close it even when the body raises.""" + start_group(title, use_subprocess=use_subprocess) + try: + yield + finally: + end_group(use_subprocess=use_subprocess) + + +def add_mask(value: Any, use_subprocess: bool = False) -> None: # type: ignore[explicit-any] + """Mask a value to prevent a string or variable from being printed in the log.""" + _print_command("add-mask", _make_string(value), use_subprocess=use_subprocess) + + +def add_problem_matcher(path: str, use_subprocess: bool = False) -> None: + """Register a matcher JSON file, resolving relative paths in the workspace.""" + _commands._validate_matcher_selector(path) + _print_command("add-matcher", path, use_subprocess=use_subprocess) + + +def remove_problem_matcher( + *, + owner: str | None = None, + path: str | None = None, + use_subprocess: bool = False, +) -> None: + """Remove a matcher by exactly one selector: its owner or its JSON file path.""" + if (owner is None) == (path is None): + raise ValueError("Specify exactly one matcher owner or file path") + if owner is not None: + _commands._validate_matcher_selector(owner) + _print_command( + "remove-matcher", + "", + _build_options_string(owner=owner), + use_subprocess=use_subprocess, + ) + else: + assert path is not None + _commands._validate_matcher_selector(path) + _print_command("remove-matcher", path, use_subprocess=use_subprocess) + + +def begin_stop_commands(token: str | None = None, use_subprocess: bool = False) -> str: + """Suspend command processing and return its resume token. + + None or an empty string generates a UUID4. Invalid tokens raise ValueError; + use end_stop_commands() with the returned token to resume processing. + """ + if not token: + token = str(uuid.uuid4()) + _commands._validate_stop_token(token) + + _print_command("stop-commands", token, use_subprocess=use_subprocess) + + return token + + +def end_stop_commands(token: str, use_subprocess: bool = False) -> None: + """Resume workflow command processing using the token from begin_stop_commands().""" + _commands._validate_stop_token(token) + message = f"{COMMAND_MARKER}{token}{COMMAND_MARKER}" + + _emit(message, use_subprocess=use_subprocess) + + +@contextmanager +def stop_commands( # type: ignore[explicit-any] + token: str | None = None, use_subprocess: bool = False +) -> Generator[Any, None, None]: + """Suspend workflow commands and resume processing even if the body raises.""" + stop_token = begin_stop_commands(token=token, use_subprocess=use_subprocess) + try: + yield + finally: + end_stop_commands(stop_token, use_subprocess=use_subprocess) + + +def set_env(name: str, value: Any) -> None: # type: ignore[explicit-any] + """Set an environment value for later steps without changing os.environ. + + Value conversion and validation match set_output(). + """ + _write_file_command("GITHUB_ENV", name, value) + + +def get_env(name: str) -> Any: # type: ignore[explicit-any] + """Look up a process variable, then a pending current-step file value. + + Empty process values take precedence. Return None for a missing name; + unreadable files and malformed records propagate their OSError/ValueError. + """ + if name in os.environ: + return os.environ[name] + return get_workflow_environment_variables().get(name) + + +@lru_cache(maxsize=1) +def event_payload() -> Dict[str, Any]: # type: ignore[explicit-any] + """Read and cache the UTF-8 JSON payload at GITHUB_EVENT_PATH. + + Calls share one mutable value. Use cache_clear() to reload on the next call + and cache_info() for statistics. File and JSON errors propagate. + """ + with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as f: + data: Dict[str, Any] = json.load(f) # type: ignore[explicit-any] + return data + + +def _emit(message: str, use_subprocess: bool = False) -> None: + """Emit text using the current subprocess setting.""" + _commands._emit(message, use_subprocess or COMMANDS_USE_SUBPROCESS) + + +def _print_command( + command: CommandTypes, + command_message: str, + options_string: str | None = "", + use_subprocess: bool = False, + escape_message: bool = True, +) -> None: + """Emit a workflow command using the current subprocess setting.""" + _commands.print_command( + command, + command_message, + options_string, + use_subprocess or COMMANDS_USE_SUBPROCESS, + escape_message, + ) + + +def _escape_property(data: Any) -> str: # type: ignore[explicit-any] + """Convert and escape a command property value.""" + return _serialization.escape_property(_make_string(data)) + + +def _write_file_command(environment_variable: str, name: str, value: Any) -> None: # type: ignore[explicit-any] + """Convert values before opening the runner file.""" + _files._write_file_command( + environment_variable, _make_string(name), _make_string(value) + ) + + +def get_workflow_environment_variables() -> Dict[str, Any]: # type: ignore[explicit-any] + """Read pending GITHUB_ENV values into a fresh dictionary. + + Later duplicate names win. Return {} without a configured file; malformed + records raise ValueError and file errors propagate. + """ + return _files.get_workflow_environment_variables() diff --git a/src/github_action_utils/_artifacts.py b/src/github_action_utils/_artifacts.py new file mode 100644 index 0000000..1e83089 --- /dev/null +++ b/src/github_action_utils/_artifacts.py @@ -0,0 +1,119 @@ +"""Conditional workflow artifact declarations and read-only metadata access. + +Runner support is conditional; exposed paths alone do not guarantee processing. +Declarations record identities without uploading files or images. +""" + +import json +import os +import re +from typing import NamedTuple, cast + +from ._files import append_line, validate_single_line +from ._serialization import JSONValue + +_OCI_REFERENCE = re.compile(r"(.+)@(sha256|sha384|sha512):([0-9a-fA-F]+)") +_DIGEST_LENGTHS = {"sha256": 64, "sha384": 96, "sha512": 128} + + +class ArtifactSubject(NamedTuple): + """An immutable declared identity: name, algorithm:hex digest, and file/oci kind.""" + + name: str + digest: str + kind: str + + +def _artifact_file(variable: str) -> str: + if not (path := os.environ.get(variable)): + raise RuntimeError(f"{variable} is not configured by the runner") + return path + + +def _validate_declaration(value: str) -> None: + validate_single_line(value, "Artifact declaration") + if "=" in value: + raise ValueError( + "Artifact declarations cannot contain the reserved '=' character" + ) + if value != value.rstrip(): + raise ValueError("Artifact declarations cannot end in whitespace") + + +def declare_file_artifact(path: str) -> None: + """Queue one file identity in GITHUB_ARTIFACTS using explicit file:// framing. + + The runner resolves paths against GITHUB_WORKSPACE and computes digests + after the step. A successful write does not guarantee runner processing. + """ + _validate_declaration(path) + append_line(_artifact_file("GITHUB_ARTIFACTS"), "file://" + path) + + +def declare_oci_artifact(reference: str) -> None: + """Queue an OCI reference pinned to a full sha256/sha384/sha512 digest. + + An optional oci:// prefix is accepted. Digest hex is normalized to lowercase. + This does not push an image or enable the runner's conditional feature. + """ + _validate_declaration(reference) + if reference.lower().startswith("oci://"): + reference = reference[len("oci://") :] + match = _OCI_REFERENCE.fullmatch(reference) + if match is None: + raise ValueError( + "OCI references require a name and a sha256/sha384/sha512 digest" + ) + name, algorithm, digest = match.groups("") + if len(digest) != _DIGEST_LENGTHS[algorithm]: + raise ValueError("OCI digest has the wrong length for its algorithm") + declaration = f"oci://{name}@{algorithm}:{digest.lower()}" + append_line(_artifact_file("GITHUB_ARTIFACTS"), declaration) + + +def get_workflow_artifacts() -> tuple[ArtifactSubject, ...]: + """Read fresh, immutable subjects from the current GITHUB_ARTIFACTS_LIST. + + An empty version-1 subjects array returns (). Missing configuration raises + RuntimeError; empty, malformed, or unsupported metadata raises ValueError. + File errors propagate. + """ + with open(_artifact_file("GITHUB_ARTIFACTS_LIST"), encoding="utf-8") as file: + try: + payload = cast(JSONValue, json.load(file)) + except json.JSONDecodeError as error: + raise ValueError( + "Workflow artifact metadata is empty or invalid JSON" + ) from error + if not isinstance(payload, dict): + raise ValueError("Workflow artifact metadata must be a JSON object") + version = payload.get("version") + if type(version) is not int or version != 1: + raise ValueError("Unsupported workflow artifact metadata version; expected 1") + subjects = payload.get("subjects") + if not isinstance(subjects, list): + raise ValueError("Workflow artifact metadata must contain a subjects array") + result = [] + for subject in subjects: + if not isinstance(subject, dict): + raise ValueError( + "Artifact subjects require nonempty name, digest, and kind strings" + ) + name, digest, kind = ( + subject.get(field) for field in ("name", "digest", "kind") + ) + if ( + not isinstance(name, str) + or not name + or not isinstance(digest, str) + or not digest + or not isinstance(kind, str) + or not kind + ): + raise ValueError( + "Artifact subjects require nonempty name, digest, and kind strings" + ) + if kind not in ("file", "oci"): + raise ValueError("Artifact subject kind must be file or oci") + result.append(ArtifactSubject(name, digest, kind)) + return tuple(result) diff --git a/src/github_action_utils/_commands.py b/src/github_action_utils/_commands.py new file mode 100644 index 0000000..51b7023 --- /dev/null +++ b/src/github_action_utils/_commands.py @@ -0,0 +1,93 @@ +"""Serialize and emit stdout workflow commands.""" + +import subprocess +import sys +from typing import BinaryIO, cast + +from ._environment import is_github_action +from ._serialization import escape_data + +_RESERVED_STOP_TOKENS = frozenset( + ( + "stop-commands", + "pause-logging", + "internal-set-repo-path", + "set-env", + "set-output", + "save-state", + "add-mask", + "add-path", + "add-matcher", + "remove-matcher", + "debug", + "warning", + "error", + "notice", + "group", + "endgroup", + "echo", + ) +) + + +def _emit(message: str, use_subprocess: bool = False) -> None: + """Write a line portably without shell interpolation or echo flags.""" + if use_subprocess: + sys.stdout.flush() + subprocess.run( + [ + sys.executable, + "-c", + "import shutil, sys; shutil.copyfileobj(sys.stdin.buffer, sys.stdout.buffer)", + ], + input=(message + "\n").encode("utf-8"), + check=True, + ) + else: + buffer = cast(BinaryIO | None, getattr(sys.stdout, "buffer", None)) + if is_github_action() and buffer is not None: + # Windows redirected Python streams can default to an ANSI encoding. + # The runner consumes UTF-8. Flush text before bypassing its encoding. + data = (message + "\n").encode("utf-8") + sys.stdout.flush() + buffer.write(data) + sys.stdout.flush() + else: + # Respect redirected text-only streams and ordinary local consoles. + print(message, flush=True) + + +def _validate_matcher_selector(value: str) -> None: + if not isinstance(value, str): + raise TypeError("Matcher selector must be a string") + if not value or "\0" in value: + raise ValueError("Matcher selector must be nonempty and contain no NUL") + + +def _validate_stop_token(token: str) -> None: + """Require an unambiguous command identifier that the runner can resume.""" + if not isinstance(token, str): + raise TypeError("Stop token must be a string") + if ( + not token + or any(part in token for part in (" ", "\r", "\n", "\0", "::")) + or token.endswith(":") + ): + raise ValueError( + "Stop token cannot be represented as an unambiguous command name" + ) + if token.lower() in _RESERVED_STOP_TOKENS: + raise ValueError("Stop token cannot be a reserved workflow command name") + + +def print_command( + command: str, + message: str, + options: str | None = "", + use_subprocess: bool = False, + escape_message: bool = True, +) -> None: + """Emit a command after applying stdout-only message escaping.""" + if escape_message: + message = escape_data(message) + _emit(f"::{command} {options or ''}::{message}", use_subprocess) diff --git a/src/github_action_utils/_environment.py b/src/github_action_utils/_environment.py new file mode 100644 index 0000000..898f71c --- /dev/null +++ b/src/github_action_utils/_environment.py @@ -0,0 +1,281 @@ +"""Live, read-only GitHub Actions environment values. + +Missing text values are None; empty strings stay empty. Boolean properties +compare against the runner's exact flag values. +""" + +import os + + +def is_github_action() -> bool: + """Return whether the current GITHUB_ACTIONS value is exactly 'true'.""" + return os.environ.get("GITHUB_ACTIONS") == "true" + + +class GitHubVariables: + """Read-only GITHUB_* values, with strings retained exactly as supplied.""" + + __slots__ = () + + @property + def action(self) -> str | None: + """Current action or step identifier.""" + return os.environ.get("GITHUB_ACTION") + + @property + def action_path(self) -> str | None: + """Directory containing the composite action.""" + return os.environ.get("GITHUB_ACTION_PATH") + + @property + def action_repository(self) -> str | None: + """Repository containing the current action.""" + return os.environ.get("GITHUB_ACTION_REPOSITORY") + + @property + def actor(self) -> str | None: + """Actor whose privileges apply to this run.""" + return os.environ.get("GITHUB_ACTOR") + + @property + def actor_id(self) -> str | None: + """Account identifier of the actor.""" + return os.environ.get("GITHUB_ACTOR_ID") + + @property + def api_url(self) -> str | None: + """GitHub REST API base URL.""" + return os.environ.get("GITHUB_API_URL") + + @property + def artifacts(self) -> str | None: + """File used to declare artifacts for this step.""" + return os.environ.get("GITHUB_ARTIFACTS") + + @property + def artifacts_list(self) -> str | None: + """File containing aggregated artifact metadata.""" + return os.environ.get("GITHUB_ARTIFACTS_LIST") + + @property + def base_ref(self) -> str | None: + """Base branch for a pull request event.""" + return os.environ.get("GITHUB_BASE_REF") + + @property + def env(self) -> str | None: + """Environment command file for the current step.""" + return os.environ.get("GITHUB_ENV") + + @property + def event_name(self) -> str | None: + """Event that triggered this workflow.""" + return os.environ.get("GITHUB_EVENT_NAME") + + @property + def event_path(self) -> str | None: + """Path to the event payload JSON file.""" + return os.environ.get("GITHUB_EVENT_PATH") + + @property + def graphql_url(self) -> str | None: + """GitHub GraphQL API URL.""" + return os.environ.get("GITHUB_GRAPHQL_URL") + + @property + def head_ref(self) -> str | None: + """Head branch for a pull request event.""" + return os.environ.get("GITHUB_HEAD_REF") + + @property + def job(self) -> str | None: + """Current job identifier.""" + return os.environ.get("GITHUB_JOB") + + @property + def output(self) -> str | None: + """Output command file for the current step.""" + return os.environ.get("GITHUB_OUTPUT") + + @property + def path(self) -> str | None: + """PATH command file for the current step.""" + return os.environ.get("GITHUB_PATH") + + @property + def ref(self) -> str | None: + """Fully qualified reference for the event.""" + return os.environ.get("GITHUB_REF") + + @property + def ref_name(self) -> str | None: + """Short reference name.""" + return os.environ.get("GITHUB_REF_NAME") + + @property + def ref_type(self) -> str | None: + """Reference kind, such as branch or tag.""" + return os.environ.get("GITHUB_REF_TYPE") + + @property + def repository(self) -> str | None: + """Repository in owner/name form.""" + return os.environ.get("GITHUB_REPOSITORY") + + @property + def repository_id(self) -> str | None: + """Repository identifier, retained as text.""" + return os.environ.get("GITHUB_REPOSITORY_ID") + + @property + def repository_owner(self) -> str | None: + """Repository owner's login.""" + return os.environ.get("GITHUB_REPOSITORY_OWNER") + + @property + def repository_owner_id(self) -> str | None: + """Repository owner's account identifier.""" + return os.environ.get("GITHUB_REPOSITORY_OWNER_ID") + + @property + def retention_days(self) -> str | None: + """Log and artifact retention period, retained as text.""" + return os.environ.get("GITHUB_RETENTION_DAYS") + + @property + def run_attempt(self) -> str | None: + """Run attempt number, retained as text.""" + return os.environ.get("GITHUB_RUN_ATTEMPT") + + @property + def run_id(self) -> str | None: + """Workflow run identifier, retained as text.""" + return os.environ.get("GITHUB_RUN_ID") + + @property + def run_number(self) -> str | None: + """Workflow run number, retained as text.""" + return os.environ.get("GITHUB_RUN_NUMBER") + + @property + def server_url(self) -> str | None: + """GitHub server base URL.""" + return os.environ.get("GITHUB_SERVER_URL") + + @property + def sha(self) -> str | None: + """Commit identifier associated with the event.""" + return os.environ.get("GITHUB_SHA") + + @property + def step_summary(self) -> str | None: + """Summary file for the current step.""" + return os.environ.get("GITHUB_STEP_SUMMARY") + + @property + def triggering_actor(self) -> str | None: + """Actor who initiated this run or rerun.""" + return os.environ.get("GITHUB_TRIGGERING_ACTOR") + + @property + def workflow(self) -> str | None: + """Workflow name.""" + return os.environ.get("GITHUB_WORKFLOW") + + @property + def workflow_ref(self) -> str | None: + """Workflow file reference.""" + return os.environ.get("GITHUB_WORKFLOW_REF") + + @property + def workflow_sha(self) -> str | None: + """Commit containing the workflow file.""" + return os.environ.get("GITHUB_WORKFLOW_SHA") + + @property + def workspace(self) -> str | None: + """Workflow working directory.""" + return os.environ.get("GITHUB_WORKSPACE") + + @property + def actions(self) -> bool: + """Whether the process is running in GitHub Actions.""" + return is_github_action() + + @property + def ref_protected(self) -> bool: + """Whether GITHUB_REF_PROTECTED is exactly 'true'.""" + return os.environ.get("GITHUB_REF_PROTECTED") == "true" + + +class RunnerVariables: + """Read-only RUNNER_* values for the current process.""" + + __slots__ = () + + @property + def arch(self) -> str | None: + """Runner CPU architecture.""" + return os.environ.get("RUNNER_ARCH") + + @property + def environment(self) -> str | None: + """Runner hosting kind, such as github-hosted or self-hosted.""" + return os.environ.get("RUNNER_ENVIRONMENT") + + @property + def name(self) -> str | None: + """Runner name.""" + return os.environ.get("RUNNER_NAME") + + @property + def os(self) -> str | None: + """Runner operating system.""" + return os.environ.get("RUNNER_OS") + + @property + def temp(self) -> str | None: + """Temporary directory for this job.""" + return os.environ.get("RUNNER_TEMP") + + @property + def tool_cache(self) -> str | None: + """Directory containing preinstalled tools.""" + return os.environ.get("RUNNER_TOOL_CACHE") + + @property + def debug(self) -> bool: + """Whether RUNNER_DEBUG is exactly '1'.""" + return os.environ.get("RUNNER_DEBUG") == "1" + + +_GITHUB_VARIABLES = GitHubVariables() +_RUNNER_VARIABLES = RunnerVariables() + + +class WorkflowVariables: + """Group live os.environ values by GitHub and runner prefixes. + + Pending GITHUB_ENV writes are read through get_env(), not this view. + """ + + __slots__ = () + + @property + def ci(self) -> bool: + """Whether CI is exactly 'true'.""" + return os.environ.get("CI") == "true" + + @property + def github(self) -> GitHubVariables: + """Live GITHUB_* environment values.""" + return _GITHUB_VARIABLES + + @property + def runner(self) -> RunnerVariables: + """Live RUNNER_* environment values.""" + return _RUNNER_VARIABLES + + +vars = WorkflowVariables() +"""Shared read-only view; values are read on property access, not import.""" diff --git a/src/github_action_utils/_files.py b/src/github_action_utils/_files.py new file mode 100644 index 0000000..eebb841 --- /dev/null +++ b/src/github_action_utils/_files.py @@ -0,0 +1,165 @@ +"""Read and write UTF-8 runner command files.""" + +import os +import uuid +from typing import Literal + +PathInput = str | os.PathLike[str] + + +def validate_single_line(value: str, label: str) -> None: + """Reject values that cannot be represented by exactly one record.""" + if not isinstance(value, str): + raise TypeError(f"{label} must be a string") + if not value or any(character in value for character in ("\r", "\n", "\0")): + raise ValueError(f"{label} must be nonempty and contain no CR, LF, or NUL") + + +def append_line(path: str, value: str) -> None: + """Encode before opening so encoding errors cannot partially write a record.""" + record = (value + os.linesep).encode("utf-8") + with open(path, "ab") as file: + file.write(record) + + +def _build_file_input(name: str, value: str) -> bytes: + """Serialize one file command without applying stdout-command escaping. + + Reject names that could create another record. A delimiter absent from both + the name and value keeps arbitrary text, including terminal newlines, intact. + Native line endings match the runner's platform-specific file parser. + """ + if not name or any(part in name for part in ("=", "<<", "\r", "\n", "\0")): + raise ValueError( + "File command names must be nonempty and contain no =, <<, CR, LF, or NUL" + ) + + delimiter = f"ghadelimiter_{uuid.uuid4().hex}" + while delimiter in name or delimiter in value: + delimiter = f"ghadelimiter_{uuid.uuid4().hex}" + newline = os.linesep + # A leading U+FEFF in a name is data, not a UTF-8 file signature. + prefix = newline if name.startswith("\ufeff") else "" + return f"{prefix}{name}<<{delimiter}{newline}{value}{newline}{delimiter}{newline}".encode() + + +def _write_file_command(environment_variable: str, name: str, value: str) -> None: + """Validate and serialize before opening the runner's append-only file.""" + record = _build_file_input(name, value) + with open(os.environ[environment_variable], "ab") as file: + file.write(record) + + +def _read_file_line(text: str, position: int) -> tuple[str, int, int]: + """Return a line, the next offset, and its newline length, like the runner. + + Windows accepts CRLF and LF framing. Other runners split only on LF, so a + carriage return in the value remains data. Avoid universal newline decoding. + """ + end = text.find("\n", position) + if end == -1: + return text[position:], len(text), 0 + if os.name == "nt" and end > position and text[end - 1] == "\r": + return text[position : end - 1], end + 1, 2 + return text[position:end], end + 1, 1 + + +def get_workflow_environment_variables() -> dict[str, str]: + """Read simple and multiline records from the current step's GITHUB_ENV. + + Preserve whitespace; later duplicate names win. An unset or empty path + returns {}. File errors propagate; malformed records raise ValueError. + """ + path = os.environ.get("GITHUB_ENV") + if not path: + return {} + with open(path, encoding="utf-8-sig", newline="") as file: + text = file.read() + + values: dict[str, str] = {} + position = 0 + while position < len(text): + line, position, _ = _read_file_line(text, position) + if not line: + continue + equals = line.find("=") + heredoc = line.find("<<") + if equals >= 0 and (heredoc < 0 or equals < heredoc): + name, value = line.split("=", 1) + if not name: + raise ValueError("Invalid environment record: empty name") + elif heredoc >= 0: + name, delimiter = line.split("<<", 1) + if not name or not delimiter: + raise ValueError("Invalid environment record: empty name or delimiter") + start = end = position + while position < len(text): + line, position, newline_length = _read_file_line(text, position) + if line == delimiter: + value = text[start:end] + break + if not newline_length: + raise ValueError( + "Invalid environment record: missing closing delimiter" + ) + end = position - newline_length + else: + raise ValueError( + "Invalid environment record: missing closing delimiter" + ) + else: + raise ValueError( + "Invalid environment record: expected NAME=value or NAME< None: + """Append raw Markdown and one LF to this step's summary using UTF-8.""" + _write_summary(markdown_text, "ab") + + +def overwrite_job_summary(markdown_text: str) -> None: + """Replace this step's summary with raw Markdown and one LF using UTF-8.""" + _write_summary(markdown_text, "wb") + + +def _write_summary(markdown_text: str, mode: Literal["ab", "wb"]) -> None: + # Encode first so invalid text cannot truncate an existing summary. + data = (str(markdown_text) + "\n").encode("utf-8") + with open(os.environ["GITHUB_STEP_SUMMARY"], mode) as file: + file.write(data) + + +def remove_job_summary() -> None: + """Remove this step's summary, ignoring an absent path or file.""" + try: + os.remove(os.environ["GITHUB_STEP_SUMMARY"]) + except (KeyError, FileNotFoundError): + pass + + +def add_system_path(path: PathInput, *, strict: bool = False) -> None: + """Queue directories for later steps without changing this process's PATH. + + Accept strings and text PathLike objects; directories need not exist yet. + The default preserves legacy CR/LF-separated batches and rejects NUL. + strict=True requires one nonempty entry without CR, LF, or NUL. + """ + if strict and not isinstance(path, (str, os.PathLike)): + raise TypeError("PATH entry must be a string or text PathLike object") + text = os.fspath(path) if isinstance(path, os.PathLike) else str(path) + if not isinstance(text, str): + raise TypeError("PATH entries must use text paths, not bytes") + if strict: + validate_single_line(text, "PATH entry") + elif "\0" in text: + raise ValueError("PATH entries cannot contain NUL") + if text and not text.endswith(("\r", "\n")): + text += os.linesep + if text.startswith("\ufeff"): + text = os.linesep + text + record = text.encode("utf-8") + with open(os.environ["GITHUB_PATH"], "ab") as file: + file.write(record) diff --git a/src/github_action_utils/_inputs.py b/src/github_action_utils/_inputs.py new file mode 100644 index 0000000..8b6fb5e --- /dev/null +++ b/src/github_action_utils/_inputs.py @@ -0,0 +1,69 @@ +"""Read raw, boolean, and multiline action inputs.""" + +import os + + +def get_user_input( + name: str, *, required: bool = False, trim_whitespace: bool = False +) -> str | None: + """Read an INPUT_* variable, returning None when an optional input is absent. + + Preserve whitespace by default. Required mode rejects missing or empty + values after trimming. Spaces fall back to underscores; an existing literal + key takes precedence. Workflow and composite inputs need environment wiring. + """ + if not isinstance(name, str): + raise TypeError("Input name must be a string") + key = "INPUT_" + name.upper() + value = os.environ.get(key) + if value is None: + value = os.environ.get(key.replace(" ", "_")) + if value is not None and trim_whitespace: + value = value.strip() + if required and not value: + raise ValueError(f"Required input is missing or empty: {name}") + return value + + +def get_boolean_input( + name: str, *, required: bool = False, trim_whitespace: bool = True +) -> bool | None: + """Parse the six YAML 1.2 true/false spellings; return None if absent. + + Empty strings and other tokens raise ValueError. Trim whitespace by default. + """ + value = get_user_input(name, required=required, trim_whitespace=trim_whitespace) + if value is None: + return None + match value: + case "true" | "True" | "TRUE": + return True + case "false" | "False" | "FALSE": + return False + raise ValueError(f"Input must be a YAML 1.2 boolean: {name}") + + +def get_multiline_input( + name: str, + *, + required: bool = False, + trim_whitespace: bool = True, + keep_empty_lines: bool = False, +) -> list[str]: + """Split LF/CRLF input into lines, then trim and optionally discard empties. + + Optional missing inputs return an empty list. When empties are retained, + terminal newlines produce terminal empty items. Required mode needs at + least one nonempty item after the selected trimming, even if empties are kept. + """ + value = get_user_input(name, required=required) + if value is None: + return [] + lines = value.replace("\r\n", "\n").split("\n") + if trim_whitespace: + lines = [line.strip() for line in lines] + if required and not any(lines): + raise ValueError(f"Required input has no nonempty lines: {name}") + if not keep_empty_lines: + lines = [line for line in lines if line] + return lines diff --git a/src/github_action_utils/_serialization.py b/src/github_action_utils/_serialization.py new file mode 100644 index 0000000..eccb84e --- /dev/null +++ b/src/github_action_utils/_serialization.py @@ -0,0 +1,17 @@ +"""Escape text for stdout workflow commands.""" + +from typing import TypeAlias + +JSONValue: TypeAlias = ( + None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"] +) + + +def escape_data(data: str) -> str: + """Escape percent first so literal escape-looking text is decoded once.""" + return data.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def escape_property(data: str) -> str: + """Also escape the separators used in command properties.""" + return escape_data(data).replace(":", "%3A").replace(",", "%2C") diff --git a/src/github_action_utils/py.typed b/src/github_action_utils/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/_types.py b/tests/_types.py new file mode 100644 index 0000000..1718117 --- /dev/null +++ b/tests/_types.py @@ -0,0 +1,24 @@ +"""Callback types shared by parameterized tests.""" + +from typing import Protocol, TypedDict + +from github_action_utils._serialization import JSONValue + +LegacyValue = JSONValue | tuple[str, ...] + + +class FileWriter(Protocol): + def __call__( + self, name: str, value: LegacyValue, use_subprocess: bool | None = None + ) -> None: ... + + +class InputReader(Protocol): + def __call__( + self, name: str, *, required: bool = False, trim_whitespace: bool = False + ) -> str | bool | list[str] | None: ... + + +class MatcherOptions(TypedDict, total=False): + owner: str + path: str diff --git a/tests/command_helpers.py b/tests/command_helpers.py new file mode 100644 index 0000000..931f1e5 --- /dev/null +++ b/tests/command_helpers.py @@ -0,0 +1,33 @@ +"""Narrow test decoder for the runner's V2 stdout protocol. + +Based on actions/runner b22b4bfead1623f77d7a76f811f4aacb24da313a, +src/Runner.Common/ActionCommand.cs (TryParseV2 and escape mappings). It does not +simulate runner execution, masking, or rendering; hosted checks remain separate. +""" + + +def _decode(value: str, properties: bool = False) -> str: + mappings = [("%0D", "\r"), ("%0A", "\n")] + if properties: + mappings.extend([("%3A", ":"), ("%2C", ",")]) + mappings.append(("%25", "%")) + for encoded, decoded in mappings: + value = value.replace(encoded, decoded) + return value + + +def parse_command(record: str) -> tuple[str, dict[str, str], str]: + """Decode one complete physical line independently of the package writer.""" + assert record.endswith("\n") and record.count("\n") == 1 + line = record[:-2] if record.endswith("\r\n") else record[:-1] + assert "\r" not in line + line = line.lstrip() + assert line.startswith("::") + separator = line.index("::", 2) + command, _, properties = line[2:separator].partition(" ") + options = {} + for item in properties.strip().split(","): + key, equals, value = item.partition("=") + if equals and key and value: + options[key] = _decode(value, properties=True) + return command, options, _decode(line[separator + 2 :]) diff --git a/tests/file_command_helpers.py b/tests/file_command_helpers.py new file mode 100644 index 0000000..5fb14cb --- /dev/null +++ b/tests/file_command_helpers.py @@ -0,0 +1,18 @@ +"""Independent decoder for the documented delimited file-command format.""" + +import os + + +def parse_records(data: bytes) -> dict[str, str]: + """Decode writer results without calling the library's environment reader.""" + newline = os.linesep.encode("ascii") + records: dict[str, str] = {} + position = 0 + while position < len(data): + header_end = data.index(newline, position) + name, delimiter = data[position:header_end].split(b"<<", 1) + start = header_end + len(newline) + end = data.index(newline + delimiter + newline, start) + records[name.decode("utf-8")] = data[start:end].decode("utf-8") + position = end + len(newline + delimiter + newline) + return records diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..63a2493 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Programs executed against isolated installed distributions.""" diff --git a/tests/fixtures/check_install.py b/tests/fixtures/check_install.py new file mode 100644 index 0000000..dd3bd91 --- /dev/null +++ b/tests/fixtures/check_install.py @@ -0,0 +1,15 @@ +"""Check a clean installed distribution without importing the source checkout.""" + +import sys +from importlib.metadata import version +from pathlib import Path + +import github_action_utils as gha + +location = Path(gha.__file__).resolve() +if not location.is_relative_to(Path("venv").resolve()): + raise RuntimeError("Imported the checkout instead of the installed distribution") +if not location.with_name("py.typed").is_file(): + raise RuntimeError("Installed package is missing its typing marker") +if version("github-action-utils") != sys.argv[1]: + raise RuntimeError("Installed version differs from the project metadata") diff --git a/tests/fixtures/upgrade_after.py b/tests/fixtures/upgrade_after.py new file mode 100644 index 0000000..046fde9 --- /dev/null +++ b/tests/fixtures/upgrade_after.py @@ -0,0 +1,36 @@ +"""Verify the published 1.1.0 contract in an isolated installation.""" + +import inspect +import json +import pickle +from pathlib import Path + +import github_action_utils as g + +if Path(g.__file__).name != "__init__.py": + raise AssertionError("The upgrade must import the new package") +if Path(Path("old-module-path.txt").read_text(encoding="utf-8")).exists(): + raise AssertionError("The upgrade left the old single module installed") +signatures = json.loads(Path("old-signatures.json").read_text(encoding="utf-8")) +for name, original in signatures.items(): + parameters = list(inspect.signature(getattr(g, name)).parameters.values()) + current = [ + (p.name, p.kind.name, repr(p.default)) for p in parameters[: len(original)] + ] + if current != [tuple(p) for p in original]: + raise AssertionError(name) + if any(p.default is inspect.Parameter.empty for p in parameters[len(original) :]): + raise AssertionError(name) +for name, function in pickle.loads(Path("old-functions.pickle").read_bytes()).items(): + if function is not getattr(g, name): + raise AssertionError(name) +namespace: dict[str, object] = {} +exec("from github_action_utils import *", namespace) +for name in signatures: + if name not in namespace or not hasattr(g, name): + raise AssertionError(name) +if "vars" in namespace: + raise AssertionError("Wildcard imports must preserve the built-in vars function") +print( + f"Published 1.1.0 upgrade passed: {len(signatures)} public call signatures, old pickles, and module cleanup." +) diff --git a/tests/fixtures/upgrade_before.py b/tests/fixtures/upgrade_before.py new file mode 100644 index 0000000..2955b13 --- /dev/null +++ b/tests/fixtures/upgrade_before.py @@ -0,0 +1,29 @@ +"""Capture the published 1.1.0 contract in an isolated installation.""" + +import inspect +import json +import pickle +from pathlib import Path + +import github_action_utils as g + +if Path(g.__file__).name != "github_action_utils.py": + raise AssertionError("The baseline must import the published single module") +Path("old-module-path.txt").write_text(g.__file__, encoding="utf-8") +signatures = {} +for name, value in vars(g).items(): + if ( + not name.startswith("_") + and callable(value) + and getattr(value, "__module__", None) == g.__name__ + ): + signatures[name] = [ + (p.name, p.kind.name, repr(p.default)) + for p in inspect.signature(value).parameters.values() + ] +Path("old-signatures.json").write_text(json.dumps(signatures), encoding="utf-8") +Path("old-functions.pickle").write_bytes( + pickle.dumps( + {name: getattr(g, name) for name in ("get_user_input", "event_payload")} + ) +) diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..6fb4629 --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1 @@ +"""Tests for distribution and runner scripts.""" diff --git a/tests/support/conftest.py b/tests/support/conftest.py new file mode 100644 index 0000000..5b54be7 --- /dev/null +++ b/tests/support/conftest.py @@ -0,0 +1,69 @@ +"""Isolated runner files shared by support-script tests.""" + +import json +import os +from pathlib import Path + +import pytest + + +@pytest.fixture +def runner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "workspace" + root.mkdir() + for key in ( + "GITHUB_ENV", + "GITHUB_OUTPUT", + "GITHUB_STATE", + "GITHUB_PATH", + "GITHUB_STEP_SUMMARY", + "GITHUB_ARTIFACTS", + "GITHUB_ARTIFACTS_LIST", + ): + path = tmp_path / key + path.touch() + monkeypatch.setenv(key, str(path)) + event = tmp_path / "event.json" + event.write_text(json.dumps({"action": "test"}), encoding="utf-8") + for key, value in { + "GITHUB_WORKSPACE": str(root), + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_PATH": str(event), + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_ACTOR": "fixture", + "INPUT_SHOWCASE_TEXT": " explicit fixture ", + "INPUT_SHOWCASE_FLAG": "true", + "INPUT_SHOWCASE_LINES": "first\n世界", + }.items(): + monkeypatch.setenv(key, value) + for key in ( + "INPUT_SHOWCASE_MISSING", + "GH_UTILS_INTEGRATION_VALUE", + "ARTIFACTS_ENABLED", + ): + monkeypatch.delenv(key, raising=False) + return root + + +def propagate(monkeypatch: pytest.MonkeyPatch) -> None: + from tests.file_command_helpers import parse_records + + output = parse_records(Path(os.environ["GITHUB_OUTPUT"]).read_bytes()) + for key, name in ( + ("OUTPUT_VALUE", "multiline"), + ("OUTPUT_NUMBER", "number"), + ("OUTPUT_EMPTY", "empty"), + ("OUTPUT_UNICODE", "unicode"), + ("OUTPUT_JSON", "json"), + ("ARTIFACTS_ENABLED", "enabled"), + ): + monkeypatch.setenv(key, output.get(name, "")) + for key, value in parse_records( + Path(os.environ["GITHUB_ENV"]).read_bytes() + ).items(): + monkeypatch.setenv(key, value) + entries = Path(os.environ["GITHUB_PATH"]).read_text(encoding="utf-8").splitlines() + monkeypatch.setenv( + "PATH", os.pathsep.join([*reversed(entries), os.environ["PATH"]]) + ) diff --git a/tests/support/test_distribution.py b/tests/support/test_distribution.py new file mode 100644 index 0000000..01e35e9 --- /dev/null +++ b/tests/support/test_distribution.py @@ -0,0 +1,78 @@ +"""Release ref and published-release validation.""" + +import json +from pathlib import Path +from unittest import mock + +import pytest + +from scripts import check_distribution as dist + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.parametrize( + "ref_type,tag,resolved,draft,release_tag", + [ + ("branch", "v2.0.0", "commit", False, "v2.0.0"), + ("tag", "vv2.0.0", "commit", False, "vv2.0.0"), + ("tag", "v2.0.0", "moved", False, "v2.0.0"), + ("tag", "v2.0.0", "commit", True, "v2.0.0"), + ("tag", "v2.0.0", "commit", False, "wrong"), + ], +) +def test_release_gate_rejects_wrong_refs_and_drafts( + monkeypatch: pytest.MonkeyPatch, + ref_type: str, + tag: str, + resolved: str, + draft: bool, + release_tag: str, +) -> None: + monkeypatch.setenv("GITHUB_REF_TYPE", ref_type) + monkeypatch.setenv("GITHUB_REF_NAME", tag) + monkeypatch.setenv("GITHUB_SHA", "commit") + with mock.patch.object( + dist, + "run", + side_effect=[resolved, json.dumps({"isDraft": draft, "tagName": release_tag})], + ): + with pytest.raises(ValueError): + dist.validate_github_release(ROOT) + + +def test_release_gate_outputs_exact_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + output = tmp_path / "output" + for name, value in { + "GITHUB_REF_TYPE": "tag", + "GITHUB_REF_NAME": "v2.0.0", + "GITHUB_SHA": "commit", + "GITHUB_OUTPUT": str(output), + }.items(): + monkeypatch.setenv(name, value) + with mock.patch.object( + dist, "run", side_effect=["commit\n", '{"isDraft":false,"tagName":"v2.0.0"}'] + ): + dist.main(["--github-release"]) + assert output.read_text() == "version=2.0.0\n" + + +def test_release_checks_survive_optimized_python() -> None: + import os + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-O", + str(ROOT / "scripts/check_distribution.py"), + "--github-release", + ], + env=dict(os.environ, GITHUB_REF_TYPE="branch"), + capture_output=True, + text=True, + ) + assert result.returncode != 0 and "not a branch" in result.stderr diff --git a/tests/support/test_runner.py b/tests/support/test_runner.py new file mode 100644 index 0000000..b896072 --- /dev/null +++ b/tests/support/test_runner.py @@ -0,0 +1,40 @@ +"""Artifact fixtures and action main/post subprocesses.""" + +import os +from pathlib import Path + +import pytest + +from tests.file_command_helpers import parse_records + +STATE_SCRIPT = ( + Path(__file__).resolve().parents[2] / ".github/actions/check-state/check_state.py" +) + + +def test_javascript_main_post_wrappers( + runner: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Launch each action hook in a separate process.""" + import subprocess + import sys + + monkeypatch.setenv( + "PATH", str(Path(sys.executable).parent) + os.pathsep + os.environ["PATH"] + ) + action = STATE_SCRIPT.parent + main = subprocess.run( + ["node", str(action / "main.cjs")], capture_output=True, text=True + ) + assert main.returncode == 0, main.stdout + main.stderr + state = parse_records(Path(os.environ["GITHUB_STATE"]).read_bytes()) + monkeypatch.setenv("STATE_from_main", state["from_main"]) + post = subprocess.run( + ["node", str(action / "post.cjs")], capture_output=True, text=True + ) + assert post.returncode == 0 and "exact main state" in post.stdout + monkeypatch.setenv("STATE_from_main", "wrong state") + failed = subprocess.run( + ["node", str(action / "post.cjs")], capture_output=True, text=True + ) + assert failed.returncode == 1 diff --git a/tests/support/test_showcase.py b/tests/support/test_showcase.py new file mode 100644 index 0000000..30d68e3 --- /dev/null +++ b/tests/support/test_showcase.py @@ -0,0 +1,114 @@ +"""Showcase execution, failure reports, and configuration cleanup.""" + +import json +from pathlib import Path +from unittest import mock + +import pytest + +import github_action_utils as gha +from github_action_utils._serialization import JSONValue +from scripts import showcase +from tests.support.conftest import propagate + + +def test_full_showcase(runner: Path, monkeypatch: pytest.MonkeyPatch) -> None: + directory = runner / "reports" + showcase.main(["summary", "--directory", str(directory)]) + showcase.main(["produce", "--directory", str(directory)]) + report = json.loads((directory / "results-produce.json").read_text()) + assert {result["status"] for result in report["results"]} == { + "PASS", + "MANUAL", + "SKIP", + } + assert "Manual review" in (directory / "report-produce.md").read_text() + propagate(monkeypatch) + showcase.main(["verify", "--directory", str(directory)]) + verified = json.loads((directory / "results-verify.json").read_text())["results"] + assert {row["name"] for row in verified} == { + case.name for case in showcase.CASES if case.phase == "verify" + } + assert all(row["status"] != "FAIL" for row in verified) + showcase.main(["report", "--directory", str(directory)]) + final = json.loads((directory / "results-final.json").read_text())["results"] + assert {row["name"] for row in final} == {case.name for case in showcase.CASES} + assert len(final) == len(showcase.CASES) + assert all(row["status"] != "FAIL" for row in final) + + +def test_failure_reports(runner: Path) -> None: + def broken() -> showcase.Observation: + raise ValueError("fixture failure") + + case = showcase.Case("failing", "produce", (), "must pass", "logs", broken) + with mock.patch.object(showcase, "CASES", (case,)): + with pytest.raises(SystemExit, match="1"): + showcase.main(["produce", "--directory", str(runner)]) + result = json.loads((runner / "results-produce.json").read_text())["results"][0] + assert result["status"] == "FAIL" and "fixture failure" in result["actual"] + + +def test_report_survives_summary_failure( + runner: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("GITHUB_SHA", "caller-commit") + result = showcase.Result( + "a|b", (), "one\ntwo", "logs", showcase.Status.FAIL, "observed" + ) + with mock.patch.object( + gha, "append_job_summary", side_effect=OSError("summary unavailable") + ): + with pytest.raises(OSError): + showcase.write_reports(runner, (result,), "failed") + assert "a\\|b" in (runner / "report-failed.md").read_text() + assert ( + json.loads((runner / "results-failed.json").read_text())["context"]["commit"] + == "caller-commit" + ) + + +def test_missing_phase_is_failure(runner: Path) -> None: + with pytest.raises(SystemExit, match="1"): + showcase.main(["report", "--directory", str(runner)]) + assert "Missing" in (runner / "report-final.md").read_text() + + +def test_console_restores_configuration_after_failure(runner: Path) -> None: + with ( + mock.patch.object(gha, "COMMANDS_USE_SUBPROCESS", True), + mock.patch.object(gha, "echo", side_effect=OSError("broken stdout")), + ): + with pytest.raises(OSError): + showcase.console() + assert gha.COMMANDS_USE_SUBPROCESS is True + + +def test_corrupt_phase_still_writes_a_failed_final_report(runner: Path) -> None: + (runner / "results-produce.json").write_text("not JSON") + with pytest.raises(SystemExit, match="1"): + showcase.main(["report", "--directory", str(runner)]) + assert "Invalid produce report" in (runner / "report-final.md").read_text() + + +@pytest.mark.parametrize( + "records", + [ + [], + [ + { + "name": "unknown", + "apis": [], + "expected": "e", + "inspect": "i", + "status": "PASS", + "actual": "a", + } + ], + ], +) +def test_unknown_or_missing_report_cases_fail(runner: Path, records: JSONValue) -> None: + (runner / "results-produce.json").write_text(json.dumps({"results": records})) + results = showcase.read_reports(runner) + assert len(results) == len(showcase.CASES) + assert all(result.status is showcase.Status.FAIL for result in results) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..b865691 --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,195 @@ +"""Conditional artifact file formats, validation, and immutable metadata reads.""" + +import json +import os +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import pytest + +import github_action_utils as gha +from github_action_utils._serialization import JSONValue + +from ._types import LegacyValue + + +def test_file_artifact_declarations_are_explicit_raw_records(tmp_path: Path) -> None: + path = tmp_path / "commands" + paths = [ + "dist/my file%0A", + "#ambiguous", + "image@sha256:" + "a" * 64, + "dist/বাংলা", + "/absolute/path", + "C:\\build\\app.exe", + " leading-space", + ] + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS": str(path)}): + for declaration in paths: + gha.declare_file_artifact(declaration) + assert path.read_bytes() == "".join( + "file://" + item + os.linesep for item in paths + ).encode("utf-8") + # No filesystem existence check or upload is performed at declaration time. + + +@pytest.mark.parametrize( + "algorithm,length", [("sha256", 64), ("sha384", 96), ("sha512", 128)] +) +@pytest.mark.parametrize("prefix", ["", "oci://", "OCI://"]) +def test_oci_artifact_declarations( + tmp_path: Path, algorithm: str, length: int, prefix: str +) -> None: + path = tmp_path / "commands" + reference = "ghcr.io/owner/image:tag@{}:{}".format(algorithm, "A" * length) + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS": str(path)}): + gha.declare_oci_artifact(prefix + reference) + assert path.read_bytes() == ("oci://" + reference.lower() + os.linesep).encode( + "utf-8" + ) + + +@pytest.mark.parametrize( + "value", + [ + "", + "bad\nname", + "bad\rname", + "bad\0name", + "bad=name", + "trailing ", + "trailing\t", + 123, + ], +) +@pytest.mark.parametrize( + "helper", [gha.declare_file_artifact, gha.declare_oci_artifact] +) +def test_invalid_artifact_declarations_do_not_change_file( + tmp_path: Path, value: LegacyValue, helper: Callable[[str], None] +) -> None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS": str(path)}): + with pytest.raises((TypeError, ValueError)): + helper(value) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert not path.exists() + path.write_bytes(b"unchanged") + with pytest.raises((TypeError, ValueError)): + helper(value) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert path.read_bytes() == b"unchanged" + + +@pytest.mark.parametrize( + "reference", + [ + "image:tag", + "@sha256:" + "a" * 64, + "image@md5:" + "a" * 32, + "image@sha256:" + "a" * 63, + "image@sha256:" + "g" * 64, + "image@sha384:" + "a" * 64, + "oci://", + "image@sha256:" + "a" * 65, + ], +) +def test_invalid_oci_reference(tmp_path: Path, reference: str) -> None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS": str(path)}): + with pytest.raises(ValueError): + gha.declare_oci_artifact(reference) + assert not path.exists() + + +@pytest.mark.parametrize( + "environment", [{}, {"GITHUB_ARTIFACTS": "", "GITHUB_ARTIFACTS_LIST": ""}] +) +def test_artifact_configuration_is_required(environment: dict[str, str]) -> None: + with mock.patch.dict(os.environ, environment, clear=True): + with pytest.raises(RuntimeError, match="GITHUB_ARTIFACTS"): + gha.declare_file_artifact("file") + with pytest.raises(RuntimeError, match="GITHUB_ARTIFACTS"): + gha.declare_oci_artifact("image@sha256:" + "a" * 64) + with pytest.raises(RuntimeError, match="GITHUB_ARTIFACTS_LIST"): + gha.get_workflow_artifacts() + + +def test_artifact_metadata_is_fresh_readonly_and_immutable(tmp_path: Path) -> None: + path = tmp_path / "metadata" + payload = { + "version": 1, + "subjects": [ + { + "name": "বাংলা", + "digest": "sha256:" + "a" * 64, + "kind": "file", + "future": "ignored", + }, + { + "name": "ghcr.io/image:tag", + "digest": "sha384:" + "b" * 96, + "kind": "oci", + }, + ], + } + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + path.write_bytes(data) + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS_LIST": str(path)}): + subjects = gha.get_workflow_artifacts() + assert subjects == ( + gha.ArtifactSubject("বাংলা", "sha256:" + "a" * 64, "file"), + gha.ArtifactSubject("ghcr.io/image:tag", "sha384:" + "b" * 96, "oci"), + ) + subject = subjects[0] + with pytest.raises(AttributeError): + subject.name = "replacement" # type: ignore[misc] # Deliberately mutate an immutable tuple. + assert path.read_bytes() == data + path.write_text('{"version":1,"subjects":[]}', encoding="utf-8") + assert gha.get_workflow_artifacts() == () + other = tmp_path / "other" + other.write_bytes(data) + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS_LIST": str(other)}): + assert gha.get_workflow_artifacts() == subjects + + +@pytest.mark.parametrize( + "payload", + [ + [], + {}, + {"version": True, "subjects": []}, + {"version": 2, "subjects": []}, + {"version": 1, "subjects": {}}, + {"version": 1, "subjects": [None]}, + {"version": 1, "subjects": [{"name": "sensitive-value"}]}, + {"version": 1, "subjects": [{"name": "n", "digest": 123, "kind": "file"}]}, + {"version": 1, "subjects": [{"name": "n", "digest": "d", "kind": "unknown"}]}, + {"version": 1, "subjects": [{"name": "", "digest": "d", "kind": "file"}]}, + ], +) +def test_invalid_artifact_metadata(tmp_path: Path, payload: JSONValue) -> None: + path = tmp_path / "metadata" + path.write_text(json.dumps(payload), encoding="utf-8") + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS_LIST": str(path)}): + with pytest.raises(ValueError) as error: + gha.get_workflow_artifacts() + assert "sensitive-value" not in str(error.value) + + +@pytest.mark.parametrize("data", [b"", b"not-json"]) +def test_unpopulated_or_malformed_metadata_is_not_empty_success( + tmp_path: Path, data: bytes +) -> None: + path = tmp_path / "metadata" + path.write_bytes(data) + with mock.patch.dict(os.environ, {"GITHUB_ARTIFACTS_LIST": str(path)}): + with pytest.raises(ValueError, match="empty or invalid JSON"): + gha.get_workflow_artifacts() + + +def test_missing_configured_artifact_file_propagates(tmp_path: Path) -> None: + with mock.patch.dict( + os.environ, {"GITHUB_ARTIFACTS_LIST": str(tmp_path / "missing")} + ): + with pytest.raises(FileNotFoundError): + gha.get_workflow_artifacts() diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..addb7ba --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,357 @@ +"""Regressions for command serialization, ordering, tokens, and command wrappers.""" + +import io +import os +import subprocess +import sys +import uuid +from collections.abc import Callable +from contextlib import redirect_stdout +from unittest import mock + +import pytest + +import github_action_utils as gha + +from ._types import LegacyValue, MatcherOptions +from .command_helpers import parse_command + + +@pytest.mark.parametrize( + "message", + [ + "", + "plain", + "100% %0A %0D %25 %250A", + "a\rb", + "a\nb", + "a\r\nb\n", + "héllo 世界", + "first\n::warning::synthetic-second-command", + ], +) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_command_data_round_trip( + capfd: pytest.CaptureFixture[str], message: str, use_subprocess: bool +) -> None: + gha.notice(message, use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == ("notice", {}, message) + + +@pytest.mark.parametrize("helper", ["echo", "notice"]) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_runner_stdout_uses_utf8_with_ansi_python_streams( + helper: str, use_subprocess: bool +) -> None: + value = "héllo 世界" + code = f"import github_action_utils as g; g.{helper}({value!r}, use_subprocess={use_subprocess!r})" + environment = dict( + os.environ, + GITHUB_ACTIONS="true", + COMMANDS_USE_SUBPROCESS="", + PYTHONIOENCODING="cp1252", + ) + result = subprocess.run( + [sys.executable, "-c", code], + env=environment, + capture_output=True, + ) + assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace") + record = result.stdout.decode("utf-8") + if helper == "notice": + assert parse_command(record) == ("notice", {}, value) + else: + assert record == value + "\n" + + +def test_runner_emission_respects_text_only_capture() -> None: + output = io.StringIO() + with ( + mock.patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), + redirect_stdout(output), + ): + gha.notice("世界") + assert parse_command(output.getvalue()) == ("notice", {}, "世界") + + +@pytest.mark.parametrize("helper", ["notice", "warning", "error"]) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_annotation_properties_round_trip( + capfd: pytest.CaptureFixture[str], helper: str, use_subprocess: bool +) -> None: + value = "a:b,c%0A\r\n世界" + getattr(gha, helper)( + value, + title=value, + file=value, + line=1, + col=2, + end_column=4, + end_line=3, + use_subprocess=use_subprocess, + ) + command, options, message = parse_command(capfd.readouterr().out) + assert command == helper and message == value + assert options == { + "title": value, + "file": value, + "line": "1", + "col": "2", + "endColumn": "4", + "endLine": "3", + } + + +@pytest.mark.parametrize( + "token", + [ + "test token", + "a::b", + "trailing:", + "a\rb", + "a\nb", + "a\0b", + "stop-commands", + "pause-logging", + "internal-set-repo-path", + "set-env", + "set-output", + "save-state", + "add-mask", + "add-path", + "add-matcher", + "remove-matcher", + "DEBUG", + "warning", + "error", + "notice", + "group", + "endgroup", + "EcHo", + ], +) +@pytest.mark.parametrize("helper", [gha.begin_stop_commands, gha.end_stop_commands]) +def test_invalid_stop_tokens_emit_nothing( + capfd: pytest.CaptureFixture[str], token: str, helper: Callable[[str], str | None] +) -> None: + with pytest.raises(ValueError, match="Stop token"): + helper(token) + assert capfd.readouterr().out == "" + + +def test_empty_end_token_is_rejected(capfd: pytest.CaptureFixture[str]) -> None: + with pytest.raises(ValueError): + gha.end_stop_commands("") + assert capfd.readouterr().out == "" + + +@pytest.mark.parametrize("token", [True, ["nonempty"], object()]) +def test_non_string_stop_token( + capfd: pytest.CaptureFixture[str], token: object +) -> None: + for helper in (gha.begin_stop_commands, gha.end_stop_commands): + with pytest.raises(TypeError, match="Stop token"): + helper(token) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert capfd.readouterr().out == "" + + +def test_random_stop_tokens_resume(capfd: pytest.CaptureFixture[str]) -> None: + tokens = set() + for _ in range(3): + token = gha.begin_stop_commands() + assert uuid.UUID(token).version == 4 + assert parse_command(capfd.readouterr().out) == ("stop-commands", {}, token) + gha.end_stop_commands(token) + assert parse_command(capfd.readouterr().out) == (token, {}, "") + tokens.add(token) + assert len(tokens) == 3 + + +@pytest.mark.parametrize( + "helper,argument,command", + [ + ("add_mask", "AUDIT_DUMMY_VALUE", "add-mask"), + ("start_group", "group-title", "group"), + ("begin_stop_commands", "safe-token", "stop-commands"), + ], +) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_commands_precede_unrelated_child_output( + helper: str, argument: str, command: str, use_subprocess: bool +) -> None: + # Capture a fresh interpreter through a pipe: pytest's own capture can flush + # streams and hide the buffering bug. Never use a real secret here. + code = ( + "import subprocess, sys, github_action_utils as g\n" + f"g.{helper}({argument!r}, use_subprocess={use_subprocess!r})\n" + "subprocess.run([sys.executable, '-c', \"print('AUDIT_DUMMY_VALUE')\"], check=True)\n" + ) + environment = dict(os.environ, COMMANDS_USE_SUBPROCESS="") + environment.pop("PYTHONUNBUFFERED", None) + result = subprocess.run( + [sys.executable, "-c", code], + env=environment, + capture_output=True, + check=True, + ) + records = result.stdout.decode("utf-8").splitlines(keepends=True) + assert len(records) == 2 + assert parse_command(records[0]) == (command, {}, argument) + assert records[1].rstrip("\r\n") == "AUDIT_DUMMY_VALUE" + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_command_echo( + capfd: pytest.CaptureFixture[str], enabled: bool, use_subprocess: bool +) -> None: + gha.set_command_echo(enabled, use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == ( + "echo", + {}, + "on" if enabled else "off", + ) + gha.echo("plain") + assert capfd.readouterr().out == "plain\n" + + +@pytest.mark.parametrize("value", ["false", 0, 1, None]) +def test_command_echo_rejects_non_bool( + capfd: pytest.CaptureFixture[str], value: LegacyValue +) -> None: + with pytest.raises(TypeError, match="boolean"): + gha.set_command_echo(value) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert capfd.readouterr().out == "" + + +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_fail_exits_after_annotation_and_cleanup(use_subprocess: bool) -> None: + code = ( + "import github_action_utils as g\n" + "with g.group('cleanup'):\n" + f" g.fail('failed%0A\\nmessage', use_subprocess={use_subprocess!r})\n" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True) + assert result.returncode == 1 + records = result.stdout.decode("utf-8").splitlines(keepends=True) + assert parse_command(records[1]) == ("error", {}, "failed%0A\nmessage") + assert parse_command(records[2]) == ("endgroup", {}, "") + control = subprocess.run( + [ + sys.executable, + "-c", + "import github_action_utils as g; g.error('annotation only')", + ], + capture_output=True, + ) + assert control.returncode == 0 + + +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_matcher_commands( + capfd: pytest.CaptureFixture[str], use_subprocess: bool +) -> None: + path = "./matchers/a,b:%0A\n世界.json" + owner = "owner,:\r\n%0A" + gha.add_problem_matcher(path, use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == ("add-matcher", {}, path) + gha.remove_problem_matcher(owner=owner, use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == ( + "remove-matcher", + {"owner": owner}, + "", + ) + gha.remove_problem_matcher(path=path, use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == ("remove-matcher", {}, path) + + +@pytest.mark.parametrize( + "options", + [{}, {"owner": "x", "path": "x"}, {"owner": ""}, {"path": ""}, {"path": "\0"}], +) +def test_invalid_matcher_removal( + capfd: pytest.CaptureFixture[str], options: MatcherOptions +) -> None: + with pytest.raises(ValueError): + gha.remove_problem_matcher(**options) + assert capfd.readouterr().out == "" + + +@pytest.mark.parametrize("value", ["", "\0", 123]) +def test_invalid_matcher_registration( + capfd: pytest.CaptureFixture[str], value: LegacyValue +) -> None: + with pytest.raises((ValueError, TypeError)): + gha.add_problem_matcher(value) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert capfd.readouterr().out == "" + + +@pytest.mark.parametrize( + "value,expected", + [ + ("", False), + ("false", True), + ("0", True), + (" ", True), + ("arbitrary", True), + ], +) +def test_subprocess_environment_flag(value: str, expected: bool) -> None: + environment = dict(os.environ, COMMANDS_USE_SUBPROCESS=value) + result = subprocess.run( + [ + sys.executable, + "-c", + "import warnings; warnings.simplefilter('always'); import github_action_utils as g; print(g.COMMANDS_USE_SUBPROCESS)", + ], + env=environment, + capture_output=True, + check=True, + ) + assert result.stdout.decode().strip() == str(expected) + assert not result.stderr + + +def test_module_flag_assignment_affects_each_emitter( + capfd: pytest.CaptureFixture[str], +) -> None: + captured = io.StringIO() + with ( + mock.patch.object(gha, "COMMANDS_USE_SUBPROCESS", True), + redirect_stdout(captured), + ): + gha.echo("flag text") + gha.notice("flag command") + assert captured.getvalue() == "" + gha._print_command("debug", "already%0Aescaped", escape_message=False) + assert ( + capfd.readouterr().out + == "flag text\n::notice ::flag command\n::debug ::already%0Aescaped\n" + ) + + +def test_subprocess_streams_values_larger_than_argv_limits( + capfd: pytest.CaptureFixture[str], +) -> None: + value = "世界\0" * 100_000 + gha.echo(value, use_subprocess=True) + assert capfd.readouterr().out == value + "\n" + + +@pytest.mark.parametrize( + "helper,command", + [ + ("debug", "debug"), + ("notice", "notice"), + ("warning", "warning"), + ("error", "error"), + ("start_group", "group"), + ("add_mask", "add-mask"), + ], +) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_command_wrappers( + capfd: pytest.CaptureFixture[str], helper: str, command: str, use_subprocess: bool +) -> None: + getattr(gha, helper)("fixture", use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == (command, {}, "fixture") diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 0000000..0e5ccca --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,251 @@ +"""Public API compatibility with version 1.1.0.""" + +import inspect +import os +import warnings +from collections.abc import Generator +from pathlib import Path +from typing import Any, get_args, get_origin, get_type_hints +from unittest import mock + +import pytest + +import github_action_utils as gha + +from ._types import FileWriter, LegacyValue +from .command_helpers import parse_command + +# Original positional/keyword names in order. New options must be optional. +LEGACY_PARAMETERS = { + "set_output": ("name", "value", "use_subprocess"), + "save_state": ("name", "value", "use_subprocess"), + "echo": ("message", "use_subprocess"), + "debug": ("message", "use_subprocess"), + "notice": ( + "message", + "title", + "file", + "col", + "end_column", + "line", + "end_line", + "use_subprocess", + ), + "warning": ( + "message", + "title", + "file", + "col", + "end_column", + "line", + "end_line", + "use_subprocess", + ), + "error": ( + "message", + "title", + "file", + "col", + "end_column", + "line", + "end_line", + "use_subprocess", + ), + "get_state": ("name",), + "get_user_input": ("name",), + "start_group": ("title", "use_subprocess"), + "end_group": ("use_subprocess",), + "group": ("title", "use_subprocess"), + "add_mask": ("value", "use_subprocess"), + "begin_stop_commands": ("token", "use_subprocess"), + "end_stop_commands": ("token", "use_subprocess"), + "stop_commands": ("token", "use_subprocess"), + "set_env": ("name", "value"), + "get_workflow_environment_variables": (), + "get_env": ("name",), + "append_job_summary": ("markdown_text",), + "overwrite_job_summary": ("markdown_text",), + "remove_job_summary": (), + "add_system_path": ("path",), + "event_payload": (), +} + + +@pytest.mark.parametrize("name,parameters", list(LEGACY_PARAMETERS.items())) +def test_original_public_call_shapes(name: str, parameters: tuple[str, ...]) -> None: + function = getattr(gha, name) + signature = inspect.signature(function) + actual = list(signature.parameters.values()) + assert ( + tuple(parameter.name for parameter in actual[: len(parameters)]) == parameters + ) + assert all( + parameter.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in actual[: len(parameters)] + ) + assert all( + parameter.default is not inspect.Parameter.empty + for parameter in actual[len(parameters) :] + ) + values = [object() for _ in parameters] + signature.bind(*values) + signature.bind(**dict(zip(parameters, values, strict=True))) + for parameter in actual[: len(parameters)]: + if parameter.name in ( + "title", + "file", + "col", + "end_column", + "line", + "end_line", + "token", + ): + assert ( + parameter.default is None + or parameter.default is inspect.Parameter.empty + ) + if parameter.name == "use_subprocess": + assert parameter.default is ( + None if name in ("set_output", "save_state") else False + ) + + +def test_legacy_constants_and_wildcard_builtin_vars() -> None: + assert gha.ACTION_ENV_DELIMITER == "__ENV_DELIMITER__" + assert gha.COMMAND_MARKER == "::" + namespace: dict[str, object] = {} + exec( + "from github_action_utils import *\nclass Example: pass\nsample=Example()\nsample.value=1\nresult=vars(sample)", + namespace, + ) + assert namespace["result"] == {"value": 1} + assert "vars" not in namespace + from github_action_utils import vars as environment_vars + + assert environment_vars is gha.vars + + +@pytest.mark.parametrize( + "helper,variable", + [(gha.set_output, "GITHUB_OUTPUT"), (gha.save_state, "GITHUB_STATE")], +) +def test_original_deprecation_warning_filters_still_work( + tmp_path: Path, helper: FileWriter, variable: str +) -> None: + with mock.patch.dict(os.environ, {variable: str(tmp_path / "commands")}): + with warnings.catch_warnings(): + warnings.simplefilter("error") + warnings.filterwarnings( + "ignore", category=DeprecationWarning, module=r"^github_action_utils$" + ) + helper("name", "value", False) + + +def test_original_permissive_return_types_remain_compatible() -> None: + assert get_type_hints(gha.get_env)["return"] is Any + mapping = get_type_hints(gha.get_workflow_environment_variables)["return"] + assert get_origin(mapping) is dict + assert get_args(mapping) == (str, Any) + for function in (gha.group, gha.stop_commands): + result = get_type_hints(function)["return"] + assert get_origin(result) is Generator + assert get_args(result) == (Any, type(None), type(None)) + + +@pytest.mark.parametrize("value", [None, "", "raw legacy value"]) +def test_literal_input_keys_keep_precedence(value: str | None) -> None: + environment = {"INPUT_OLD_NAME": "normalized"} + if value is not None: + environment["INPUT_OLD NAME"] = value + with mock.patch.dict(os.environ, environment, clear=True): + assert gha.get_user_input("old name") == ( + "normalized" if value is None else value + ) + + +def test_empty_input_name_keeps_original_lookup() -> None: + with mock.patch.dict(os.environ, {}, clear=True): + assert gha.get_user_input("") is None + with mock.patch.dict(os.environ, {"INPUT_": "legacy"}, clear=True): + assert gha.get_user_input("") == "legacy" + + +@pytest.mark.parametrize( + "token", + [ + "release.v1", + "name=value", + "semi;colon", + "single:colon", + ":prefix", + "token%0A", + "世界", + ], +) +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_working_custom_stop_tokens_remain_supported( + capfd: pytest.CaptureFixture[str], token: str, use_subprocess: bool +) -> None: + assert gha.begin_stop_commands(token, use_subprocess=use_subprocess) == token + assert parse_command(capfd.readouterr().out) == ("stop-commands", {}, token) + gha.end_stop_commands(token, use_subprocess=use_subprocess) + assert parse_command(capfd.readouterr().out) == (token, {}, "") + + +@pytest.mark.parametrize("value", ["", None, False, 0, [], {}]) +def test_empty_stop_token_still_generates_a_canonical_uuid( + capfd: pytest.CaptureFixture[str], value: LegacyValue +) -> None: + token = gha.begin_stop_commands(value) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert len(token) == 36 and token.count("-") == 4 + assert parse_command(capfd.readouterr().out) == ("stop-commands", {}, token) + + +@pytest.mark.parametrize( + "helper,command", + [ + ("debug", "debug"), + ("notice", "notice"), + ("warning", "warning"), + ("error", "error"), + ("start_group", "group"), + ], +) +@pytest.mark.parametrize("value", [["item", True], {"item": False}]) +def test_logging_keeps_legacy_string_conversion( + capfd: pytest.CaptureFixture[str], helper: str, command: str, value: LegacyValue +) -> None: + getattr(gha, helper)(value) + assert parse_command(capfd.readouterr().out) == (command, {}, str(value)) + + +@pytest.mark.parametrize( + "value", + [ + "", + "/tmp/already-terminated\n", + "first\nsecond\n", + "first\r\nsecond\r\n", + "first\rsecond", + ], +) +def test_legacy_path_records_remain_supported(tmp_path: Path, value: str) -> None: + path = tmp_path / "path-file" + with mock.patch.dict(os.environ, {"GITHUB_PATH": str(path)}): + gha.add_system_path(value) + expected = ( + value if not value or value.endswith(("\r", "\n")) else value + os.linesep + ) + assert path.read_bytes() == expected.encode("utf-8") + + +def test_pathlike_and_legacy_value_conversion(tmp_path: Path) -> None: + path = tmp_path / "path-file" + entry = tmp_path / "future-directory" + value: LegacyValue = 123 + with mock.patch.dict(os.environ, {"GITHUB_PATH": str(path)}): + gha.add_system_path(entry) + gha.add_system_path(value) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert path.read_bytes() == (str(entry) + os.linesep + "123" + os.linesep).encode( + "utf-8" + ) diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..afeb264 --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,124 @@ +"""Read-only environment views must remain live and safe outside Actions.""" + +import os +import subprocess +import sys +from pathlib import Path +from unittest import mock + +import pytest + +import github_action_utils as gha + + +@pytest.mark.parametrize( + "value,expected", + [ + (None, False), + ("", False), + ("false", False), + ("true", True), + ("True", False), + ("0", False), + ], +) +def test_detection_uses_github_specific_signal( + value: str | None, expected: bool +) -> None: + environment = {"CI": "true"} + if value is not None: + environment["GITHUB_ACTIONS"] = value + with mock.patch.dict(os.environ, environment, clear=True): + assert gha.is_github_action() is expected + assert gha.vars.github.actions is expected + + +def test_properties_read_the_current_environment() -> None: + with mock.patch.dict(os.environ, {}, clear=True): + assert not gha.is_github_action() + assert gha.vars.github.actor is None + os.environ["GITHUB_ACTIONS"] = "true" + os.environ["GITHUB_ACTOR"] = "first" + assert gha.is_github_action() + assert str(gha.vars.github.actor) == "first" + os.environ["GITHUB_ACTOR"] = "second" + assert str(gha.vars.github.actor) == "second" + del os.environ["GITHUB_ACTOR"] + assert gha.vars.github.actor is None + + +@pytest.mark.parametrize( + "value,expected", [(None, False), ("false", False), ("true", True), ("0", False)] +) +def test_boolean_flags_are_parsed_explicitly(value: str | None, expected: bool) -> None: + environment = {} if value is None else {"CI": value, "GITHUB_REF_PROTECTED": value} + with mock.patch.dict(os.environ, environment, clear=True): + assert gha.vars.ci is expected + assert gha.vars.github.ref_protected is expected + + +@pytest.mark.parametrize( + "value,expected", [(None, False), ("1", True), ("0", False), ("true", False)] +) +def test_runner_debug_uses_one(value: str | None, expected: bool) -> None: + environment = {} if value is None else {"RUNNER_DEBUG": value} + with mock.patch.dict(os.environ, environment, clear=True): + assert gha.vars.runner.debug is expected + + +@pytest.mark.parametrize( + "namespace,attribute", + [ + (gha.vars, "ci"), + (gha.vars, "github"), + (gha.vars, "runner"), + (gha.vars.github, "actor"), + (gha.vars.runner, "debug"), + (gha.vars.github, "unknown"), + ], +) +def test_environment_objects_are_read_only( + namespace: gha.WorkflowVariables | gha.GitHubVariables | gha.RunnerVariables, + attribute: str, +) -> None: + with pytest.raises(AttributeError): + setattr(namespace, attribute, "replacement") + + +def test_import_does_not_read_an_environment_file(tmp_path: Path) -> None: + environment = dict(os.environ, GITHUB_ENV=str(tmp_path / "missing")) + subprocess.run( + [ + sys.executable, + "-c", + "from github_action_utils import vars; assert vars.github is not None", + ], + env=environment, + check=True, + ) + + +def test_public_exports_are_explicit_and_available() -> None: + assert all(hasattr(gha, name) for name in gha.__all__) + assert {"is_github_action", "WorkflowVariables", "set_output"} <= set(gha.__all__) + assert not {"_build_file_input", "vars"} & set(gha.__all__) + + +@pytest.mark.parametrize("view", [gha.GitHubVariables(), gha.RunnerVariables()]) +def test_every_text_property_is_live( + view: gha.GitHubVariables | gha.RunnerVariables, +) -> None: + prefix = "GITHUB_" if isinstance(view, gha.GitHubVariables) else "RUNNER_" + for name, descriptor in vars(type(view)).items(): + if isinstance(descriptor, property) and name not in ( + "actions", + "ref_protected", + "debug", + ): + key = prefix + name.upper() + with mock.patch.dict(os.environ, {}, clear=True): + assert getattr(view, name) is None, name + os.environ[key] = "first 世界" + assert getattr(view, name) == "first 世界", name + os.environ[key] = "" + assert getattr(view, name) == "", name diff --git a/tests/test_file_commands.py b/tests/test_file_commands.py new file mode 100644 index 0000000..ecad8d6 --- /dev/null +++ b/tests/test_file_commands.py @@ -0,0 +1,290 @@ +"""Behavioral regressions for GitHub Actions file commands and local lookup.""" + +import json +import os +import uuid +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import pytest + +import github_action_utils as gha +from github_action_utils._files import _build_file_input + +from ._types import FileWriter, LegacyValue +from .file_command_helpers import parse_records + + +@pytest.mark.parametrize( + "value", + [ + "hello\nworld", + "100%", + "%0A %0D %25", + "héllo 世界", + "", + " padded value ", + "\nfirst\n\nlast\n", + "carriage\rreturn\r", + "windows\r\nlines\r\n", + "vertical\vtab\u2028separator", + "__ENV_DELIMITER__\nOTHER=value", + 42, + 3.14, + None, + True, + ["first", "second"], + ("first", "second"), + {"enabled": True, "count": 2}, + ], +) +def test_file_values_round_trip( + tmp_path: Path, + value: LegacyValue, +) -> None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(path)}): + gha.set_output("result", value) + expected = ( + json.dumps(value) if isinstance(value, (list, tuple, dict)) else str(value) + ) + assert parse_records(path.read_bytes()) == {"result": expected} + + +def test_delimiter_collision_does_not_inject_records(tmp_path: Path) -> None: + collision = uuid.UUID(int=0) + replacement = uuid.UUID(int=1) + value = f"first\nghadelimiter_{collision.hex}\nINJECTED=value" + with mock.patch( + "github_action_utils.uuid.uuid4", side_effect=[collision, replacement] + ): + data = _build_file_input("result", value) + assert parse_records(data) == {"result": value} + path = tmp_path / "commands" + path.write_bytes(data) + with mock.patch.dict(os.environ, {"GITHUB_ENV": str(path)}): + assert gha.get_workflow_environment_variables() == {"result": value} + + +def test_delimiter_collision_with_name_is_regenerated() -> None: + collision = uuid.UUID(int=0) + replacement = uuid.UUID(int=1) + name = f"ghadelimiter_{collision.hex}" + with mock.patch( + "github_action_utils.uuid.uuid4", side_effect=[collision, replacement] + ): + data = _build_file_input(name, "value") + assert parse_records(data) == {name: "value"} + assert data.split(os.linesep.encode(), 1)[0].endswith(replacement.hex.encode()) + + +def test_file_names_are_not_stdout_properties() -> None: + assert parse_records(_build_file_input("result:part,100%", "value")) == { + "result:part,100%": "value" + } + + +@pytest.mark.parametrize( + "name", ["", "bad=name", "bad< None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {variable: str(path)}): + with pytest.raises(ValueError, match="names"): + writer(name, "value") + assert not path.exists() + path.write_bytes(b"unchanged") + with pytest.raises(ValueError, match="names"): + writer(name, "value") + assert path.read_bytes() == b"unchanged" + + +def test_non_string_name_keeps_legacy_conversion(tmp_path: Path) -> None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(path)}): + gha.set_output(123, "value") # type: ignore[arg-type] # Check legacy name conversion. + assert parse_records(path.read_bytes()) == {"123": "value"} + + +def test_each_record_has_its_own_delimiter() -> None: + first = _build_file_input("result", "first") + second = _build_file_input("result", "second") + assert first.splitlines()[0] != second.splitlines()[0] + assert parse_records(first + second) == {"result": "second"} + + +@pytest.mark.parametrize( + "value", + ["hello\nworld", " padded \n\n", "", "100% %0A", "héllo\r\nworld\r"], +) +def test_environment_reader_preserves_written_values( + tmp_path: Path, value: str +) -> None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {"GITHUB_ENV": str(path)}): + gha.set_env("EXAMPLE", value) + assert gha.get_workflow_environment_variables() == {"EXAMPLE": value} + + +def test_reader_accepts_mixed_records_and_legacy_delimiters(tmp_path: Path) -> None: + path = tmp_path / "commands" + path.write_bytes( + b"\nSIMPLE=first=second< None: + path = tmp_path / "commands" + path.touch() + with mock.patch.dict(os.environ, {"GITHUB_ENV": str(path)}): + assert gha.get_workflow_environment_variables() == {} + + +@pytest.mark.parametrize( + "data", + [ + "not-a-record\n", + "=sensitive-value\n", + "< None: + path = tmp_path / "commands" + path.write_bytes(data.encode("utf-8")) + with mock.patch.dict(os.environ, {"GITHUB_ENV": str(path)}): + with pytest.raises(ValueError, match="Invalid environment record") as error: + gha.get_workflow_environment_variables() + assert "sensitive-value" not in str(error.value) + + +@pytest.mark.parametrize("environment", [{}, {"GITHUB_ENV": ""}]) +def test_missing_local_environment(environment: dict[str, str]) -> None: + with mock.patch.dict(os.environ, environment, clear=True): + assert gha.get_workflow_environment_variables() == {} + assert gha.get_env("MISSING") is None + + +def test_missing_configured_file_is_not_silenced(tmp_path: Path) -> None: + with mock.patch.dict(os.environ, {"GITHUB_ENV": str(tmp_path / "missing")}): + with pytest.raises(FileNotFoundError): + gha.get_workflow_environment_variables() + + +def test_process_environment_precedes_pending_values(tmp_path: Path) -> None: + path = tmp_path / "commands" + path.write_bytes(b"EMPTY=pending\nEXAMPLE=pending\nONLY_FILE=pending\n") + with mock.patch.dict( + os.environ, + {"GITHUB_ENV": str(path), "EMPTY": "", "EXAMPLE": "process"}, + clear=True, + ): + assert gha.get_env("EMPTY") == "" + assert gha.get_env("EXAMPLE") == "process" + assert gha.get_env("ONLY_FILE") == "pending" + assert gha.get_env("MISSING") is None + + +@pytest.mark.parametrize( + "writer,variable", + [(gha.set_output, "GITHUB_OUTPUT"), (gha.save_state, "GITHUB_STATE")], +) +def test_false_subprocess_argument_still_warns( + tmp_path: Path, writer: FileWriter, variable: str +) -> None: + with mock.patch.dict(os.environ, {variable: str(tmp_path / "commands")}): + with pytest.deprecated_call(): + writer("name", "value", use_subprocess=False) + + +@pytest.mark.parametrize("use_subprocess", [False, True]) +def test_context_managers_clean_up_after_errors( + capfd: pytest.CaptureFixture[str], use_subprocess: bool +) -> None: + with pytest.raises(RuntimeError, match="failure"): + with gha.group("example", use_subprocess=use_subprocess): + raise RuntimeError("failure") + assert capfd.readouterr().out.endswith("::endgroup::\n") + with pytest.raises(RuntimeError, match="failure"): + with gha.stop_commands("example-token", use_subprocess=use_subprocess): + raise RuntimeError("failure") + assert capfd.readouterr().out.endswith("::example-token::\n") + + +@pytest.mark.parametrize("platform,newline", [("posix", "\n"), ("nt", "\r\n")]) +def test_native_framing_preserves_carriage_returns( + tmp_path: Path, platform: str, newline: str +) -> None: + path = str(tmp_path / "commands") + expected = "first\r\nsecond\r\n\r" + with mock.patch.dict(os.environ, {"GITHUB_ENV": path}): + with ( + mock.patch("github_action_utils.os.name", platform), + mock.patch("github_action_utils.os.linesep", newline), + ): + gha.set_env("EXAMPLE", expected) + actual = gha.get_workflow_environment_variables() + assert actual == {"EXAMPLE": expected} + + +@pytest.mark.parametrize( + "message", ["-n", "backslash\\n", "héllo 世界", "quotes ' \" $() `literal`"] +) +def test_subprocess_console_output_is_literal( + capfd: pytest.CaptureFixture[str], message: str +) -> None: + gha.echo(message, use_subprocess=True) + assert capfd.readouterr().out == message + "\n" + + +def test_leading_bom_in_a_record_name_is_not_a_file_signature(tmp_path: Path) -> None: + path = tmp_path / "commands" + with mock.patch.dict(os.environ, {"GITHUB_ENV": str(path)}): + gha.set_env("\ufeffNAME", "世界") + assert gha.get_workflow_environment_variables() == {"\ufeffNAME": "世界"} + assert gha.get_env("\ufeffNAME") == "世界" + assert not path.read_bytes().startswith(b"\xef\xbb\xbf") + + +@pytest.mark.parametrize( + "variable,writer", + [ + ("GITHUB_OUTPUT", gha.set_output), + ("GITHUB_STATE", gha.save_state), + ("GITHUB_ENV", gha.set_env), + ], +) +def test_file_writer_destinations( + tmp_path: Path, variable: str, writer: Callable[[str, LegacyValue], None] +) -> None: + path = tmp_path / "commands" + value = "first\n%0A\n世界" + with mock.patch.dict(os.environ, {variable: str(path)}): + writer("result", value) + assert parse_records(path.read_bytes()) == {"result": value} diff --git a/tests/test_file_interoperability.py b/tests/test_file_interoperability.py new file mode 100644 index 0000000..44135cd --- /dev/null +++ b/tests/test_file_interoperability.py @@ -0,0 +1,187 @@ +"""Encoding, record separation, BOM compatibility, and literal summary content.""" + +import io +import json +import os +from pathlib import Path +from unittest import mock + +import pytest + +import github_action_utils as gha + +from ._types import LegacyValue + + +def test_repeated_path_entries_preserve_literal_text(tmp_path: Path) -> None: + path = tmp_path / "paths" + path.write_bytes(("existing" + os.linesep).encode("utf-8")) + entries = ["/tmp/first", "C:\\Program Files\\second", "বাংলা %0A"] + with mock.patch.dict(os.environ, {"GITHUB_PATH": str(path)}): + original = os.environ.get("PATH") + for entry in entries: + gha.add_system_path(entry) + assert os.environ.get("PATH") == original + assert path.read_bytes() == ( + os.linesep.join(["existing"] + entries) + os.linesep + ).encode("utf-8") + + +@pytest.mark.parametrize( + "value", ["", "bad\nentry", "bad\rentry", "bad\0entry", None, 123] +) +def test_invalid_path_entries_leave_file_unchanged( + tmp_path: Path, value: LegacyValue +) -> None: + path = tmp_path / "paths" + with mock.patch.dict(os.environ, {"GITHUB_PATH": str(path)}): + with pytest.raises((TypeError, ValueError)): + gha.add_system_path(value, strict=True) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert not path.exists() + path.write_bytes(b"unchanged") + with pytest.raises((TypeError, ValueError)): + gha.add_system_path(value, strict=True) # type: ignore[arg-type] # Deliberately invalid runtime input. + assert path.read_bytes() == b"unchanged" + + +@pytest.mark.parametrize( + "markdown", ["", "%0A %0D %25 %250A", "# বাংলা\n```\r\n%0A\n```", "a\rb\r\n"] +) +def test_summary_bytes_are_raw_utf8(tmp_path: Path, markdown: str) -> None: + path = tmp_path / "summary" + expected = (markdown + "\n").encode("utf-8") + with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(path)}): + gha.append_job_summary(markdown) + gha.append_job_summary(markdown) + assert path.read_bytes() == expected * 2 + gha.overwrite_job_summary(markdown) + assert path.read_bytes() == expected + gha.remove_job_summary() + assert not path.exists() + gha.append_job_summary(markdown) + assert path.read_bytes() == expected + + +def test_encoding_failure_cannot_truncate_summary(tmp_path: Path) -> None: + path = tmp_path / "summary" + path.write_bytes(b"unchanged") + with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(path)}): + with pytest.raises(UnicodeEncodeError): + gha.overwrite_job_summary("\ud800") + assert path.read_bytes() == b"unchanged" + + +@pytest.mark.parametrize("platform,newline", [("posix", "\n"), ("nt", "\r\n")]) +@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"]) +def test_environment_bom_preserves_records_and_value_characters( + tmp_path: Path, platform: str, newline: str, bom: bytes +) -> None: + path = tmp_path / "env" + records = newline.join( + ["FIRST=value", "BLOCK< None: + path = tmp_path / "env" + path.write_bytes(b"\xef\xbb\xbfBLOCK< None: + paths = { + name: str(tmp_path / name) + for name in [ + "GITHUB_PATH", + "GITHUB_STEP_SUMMARY", + "GITHUB_EVENT_PATH", + "GITHUB_ARTIFACTS_LIST", + ] + } + payload = {"message": "café বাংলা"} + Path(paths["GITHUB_EVENT_PATH"]).write_bytes( + json.dumps(payload, ensure_ascii=False).encode("utf-8") + ) + Path(paths["GITHUB_ARTIFACTS_LIST"]).write_bytes(b'{"version":1,"subjects":[]}') + + original_open = io.open + + def locale_open( + file: str | Path, + mode: str = "r", + *, + encoding: str | None = None, + newline: str | None = None, + ) -> io.TextIOWrapper | io.BufferedWriter: + if mode in ("ab", "wb"): + return original_open(file, "ab" if mode == "ab" else "wb") + return original_open(file, encoding=encoding or "cp1252", newline=newline) + + gha.event_payload.cache_clear() + try: + with ( + mock.patch.dict(os.environ, paths), + mock.patch("builtins.open", locale_open), + ): + gha.add_system_path("বাংলা") + gha.overwrite_job_summary("বাংলা") + assert gha.event_payload() == payload + assert gha.get_workflow_artifacts() == () + finally: + gha.event_payload.cache_clear() + assert Path(paths["GITHUB_PATH"]).read_bytes() == ("বাংলা" + os.linesep).encode( + "utf-8" + ) + assert Path(paths["GITHUB_STEP_SUMMARY"]).read_bytes() == "বাংলা\n".encode() + + +def test_event_payload_cache_contract_is_retained(tmp_path: Path) -> None: + path = tmp_path / "event" + path.write_text('{"message":"first"}', encoding="utf-8") + gha.event_payload.cache_clear() + try: + with mock.patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(path)}): + first = gha.event_payload() + first["message"] = "shared mutation" + path.write_text('{"message":"second"}', encoding="utf-8") + assert gha.event_payload()["message"] == "shared mutation" + gha.event_payload.cache_clear() + assert gha.event_payload()["message"] == "second" + finally: + gha.event_payload.cache_clear() + + +class BytesPath(os.PathLike[bytes]): + def __fspath__(self) -> bytes: + return b"binary-path" + + +@pytest.mark.parametrize("value", [BytesPath(), "bad\0path"]) +def test_invalid_legacy_path_cannot_create_a_record( + tmp_path: Path, value: BytesPath | str +) -> None: + path = tmp_path / "path" + with ( + mock.patch.dict(os.environ, {"GITHUB_PATH": str(path)}), + pytest.raises((TypeError, ValueError)), + ): + gha.add_system_path(value) # type: ignore[arg-type] # Deliberately invalid text path. + assert not path.exists() + + +def test_leading_bom_in_a_path_is_preserved(tmp_path: Path) -> None: + path = tmp_path / "paths" + entry = "\ufeffdirectory 世界" + with mock.patch.dict(os.environ, {"GITHUB_PATH": str(path)}): + gha.add_system_path(entry) + assert path.read_text(encoding="utf-8-sig").splitlines() == ["", entry] diff --git a/tests/test_github_action_utils.py b/tests/test_github_action_utils.py index 2c75404..3a63052 100644 --- a/tests/test_github_action_utils.py +++ b/tests/test_github_action_utils.py @@ -1,6 +1,5 @@ -import json import os -from typing import Any +from pathlib import Path from unittest import mock import pytest @@ -18,71 +17,16 @@ (["debug", "test debug", None], "::debug ::test debug\n"), ], ) -def test__print_command(capfd: Any, test_input: Any, expected: str) -> None: +def test__print_command( + capfd: pytest.CaptureFixture[str], + test_input: tuple[gha_utils.CommandTypes, str, str | None], + expected: str, +) -> None: gha_utils._print_command(*test_input) out, err = capfd.readouterr() assert out == expected -@pytest.mark.parametrize( - "test_input,expected", - [ - ("test", "test"), - (1, "1"), - (3.14, "3.14"), - (["test", "test"], '["test", "test"]'), - ( - ( - "test", - "test", - ), - '["test", "test"]', - ), - ({"test": 3.14, "key": True}, '{"test": 3.14, "key": true}'), - ], -) -def test__make_string(test_input: Any, expected: str) -> None: - assert gha_utils._make_string(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("test", "test"), - ("test\n", "test%0A"), - ("%test", "%25test"), - ("\rtest", "%0Dtest"), - ], -) -def test__escape_data(test_input: str, expected: str) -> None: - assert gha_utils._escape_data(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("test", "test"), - ("test:", "test%3A"), - ("test,", "test%2C"), - ], -) -def test__escape_property(test_input: str, expected: str) -> None: - assert gha_utils._escape_property(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("# test", "# test"), - ("# test%0A", "# test\n"), - ("- %25test", "- %test"), - ("**%0Dtest**", "**\rtest**"), - ], -) -def test__clean_markdown_string(test_input: str, expected: str) -> None: - assert gha_utils._clean_markdown_string(test_input) == expected - - @pytest.mark.parametrize( "test_input,expected", [ @@ -113,17 +57,12 @@ def test__to_camel_case(test_input: str, expected: str) -> None: ({}, ""), ], ) -def test__build_options_string(input_kwargs: Any, expected: str) -> None: +def test__build_options_string( + input_kwargs: dict[str, str | int], expected: str +) -> None: assert gha_utils._build_options_string(**input_kwargs) == expected -def test__build_file_input() -> None: - assert ( - gha_utils._build_file_input("test", "value") - == b"test<<__ENV_DELIMITER__\nvalue\n__ENV_DELIMITER__\n" - ) - - @pytest.mark.parametrize( "test_input,expected", [ @@ -133,158 +72,27 @@ def test__build_file_input() -> None: ("\rtest", "\rtest\n"), ], ) -def test_echo(capfd: Any, test_input: str, expected: str) -> None: - gha_utils.echo(test_input) - out, err = capfd.readouterr() - assert out == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("test", "::debug ::test\n"), - ("test\n", "::debug ::test\n\n"), - ("%test", "::debug ::%test\n"), - ("\rtest", "::debug ::\rtest\n"), - ], -) -def test_debug(capfd: Any, test_input: str, expected: str) -> None: - gha_utils.debug(test_input) - out, err = capfd.readouterr() - assert out == expected - - -@pytest.mark.parametrize( - "input_args,input_kwargs,expected", - [ - ( - ["test notice"], - { - "title": "test \ntitle", - "file": "abc.py", - "col": 1, - "end_column": 2, - "line": 4, - "end_line": 5, - }, - "::notice title=test %0Atitle,file=abc.py,col=1,endColumn=2,line=4,endLine=5::test notice\n", - ), - (["test notice"], {}, "::notice ::test notice\n"), - ], -) -def test_notice( - capfd: Any, - input_args: Any, - input_kwargs: Any, - expected: str, -) -> None: - gha_utils.notice(*input_args, **input_kwargs) - out, err = capfd.readouterr() - assert out == expected - - -@pytest.mark.parametrize( - "input_args,input_kwargs,expected", - [ - ( - ["test warning"], - { - "title": "test \ntitle", - "file": "abc.py", - "col": 1, - "end_column": 2, - "line": 4, - "end_line": 5, - }, - "::warning title=test %0Atitle,file=abc.py,col=1,endColumn=2,line=4,endLine=5::test warning\n", - ), - (["test warning"], {}, "::warning ::test warning\n"), - ], -) -def test_warning( - capfd: Any, - input_args: Any, - input_kwargs: Any, - expected: str, +def test_echo( + capfd: pytest.CaptureFixture[str], test_input: str, expected: str ) -> None: - gha_utils.warning(*input_args, **input_kwargs) - out, err = capfd.readouterr() - assert out == expected - - -@pytest.mark.parametrize( - "input_args,input_kwargs,expected", - [ - ( - ["test error"], - { - "title": "test \ntitle", - "file": "abc.py", - "col": 1, - "end_column": 2, - "line": 4, - "end_line": 5, - }, - "::error title=test %0Atitle,file=abc.py,col=1,endColumn=2,line=4,endLine=5::test error\n", - ), - (["test error"], {}, "::error ::test error\n"), - ], -) -def test_error( - capfd: Any, - input_args: Any, - input_kwargs: Any, - expected: str, -) -> None: - gha_utils.error(*input_args, **input_kwargs) + gha_utils.echo(test_input) out, err = capfd.readouterr() - print(out) assert out == expected -def test_set_output(tmpdir: Any) -> None: - file = tmpdir.join("output_file") - - with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": file.strpath}): - gha_utils.set_output("test", "test") - gha_utils.set_output("another", 2) - - assert file.read() == ( - "test<<__ENV_DELIMITER__\n" - "test\n__ENV_DELIMITER__\n" - "another<<__ENV_DELIMITER__\n2\n" - "__ENV_DELIMITER__\n" - ) - - -def test_set_output_deprecation_warning(tmpdir: Any) -> None: - file = tmpdir.join("output_file") +def test_set_output_deprecation_warning(tmp_path: Path) -> None: + file = tmp_path.joinpath("output_file") with pytest.deprecated_call(): - with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": file.strpath}): + with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(file)}): gha_utils.set_output("test", "test", use_subprocess=True) -def test_save_state(tmpdir: Any) -> None: - file = tmpdir.join("state_file") - - with mock.patch.dict(os.environ, {"GITHUB_STATE": file.strpath}): - gha_utils.save_state("test", "test") - gha_utils.save_state("another", 2) - - assert file.read() == ( - "test<<__ENV_DELIMITER__\n" - "test\n__ENV_DELIMITER__\n" - "another<<__ENV_DELIMITER__\n2\n" - "__ENV_DELIMITER__\n" - ) - - -def test_save_state_deprecation_warning(tmpdir: Any) -> None: - file = tmpdir.join("state_file") +def test_save_state_deprecation_warning(tmp_path: Path) -> None: + file = tmp_path.joinpath("state_file") with pytest.deprecated_call(): - with mock.patch.dict(os.environ, {"GITHUB_STATE": file.strpath}): + with mock.patch.dict(os.environ, {"GITHUB_STATE": str(file)}): gha_utils.save_state("test", "test", use_subprocess=True) @@ -294,33 +102,6 @@ def test_get_state() -> None: assert gha_utils.get_state("abc") is None -@mock.patch.dict(os.environ, {"INPUT_USERNAME": "test", "ANOTHER": "another test"}) -def test_get_user_input() -> None: - assert gha_utils.get_user_input("username") == "test" - assert gha_utils.get_user_input("another") is None - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("test", "::group ::test\n"), - ("test\n", "::group ::test\n\n"), - ("%test", "::group ::%test\n"), - ("\rtest", "::group ::\rtest\n"), - ], -) -def test_start_group(capfd: Any, test_input: str, expected: str) -> None: - gha_utils.start_group(test_input) - out, err = capfd.readouterr() - assert out == expected - - -def test_end_group(capfd: Any) -> None: - gha_utils.end_group() - out, err = capfd.readouterr() - assert out == "::endgroup::\n" - - @pytest.mark.parametrize( "test_input,expected", [ @@ -338,100 +119,18 @@ def test_end_group(capfd: Any) -> None: ({"test": 3.14, "key": True}, '::add-mask ::{"test": 3.14, "key": true}\n'), ], ) -def test_add_mask(capfd: Any, test_input: str, expected: str) -> None: +def test_add_mask( + capfd: pytest.CaptureFixture[str], test_input: str, expected: str +) -> None: gha_utils.add_mask(test_input) out, err = capfd.readouterr() assert out == expected -def test_begin_stop_commands(capfd: Any) -> None: - gha_utils.begin_stop_commands(token="test token") - out, err = capfd.readouterr() - assert out == "::stop-commands ::test token\n" - - -def test_end_stop_commands(capfd: Any) -> None: - gha_utils.end_stop_commands("test token") - out, err = capfd.readouterr() - assert out == "::test token::\n" - - -def test_set_env(tmpdir: Any) -> None: - file = tmpdir.join("envfile") - - with mock.patch.dict(os.environ, {"GITHUB_ENV": file.strpath}): - gha_utils.set_env("test", "test") - gha_utils.set_env("another", 2) - - assert file.read() == ( - "test<<__ENV_DELIMITER__\n" - "test\n__ENV_DELIMITER__\n" - "another<<__ENV_DELIMITER__\n2\n" - "__ENV_DELIMITER__\n" - ) - - -def test_get_workflow_environment_variables(tmpdir: Any) -> None: - file = tmpdir.join("envfile") - - with mock.patch.dict(os.environ, {"GITHUB_ENV": file.strpath}): - gha_utils.set_env("test", "test") - gha_utils.set_env("another", 2) - data = gha_utils.get_workflow_environment_variables() - - assert data == {"test": "test", "another": "2"} +def test_remove_job_summary(tmp_path: Path) -> None: + file = tmp_path.joinpath("summary") - -@mock.patch.dict(os.environ, {"GITHUB_ACTOR": "test", "ANOTHER": "another test"}) -def test_get_env() -> None: - assert gha_utils.get_env("GITHUB_ACTOR") == "test" - assert gha_utils.get_env("ANOTHER") == "another test" - - -def test_append_job_summary(tmpdir: Any) -> None: - file = tmpdir.join("summary") - - with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": file.strpath}): - gha_utils.append_job_summary("# TEST") - gha_utils.append_job_summary("- point 1") - - assert file.read() == "# TEST\n- point 1\n" - - -def test_overwrite_job_summary(tmpdir: Any) -> None: - file = tmpdir.join("summary") - - with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": file.strpath}): - gha_utils.append_job_summary("# TEST") - gha_utils.overwrite_job_summary("- point 1") - - assert file.read() == "- point 1\n" - - -def test_remove_job_summary(tmpdir: Any) -> None: - file = tmpdir.join("summary") - - with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": file.strpath}): + with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(file)}): gha_utils.remove_job_summary() - assert os.path.isfile(file.strpath) is False - - -def test_add_system_path(tmpdir: Any) -> None: - file = tmpdir.join("summary") - - with mock.patch.dict(os.environ, {"GITHUB_PATH": file.strpath}): - gha_utils.add_system_path("usr/a/b") - - assert file.read() == "usr/a/b" - - -def test_event_payload(tmpdir: Any) -> None: - file = tmpdir.join("summary") - payload = {"test": "test"} - file.write(json.dumps(payload)) - - with mock.patch.dict(os.environ, {"GITHUB_EVENT_PATH": file.strpath}): - data = gha_utils.event_payload() - - assert data == payload + assert os.path.isfile(str(file)) is False diff --git a/tests/test_inputs.py b/tests/test_inputs.py new file mode 100644 index 0000000..17dfdb9 --- /dev/null +++ b/tests/test_inputs.py @@ -0,0 +1,109 @@ +"""Public input contracts: missing values, whitespace, and explicit parsing.""" + +import os +from unittest import mock + +import pytest + +import github_action_utils as gha + +from ._types import InputReader + + +def test_raw_input_defaults_and_name_normalization() -> None: + with mock.patch.dict( + os.environ, + {"INPUT_MY-NAME": " value ", "INPUT_OLD_NAME": "legacy", "INPUT_EMPTY": ""}, + clear=True, + ): + assert gha.get_user_input("my-name") == " value " + assert gha.get_user_input("my-name", trim_whitespace=True) == "value" + assert gha.get_user_input("old name") == "legacy" + assert gha.get_user_input("empty") == "" + assert gha.get_user_input("missing") is None + + +@pytest.mark.parametrize("value", [None, "", " \r\n "]) +@pytest.mark.parametrize( + "helper", [gha.get_user_input, gha.get_boolean_input, gha.get_multiline_input] +) +def test_required_inputs(value: str | None, helper: InputReader) -> None: + environment = {} if value is None else {"INPUT_NAME": value} + with mock.patch.dict(os.environ, environment, clear=True): + with pytest.raises(ValueError, match="input"): + helper("name", required=True, trim_whitespace=True) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("true", True), + ("True", True), + ("TRUE", True), + ("false", False), + ("False", False), + ("FALSE", False), + (" \ttrue\n", True), + (" false ", False), + ], +) +def test_boolean_inputs(value: str, expected: bool) -> None: + with mock.patch.dict(os.environ, {"INPUT_NAME": value}, clear=True): + assert gha.get_boolean_input("name") is expected + + +@pytest.mark.parametrize( + "value", ["", "0", "1", "yes", "no", "tRuE", "sensitive-value"] +) +def test_invalid_boolean_does_not_expose_value(value: str) -> None: + with mock.patch.dict(os.environ, {"INPUT_NAME": value}, clear=True): + with pytest.raises(ValueError, match="name") as error: + gha.get_boolean_input("name") + assert str(error.value) == "Input must be a YAML 1.2 boolean: name" + + +def test_boolean_whitespace_policy() -> None: + with mock.patch.dict(os.environ, {"INPUT_NAME": " true "}, clear=True): + with pytest.raises(ValueError): + gha.get_boolean_input("name", trim_whitespace=False) + + +def test_optional_missing_typed_inputs() -> None: + with mock.patch.dict(os.environ, {}, clear=True): + assert gha.get_boolean_input("name") is None + assert gha.get_multiline_input("name") == [] + + +@pytest.mark.parametrize( + "trim,keep,expected", + [ + (True, False, ["first", "second"]), + (True, True, ["", "first", "", "second", ""]), + (False, False, [" first ", " ", "second"]), + (False, True, ["", " first ", " ", "second", ""]), + ], +) +def test_multiline_input_policies(trim: bool, keep: bool, expected: list[str]) -> None: + with mock.patch.dict( + os.environ, {"INPUT_NAME": "\r\n first \r\n \r\nsecond\r\n"}, clear=True + ): + assert ( + gha.get_multiline_input("name", trim_whitespace=trim, keep_empty_lines=keep) + == expected + ) + + +def test_empty_multiline_and_required_retained_lines() -> None: + with mock.patch.dict(os.environ, {"INPUT_NAME": ""}, clear=True): + assert gha.get_multiline_input("name") == [] + assert gha.get_multiline_input("name", keep_empty_lines=True) == [""] + with mock.patch.dict(os.environ, {"INPUT_NAME": "\n \n"}, clear=True): + with pytest.raises(ValueError): + gha.get_multiline_input("name", required=True, keep_empty_lines=True) + assert gha.get_user_input("name", required=True) == "\n \n" + + +@pytest.mark.parametrize("name", [123, None]) +def test_invalid_input_names(name: int | None) -> None: + with pytest.raises((TypeError, ValueError)): + gha.get_user_input(name) # type: ignore[arg-type] # Deliberately invalid runtime input. diff --git a/tests/typing/invalid.txt b/tests/typing/invalid.txt new file mode 100644 index 0000000..2f7f15b --- /dev/null +++ b/tests/typing/invalid.txt @@ -0,0 +1,20 @@ +from github_action_utils import ArtifactSubject, add_problem_matcher, add_system_path, declare_file_artifact, declare_oci_artifact, fail, get_boolean_input, get_multiline_input, get_user_input, get_workflow_artifacts, remove_problem_matcher, set_command_echo, set_output, vars + +set_output(123, "value") # error +number: int = vars.github.run_number # error +vars.github.actor = "replacement" # error +vars.runner.debug = False # error +set_command_echo("false") # error +get_user_input("name", required="yes") # error +get_boolean_input("name", trim_whitespace="yes") # error +flag: bool = get_boolean_input("optional") # error +lines: int = get_multiline_input("lines") # error +declare_file_artifact(123) # error +declare_oci_artifact(None) # error +add_problem_matcher(123) # error +remove_problem_matcher(owner=123) # error +subject: ArtifactSubject = get_workflow_artifacts() # error +artifact = ArtifactSubject("name", "digest", "file") +artifact.name = "replacement" # error +add_system_path("path", strict="yes") # error +fail(123) # error diff --git a/tests/typing/valid.txt b/tests/typing/valid.txt new file mode 100644 index 0000000..e313826 --- /dev/null +++ b/tests/typing/valid.txt @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import Any, Dict, List, NoReturn, Optional, Tuple + +from github_action_utils import ( + ArtifactSubject, + GitHubVariables, + RunnerVariables, + WorkflowVariables, + add_problem_matcher, + add_system_path, + declare_file_artifact, + declare_oci_artifact, + fail, + get_boolean_input, + get_env, + get_multiline_input, + get_user_input, + get_workflow_artifacts, + get_workflow_environment_variables, + group, + is_github_action, + remove_problem_matcher, + save_state, + set_command_echo, + set_env, + set_output, + vars, +) + +workflow: WorkflowVariables = vars +github: GitHubVariables = vars.github +runner: RunnerVariables = vars.runner +actor: Optional[str] = github.actor +path: Optional[str] = github.event_path +number: Optional[str] = github.run_number +detected: bool = is_github_action() +debugging: bool = runner.debug +value: Optional[str] = get_env("EXAMPLE") +pending: Dict[str, str] = get_workflow_environment_variables() +legacy_mapping: Dict[str, object] = get_workflow_environment_variables() +legacy_path: str = get_env("PATH") +payload: Dict[str, Any] = {"enabled": True} +with group("example"): + set_output("result", payload) + set_env("EXAMPLE", "value") + save_state("cleanup", ["first", "second"]) + +raw: Optional[str] = get_user_input("name", required=True, trim_whitespace=True) +flag: Optional[bool] = get_boolean_input("flag") +lines: List[str] = get_multiline_input("lines", keep_empty_lines=True) +subjects: Tuple[ArtifactSubject, ...] = get_workflow_artifacts() +subject: ArtifactSubject = ArtifactSubject("name", "sha256:digest", "file") +subject_name: str = subject.name +set_command_echo(False) +add_problem_matcher("matcher.json") +remove_problem_matcher(owner="matcher-owner") +remove_problem_matcher(path="matcher.json") +declare_file_artifact("dist/app") +declare_oci_artifact("image@sha256:" + "a" * 64) +add_system_path(Path("dist/bin")) +add_system_path("dist/bin", strict=True) + + +def abort() -> NoReturn: + fail("failed") + + +class ArbitraryLegacyValue: + def __str__(self) -> str: + return "custom value" + + +custom_value = ArbitraryLegacyValue() +set_output("custom", custom_value) +set_env("CUSTOM", custom_value) +save_state("custom", custom_value) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index a0ee004..0000000 --- a/tox.ini +++ /dev/null @@ -1,21 +0,0 @@ -[tox] -envlist = py36,py37,py38,py39,py310,py311,black-formatter,isort,mypy - -[testenv] -commands = pytest --cov=. --cov-report xml {posargs} -deps = -rrequirements-dev.txt - -[testenv:black-formatter] -basepython = python3.10 -commands = black --check --diff . -deps = black - -[testenv:isort] -basepython = python3.10 -commands = isort --profile black --check-only --diff . -deps = isort - -[testenv:mypy] -basepython = python3.10 -commands = mypy --strict --install-types --non-interactive . -deps = -rrequirements-dev.txt diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..77e0f04 --- /dev/null +++ b/uv.lock @@ -0,0 +1,785 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[manifest] +build-constraints = [{ name = "uv-build", specifier = "==0.13.1" }] + +[[package]] +name = "ast-serialize" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, + { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/0e30f9eddf7d44ccf9d25979030754fd0457af61fa4279acb8c293a04f97/ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5", size = 897546, upload-time = "2026-10-17T00:03:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, +] + +[[package]] +name = "cachetools" +version = "7.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/88/27fe0785c7a9aa285c753db0b4ea967adbf76128fede66379c35a4b8d054/cachetools-7.2.3.tar.gz", hash = "sha256:e6fafd06de4a5bd915ff7d247588ea497e28c9a568c62db8678d9a5f11515a9a", size = 43727, upload-time = "2026-10-17T11:41:25.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/dd/044062b61d94c59c29ad0da6918e6a21106f5b115096e9c87081cdd96167/cachetools-7.2.3-py3-none-any.whl", hash = "sha256:85ef9d2703918bedf7f88caa0b06bb3bb7a71360eb0b05c20796b89fede6e30c", size = 18352, upload-time = "2026-10-17T11:41:24.161Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz", hash = "sha256:ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d", size = 621304, upload-time = "2026-10-16T13:56:34.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl", hash = "sha256:cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638", size = 472099, upload-time = "2026-10-16T13:56:32.053Z" }, +] + +[[package]] +name = "filelock" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" }, +] + +[[package]] +name = "github-action-utils" +version = "2.0.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] +lint = [ + { name = "ruff" }, +] +test = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] +tox = [ + { name = "tox" }, + { name = "tox-uv" }, +] +type = [ + { name = "mypy" }, + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=7.16.2" }, + { name = "mypy", specifier = ">=2.4.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.17.1" }, + { name = "tox", specifier = ">=4.67.1" }, + { name = "tox-uv", specifier = ">=1.37.0" }, +] +lint = [{ name = "ruff", specifier = ">=0.17.1" }] +test = [ + { name = "coverage", specifier = ">=7.16.2" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, +] +tox = [ + { name = "tox", specifier = ">=4.67.1" }, + { name = "tox-uv", specifier = ">=1.37.0" }, +] +type = [ + { name = "mypy", specifier = ">=2.4.0" }, + { name = "pytest", specifier = ">=9.1.1" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c6/04bb77bccfa36aabc783a31e335b5441829f5ec9ff16e313b5b7022d81a8/pyproject_api-1.12.0.tar.gz", hash = "sha256:2e83226079dc446a8e2357def7622c01c733284e39afc8e332d9f722fe0c2beb", size = 28058, upload-time = "2026-10-18T07:00:59.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/b6/ce66a85089985da9b15b0e0e0d668c4b26c994f619e28a11d609454f3d70/pyproject_api-1.12.0-py3-none-any.whl", hash = "sha256:1d3cbf37ac4c3a40940e09cbd4374608d25cdd89e6019f7e06fc285afd3ab467", size = 14055, upload-time = "2026-10-18T07:00:58.102Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/ee/cf608372c88ce2c136ee13c69ab6f667dbc0d0c1176af188b4ddc8853e66/python_discovery-1.6.4.tar.gz", hash = "sha256:bc70d31f0ec5de11eda13d4d64c16e24b3506a65984684f2b6c98cb83b2ea9df", size = 89883, upload-time = "2026-10-19T01:45:14.855Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/91/1aa70d3dfcab0dbcf88a2cbc3bce7f7c69562287a90216e617eaa4937e25/python_discovery-1.6.4-py3-none-any.whl", hash = "sha256:530179cc51a1816d512b15635b3dc931295ae12efb183783ad31190a5d425d2b", size = 39385, upload-time = "2026-10-19T01:45:13.668Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tox" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli-w" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/6e/677274ae411fa13f5c2ea5c633a6abfa0886e949bd2ef53c7e564a0b0b2e/tox-4.67.1.tar.gz", hash = "sha256:a68602f64421b199016127364ee47347b958391d19c2b2e53fe95673a8fe98e9", size = 347211, upload-time = "2026-10-18T07:34:04.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ac/cfba9de0f43b58d199f47c441efd1b670b55c480f3ec18bc1345f752c3d8/tox-4.67.1-py3-none-any.whl", hash = "sha256:23d614cb703e1f7bd39e00c046d4c261b63fb9986e185134c6be2f93531d9726", size = 238078, upload-time = "2026-10-18T07:34:02.728Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/65/7cf72148bff33fd0a572e620c7b1573d6a5e4812e9207878d4e29e4a938a/tox_uv-1.37.0-py3-none-any.whl", hash = "sha256:f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4", size = 6564, upload-time = "2026-10-11T00:33:49.804Z" }, +] + +[[package]] +name = "tox-uv-bare" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tox" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/27/203b4dbe73cc6dadc2480e319631310981540dabe95893de983106d7b103/tox_uv_bare-1.37.0.tar.gz", hash = "sha256:97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9", size = 32961, upload-time = "2026-10-11T00:33:52.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/4c/9596668bb448b868c684a3df9190103e9bc2f7de13e646628ac914f928be/tox_uv_bare-1.37.0-py3-none-any.whl", hash = "sha256:a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13", size = 22322, upload-time = "2026-10-11T00:33:51.338Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uv" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/10/81f499de381ac81e09961e1c19255c5cf49563ac418464def4190b6c7400/uv-0.13.1.tar.gz", hash = "sha256:6df50a86b09be66a413f8f1b16258d585d4dd1cbff591077ae1e6f65c26de2eb", size = 7434239, upload-time = "2026-10-14T05:13:35.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/2b/79ed9d3a78a43e83ef685bf1e1c84f6c63fe25a0e765b24a169727dd87f1/uv-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:23dfbdcfd9ff0dc7fefb1b91a6dbca2d492267976becd7c46501f89fc353fdc7", size = 18575444, upload-time = "2026-10-14T05:12:50.786Z" }, + { url = "https://files.pythonhosted.org/packages/db/3d/6ac43d7a15be1d589aae1e15289507f5ccebeac14f28c37882058f2474a3/uv-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e60223e3e9636a3734d6c11b3552aeed987c40f1ca2497a546af58f2f883d56", size = 17663254, upload-time = "2026-10-14T05:12:53.604Z" }, + { url = "https://files.pythonhosted.org/packages/3b/31/fad7fd2ac81954d02d33765bf8535eb320fcc2d71f2603a5c1ba0b13b8ef/uv-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cfa719fbbd23f26713394dea2692b7f146aa1b5713ce5e776e878701214ea52c", size = 14391237, upload-time = "2026-10-14T05:12:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6a/ff4c51a245345a167eed090e8cea27d5979e8b665c97617805bb8748966f/uv-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f2518450da2f6d0ea8a943c9f402abda63d5a66acc9999216c2792115b922ba4", size = 18047204, upload-time = "2026-10-14T05:12:58.371Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/268c9c17d9a808f41b71241f9d61030c4f262213815eeaa329bae1c76c3f/uv-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:29b9d0dbe9c44d3dcea8af9e7b4ea2444c7a0c9ec0a11e2f2d357fb3afa2c05b", size = 18122326, upload-time = "2026-10-14T05:13:00.66Z" }, + { url = "https://files.pythonhosted.org/packages/44/88/33b72d5c4aed94a504fe92b7312f76c54e16a00b695045053f7a72953815/uv-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7161cfb854f7353fd351e5e735e3659ed48412352b1edd4d93212c82ec0cbdf0", size = 18139075, upload-time = "2026-10-14T05:13:03.27Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/866ca1f07f899f007fcc006663d341157c5dbdae7271e5004c5a120022ee/uv-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aaec25ff6900ee30a29a2e7a3335aff19a2305c60488bb4e0f29cf1d6ada9f", size = 18840481, upload-time = "2026-10-14T05:13:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a9/928d7435a00d1171f4557212704d71d709cf4b6024f2ee6b0f582251c761/uv-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81345a4733737a378db988aea48fd5d1c76c092cf231e6ddfdb6db225b4b21e5", size = 20106013, upload-time = "2026-10-14T05:13:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/3c/70/ac84eee888c4a11e12c361c10fdf3f726ef6f8f1178302c80a9dee5189a3/uv-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f9991b8eedca00b6aeb9a8fc6b225f6afa2e24ec9de6b6ef72845f3a545639c", size = 19840034, upload-time = "2026-10-14T05:13:10.613Z" }, + { url = "https://files.pythonhosted.org/packages/14/0b/6602a613533602e7a083e908c4375f9b0e78a94ba2e21c29b21c41a0a611/uv-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:696dcf6d807d85da978f4db8472da81dccc1254b18b52cbd1367c4d923c421e0", size = 17279385, upload-time = "2026-10-14T05:13:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/09/f6/52e2136653a1f3a4dc489da83444f062ed588570cc3e5242fda50996951a/uv-0.13.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ccbe233d4bce39865af7c30ec1fb28b0cc353f9d51711629f54fca71b8db7382", size = 16577060, upload-time = "2026-10-14T05:13:15.567Z" }, + { url = "https://files.pythonhosted.org/packages/df/7c/a2f4ff386673a4bed33be69fdb857341eaa6b9f31eed3005ffc15df94d7f/uv-0.13.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:ec1bdc90e2cccc32f51dcea2fa4ec9082b321c77f3fe51a0407d40e8a44f540f", size = 18417071, upload-time = "2026-10-14T05:13:18.422Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/a2d375f8fcb453e75caf6d1503f7e149e76a3403091a2a77ada4d42482c1/uv-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b5e1b930cbb463c0d8f6b925743ddbae31277841f16c324d14e1a815b0c3b824", size = 18329037, upload-time = "2026-10-14T05:13:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/0c/51/070341c84c141e73933f6087dcc9a5fd276cb034bdc0316f2d2dffd0cd44/uv-0.13.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4af921c96f5f85c456c7fa93a9bc13514a2a5bbab11ce63dbf490d6e5bfef503", size = 18130710, upload-time = "2026-10-14T05:13:23.008Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0d/529a302582685e77e5e2d16de554e7c230b45c6175b0b0f234af9795d981/uv-0.13.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6f1f391756385e2cd1521fe4a198e0ca764ef498dd677cb214b95a69a7ce3608", size = 19307551, upload-time = "2026-10-14T05:13:25.744Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/c81ec77c0eb772ec4fc515e733625969c64df55ee2f7b6b3fb1e07c2ee26/uv-0.13.1-py3-none-win32.whl", hash = "sha256:1f6ae6f843ee01a1bb69e2e7a881a452890eb9acc686a8c37e8e95093a58f68e", size = 16434446, upload-time = "2026-10-14T05:13:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/d23278bb327d2a75dc1d033c81b7fe23bbb28e363e02e7f306abd047924a/uv-0.13.1-py3-none-win_amd64.whl", hash = "sha256:4f693cd4feb52f08f30474c4612f24be2050472609be65b9960b635f286da284", size = 15404994, upload-time = "2026-10-14T05:13:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e0/f27f161ee2ad242cb714fe7185f63cc2a88b83c4d8b375ca8c580f9f23b7/uv-0.13.1-py3-none-win_arm64.whl", hash = "sha256:b173413a5dc18437b2d28266a02be636f35ac7e97741773aed163ed2143abfaf", size = 15899284, upload-time = "2026-10-14T05:13:32.891Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.14.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/be/4d1d59d309db76b5808fcfd78bd5996f4e7241366691c38fc331a3136398/virtualenv-21.14.11.tar.gz", hash = "sha256:593052d8477ea8c55a842387ce111bd08c72618f0c1133c302cafe335e570a57", size = 5503091, upload-time = "2026-10-18T07:06:34.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/6b/d85da8982b41190eafcb9df91dff9d83fc6989e98af4b5be4c8f85510c95/virtualenv-21.14.11-py3-none-any.whl", hash = "sha256:b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454", size = 5495268, upload-time = "2026-10-18T07:06:32.719Z" }, +] From df4a87a587c5418562898aea63664c16d599687d Mon Sep 17 00:00:00 2001 From: saadmk11 Date: Tue, 20 Oct 2026 12:23:51 +0600 Subject: [PATCH 2/3] Restore README screenshots --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index bf48bfe..cb42af5 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ jobs: The examples below use `import github_action_utils as gha`. +![GitHub Actions workflow run](https://user-images.githubusercontent.com/24854406/180658147-9cfddcfe-ef51-40bc-8e0f-1949482e6a09.png) + ## Logging | Function | Result | @@ -97,6 +99,8 @@ Use columns for a single line; GitHub drops column properties for annotations spanning multiple lines. Pass normal text—command helpers handle escaping. See the [workflow command reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands). +![Log annotations associated with a source file](https://user-images.githubusercontent.com/24854406/180003164-12735d03-a452-4bef-96a6-f1dc4298756e.png) + ## Groups and command control ### Group related logs @@ -110,6 +114,8 @@ with gha.group("Run checks"): The group closes even if the body raises. For manual control, use `start_group(title)` and `end_group()`. +![Grouped logs in GitHub Actions](https://user-images.githubusercontent.com/24854406/180003937-5839856e-09f9-47e7-8b62-f5126a78cad6.png) + ### Mask sensitive values ```python @@ -239,6 +245,8 @@ gha.append_job_summary( Markdown is written as raw UTF-8. Replacement and deletion affect the current step's summary. [GitHub summary documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#adding-a-job-summary) +![GitHub Actions annotations and rendered job summary](https://user-images.githubusercontent.com/24854406/180003153-99434824-d08c-4a54-9a89-4c6163def1b2.png) + ## Inputs These helpers read `INPUT_*` environment variables: From 7bc239fa4737a89e1adb08c05aeefcd2a3438658 Mon Sep 17 00:00:00 2001 From: saadmk11 Date: Tue, 20 Oct 2026 12:31:23 +0600 Subject: [PATCH 3/3] Fix publish workflow file glob safety --- .github/workflows/pypi_publish.yaml | 2 +- CONTRIBUTING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi_publish.yaml b/.github/workflows/pypi_publish.yaml index b6c2f9c..34cf9ad 100644 --- a/.github/workflows/pypi_publish.yaml +++ b/.github/workflows/pypi_publish.yaml @@ -85,7 +85,7 @@ jobs: run: | cd dist sha256sum --check SHA256SUMS - uv publish --trusted-publishing always *.whl *.tar.gz + uv publish --trusted-publishing always ./*.whl ./*.tar.gz verify-release: needs: [release, publish] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f18a99..04a0de5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ Linux, Windows, and macOS. Tox tests an installed wheel. Settings are in Test observable behavior and add regression tests for bug fixes. Package code requires 100% statement and branch coverage. Source, tests, and scripts must pass -strict mypy. Check workflow changes with actionlint 1.7.12. +strict mypy. Check workflow changes with actionlint 1.7.12 and ShellCheck on PATH. The `package` check verifies source and wheel installs, installed typing, and upgrades from the published 1.1.0 package. It needs network access. To use a local