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..34cf9ad 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..04a0de5
--- /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 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
+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..cb42af5 100644
--- a/README.md
+++ b/README.md
@@ -1,469 +1,459 @@
# GitHub Action Utils
-[](https://github.com/saadmk11/github-action-utils/releases/latest)
-
-
-[](https://github.com/saadmk11/github-action-utils/blob/main/LICENSE)
-[](https://github.com/saadmk11/github-action-utils/stargazers)
+[](https://pypi.org/project/github-action-utils/)
+[](https://github.com/saadmk11/github-action-utils/actions/workflows/test.yaml)
+[](LICENSE)
-
+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
-
-
-#### Log Annotations and Build Summery
-
-
-#### Log Annotations Associated with a File
-
-
-
-## Available Functions
+The examples below use `import github_action_utils as gha`.
-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.
+## Logging
-### **`echo(message, use_subprocess=False)`**
+| 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`. |
-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)`**
-
-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)
+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).
-**example:**
+
-```python
->> from github_action_utils import debug
+## Groups and command control
->> debug("Hello World")
+### Group related logs
-# Output:
-# ::debug ::Hello World
+```python
+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)`**
+The group closes even if the body raises. For manual control, use
+`start_group(title)` and `end_group()`.
-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)
+
-**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)`**
+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)
-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)
+
-**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)`**
-
-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)
+## Environment and events
-**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)`**
+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.
-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)
-
-**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
+payload = gha.event_payload()
+print(payload.get("action"))
->> get_env("my_env")
->> get_env("GITHUB_API_URL")
-
-# 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()`**
+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)
-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)
+## Artifact identities
-**example:**
+> [!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.
-```python
->> from github_action_utils import 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"`). |
->> remove_job_summary()
-```
+On a supported runner:
-### **`add_system_path(path)`**
-
-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" },
+]