diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4000626 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: Bug report +description: Something behaves differently from what the documentation says. +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened? + description: What you did, what you expected, and what you got instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: The smallest code that shows the problem, including the decorator and how it is called (sync, async, method). + render: python + validations: + required: true + - type: input + id: version + attributes: + label: python-wrap version + description: "Run: python -c 'import python_wrap; print(python_wrap.__version__)'" + validations: + required: true + - type: input + id: python + attributes: + label: Python version and operating system + placeholder: "3.13.1 on Windows 11" + validations: + required: true + - type: textarea + id: traceback + attributes: + label: Traceback or log output + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5ef7d5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/LitePy/PythonWrap/security/advisories/new + about: Please report security issues privately, not in a public issue. + - name: Documentation + url: https://python-wrap.readthedocs.io + about: Check the docs first; most questions are answered there. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..328abed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Suggest a new decorator or an option on an existing one. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: The situation first, not the solution. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What would you like to write? + description: A sketch of the API as you would want to call it. + render: python + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other libraries or hand-written code you tried, and why they fall short. + - type: markdown + attributes: + value: | + python-wrap has no runtime dependencies and supports sync and async code, + so proposals are weighed against both. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0bfedde --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## What and why + + + +## Checklist + +- [ ] Tests added or updated (sync and async paths; use `fake_clock` rather than real sleeps) +- [ ] `uv run pytest` passes +- [ ] `uv run ruff check src tests` and `uv run ruff format --check src tests` pass +- [ ] `uv run mypy` passes +- [ ] Public functions have Google-style docstrings +- [ ] `CHANGELOG.md` updated under `Unreleased` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fb96e6e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +# Keeps the pinned GitHub Actions and the lockfile moving, in as few pull requests as +# possible. Grouped, weekly: they are reviewed together and it keeps the queue readable. +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: "ci" + + # Refreshes uv.lock, which pins the dev and docs tooling (the package itself has no + # runtime dependencies). + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + groups: + dev-tooling: + patterns: ["*"] + commit-message: + prefix: "deps" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7fd8cbb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +# Continuous integration. +# +# Four jobs: lint (seconds), the test matrix, mypy, and a packaging check. +# +# python-wrap is pure Python with no dependencies, so the matrix is wide rather than +# deep: every interpreter the classifiers name, on all three operating systems. Time and +# threading behave differently on Windows (coarser clocks) and macOS (spawn, not fork), +# and a decorator library that schedules, throttles and times things is exactly where +# that shows up. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# A force-push should not leave the previous run burning minutes on a dead commit. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_COLOR: "1" + +jobs: + lint: + name: ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + + - run: uv sync --group dev --frozen + + - name: ruff check + run: uv run --no-sync ruff check src tests + + - name: ruff format + run: uv run --no-sync ruff format --check --diff src tests + + test: + name: py${{ matrix.python-version }} · ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + # One red cell should not hide the other fourteen. + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: ${{ matrix.python-version }} + + - run: uv sync --group dev --frozen + + - name: pytest + run: uv run --no-sync pytest --cov --cov-report=term-missing --cov-report=xml + + # One coverage report is enough; it is the same code on every cell. Keeping it as an + # artifact (rather than a third-party upload) needs no token and cannot fail the + # build for a reason unrelated to the code. + - name: Upload the coverage report + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage.xml + if-no-files-found: warn + + typecheck: + name: mypy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + + - run: uv sync --group dev --frozen + + # Strict mode, configured in pyproject.toml; `files` already points at src/. + - name: mypy + run: uv run --no-sync mypy + + build: + name: build & wheel contents + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + + - name: Build the sdist and wheel + run: uv build + + - name: twine check + run: uvx twine check --strict dist/* + + # The package ships `py.typed` so that type checkers use its annotations. A wheel + # that drops it still imports and still passes twine, and silently turns every + # downstream user's types into `Any`. + - name: The wheel ships py.typed + run: | + python3 -m zipfile -l dist/*.whl | tee wheel-listing.txt + grep -q 'python_wrap/py.typed' wheel-listing.txt + + # Install the built wheel, not the source tree, into an empty venv and run from a + # scratch directory so `src/` cannot stand in for anything the wheel left out. + - name: Install the wheel into a clean environment + run: | + uv venv "$RUNNER_TEMP/smoke" --python 3.13 + uv pip install --python "$RUNNER_TEMP/smoke" dist/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/smoke/bin/python" -c "import python_wrap; print(python_wrap.__version__)" + + - name: Upload the distributions + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + if-no-files-found: error diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..818a72e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +# Build check for the documentation site. +# +# Read the Docs does the hosting (see .readthedocs.yaml). This workflow only catches +# breakage before it gets there: `--strict` turns a broken link, a page missing from the +# nav or a docstring mkdocstrings cannot render into a failed check on the pull request. +name: Docs + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_COLOR: "1" + +jobs: + build: + name: mkdocs build --strict + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + + # The package itself is installed too, because mkdocstrings reads the docstrings + # from it. The dev group is included because some examples are pytest tests. + - run: uv sync --frozen --no-default-groups --group docs --group dev + + - name: Build the site + run: uv run --no-sync mkdocs build --strict + env: + NO_MKDOCS_2_WARNING: "1" + + # Every ```python block in docs/ is executed, so an example that drifts from the + # real API fails review instead of misleading a reader. + - name: Run the documentation examples + run: uv run --no-sync python scripts/check_docs_examples.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 9ffd6a7..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [created] - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.8' - - - name: Upgrade pip and setuptools - run: | - python -m pip install --upgrade pip setuptools - - - name: Install pipx - run: | - python -m pip install --upgrade pipx - - - name: Install Poetry using pipx - run: | - pipx install poetry - - - name: Configure Poetry - run: | - poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} - - - name: Regenerate lock file if needed - run: | - set -e - if poetry check; then - echo "Dependencies are consistent" - else - echo "Regenerating lock file..." - poetry lock - fi - - - name: Install dependencies - run: poetry install --only main - - - name: Install dependencies (including dev) - run: poetry install --with dev - - - name: Run tests - run: poetry run pytest - - - name: Build and publish - env: - POETRY_HTTP_BASIC_PYPI_USERNAME: __token__ - POETRY_HTTP_BASIC_PYPI_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - poetry build - poetry publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2860073 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,184 @@ +# Publish on a version tag, using PyPI Trusted Publishing. +# +# One-time setup (a publisher registered on PyPI and TestPyPI, and two GitHub +# environments) is described in RELEASING.md. It must exist before the first tag: a tag +# pushed against an unregistered publisher fails at the upload, and a version number on +# PyPI can never be reused. +# +# Routing: a final version (0.2.0, 0.2.0.post1) goes to PyPI. Anything else (a, b, rc, +# .dev) goes to TestPyPI only, so a release candidate can be rehearsed without burning a +# real version number. +# +# No API token is involved anywhere in this file, and none should be added. Trusted +# Publishing mints a short-lived credential scoped to this workflow and environment. +name: Release + +on: + push: + tags: + - "v*" + # Lets the build-and-verify half be exercised without cutting a tag. + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build and verify the distributions + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + + # Read the version from the file rather than importing the package: the build + # backend reads it from the same place, so this is what would actually be + # published. A tag that disagrees with it is how a release goes out under the + # wrong number, and that is unfixable afterwards. + - name: The tag must match the packaged version + id: meta + run: | + packaged=$(sed -nE 's/^__version__ *= *"([^"]+)".*/\1/p' src/python_wrap/__init__.py | head -n1) + if [ -z "$packaged" ]; then + echo "::error::could not read __version__ from src/python_wrap/__init__.py" + exit 1 + fi + echo "version=$packaged" >> "$GITHUB_OUTPUT" + if printf '%s' "$packaged" | grep -Eq '^[0-9]+(\.[0-9]+)*(\.post[0-9]+)?$'; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + tag="${GITHUB_REF_NAME#v}" + echo "tag=$tag packaged=$packaged" + if [ "$tag" != "$packaged" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match python_wrap.__version__ ($packaged)" + exit 1 + fi + fi + + - name: Build + run: uv build + + - name: twine check + run: uvx twine check --strict dist/* + + - name: Smoke-test the wheel in a clean environment + run: | + python3 -m zipfile -l dist/*.whl | grep -q 'python_wrap/py.typed' + uv venv "$RUNNER_TEMP/smoke" --python 3.13 + uv pip install --python "$RUNNER_TEMP/smoke" dist/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/smoke/bin/python" -c "import python_wrap; print(python_wrap.__version__)" + + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish-testpypi: + name: Publish to TestPyPI + needs: build + if: github.ref_type == 'tag' && needs.build.outputs.prerelease == 'true' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/python-wrap + permissions: + # The OIDC token Trusted Publishing exchanges for a short-lived upload token. + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + # Re-running after a failed attempt should not be blocked by files that + # already landed. + skip-existing: true + + publish-pypi: + name: Publish to PyPI + needs: build + if: github.ref_type == 'tag' && needs.build.outputs.prerelease == 'false' + runs-on: ubuntu-latest + environment: + # Add a required reviewer to this environment to make every real upload wait for + # one click. + name: pypi + url: https://pypi.org/p/python-wrap + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@v1.14.2 + with: + # PEP 740 attestations, signed by the same OIDC identity. + attestations: true + + github-release: + name: Create the GitHub release + needs: [build, publish-testpypi, publish-pypi] + # Whichever publish job applied must have succeeded; the other is skipped. + if: >- + !cancelled() && github.ref_type == 'tag' && needs.build.result == 'success' + && contains(fromJSON('["success", "skipped"]'), needs.publish-testpypi.result) + && contains(fromJSON('["success", "skipped"]'), needs.publish-pypi.result) + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + + # The release notes are the CHANGELOG section for this version: from its `## ` + # heading up to the next one. Matches `## [0.2.0] - date` and `## 0.2.0`. + - name: Extract the changelog section + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + awk -v v="$VERSION" ' + /^## / { + if (found) exit + if (index($0, "[" v "]") || index($0, "## " v) == 1) { found = 1; next } + } + found { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "::warning::no CHANGELOG.md section found for $VERSION" + echo "See CHANGELOG.md for what changed in this release." > release-notes.md + fi + cat release-notes.md + + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + run: | + flags=() + if [ "$PRERELEASE" = "true" ]; then flags+=(--prerelease); fi + gh release create "$GITHUB_REF_NAME" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "$GITHUB_REF_NAME" \ + --notes-file release-notes.md \ + "${flags[@]}" diff --git a/.gitignore b/.gitignore index 1800114..942ad14 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,14 @@ cython_debug/ .ruff_cache/ # PyPI configuration file -.pypirc \ No newline at end of file +.pypirc + +# Project-specific (explicit, in case the sections above change) +.venv/ +site/ +dist/ +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ +.coverage +coverage.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6901b27 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +# Optional but recommended: `uvx pre-commit install`. +# +# A hook here is either something CI also gates on, or something with no plausible false +# positive. A hook that fails for a reason CI does not care about trains people to pass +# --no-verify. +default_language_version: + python: python3.13 + +exclude: '^(\.venv/|uv\.lock)' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + args: [--maxkb=512] + - id: check-merge-conflict + - id: check-case-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + # Markdown uses two trailing spaces as a hard line break. + args: [--markdown-linebreak-ext=md] + - id: debug-statements + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.3 + hooks: + # The lint gate, identical to CI's. + - id: ruff-check + args: [--fix] + # The format gate, identical to CI's. + - id: ruff-format + + # Catches mistakes that only show up in a GitHub Actions run, where the feedback loop + # is minutes rather than seconds. + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.38.0 + hooks: + - id: check-github-workflows diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 575f578..7ea58c6 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,23 +1,25 @@ -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required +# Read the Docs configuration. +# https://docs.readthedocs.io/en/stable/config-file/v2.html version: 2 -# Set the OS, Python version, and other tools you might need build: os: ubuntu-24.04 tools: python: "3.13" + # uv installs the `docs` dependency group from the lockfile, so the hosted build uses + # the same versions as `mkdocs build --strict` in CI. + # https://docs.readthedocs.com/platform/stable/build-customization.html#install-dependencies-with-uv + jobs: + pre_create_environment: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + create_environment: + - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" + install: + # Installs the project (mkdocstrings reads docstrings from it) and the docs group. + - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --no-default-groups --group docs -# Build documentation in the "docs/" directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Optionally, but recommended, -# declare the Python requirements required to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -# python: -# install: -# - requirements: docs/requirements.txt - +mkdocs: + configuration: mkdocs.yml + fail_on_warning: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a339208 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,265 @@ +# Changelog + +All notable changes to this project are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +While the version is below `1.0` the public API may change between minor versions. +Breaking changes are listed under **Breaking changes**, each with its migration. + +## [Unreleased] + +## [0.2.0] - 2026-10-27 + +A ground-up rework and a much larger toolkit. Every decorator works on plain functions, +`async def` functions and methods, keeps the wrapped signature for type checkers, and never +configures logging. Several +v0.1 decorators that did not do what their names promised (`transactional`, `revert_on_failure`, +async `timing`) now work. See the +[migration guide](https://python-wrap.readthedocs.io/en/latest/migration/) for the full upgrade +path. + +### Highlights + +- **Zero dependencies, fully typed.** `wrapt` and `typing-extensions` are gone; signatures are + preserved with `ParamSpec`, the package ships `py.typed`, and `mypy --strict` is clean. +- **Sync and async with one API.** `async def` wrappers stay coroutine functions and never block + the event loop. +- **A real resilience toolkit.** `retry` with backoff, jitter and hooks; `CircuitBreaker`; + `fallback`; `suppress`; `timeout` that actually interrupts. +- **A real concurrency toolkit.** `memoize` with TTL and LRU, `rate_limit` with a sliding window, + a shared `RateLimiter`, `Bulkhead`, `throttle`, `debounce` and `singleflight`. +- **Retry that honours the server.** `retry(delay_from=retry_after_header)` follows `Retry-After`, + `max_time=` and `deadline()` cap the total time, and cancellation is never retried. +- **An async toolkit.** `gather_limit`, `map_async`, `amap_iter`, `race` and `run_sync`, plus + `batch_calls` (the DataLoader pattern) and `hedge`. +- **Policies and configuration.** `policy(...)` applies protections in the recommended order, and + `configure()` sets project-wide defaults. +- **Observable and testable.** `count_calls`, one `python_wrap.events` hook for every component, + `log_context`, and `python_wrap.testing` (`fast_mode`, `FakeClock`, `spy`, pytest fixtures). +- **Fast.** The `memoize` hit path is about 9x faster and the validation and logging decorators + skip signature binding on plain calls. +- **Safe by default.** Importing the package no longer touches the root logger, and `log_args`, + `audit` and `trace` redact secrets. +- **Works on Windows**, and is tested on Python 3.10 to 3.14 on Linux, macOS and Windows. + +### Breaking changes + +- **`run_in_thread` returns a `concurrent.futures.Future`** instead of a `Thread`. Migrate: + replace `.join()` with `.result()` (which also returns the value and re-raises errors). +- **`benchmark` is a plain function**, `benchmark(func, *, args=(), kwargs=None, runs=5, warmup=1) + -> BenchmarkResult`, instead of a decorator that silently ran the function three times per + call. Migrate: call `benchmark(func, args=(...))`; use `benchmark_async` for coroutines. +- **`retry_on_exception` is deprecated and now stops after 3 attempts** by default (v0.1 retried + forever with no delay). Migrate: use `retry(on=...)`, and pass `max_attempts=None` to keep the + old unlimited behaviour. +- **`rate_limit` raises `RateLimitExceeded`** instead of a bare `Exception`. Migrate: catch + `RateLimitExceeded` (use `exc.retry_after`), or pass `block=True` to wait instead. +- **`log_args`, `log_return` and `trace` log at `DEBUG` by default** (`log_args` used `INFO`). + Migrate: pass `level=logging.INFO` to keep the old level. +- **`no_debug` is a deprecated no-op** and will be removed in 0.3. Migrate: delete the decorator. +- **`validate_args` and `check_type` raise `TypeError` at decoration time** for a name that is not + a parameter of the function. Migrate: fix the misspelled or stale parameter name. +- **Python 3.8 and 3.9 are no longer supported.** Migrate: upgrade to Python 3.10 or newer, or + stay on 0.1.0. +- **Importing the package no longer calls `logging.basicConfig`.** Migrate: configure logging in + your application if you relied on python-wrap's root-logger setup. + +### Added + +Resilience: + +- `retry`: `delay_from=` (with the `retry_after_header` helper for `Retry-After`), `max_time=`, + `log_level=` and `log_exc_info=`; `retry_stats(func)` and `RetryStats`; `retrying(...)` / + `Retrying` for retrying an inline block (`for` / `async for` with `with attempt`); retries + honour `deadline()`. Cancellation, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` are + never retried. +- `CircuitBreaker`: `success_threshold`, `exclude=`, `on_state_change` and `.stats` + (`CircuitStats`). +- `timeout` is also a context manager (`with timeout(5):` / `async with timeout(5):`) and returns + a `Timeout` object. `python_wrap.deadline` (`deadline()`, `remaining()`, `check()`) gives code a + shared time budget, and `timeout` clamps to it. +- `hedge(delay, max_extra=, on=)`: hedged requests for idempotent calls; the first success wins + and async losers are cancelled. + +Async toolkit and concurrency: + +- `python_wrap.aio`: `gather_limit`, `map_async` and `amap_iter` (bounded concurrency, lazy + input, ordered results; everything is cancelled and awaited on error, cancellation or early + exit), `race` (first success wins; `RaceFailed`) and `run_sync` (refuses to run inside a running + loop). +- `Bulkhead(max_concurrent, max_waiting=, timeout=, name=)` for sync and async (decorator, `with`, + `async with`, `.active`, `.waiting`), `limit_concurrency(n)` and `BulkheadFull`. +- `RateLimiter`: one budget shared across functions (`with`, `async with`, `acquire`, + `try_acquire`), and several windows at once with `rate_limit(limits=[(5, 1), (600, 900)])`. +- `batch_calls(max_size=, max_wait=, key=)`: the DataLoader pattern for sync and async code, with + de-duplication, `.flush()` / `.aflush()` / `.pending`, and `BatchError` on contract violations. + +Caching: + +- `cached_property(ttl=)` (thread-safe, `async def` getters, `invalidate()`). +- `memoize`: `cache_exceptions=` / `exception_ttl=`, a per-item `ttl=` callable, and + `cache_contains()`, `cache_get()` and `cache_parameters()`. + +Observability, configuration and testing: + +- `count_calls`: calls, errors, in-flight, mean, p50, p95 and p99 per function. +- `python_wrap.events`: `subscribe()`, `listening()`, `filter_events()` and `EVENT_NAMES`, one hook + for retry, cache, circuit, limiter, bulkhead and timeout events (Prometheus, OpenTelemetry and + statsd recipes in the docs). +- `log_context()` / `current_log_context()`: contextvar fields in `log_args`, `log_return`, + `audit`, `trace` and `timing` messages, on `LogRecord.context` and on `AuditRecord.context`. + `log_args` and `audit` accept `redact_extra=`. +- `policy(...)` / `Policy`: reusable, immutable decorator bundles placed by role (`observe`, + `validate`, `fallback`, `cache`, `limiter`, `bulkhead`, `breaker`, `retry`, `timeout`, `guard`, + `outer`, `inner`) in the recommended order, with `.with_()`; `wrap_methods(*decorators, + include=, exclude=, include_private=)` decorates a class. +- `python_wrap.config`: `configure()`, `configured()`, `get_config()` and `reset_config()` for + project-wide defaults (logger, retry options, redaction), read at decoration time; explicit + arguments win. +- `python_wrap.testing`: `fast_mode()`, `disable_retries()`, `disable_rate_limits()`, + `disable_caching()`, `overrides()` (context managers and decorators), `FakeClock` / + `fake_clock()`, `spy()` / `spy_on()`, `eventually()` / `eventually_sync()`, and the pytest + fixtures `pw_fake_clock` and `pw_fast_mode` (`pytest_plugins = ["python_wrap.testing"]`). +- Typing: stacked decorators keep the exact function type (`@retry` over `@memoize` keeps + `.cache_info()`), and `Memoized`, `Once`, `CircuitProtected`, `Limited` and `Counted` bind + correctly under methods and `@classmethod`. Typing regression tests run `mypy` in CI. +- A cookbook of eight recipes, seven runnable `examples/`, a performance page with + `benchmarks/run.py`, and a thread-safety stress suite (`PW_STRESS=1`, also run on free-threaded + 3.13t and 3.14t). + +Core: + +- `retry`: `backoff`, `max_delay`, `jitter`, `on=` exception filter, `retry_if=` predicate, + `on_retry=` hook receiving a `RetryState`, unlimited attempts (`max_attempts=None`), and bare + `@retry` usage. +- `fallback(value)` / `fallback(handler=...)` and `suppress(*exceptions)` for failing soft. +- `CircuitBreaker` (shareable across functions), the `circuit_breaker` decorator (breaker exposed + as `.breaker`) and `CircuitState`. +- `memoize`: `maxsize` (LRU), `ttl`, `typed`, `key=`, `cache_info()`, `cache_clear()`, + `cache_delete()`, and in-flight de-duplication for async functions. +- `throttle(interval)`, `debounce(wait)` with `.cancel()` and `.flush()`, `singleflight(key=)`, + and `rate_limit(block=, key=)`. +- `timing(threshold=, callback=)` with `TimingRecord`, `measure_memory(callback=)` with + `MemoryRecord`, `profile(sort=, limit=, callback=)`, and `benchmark_async`. +- `log_args` secret redaction (`redact=`, `DEFAULT_REDACT`) and `max_length=`; `audit` now yields + a structured `AuditRecord` with `sink=` and `include_result=`; `trace(limit=)`. Every logging + decorator accepts `logger=` and `level=`. +- `enforce_types`: runtime checks from annotations (generics, unions, `Literal`, `Annotated`, + `NewType` and more) for arguments and return values. +- `Transaction` with `on_commit` / `on_rollback`, `tx=` injection, `current_transaction()`, and + sync and async context-manager support. +- `revert_on_failure(attrs=, deep=, target=)`. +- `deprecate(since=, removed_in=, alternative=, category=)`, which also works on classes; + `mock_data(enabled=bool | callable)`; `compose(*decorators)`. +- Exceptions in `python_wrap.errors`, each subclassing the closest builtin: `PythonWrapError`, + `TimeoutExpired` (`TimeoutError`), `RateLimitExceeded`, `CircuitOpenError`, `ValidationError` + (`ValueError`) and `ArgumentTypeError` (`TypeError`). +- `once` gained `.reset()` and `.called`. +- A `py.typed` marker, and a `NullHandler` on the `python_wrap` logger. + +### Changed + +- The project uses a `src/` layout and Hatchling, and requires Python 3.10 or newer. +- The runtime dependencies `wrapt` and `typing-extensions` were unused and have been removed. +- `retry` waits with `asyncio.sleep` for `async def` functions, and re-raises the final + exception unchanged with its original traceback. +- `timeout` raises `TimeoutExpired`. Async functions are cancelled with `asyncio.wait_for`; sync + functions are interrupted with `SIGALRM` on the Unix main thread. The thread fallback used + elsewhere cannot stop the work, and is now documented. +- `memoize` and `cache` keys are bound to the function signature with defaults applied, so + `f(1)`, `f(x=1)` and `f(1, y=)` share one entry; list, dict and set arguments are + converted to hashable keys. +- `deprecate` points its warning at the caller and sets `__deprecated__` (PEP 702). +- `from python_wrap.decorators import ...` still works through a compatibility module. +- `timeout(...)` now returns a `Timeout` object, which is both a decorator and a context manager. + Decorator use is unchanged. +- `retry` never retries cancellation-type exceptions (`asyncio.CancelledError`, + `KeyboardInterrupt`, `SystemExit`, `GeneratorExit`), even if `on=` lists them. +- The `redact=` default on `log_args` and `audit` is `None`, which means "the configured value, or + `DEFAULT_REDACT`". Pass `redact=()` to turn redaction off. +- `audit` with no `sink` and a disabled log level builds no record at all. +- `Limited.bulkhead` is a read-only property. +- Test overrides apply only to backoff waits. `fast_mode()` and `fake_clock()` no longer shorten + limits on how long to wait: async `Bulkhead`/`limit_concurrency` timeouts, `race(timeout=)`, + async `hedge` delays and async `batch_calls`/`debounce` windows run on real time, like their + sync versions and `timeout`. +- Under `fast_mode()`, a blocking rate limiter (`block=True`) that would wait lets the call + through at once and still counts it. + +### Fixed + +- `revert_on_failure` never restored anything because its snapshot was always empty. +- `transactional` was a no-op; it now commits and rolls back through a `Transaction`. +- `memoize` and `cache` built keys from `str(args)`, which collided and ignored keyword + equivalence. `cache()` also shared a single store across all functions it decorated. +- `timing` measured about zero seconds for `async def` functions, and now uses `perf_counter`. +- `measure_memory` used `ru_maxrss`, which is Unix-only and reported the wrong units; it now + uses `tracemalloc`. +- The package failed to import on Windows because it imported the Unix-only `resource` module. +- `audit` reported the wrapper's own frame as the caller. +- `once` was not thread-safe, and now retries after an exception. +- `validate_args` rebuilt the function signature on every call. +- Under `fast_mode()` or `fake_clock()`, async bulkhead and `race` timeouts expired at once and + `hedge` launched every backup attempt immediately. +- A blocking `RateLimiter`/`rate_limit(block=True)` busy-spun at full CPU under `fast_mode()`. +- `timeout`'s thread fallback reported a call as late when it advanced the fake clock (for + example a `retry` inside it), and a nested signal-based `timeout` did not fire under + `fake_clock()`. +- `@transactional` on a function with a positional-only (or `*tx` / `**tx`) `tx` parameter + crashed at call time; it now raises `TypeError` when decorating. +- `singleflight`'s default key treated `f(1, b=2)` and `f((1,), (("b", 2),))` as the same call, + and `memoize` on a callable without an inspectable signature treated `f(1, b=2)` and + `f(1, 2)` as the same call. + +### Performance + +- The `memoize` hit path is about 9x faster. +- `validate_args`, `check_type`, `enforce_types`, `log_args` and `audit` skip signature binding on + plain positional calls (5x to 40x faster). +- `audit` skips building a record when nobody would receive it, and `timing` skips formatting + when its log level is disabled. +- Numbers and a benchmark script are on the + [performance page](https://python-wrap.readthedocs.io/en/latest/performance/). + +### Deprecated + +- `retry_on_exception`: use `retry(on=...)`. Emits a `DeprecationWarning`. +- `no_debug`: a no-op, to be removed in 0.3. +- `cache`: soft-deprecated in the docs only (no warning yet); prefer `memoize(ttl=...)`. + +### Removed + +- Support for Python 3.8 and 3.9. +- Poetry as the build and dependency tool (replaced by Hatchling and uv). +- The Sphinx documentation (replaced by the new documentation site). +- The token-based PyPI publish workflow (replaced by tag-driven publishing with Trusted + Publishing). + +### Infrastructure + +- CI runs ruff, `mypy --strict`, the test suite on Linux, macOS and Windows across Python 3.10 to + 3.14, and a wheel check. +- Tag-driven releases publish to PyPI through Trusted Publishing, with no stored token. +- The documentation build is checked in CI and hosted on Read the Docs. +- Added Dependabot, issue and pull request templates, `CONTRIBUTING.md`, `SECURITY.md`, + `CODE_OF_CONDUCT.md` and `RELEASING.md`. + +### Known limitations + +- The `timeout` thread fallback (Windows, non-main threads) cannot stop the running work. +- `rate_limit`, `throttle` and `debounce` share state across instances when used on methods + (`rate_limit` accepts `key=` to split it). +- `memoize` on methods keeps instances alive; use `maxsize` or `ttl`. +- Concurrent calls to `measure_memory` share one tracker, and `profile` on an async function + includes event-loop work. +- `transactional` injects `tx=` by keyword, so declare `tx` last or keyword-only. + +## [0.1.0] - 2025-01-21 + +Initial release. + +[Unreleased]: https://github.com/LitePy/PythonWrap/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/LitePy/PythonWrap/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/LitePy/PythonWrap/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..48578e1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,123 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a +harassment-free experience for everyone, regardless of age, body size, visible or +invisible disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal appearance, +race, caste, colour, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, +inclusive, and healthy community. + +## Our Standards + +Examples of behaviour that contributes to a positive environment for our community +include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologising to those affected by our mistakes, and + learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behaviour include: + +* The use of sexualised language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without + their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional + setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behaviour and will take appropriate and fair corrective action in response to +any behaviour that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, +commits, code, wiki edits, issues, and other contributions that are not aligned to this +Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces — the issue tracker, pull +requests, discussions, and the commit history — and also applies when an individual is +officially representing the community in public spaces. Examples of representing our +community include using an official email address, posting via an official social media +account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported to +the maintainers responsible for enforcement at: + +**** + +All complaints will be reviewed and investigated promptly and fairly. All community +leaders are obligated to respect the privacy and security of the reporter of any +incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the +consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behaviour deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity +around the nature of the violation and an explanation of why the behaviour was +inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behaviour. No interaction with +the people involved, including unsolicited interaction with those enforcing the Code of +Conduct, for a specified period of time. This includes avoiding interactions in community +spaces as well as external channels like social media. Violating these terms may lead to +a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained +inappropriate behaviour. + +**Consequence**: A temporary ban from any sort of interaction or public communication +with the community for a specified period of time. No public or private interaction with +the people involved, including unsolicited interaction with those enforcing the Code of +Conduct, is allowed during this period. Violating these terms may lead to a permanent +ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, +including sustained inappropriate behaviour, harassment of an individual, or aggression +toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, +available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla]. + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. + +[homepage]: https://www.contributor-covenant.org +[mozilla]: https://github.com/mozilla/inclusion diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0ea322c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing to python-wrap + +Thank you for considering it. This page covers how to set up, how to run the checks, +and the conventions every module follows. + +By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). + +## Set up + +The project uses [uv](https://docs.astral.sh/uv/). + +```bash +git clone https://github.com/LitePy/PythonWrap +cd PythonWrap +uv sync --all-groups +``` + +This creates `.venv/`, installs the package in editable mode and installs the `dev` and +`docs` groups. You never need to activate the virtualenv: prefix commands with `uv run`. +There is no Makefile; the commands below are the whole workflow. + +## Run the checks + +CI runs exactly these, so if they pass locally the pull request will be green. + +```bash +uv run pytest # tests +uv run pytest --cov --cov-report=term-missing # with coverage +uv run pytest tests/test_retry.py -k async -x # a subset +uv run ruff check src tests # lint +uv run ruff format src tests # format (CI runs it with --check) +uv run mypy # strict type check of src/ +uv run mkdocs serve # live docs preview +uv run mkdocs build --strict # what the docs CI job runs +``` + +To test another interpreter: `uv run --python 3.10 pytest`. + +Optional, and worth installing once: `uvx pre-commit install`. + +## Conventions + +The authoritative version is the docstring at the top of `src/python_wrap/_core.py`. +In short: + +1. **Standard library only.** No runtime dependencies. Python 3.10 and newer. +2. **Sync and async.** Every decorator works on plain functions, `async def` functions + and methods (instance, class, static). For `async def`, return an `async def` + wrapper so `inspect.iscoroutinefunction(wrapped)` stays true, and never block the + event loop. +3. **No logging configuration.** Never call `logging.basicConfig` or add handlers. + Log through `_core.get_logger(logger)`; logging decorators take + `logger: LoggerLike = None` and `level`. +4. **A patchable clock.** Read time through `_core.monotonic()` and + `_core.perf_counter()` and wait through `_core.sleep()` and + `await _core.async_sleep()`, always as attributes of the module + (`from python_wrap import _core`), never imported by name. +5. **Full typing.** `mypy --strict` clean, signatures preserved with `ParamSpec`. + Decorators usable both bare and with options use the overload pattern: + + ```python + @overload + def timing(func: Callable[P, R], /) -> Callable[P, R]: ... + @overload + def timing(func: None = None, /, *, logger: LoggerLike = None) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + def timing(func=None, /, *, logger=None): + def decorate(f): ... + return decorate if func is None else decorate(func) + ``` +6. **Thread safety** where state is shared: locks around caches, counters and buckets. +7. **`functools.wraps`** on every wrapper, and a Google-style docstring (Args, Returns, + Raises, Example) on every public function; the docs site renders them. +8. **`__all__`** in every module. + +## Tests + +Put tests in `tests/test_.py`. They should be behaviour-focused and fast, cover +sync and async paths and error paths, and test thread safety where relevant. + +Use the `fake_clock` fixture from `tests/conftest.py` instead of real sleeping. It +patches every time source in `_core`, and `sleep` / `async_sleep` advance the fake clock +instead of waiting: + +```python +def test_retry_waits_between_attempts(fake_clock): + ... + assert fake_clock.sleeps == [0.5, 1.0] +``` + +No real sleep over 0.05 seconds. + +## Pull requests + +Keep them focused, add a line under `Unreleased` in `CHANGELOG.md`, and explain the +reasoning in the description. Releases are described in [RELEASING.md](RELEASING.md). diff --git a/README.md b/README.md index 1037d5a..8a1d77b 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,446 @@ -# PythonWrap +
-A comprehensive collection of Python decorators and utility functions to enhance your development workflow. +# python-wrap -## Installation +**The resilience and concurrency toolkit you add to every Python project.**
+Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhead, batching, async helpers, metrics and test helpers. One import, sync and async, zero dependencies. + +[![CI](https://github.com/LitePy/PythonWrap/actions/workflows/ci.yml/badge.svg)](https://github.com/LitePy/PythonWrap/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/python-wrap?logo=pypi&logoColor=white)](https://pypi.org/project/python-wrap/) +[![Python](https://img.shields.io/pypi/pyversions/python-wrap?logo=python&logoColor=white)](https://pypi.org/project/python-wrap/) +[![License: MIT](https://img.shields.io/pypi/l/python-wrap)](https://github.com/LitePy/PythonWrap/blob/main/LICENSE) +[![Docs](https://readthedocs.org/projects/python-wrap/badge/?version=latest)](https://python-wrap.readthedocs.io) +[![Typed: mypy strict](https://img.shields.io/badge/typed-mypy%20strict-2a6db2)](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict) + +[**Documentation**](https://python-wrap.readthedocs.io) · +[Cookbook](https://python-wrap.readthedocs.io/en/latest/cookbook/) · +[Performance](https://python-wrap.readthedocs.io/en/latest/performance/) · +[Migration guide](https://python-wrap.readthedocs.io/en/latest/migration/) · +[Changelog](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md) + +
+ + +## Why python-wrap + +- **One import for the things every project hand-writes.** Retry, circuit breaker, timeout, cache, rate limit, bulkhead, batching, bounded `gather`, metrics and test helpers, designed to stack and to be tested together. +- **Resilience that behaves.** `retry` honours `Retry-After`, a time budget and cancellation; a shared `deadline()` caps a whole request; `policy(...)` stacks the protections in the right order for you. +- **Zero dependencies.** Pure Python, nothing to audit or pin. +- **Typed.** Decorators preserve the wrapped signature (`ParamSpec`), and stacking keeps the exact type (`@retry` over `@memoize` still has `.cache_info()`). Ships `py.typed`, checked with `mypy --strict`. +- **Sync and async, one API.** Works on `def`, `async def` and methods. Async wrappers stay coroutine functions and never block the event loop. +- **Fast.** Cache hits and validation skip signature binding on the hot path ([numbers](https://python-wrap.readthedocs.io/en/latest/performance/)). +- **Observable and testable.** One event stream for retries, cache, circuit, limiters and timeouts; `python_wrap.testing` makes tests instant and deterministic. +- **Never configures your logging**, is thread-safe, and is tested on Python 3.10 to 3.14 on Linux, macOS and Windows. + +## Install ```bash pip install python-wrap +# or +uv add python-wrap ``` -## Features +Python 3.10 or newer. + +## 30-second tour + +```python +import asyncio +import time + +from python_wrap import ( + CircuitBreaker, gather_limit, memoize, policy, rate_limit, retry, retry_after_header, timeout, +) +from python_wrap.testing import fast_mode + +# One reusable stack for a flaky HTTP dependency. Roles decide the order, not you. +http = policy( + cache=memoize(ttl=60, maxsize=256), # hits skip everything below + breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30, name="api"), + retry=retry(max_attempts=4, delay=0.5, backoff=2, jitter=0.1, + on=ConnectionError, delay_from=retry_after_header, max_time=20), + timeout=timeout(5), # per attempt + name="http", +) + + +@http +def fetch_user(user_id: int) -> dict[str, int]: + return {"id": user_id} # your real HTTP call goes here -PythonWrap provides a rich set of decorators that can be used either as decorators or as standalone functions: -### Performance & Profiling -- `@timing`: Measure function execution time -- `@profile`: Profile function performance -- `@benchmark`: Compare execution times -- `@measure_memory`: Track memory usage +print(fetch_user(1), fetch_user(1)) # the second one is a cache hit +print(fetch_user.cache_info()) # the cache API survives the stack -### Error Handling & Reliability -- `@retry`: Retry failed operations -- `@retry_on_exception`: Retry on specific exceptions -- `@timeout`: Set execution time limits -- `@revert_on_failure`: Automatic state rollback on failure -### Caching & Optimization -- `@memoize`: Cache function results -- `@cache`: Time-based result caching -- `@once`: Single execution guarantee +@rate_limit(100, 1.0, block=True) # at most 100 calls per second, waits when full +async def fetch_async(user_id: int) -> dict[str, int]: + await asyncio.sleep(0.01) + return {"id": user_id} -### Debugging & Logging -- `@log_args`: Log function arguments -- `@log_return`: Log return values -- `@trace`: Log call stack traces -- `@audit`: Comprehensive function call auditing -### Type Safety & Validation -- `@check_type`: Runtime type checking -- `@validate_args`: Custom argument validation +async def main() -> None: + # 50 requests, never more than 20 in flight; results come back in order. + users = await gather_limit(*(fetch_async(i) for i in range(50)), limit=20) + print(len(users), "users") -### Development Tools -- `@deprecate`: Mark deprecated functions -- `@no_debug`: Disable debug output -- `@mock_data`: Easy data mocking -- `@rate_limit`: Control execution frequency -### Concurrency -- `@run_in_thread`: Asynchronous execution -- `@transactional`: Atomic operations +asyncio.run(main()) -## Usage Examples -### Using as Decorators +# In tests, every wait is instant but retries still run the configured number of times. +calls = [] + + +@retry(max_attempts=4, delay=30, backoff=2, on=ConnectionError) +def flaky() -> str: + calls.append(1) + if len(calls) < 4: + raise ConnectionError + return "ok" + + +start = time.monotonic() +with fast_mode(): + assert flaky() == "ok" # would take 30 + 60 + 120 seconds +print(f"{time.monotonic() - start:.2f}s") +``` + +## What is in the box + +Everything below is importable from `python_wrap` unless a module is shown. [Choose a decorator](https://python-wrap.readthedocs.io/en/latest/choosing/) is the quick way in. + +| Area | Name | What it does | +|---|---|---| +| **Resilience** | `retry` | Backoff, jitter, `max_delay`, `max_time`, `retry_if`, `on_retry`; `delay_from=retry_after_header` honours `Retry-After`; never retries cancellation | +| | `retrying`, `Retrying` | Retry an inline block (`for attempt in retrying(...)`, sync and `async for`) | +| | `retry_stats`, `RetryStats` | Attempts, successes and failures per retried function | +| | `fallback`, `suppress` | Return a default (or handler result), or swallow chosen exceptions | +| | `circuit_breaker`, `CircuitBreaker` | Fail fast while a dependency is down; `success_threshold`, `exclude`, `on_state_change`, `.stats`; one breaker can guard many functions | +| | `timeout`, `Timeout` | Decorator or `with timeout(5):` / `async with`; raises `TimeoutExpired` | +| | `deadline`, `Deadline` | A shared time budget; `retry` and `timeout` clamp to it | +| | `hedge` | Fire a backup call when the first is slow and take the first success (idempotent calls only) | +| **Async toolkit** | `gather_limit`, `map_async`, `amap_iter` | Bounded concurrency, lazy input, ordered results; everything is cancelled and awaited on error | +| | `race`, `RaceFailed` | First success wins, the rest are cancelled | +| | `run_sync` | Call async code from sync code (refuses inside a running loop) | +| **Caching** | `memoize` | LRU + TTL, `typed`, `key=`, per-item `ttl=`, `cache_exceptions=`, `cache_info/clear/delete/contains/get`; async in-flight dedupe | +| | `cached_property`, `invalidate` | `functools.cached_property` plus `ttl`, locking and `async def` getters | +| | `once`, `cache` | Run once and reuse (thread-safe, `.reset()`); `cache` is the v0.1 name for `memoize(ttl=...)` | +| **Concurrency & limits** | `rate_limit`, `RateLimiter` | Sliding window, `block=True`, `key=`, several limits, one budget shared across functions | +| | `Bulkhead`, `limit_concurrency` | Cap concurrent calls, bound the queue, raise `BulkheadFull` | +| | `throttle`, `debounce` | At most once per interval; run after calls stop (`.cancel()`, `.flush()`) | +| | `singleflight`, `run_in_thread` | Share one execution between identical concurrent calls; run in a thread, get a `Future` | +| **Batching** | `batch_calls` | DataLoader pattern: many single-item calls become one bulk call; sync and async, deduped | +| **Observability & metrics** | `count_calls` | `.stats`: calls, errors, in-flight, mean, p50, p95, p99 | +| | `subscribe`, `listening` | One event hook for retry, cache, circuit, limiter, bulkhead and timeout events (`python_wrap.events` also has `filter_events`, `EVENT_NAMES`) | +| | `log_args`, `log_return`, `trace`, `audit` | Structured, redacted logging; `AuditRecord` to a sink | +| | `log_context`, `current_log_context` | Contextvar fields in every log line and audit record | +| | `timing`, `profile`, `measure_memory`, `benchmark` | Duration with `threshold=`/`callback=`, `cProfile`, `tracemalloc`, `benchmark_async` | +| **Validation** | `enforce_types`, `validate_args`, `check_type` | Check from annotations, with predicates, or with explicit types | +| **State** | `transactional`, `Transaction`, `current_transaction` | Commit and rollback callbacks, usable as a (async) context manager | +| | `revert_on_failure` | Restore an object's attributes if the call raises | +| **Configuration** | `configure`, `configured`, `get_config`, `reset_config` | Project-wide defaults for logger, retry options and redaction | +| | `policy`, `Policy` | Named, immutable decorator bundles applied in the recommended order | +| **Testing** (`python_wrap.testing`) | `fast_mode`, `disable_retries`, `disable_rate_limits`, `disable_caching`, `overrides` | Skip waits, cap retries, switch limiters and caches off | +| | `FakeClock`, `fake_clock`, `spy`, `spy_on`, `eventually` | Control time, record calls, wait for background work; pytest fixtures `pw_fake_clock`, `pw_fast_mode` | +| **Dev tools** | `deprecate`, `mock_data`, `compose`, `wrap_methods` | Deprecation warnings at the caller, fixed test data, decorator bundles, decorate a whole class | + +Exceptions live in `python_wrap.errors` and at the top level: `PythonWrapError`, `TimeoutExpired` (a `TimeoutError`), `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, `BatchError`, `ValidationError` (a `ValueError`) and `ArgumentTypeError` (a `TypeError`). + +## Highlights + +### Retry, `Retry-After` and fail-soft ```python -from python_wrap import timing, retry, memoize - -# As a decorator -@timing -def slow_operation(): - time.sleep(1) - return "Done" - -# As a decorator with parameters -@retry(max_attempts=3, delay=1.0) -def unreliable_operation(): - return api_call() - -# Simple decorator -@memoize -def fibonacci(n): - if n < 2: - return n - return fibonacci(n-1) + fibonacci(n-2) +from python_wrap import RetryState, retry + + +def note(state: RetryState) -> None: + print(f"attempt {state.attempt} failed: {state.exception!r}") + + +calls = 0 + + +@retry( + max_attempts=5, + delay=0.01, # wait before the 2nd attempt + backoff=2.0, # then 2x longer each time + max_delay=0.1, # but never more than this + jitter=0.01, # plus up to this many random seconds + max_time=5, # and never spend longer than this in total + on=(ConnectionError, TimeoutError), + retry_if=lambda exc: "temporary" in str(exc), + on_retry=note, +) +def flaky() -> str: + global calls + calls += 1 + if calls < 3: + raise ConnectionError("temporary glitch") + return "ok" + + +print(flaky()) # ok, after two retries ``` -### Using as Functions +Fail soft with `fallback(value)` or `suppress(OSError)`. The last exception is re-raised unchanged once attempts run out. `max_attempts=None` retries forever. Cancellation (`asyncio.CancelledError`, `KeyboardInterrupt`) is never retried. Add `delay_from=retry_after_header` to wait exactly as long as a 429/503 `Retry-After` header says, and retry a block of code with `for attempt in retrying(...)`. + +### Circuit breaker, timeout and deadline ```python -from python_wrap import timing, retry, memoize +import time + +from python_wrap import CircuitBreaker, CircuitOpenError, CircuitState, TimeoutExpired, deadline, timeout + +payments = CircuitBreaker(failure_threshold=2, recovery_timeout=30, name="payments") + + +@payments +def charge(amount: int) -> str: + raise ConnectionError("gateway down") + -# Using timing as a function -def slow_operation(): - time.sleep(1) - return "Done" +for _ in range(2): + try: + charge(10) + except ConnectionError: + pass -result = timing(slow_operation)() +try: + charge(10) +except CircuitOpenError: + print("failing fast, circuit is", payments.state.value) # no call was made +assert payments.state is CircuitState.OPEN -# Using retry as a function -def unreliable_operation(): - return api_call() -result = retry(unreliable_operation, max_attempts=3, delay=1.0)() +@timeout(1) +def step(seconds: float) -> None: + time.sleep(seconds) -# Using memoize as a function -def fibonacci(n): - if n < 2: - return n - return fibonacci(n-1) + fibonacci(n-2) -memoized_fib = memoize(fibonacci) -result = memoized_fib(10) +try: + with deadline(0.05): # one budget for everything inside; timeouts clamp to it + step(0.01) + step(1) +except TimeoutExpired: + print("out of time") ``` -### Advanced Usage +After `recovery_timeout` the breaker goes half-open and lets a trial call through. Share one `CircuitBreaker` across functions for one dependency, or use `@circuit_breaker(...)` for a private one (`func.breaker`). `timeout` interrupts sync code with `SIGALRM` on the Unix main thread, cancels `async def` with `asyncio.wait_for`, and elsewhere falls back to a thread that cannot stop the work (see [Limitations](https://python-wrap.readthedocs.io/en/latest/limitations/)). + +### Caching ```python -from python_wrap import validate_args, check_type, timeout +from python_wrap import memoize + -# As decorators -@validate_args(x=lambda x: x > 0, y=lambda y: y < 100) -def process_numbers(x, y): - return x + y +@memoize(maxsize=128, ttl=30) +def square(n: int, *, scale: int = 1) -> int: + return n * n * scale -@check_type(name=str, age=int) -def create_user(name, age): - return {"name": name, "age": age} -# As functions -def long_running_task(): - process_large_dataset() +square(3) +square(n=3) # same entry: arguments are bound to the signature +square(3, scale=1) # still the same entry +print(square.cache_info()) # 2 hits, 1 miss +square.cache_delete(3) # drop one entry +square.cache_clear() # drop everything -timed_task = timeout(5)(long_running_task) -result = timed_task() ``` -### Combining Multiple Functions +`cached_property(ttl=)` does the same for attributes. Keys are built from the bound signature, lists, dicts and sets are converted automatically, and exceptions are not cached unless you ask (`cache_exceptions=True, exception_ttl=`). Concurrent awaits of the same missing key in an `async def` share one in-flight task. On methods the cache keeps instances alive, so give it `maxsize` or `ttl`. + +### Async toolkit, bulkheads and batching ```python -from python_wrap import timing, retry, log_args - -# As decorators -@timing -@retry(max_attempts=3) -@log_args -def complex_operation(x, y): - return expensive_calculation(x, y) - -# As functions -def complex_operation(x, y): - return expensive_calculation(x, y) - -# Compose functions -monitored_op = timing(retry(log_args(complex_operation), max_attempts=3)) -result = monitored_op(1, 2) +import asyncio + +from python_wrap import Bulkhead, BulkheadFull, batch_calls, map_async, race + +queries: list[list[int]] = [] + + +@batch_calls(max_size=100, max_wait=0.005) # the DataLoader pattern +async def get_user(ids: list[int]) -> dict[int, str]: + queries.append(ids) # one round trip for the whole batch + return {i: f"user{i}" for i in ids} + + +db = Bulkhead(2, max_waiting=1, name="db") # 2 running, 1 queued, the rest rejected + + +@db +async def query(n: int) -> int: + await asyncio.sleep(0.01) + return n + + +async def main() -> None: + users = await asyncio.gather(get_user(1), get_user(2), get_user(1)) + assert users == ["user1", "user2", "user1"] and queries == [[1, 2]] + + doubled = await map_async(lambda n: query(n * 2), range(3), limit=2) + assert doubled == [0, 2, 4] + + results = await asyncio.gather(*(query(n) for n in range(4)), return_exceptions=True) + assert isinstance(results[3], BulkheadFull) + + async def slow() -> str: + await asyncio.sleep(1) + return "slow" + + async def fast() -> str: + return "fast" + + assert await race(slow(), fast()) == "fast" # the loser is cancelled + + +asyncio.run(main()) ``` +Every helper cancels and awaits the tasks it started, so nothing leaks on error or cancellation. Also see `hedge` for backup requests on slow reads and `run_sync` for calling async code from scripts. + +### Rate limits, throttle and debounce + +```python +from python_wrap import RateLimitExceeded, RateLimiter, rate_limit + + +@rate_limit(2, 0.2, key=lambda user, *_: user) # 2 calls / 0.2 s, per user +def send(user: str, text: str) -> str: + return f"{user}: {text}" + + +send("ann", "a") +send("ann", "b") +try: + send("ann", "c") +except RateLimitExceeded as exc: + print(f"slow down, retry in {exc.retry_after:.2f}s") +send("bob", "hello") # different key, different budget + +github = RateLimiter(5, 1.0, name="github") # one budget shared by several functions + + +@github +def get_repo(name: str) -> str: + return name + +``` + +`rate_limit(block=True)` waits instead of raising (with `await` for `async def`), and `limits=[(5, 1), (600, 900)]` enforces several windows at once. `throttle` and `debounce` cover "at most once per interval" and "after the burst settles". The sliding window never lets a `2 * calls` burst through at a window edge. + +### Metrics and events + +```python +from python_wrap import count_calls, listening, retry + + +@count_calls +@retry(max_attempts=3, delay=0) +def work(attempts: list[int]) -> str: + attempts.append(1) + if len(attempts) < 3: + raise ConnectionError + return "ok" + + +names: list[str] = [] +with listening(lambda event: names.append(event.name)): + work([]) + +print(names) # retry.attempt_failed, ..., retry.succeeded +print(work.stats.calls, work.stats.p95) +``` + +Subscribe once and forward events to Prometheus, OpenTelemetry or statsd (recipes in the [guide](https://python-wrap.readthedocs.io/en/latest/guides/metrics/)). Listeners cannot break the decorated function. + +### Validation and logging with redaction + +```python +import logging + +from python_wrap import ArgumentTypeError, enforce_types, log_args + +logging.basicConfig(level=logging.DEBUG, format="%(message)s") # your app configures logging + + +@enforce_types +def total(prices: list[float], currency: str = "USD") -> str: + return f"{sum(prices):.2f} {currency}" + + +print(total([1.5, 2.5])) +try: + total([1.5, "2.5"]) # type: ignore[list-item] +except ArgumentTypeError as exc: + print(exc) # parameter 'prices' expected list[float], got list containing str at index 1 + + +@log_args(logger="myapp.auth") +def login(username: str, password: str, api_key: str = "") -> bool: + return True + + +login("ann", "hunter2", api_key="sk-123") +# Calling login(username='ann', password='***', api_key='***') +``` + +Parameters whose names contain `password`, `token`, `secret`, `api_key` and similar (`DEFAULT_REDACT`) are masked in `log_args` and `audit`; extend with `redact_extra=` or project-wide through `configure(redact_extra=...)`. For custom rules use `validate_args(x=lambda x: x > 0)`. + +### Transactions and defaults + +`@transactional` gives a function a `tx: Transaction` (declare it last or keyword-only) to register `on_commit` and `on_rollback` callbacks; `current_transaction()` reaches it from anywhere. `configure(logger=..., redact_extra=...)` sets project-wide defaults read at decoration time, and explicit arguments always win. + +## Stacking decorators and policies + +Decorators are applied bottom-up and run top-down, so order matters: + +| Put this... | ...outside of this | Because | +|---|---|---| +| `memoize` | `retry`, `rate_limit`, `timeout` | Cache hits skip everything; only misses pay | +| `retry` | `timeout`, `circuit_breaker`, `rate_limit` | Each attempt gets its own deadline | +| `circuit_breaker` | `retry` or `timeout` | The breaker counts calls whose retries were used up | +| `fallback` | `retry`, `circuit_breaker` | Fall back only after retries are exhausted or the circuit is open | +| `log_args`, `timing`, `audit` | everything | They observe what callers actually experience | +| `enforce_types`, `validate_args` | the rest | Reject bad input before doing any work | + +You do not have to memorise this. `policy(observe=, validate=, fallback=, cache=, limiter=, bulkhead=, breaker=, retry=, timeout=, guard=)` takes each decorator by role and applies them in the recommended order, whatever order you wrote them in. Policies are immutable (`.with_(timeout=timeout(30))`), work on methods and `async def`, and `wrap_methods(policy)` protects a whole class. `compose(a, b, c)(f)` is `a(b(c(f)))` when you just want a named stack. Details: [Stacking](https://python-wrap.readthedocs.io/en/latest/stacking/) and [Policies](https://python-wrap.readthedocs.io/en/latest/guides/policies/). + +## How it compares + +python-wrap is a broad toolkit; the others are deeper in one area. Use them when that one area is all you need and you want the specialist. + +| Library | Use it instead when... | python-wrap's angle | +|---|---|---| +| [tenacity](https://github.com/jd/tenacity) | You need very rich retry policies: composable stop/wait strategies, per-call overrides | `retry` covers the common cases (backoff, jitter, `Retry-After`, time budget, hooks, async) with a flat signature, and shares a deadline, breaker and events with the rest | +| [stamina](https://github.com/hynek/stamina) | You want opinionated, production-ready retries with Prometheus and structlog integration built in | Similar defaults-first retry, plus the breaker, timeout, caches and limits it composes with; metrics via a generic event hook | +| [backoff](https://github.com/litl/backoff) | You already use it and are happy. The repository was archived in 2025, so it no longer receives fixes | A maintained, typed retry with the same core ideas | +| [cachetools](https://github.com/tkem/cachetools) | You need other eviction policies (LFU, FIFO, RR), explicit cache objects or custom locks | `memoize` gives LRU + TTL, `cache_info()`, async in-flight dedupe, signature-bound keys and `cached_property(ttl=)` | +| [async-lru](https://github.com/aio-libs/async-lru) | You only cache coroutines and want a tiny dependency | Same async dedupe, also for sync code, with TTL, per-item TTL and exception caching | +| [aiolimiter](https://github.com/mjpieters/aiolimiter) | You only need an asyncio leaky-bucket limiter | Sliding window for sync and async, per-key buckets, shared `RateLimiter`, `block=True`, plus `Bulkhead` for concurrency | +| [pybreaker](https://github.com/danielfm/pybreaker) | You need listeners or circuit state shared across processes (Redis storage) | A typed `CircuitBreaker` with async, `exclude=`, `success_threshold` and `.stats`, no dependency | +| [functools](https://docs.python.org/3/library/functools.html) | A plain `lru_cache` or `cache` on a sync function with hashable arguments is enough. Prefer it for that | Adds TTL, async, unhashable arguments and `cache_delete` | + +None of them cover bounded `gather`, bulkheads, batching, hedging, policies or test helpers; python-wrap trades per-area depth for one consistent, typed, dependency-free API. + +## Learn more + +- [Cookbook](https://python-wrap.readthedocs.io/en/latest/cookbook/): resilient HTTP client, async crawler, FastAPI, Django, CLI tools, DataLoader batching, testing, observability. +- [Performance](https://python-wrap.readthedocs.io/en/latest/performance/): per-call overhead and how to benchmark it yourself. +- [Testing](https://python-wrap.readthedocs.io/en/latest/testing/): `fast_mode`, `FakeClock`, `spy` and pytest fixtures. +- [Migration guide](https://python-wrap.readthedocs.io/en/latest/migration/) and [CHANGELOG](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md). +- Runnable [examples](https://github.com/LitePy/PythonWrap/tree/main/examples). + +## Upgrading from 0.1 + +0.2.0 fixes decorators that did not do what their names promised (`transactional`, `revert_on_failure`, async `timing`) and makes a few breaking changes: `run_in_thread` returns a `Future`, `benchmark` is a function, `retry_on_exception` is deprecated and bounded, `rate_limit` raises `RateLimitExceeded`, and the logging decorators default to `DEBUG`. Python 3.8 and 3.9 are no longer supported. `from python_wrap.decorators import ...` still works. See the [migration guide](https://python-wrap.readthedocs.io/en/latest/migration/). + ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](https://github.com/LitePy/PythonWrap/blob/main/CONTRIBUTING.md) for the development setup and checks, and [SECURITY.md](https://github.com/LitePy/PythonWrap/blob/main/SECURITY.md) for reporting vulnerabilities. ## License -This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file +[MIT](https://github.com/LitePy/PythonWrap/blob/main/LICENSE) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..ff5f40f --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,75 @@ +# Releasing + +How a version of `python-wrap` gets from this repository to PyPI. + +Publishing uses **PyPI Trusted Publishing** from `.github/workflows/release.yml`. No API +token exists anywhere, and none should be created. (The old token-based `publish.yml` +was removed; revoke the token it used at .) + +## Once: set up Trusted Publishing + +1. **Add a publisher on PyPI.** The project `python-wrap` already exists, so go to + and add a GitHub + publisher with exactly: + + | Field | Value | + |---|---| + | Owner | `LitePy` | + | Repository name | `PythonWrap` | + | Workflow name | `release.yml` | + | Environment name | `pypi` | + +2. **Add a pending publisher on TestPyPI.** The project does not exist there yet, so use + with the same values, project name + `python-wrap`, and environment name `testpypi`. + +3. **Create the GitHub environments** in the repository under Settings, Environments: + `pypi` and `testpypi`. Add yourself as a required reviewer on `pypi` so every real + upload waits for one click. + +## Every release + +1. **Version.** Set `__version__` in `src/python_wrap/__init__.py`. It is the only place + the version lives. Use PEP 440 spelling: `0.2.0rc1`, `0.2.0`. +2. **Changelog.** Move the `Unreleased` entries under `## [] - `. The + GitHub release notes are taken from this section. +3. **Check locally.** + + ```bash + uv run pytest + uv run ruff check src tests && uv run ruff format --check src tests + uv run mypy + uv run mkdocs build --strict + uv build && uvx twine check --strict dist/* && rm -rf dist + ``` + +4. **Merge to `main`**, with CI green. +5. **Rehearse (recommended for a big release).** Tag a pre-release such as `v0.2.0rc1` + (after setting `__version__ = "0.2.0rc1"`). Pre-releases (`a`, `b`, `rc`, `.dev`) are + published to TestPyPI only. A version can never be reused on PyPI, so this is cheap + insurance. +6. **Tag and push.** + + ```bash + git tag -a v0.2.0 -m "python-wrap 0.2.0" + git push origin v0.2.0 + ``` + + The workflow refuses a tag that disagrees with `__version__`, builds, checks the + wheel, and then waits for your approval of the `pypi` environment. After you approve + it uploads to PyPI with attestations and creates the GitHub release with the files + attached. +7. **Smoke-test** from a fresh directory: + + ```bash + uv run --no-project --with python-wrap==0.2.0 python -c "import python_wrap; print(python_wrap.__version__)" + ``` + +8. **Open the next cycle.** Add an empty `Unreleased` section to the changelog and bump + `__version__` to the next `.dev0`. + +## If something goes wrong + +- **The upload failed halfway.** Re-run the failed job. TestPyPI uses `skip-existing`. +- **A broken version reached PyPI.** Yank it (PyPI, Manage, Release, Options, Yank) and + release the fix under the next number. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ed3e0b5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# Security policy + +python-wrap is a library of decorators that you wrap around your own code. It has no +runtime dependencies, opens no sockets and reads no files, but a defect in a decorator +such as `timeout`, `rate_limit` or `validate_args` can still weaken the code around it. +Reports are taken seriously and answered. + +## Reporting a vulnerability + +**Do not open a public issue.** + +Report privately by either route: + +- **Preferred:** [open a private advisory on GitHub](https://github.com/LitePy/PythonWrap/security/advisories/new). +- **Email:** , with `python-wrap security` in the subject. + +Please include the version of python-wrap and Python, what an attacker gains, and the +smallest reproduction you can manage. + +## What to expect + +| | | +| --- | --- | +| Acknowledgement | within **3 working days** | +| Initial assessment | within **7 days** | +| Fix or a dated plan | within **30 days** for high and critical, best effort otherwise | +| Public disclosure | after a fix ships, coordinated with you | + +This is a small project maintained by volunteers. If you have not heard back in a week, +please chase: it means the message was missed, not ignored. You will be credited in the +advisory and changelog if you wish. There is no bug bounty. + +## Supported versions + +Security fixes are released as a new patch of the latest minor version. Older minors are +not backported. + +| Version | Supported | +| --- | --- | +| 0.2.x | yes | +| 0.1.x | no | + +Supported Python versions: 3.10 to 3.14. + +## Scope + +In scope: a decorator that fails to enforce what its documentation promises in a way +that has security impact (a rate limit or circuit breaker that can be bypassed, a cache +that leaks results between callers that should not share them, a validation decorator +that accepts what it documents as rejected, a denial of service from unbounded memory +growth in a documented-bounded structure). + +Out of scope: behaviour documented as such, and findings from an automated scanner with +no demonstrated impact. diff --git a/benchmarks/run.py b/benchmarks/run.py new file mode 100644 index 0000000..5642ac3 --- /dev/null +++ b/benchmarks/run.py @@ -0,0 +1,284 @@ +"""Micro-benchmarks for python-wrap decorators (standard library only). + +For every decorator this measures the per-call cost of a trivial function wrapped with +it, for sync and ``async def`` functions, next to the bare (undecorated) call. + + python benchmarks/run.py # full run (about a minute) + python benchmarks/run.py --quick # <= 5 s, noisier + python benchmarks/run.py --filter memoize --json + +Numbers are total nanoseconds per call (loop overhead included, identical for the bare +baseline), so ``x bare`` is the slowdown factor. Timings are the best of several repeats. +""" + +from __future__ import annotations + +import argparse +import asyncio +import functools +import json +import logging +import platform +import signal +import sys +import time +import warnings +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + import python_wrap # noqa: F401 +except ImportError: # running from a checkout without installing + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from python_wrap.batching import batch_calls +from python_wrap.bulkhead import Bulkhead, limit_concurrency +from python_wrap.caching import cache, memoize, once +from python_wrap.circuit import circuit_breaker +from python_wrap.concurrency import rate_limit, singleflight, throttle, timeout +from python_wrap.devtools import deprecate, mock_data +from python_wrap.logs import audit, log_args +from python_wrap.retry import retry +from python_wrap.timing import timing +from python_wrap.validation import check_type, enforce_types, validate_args + +perf_ns = time.perf_counter_ns + +Fn = Callable[..., Any] +Factory = Callable[[Fn], Fn] + + +def add(a: int, b: int) -> int: + return a + b + + +async def aadd(a: int, b: int) -> int: + return a + b + + +@dataclass +class Case: + name: str + sync: Factory | None # takes the bare sync function, returns the decorated one + asyn: Factory | None # same for the async function + + +def _quiet_logger() -> logging.Logger: + lg = logging.getLogger("python_wrap.bench") + lg.setLevel(logging.CRITICAL + 10) + lg.propagate = False + return lg + + +LOG = _quiet_logger() +HAS_SIGNALS = hasattr(signal, "setitimer") and sys.platform != "win32" + + +def _both(make: Callable[[], Callable[[Any], Any]]) -> Case: + """A case whose decorator works on sync and async functions alike.""" + return Case("", lambda fn: make()(fn), lambda fn: make()(fn)) + + +def _case(name: str, make: Callable[[], Callable[[Any], Any]]) -> Case: + c = _both(make) + c.name = name + return c + + +def _stack(fn: Fn) -> Fn: + return retry(max_attempts=3, delay=0, logger=LOG)( + timeout(5, use_signals=False)(circuit_breaker(failure_threshold=10, logger=LOG)(fn)) + ) + + +def _stack_async(fn: Fn) -> Fn: + return retry(max_attempts=3, delay=0, logger=LOG)( + timeout(5)(circuit_breaker(failure_threshold=10, logger=LOG)(fn)) + ) + + +def _batch_async(fn: Fn) -> Fn: + @batch_calls(max_size=1, max_wait=0) + async def batched(items: list[int]) -> list[int]: + return items + + async def call(a: int, b: int) -> int: + return int(await batched(a)) + + return call + + +def cases() -> list[Case]: + out = [ + _case("timing (logging off)", lambda: timing(logger=LOG)), + _case("retry (success path)", lambda: retry(max_attempts=3, delay=0, logger=LOG)), + _case("memoize (hit)", lambda: memoize), + _case("memoize(maxsize=128) (hit)", lambda: memoize(maxsize=128)), + Case("functools.lru_cache(128) (hit)", functools.lru_cache(maxsize=128), None), + Case("functools.cache (hit)", functools.cache, None), + _case("once (hit)", lambda: once), + _case("cache(60) (hit)", lambda: cache(60)), + _case("rate_limit (under limit)", lambda: rate_limit(10**9, 1.0)), + _case("throttle (skipped)", lambda: throttle(3600)), + _case("singleflight (uncontended)", lambda: singleflight), + _case("circuit_breaker (closed)", lambda: circuit_breaker(logger=LOG)), + Case("timeout (thread fallback)", timeout(5, use_signals=False), None), + Case("timeout (signal)", timeout(5, use_signals=True), None) if HAS_SIGNALS else None, + Case("timeout (async)", None, timeout(5)), + _case("log_args (level off)", lambda: log_args(logger=LOG)), + _case("audit (logging off)", lambda: audit(logger=LOG)), + _case("validate_args", lambda: validate_args(a=lambda v: v >= 0, b=lambda v: v >= 0)), + _case("check_type", lambda: check_type(a=int, b=int)), + _case("enforce_types", lambda: enforce_types), + _case("deprecate (warning ignored)", lambda: deprecate("old")), + _case("mock_data (disabled)", lambda: mock_data(1, enabled=False)), + _case("mock_data (enabled)", lambda: mock_data(1)), + _case("limit_concurrency(8)", lambda: limit_concurrency(8)), + _case("Bulkhead(8)", lambda: Bulkhead(8)), + Case("batch_calls (max_size=1)", None, _batch_async), + Case("retry+timeout+circuit_breaker", _stack, _stack_async), + ] + return [c for c in out if c is not None] + + +# -- measuring ------------------------------------------------------------------------ + + +def _time_sync(f: Fn, n: int) -> float: + t0 = perf_ns() + for _ in range(n): + f(1, 2) + return (perf_ns() - t0) / n + + +async def _arun(f: Callable[..., Awaitable[Any]], n: int) -> float: + t0 = perf_ns() + for _ in range(n): + await f(1, 2) + return (perf_ns() - t0) / n + + +def _time_async(f: Fn, n: int) -> float: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(_arun(f, n)) + finally: + loop.close() + + +def _measure( + timer: Callable[[Fn, int], float], f: Fn, budget_ns: float, repeats: int, n_fixed: int | None +) -> float: + for _ in range(3): # warm-up: fills caches, runs first-call setup + timer(f, 1) + if n_fixed: + n = n_fixed + else: + probe = max(timer(f, 20), 1.0) + n = max(20, min(200_000, int(budget_ns / repeats / probe))) + return min(timer(f, n) for _ in range(repeats)) + + +def run(selected: list[Case], quick: bool, iterations: int | None) -> list[dict[str, Any]]: + cells = sum((c.sync is not None) + (c.asyn is not None) for c in selected) + 2 + budget = (2.5 / cells if quick else 0.35) * 1e9 # ns of measuring per cell + repeats = 3 if quick else 7 + base = { + "sync": _measure(_time_sync, add, budget, repeats, iterations), + "async": _measure(_time_async, aadd, budget, repeats, iterations), + } + results: list[dict[str, Any]] = [ + { + "name": "bare call", + "sync_ns": base["sync"], + "async_ns": base["async"], + "sync_x": 1.0, + "async_x": 1.0, + } + ] + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for c in selected: + row: dict[str, Any] = { + "name": c.name, + "sync_ns": None, + "async_ns": None, + "sync_x": None, + "async_x": None, + } + for kind, factory, fn, timer in ( + ("sync", c.sync, add, _time_sync), + ("async", c.asyn, aadd, _time_async), + ): + if factory is None: + continue + try: + ns = _measure(timer, factory(fn), budget, repeats, iterations) + except Exception as exc: # report, don't abort the whole run + row[f"{kind}_error"] = f"{type(exc).__name__}: {exc}" + continue + row[f"{kind}_ns"] = ns + row[f"{kind}_x"] = ns / base[kind] + results.append(row) + return results + + +def _fmt(ns: float | None) -> str: + return "-" if ns is None else f"{ns:,.0f}" + + +def _fx(x: float | None) -> str: + return "-" if x is None else f"{x:.1f}x" + + +def markdown(results: list[dict[str, Any]]) -> str: + lines = [ + "| Decorator | sync ns/call | async ns/call | sync x bare | async x bare |", + "| --- | ---: | ---: | ---: | ---: |", + ] + for r in results: + lines.append( + f"| {r['name']} | {_fmt(r['sync_ns'])} | {_fmt(r['async_ns'])} " + f"| {_fx(r['sync_x'])} | {_fx(r['async_x'])} |" + ) + for r in results: + for k in ("sync_error", "async_error"): + if k in r: + lines.append(f"\n> {r['name']} ({k[:-6]}): {r[k]}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + p.add_argument("--quick", action="store_true", help="finish in <= 5 s (noisier numbers)") + p.add_argument("--filter", default="", help="only decorators whose name contains this text") + p.add_argument("--json", action="store_true", help="print JSON instead of a Markdown table") + p.add_argument( + "--iterations", type=int, default=None, help="fixed calls per repeat (skip calibration)" + ) + args = p.parse_args(argv) + + selected = [c for c in cases() if args.filter.lower() in c.name.lower()] + if not selected: + print(f"no decorator matches {args.filter!r}", file=sys.stderr) + return 2 + results = run(selected, args.quick, args.iterations) + if args.json: + meta = { + "python": platform.python_version(), + "implementation": platform.python_implementation(), + "platform": platform.platform(), + "machine": platform.machine(), + "quick": args.quick, + } + print(json.dumps({"meta": meta, "results": results}, indent=2)) + else: + print(markdown(results)) + print(f"\nPython {platform.python_version()} on {platform.platform()}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api/aio.md b/docs/api/aio.md new file mode 100644 index 0000000..443c5c1 --- /dev/null +++ b/docs/api/aio.md @@ -0,0 +1 @@ +::: python_wrap.aio diff --git a/docs/api/batching.md b/docs/api/batching.md new file mode 100644 index 0000000..deed706 --- /dev/null +++ b/docs/api/batching.md @@ -0,0 +1 @@ +::: python_wrap.batching diff --git a/docs/api/bulkhead.md b/docs/api/bulkhead.md new file mode 100644 index 0000000..f5feac1 --- /dev/null +++ b/docs/api/bulkhead.md @@ -0,0 +1 @@ +::: python_wrap.bulkhead diff --git a/docs/api/caching.md b/docs/api/caching.md new file mode 100644 index 0000000..0150ae2 --- /dev/null +++ b/docs/api/caching.md @@ -0,0 +1 @@ +::: python_wrap.caching diff --git a/docs/api/circuit.md b/docs/api/circuit.md new file mode 100644 index 0000000..be30c03 --- /dev/null +++ b/docs/api/circuit.md @@ -0,0 +1 @@ +::: python_wrap.circuit diff --git a/docs/api/concurrency.md b/docs/api/concurrency.md new file mode 100644 index 0000000..f59077a --- /dev/null +++ b/docs/api/concurrency.md @@ -0,0 +1 @@ +::: python_wrap.concurrency diff --git a/docs/api/config.md b/docs/api/config.md new file mode 100644 index 0000000..21d6348 --- /dev/null +++ b/docs/api/config.md @@ -0,0 +1 @@ +::: python_wrap.config diff --git a/docs/api/deadline.md b/docs/api/deadline.md new file mode 100644 index 0000000..14c541f --- /dev/null +++ b/docs/api/deadline.md @@ -0,0 +1 @@ +::: python_wrap.deadline diff --git a/docs/api/devtools.md b/docs/api/devtools.md new file mode 100644 index 0000000..da23181 --- /dev/null +++ b/docs/api/devtools.md @@ -0,0 +1 @@ +::: python_wrap.devtools diff --git a/docs/api/errors.md b/docs/api/errors.md new file mode 100644 index 0000000..08ad6e5 --- /dev/null +++ b/docs/api/errors.md @@ -0,0 +1 @@ +::: python_wrap.errors diff --git a/docs/api/events.md b/docs/api/events.md new file mode 100644 index 0000000..8dd103d --- /dev/null +++ b/docs/api/events.md @@ -0,0 +1 @@ +::: python_wrap.events diff --git a/docs/api/hedging.md b/docs/api/hedging.md new file mode 100644 index 0000000..a6835f6 --- /dev/null +++ b/docs/api/hedging.md @@ -0,0 +1 @@ +::: python_wrap.hedging diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..64c75d9 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,19 @@ +# API reference + +Everything is importable from the top-level `python_wrap` package. These pages are generated from the docstrings, so they always match the code. + +| Module | Contents | +| --- | --- | +| [errors](errors.md) | `PythonWrapError`, `TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `ValidationError`, `ArgumentTypeError`, `BulkheadFull` | +| [retry](retry.md) | `retry`, `fallback`, `suppress`, `retry_on_exception` | +| [circuit](circuit.md) | `CircuitBreaker`, `circuit_breaker`, `CircuitState`, `CircuitStats` | +| [deadline](deadline.md) | `deadline`, `remaining`, `check`, `Deadline` | +| [caching](caching.md) | `memoize`, `cache`, `once` | +| [concurrency](concurrency.md) | `timeout`, `Timeout`, `rate_limit`, `RateLimiter`, `throttle`, `debounce`, `singleflight`, `run_in_thread` | +| [aio](aio.md) | `gather_limit`, `map_async`, `amap_iter`, `race`, `RaceFailed`, `run_sync` | +| [bulkhead](bulkhead.md) | `Bulkhead`, `limit_concurrency`, `Limited` | +| [timing](timing.md) | `timing`, `benchmark`, `benchmark_async`, `measure_memory`, `profile` | +| [logs](logs.md) | `log_args`, `log_return`, `audit`, `trace`, `DEFAULT_REDACT` | +| [validation](validation.md) | `validate_args`, `check_type`, `enforce_types` | +| [state](state.md) | `revert_on_failure`, `Transaction`, `transactional` | +| [devtools](devtools.md) | `deprecate`, `mock_data`, `compose`, `no_debug` | diff --git a/docs/api/logs.md b/docs/api/logs.md new file mode 100644 index 0000000..b7088f1 --- /dev/null +++ b/docs/api/logs.md @@ -0,0 +1 @@ +::: python_wrap.logs diff --git a/docs/api/metrics.md b/docs/api/metrics.md new file mode 100644 index 0000000..413e3e4 --- /dev/null +++ b/docs/api/metrics.md @@ -0,0 +1 @@ +::: python_wrap.metrics diff --git a/docs/api/policy.md b/docs/api/policy.md new file mode 100644 index 0000000..8c9a91e --- /dev/null +++ b/docs/api/policy.md @@ -0,0 +1 @@ +::: python_wrap.policy diff --git a/docs/api/retry.md b/docs/api/retry.md new file mode 100644 index 0000000..8b67c5f --- /dev/null +++ b/docs/api/retry.md @@ -0,0 +1 @@ +::: python_wrap.retry diff --git a/docs/api/state.md b/docs/api/state.md new file mode 100644 index 0000000..9f01244 --- /dev/null +++ b/docs/api/state.md @@ -0,0 +1 @@ +::: python_wrap.state diff --git a/docs/api/testing.md b/docs/api/testing.md new file mode 100644 index 0000000..4243917 --- /dev/null +++ b/docs/api/testing.md @@ -0,0 +1 @@ +::: python_wrap.testing diff --git a/docs/api/timing.md b/docs/api/timing.md new file mode 100644 index 0000000..4dca44e --- /dev/null +++ b/docs/api/timing.md @@ -0,0 +1 @@ +::: python_wrap.timing diff --git a/docs/api/validation.md b/docs/api/validation.md new file mode 100644 index 0000000..d6e47dc --- /dev/null +++ b/docs/api/validation.md @@ -0,0 +1 @@ +::: python_wrap.validation diff --git a/docs/async.md b/docs/async.md new file mode 100644 index 0000000..d301456 --- /dev/null +++ b/docs/async.md @@ -0,0 +1,84 @@ +# Async support + +Every decorator in python-wrap works on `async def` functions. You use it the same way; the wrapper behaves correctly for coroutines. + +## What you get + +When you decorate an `async def` function, python-wrap returns an `async def` wrapper. That means: + +- `inspect.iscoroutinefunction(wrapped)` stays `True`, so frameworks that check it (FastAPI, Starlette, pytest-asyncio, `asyncio.to_thread` guards) treat it correctly. +- The decorator awaits your function. It does not return a coroutine to a sync wrapper that hides it. +- Waiting uses `asyncio.sleep`, so the event loop is never blocked. + +```python +import asyncio +import inspect +from python_wrap import ( + circuit_breaker, fallback, memoize, once, rate_limit, retry, singleflight, + throttle, timeout, timing, +) + +@retry(max_attempts=2, delay=0.01) +@timeout(1) +@timing +async def fetch() -> str: + return "ok" + +assert inspect.iscoroutinefunction(fetch) +assert asyncio.run(fetch()) == "ok" + +for decorator in ( + retry, fallback(0), memoize, once, singleflight, timeout(1), throttle(1), + rate_limit(1, 1), circuit_breaker, timing, +): + async def probe() -> None: ... + assert inspect.iscoroutinefunction(decorator(probe)), decorator +``` + +Methods work too, including `async def` methods; `self` is just the first argument. + +## Per-decorator behaviour + +| Decorator | What is different for `async def` | +| --- | --- | +| `retry` | Awaits the function and sleeps with `asyncio.sleep` between attempts. | +| `fallback`, `suppress` | Catch exceptions from the awaited call. The `fallback` handler is still a plain (sync) function. | +| `circuit_breaker`, `CircuitBreaker` | Counts failures from the awaited call. `breaker.call(async_func, ...)` returns a coroutine to await. State changes are lock-protected but never hold the lock across an `await`. | +| `timeout` | Cancels the coroutine with `asyncio.wait_for`, which actually stops the work. | +| `memoize`, `cache` | Caches the **awaited** result. Concurrent awaits of the same missing key share one in-flight task, so the function runs once. Cancelling one caller does not cancel the shared computation. | +| `once` | Concurrent first awaits share one task. A first call that raises can be retried. | +| `singleflight` | De-duplicates across tasks on the same event loop, using a shared task. | +| `rate_limit` | With `block=True` waits with `await`; otherwise raises `RateLimitExceeded`. | +| `throttle` | Same semantics with an `async` wrapper. | +| `debounce` | Schedules a task on the running loop. Every caller in a burst awaits the same outcome (the last call's result). Tracked per event loop. | +| `run_in_thread` | The coroutine runs with `asyncio.run` in a new thread (its own loop). The wrapper is a plain function returning a `Future`. | +| `timing`, `measure_memory`, `profile` | Cover the whole awaited coroutine. (`profile` includes event-loop work for other tasks; see [Limitations](limitations.md#measure_memory-and-profile).) | +| `benchmark` / `benchmark_async` | Use `benchmark_async` for coroutine functions; `benchmark` raises `TypeError`. | +| `log_args`, `log_return`, `audit`, `trace` | Log around the awaited call. | +| `validate_args`, `check_type`, `enforce_types` | Validate at call time; `enforce_types` checks the awaited result against the return annotation. | +| `revert_on_failure`, `transactional` | Work on `async def`. `Transaction` supports `async with`, and async callbacks are awaited. | +| `deprecate` | Warns on every await. | +| `mock_data` | Works on `async def`; the awaited result is the data. | + +## Calling async code from sync code + +Use `asyncio.run` from a plain script or thread, as the examples in these docs do: + +```python +import asyncio +from python_wrap import retry + +@retry(max_attempts=2, delay=0.01) +async def job() -> int: + return 1 + +assert asyncio.run(job()) == 1 +``` + +To run a coroutine function in the background from sync code, use [`run_in_thread`](guides/concurrency.md#run_in_thread). It gives the coroutine its own event loop in a new thread and hands you a `Future`. + +## Gotchas + +- **One event loop per `debounce` and `singleflight` burst.** Their state is tracked per loop; do not share a debounced coroutine across loops and expect them to coordinate. +- **`rate_limit(block=False)` raises inside the coroutine**, at the `await`, not when you call the function. +- **Sync decorators on an `async def` function you wrote yourself** (a `functools.wraps` wrapper that is not itself `async def`) hide the coroutine from python-wrap's detection. Stack python-wrap decorators directly on the `async def`, or make your wrapper `async def`. diff --git a/docs/caching.rst b/docs/caching.rst deleted file mode 100644 index d2dfa51..0000000 --- a/docs/caching.rst +++ /dev/null @@ -1,91 +0,0 @@ -Caching & Optimization -====================== - -This section covers tools for caching and optimizing function execution. - -@memoize --------- -Cache the results of a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @memoize - def expensive_calculation(x, y): - # Perform a costly operation - return x * y - -**Usage as a Function:** - -.. code-block:: python - - def expensive_calculation(x, y): - # Perform a costly operation - return x * y - - memoized_function = memoize(expensive_calculation) - result = memoized_function(5, 10) # Caches the result for (5, 10) - -@cache ------- -Cache the results of a function for a specified duration. - -**Parameters:** -- **duration (timedelta):** The time duration for which the cached result is valid. -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - from datetime import timedelta - - @cache(duration=timedelta(minutes=5)) - def fetch_data(): - # Retrieve data from an API or database - return {"key": "value"} - -**Usage as a Function:** - -.. code-block:: python - - from datetime import timedelta - - def fetch_data(): - # Retrieve data from an API or database - return {"key": "value"} - - cached_function = cache(duration=timedelta(minutes=5))(fetch_data) - result = cached_function() # Caches the result for 5 minutes - -@once ------ -Ensure a function is executed only once. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @once - def initialize_service(): - # Perform a one-time initialization - print("Service initialized") - -**Usage as a Function:** - -.. code-block:: python - - def initialize_service(): - # Perform a one-time initialization - print("Service initialized") - - one_time_function = once(initialize_service) - one_time_function() # Executes the function - one_time_function() # Does nothing, as the function has already been executed \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..786b75d --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/choosing.md b/docs/choosing.md new file mode 100644 index 0000000..90a12bc --- /dev/null +++ b/docs/choosing.md @@ -0,0 +1,155 @@ +# Which decorator do I need? + +Find your problem, use the decorator in the second column. + +## Things fail + +| Problem | Use | Guide | +| --- | --- | --- | +| A network call fails now and then | `@retry(on=ConnectionError, delay=..., backoff=2)` | [Resilience](guides/resilience.md#retry) | +| I want a default value instead of an exception | `@fallback(value)` or `@fallback(handler=fn)` | [Resilience](guides/resilience.md#fallback) | +| I just want an error to go away (returns `None`) | `@suppress(KeyError)` | [Resilience](guides/resilience.md#suppress) | +| A dependency is down and I keep hammering it | `@circuit_breaker(...)` or a shared `CircuitBreaker` | [Resilience](guides/resilience.md#circuit-breaker) | +| A call can hang forever | `@timeout(seconds)` or `with timeout(seconds):` | [Resilience](guides/resilience.md#timeout) | +| The server says `Retry-After` | `@retry(delay_from=retry_after_header)` | [Resilience](guides/resilience.md#honour-retry-after) | +| Retry only part of a function | `for attempt in retrying(...)` | [Resilience](guides/resilience.md#retrying-a-block-of-code) | +| A whole request needs one time budget | `with deadline(seconds):` | [Resilience](guides/resilience.md#deadlines-one-budget-for-many-steps) | +| A read is occasionally very slow | `@hedge(delay)` (idempotent calls only) | [Batching and hedging](guides/batching.md#hedge) | +| Apply retry, breaker and timeout in the right order | `policy(retry=..., breaker=..., timeout=...)` | [Policies](guides/policies.md) | + +## Things are slow or repeated + +| Problem | Use | Guide | +| --- | --- | --- | +| Same arguments, same answer, compute it once | `@memoize` | [Caching](guides/caching.md#memoize) | +| Cached values should go stale after N seconds | `@memoize(ttl=...)` | [Caching](guides/caching.md#expiry-and-size) | +| Expensive one-time setup | `@once` | [Caching](guides/caching.md#once) | +| 100 concurrent requests for the same thing | `@singleflight` | [Concurrency](guides/concurrency.md#singleflight) | +| An expensive attribute on an object | `@cached_property(ttl=...)` | [Caching](guides/caching.md#cached_property) | +| Run a blocking function in the background | `@run_in_thread` | [Concurrency](guides/concurrency.md#run_in_thread) | + +## Too many things at once + +| Problem | Use | Guide | +| --- | --- | --- | +| `gather` thousands of coroutines without flooding a server | `await gather_limit(*coros, limit=20)` | [Async toolkit](guides/async-toolkit.md#gather_limit) | +| Apply an async function to a list, a few at a time | `await map_async(fn, items, limit=20)` | [Async toolkit](guides/async-toolkit.md#map_async-and-amap_iter) | +| Take whichever of several calls answers first | `await race(a(), b())` | [Async toolkit](guides/async-toolkit.md#race) | +| Call async code from a script | `run_sync(coro)` | [Async toolkit](guides/async-toolkit.md#run_sync) | +| Protect a dependency from overload; fail fast when full | `Bulkhead(n, max_waiting=...)` or `@limit_concurrency(n)` | [Async toolkit](guides/async-toolkit.md#bulkhead) | +| Many single lookups should become one bulk query | `@batch_calls(max_size=..., max_wait=...)` | [Batching and hedging](guides/batching.md#batch_calls) | + +## Things happen too often + +| Problem | Use | Guide | +| --- | --- | --- | +| Reject calls over N per second | `@rate_limit(n, period)` | [Concurrency](guides/concurrency.md#rate_limit) | +| Slow callers down instead of rejecting them | `@rate_limit(n, period, block=True)` | [Concurrency](guides/concurrency.md#rate_limit) | +| One limit per user or API key | `@rate_limit(..., key=...)` | [Concurrency](guides/concurrency.md#one-limit-per-user) | +| Several functions share one API quota | `RateLimiter(n, period)` | [Concurrency](guides/concurrency.md#one-budget-for-several-functions-ratelimiter) | +| Run at most once per interval, skip the rest | `@throttle(interval)` | [Concurrency](guides/concurrency.md#throttle) | +| Wait for a burst of calls to settle, then run once | `@debounce(wait)` | [Concurrency](guides/concurrency.md#debounce) | + +## I need to see what is happening + +| Problem | Use | Guide | +| --- | --- | --- | +| How long does this take? | `@timing` | [Observability](guides/observability.md#timing) | +| Send durations to my metrics system | `@timing(callback=...)` | [Observability](guides/observability.md#timing) | +| Compare how fast a function is over many runs | `benchmark(func, ...)` | [Observability](guides/observability.md#benchmark) | +| How much memory does it allocate? | `@measure_memory` | [Observability](guides/observability.md#measure_memory) | +| Where does the time go? | `@profile` | [Observability](guides/observability.md#profile) | +| Log the arguments (without leaking passwords) | `@log_args` | [Observability](guides/observability.md#log_args-and-log_return) | +| Log what a function returns | `@log_return` | [Observability](guides/observability.md#log_args-and-log_return) | +| Keep an audit trail of who called what | `@audit` | [Observability](guides/observability.md#audit) | +| Log the call stack that led here | `@trace` | [Observability](guides/observability.md#trace) | +| Count calls, errors and p95 latency per function | `@count_calls` then `func.stats` | [Metrics and events](guides/metrics.md#count_calls) | +| Feed retries, cache hits and breaker trips to Prometheus or OpenTelemetry | `subscribe(listener)` | [Metrics and events](guides/metrics.md#events) | +| Put a request id on every log line | `with log_context(request_id=...):` | [Observability](guides/observability.md#log_context) | + +## Bad input + +| Problem | Use | Guide | +| --- | --- | --- | +| Check arguments with my own rules | `@validate_args(x=lambda x: x > 0)` | [Validation](guides/validation.md#validate_args) | +| `isinstance` checks on a few arguments | `@check_type(x=int)` | [Validation](guides/validation.md#check_type) | +| Enforce my type annotations at runtime | `@enforce_types` | [Validation](guides/validation.md#enforce_types) | + +## Half-finished work + +| Problem | Use | Guide | +| --- | --- | --- | +| Undo changes to `self` if a method raises | `@revert_on_failure` | [State safety](guides/state.md#revert_on_failure) | +| Register undo steps and run them if anything fails | `@transactional` / `Transaction` | [State safety](guides/state.md#transactions) | + +## Maintaining and testing code + +| Problem | Use | Guide | +| --- | --- | --- | +| Tests that wait on retries and backoff | `with fast_mode():` from `python_wrap.testing` | [Testing](testing.md) | +| Switch retries, rate limits or caches off in a test | `disable_retries()`, `disable_rate_limits()`, `disable_caching()` | [Testing](testing.md) | +| Control time in a test | `FakeClock` / `fake_clock()` | [Testing](testing.md) | +| Set the same logger, retry defaults or redaction everywhere | `configure(...)` | [Configuration](guides/configuration.md) | +| Protect every method of a class | `@wrap_methods(...)` | [Developer tools](guides/devtools.md#wrap_methods) | +| Warn callers that an API is going away | `@deprecate(since=..., alternative=...)` | [Developer tools](guides/devtools.md#deprecate) | +| Return fixed data in tests or local runs | `@mock_data(...)` | [Developer tools](guides/devtools.md#mock_data) | +| Reuse a stack of decorators | `compose(a, b, c)` | [Developer tools](guides/devtools.md#compose) | + +## Common recipes + +??? example "Robust HTTP call" + + ```python + from python_wrap import circuit_breaker, retry, timeout + + @circuit_breaker(failure_threshold=5, recovery_timeout=30) + @retry(max_attempts=3, delay=0.2, backoff=2, jitter=0.1, on=(ConnectionError, TimeoutError)) + @timeout(5) + def call_api() -> dict: + ... + ``` + + Each attempt gets its own 5 second deadline, and the breaker counts a call as failed only once its retries are used up. See [Stacking decorators](stacking.md). + +??? example "Cached, de-duplicated lookup" + + ```python + from python_wrap import memoize, singleflight + + @memoize(ttl=30) + @singleflight + def load_price(sku: str) -> float: + ... + ``` + +??? example "Public API with input checks" + + ```python + from python_wrap import enforce_types, log_args, validate_args + + @log_args + @enforce_types + @validate_args(amount=(lambda a: a > 0, "amount must be positive")) + def charge(account: str, amount: float) -> None: + ... + ``` + +??? example "The same thing as one named policy" + + ```python + from python_wrap import CircuitBreaker, policy, retry, retry_after_header, timeout + + api = policy( + breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30), + retry=retry(max_attempts=3, delay=0.2, backoff=2, on=ConnectionError, + delay_from=retry_after_header), + timeout=timeout(5), + name="api", + ) + + @api + def call_api() -> dict: + ... + ``` + + The roles decide the order, so listing them differently changes nothing. See [Policies](guides/policies.md). diff --git a/docs/concurrency.rst b/docs/concurrency.rst deleted file mode 100644 index 97dca57..0000000 --- a/docs/concurrency.rst +++ /dev/null @@ -1,54 +0,0 @@ -Concurrency -=========== - -This section covers tools for managing concurrency. - -@run_in_thread --------------- -Run a function asynchronously in a separate thread. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @run_in_thread - def background_task(): - print("This task is running in a separate thread.") - -**Usage as a Function:** - -.. code-block:: python - - def background_task(): - print("This task is running in a separate thread.") - - threaded_function = run_in_thread(background_task) - threaded_function() # Runs in a separate thread - -@transactional --------------- -Ensure atomic operations within a transaction. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @transactional - def update_user_balance(user_id, amount): - print(f"Updating user {user_id} balance by {amount}.") - -**Usage as a Function:** - -.. code-block:: python - - def update_user_balance(user_id, amount): - print(f"Updating user {user_id} balance by {amount}.") - - transactional_function = transactional(update_user_balance) - transactional_function(42, 100) # Ensures atomic execution \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 5a51a83..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,28 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = 'PythonWrap' -copyright = '2025, Mahmoud Ahmed' -author = 'Mahmoud Ahmed' -release = '0.1.0' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [] - -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - - - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = 'alabaster' -html_static_path = ['_static'] diff --git a/docs/cookbook/async-crawler.md b/docs/cookbook/async-crawler.md new file mode 100644 index 0000000..e9f4631 --- /dev/null +++ b/docs/cookbook/async-crawler.md @@ -0,0 +1,278 @@ +# An async crawler and bulk API fetch + +## The problem + +You need to fetch thousands of URLs (a crawl, a nightly sync, "enrich these 50,000 rows from an API"). Plain `asyncio.gather` starts everything at once and exhausts sockets or gets you banned. A hand-written `Semaphore` plus a queue plus error handling is forty lines you rewrite every time. You also want: + +- a **global** cap on concurrent requests, +- a **per-host** cap, so one slow site cannot absorb every slot and nobody is hammered, +- failures **collected**, not fatal to the whole batch, +- a way to ask several **mirrors** and take the first good answer. + +## The code + +`map_async` is the global cap, `Bulkhead` is the per-host cap, `race` handles mirrors and `run_sync` is the sync entry point. The network is simulated with `asyncio.sleep` so the page runs offline. + +```python +import asyncio +from collections import Counter +from urllib.parse import urlsplit + +from python_wrap.aio import map_async, race, run_sync +from python_wrap.bulkhead import Bulkhead + +# --- simulated network ------------------------------------------------------------- + +LATENCY = {"fast.example": 0.002, "slow.example": 0.01, "mirror-a.example": 0.02} +in_flight: Counter[str] = Counter() +peak: Counter[str] = Counter() + + +async def http_get(url: str) -> str: + host = urlsplit(url).netloc + in_flight[host] += 1 + peak[host] = max(peak[host], in_flight[host]) + try: + await asyncio.sleep(LATENCY.get(host, 0.005)) + if "broken" in url: + raise ConnectionError(f"cannot reach {url}") + return f"{url}" + finally: + in_flight[host] -= 1 + + +# --- the crawler ------------------------------------------------------------------- + +PER_HOST = 2 +_host_bulkheads: dict[str, Bulkhead] = {} + + +def bulkhead_for(host: str) -> Bulkhead: + if host not in _host_bulkheads: + _host_bulkheads[host] = Bulkhead(PER_HOST, timeout=5, name=host) + return _host_bulkheads[host] + + +async def fetch(url: str) -> str: + async with bulkhead_for(urlsplit(url).netloc): + return await http_get(url) + + +async def fetch_from_mirrors(path: str, mirrors: list[str]) -> str: + """First mirror to answer wins; the others are cancelled.""" + return await race(*(fetch(f"https://{m}{path}") for m in mirrors), timeout=2) + + +async def crawl( + urls: list[str], *, limit: int = 8 +) -> tuple[dict[str, str], dict[str, BaseException]]: + results = await map_async(fetch, urls, limit=limit, return_exceptions=True) + pages: dict[str, str] = {} + errors: dict[str, BaseException] = {} + for url, result in zip(urls, results, strict=True): + if isinstance(result, BaseException): + errors[url] = result + else: + pages[url] = result + return pages, errors + + +async def amain() -> None: + urls = [f"https://{host}/page/{i}" for i in range(10) for host in LATENCY] + urls.append("https://fast.example/broken") + + pages, errors = await crawl(urls) + print(f"fetched {len(pages)} pages, {len(errors)} failed") + assert len(pages) == 30 + assert list(errors) == ["https://fast.example/broken"] + assert all(count <= PER_HOST for count in peak.values()), peak + print("peak concurrency per host:", dict(peak)) + + body = await fetch_from_mirrors("/file", ["mirror-a.example", "fast.example"]) + assert body == "https://fast.example/file" + print("mirror race won by the fast mirror") + + +def main() -> None: + run_sync(amain()) # the entry point of a sync script + + +main() +``` + +## How it works + +* **`map_async(fetch, urls, limit=8, return_exceptions=True)`** runs `fetch` on every URL with at most 8 in flight. It reads `urls` lazily (an item is pulled only when a slot frees), so a generator of a million URLs never becomes a million tasks. Results come back in input order; with `return_exceptions=True` a failure is an item in the list rather than an exception that cancels everything else. +* **`Bulkhead(PER_HOST, timeout=5)`** per host. `async with bulkhead:` takes a slot or waits for one; if a caller waits more than `timeout` seconds it gets `BulkheadFull` (a `PythonWrapError`), which the crawler records as that URL's error like any other. Use `max_waiting=` to bound the queue instead of the time. +* **`race(*coros, timeout=2)`** starts every mirror, returns the first **successful** result and cancels and awaits the losers. A mirror that fails does not end the race. If all fail, `RaceFailed` carries every exception in `.exceptions`. +* **`run_sync(amain())`** is `asyncio.run` that also accepts a function and its arguments. It refuses to run inside an already-running loop (rather than hanging), which is what you want from a script entry point. + +For inputs too large to hold the results, stream them. `amap_iter` yields results as they are ready and cancels the in-flight calls if you stop early: + +```python +import asyncio +from contextlib import aclosing + +from python_wrap.aio import amap_iter + + +async def fetch(n: int) -> str: + await asyncio.sleep(0) + return f"page {n}" + + +async def main() -> None: + seen = 0 + async with aclosing(amap_iter(fetch, range(1_000_000), limit=16)) as pages: + async for page in pages: + seen += 1 + if seen == 100: # stop early: the rest are never fetched + break + assert seen == 100 + + +asyncio.run(main()) +``` + +If you already have a fixed list of coroutines, `gather_limit(*coros, limit=8)` is `asyncio.gather` with a cap. + +## Pitfalls + +!!! warning "Two limits can block each other" + A task occupies one of the global `limit` slots while it waits for its host's bulkhead. If your URL list is sorted by host and the global limit is 8 but the per-host limit is 2, six slots sit idle waiting on one host while other hosts have no work. Interleave URLs from different hosts (as the demo does), keep the global limit close to `hosts * per_host`, or sort by round-robin. Symptoms: throughput far below `limit`. + +!!! warning "`race` is for idempotent work" + The losers are cancelled mid-flight and may already have done part of their job. Racing mirrors for a `GET` is fine; racing two `POST`s is not. + +* Without `return_exceptions=True` the **first** exception cancels the rest of the batch and is raised. That is right for "all or nothing" work and wrong for a crawl. +* `return_exceptions=True` also returns `asyncio.CancelledError` objects if a task was cancelled; check for `BaseException`, as the demo does, if that matters. +* Bulkheads keep one semaphore per event loop, so creating them at module level is safe even if you call `asyncio.run` several times, but the cap then applies **per loop**. Do not mix `with bulkhead:` (threads) and `async with bulkhead:` for the same resource; give each side its own. +* `timeout=` on a bulkhead measures time spent **waiting** for a slot, not the request itself. Put a real timeout on the HTTP client or wrap the request in `python_wrap.timeout`. +* Add `retry` to `fetch` (outside the `async with`, so a retry waits for a slot again) if transient errors are expected; see [the resilient client](resilient-http-client.md). + +## Test it + +Async code is testable with `asyncio.run` inside plain test functions, so no pytest plugin is needed. The structure to check is the **limits**: the peak concurrency, the error handling and the mirror winner. + +```python +import asyncio +from collections import Counter +from urllib.parse import urlsplit + +from python_wrap.aio import map_async, race, run_sync +from python_wrap.bulkhead import Bulkhead + +# --- simulated network ------------------------------------------------------------- + +LATENCY = {"fast.example": 0.002, "slow.example": 0.01, "mirror-a.example": 0.02} +in_flight: Counter[str] = Counter() +peak: Counter[str] = Counter() + + +async def http_get(url: str) -> str: + host = urlsplit(url).netloc + in_flight[host] += 1 + peak[host] = max(peak[host], in_flight[host]) + try: + await asyncio.sleep(LATENCY.get(host, 0.005)) + if "broken" in url: + raise ConnectionError(f"cannot reach {url}") + return f"{url}" + finally: + in_flight[host] -= 1 + + +# --- the crawler ------------------------------------------------------------------- + +PER_HOST = 2 +_host_bulkheads: dict[str, Bulkhead] = {} + + +def bulkhead_for(host: str) -> Bulkhead: + if host not in _host_bulkheads: + _host_bulkheads[host] = Bulkhead(PER_HOST, timeout=5, name=host) + return _host_bulkheads[host] + + +async def fetch(url: str) -> str: + async with bulkhead_for(urlsplit(url).netloc): + return await http_get(url) + + +async def fetch_from_mirrors(path: str, mirrors: list[str]) -> str: + """First mirror to answer wins; the others are cancelled.""" + return await race(*(fetch(f"https://{m}{path}") for m in mirrors), timeout=2) + + +async def crawl( + urls: list[str], *, limit: int = 8 +) -> tuple[dict[str, str], dict[str, BaseException]]: + results = await map_async(fetch, urls, limit=limit, return_exceptions=True) + pages: dict[str, str] = {} + errors: dict[str, BaseException] = {} + for url, result in zip(urls, results, strict=True): + if isinstance(result, BaseException): + errors[url] = result + else: + pages[url] = result + return pages, errors + +import asyncio + +import pytest + +from python_wrap.aio import RaceFailed +from python_wrap.testing import eventually + + +def test_global_and_per_host_limits() -> None: + peak.clear() + urls = [f"https://{h}/p{i}" for i in range(12) for h in LATENCY] + pages, errors = asyncio.run(crawl(urls, limit=4)) + assert len(pages) == len(urls) and not errors + assert max(peak.values()) <= PER_HOST + assert sum(1 for v in peak.values() if v) <= len(LATENCY) + + +def test_failures_are_collected_not_raised() -> None: + pages, errors = asyncio.run(crawl(["https://fast.example/ok", "https://fast.example/broken"])) + assert list(pages) == ["https://fast.example/ok"] + assert isinstance(errors["https://fast.example/broken"], ConnectionError) + + +def test_mirror_race_survives_a_broken_mirror() -> None: + # The first contestant fails at once; the race carries on and the slow site wins. + async def scenario() -> str: + async def broken() -> str: + raise ConnectionError("mirror down") + + return await race(broken(), fetch("https://slow.example/file"), timeout=2) + + assert asyncio.run(scenario()) == "https://slow.example/file" + + +def test_all_mirrors_down() -> None: + with pytest.raises(RaceFailed) as info: + asyncio.run(fetch_from_mirrors("/broken", ["fast.example", "slow.example"])) + assert len(info.value.exceptions) == 2 + + +def test_no_request_is_left_running() -> None: + def nothing_in_flight() -> None: + assert not any(in_flight.values()) + + async def scenario() -> None: + await crawl([f"https://slow.example/{i}" for i in range(6)]) + await eventually(nothing_in_flight, timeout=1) # polls until the assertion passes + + asyncio.run(scenario()) + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` + +!!! tip "Pytest-asyncio" + With `pytest-asyncio` (or `anyio`) you can write `async def test_...` directly and `await` the same calls. The helper `eventually(assertion, timeout=...)` is awaitable and polls an assertion until it passes, which is the clean way to wait for background work in an async test. diff --git a/docs/cookbook/cli-and-scripts.md b/docs/cookbook/cli-and-scripts.md new file mode 100644 index 0000000..f18201f --- /dev/null +++ b/docs/cookbook/cli-and-scripts.md @@ -0,0 +1,257 @@ +# CLI tools and scripts + +## The problem + +Scripts and command-line tools have their own small recurring needs: + +- "How long did each step take?" without sprinkling `time.time()` around. +- "Why is this slow?" without learning `cProfile`'s API. +- "Is version B really faster than version A?" with more than one measurement. +- Calling `async` library code from a normal `main()`. +- A **file watcher** that fires five events for one save and rebuilds five times. +- A progress line that prints ten thousand times a second. + +## The code + +```python +import asyncio +import pstats +import time + +from python_wrap import ( + BenchmarkResult, + TimingRecord, + benchmark, + debounce, + profile, + throttle, + timing, +) +from python_wrap.aio import run_sync + +# 1. Time a step. The callback receives a record for every call (feed a metrics system). +timings: list[TimingRecord] = [] + + +@timing(callback=timings.append, threshold=10.0) # only *log* calls slower than 10 s +def load_data() -> list[int]: + return list(range(50_000)) + + +# 2. Profile a function and keep the pstats object instead of printing. +profiles: list[pstats.Stats] = [] + + +@profile(sort="tottime", limit=5, callback=profiles.append) +def crunch(data: list[int]) -> int: + return sum(x * x for x in data) + + +# 3. Async code behind a sync CLI entry point. +async def fetch_remote_total() -> int: + await asyncio.sleep(0.001) + return 7 + + +# 4. File watcher: a save usually fires several events; rebuild once, after they stop. +builds: list[str] = [] + + +@debounce(0.03) +def rebuild(path: str) -> None: + builds.append(path) + + +# 5. Progress output at most once per interval; skipped calls return the last result. +printed: list[int] = [] + + +@throttle(0.05) +def show_progress(done: int) -> None: + printed.append(done) + + +def main() -> None: + data = load_data() + print(f"load_data took {timings[0].elapsed * 1000:.1f} ms") + assert timings[0].success and timings[0].name.endswith("load_data") + + assert crunch(data) == sum(x * x for x in data) + assert isinstance(profiles[0], pstats.Stats) + + # Compare two implementations with real statistics. + def slow() -> int: + return sum(list(range(2_000))) + + def fast() -> int: + return sum(range(2_000)) + + results: list[BenchmarkResult] = [benchmark(f, runs=20, warmup=3) for f in (slow, fast)] + for r in results: + print(f"{r.name}: median {r.median * 1e6:.1f} us over {r.runs} runs") + assert all(r.min <= r.median <= r.max for r in results) + + assert run_sync(fetch_remote_total) == 7 + print("run_sync returned 7") + + for _ in range(5): # five events from one save + rebuild("src/app.py") + assert builds == [] + time.sleep(0.08) # the quiet period; a real watcher just keeps running + assert builds == ["src/app.py"] + print("5 file events -> 1 rebuild") + + rebuild("final.py") + rebuild.flush() # at shutdown: run the pending call now instead of losing it + assert builds[-1] == "final.py" + + for done in range(100): + show_progress(done) + assert printed == [0] + print("100 progress updates -> printed", printed) + + +main() +``` + +## How it works + +* **`@timing(callback=..., threshold=...)`** measures every call with `perf_counter` (for `async def` it times the whole awaited coroutine) and logs `name took 12.34 ms`. `callback` receives a `TimingRecord(name, elapsed, success)` for **every** call, which is how you feed a metrics system or build a summary table at the end of the run. `threshold` only suppresses the *log line* for fast successful calls. +* **`@profile(callback=...)`** runs the function under `cProfile` and hands you the `pstats.Stats`. Without a callback it logs the top `limit` rows sorted by `sort`. +* **`benchmark(func, args=..., runs=..., warmup=...)`** is a plain function (not a decorator). It calls the function `warmup` times, then `runs` times, and returns a `BenchmarkResult` with `mean`, `median`, `min`, `max` and `stdev` in seconds. Compare **medians**; the mean is dragged around by outliers. Use `benchmark_async` for coroutine functions. +* **`run_sync(coro_or_function, *args)`** is `asyncio.run` for a sync entry point. It accepts a coroutine or a function plus arguments. +* **`@debounce(wait)`** postpones the call until `wait` seconds have passed since the **last** call, and each call replaces the pending arguments. Five save events in a row become one rebuild with the last path. `.flush()` runs the pending call now; `.cancel()` drops it. +* **`@throttle(interval)`** runs the function at most once per `interval`; calls inside the window do nothing and return the last result. Right for progress output and "sync at most once a minute". + +To see the log lines the decorators emit, configure logging in your CLI's `main` (python-wrap never does it for you): + + +```python +import logging + +logging.basicConfig(level=logging.INFO, format="%(message)s") +``` + +## Pitfalls + +!!! warning "A debounced function runs on a timer thread" + The body of a sync `@debounce` function executes on a daemon thread, not on the caller's thread, and a **pending call is lost when the process exits**. Call `.flush()` in your shutdown path (an `atexit` handler or a `finally`), as the demo does, or the final change never gets built. Exceptions raised by the debounced function do not reach the caller; log inside it. + +!!! warning "`run_sync` refuses to nest" + Calling `run_sync` while an event loop is already running in the same thread raises `RuntimeError`. That is deliberate: there is no safe way to block a running loop. In async code `await` instead, and from sync code called by async code use `asyncio.to_thread`. + +* `benchmark` measures wall-clock time of the call, including any allocations and I/O. For code that is faster than a microsecond, benchmark a loop of calls inside the function. For allocation numbers use `measure_memory`. +* `profile` and `measure_memory` have overhead (and `tracemalloc` is process-wide), so use them while investigating, not permanently. +* `throttle` returns `None` until the first successful call has finished, and a call that raises still uses up its window. +* `timing` on a generator times only creating it, not iterating it. + +## Test it + +Time is the enemy of script tests. Patch it: `fake_clock` replaces the monotonic clock python-wrap reads, so a throttle window passes with one `advance`. For `debounce`, whose timer is a real thread timer, call `.flush()` rather than sleeping. + +```python +import asyncio +import pstats +import time + +from python_wrap import ( + BenchmarkResult, + TimingRecord, + benchmark, + debounce, + profile, + throttle, + timing, +) +from python_wrap.aio import run_sync + +# 1. Time a step. The callback receives a record for every call (feed a metrics system). +timings: list[TimingRecord] = [] + + +@timing(callback=timings.append, threshold=10.0) # only *log* calls slower than 10 s +def load_data() -> list[int]: + return list(range(50_000)) + + +# 2. Profile a function and keep the pstats object instead of printing. +profiles: list[pstats.Stats] = [] + + +@profile(sort="tottime", limit=5, callback=profiles.append) +def crunch(data: list[int]) -> int: + return sum(x * x for x in data) + + +# 3. Async code behind a sync CLI entry point. +async def fetch_remote_total() -> int: + await asyncio.sleep(0.001) + return 7 + + +# 4. File watcher: a save usually fires several events; rebuild once, after they stop. +builds: list[str] = [] + + +@debounce(0.03) +def rebuild(path: str) -> None: + builds.append(path) + + +# 5. Progress output at most once per interval; skipped calls return the last result. +printed: list[int] = [] + + +@throttle(0.05) +def show_progress(done: int) -> None: + printed.append(done) + +import pytest + +from python_wrap.testing import fake_clock + + +def test_timing_reports_every_call() -> None: + timings.clear() + load_data() + load_data() + assert [t.success for t in timings] == [True, True] + assert all(t.name.endswith("load_data") for t in timings) + + +def test_debounce_collapses_a_burst() -> None: + builds.clear() + for path in ("a.py", "b.py", "c.py"): + rebuild(path) + rebuild.flush() # no sleeping: run the pending call now + assert builds == ["c.py"] # only the last event survived + rebuild.flush() # nothing pending any more + assert builds == ["c.py"] + + +def test_throttle_reopens_after_the_interval() -> None: + printed.clear() + with fake_clock() as clock: + show_progress(1) + show_progress(2) # inside the window: skipped + clock.advance(0.06) + show_progress(3) + assert printed == [1, 3] + + +def test_run_sync_cannot_nest() -> None: + async def inner() -> None: + with pytest.raises(RuntimeError): + run_sync(fetch_remote_total()) + + asyncio.run(inner()) + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` + +!!! note "`RuntimeWarning` for the discarded coroutine" + In the last test the coroutine passed to the refused `run_sync` is closed by `run_sync` itself, so you do not get a "never awaited" warning. diff --git a/docs/cookbook/dataloader-batching.md b/docs/cookbook/dataloader-batching.md new file mode 100644 index 0000000..09a9625 --- /dev/null +++ b/docs/cookbook/dataloader-batching.md @@ -0,0 +1,200 @@ +# DataLoader-style batching: ending N+1 queries + +## The problem + +You render a list of 20 posts, and each post needs its author. The natural code is the problem: + +```text +for post in posts: + author = get_author(post.author_id) # 20 queries +``` + +This is the "N+1" query problem, and it appears everywhere: ORMs, GraphQL resolvers, templates, serializers. Fixing it by hand means restructuring the code so someone collects all ids first, loads them in one query and passes the results back down, and that couples every layer to the loading strategy. + +[`batch_calls`](../guides/batching.md) is the DataLoader pattern: write **one bulk function** that takes a list of ids, and call it **one item at a time** from anywhere. Calls made close together are collected into a single bulk call, and each caller gets its own result. + +## The code + +```python +import asyncio +from typing import Any + +from python_wrap.batching import batch_calls + +QUERIES: list[str] = [] +AUTHORS = {i: {"id": i, "name": f"author{i}"} for i in range(1, 6)} +POSTS = [{"id": n, "author_id": n % 5 + 1, "title": f"post {n}"} for n in range(20)] + + +@batch_calls(max_size=100, max_wait=0.005) +async def get_author(ids: list[int]) -> dict[int, dict[str, Any]]: + """The one place that talks to the database. Receives distinct ids.""" + QUERIES.append(f"SELECT * FROM authors WHERE id IN ({', '.join(map(str, ids))})") + await asyncio.sleep(0.001) + return {i: AUTHORS[i] for i in ids if i in AUTHORS} # missing ids get a BatchError + + +async def render_post(post: dict[str, Any]) -> str: + author = await get_author(post["author_id"]) # looks like one query per post... + return f"{post['title']} by {author['name']}" + + +async def render_all(posts: list[dict[str, Any]]) -> list[str]: + return list(await asyncio.gather(*(render_post(p) for p in posts))) # ...but is 1 in total + + +async def amain() -> None: + lines = await render_all(POSTS) + assert lines[0] == "post 0 by author1" + print(len(lines), "posts rendered with", len(QUERIES), "query:") + print(" ", QUERIES[0]) + assert len(QUERIES) == 1 # 20 posts, 5 distinct authors, one query + + # A second wave goes out as a second batch. + await asyncio.gather(get_author(1), get_author(2)) + assert len(QUERIES) == 2 + + +def main() -> None: + asyncio.run(amain()) + + +main() +``` + +## How it works + +* **`@batch_calls(max_size=100, max_wait=0.005)`** turns `get_author(ids: list[int])` into a function you call as `get_author(42)`. The first call opens a *window*. The batch is sent when `max_wait` seconds pass (here 5 ms) or when `max_size` distinct items are queued, whichever comes first. +* All 20 `render_post` tasks run in the same event-loop tick via `asyncio.gather`, so they all join one window: **one query for 20 posts**, and only the 5 distinct author ids are sent (duplicates in a window share one slot and one result). +* **The output contract.** The bulk function returns either a *list* with one result per input id in the same order, or a *dict* keyed by id. With a dict, an id that is missing makes **only that caller** get a `BatchError`; the others succeed. If the function raises, every caller in that batch gets the exception. +* The calling code (`render_post`) is written as if there were no batching, so it can stay in the layer where it belongs. + +### The same thing with threads + +A sync function is batched across threads. This is useful for thread-per-request servers (Flask/Gunicorn threads, Django with threaded workers). + +```python +from concurrent.futures import ThreadPoolExecutor + +from python_wrap.batching import batch_calls + +queries = [] + + +@batch_calls(max_size=4, max_wait=0.05) +def get_price(skus: list[str]) -> dict[str, float]: + queries.append(sorted(skus)) # one "SELECT ... WHERE sku IN (...)" + return {sku: float(len(sku)) for sku in skus} + + +with ThreadPoolExecutor(4) as pool: + prices = list(pool.map(get_price, ["a", "bb", "ccc", "dddd"])) + +assert prices == [1.0, 2.0, 3.0, 4.0] +assert queries == [["a", "bb", "ccc", "dddd"]] +``` + +There is no timer thread. The first caller of a window waits up to `max_wait` and then runs the batch in its own thread; a caller that fills the batch to `max_size` runs it at once. + +## Pitfalls + +!!! warning "A lone sequential caller pays the window" + If a single thread calls the sync loader in a loop, each call waits up to `max_wait` for company that never arrives. Batching needs *concurrent* callers. Never call a sync batched function from `async def` code (it blocks the event loop); decorate an `async def` instead. + +* **Return values must line up.** A list must have exactly one item per input id (otherwise every caller gets `BatchError`); a dict may omit ids (those callers get `BatchError`) but extra keys are ignored. Strings, `None` and generators are rejected: return a `list` or `dict`. +* **Items must be hashable**, or pass `key=lambda request: request.id` to say what identifies an item (it is used for deduplication and for dict lookups). +* **Choose `max_wait` to match your latency budget.** 1 to 10 ms is typical. It is added to the latency of the *first* caller of each window. +* **Cancellation is safe.** A cancelled caller removes its item from an unsent batch (unless someone else wants the same key), and cancelling one caller never cancels the batch for the others. +* **It is not a cache.** The same id requested in a later window is queried again. Stack `memoize(ttl=...)` on top if you also want results remembered: `@memoize(ttl=30)` above `@batch_calls()` caches per id and batches the misses. +* `await get_author.aflush()` sends everything queued right now (end of a request, shutdown); `get_author.pending` is the number of items waiting. +* On methods, each instance gets its own batches, so two repositories are never mixed into one bulk call. + +## Test it + +The bulk function is the only thing that touches the database, so a test counts how many times it was called. Wrap it with `spy` to count without changing behaviour, or, as here, record queries in the fake. + +```python +import asyncio +from typing import Any + +from python_wrap.batching import batch_calls + +QUERIES: list[str] = [] +AUTHORS = {i: {"id": i, "name": f"author{i}"} for i in range(1, 6)} +POSTS = [{"id": n, "author_id": n % 5 + 1, "title": f"post {n}"} for n in range(20)] + + +@batch_calls(max_size=100, max_wait=0.005) +async def get_author(ids: list[int]) -> dict[int, dict[str, Any]]: + """The one place that talks to the database. Receives distinct ids.""" + QUERIES.append(f"SELECT * FROM authors WHERE id IN ({', '.join(map(str, ids))})") + await asyncio.sleep(0.001) + return {i: AUTHORS[i] for i in ids if i in AUTHORS} # missing ids get a BatchError + + +async def render_post(post: dict[str, Any]) -> str: + author = await get_author(post["author_id"]) # looks like one query per post... + return f"{post['title']} by {author['name']}" + + +async def render_all(posts: list[dict[str, Any]]) -> list[str]: + return list(await asyncio.gather(*(render_post(p) for p in posts))) # ...but is 1 in total + +import pytest + +from python_wrap.errors import BatchError +from python_wrap.testing import spy + + +def test_twenty_posts_one_query() -> None: + QUERIES.clear() + lines = asyncio.run(render_all(POSTS)) + assert len(lines) == 20 + assert len(QUERIES) == 1 + assert "IN (1, 2, 3, 4, 5)" in QUERIES[0] # five distinct authors, sent once each + + +def test_one_missing_row_only_fails_its_own_caller() -> None: + async def scenario() -> list[object]: + return list(await asyncio.gather(get_author(1), get_author(999), return_exceptions=True)) + + found, missing = asyncio.run(scenario()) + assert found == {"id": 1, "name": "author1"} + assert isinstance(missing, BatchError) + + +def test_flush_sends_the_queue_immediately() -> None: + @batch_calls(max_wait=60) # a long window... + async def slow_window(ids: list[int]) -> list[int]: + return [i * 10 for i in ids] + + async def scenario() -> list[int]: + tasks = [asyncio.create_task(slow_window(i)) for i in range(3)] + await asyncio.sleep(0) # let the tasks queue their items + assert slow_window.pending == 3 + await slow_window.aflush() # ...cut short: do not wait a minute + return [t.result() for t in tasks] + + assert asyncio.run(scenario()) == [0, 10, 20] + + +def test_a_spy_counts_bulk_calls() -> None: + async def bulk(ids: list[int]) -> list[int]: + return [i * 10 for i in ids] + + calls = spy(bulk) # records every call, changes nothing + loader = batch_calls(max_wait=0.001)(calls) + + async def scenario() -> list[int]: + return list(await asyncio.gather(loader(1), loader(2), loader(1))) + + assert asyncio.run(scenario()) == [10, 20, 10] + assert calls.call_count == 1 + assert calls.calls[0].args == ([1, 2],) # distinct ids, in arrival order + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` diff --git a/docs/cookbook/django.md b/docs/cookbook/django.md new file mode 100644 index 0000000..71b6a56 --- /dev/null +++ b/docs/cookbook/django.md @@ -0,0 +1,241 @@ +# Django: cached services, commit hooks and API evolution + +## The problem + +A Django codebase accumulates a service layer: functions such as `get_plan(user)` or `upgrade(user, plan)` that views, tasks and management commands all call. Three recurring needs: + +1. **Cache reads** with an explicit way to invalidate them when the data changes. +2. **Run side effects only after success**: send the email, clear the cache, enqueue the job only when the work really committed. Django's answer for database work is `transaction.on_commit`. What about the non-database parts (a file written, an external API charged) that have to be undone if a later step fails? +3. **Rename and remove functions** without breaking callers all at once. + +The decorators involved (`memoize`, `transactional`, `deprecate`) know nothing about Django, so this page starts with a framework-free service layer you can run, then shows where Django plugs in. + +## The framework-free service layer + +```python +import contextlib +import warnings + +from python_wrap import deprecate, memoize +from python_wrap.state import INJECTED, Transaction, transactional + +DB: dict[int, str] = {1: "free", 2: "pro"} +OUTBOX: list[str] = [] # emails "sent" +QUERIES: list[int] = [] + + +# Cached read. cache_delete(user_id) is the explicit invalidation hook. +@memoize(ttl=300, maxsize=10_000) +def get_plan(user_id: int) -> str: + QUERIES.append(user_id) + return DB[user_id] + + +@deprecate(since="2.0", removed_in="3.0", alternative="get_plan") +def fetch_plan(user_id: int) -> str: + return get_plan(user_id) + + +@transactional +def upgrade(user_id: int, plan: str, *, fail: bool = False, tx: Transaction = INJECTED) -> None: + previous = DB[user_id] + DB[user_id] = plan + tx.on_rollback(DB.__setitem__, user_id, previous) # undo the write if anything fails + if fail: + raise RuntimeError("payment declined") + # Runs only if the whole function succeeds, like transaction.on_commit(). + tx.on_commit(get_plan.cache_delete, user_id) + tx.on_commit(OUTBOX.append, f"user {user_id} is now on {plan}") + + +def main() -> None: + assert get_plan(1) == "free" + assert get_plan(1) == "free" + assert QUERIES == [1] # the second read came from the cache + + with contextlib.suppress(RuntimeError): + upgrade(1, "pro", fail=True) + assert DB[1] == "free" and OUTBOX == [] # rolled back, nobody was emailed + assert get_plan(1) == "free" + print("failed upgrade: database restored, no email, cache untouched") + + upgrade(1, "pro") + assert OUTBOX == ["user 1 is now on pro"] + assert get_plan(1) == "pro" # the commit hook invalidated the stale entry + assert QUERIES == [1, 1] + print("successful upgrade:", OUTBOX[0]) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert fetch_plan(2) == "pro" + assert caught[0].category is DeprecationWarning + print("deprecation:", caught[0].message) + + +main() +``` + +### How it works + +* **`get_plan`** is `memoize(ttl=300)`: repeated reads in a process are free for five minutes, and `get_plan.cache_delete(user_id)` drops exactly one entry. +* **`upgrade`** is `@transactional`. The function body registers what to do in each outcome on the current `Transaction`: + * `tx.on_rollback(fn, *args)` registers an *undo step*. If the function raises, undo steps run in reverse order and the original exception propagates. + * `tx.on_commit(fn, *args)` registers a *follow-up step*. If the function returns normally, they run in registration order. + This is the same idea as Django's `transaction.on_commit`, extended with rollback hooks for things a database cannot roll back for you. +* **`fetch_plan`** is the old name, kept working with `@deprecate`. Every call emits a `DeprecationWarning` that points at the **caller's** line, with the version information in the message. + +## Where Django plugs in + +With the ORM, keep the database changes inside `transaction.atomic()` and use Django's own `on_commit` for follow-ups that depend on the database commit. Use python-wrap where Django has nothing: caching with explicit keys, undo steps for non-database side effects, and deprecations. + + +```python +# services.py +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver + +from python_wrap import deprecate, memoize +from python_wrap.state import INJECTED, Transaction, transactional + +from .models import Subscription +from .tasks import send_receipt + + +@memoize(ttl=300, maxsize=10_000) +def get_plan(user_id: int) -> str: + # Return a plain value, not a model instance: cached objects are shared. + return Subscription.objects.values_list("plan", flat=True).get(user_id=user_id) + + +@receiver(post_save, sender=Subscription) +def invalidate_plan(sender, instance, **kwargs) -> None: + # Runs for every save, including saves from the admin and from other code paths. + transaction.on_commit(lambda: get_plan.cache_delete(instance.user_id)) + + +@transactional +def upgrade(user_id: int, plan: str, *, tx: Transaction = INJECTED) -> None: + with transaction.atomic(): # the database part: Django rolls this back by itself + Subscription.objects.filter(user_id=user_id).update(plan=plan) + transaction.on_commit(lambda: send_receipt.delay(user_id)) # after the DB commit + + charge_id = billing_api.charge(user_id, plan) # not a database operation... + tx.on_rollback(billing_api.refund, charge_id) # ...so register its undo step + audit_log.write(user_id, plan) # if this raises, the charge is refunded + + +@deprecate(since="2.0", removed_in="3.0", alternative="get_plan") +def fetch_plan(user_id: int) -> str: + return get_plan(user_id) +``` + +Run the Django tests with `python -W error::DeprecationWarning manage.py test` (or `filterwarnings = error::DeprecationWarning` in pytest) to find every remaining caller of `fetch_plan`: the warning names the file and line of the call. + +## Pitfalls + +!!! warning "The cache is per process" + Each gunicorn worker has its own `get_plan` cache. `cache_delete` in one process does not clear the others, so a plan change is visible everywhere only after `ttl` expires. That is acceptable for data that tolerates a few minutes of staleness. For data that must be consistent across processes, use Django's cache framework (Redis or Memcached) instead, and use `memoize` for pure, cheap-to-recompute functions. + +!!! warning "Cache values, not model instances" + A memoized function returns the same object to every caller. A cached model instance carries a reference to its fields, its related caches and (for lazy relations) the ability to hit the database. Return strings, numbers, tuples or frozen dataclasses. + +* **`python-wrap`'s `Transaction` is not a database transaction.** It does not give isolation or atomicity; it only runs callbacks. Keep database writes inside `transaction.atomic()` and use the `Transaction` for the steps that need an explicit undo. +* **Django's `TestCase` does not fire `on_commit` callbacks** (the test transaction never commits); use `self.captureOnCommitCallbacks(execute=True)`. python-wrap's `on_commit` callbacks run when the `@transactional` function returns, so they fire in tests. +* Undo steps must be safe to run and ideally idempotent; a failing undo step is logged and the remaining ones still run. +* Do not put `@memoize` on methods of Django model instances unless you accept that the cache keeps every instance alive; use a module-level function keyed by primary key. +* `deprecate` warns on **every call**. Python's default filters show a `DeprecationWarning` only when triggered in `__main__`, so make sure your test runner shows them. + +## Test it + +The service layer is plain Python, so its tests do not need the database. Reset state at the start of each test, and assert on the *order* of effects. + +```python +import contextlib +import warnings + +from python_wrap import deprecate, memoize +from python_wrap.state import INJECTED, Transaction, transactional + +DB: dict[int, str] = {1: "free", 2: "pro"} +OUTBOX: list[str] = [] # emails "sent" +QUERIES: list[int] = [] + + +# Cached read. cache_delete(user_id) is the explicit invalidation hook. +@memoize(ttl=300, maxsize=10_000) +def get_plan(user_id: int) -> str: + QUERIES.append(user_id) + return DB[user_id] + + +@deprecate(since="2.0", removed_in="3.0", alternative="get_plan") +def fetch_plan(user_id: int) -> str: + return get_plan(user_id) + + +@transactional +def upgrade(user_id: int, plan: str, *, fail: bool = False, tx: Transaction = INJECTED) -> None: + previous = DB[user_id] + DB[user_id] = plan + tx.on_rollback(DB.__setitem__, user_id, previous) # undo the write if anything fails + if fail: + raise RuntimeError("payment declined") + # Runs only if the whole function succeeds, like transaction.on_commit(). + tx.on_commit(get_plan.cache_delete, user_id) + tx.on_commit(OUTBOX.append, f"user {user_id} is now on {plan}") + +import pytest + +from python_wrap.testing import spy_on + + +def reset() -> None: + DB.update({1: "free", 2: "pro"}) + OUTBOX.clear() + QUERIES.clear() + get_plan.cache_clear() + + +def test_reads_are_cached_and_invalidated_on_commit() -> None: + reset() + get_plan(1) + get_plan(1) + assert QUERIES == [1] + upgrade(1, "pro") + assert get_plan(1) == "pro" + assert QUERIES == [1, 1] + + +def test_failed_upgrade_rolls_back_and_sends_nothing() -> None: + reset() + with pytest.raises(RuntimeError, match="declined"): + upgrade(1, "pro", fail=True) + assert DB[1] == "free" + assert OUTBOX == [] + + +def test_commit_hooks_run_in_order() -> None: + reset() + with spy_on(get_plan, "cache_delete") as invalidate: + upgrade(1, "pro") + assert invalidate.call_count == 1 + assert OUTBOX == ["user 1 is now on pro"] + + +def test_deprecated_name_still_works_and_warns() -> None: + reset() + with pytest.warns(DeprecationWarning, match="use get_plan instead"): + assert fetch_plan(2) == "pro" + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` + +!!! note "`spy_on` and cached functions" + `spy_on(get_plan, "cache_delete")` temporarily replaces an attribute of the memoized function and restores it afterwards, so you can check that the invalidation hook was called without touching production code. + +For Django's own test runner, add a `setUp` that calls `get_plan.cache_clear()`, or wrap the test in `python_wrap.testing.disable_caching()` to bypass every cache for that test. diff --git a/docs/cookbook/fastapi.md b/docs/cookbook/fastapi.md new file mode 100644 index 0000000..85f991b --- /dev/null +++ b/docs/cookbook/fastapi.md @@ -0,0 +1,299 @@ +# FastAPI: caching, thundering herds and per-user rate limits + +## The problem + +A web endpoint reads a profile, a feed or a price list from a slow backend. Under load three things go wrong: + +1. The same expensive lookup runs on every request. +2. When a cached entry expires, **hundreds of requests miss at the same moment** and all hit the database (the thundering herd, or cache stampede). +3. One noisy user uses everybody's capacity. + +python-wrap fixes each with one decorator on a plain function, and the pieces are framework-free: the same functions work in FastAPI, Flask, Starlette or a worker. This page first shows the framework-free code that you can run and test, then the thin FastAPI layer on top. + +## The framework-free core + +```python +import asyncio +import time +from concurrent.futures import ThreadPoolExecutor + +from python_wrap import RateLimitExceeded, memoize, rate_limit, singleflight +from python_wrap.testing import fake_clock + +DB_QUERIES: list[int] = [] + + +# Order matters: memoize outside (check the cache first), singleflight inside (only one +# miss per key goes to the database at a time). +@memoize(ttl=60, maxsize=1000) +@singleflight +def load_profile(user_id: int) -> dict[str, object]: + DB_QUERIES.append(user_id) + time.sleep(0.03) # a slow query + return {"id": user_id, "name": f"user{user_id}"} + + +# Async code: memoize already shares one in-flight task between concurrent callers. +@memoize(ttl=60) +async def load_feed(user_id: int) -> list[str]: + DB_QUERIES.append(-user_id) + await asyncio.sleep(0.02) + return [f"post {n} for {user_id}" for n in range(3)] + + +@rate_limit(3, 60, key=lambda user, *args, **kwargs: user) # 3 searches/minute/user +def search(user: str, query: str) -> list[str]: + return [f"{query} result {n}" for n in range(2)] + + +def search_endpoint(user: str, query: str) -> tuple[int, dict[str, object]]: + """What an HTTP layer does with the exceptions: map them to status codes.""" + try: + return 200, {"results": search(user, query)} + except RateLimitExceeded as exc: + return 429, {"error": "slow down", "retry_after": round(exc.retry_after)} + + +async def amain() -> None: + feeds = await asyncio.gather(*(load_feed(7) for _ in range(50))) + assert all(feed == feeds[0] for feed in feeds) + assert DB_QUERIES.count(-7) == 1 # 50 concurrent requests, one query + print("async: 50 concurrent feed requests ->", DB_QUERIES.count(-7), "query") + + +def main() -> None: + # Install the fake clock before the first call: cache entries remember the clock + # that was running when they were stored. + with fake_clock() as clock: + # Thundering herd: 20 threads miss the cache at the same instant. + with ThreadPoolExecutor(20) as pool: + profiles = list(pool.map(load_profile, [42] * 20)) + assert all(p["id"] == 42 for p in profiles) + assert DB_QUERIES.count(42) == 1 + print("threads: 20 concurrent profile requests ->", DB_QUERIES.count(42), "query") + + # Later calls are served from the cache until the TTL passes. + load_profile(42) + assert DB_QUERIES.count(42) == 1 + clock.advance(61) + load_profile(42) + assert DB_QUERIES.count(42) == 2 + print("cache info:", load_profile.cache_info()) + + asyncio.run(amain()) + + # Per-user limits: ana runs out, ben is unaffected. + statuses = [search_endpoint("ana", "cats")[0] for _ in range(4)] + assert statuses == [200, 200, 200, 429] + assert search_endpoint("ben", "dogs")[0] == 200 + print("ana's statuses:", statuses, "| ben: 200") + + +main() +``` + +### How it works + +* **`@memoize(ttl=60, maxsize=1000)`** keeps results per argument for 60 seconds, evicting least-recently-used entries past 1000. `load_profile.cache_info()` reports hits, misses and size; `cache_delete(user_id)` and `cache_clear()` invalidate. +* **`@singleflight`** lets only one call per key run at a time; every concurrent caller with the same key waits for that call and receives the same result (or the same exception). Stacked *under* `memoize`, the order is "check the cache first, and if it misses, coalesce the misses". +* For `async def` you do not need `singleflight`: `memoize` already shares one in-flight task between concurrent awaits of a missing key, as the `load_feed` demo shows (50 requests, 1 query). +* **`@rate_limit(3, 60, key=...)`** allows 3 calls per 60 seconds per key. `key=` receives the call's arguments and returns the bucket. Over the limit it raises `RateLimitExceeded`, whose `retry_after` says when a slot frees; rejected calls do not use up budget. + +## The FastAPI layer + +The handlers stay thin: translate HTTP into function calls and exceptions into status codes. + + +```python +import math + +from fastapi import Depends, FastAPI, Header, HTTPException + +from python_wrap import RateLimitExceeded, memoize, rate_limit, singleflight + +app = FastAPI() + + +# A sync service function: FastAPI runs `def` endpoints in a thread pool, so the +# herd is made of threads and the sync singleflight coalesces them. +@memoize(ttl=60, maxsize=1000) +@singleflight +def load_profile(user_id: int) -> dict: + return db.query_profile(user_id) # slow + + +# An async service function: memoize alone dedupes concurrent misses. +@memoize(ttl=30) +async def load_feed(user_id: int) -> list[str]: + return await db.query_feed(user_id) + + +@rate_limit(30, 60, key=lambda user: user) # 30 requests/minute per user +def consume_quota(user: str) -> None: ... + + +def current_user(x_user: str = Header()) -> str: + return x_user # replace with your real authentication + + +def enforce_quota(user: str = Depends(current_user)) -> str: + try: + consume_quota(user) + except RateLimitExceeded as exc: + raise HTTPException( + status_code=429, + detail="slow down", + headers={"Retry-After": str(math.ceil(exc.retry_after))}, + ) from exc + return user + + +@app.get("/profile/{user_id}") +def read_profile(user_id: int, user: str = Depends(enforce_quota)) -> dict: + return load_profile(user_id) + + +@app.get("/feed") +async def read_feed(user: str = Depends(enforce_quota)) -> list[str]: + return await load_feed(int(user)) +``` + +Two details make this work. `memoize` and `singleflight` use `functools.wraps`, so FastAPI still sees the original signature of anything you decorate. And the rate limiter is applied to a small `consume_quota` function rather than to the endpoint, because its job is to count and raise: the HTTP concern (`429`, `Retry-After`) lives in one dependency that every route can reuse. + +## Pitfalls + +!!! warning "Cached values are shared objects" + `load_profile(42)` returns the **same dict** to every caller. If one handler mutates it, every later request sees the change. Return immutable data (tuples, frozen dataclasses, Pydantic models with `frozen=True`) or copy before you modify. + +!!! warning "Everything here is per process" + `uvicorn --workers 4` gives you four independent caches and four independent rate-limit counters, so a "30 per minute" limit is really up to 120. That is fine for caches (each worker warms its own) and for protecting a single process, but it is not a global quota. Use a shared store (Redis, your API gateway) when the limit is a contract. + +* **Do not memoize functions that take a `Request`, a DB session or other unhashable or per-request arguments.** Decorate the inner service function that takes plain values (`user_id`), or pass `key=lambda request, user_id: user_id`. +* **Errors are not cached.** If `load_profile` raises, the next request tries again, which is usually what you want. Wrap it in `fallback` or `retry` if not. +* **TTL counts from the moment an entry was stored**, not from its last read, so a hot key is recomputed every 60 s. That recomputation is exactly the moment `singleflight` protects. Keys stored together also expire together. +* **Use `cache_delete(...)` after a write** to the underlying data, or users see stale values for up to `ttl` seconds. +* Sync singleflight blocks threads while it waits; that is fine in a thread pool and exactly why you must not call it from `async def` code (use the async variant there). +* `rate_limit` with `block=True` would hold a request open until the window passes. For an API you almost always want the default (raise and return 429). + +## Test it + +Tests of cached code need two things: control over **time** (`fake_clock`, so a TTL passes instantly) and a clean slate (`cache_clear`). The block below is a complete, runnable test file for the core above. + +```python +import asyncio +import time +from concurrent.futures import ThreadPoolExecutor + +from python_wrap import RateLimitExceeded, memoize, rate_limit, singleflight +from python_wrap.testing import fake_clock + +DB_QUERIES: list[int] = [] + + +# Order matters: memoize outside (check the cache first), singleflight inside (only one +# miss per key goes to the database at a time). +@memoize(ttl=60, maxsize=1000) +@singleflight +def load_profile(user_id: int) -> dict[str, object]: + DB_QUERIES.append(user_id) + time.sleep(0.03) # a slow query + return {"id": user_id, "name": f"user{user_id}"} + + +# Async code: memoize already shares one in-flight task between concurrent callers. +@memoize(ttl=60) +async def load_feed(user_id: int) -> list[str]: + DB_QUERIES.append(-user_id) + await asyncio.sleep(0.02) + return [f"post {n} for {user_id}" for n in range(3)] + + +@rate_limit(3, 60, key=lambda user, *args, **kwargs: user) # 3 searches/minute/user +def search(user: str, query: str) -> list[str]: + return [f"{query} result {n}" for n in range(2)] + + +def search_endpoint(user: str, query: str) -> tuple[int, dict[str, object]]: + """What an HTTP layer does with the exceptions: map them to status codes.""" + try: + return 200, {"results": search(user, query)} + except RateLimitExceeded as exc: + return 429, {"error": "slow down", "retry_after": round(exc.retry_after)} + +from concurrent.futures import ThreadPoolExecutor + +from python_wrap.testing import spy + + +def test_thundering_herd_is_one_query() -> None: + load_profile.cache_clear() + DB_QUERIES.clear() + with fake_clock(), ThreadPoolExecutor(16) as pool: + results = list(pool.map(load_profile, [900] * 16)) + assert len({id(r) for r in results}) == 1 # everyone got the same object + assert DB_QUERIES == [900] + + +def test_entry_expires_after_ttl() -> None: + DB_QUERIES.clear() + with fake_clock() as clock: # install it BEFORE the first call + load_profile.cache_clear() + load_profile(901) + clock.advance(59) + load_profile(901) + assert DB_QUERIES == [901] # still fresh + clock.advance(2) + load_profile(901) + assert DB_QUERIES == [901, 901] # expired and reloaded + + +def test_rate_limit_is_per_user() -> None: + with fake_clock(): + assert [search_endpoint("tess", "q")[0] for _ in range(4)] == [200, 200, 200, 429] + status, body = search_endpoint("tess", "q") + assert status == 429 and body["retry_after"] > 0 + assert search_endpoint("uma", "q")[0] == 200 # someone else is not affected + + +def test_cache_hits_are_visible_with_a_spy() -> None: + spied = spy(lambda user_id: {"id": user_id}) + cached = memoize(spied) + cached(1) + cached(1) + cached(2) + assert spied.call_count == 2 # the second call for user 1 never reached the function + assert cached.cache_info().hits == 1 + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` + +For tests of the HTTP layer, use FastAPI's `TestClient` and reset state between tests: + + +```python +import pytest +from fastapi.testclient import TestClient + +from myapp.main import app, load_feed, load_profile +from python_wrap.testing import disable_caching, disable_rate_limits + + +@pytest.fixture(autouse=True) +def clean_state(): + load_profile.cache_clear() + load_feed.cache_clear() + yield + + +def test_profile_endpoint_not_cached_in_tests(): + # Switch off every cache and limiter for this test only. + with disable_caching(), disable_rate_limits(): + client = TestClient(app) + assert client.get("/profile/1", headers={"x-user": "a"}).status_code == 200 +``` + +`disable_caching()` is the right switch for a test of the endpoint's own logic; use `fake_clock` and `cache_clear` (as above) for tests that are *about* the caching. diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md new file mode 100644 index 0000000..44b32bb --- /dev/null +++ b/docs/cookbook/index.md @@ -0,0 +1,35 @@ +# Cookbook + +The guides explain one decorator at a time. The cookbook shows how they combine to solve problems you actually have. Each recipe has the same shape: + +1. **The problem**, in terms of what goes wrong in production. +2. **The code**, complete and runnable. Every code block on these pages is executed by the docs test suite, so what you read works. +3. **How it works**, one decorator at a time. +4. **Pitfalls**, the mistakes that cost a day. +5. **Test it**, a runnable test file that uses [`python_wrap.testing`](../testing.md), so the decorators never make your tests slow or flaky. + +Every recipe that matters has a standalone script in the [`examples/`](https://github.com/LitePy/PythonWrap/tree/main/examples) directory that you can run with `python examples/.py`. No third-party packages are needed; framework code (FastAPI, Django, httpx) is shown but marked as such. + +## Recipes + +| Recipe | Decorators and helpers | You get | +| --- | --- | --- | +| [A resilient HTTP client](resilient-http-client.md) | `retry`, `CircuitBreaker`, `rate_limit`, `timeout` | Retries with backoff and jitter, a circuit breaker per host, a request budget, an overall deadline | +| [An async crawler and bulk fetch](async-crawler.md) | `map_async`, `Bulkhead`, `race`, `run_sync`, `amap_iter` | Bounded concurrency, per-host limits, collected errors, mirror racing | +| [FastAPI](fastapi.md) | `memoize(ttl=)`, `singleflight`, `rate_limit(key=)` | Cached endpoints, no thundering herd, per-user limits and `429` responses | +| [Django](django.md) | `memoize`, `transactional`, `deprecate` | Cached services with explicit invalidation, commit hooks and undo steps, painless renames | +| [CLI tools and scripts](cli-and-scripts.md) | `timing`, `profile`, `benchmark`, `run_sync`, `debounce`, `throttle` | Step timings, profiles, honest benchmarks, file watchers that rebuild once | +| [DataLoader-style batching](dataloader-batching.md) | `batch_calls` | N+1 queries collapsed into one, with per-item calling code | +| [Testing code that uses python-wrap](testing-your-code.md) | `fast_mode`, `fake_clock`, `spy`, `disable_*` | Fast, deterministic tests with no sleeping and no leaked state | +| [Observability](observability.md) | `log_args`, `audit`, `timing` | Redacted, structured logs and an audit trail | + +## Reading order + +New to python-wrap? Start with the [resilient HTTP client](resilient-http-client.md): it uses four decorators together and introduces the testing helpers. If you write web services, continue with [FastAPI](fastapi.md) or [Django](django.md). Whatever you build, finish with [Testing](testing-your-code.md). + +## Ground rules the recipes follow + +* **Stack deliberately.** The decorator closest to the function runs first. `@retry` above `@rate_limit` means every retry counts against the limit; the other way round, the whole retry loop counts once. See [Stacking decorators](../stacking.md). +* **State belongs to the function.** Caches, breakers and rate-limit windows are created once per decorated function and shared by every caller in the process. That is the point, and also the first thing to reset in tests. +* **Libraries do not configure logging.** Recipes that print or collect logs set up their own handlers, as your application should. +* **Be explicit about what is safe to repeat.** `retry`, `hedge` and `race` may run an operation more than once. Use them for reads and idempotent writes. diff --git a/docs/cookbook/observability.md b/docs/cookbook/observability.md new file mode 100644 index 0000000..09a0df8 --- /dev/null +++ b/docs/cookbook/observability.md @@ -0,0 +1,256 @@ +# Observability: redacted logs and an audit trail + +## The problem + +You need to know *what happened* in production: which function was called, with what, by whom, how long it took and how it ended. You also have to make sure the log never contains a password, a token or a national ID number, because logs are copied to systems with far weaker access control than your database. + +Hand-written `logger.info("calling %s with %s", ...)` lines drift out of date, forget to redact, and end up in a different format in every module. The goal here is: + +- one decorator per function, so coverage is visible in the code, +- secrets masked **by parameter name**, in one shared list, +- structured (JSON) output your log pipeline can index, +- a separate, structured **audit record** per call for security-relevant operations. + +## The code + +python-wrap never configures logging. The script below does it explicitly: a JSON formatter on a handler on the application's own logger. + +```python +import contextlib +import dataclasses +import io +import json +import logging + +from python_wrap import DEFAULT_REDACT, AuditRecord, audit, log_args + + +class JsonFormatter(logging.Formatter): + """One JSON object per line; includes the AuditRecord when python-wrap attached one.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, object] = { + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + audit_record = getattr(record, "audit", None) + if isinstance(audit_record, AuditRecord): + payload["audit"] = dataclasses.asdict(audit_record) + return json.dumps(payload, default=str) + + +stream = io.StringIO() +handler = logging.StreamHandler(stream) +handler.setFormatter(JsonFormatter()) +log = logging.getLogger("myapp") +log.addHandler(handler) +log.setLevel(logging.DEBUG) +log.propagate = False # keep this demo's output out of the root logger + +audit_trail: list[AuditRecord] = [] # stand-in for a database table or a log shipper + + +# `redact=` REPLACES the defaults, so extend DEFAULT_REDACT instead of overwriting it, and +# pass the same list to every decorator: each one masks on its own. +REDACT = (*DEFAULT_REDACT, "ssn") + + +@log_args(logger=log, redact=REDACT) +@audit(logger=log, sink=audit_trail.append, redact=REDACT) +def change_email(user_id: int, new_email: str, password: str, ssn: str = "") -> bool: + if "@" not in new_email: + raise ValueError("invalid email") + return True + + +def main() -> None: + change_email(7, "ana@example.com", password="hunter2", ssn="123-45-6789") + with contextlib.suppress(ValueError): + change_email(7, "not-an-email", password="hunter2") + + lines = [json.loads(line) for line in stream.getvalue().splitlines()] + text = stream.getvalue() + assert "hunter2" not in text and "123-45-6789" not in text # secrets never reach the log + print(lines[0]["message"]) + + ok, failed = audit_trail + assert ok.status == "success" and failed.status == "error" + assert ok.arguments["password"] == "***" + print(json.dumps({"function": ok.function, "status": ok.status, "args": ok.arguments})) + print(json.dumps({"function": failed.function, "status": failed.status, "error": failed.error})) + + audited = [line["audit"] for line in lines if "audit" in line] + assert [a["status"] for a in audited] == ["success", "error"] + + +main() +``` + +## How it works + +* **`@log_args`** logs `Calling change_email(user_id=7, new_email='...', password='***', ssn='***')` before the call (default level `DEBUG`). A parameter is masked when its **name contains** any redaction fragment, case-insensitively. Long values are truncated at `max_length` (200). +* **`@audit`** emits one `AuditRecord` per call: `function`, `timestamp` (UTC), `caller` (`"file:line"` of the calling code), `arguments` (masked), `status` (`"success"` or `"error"`), `error` (the exception's `repr`), `duration` and, with `include_result=True`, `result`. It delivers the record to your **`sink`** callable and also attaches it to the log record as `record.audit`, which is how the JSON formatter above finds it. +* **`REDACT = (*DEFAULT_REDACT, "ssn")`** extends the defaults. `DEFAULT_REDACT` covers `password`, `passwd`, `secret`, `token`, `api_key`, `apikey`, `authorization`, `auth`, `credential`, `private_key`, `cookie` and `session`. +* The sink is where the audit trail goes: a database insert, a queue, a file. An exception raised by the sink is logged and swallowed, so a broken audit store never breaks the audited function. Exceptions from the function itself are recorded (`status="error"`) and re-raised. + +Stacking order matters little for correctness (each decorator sees the same arguments), but keep the cheap logging outermost and the audit closest to the function so the recorded `duration` covers only the function. + +For latency metrics, `@timing(callback=...)` hands you a `TimingRecord(name, elapsed, success)` for every call, which you can forward to StatsD or Prometheus: + +```python +from python_wrap import TimingRecord, timing + +samples: list[TimingRecord] = [] + + +@timing(callback=samples.append) # in production: callback=lambda r: statsd.timing(r.name, r.elapsed) +def lookup(key: str) -> str: + return key.upper() + + +lookup("a") +assert samples[0].success and samples[0].name.endswith("lookup") +``` + +## Pitfalls + +!!! warning "Each decorator redacts on its own" + `redact=` is a per-decorator option and **replaces** the defaults. The demo passes the same `REDACT` tuple to both `log_args` and `audit`; with `redact=("ssn",)` alone, `password` would be logged in clear. Define the list once and reuse it, always built from `DEFAULT_REDACT`. + +!!! warning "Redaction is by name, not by value" + A parameter called `data` that happens to contain a token is logged in full. Redaction does not look inside values, and it does not hide what the function itself logs, returns or puts in an exception message. Name sensitive parameters accordingly (`api_token`, `card_number` plus an entry in `redact`), and keep secrets out of exception messages. + +* **`log_args` logs at `DEBUG`** and `audit` at `INFO` by default. Configure levels for the `logger` you pass, or secrets-free argument logs vanish in production when you expected them. +* **`AuditRecord.caller` is a file path and line.** That is what you want for forensics, but treat the log as internal. +* **`include_result=True` stores the return value** in the record. Do not turn it on for functions that return sensitive data; results are not redacted. +* The record is created after the call finishes, so a process that is killed mid-call leaves no audit entry. For operations that must never be missed, write an "intent" row first. +* A sink that blocks (a slow database insert) slows every audited call. Push to a queue in the sink and write asynchronously. + +## Test it + +Test redaction with a real handler and a string search: the property you care about is "this secret never appears in any log output". Capture the audit trail with a list as the sink. In pytest you would use the `caplog` fixture; the plain handler below works in any test runner. + +```python +import contextlib +import dataclasses +import io +import json +import logging + +from python_wrap import DEFAULT_REDACT, AuditRecord, audit, log_args + + +class JsonFormatter(logging.Formatter): + """One JSON object per line; includes the AuditRecord when python-wrap attached one.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, object] = { + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + audit_record = getattr(record, "audit", None) + if isinstance(audit_record, AuditRecord): + payload["audit"] = dataclasses.asdict(audit_record) + return json.dumps(payload, default=str) + + +stream = io.StringIO() +handler = logging.StreamHandler(stream) +handler.setFormatter(JsonFormatter()) +log = logging.getLogger("myapp") +log.addHandler(handler) +log.setLevel(logging.DEBUG) +log.propagate = False # keep this demo's output out of the root logger + +audit_trail: list[AuditRecord] = [] # stand-in for a database table or a log shipper + + +# `redact=` REPLACES the defaults, so extend DEFAULT_REDACT instead of overwriting it, and +# pass the same list to every decorator: each one masks on its own. +REDACT = (*DEFAULT_REDACT, "ssn") + + +@log_args(logger=log, redact=REDACT) +@audit(logger=log, sink=audit_trail.append, redact=REDACT) +def change_email(user_id: int, new_email: str, password: str, ssn: str = "") -> bool: + if "@" not in new_email: + raise ValueError("invalid email") + return True + +import pytest + +from python_wrap.testing import fake_clock, spy + + +def reset() -> None: + stream.seek(0) + stream.truncate() + audit_trail.clear() + + +def test_secrets_never_reach_the_log_output() -> None: + reset() + change_email(7, "ana@example.com", password="s3cret-pw", ssn="123-45-6789") + output = stream.getvalue() + assert "s3cret-pw" not in output and "123-45-6789" not in output + assert "password='***'" in output + + +def test_audit_records_success_and_failure() -> None: + reset() + change_email(7, "ana@example.com", password="x") + with pytest.raises(ValueError): + change_email(7, "nope", password="x") + ok, failed = audit_trail + assert (ok.status, failed.status) == ("success", "error") + assert "ValueError" in (failed.error or "") + assert ok.arguments["user_id"] == 7 and ok.arguments["password"] == "***" + + +def test_json_lines_are_machine_readable() -> None: + reset() + change_email(1, "ana@example.com", password="x") + lines = [json.loads(line) for line in stream.getvalue().splitlines()] + audit_line = next(line for line in lines if "audit" in line) + assert audit_line["audit"]["status"] == "success" + assert audit_line["level"] == "INFO" + + +def test_a_broken_sink_does_not_break_the_function() -> None: + reset() + + def broken_sink(record: AuditRecord) -> None: + raise OSError("audit database is down") + + @audit(sink=broken_sink, logger=log) + def delete_account(user_id: int) -> str: + return "deleted" + + assert delete_account(5) == "deleted" # the business operation still succeeds + + +def test_durations_are_deterministic_under_a_fake_clock() -> None: + reset() + with fake_clock(): + change_email(2, "ana@example.com", password="x") + assert audit_trail[0].duration == 0.0 + + +def test_spy_confirms_the_sink_is_called_once_per_call() -> None: + sink = spy(lambda record: None) + + @audit(sink=sink) + def ping() -> None: ... + + ping() + ping() + assert sink.call_count == 2 + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` diff --git a/docs/cookbook/resilient-http-client.md b/docs/cookbook/resilient-http-client.md new file mode 100644 index 0000000..c81afd4 --- /dev/null +++ b/docs/cookbook/resilient-http-client.md @@ -0,0 +1,358 @@ +# A resilient HTTP client + +## The problem + +Your service calls other services. They return 503 during deploys, drop connections, answer slowly, and sometimes stay down for minutes. A bare `requests.get` turns every one of those into an error for your user, and a naive retry loop makes an outage worse by hammering the dependency. + +You want a client that: + +- retries **transient** failures (connection resets, timeouts, 5xx) but never 4xx, +- waits longer between attempts and adds jitter so a fleet of clients does not retry in lockstep, +- stops calling a host that is clearly down, **per host**, so one bad dependency does not block the others, +- never exceeds a request budget per host, +- has an overall deadline. + +## The code + +The network is a scripted `FakeTransport`, so this page runs offline and the behaviour is deterministic. The client only needs an object with `send(url, *, timeout) -> Response`; [mapping it to httpx or requests](#using-httpx-or-requests) is a ten-line adapter. + +```python +import threading +from urllib.parse import urlsplit + +from python_wrap import CircuitBreaker, CircuitOpenError, rate_limit, retry, timeout +from python_wrap.testing import fake_clock + + +class Response: + def __init__(self, status: int, body: str = "") -> None: + self.status = status + self.body = body + + +class ServerError(Exception): + """A 5xx answer: worth retrying.""" + + +class ClientError(Exception): + """A 4xx answer: our fault, retrying cannot help.""" + + +TRANSIENT = (ConnectionError, TimeoutError, ServerError) + + +def host_of(url: str) -> str: + return urlsplit(url).netloc + + +class FakeTransport: + """Plays back a script per host: an int is a status code, an exception is raised.""" + + def __init__(self, script: dict[str, list[object]]) -> None: + self.script = script + self.calls: list[str] = [] + + def send(self, url: str, *, timeout: float) -> Response: + self.calls.append(url) + steps = self.script[host_of(url)] + step = steps.pop(0) if len(steps) > 1 else steps[0] # the last step repeats + if isinstance(step, BaseException): + raise step + assert isinstance(step, int) + return Response(step, f"body of {url}") + + +class ResilientClient: + def __init__(self, transport: FakeTransport) -> None: + self._transport = transport + self._breakers: dict[str, CircuitBreaker] = {} + self._lock = threading.Lock() + + def breaker_for(self, host: str) -> CircuitBreaker: + with self._lock: + if host not in self._breakers: + self._breakers[host] = CircuitBreaker( + failure_threshold=3, recovery_timeout=30, on=TRANSIENT, name=host + ) + return self._breakers[host] + + @timeout(30) # overall deadline, retries included + @retry(max_attempts=4, delay=0.2, backoff=2, max_delay=5, jitter=0.1, on=TRANSIENT) + @rate_limit(5, 1.0, block=True, key=lambda self, url: host_of(url)) + def get(self, url: str) -> Response: + # CircuitOpenError is not in TRANSIENT, so an open circuit is not retried. + return self.breaker_for(host_of(url)).call(self._send, url) + + def _send(self, url: str) -> Response: + response = self._transport.send(url, timeout=5.0) + if response.status >= 500: + raise ServerError(f"{response.status} from {url}") + if response.status >= 400: + raise ClientError(f"{response.status} from {url}") + return response + + +def main() -> None: + transport = FakeTransport( + { + "api.example.com": [503, ConnectionError("reset"), 200], + "down.example.com": [503], + "missing.example.com": [404], + } + ) + client = ResilientClient(transport) + + with fake_clock() as clock: # backoff sleeps are instant and recorded + # 1. Transient failures are retried with growing, jittered waits. + assert client.get("https://api.example.com/users").status == 200 + print("retried twice, waits:", [round(s, 2) for s in clock.sleeps]) + + # 2. A 404 is not transient: it is raised at once, not retried. + before = len(transport.calls) + try: + client.get("https://missing.example.com/x") + except ClientError: + assert len(transport.calls) == before + 1 + print("404 raised without retrying") + + # 3. A dead host opens its breaker; other hosts are unaffected. + try: + client.get("https://down.example.com/a") + except CircuitOpenError as exc: + print(f"circuit for down.example.com open, retry in {exc.retry_after:.0f}s") + before = len(transport.calls) + try: + client.get("https://down.example.com/b") + except CircuitOpenError: + assert len(transport.calls) == before # rejected without touching the network + assert client.get("https://api.example.com/users").status == 200 + + # 4. After the recovery timeout one trial call is allowed through. + transport.script["down.example.com"] = [200] + clock.advance(31) + assert client.get("https://down.example.com/c").status == 200 + print("circuit closed again after recovery") + + # 5. The rate limit waits (it does not fail) once a host's budget is used up. + clock.sleeps.clear() + for i in range(7): + client.get(f"https://api.example.com/page/{i}") + assert any(s > 0 for s in clock.sleeps) + print("rate limit waited:", [round(s, 2) for s in clock.sleeps]) + + +main() +``` + +## How it works + +The decorators on `get` read from the bottom up, because the innermost one runs first on each attempt: + +1. **`@rate_limit(5, 1.0, block=True, key=...)`** allows 5 calls per second per host. `key=` receives the call's arguments (including `self`) and returns the bucket; here it is the host. With `block=True` the call waits (it does not fail), and in async code it waits with `await`, so the event loop stays free. +2. **`@retry(..., on=TRANSIENT)`** wraps the rate limiter, so every attempt, including retries, counts against the host's budget. The wait before attempt `n + 1` is `min(delay * backoff ** (n - 1), max_delay) + uniform(0, jitter)`: roughly 0.2 s, 0.4 s, 0.8 s, each plus up to 0.1 s of jitter. `on=` limits retries to the listed exception types; a `ClientError` (4xx) propagates immediately. +3. **`@timeout(30)`** is outermost, so it bounds the whole call, retries and waits included. + +The circuit breaker is not a decorator on `get` because there must be **one breaker per host**, created on demand. `breaker.call(func, *args)` runs a call through a breaker without decorating anything. Because `on=TRANSIENT` is set on the breaker too, a 404 does not count as a failure: only problems that suggest the host is unhealthy trip it. + +Notice what happens when a host is down. The first three failed attempts open its breaker (`failure_threshold=3`). The fourth attempt raises `CircuitOpenError`, which is **not** in `TRANSIENT`, so `retry` lets it through instead of waiting out more backoff, and later calls fail instantly with `exc.retry_after` telling the caller how long until the circuit tries again. + +## Pitfalls + +!!! warning "Rate-limit state belongs to the function, not the instance" + `@rate_limit` on a method keeps its buckets on the decorated function, so every `ResilientClient` instance shares them (separated only by your `key=`). That is usually what you want for "5 calls per second to this host from this process", but it surprises tests. Use `disable_rate_limits()` or a unique host name per test. For a limit per instance, include `id(self)` in the key. + +!!! warning "`timeout` on sync code uses signals" + On Unix, in the main thread, `@timeout` interrupts the call with `SIGALRM`. In worker threads, or on Windows, it falls back to a helper thread and **cannot stop the work**, only stop waiting for it. Set a real timeout on the HTTP library as well (the `timeout=5.0` passed to the transport above); the decorator is the overall budget, not a replacement. In `async def` code `@timeout` cancels the coroutine properly. + +!!! warning "Only retry what is safe to repeat" + Retrying a `GET` is safe. Retrying a `POST` that charges a card is not, unless the endpoint is idempotent (for example it accepts an idempotency key). Give write methods a narrower `on=` (for example only `ConnectionError` raised before the request was sent) or no retry at all. + +!!! note "Honouring `Retry-After`" + `retry` computes its waits from `delay`, `backoff` and `jitter`; it cannot take a delay from the exception. If a server sends `Retry-After` and you must obey it, catch that case yourself and sleep, or raise a non-retried exception for it. + +* Keep `failure_threshold` and `recovery_timeout` honest. Three failures out of three calls is a dead host; three failures out of 3000 is noise. The breaker counts **consecutive** failures, so a single success resets it. +* Do not create a new `CircuitBreaker` on every request: it would never see enough failures. Keep them in a dict (as above) or module-level. + +## Using httpx or requests + +Everything above the transport stays the same. Write an adapter that turns the library's exceptions into the built-in ones the client retries on. + + +```python +import httpx + +class HttpxTransport: + def __init__(self) -> None: + self._client = httpx.Client() + + def send(self, url: str, *, timeout: float) -> Response: + try: + r = self._client.get(url, timeout=timeout) + except httpx.TimeoutException as exc: + raise TimeoutError(str(exc)) from exc + except httpx.TransportError as exc: # connect errors, resets, DNS + raise ConnectionError(str(exc)) from exc + return Response(r.status_code, r.text) + +client = ResilientClient(HttpxTransport()) +``` + + +```python +import requests + +class RequestsTransport: + def __init__(self) -> None: + self._session = requests.Session() + + def send(self, url: str, *, timeout: float) -> Response: + try: + r = self._session.get(url, timeout=timeout) + except requests.Timeout as exc: + raise TimeoutError(str(exc)) from exc + except requests.ConnectionError as exc: + raise ConnectionError(str(exc)) from exc + return Response(r.status_code, r.text) +``` + +For `httpx.AsyncClient` make `get` and `_send` `async def` and use `await breaker.call(...)`: every decorator used here works unchanged on coroutine functions. + +## Test it + +The decorators wait and remember, so tests need the helpers from [`python_wrap.testing`](../testing.md): `fake_clock` makes sleeps instant and records them, `spy_on` counts transport calls and `disable_rate_limits` lets calls through limiters. (`fast_mode` skips waits without recording them; see [Testing code that uses python-wrap](testing-your-code.md).) + +```python +import threading +from urllib.parse import urlsplit + +from python_wrap import CircuitBreaker, CircuitOpenError, rate_limit, retry, timeout +from python_wrap.testing import fake_clock + + +class Response: + def __init__(self, status: int, body: str = "") -> None: + self.status = status + self.body = body + + +class ServerError(Exception): + """A 5xx answer: worth retrying.""" + + +class ClientError(Exception): + """A 4xx answer: our fault, retrying cannot help.""" + + +TRANSIENT = (ConnectionError, TimeoutError, ServerError) + + +def host_of(url: str) -> str: + return urlsplit(url).netloc + + +class FakeTransport: + """Plays back a script per host: an int is a status code, an exception is raised.""" + + def __init__(self, script: dict[str, list[object]]) -> None: + self.script = script + self.calls: list[str] = [] + + def send(self, url: str, *, timeout: float) -> Response: + self.calls.append(url) + steps = self.script[host_of(url)] + step = steps.pop(0) if len(steps) > 1 else steps[0] # the last step repeats + if isinstance(step, BaseException): + raise step + assert isinstance(step, int) + return Response(step, f"body of {url}") + + +class ResilientClient: + def __init__(self, transport: FakeTransport) -> None: + self._transport = transport + self._breakers: dict[str, CircuitBreaker] = {} + self._lock = threading.Lock() + + def breaker_for(self, host: str) -> CircuitBreaker: + with self._lock: + if host not in self._breakers: + self._breakers[host] = CircuitBreaker( + failure_threshold=3, recovery_timeout=30, on=TRANSIENT, name=host + ) + return self._breakers[host] + + @timeout(30) # overall deadline, retries included + @retry(max_attempts=4, delay=0.2, backoff=2, max_delay=5, jitter=0.1, on=TRANSIENT) + @rate_limit(5, 1.0, block=True, key=lambda self, url: host_of(url)) + def get(self, url: str) -> Response: + # CircuitOpenError is not in TRANSIENT, so an open circuit is not retried. + return self.breaker_for(host_of(url)).call(self._send, url) + + def _send(self, url: str) -> Response: + response = self._transport.send(url, timeout=5.0) + if response.status >= 500: + raise ServerError(f"{response.status} from {url}") + if response.status >= 400: + raise ClientError(f"{response.status} from {url}") + return response + +import pytest + +from python_wrap.testing import disable_rate_limits, spy_on + + +def make(script: dict[str, list[object]]) -> tuple[ResilientClient, FakeTransport]: + transport = FakeTransport(script) + return ResilientClient(transport), transport + + +def test_retries_with_exponential_backoff() -> None: + client, transport = make({"a.test": [503, ConnectionError("reset"), 200]}) + with fake_clock() as clock, disable_rate_limits(): + assert client.get("https://a.test/x").status == 200 + assert len(transport.calls) == 3 + first, second = clock.sleeps + assert 0.2 <= first <= 0.3 # delay 0.2 + up to 0.1 jitter + assert 0.4 <= second <= 0.5 # backoff doubles it + + +def test_client_errors_are_not_retried() -> None: + client, transport = make({"b.test": [404]}) + with spy_on(transport, "send") as send, fake_clock() as clock, disable_rate_limits(): + with pytest.raises(ClientError): + client.get("https://b.test/missing") + assert send.call_count == 1 + assert clock.sleeps == [] + + +def test_open_circuit_fails_fast_and_is_per_host() -> None: + client, transport = make({"down.test": [503], "up.test": [200]}) + with fake_clock() as clock, disable_rate_limits(): + with pytest.raises(CircuitOpenError): + client.get("https://down.test/x") + calls_when_open = len(transport.calls) + with pytest.raises(CircuitOpenError): + client.get("https://down.test/y") + assert len(transport.calls) == calls_when_open # no network traffic + assert client.get("https://up.test/").status == 200 # other hosts unaffected + + transport.script["down.test"] = [200] + clock.advance(31) # past recovery_timeout + assert client.get("https://down.test/z").status == 200 + + +def test_rate_limit_waits_instead_of_failing() -> None: + client, _ = make({"rate.test": [200]}) + with fake_clock() as clock: # rate limiting stays on here + for i in range(6): + client.get(f"https://rate.test/{i}") + assert any(s > 0.5 for s in clock.sleeps) + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` + +If you use pytest, run it as usual; in a larger suite enable the plugin once (`pytest_plugins = ["python_wrap.testing"]` in `conftest.py`) to get the `pw_fake_clock` and `pw_fast_mode` fixtures instead of the context managers. diff --git a/docs/cookbook/testing-your-code.md b/docs/cookbook/testing-your-code.md new file mode 100644 index 0000000..d066127 --- /dev/null +++ b/docs/cookbook/testing-your-code.md @@ -0,0 +1,245 @@ +# Testing code that uses python-wrap + +## The problem + +Decorators that wait and remember make tests slow and order-dependent: + +- A `retry(delay=30, backoff=2)` makes the failing-path test take minutes. +- A `memoize` leaks results from one test into the next. +- A `rate_limit` or circuit breaker keeps counting across tests, so a test passes alone and fails in the suite. +- A TTL cache or throttle window needs real time to pass. + +[`python_wrap.testing`](../testing.md) is built for this. It is standard library only (pytest is optional), and the helpers change python-wrap's behaviour from the outside, so production code stays unchanged. + +| Helper | Use it to | +| --- | --- | +| `fast_mode()` | skip every wait (retry backoff, blocking rate limits) but keep the logic | +| `fake_clock()` | control time: make waits instant **and recorded**, and move TTLs, windows and circuit timers forward with `.advance(seconds)` | +| `spy(func)` / `spy_on(obj, name)` | record calls without changing behaviour | +| `disable_caching()`, `disable_rate_limits()`, `disable_retries()` | switch a whole feature off for a test | +| `eventually(assertion)` | wait for background work without a fixed `sleep` | + +## The code under test + +A small service with a retry, a cache, a rate limit and a circuit breaker, which is a typical day in a real codebase. The tests are a complete, runnable file. + +```python +import pytest + +from python_wrap import ( + CircuitBreaker, + CircuitOpenError, + CircuitState, + RateLimitExceeded, + memoize, + rate_limit, + retry, +) +from python_wrap.testing import fake_clock, fast_mode, spy_on + + +class Gateway: + """Stands in for a payment/HTTP client. Tests make it fail on demand.""" + + def __init__(self) -> None: + self.failures = 0 # how many calls should still fail + + def charge(self, amount: int) -> str: + if self.failures > 0: + self.failures -= 1 + raise ConnectionError("gateway unreachable") + return f"charged {amount}" + + +gateway = Gateway() +breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=60, on=ConnectionError) + + +@retry(max_attempts=5, delay=30, backoff=2, on=ConnectionError) # up to 7.5 minutes of waiting! +def charge_with_retry(amount: int) -> str: + return gateway.charge(amount) + + +@breaker +def charge_guarded(amount: int) -> str: + return gateway.charge(amount) + + +@memoize(ttl=600) +def exchange_rate(currency: str) -> float: + return {"EUR": 1.1, "GBP": 1.3}[currency] + + +@rate_limit(2, 60, key=lambda user: user) +def send_sms(user: str) -> str: + return f"sms to {user}" + + +# ---- tests -------------------------------------------------------------------------- + + +def test_retry_succeeds_without_waiting() -> None: + gateway.failures = 2 + with fast_mode(): # the 30 s and 60 s waits become instant + assert charge_with_retry(10) == "charged 10" + + +def test_retry_backoff_schedule_is_what_we_configured() -> None: + gateway.failures = 3 + with fake_clock() as clock: + charge_with_retry(10) + assert clock.sleeps == [30, 60, 120] # exact, because there is no jitter configured + + +def test_retry_gives_up_after_max_attempts() -> None: + gateway.failures = 99 + with fast_mode(), spy_on(gateway, "charge") as charge: + with pytest.raises(ConnectionError): + charge_with_retry(10) + assert charge.call_count == 5 # and the spy proves it, without waiting + + +def test_cache_entry_expires_by_moving_the_clock() -> None: + with fake_clock() as clock: # install it before the first call + exchange_rate.cache_clear() # start from a clean slate + exchange_rate("EUR") + exchange_rate("EUR") + assert exchange_rate.cache_info().hits == 1 + clock.advance(601) + exchange_rate("EUR") + assert exchange_rate.cache_info().misses == 2 # expired, recomputed + + +def test_circuit_opens_and_recovers() -> None: + breaker.reset() # breaker state outlives a single test + gateway.failures = 3 + with fake_clock() as clock: + for _ in range(3): + with pytest.raises(ConnectionError): + charge_guarded(1) + assert breaker.state is CircuitState.OPEN + with pytest.raises(CircuitOpenError): + charge_guarded(1) # rejected without calling the gateway + + clock.advance(61) + assert breaker.state is CircuitState.HALF_OPEN + assert charge_guarded(1) == "charged 1" # the trial call succeeds + assert breaker.state is CircuitState.CLOSED + + +def test_rate_limit_window_moves_with_the_clock() -> None: + with fake_clock() as clock: + send_sms("ana") + send_sms("ana") + with pytest.raises(RateLimitExceeded) as info: + send_sms("ana") + assert 0 < info.value.retry_after <= 60 + clock.advance(61) + assert send_sms("ana") == "sms to ana" + + +# pytest collects the test_ functions itself; this loop only lets the page run them directly. +for _name, _test in list(globals().items()): + if _name.startswith("test_"): + _test() +``` + +## How it works + +* **`fast_mode()`** makes python-wrap's internal sleep a no-op. Retries still run the configured number of times, so you test the real logic with none of the waiting. Use it when you do not care about the *timing*. +* **`fake_clock()`** goes further: it replaces the clocks python-wrap reads. A sleep advances the fake time instead of waiting and is **recorded** in `clock.sleeps`, so you can assert the exact backoff schedule; `clock.advance(n)` moves time forward for TTLs, rate-limit windows and circuit recovery. Time only moves when you say so, so these tests are deterministic. +* **`spy_on(obj, name)`** wraps an attribute for the duration of the block, records `.calls` (args, kwargs, result, exception, duration) and `.call_count`, and restores the original afterwards. `spy(func)` does the same for a function you own, and works for `async def`. +* Both context managers also work as **decorators** on test functions (`@fast_mode()`), including `async def` tests. + +## Pitfalls + +!!! warning "State lives on the decorated function" + Caches, circuit breakers, rate-limit windows and `once` results belong to the function object, which lives as long as the module. Reset them between tests or tests depend on their order: `func.cache_clear()`, `breaker.reset()` / `func.breaker.reset()`, `func.reset()` for `once`, `func.cancel()` for `debounce`. `rate_limit`, `throttle` and `singleflight` have no reset: move the clock with `fake_clock`, use a distinct key per test (as `send_sms("ana")` does), or build the decorated function inside the test. + +!!! note "Entries made before the fake clock" + `fake_clock()` starts at the real `time.monotonic()` when it is created, so a cache warmed *before* entering the clock is in its past and expires normally after `advance()`. A fake clock created with an explicit `start=` (say `start=0`) is on a different timeline from earlier entries; entering the clock first, or `cache_clear()` inside it, avoids surprises. + +* `fake_clock` does not control `timeout` deadlines or `debounce` timers; they use real timers. Test `debounce` with `.flush()` and `timeout` with tiny real durations. +* `fast_mode` and `fake_clock` change the process-wide clock, not just the current thread, so worker threads started by the test see them. Do not run tests that use them in parallel threads of the same process (pytest-xdist processes are fine). +* `eventually()` polls with python-wrap's sleep, so inside `fast_mode()` it spins without pausing until its timeout. Keep it outside fast mode when you wait on real threads. + +## With pytest fixtures + +Enable the bundled plugin once, in the top-level `conftest.py`, to get fixtures instead of context managers, and add an autouse fixture that resets the state you own. + + +```python +# conftest.py +import pytest + +from myapp.billing import breaker, exchange_rate + +pytest_plugins = ["python_wrap.testing"] # adds pw_fake_clock and pw_fast_mode + + +@pytest.fixture(autouse=True) +def reset_python_wrap_state(): + yield + exchange_rate.cache_clear() + breaker.reset() +``` + + +```python +# test_billing.py +from myapp.billing import charge_with_retry + + +def test_backoff(pw_fake_clock): + ... + charge_with_retry(10) + assert pw_fake_clock.sleeps == [30, 60, 120] + + +def test_quick(pw_fast_mode): + ... +``` + +To switch a feature off for a whole test (not to test it, but because it is in the way), use the `disable_*` helpers: + +```python +from python_wrap import memoize, rate_limit, retry +from python_wrap.testing import disable_caching, disable_rate_limits, disable_retries + +calls = {"charge": 0, "rate": 0} + + +@retry(max_attempts=5, delay=0) +def charge() -> None: + calls["charge"] += 1 + raise ConnectionError("card network down") + + +@rate_limit(1, 60) +@memoize +def exchange_rate() -> float: + calls["rate"] += 1 + return 1.08 + + +def test_error_path_makes_one_call() -> None: + with disable_retries(): # retry makes a single attempt + try: + charge() + except ConnectionError: + pass + assert calls["charge"] == 1 + + +def test_each_call_hits_the_backend() -> None: + with disable_caching(), disable_rate_limits(): # memoize and limiters pass through + exchange_rate() + exchange_rate() + assert calls["rate"] == 2 + + +test_error_path_makes_one_call() +test_each_call_hits_the_backend() +``` + +`disable_caching()` is the right tool when the cache is incidental. When the test is *about* the cache, use `fake_clock` and `cache_clear()` as above. diff --git a/docs/debugging.rst b/docs/debugging.rst deleted file mode 100644 index 8f5e6d8..0000000 --- a/docs/debugging.rst +++ /dev/null @@ -1,108 +0,0 @@ -Debugging & Logging -=================== - -This section covers tools for debugging and logging function execution. - -@log_args ---------- -Log the arguments passed to a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @log_args - def calculate_sum(a, b): - return a + b - -**Usage as a Function:** - -.. code-block:: python - - def calculate_sum(a, b): - return a + b - - logged_function = log_args(calculate_sum) - result = logged_function(10, 20) # Logs arguments: a=10, b=20 - -@log_return ------------ -Log the return value of a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @log_return - def fetch_data(): - return {"key": "value"} - -**Usage as a Function:** - -.. code-block:: python - - def fetch_data(): - return {"key": "value"} - - logged_function = log_return(fetch_data) - result = logged_function() # Logs return value: {"key": "value"} - -@trace ------- -Log the call stack trace of a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @trace - def perform_task(): - # Perform a task - print("Task performed") - -**Usage as a Function:** - -.. code-block:: python - - def perform_task(): - # Perform a task - print("Task performed") - - traced_function = trace(perform_task) - traced_function() # Logs the call stack trace - -@audit ------- -Provide comprehensive auditing for function calls. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @audit - def process_data(data): - # Process the input data - return len(data) - -**Usage as a Function:** - -.. code-block:: python - - def process_data(data): - # Process the input data - return len(data) - - audited_function = audit(process_data) - result = audited_function(["item1", "item2"]) # Logs arguments, return value, and execution details \ No newline at end of file diff --git a/docs/development_tools.rst b/docs/development_tools.rst deleted file mode 100644 index 28cc00e..0000000 --- a/docs/development_tools.rst +++ /dev/null @@ -1,107 +0,0 @@ -Development Tools -================= - -This section covers tools for development and testing. - -@deprecate ----------- -Mark a function as deprecated. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @deprecate - def old_function(): - print("This function is deprecated.") - -**Usage as a Function:** - -.. code-block:: python - - def old_function(): - print("This function is deprecated.") - - deprecated_function = deprecate(old_function) - deprecated_function() # Outputs a deprecation warning - -@no_debug ---------- -Disable debug output for a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @no_debug - def sensitive_function(): - print("This is a production-safe function.") - -**Usage as a Function:** - -.. code-block:: python - - def sensitive_function(): - print("This is a production-safe function.") - - no_debug_function = no_debug(sensitive_function) - no_debug_function() # Executes without debug output - -@mock_data ----------- -Mock the output of a function for testing. - -**Parameters:** -- **data (Any):** The data to return when the function is called. Can be a value or a callable. -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @mock_data(return_value={"id": 1, "name": "Mock User"}) - def get_user_data(user_id): - pass - -**Usage as a Function:** - -.. code-block:: python - - def get_user_data(user_id): - pass - - mocked_function = mock_data(return_value={"id": 1, "name": "Mock User"})(get_user_data) - result = mocked_function(42) # Returns {"id": 1, "name": "Mock User"} - -@rate_limit ------------ -Limit the frequency of function execution. - -**Parameters:** -- **calls (int):** The maximum number of calls allowed within the specified period. -- **period (float):** The time period (in seconds) for rate limiting. -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @rate_limit(calls=5, period=10) - def api_request(): - print("API request made") - -**Usage as a Function:** - -.. code-block:: python - - def api_request(): - print("API request made") - - rate_limited_function = rate_limit(calls=5, period=10)(api_request) - rate_limited_function() # Enforces rate limiting \ No newline at end of file diff --git a/docs/error_handling.rst b/docs/error_handling.rst deleted file mode 100644 index 6605f4f..0000000 --- a/docs/error_handling.rst +++ /dev/null @@ -1,116 +0,0 @@ -Error Handling & Reliability -============================ - -This section covers tools for improving the reliability of your functions. - -@retry ------- -Retry a function if it fails. - -**Parameters:** -- **max_attempts (int):** The maximum number of retry attempts. Default is ``3``. -- **delay (float):** The delay (in seconds) between retry attempts. Default is ``1.0``. -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @retry(max_attempts=3, delay=2.0) - def unreliable_function(): - # Code that may fail - pass - -**Usage as a Function:** - -.. code-block:: python - - def unreliable_function(): - # Code that may fail - pass - - retried_function = retry(max_attempts=3, delay=2.0)(unreliable_function) - retried_function() # Retries up to 3 times with a 2-second delay - -@retry_on_exception -------------------- -Retry a function only when specific exceptions are raised. - -**Parameters:** -- **exceptions (tuple):** One or more exception types that trigger a retry. -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @retry_on_exception(ValueError, KeyError) - def operation_with_exceptions(): - # Code that may raise exceptions - pass - -**Usage as a Function:** - -.. code-block:: python - - def operation_with_exceptions(): - # Code that may raise exceptions - pass - - retried_function = retry_on_exception(ValueError, KeyError)(operation_with_exceptions) - retried_function() # Retries only on ValueError or KeyError - -@timeout --------- -Limit the execution time of a function. - -**Parameters:** -- **seconds (int):** The maximum execution time for the function. -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @timeout(seconds=5) - def time_sensitive_task(): - # Code that should finish within 5 seconds - pass - -**Usage as a Function:** - -.. code-block:: python - - def time_sensitive_task(): - # Code that should finish within 5 seconds - pass - - timeout_function = timeout(seconds=5)(time_sensitive_task) - timeout_function() # Raises a timeout exception if execution exceeds 5 seconds - -@revert_on_failure ------------------- -Roll back the system state if a failure occurs. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @revert_on_failure - def critical_operation(): - # Code that needs rollback on failure - pass - -**Usage as a Function:** - -.. code-block:: python - - def critical_operation(): - # Code that needs rollback on failure - pass - - revert_function = revert_on_failure(critical_operation) - revert_function() # Rolls back state on failure \ No newline at end of file diff --git a/docs/guides/async-toolkit.md b/docs/guides/async-toolkit.md new file mode 100644 index 0000000..f2f1851 --- /dev/null +++ b/docs/guides/async-toolkit.md @@ -0,0 +1,319 @@ +# Async toolkit and bulkheads + +Small helpers for the things every asyncio project ends up hand-writing: bounded +`gather`, bounded `map`, racing, calling async code from sync code, and capping how many +calls hit a fragile dependency at once. + +| Helper | One-line behaviour | +| --- | --- | +| [`gather_limit`](#gather_limit) | `asyncio.gather`, but at most `limit` run at once. | +| [`map_async`](#map_async-and-amap_iter) | Apply an async function to every item, `limit` at a time; get a list. | +| [`amap_iter`](#map_async-and-amap_iter) | Same, as an async generator: results stream out. | +| [`race`](#race) | First success wins; the others are cancelled. | +| [`run_sync`](#run_sync) | Run a coroutine from sync code (never inside a running loop). | +| [`Bulkhead`](#bulkhead) | Cap concurrent calls, bound the queue, fail fast with `BulkheadFull`. | +| [`limit_concurrency`](#limit_concurrency) | Decorator: a private `Bulkhead(n)` per function. | + +The async helpers live in `python_wrap.aio`, the bulkhead in `python_wrap.bulkhead`. + +!!! note "No task is left behind" + Every helper owns the tasks it starts. When it returns, raises, is cancelled or (for + `amap_iter`) is closed early, all of them have been cancelled *and awaited*. Nothing + keeps running in the background, and you never see "Task exception was never + retrieved" or "coroutine was never awaited" warnings from them. + +## `gather_limit` + +Like `asyncio.gather`, but coroutines are started only when one of the `limit` slots is +free. Results come back in input order. + +```python +import asyncio +from python_wrap.aio import gather_limit + +running = 0 +peak = 0 + +async def download(n: int) -> str: + global running, peak + running += 1 + peak = max(peak, running) + await asyncio.sleep(0.001) + running -= 1 + return f"file-{n}" + +async def main() -> None: + files = await gather_limit(*(download(n) for n in range(20)), limit=5) + assert files[:2] == ["file-0", "file-1"] + assert peak == 5 + +asyncio.run(main()) +``` + +On the first exception the other awaitables are cancelled, coroutines that never started +are closed, and the exception is raised. Pass `return_exceptions=True` to collect errors +in the list instead: + +```python +import asyncio +from python_wrap.aio import gather_limit + +async def parse(text: str) -> int: + return int(text) + +async def main() -> None: + results = await gather_limit(parse("1"), parse("x"), limit=2, return_exceptions=True) + assert results[0] == 1 + assert isinstance(results[1], ValueError) + +asyncio.run(main()) +``` + +Tasks and futures are accepted too, but they are already running when you pass them, so +`limit` cannot delay them; pass coroutines to get the cap. + +## `map_async` and `amap_iter` + +`map_async(fn, items, limit=...)` calls `fn` on every item with at most `limit` calls in +flight, and returns the list of results. `items` may be a normal or an async iterable, +and it is read **lazily**: an item is pulled only when a slot is free, so a generator of +a million rows never sits in memory as a million tasks. + +```python +import asyncio +from python_wrap.aio import map_async + +async def rows(): + for i in range(100): + yield {"id": i} + +async def save(row: dict[str, int]) -> int: + await asyncio.sleep(0) + return row["id"] + +async def main() -> None: + ids = await map_async(save, rows(), limit=8) + assert ids == list(range(100)) + +asyncio.run(main()) +``` + +`ordered=False` returns results in completion order instead. If a call fails, the others +are cancelled and the error is raised (unless `return_exceptions=True`). + +`amap_iter` is the streaming version: it yields results as they are ready, which also +works with infinite inputs. + +```python +import asyncio +from contextlib import aclosing +from itertools import count +from python_wrap.aio import amap_iter + +async def square(n: int) -> int: + await asyncio.sleep(0) + return n * n + +async def main() -> None: + found = [] + async with aclosing(amap_iter(square, count(), limit=4)) as squares: + async for value in squares: + if value > 50: + break + found.append(value) + assert found == [0, 1, 4, 9, 16, 25, 36, 49] + +asyncio.run(main()) +``` + +* `ordered=True` (default) yields in input order. Results that finish early wait in a + buffer, and buffered results count towards `limit`, so memory stays bounded; a slow + item holds back the ones after it. An error is raised when its turn comes. +* `ordered=False` yields each result as soon as it finishes. + +Breaking out of the loop, calling `aclose()` or cancelling the consumer cancels and +awaits every call still in flight. Wrap the generator in `contextlib.aclosing` (as above) +so this happens immediately instead of whenever the generator is garbage-collected. The +input iterable is yours: `amap_iter` does not close it. + +## `race` + +Start every awaitable and return the first **successful** result. The losers are +cancelled and awaited before `race` returns. A contestant that raises does not end the +race; the others keep going. + +```python +import asyncio +from python_wrap.aio import race + +async def from_cache() -> str: + raise LookupError("cache miss") + +async def from_replica() -> str: + await asyncio.sleep(0.01) + return "replica" + +async def from_primary() -> str: + await asyncio.sleep(1) + return "primary" + +async def main() -> None: + assert await race(from_cache(), from_replica(), from_primary(), timeout=5) == "replica" + +asyncio.run(main()) +``` + +* With `timeout=`, `TimeoutExpired` (a `TimeoutError`) is raised if nobody succeeds in + time. +* If every contestant fails, `RaceFailed` is raised with all the errors, in input order, + in `.exceptions`. On Python 3.11+ `RaceFailed` is also an `ExceptionGroup`, so + `except* LookupError:` works; on 3.10 it is a plain `PythonWrapError`. Catching + `RaceFailed` works on every version. + +```python +import asyncio +from python_wrap.aio import RaceFailed, race + +async def broken(name: str) -> str: + raise ConnectionError(name) + +async def main() -> None: + try: + await race(broken("a"), broken("b")) + except RaceFailed as exc: + assert [str(e) for e in exc.exceptions] == ["a", "b"] + +asyncio.run(main()) +``` + +`race` is for idempotent work: the losers may already have done part of their job when +they are cancelled. + +## `run_sync` + +Run async code from a sync entry point (a CLI command, a script, a test helper). It is +`asyncio.run` with a friendlier call form: + +```python +import asyncio +from python_wrap.aio import run_sync + +async def fetch_total(a: int, b: int) -> int: + await asyncio.sleep(0) + return a + b + +assert run_sync(fetch_total, 2, 3) == 5 +assert run_sync(fetch_total(4, 5)) == 9 +``` + +There is deliberately **no nested-event-loop magic**. Called while an event loop is +running in the current thread, `run_sync` raises `RuntimeError` (and closes the coroutine +so you get no "never awaited" warning). Inside async code, `await` instead; from sync +code that runs inside a loop, move the sync code to a thread with `asyncio.to_thread`. + +## `Bulkhead` + +A bulkhead protects something with limited capacity (a connection pool, a rate-limited +API, a CPU-heavy section) by capping how many calls use it at once, and by refusing to +build an unbounded queue in front of it. + +```python +import asyncio +from python_wrap.bulkhead import Bulkhead +from python_wrap.errors import BulkheadFull + +payments = Bulkhead(2, max_waiting=1, name="payments") + +@payments +async def charge(amount: int) -> int: + await asyncio.sleep(0.01) + return amount + +async def main() -> None: + results = await asyncio.gather(*(charge(n) for n in range(4)), return_exceptions=True) + assert results[:3] == [0, 1, 2] # two ran at once, one waited + assert isinstance(results[3], BulkheadFull) # the queue was full + +asyncio.run(main()) +``` + +| Argument | Meaning | +| --- | --- | +| `max_concurrent` | Calls allowed to run at once. | +| `max_waiting` | Callers allowed to queue for a slot. `None` = unlimited, `0` = never wait. | +| `timeout` | Seconds a queued caller waits before giving up with `BulkheadFull`. | +| `name` | Shown in error messages and `repr`. | + +One bulkhead can guard several functions and blocks at once. Use it as a decorator (sync +or `async def`), as `with bulkhead:` in threaded code, or as `async with bulkhead:` in +async code. `bulkhead.active` and `bulkhead.waiting` report the current load. + +```python +from concurrent.futures import ThreadPoolExecutor +from python_wrap.bulkhead import Bulkhead + +db = Bulkhead(3, timeout=5, name="db") + +def query(n: int) -> int: + with db: + return n * 2 + +with ThreadPoolExecutor(10) as pool: + assert list(pool.map(query, range(10))) == [n * 2 for n in range(10)] +assert db.active == 0 +``` + +!!! warning "Threads and event loops have separate limits" + Sync callers share a `threading.BoundedSemaphore`; async callers use one + `asyncio.Semaphore` per event loop (asyncio primitives cannot be shared across loops). + So the cap applies separately to threads and to each loop. If one program uses both + sides, give each side its own bulkhead. Never use `with bulkhead:` inside a coroutine: + waiting for a slot there would block the event loop. + +!!! warning "A bulkhead inside a global limit can starve" + Suppose `map_async(fetch, urls, limit=10)` calls a function that waits for a per-host + `Bulkhead(2)`. A task holds one of the 10 global slots *while it queues for the + bulkhead*. If most URLs hit the same host, ten tasks sit on global slots, two work and + eight wait, so requests to every other host are blocked behind them (and with a + `timeout` or `max_waiting` the waiters may be rejected instead). Two ways out: + + * Put the bulkhead outermost per host: acquire it *before* starting the work that + takes a global slot, or group the items by host and run one `map_async` per host + (each with `limit` equal to that host's bulkhead size). + * Size the limits so each per-host limit is at most the global one and the global + limit is at least the sum of all the per-host limits, so a global slot is never + held for a bulkhead slot that cannot be free. + +`BulkheadFull` is a `PythonWrapError`, so it composes with the other decorators, for +example `fallback` to serve a degraded answer when the bulkhead is full, or `retry` to +try again a little later. Every rejection also emits the `bulkhead.rejected` event, with +the `active` and `waiting` counts at that moment (see [Observability](observability.md)). + +## `limit_concurrency` + +The decorator form for the common case "at most N of these at once": each decorated +function gets its own `Bulkhead(n)` with no queue limit and no timeout, available as +`.bulkhead`. + +```python +import asyncio +from python_wrap.bulkhead import limit_concurrency + +@limit_concurrency(3) +async def crawl(url: str) -> str: + await asyncio.sleep(0) + return url.upper() + +async def main() -> None: + pages = await asyncio.gather(*(crawl(f"site{i}") for i in range(10))) + assert pages[0] == "SITE0" + assert crawl.bulkhead.max_concurrent == 3 + +asyncio.run(main()) +``` + +It works on methods too, including under a `@classmethod` stacked above it, and type +checkers (mypy and pyright) keep the signature and the `.bulkhead` attribute there. +On a method, the limit is shared by every instance (it belongs to the function). For a +limit shared by several functions, or for `max_waiting` / `timeout`, create a `Bulkhead` +yourself. diff --git a/docs/guides/batching.md b/docs/guides/batching.md new file mode 100644 index 0000000..dda8dc0 --- /dev/null +++ b/docs/guides/batching.md @@ -0,0 +1,263 @@ +# Batching and hedging + +Two decorators for calls that go over the network: + +| Decorator | One-line behaviour | +| --- | --- | +| [`batch_calls`](#batch_calls) | Many single-item calls made close together become one batched call. | +| [`hedge`](#hedge) | If a call is slow, start a backup attempt; the first success wins. | + +## `batch_calls` + +You often have code that loads one thing at a time — `await get_user(id)` in many places, many tasks — while the backend is much faster when asked for many things at once (`SELECT ... WHERE id IN (...)`, a bulk API endpoint). `batch_calls` is the DataLoader pattern: write the bulk function once, and call it with one item from everywhere. + +=== "Async" + + ```python + import asyncio + from python_wrap.batching import batch_calls + + queries = [] + + @batch_calls(max_size=100, max_wait=0.005) + async def get_user(ids: list[int]) -> dict[int, str]: + queries.append(ids) # one round trip for the whole batch + return {i: f"user{i}" for i in ids} + + async def main() -> None: + users = await asyncio.gather(get_user(1), get_user(2), get_user(1)) + assert users == ["user1", "user2", "user1"] + assert queries == [[1, 2]] # one call; the duplicate 1 was sent once + + asyncio.run(main()) + ``` + +=== "Sync (threads)" + + ```python + from concurrent.futures import ThreadPoolExecutor + from python_wrap.batching import batch_calls + + queries = [] + + @batch_calls(max_size=4, max_wait=0.05) + def get_price(skus: list[str]) -> list[float]: + queries.append(sorted(skus)) + return [float(len(s)) for s in skus] # same order as the input + + with ThreadPoolExecutor(4) as pool: + prices = list(pool.map(get_price, ["a", "bb", "ccc", "dddd"])) + + assert prices == [1.0, 2.0, 3.0, 4.0] + assert queries == [["a", "bb", "ccc", "dddd"]] # the 4th caller filled the batch + ``` + +### When a batch is sent + +A **window** opens with the first call. The batch is sent when either happens first: + +* `max_wait` seconds have passed since the window opened (default 10 ms), or +* the batch holds `max_size` distinct items (default 100). + +The next call after that opens a new window. Duplicate items in one window — same key — are sent once and their callers share the result. + +### The output contract + +The batch function receives a `list` of the **distinct** items, in the order they first arrived. It must return one of: + +| Return value | How results are matched | +| --- | --- | +| A sequence (`list`, `tuple`, ...) | Position: result `i` belongs to item `i`. The length must equal the number of items. | +| A mapping (`dict`, ...) | By key: `key(item)` if you passed `key=`, otherwise the item itself. Extra entries are ignored. | + +Anything else is a contract violation. What callers see: + +| What the batch function did | Who gets what | +| --- | --- | +| Returned a valid result | Each caller gets its own item's result. | +| Raised an exception | **Every** caller in the batch gets that exception. | +| Returned a sequence of the wrong length | **Every** caller gets `BatchError`. | +| Returned a mapping missing some keys | Only callers of the missing items get `BatchError`; the rest get results. | +| Returned something else (`None`, a `str`, a generator, ...) | **Every** caller gets `BatchError`. | + +`str` and `bytes` are not accepted as sequences, and iterators are not consumed: return a `list`. `BatchError` is a `PythonWrapError`. + +```python +import asyncio +from python_wrap.batching import batch_calls +from python_wrap.errors import BatchError + +@batch_calls() +async def lookup(ids: list[int]) -> dict[int, str]: + return {i: "found" for i in ids if i != 404} + +async def main() -> None: + found, missing = await asyncio.gather(lookup(1), lookup(404), return_exceptions=True) + assert found == "found" + assert isinstance(missing, BatchError) # only this caller fails + +asyncio.run(main()) +``` + +### Keys: deduplication and unhashable items + +Items must be hashable, or you pass `key=` to say what identifies one. The key is used to deduplicate within a window and to look results up in a returned mapping. + +```python +import asyncio +from python_wrap.batching import batch_calls + +@batch_calls(key=lambda req: req["id"]) +async def resolve(requests: list[dict]) -> dict[int, str]: + return {r["id"]: r["name"].title() for r in requests} + +async def main() -> None: + assert await resolve({"id": 7, "name": "ada"}) == "Ada" + +asyncio.run(main()) +``` + +### Async details + +* Callers on the **same event loop** are batched together; batches never span loops. +* The batch runs in its own task. Cancelling one caller does not cancel the batch for the others. +* A caller cancelled **before** its batch is sent removes its item from the batch, unless another caller is waiting for the same key. If every caller goes away, the batch is never sent. +* No tasks are left behind once the batch has been delivered. +* When a call fills the batch to `max_size` and its caller is the batch's only waiter (always the case with `max_size=1`), the batch runs right in the calling task: no extra task, no event-loop round trip. + +### Sync details + +* Callers are threads; each blocks until its result is ready. +* There is no timer thread. The **first caller of a window** keeps time: it waits up to `max_wait` and then runs the batch in its own thread. A caller that fills the batch to `max_size` runs it immediately in its thread. So the batch function always runs in one of the calling threads (or the thread calling `.flush()`). +* A single thread calling the loader in a loop waits up to `max_wait` per call — batching needs concurrency. Do not call a sync loader from `async` code; it blocks the event loop. Decorate an `async def` instead. + +!!! warning "A sync loader is not awaitable" + Decorating a plain `def` gives a plain function: `loader(1)` blocks and returns the value itself. So `await loader(1)` and `asyncio.create_task(loader(1))` fail (with `TypeError`, after the loop was already blocked for the batch window). The first time a sync loader is called from a running event loop it emits a `RuntimeWarning` saying so. In async code, decorate an `async def` batch function. + +### Flushing and inspecting + +* `.pending` — how many distinct items are queued and not yet sent. +* `.flush()` (sync) — send every queued batch now, in the calling thread. +* `await .aflush()` (async) — send the batches queued on the running loop now, and wait until every batch on that loop has finished. Useful at the end of a request or before shutdown. + +```python +import asyncio +from python_wrap.batching import batch_calls + +@batch_calls(max_wait=60) # a long window... +async def load(ids: list[int]) -> list[int]: + return [i * 10 for i in ids] + +async def main() -> None: + tasks = [asyncio.create_task(load(i)) for i in range(3)] + await asyncio.sleep(0) # let the tasks queue their items + assert load.pending == 3 + await load.aflush() # ...cut short + assert [t.result() for t in tasks] == [0, 10, 20] + +asyncio.run(main()) +``` + +### Methods + +On a method the batch function is called as `func(self, items)`, and each instance gets its own batches (for a class method, each class). Calls on two instances are never mixed in one batch. + +```python +import asyncio +from python_wrap.batching import batch_calls + +class UserRepo: + def __init__(self, table: str) -> None: + self.table = table + + @batch_calls() + async def get(self, ids: list[int]) -> list[str]: + return [f"{self.table}:{i}" for i in ids] + +async def main() -> None: + a, b = UserRepo("a"), UserRepo("b") + assert await asyncio.gather(a.get(1), b.get(2)) == ["a:1", "b:2"] + +asyncio.run(main()) +``` + +!!! note "Static typing" + The wrapper is typed as `Batched[[Item], Result]` (or `AsyncBatched`), with `.pending`, `.flush()` / `.aflush()`. Pyright types instance, class and static methods exactly. mypy types instance and class methods correctly but binds the first parameter too eagerly for a `@staticmethod` stacked on top, and for unbound access such as `Repo.get(repo, 1)`; prefer a module-level loader there. + +## `hedge` + +Most calls to a replicated service are fast, but a few hit a slow replica, a GC pause, or a lost packet. A **hedged request** waits a little; if the call is still running, it fires a second, identical call and takes whichever answers first. + +!!! warning "Only for idempotent operations" + A hedged function can run several times for one call, at the same time. Use it for reads and other operations that are safe to repeat. Never hedge payments, emails, counters, or anything else whose side effect must happen once. + +```python +import asyncio +from python_wrap.hedging import hedge + +attempts = [] + +@hedge(0.05, max_extra=1) +async def get_price(sku: str) -> float: + attempts.append(sku) + if len(attempts) == 1: + await asyncio.sleep(10) # the first replica is stuck... + return 9.99 + +async def main() -> None: + assert await get_price("A1") == 9.99 # ...so the backup answers after ~50 ms + assert len(attempts) == 2 + +asyncio.run(main()) +``` + +### How it decides + +1. Start the call. +2. If no attempt has finished after `delay` seconds, start another one (up to `max_extra` extra attempts in total, each `delay` after the previous one). +3. The first attempt to **succeed** wins; the rest are cancelled. +4. If an attempt fails with an exception matching `on` (default: any `Exception`) while there are attempts left, the next one starts **immediately** — there is no point waiting out the delay. +5. An exception that does not match `on` is raised at once (and the other attempts are cancelled). +6. If every attempt fails, the last exception is raised. + +Pick `delay` near the latency percentile you want to cut — for example the p95 of the call. Then only about 5% of calls send an extra request. + +```python +import asyncio +from python_wrap.hedging import hedge + +calls = [] + +@hedge(10, max_extra=2, on=ConnectionError) +async def fetch() -> str: + calls.append(1) + if len(calls) < 3: + raise ConnectionError("replica down") + return "ok" + +async def main() -> None: + assert await fetch() == "ok" # failures hedge immediately: no 10 s waits + assert len(calls) == 3 + +asyncio.run(main()) +``` + +### Async: cancellation guarantees + +Each attempt is a task. When the wrapper returns or raises, every other attempt has been cancelled **and awaited**, so no tasks are left running. Cancelling the caller cancels all attempts. Errors raised by losing attempts while they are being cancelled are discarded. Context variables are copied into each attempt. + +### Sync: attempts cannot be cancelled + +For a regular function each attempt runs in its own daemon thread and the caller blocks until one succeeds. **Python threads cannot be cancelled**: losing attempts keep running in the background until they finish, and their results are thrown away. Their side effects still happen, and they hold their resources (a connection, a lock) until done. Make sure the function has its own timeout, so a stuck attempt does not run forever. + +```python +from python_wrap.hedging import hedge + +@hedge(0.01) +def lookup(key: str) -> str: + return key.upper() + +assert lookup("a") == "A" +``` + +`hedge` keeps the decorated function's signature and type, so it stacks with other decorators and works on methods. diff --git a/docs/guides/caching.md b/docs/guides/caching.md new file mode 100644 index 0000000..1feb19c --- /dev/null +++ b/docs/guides/caching.md @@ -0,0 +1,327 @@ +# Caching + +Four tools, four different questions: "have I computed this before?" (`memoize`), "for how long is the answer good?" (`memoize(ttl=...)`), "have I done this setup yet?" (`once`), and "what is this object's expensive attribute?" (`cached_property`). + +## `memoize` + +Cache a function's result per argument values. + +=== "Sync" + + ```python + from python_wrap import memoize + + calls = [] + + @memoize + def square(n: int) -> int: + calls.append(n) + return n * n + + assert square(4) == 16 + assert square(n=4) == 16 # arguments are bound to the signature: same entry + assert calls == [4] + assert square.cache_info().hits == 1 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import memoize + + calls = [] + + @memoize + async def square(n: int) -> int: + calls.append(n) + await asyncio.sleep(0.01) + return n * n + + async def main() -> None: + # Concurrent awaits of a missing key share one in-flight task. + results = await asyncio.gather(square(4), square(4), square(n=4)) + assert results == [16, 16, 16] + assert calls == [4] + + asyncio.run(main()) + ``` + +How keys work: + +- Arguments are bound to the function signature and defaults are applied, so `f(1)`, `f(x=1)` and `f(1, y=)` share one entry. +- Lists, dicts and sets (also nested) are converted to hashable forms automatically, and `[1]` and `(1,)` do not collide. +- Any other unhashable argument raises `TypeError` at call time. Pass `key=` to build the key yourself. +- Exceptions are not cached by default; see [Caching exceptions](#caching-exceptions). + +```python +from python_wrap import memoize + +@memoize +def total(items: list[int]) -> int: + return sum(items) + +assert total([1, 2, 3]) == 6 +assert total([1, 2, 3]) == 6 +assert total.cache_info().hits == 1 +``` + +### Expiry and size + +```python +import time +from python_wrap import memoize + +calls = [] + +@memoize(maxsize=2, ttl=0.02) +def load(key: str) -> str: + calls.append(key) + return key.upper() + +load("a"); load("b"); load("c") # "a" is evicted (least recently used) +assert load.cache_info().currsize == 2 +assert load.cache_info().maxsize == 2 + +time.sleep(0.03) # entries expire after ttl +assert load.cache_info().currsize == 0 +load("c") +assert calls == ["a", "b", "c", "c"] +``` + +| Option | Meaning | +| --- | --- | +| `maxsize` | Maximum entries; least recently used are evicted. `None` is unbounded. | +| `ttl` | Seconds (or a `datetime.timedelta`) an entry stays valid. `None` never expires. | +| `typed` | If `True`, `f(1)` and `f(1.0)` are cached separately. | +| `key` | Custom `key(*args, **kwargs)` returning a hashable key. | +| `cache_exceptions` | Exception class (or tuple) to cache like a result. | +| `exception_ttl` | Seconds a cached exception stays valid (defaults to `ttl`). | + +### Per-item TTL + +`ttl` may also be a function of the result, evaluated when the result is stored. Return `None` for "never expires"; a zero or negative value means "do not store". + +```python +from python_wrap import memoize + +@memoize(ttl=lambda token: token["expires_in"]) +def fetch_token(scope: str) -> dict: + return {"scope": scope, "expires_in": 3600} + +fetch_token("read") +assert fetch_token.cache_contains("read") +``` + +### Caching exceptions + +List the exceptions that are a stable answer (a missing record, a rejected input) in `cache_exceptions`. They are stored for `exception_ttl` seconds (default: `ttl`) and re-raised on every hit as the **same exception instance**, so its traceback grows with each raise. `asyncio.CancelledError` is never cached. When `ttl` is a function you must pass `exception_ttl` too. + +```python +from python_wrap import memoize + +calls = [] + +@memoize(cache_exceptions=KeyError, exception_ttl=30) +def lookup(key: str) -> str: + calls.append(key) + raise KeyError(key) + +for _ in range(3): + try: + lookup("x") + except KeyError: + pass +assert calls == ["x"] +``` + +### Controlling the cache + +Every memoized function has: + +```python +from python_wrap import memoize + +@memoize +def double(n: int) -> int: + return n * 2 + +double(1); double(2) +assert double.cache_info().currsize == 2 # CacheInfo(hits, misses, maxsize, currsize) +assert double.cache_delete(1) is True # drop one entry +assert double.cache_delete(1) is False # it was not there +double.cache_clear() # drop everything and reset the statistics +assert double.cache_info() == (0, 0, None, 0) +``` + +Inspect without side effects (nothing is computed, counted or moved in the LRU order): + +```python +from python_wrap import memoize + +@memoize(maxsize=8) +def triple(n: int) -> int: + return n * 3 + +assert not triple.cache_contains(2) +triple(2) +assert triple.cache_contains(2) +assert triple.cache_get(2) == 6 # raises KeyError when absent +assert triple.cache_parameters()["maxsize"] == 8 +``` + +On a method, `cache_delete`, `cache_contains` and `cache_get` take the instance as the first argument, because `obj.method` is an ordinary bound method and its attributes are not bound: `repo.load.cache_get(repo, 42)`. + +`cache_get` is typed `Any` because for `async def` functions it returns the awaited result, not a coroutine. Pass `python_wrap.testing.disable_caching()` to bypass every cache in tests. + +### Events + +`memoize` (and `cache`) emit `cache.hit`, `cache.miss` and `cache.evicted` (field `reason`: `"lru"` or `"ttl"`) to `python_wrap.events` listeners, with the function's qualified name. With no listener registered this costs one list check. + +### A custom key + +Ignore arguments that do not change the answer, or accept arguments that are not hashable: + +```python +from python_wrap import memoize + +class Client: ... + +@memoize(key=lambda client, user_id: user_id) # ignore the client object +def fetch_user(client: Client, user_id: int) -> dict: + return {"id": user_id} + +fetch_user(Client(), 1) +fetch_user(Client(), 1) +assert fetch_user.cache_info().hits == 1 +``` + +### Caching in the face of threads + +The cache is guarded by a lock, but the function is never called while holding it. For sync functions, two threads that miss on the same key at the same moment may both compute the value, and the last result stored wins. If that is not acceptable, stack [`singleflight`](concurrency.md#singleflight) under `memoize`. + +### Methods + +`self` is part of the key, so results are per instance. The cache holds a strong reference to every instance it has seen, which keeps them alive. See [Limitations](../limitations.md#memoize-on-methods). + +## `cache` + +`cache(duration, maxsize=None)` is v0.1's name for `memoize(ttl=duration, maxsize=maxsize)`, kept for compatibility. Prefer `memoize(ttl=...)` in new code (and note that `cache` also clashes with `functools.cache`). + +```python +import datetime +from python_wrap import cache + +@cache(datetime.timedelta(minutes=5)) +def load() -> int: + return 42 + +assert load() == 42 +``` + +Unlike v0.1, every decorated function gets its own store, even when one `cache(...)` result is applied to several functions. + +## `once` + +Run a function a single time; later calls return the first result and ignore their arguments. + +=== "Sync" + + ```python + from python_wrap import once + + @once + def setup() -> object: + return object() + + assert setup() is setup() + assert setup.called is True + + setup.reset() # forget the result; the next call runs again + assert setup.called is False + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import once + + runs = [] + + @once + async def connect() -> str: + runs.append(1) + await asyncio.sleep(0.01) + return "connection" + + async def main() -> None: + a, b = await asyncio.gather(connect(), connect()) # share one task + assert a == b == "connection" + assert runs == [1] + + asyncio.run(main()) + ``` + +`once` is thread-safe. If the first call raises, the function is not marked done and the next call tries again. + +## `cached_property` + +Like `functools.cached_property`, plus `ttl`, per-instance locking, `async def` getters and explicit invalidation. + +=== "Sync" + + ```python + from python_wrap import cached_property, invalidate + + class Report: + loads = 0 + + @cached_property(ttl=60) + def totals(self) -> int: + Report.loads += 1 + return 42 + + r = Report() + assert r.totals == 42 and r.totals == 42 + assert Report.loads == 1 + + del r.totals # invalidate; the next access recomputes + invalidate(r, "totals") # same, by name (no names: every property) + assert r.totals == 42 and Report.loads == 2 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import cached_property + + class Client: + calls = 0 + + @cached_property + async def profile(self) -> dict: + Client.calls += 1 + await asyncio.sleep(0.01) + return {"name": "ada"} + + async def main() -> None: + c = Client() + a, b = await asyncio.gather(c.profile, c.profile) # one shared task + assert a == b == {"name": "ada"} and Client.calls == 1 + + asyncio.run(main()) + ``` + +Notes: the value is stored in the instance `__dict__`, so a `__slots__` class must list `"__dict__"` (otherwise access raises `TypeError`). The attribute is read-only (assigning raises `AttributeError`); use `del` or `invalidate`. Copies and pickles start with an empty cache. Type checkers infer the getter's return type, and `Awaitable[T]` for `async def` getters. + +## Which one? + +| You want | Use | +| --- | --- | +| Same arguments give the same answer forever | `@memoize` (add `maxsize` so it cannot grow without bound) | +| The answer goes stale | `@memoize(ttl=...)` | +| One result regardless of arguments (setup, config load) | `@once` | +| An expensive attribute of one object | `@cached_property` | +| No cache, but do not run the same in-flight call twice | [`@singleflight`](concurrency.md#singleflight) | diff --git a/docs/guides/concurrency.md b/docs/guides/concurrency.md new file mode 100644 index 0000000..db53fdc --- /dev/null +++ b/docs/guides/concurrency.md @@ -0,0 +1,401 @@ +# Concurrency and rate limits + +Control how often a function runs and how many identical calls run at once. + +| Decorator | One-line behaviour | +| --- | --- | +| [`rate_limit`](#rate_limit) | At most N calls per period; reject or wait. | +| [`RateLimiter`](#one-budget-for-several-functions-ratelimiter) | One rate limit shared by several functions and blocks. | +| [`throttle`](#throttle) | At most one run per interval; skipped calls return the last result. | +| [`debounce`](#debounce) | Run once, after calls have stopped for a while. | +| [`singleflight`](#singleflight) | Identical concurrent calls share one execution. | +| [`run_in_thread`](#run_in_thread) | Start the call in a new thread and get a `Future`. | + +For `timeout`, see [Resilience](resilience.md#timeout). + +!!! note "State belongs to the function" + The state kept by `rate_limit`, `throttle` and `debounce` belongs to the decorated function. On a **method** it is shared by every instance. `rate_limit` (and `singleflight`) take `key=` to split it. See [Limitations](../limitations.md#shared-state-on-methods). + +## `rate_limit` + +Allow at most `calls` calls per `period` seconds, using a sliding window. Unlike a fixed window, it never lets a burst of `2 * calls` through at a window boundary. + +=== "Sync" + + ```python + from python_wrap import RateLimitExceeded, rate_limit + + @rate_limit(2, 60) + def send(text: str) -> str: + return text + + send("one") + send("two") + try: + send("three") + except RateLimitExceeded as exc: + assert 0 < exc.retry_after <= 60 # seconds until a slot frees + else: + raise AssertionError("expected the limit to apply") + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import RateLimitExceeded, rate_limit + + @rate_limit(1, 60) + async def send(text: str) -> str: + return text + + async def main() -> None: + await send("one") + try: + await send("two") + except RateLimitExceeded: + pass + else: + raise AssertionError("expected the limit to apply") + + asyncio.run(main()) + ``` + +By default an over-limit call raises `RateLimitExceeded`; its `retry_after` attribute says how many seconds until a slot frees. Rejected calls are not counted. + +### Waiting instead of rejecting + +With `block=True` the call waits until it is allowed. Async functions wait with `await`, so the event loop is never blocked. + +```python +from python_wrap import rate_limit + +@rate_limit(1, 0.02, block=True) +def tick() -> int: + return 1 + +assert tick() + tick() == 2 # the second call waits about 20 ms +``` + +### One limit per user + +`key=` receives the call's arguments (including `self` for methods) and returns a hashable bucket key. Each key gets its own limit. + +```python +from python_wrap import RateLimitExceeded, rate_limit + +@rate_limit(1, 60, key=lambda user, *_: user) +def send(user: str, text: str) -> None: ... + +send("ana", "hi") +send("ben", "hi") # a different bucket +try: + send("ana", "again") +except RateLimitExceeded: + pass +else: + raise AssertionError("ana is over her limit") +``` + +!!! tip "Per-instance limits on methods" + Use `key=lambda self, *a, **kw: id(self)` (or an identifying attribute) to give each instance its own limit. + +### Several limits at once + +APIs often have more than one quota, such as "5 per second and 600 per 15 minutes". Pass them all with `limits=`; a call goes through only when **every** limit allows it, and a rejected call uses up none of them. `retry_after` (and the wait with `block=True`) is the time until all of them allow a call. + +```python +from python_wrap import RateLimitExceeded, rate_limit + +@rate_limit(limits=[(5, 1.0), (600, 900.0)]) +def strava(activity: int) -> int: + return activity + +for n in range(5): + strava(n) +try: + strava(5) # the per-second limit is used up +except RateLimitExceeded as exc: + assert 0 < exc.retry_after <= 1 +else: + raise AssertionError("expected the limit to apply") +``` + +`limits=` can also be combined with the positional pair: `rate_limit(5, 1.0, limits=[(600, 900.0)])`. + +### One budget for several functions: `RateLimiter` + +`rate_limit` gives each decorated function its own budget. When several functions call the same service, create one `RateLimiter` and use it everywhere: as a decorator, as `with limiter:` / `async with limiter:` around a block, or through its methods. + +```python +from python_wrap import RateLimitExceeded +from python_wrap.concurrency import RateLimiter + +github = RateLimiter(2, 60, name="github") + +@github +def get_repo(name: str) -> str: + return name + +@github +def get_user(name: str) -> str: + return name + +get_repo("python-wrap") +get_user("ana") # same budget: 2 of 2 used +try: + with github: # a plain block counts against it too + pass +except RateLimitExceeded as exc: + assert "github rate limit" in str(exc) +else: + raise AssertionError("expected the shared limit to apply") +``` + +`RateLimiter(calls, period, *, limits=None, block=False, name=None)` takes the same limits as `rate_limit`. `block` decides what the decorator and `with` do when over the limit (raise, or wait). For explicit control: + +| Method | Behaviour | +| --- | --- | +| `try_acquire()` | Take a slot if one is free now; returns `True` or `False`. | +| `acquire(block=True, timeout=None)` | Wait in this thread for a slot. With `timeout`, returns `False` as soon as the next slot is further away than that. | +| `await acquire_async(block=True, timeout=None)` | The same for async code; never blocks the event loop. | + +```python +import asyncio +from python_wrap.concurrency import RateLimiter + +limiter = RateLimiter(1, 0.02, block=True) + +async def main() -> None: + async with limiter: + pass + async with limiter: # waits about 20 ms, without blocking the loop + pass + assert not limiter.try_acquire() + assert await limiter.acquire_async(timeout=1) + +asyncio.run(main()) +``` + +### Rate limits in tests and metrics + +`python_wrap.testing.disable_rate_limits()` makes `rate_limit`, `RateLimiter` and `throttle` let every call through. The switch is read at call time, so it also works on functions decorated at import. Under `fast_mode()` limiters keep counting: a non-blocking call over the limit is still rejected, and a `block=True` call that would wait goes through at once (and is recorded). See [Testing](../testing.md). + +To keep limits on in a test but start each test with a fresh budget, call `RateLimiter.reset()`: it forgets every recorded call, for every key. Use a shared `RateLimiter` you can reach from the test, or get the limiter of a `rate_limit`-decorated function with `limiter_of(func)` (it returns `None` for a function without one; `throttle` has no reset): + +```python +from python_wrap.concurrency import limiter_of, rate_limit + +@rate_limit(1, 60) +def send() -> str: + return "sent" + +assert send() == "sent" +limiter = limiter_of(send) +assert limiter is not None +limiter.reset() # e.g. in a fixture, between tests +assert send() == "sent" +``` + +Rate limits emit `rate_limit.rejected` (`retry_after`) when a call is refused and `rate_limit.waited` (`seconds`) after a call had to wait; `throttle` emits `throttle.skipped` for each skipped call. See [Observability](observability.md). + +## `throttle` + +Run at most once per `interval` seconds. The first call runs and opens a window; calls inside the window do not run and return **the result of the last call that did run** (`None` if none has finished yet, or it raised). + +=== "Sync" + + ```python + from python_wrap import throttle + + runs = [] + + @throttle(60) + def refresh() -> int: + runs.append(1) + return len(runs) + + assert refresh() == 1 + assert refresh() == 1 # skipped: returns the previous result + assert len(runs) == 1 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import throttle + + runs = [] + + @throttle(60) + async def refresh() -> int: + runs.append(1) + return len(runs) + + async def main() -> None: + assert await refresh() == 1 + assert await refresh() == 1 + assert len(runs) == 1 + + asyncio.run(main()) + ``` + +A call that raises still uses up its window. The wrapped function returns `R | None` for the type checker. + +Use `throttle` for "update the progress bar", "sync at most once a minute" and similar. Use `rate_limit` when excess calls must be rejected or delayed rather than silently skipped. + +## `debounce` + +Postpone running until `wait` seconds have passed since the **last** call. Each call restarts the countdown and replaces the pending arguments, so only the final call of a burst runs. This is the right tool for "save after the user stops typing". + +=== "Sync" + + ```python + import time + from python_wrap import debounce + + saved = [] + + @debounce(0.02) + def save(text: str) -> None: + saved.append(text) + + save("a") + save("ab") + save("abc") # each call returns None immediately + time.sleep(0.04) # the timer thread runs the last call + assert saved == ["abc"] + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import debounce + + saved = [] + + @debounce(0.02) + async def save(text: str) -> str: + saved.append(text) + return text + + async def main() -> None: + # Every caller in a burst awaits the same outcome: the final call's result. + results = await asyncio.gather(save("a"), save("ab"), save("abc")) + assert results == ["abc", "abc", "abc"] + assert saved == ["abc"] + + asyncio.run(main()) + ``` + +The wrapper also has: + +- `.cancel()` drops the pending call (async awaiters get `CancelledError`). +- `.flush()` runs the pending call right now and returns its result, or `None` if nothing is pending. For `async def` functions it is a coroutine to `await`. + +```python +from python_wrap import debounce + +saved = [] + +@debounce(60) +def save(text: str) -> None: + saved.append(text) + +save("draft") +save("final") +save.flush() # run now instead of waiting a minute +assert saved == ["final"] + +save("never saved") +save.cancel() # drop it +assert saved == ["final"] +``` + +Sync debounced functions run on a daemon timer thread, so the function body runs on a different thread from the caller. Calls must be awaited (or wrapped in tasks) for async debounce to take effect. + +## `singleflight` + +While a call for a given key is running, further calls with the same key wait and receive **the same result or exception**. Once it finishes, the next call runs fresh. This de-duplicates work in flight; it is not a cache. Use it to stop a thundering herd of identical requests (a cache miss that every request tries to fill). + +=== "Sync" + + ```python + import time + from concurrent.futures import ThreadPoolExecutor + from python_wrap import singleflight + + calls = [] + + @singleflight + def load(path: str) -> str: + calls.append(path) + time.sleep(0.03) + return f"contents of {path}" + + with ThreadPoolExecutor(3) as pool: + results = list(pool.map(load, ["a.txt"] * 3)) + + assert results == ["contents of a.txt"] * 3 + assert len(calls) == 1 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import singleflight + + calls = [] + + @singleflight + async def load(path: str) -> str: + calls.append(path) + await asyncio.sleep(0.01) + return f"contents of {path}" + + async def main() -> None: + results = await asyncio.gather(*(load("a.txt") for _ in range(3))) + assert results == ["contents of a.txt"] * 3 + assert calls == ["a.txt"] + + asyncio.run(main()) + ``` + +Sync functions de-duplicate across threads. Async functions de-duplicate across tasks on the same event loop; the work runs in a shared task, so cancelling one waiting caller does not cancel it for the others. + +The default key is the positional arguments plus sorted keyword arguments (including `self` for methods), which must be hashable. Pass `key=` to choose what counts as "the same call", for example `key=lambda path, **_: path`. + +## `run_in_thread` + +Start each call in a new thread and get a `concurrent.futures.Future` back. + +```python +from python_wrap import run_in_thread + +@run_in_thread +def add(a: int, b: int) -> int: + return a + b + +future = add(1, 2) +assert future.result() == 3 # waits; re-raises the function's exception, if any +``` + +- `future.result()` is the equivalent of v0.1's `thread.join()`, plus the return value. +- `async def` functions run with `asyncio.run` in the new thread (their own event loop), and the wrapper itself is a plain function returning a `Future`. +- The caller's `contextvars` are copied into the thread. +- Options: `daemon=True` (do not keep the interpreter alive) and `name=` (thread name). + +```python +import asyncio +from python_wrap import run_in_thread + +@run_in_thread(daemon=True, name="worker") +async def fetch() -> str: + await asyncio.sleep(0.01) + return "done" + +assert fetch().result() == "done" +``` diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 0000000..2eb27d4 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,77 @@ +# Project-wide configuration + +Repeating `retry(max_attempts=5, delay=0.5, ...)` or `redact=(...)` on every decorator is how defaults drift. `python_wrap.config` sets them once. + +```python +import logging +from python_wrap.config import configure, get_config, reset_config + +configure( + logger=logging.getLogger("myapp"), + retry={"max_attempts": 5, "delay": 0.5, "backoff": 2.0, "max_time": 30}, + redact_extra=["ssn", "iban"], +) +assert get_config()["retry"]["max_attempts"] == 5 +reset_config() +``` + +## When values are read + +Call `configure` **once, at start-up, before you decorate anything**. Defaults are read when a decorator is *applied* (at import time for module-level functions), not on every call. A decorator applied earlier keeps the settings it saw. An explicit argument always beats a configured default. + +```python +from python_wrap import retry +from python_wrap.config import configure, reset_config + +@retry +def early() -> None: ... # 3 attempts: applied before configure() + +configure(retry={"max_attempts": 7}) + +@retry +def late() -> None: ... # 7 attempts + +@retry(max_attempts=2) +def explicit() -> None: ... # 2 attempts: explicit wins + +reset_config() +``` + +## Keys + +| Key | Effect | +| --- | --- | +| `logger` | Logger (or name) used by every decorator that logs and was not given `logger=`. `None` restores the package logger. | +| `retry` | Dict of defaults for `retry` and `retrying`: `max_attempts`, `delay`, `backoff`, `max_delay`, `jitter`, `max_time`, `log_level`. | +| `redact` | Replaces the built-in `DEFAULT_REDACT` list for `log_args` and `audit`. `None` restores it. | +| `redact_extra` | Names **added** to the redaction list (the built-ins stay), for `log_args` and `audit`. | + +Each key you pass replaces that key as a whole (`retry={...}` replaces the previous retry defaults); keys you don't mention are untouched. Unknown keys raise `TypeError` with a hint: + +```python +from python_wrap.config import configure + +try: + configure(retyr={"max_attempts": 3}) +except TypeError as exc: + assert "Did you mean 'retry'" in str(exc) +``` + +Retry defaults are validated immediately (`ValueError` for `max_attempts=0`, and so on). Because `redact=` on a decorator replaces the list, prefer `redact_extra` in project settings; it is also what `redact_extra=` does on `log_args` and `audit` themselves. + +## In tests + +`configured()` applies defaults for a block and restores the previous state on exit, even after an error. Apply the decorators inside the block. + +```python +from python_wrap import retry +from python_wrap.config import configured, get_config + +with configured(retry={"max_attempts": 1}): + @retry + def once() -> None: ... + +assert get_config()["retry"] == {} +``` + +`reset_config()` forgets everything; `get_config()` returns a copy of what is in force. diff --git a/docs/guides/devtools.md b/docs/guides/devtools.md new file mode 100644 index 0000000..f705010 --- /dev/null +++ b/docs/guides/devtools.md @@ -0,0 +1,194 @@ +# Developer tools + +Tools for evolving an API, faking data, and keeping decorator stacks tidy. + +## `deprecate` + +Mark a function, method or class as deprecated. Every call (or, for `async def`, every await) emits a warning that **points at the caller's line**, not at the library, so users see which of their code to change. The wrapped object also gets a `__deprecated__` attribute (PEP 702). + +```python +import warnings +from python_wrap import deprecate + +@deprecate(since="0.2", removed_in="1.0", alternative="new_func") +def old_func() -> int: + return 1 + +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert old_func() == 1 + +assert caught[0].category is DeprecationWarning +text = str(caught[0].message) +assert "old_func" in text and "0.2" in text and "1.0" in text and "new_func" in text +``` + +The structured parts are combined into `" is deprecated since ... and will be removed in ...; use ... instead."`. Pass a message to replace the generated text, and `category=` to change the warning class: + +=== "Message" + + ```python + import warnings + from python_wrap import deprecate + + @deprecate("fetch() is going away; call get() instead") + def fetch() -> None: ... + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + fetch() + assert str(caught[0].message) == "fetch() is going away; call get() instead" + ``` + +=== "Class" + + ```python + import warnings + from python_wrap import deprecate + + @deprecate("Use Widget2 instead") + class Widget: + pass + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + widget = Widget() + + assert isinstance(widget, Widget) # still a class, isinstance still works + assert "Widget2" in str(caught[0].message) + ``` + +=== "Category" + + ```python + import warnings + from python_wrap import deprecate + + @deprecate(since="0.2", category=FutureWarning) + def shown_to_end_users() -> None: ... + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + shown_to_end_users() + assert caught[0].category is FutureWarning + ``` + +Classes are changed in place: their `__init__` warns. Remember that Python hides `DeprecationWarning` by default outside `__main__` and test runners; use `category=FutureWarning` when end users, not developers, should see it. + +## `mock_data` + +Replace a function's result with fixed data, for tests and local development. When enabled, the real function is **not called**. + +```python +import os +from python_wrap import mock_data + +@mock_data({"id": 1, "name": "Ada"}, enabled=lambda: os.environ.get("USE_MOCKS") == "1") +def fetch_user(user_id: int) -> dict: + raise RuntimeError("would call the real API") + +os.environ["USE_MOCKS"] = "1" +assert fetch_user(99) == {"id": 1, "name": "Ada"} + +os.environ["USE_MOCKS"] = "0" +try: + fetch_user(99) +except RuntimeError: + pass # disabled: the real function ran +else: + raise AssertionError("expected the real function to run") +``` + +- `enabled` is `True`, `False`, or a zero-argument callable evaluated **on every call**, so it can read an environment variable or a settings flag. +- If `data` is callable it is called on each call and its result is returned. To return a callable itself, wrap it: `mock_data(lambda: my_fn)`. +- Works for `async def` functions (the awaited result is the data). + +```python +import asyncio +from python_wrap import mock_data + +@mock_data(lambda: [1, 2, 3]) # a fresh list on every call +async def numbers() -> list[int]: + raise RuntimeError + +assert asyncio.run(numbers()) == [1, 2, 3] +``` + +!!! danger "Never ship it enabled" + `mock_data` silently replaces real behaviour. Tie `enabled=` to an explicit opt-in such as an environment variable that is off by default, never a default of `True` in code that reaches production. In tests, see [Testing](../testing.md#disabling-mock_data). + +## `compose` + +Combine decorators into one. `compose(a, b, c)(f)` is exactly `a(b(c(f)))`, so list them outermost first, as if stacked top to bottom. + +```python +from python_wrap import compose, memoize, retry, validate_args + +resilient_lookup = compose( + memoize(ttl=60), + retry(max_attempts=3, delay=0.01), + validate_args(key=lambda k: k != ""), +) + +@resilient_lookup +def lookup(key: str) -> str: + return key.upper() + +@resilient_lookup +def lookup_other(key: str) -> str: + return key.lower() + +assert lookup("a") == "A" +assert lookup_other("A") == "a" +``` + +Define a stack once and reuse it across your codebase. `compose()` with no decorators returns the function unchanged, and a non-callable argument raises `TypeError`. The order rules in [Stacking decorators](../stacking.md) apply to the arguments. + +!!! note "Share state deliberately" + `compose(memoize(...))` creates its `memoize` decorator once, but `memoize` still gives each decorated function its own cache. A `CircuitBreaker` instance, on the other hand, is shared by everything it decorates. That is usually what you want for a breaker, but check it. + +## `wrap_methods` + +Apply decorators to the methods of a class in one line. Decorators are listed outermost first, like `compose`, and a [policy](policies.md) works as well. + +```python +import asyncio +from python_wrap import policy, retry, timeout, wrap_methods + +calls = [] + +@wrap_methods( + policy(retry=retry(max_attempts=2, delay=0)), + exclude=["close"], +) +class Client: + def get(self, key: str) -> str: + calls.append(key) + return key + + async def fetch(self, key: str) -> str: + return key + + @staticmethod + def parse(raw: str) -> int: + return int(raw) + + def close(self) -> None: ... + +client = Client() +assert client.get("a") == "a" +assert asyncio.run(client.fetch("b")) == "b" +assert Client.parse("3") == 3 +``` + +Plain, `async def`, `@staticmethod` and `@classmethod` methods are decorated correctly (the underlying function is wrapped and put back in its descriptor). By default these are skipped: dunder methods, names starting with `_`, properties, non-function attributes and inherited methods. + +| Option | Meaning | +| --- | --- | +| `include` | Names (list/set) or a predicate. Restricts the selection. Naming a dunder or a property here opts it in. | +| `exclude` | Names or a predicate to leave alone. Wins over `include`. | +| `include_private` | Also select `_single_underscore` methods. | + +## `no_debug` + +A deprecated no-op kept so v0.1 code keeps importing. It returns the function unchanged and warns when applied. It will be removed in 0.3. To hide debug output, configure [logging levels](observability.md#configuring-logging) instead. diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md new file mode 100644 index 0000000..1fd5793 --- /dev/null +++ b/docs/guides/metrics.md @@ -0,0 +1,199 @@ +# Metrics and events + +Two tools for seeing what your code does in production, with no dependencies: + +- `count_calls` keeps in-process call counts and latency percentiles for one function. +- `python_wrap.events` publishes what the resilience decorators do (retries, cache hits, circuit changes, rejections) so you can forward them to Prometheus, OpenTelemetry, statsd or your logs. + +## `count_calls` + +```python +from python_wrap.metrics import count_calls + +@count_calls(window=500) +def work(n: int) -> int: + if n < 0: + raise ValueError("negative") + return n * 2 + +work(1) +work(2) +try: + work(-1) +except ValueError: + pass + +stats = work.stats +assert (stats.calls, stats.errors, stats.in_flight) == (3, 1, 0) +assert stats.last_duration >= 0 +assert 0 <= stats.p50 <= stats.p95 <= stats.p99 +work.reset() +assert work.stats.calls == 0 +``` + +`.stats` is a live view with `calls`, `errors`, `in_flight`, `last_duration`, `mean`, `p50`, `p95` and `p99` (seconds). `calls` and `errors` count everything since the last `reset()`; the latency figures cover the last `window` finished calls (default 1000), failed ones included. Percentiles use the nearest-rank method. All of it is thread-safe. + +`async def` functions work the same, timed across the whole awaited coroutine, and `in_flight` shows how many calls are running right now: + +```python +import asyncio +from python_wrap.metrics import count_calls + +@count_calls +async def fetch(n: int) -> int: + await asyncio.sleep(0) + return n + +async def main() -> None: + await asyncio.gather(*(fetch(i) for i in range(5))) + assert fetch.stats.calls == 5 + +asyncio.run(main()) +``` + +On methods the statistics belong to the function, so they are shared by every instance. Cancellation counts as an error. + +## Events + +Decorators publish `Event(name, function, fields)` objects. Subscribe to receive them: + +```python +from python_wrap.retry import retry +from python_wrap.events import Event, listening + +seen: list[Event] = [] +calls = 0 + +@retry(max_attempts=3, delay=0) +def flaky() -> str: + global calls + calls += 1 + if calls < 3: + raise ConnectionError("down") + return "ok" + +with listening(seen.append): + assert flaky() == "ok" + +names = [e.name for e in seen] +assert names == ["retry.attempt_failed", "retry.attempt_failed", "retry.succeeded"] +assert seen[0].fields["attempt"] == 1 +``` + +- `subscribe(listener)` registers a listener and returns a function that unsubscribes it. +- `listening(listener)` is the same as a `with` block. +- `filter_events("retry.", listener)` wraps a listener so it only sees names with that prefix (or any of a tuple of prefixes). + +Listeners run synchronously in the thread that triggered the event, so keep them fast and never block. An exception in a listener is logged and swallowed: monitoring code can not break the decorated function. With no listeners registered, emitting is a single list check. + +```python +from python_wrap.events import Event, filter_events, subscribe + +retries: list[Event] = [] +unsubscribe = subscribe(filter_events("retry.", retries.append)) +unsubscribe() +``` + +### Event catalogue + +`python_wrap.events.EVENT_NAMES` maps each name to a description; this table is the same data. `Event.function` is the decorated function's qualified name (for `circuit.*` events it is the breaker's name). + +| Event | Fields | +| --- | --- | +| `retry.attempt_failed` | `attempt` (int, 1-based), `exception`, `next_delay` (seconds) | +| `retry.succeeded` | `attempts` (total made) | +| `retry.gave_up` | `attempts`, `exception` | +| `timeout.expired` | `seconds` (the limit) | +| `circuit.state_changed` | `old`, `new` (`closed`, `open`, `half_open`) | +| `circuit.rejected` | `retry_after` (seconds) | +| `rate_limit.rejected` | `retry_after` (seconds) | +| `rate_limit.waited` | `seconds` | +| `throttle.skipped` | none | +| `cache.hit` | none | +| `cache.miss` | none | +| `cache.evicted` | `reason` (`lru` or `ttl`) | +| `bulkhead.rejected` | `active`, `waiting` | + +```python +from python_wrap.events import EVENT_NAMES + +assert "cache.hit" in EVENT_NAMES +for name, description in EVENT_NAMES.items(): + assert description +``` + +## Recipes + +None of these libraries are dependencies of python-wrap, so the blocks below are not run by the docs checker. Each is a few lines: subscribe once at start-up. + +### Prometheus + + +```python +from prometheus_client import Counter +from python_wrap.events import Event, subscribe + +_counters: dict[str, Counter] = {} + +def to_prometheus(event: Event) -> None: + name = "python_wrap_" + event.name.replace(".", "_") + "_total" + counter = _counters.get(name) + if counter is None: + counter = _counters[name] = Counter(name, event.name, ["function"]) + counter.labels(function=event.function).inc() + +subscribe(to_prometheus) +``` + +Use a fixed set of names (for instance built from `EVENT_NAMES`) to avoid the lookup race on first use, and keep `function` as the only label: never label with exception messages. + +### OpenTelemetry + + +```python +from opentelemetry import metrics, trace +from python_wrap.events import Event, subscribe + +counter = metrics.get_meter("python_wrap").create_counter("python_wrap.events") + +def to_otel(event: Event) -> None: + counter.add(1, {"event": event.name, "function": event.function}) + span = trace.get_current_span() # annotate the active span, if any + if span.is_recording(): + span.add_event(event.name, {"function": event.function}) + +subscribe(to_otel) +``` + +### statsd + + +```python +from statsd import StatsClient +from python_wrap.events import Event, subscribe + +statsd = StatsClient() + +def to_statsd(event: Event) -> None: + statsd.incr(f"python_wrap.{event.name}") + +subscribe(to_statsd) +``` + +### structlog or logging + + +```python +import structlog +from python_wrap.events import Event, subscribe + +log = structlog.get_logger("python_wrap.events") + +def to_structlog(event: Event) -> None: + fields = {k: v for k, v in event.fields.items() if k != "exception"} + log.info(event.name, function=event.function, **fields) + +subscribe(to_structlog) +``` + +With the standard library instead, call `logging.getLogger("myapp.events").info("%s %s", event.name, event.function, extra={"fields": event.fields})`. diff --git a/docs/guides/observability.md b/docs/guides/observability.md new file mode 100644 index 0000000..24f813d --- /dev/null +++ b/docs/guides/observability.md @@ -0,0 +1,369 @@ +# Observability + +Find out how long calls take, how much they allocate, what they were called with, and who called them. + +## Configuring logging + +python-wrap **never configures logging**. Importing it does not touch the root logger, and the `python_wrap` logger only carries a `NullHandler`. Until your application sets logging up, the decorators below are silent. + +Every logging decorator takes two options: + +- `logger=` a `logging.Logger`, a logger name, or `None` for the package logger `python_wrap`. +- `level=` the log level (`timing` and `audit` default to `INFO`; `log_args`, `log_return` and `trace` default to `DEBUG`). + +Turn everything on for development: + + +```python +import logging + +logging.basicConfig(level=logging.DEBUG) +``` + +Or route and filter like any other library: + + +```python +import logging + +# Only python-wrap's messages, to their own handler, at their own level. +logger = logging.getLogger("python_wrap") +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s")) +logger.addHandler(handler) +logger.setLevel(logging.INFO) +logger.propagate = False + +# Or silence it completely. +logging.getLogger("python_wrap").setLevel(logging.CRITICAL + 1) +``` + +To send one decorator's output somewhere specific, name the logger: + +```python +import logging +from python_wrap import timing + +@timing(logger="myapp.perf", level=logging.DEBUG) +def work() -> None: ... + +work() +``` + +The examples below attach a small handler that collects log records, so they can show exactly what is logged. In your own code you configure logging once, as above. + +## `timing` + +Measure and log how long each call takes. Async functions are timed across the whole awaited coroutine. A call that raises is logged as ` raised ValueError after 12.34 ms` and the exception propagates. + +=== "Sync" + + ```python + import logging + from python_wrap import TimingRecord, timing + + records: list[TimingRecord] = [] + + @timing(logger="demo", threshold=0.5, callback=records.append) + def fast() -> int: + return 1 + + fast() + assert records[0].success is True + assert records[0].name.endswith("fast") + assert records[0].elapsed < 0.5 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import TimingRecord, timing + + records: list[TimingRecord] = [] + + @timing(callback=records.append) + async def pause() -> None: + await asyncio.sleep(0.02) + + asyncio.run(pause()) + assert records[0].elapsed >= 0.015 # the awaited time is measured + ``` + +| Option | Meaning | +| --- | --- | +| `threshold` | Only log successful calls slower than this many seconds. Failed calls are always logged. | +| `callback` | Called with a `TimingRecord(name, elapsed, success)` for **every** call, regardless of `threshold`. Use it to feed a metrics system. Errors in the callback are logged and swallowed. | + +`timing` uses `time.perf_counter`. + +## `benchmark` + +Run a function repeatedly and get statistics. It is a **plain function, not a decorator** (see [Migration](../migration.md#benchmark-is-a-function)). + +=== "Sync" + + ```python + from python_wrap import benchmark + + result = benchmark(sorted, args=([3, 1, 2],), runs=10, warmup=2) + assert result.runs == 10 + assert result.min <= result.median <= result.max + print(result) # sorted: 10 runs, mean 1.2 us, median 1.1 us, ... + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import benchmark_async + + async def work(n: int) -> int: + await asyncio.sleep(0) + return n + + async def main() -> None: + result = await benchmark_async(work, args=(1,), runs=3) + assert result.runs == 3 + + asyncio.run(main()) + ``` + +`BenchmarkResult` has `name`, `runs`, `timings`, `mean`, `median`, `min`, `max` and `stdev`, all in seconds. `benchmark` raises `TypeError` for an `async def` function (use `benchmark_async`). + +## `measure_memory` + +Measure the Python memory a call allocates, using `tracemalloc`. Works on every OS and logs ` peak memory 1.00 MiB (net +12.00 KiB)`. + +```python +from python_wrap import MemoryRecord, measure_memory + +records: list[MemoryRecord] = [] + +@measure_memory(callback=records.append) +def build() -> list[int]: + return [0] * 100_000 + +data = build() +assert records[0].peak_bytes >= 100_000 * 8 * 0.5 # at least roughly the list's size +assert records[0].net_bytes > 0 # the list is still alive +``` + +`peak_bytes` is the highest traced allocation during the call, relative to its start; `net_bytes` is the memory still held at the end minus at the start (it can be negative). + +!!! note + `tracemalloc` is process-global. Allocations by other threads or tasks while the call runs are counted too, and tracing slows the program. See [Limitations](../limitations.md#measure_memory-and-profile). + +## `profile` + +Profile each call with `cProfile` and log the top rows. + +```python +import pstats +from python_wrap import profile + +captured: list[pstats.Stats] = [] + +@profile(sort="tottime", limit=5, callback=captured.append) +def work() -> int: + return sum(i * i for i in range(1000)) + +work() +assert isinstance(captured[0], pstats.Stats) +``` + +`sort` is any `pstats.SortKey` value (`"cumulative"` by default, or `"tottime"`, `"calls"`, ...); `limit` is the number of rows logged (`None` for all). If another profiler is already active, a warning is logged once and the function runs unprofiled. + +## `log_args` and `log_return` + +`log_args` logs each call's arguments by parameter name, before running it. `log_return` logs the return value, or the exception type if it raises. + +```python +import logging +from python_wrap import log_args, log_return + +class ListHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(logging.DEBUG) + self.messages: list[str] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.messages.append(record.getMessage()) + +log = logging.getLogger("demo") +log.setLevel(logging.DEBUG) +handler = ListHandler() +log.addHandler(handler) + +@log_args(logger=log) +@log_return(logger=log) +def add(a: int, b: int) -> int: + return a + b + +add(1, b=2) +assert "Calling add(a=1, b=2)" in handler.messages +assert "add returned 3" in handler.messages +``` + +Both truncate long values (`max_length`, default 200), and both default to `DEBUG` (in v0.1, `log_args` logged at `INFO`). + +### Redaction + +`log_args` (and `audit`) mask parameters whose **name contains** any of the redaction fragments, case-insensitively. The masked value is shown as `'***'`. + +The default list is `DEFAULT_REDACT`: `password`, `passwd`, `secret`, `token`, `api_key`, `apikey`, `authorization`, `auth`, `credential`, `private_key`, `cookie`, `session`. + +```python +import logging +from python_wrap import DEFAULT_REDACT, log_args + +class ListHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(logging.DEBUG) + self.messages: list[str] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.messages.append(record.getMessage()) + +log = logging.getLogger("redaction-demo") +log.setLevel(logging.DEBUG) +sink = ListHandler() +log.addHandler(sink) + +@log_args(logger=log) +def login(user: str, password: str) -> None: ... + +login("ana", "hunter2") +assert "hunter2" not in sink.messages[0] +assert "password='***'" in sink.messages[0] + +# Extend the defaults, or pass () to disable redaction. +@log_args(logger=log, redact=(*DEFAULT_REDACT, "ssn")) +def register(name: str, ssn: str) -> None: ... + +register("ana", "123-45-6789") +assert "123-45-6789" not in sink.messages[1] +``` + +To **add** names without replacing the defaults, use `redact_extra=`, on `log_args` and on `audit`: + +```python +from python_wrap import audit + +records = [] + +@audit(redact_extra=["ssn"], sink=records.append) +def register(name: str, ssn: str, password: str) -> None: ... + +register("ana", "123-45-6789", "hunter2") +args = records[0].arguments +assert args["ssn"] == "***" and args["password"] == "***" and args["name"] == "ana" +``` + +To apply the same names to every `log_args` and `audit` in the project, set them once with `configure(redact_extra=[...])` (or replace the whole list with `configure(redact=[...])`); see [Project-wide configuration](configuration.md). + +!!! warning "`redact=` replaces the defaults" + Passing `redact=("ssn",)` masks only `ssn`; `password` is no longer masked. Prefer `redact_extra=`. Redaction works by parameter **name**; it does not scan values, and it does not touch what the function itself logs or returns. + +## `audit` + +Record who called a function, with what, and how it ended. Each call produces an `AuditRecord`, delivered to your `sink`, and also attached to the log line as `LogRecord.audit`. + +```python +from python_wrap import AuditRecord, audit + +records: list[AuditRecord] = [] + +@audit(sink=records.append, include_result=True) +def delete_user(user_id: int, token: str) -> str: + if user_id < 0: + raise ValueError("bad id") + return "deleted" + +delete_user(7, token="abc123") +try: + delete_user(-1, token="abc123") +except ValueError: + pass + +ok, failed = records +assert ok.status == "success" and ok.result == "deleted" +assert ok.arguments == {"user_id": 7, "token": "***"} # sensitive values are masked +assert failed.status == "error" and "ValueError" in failed.error +assert ok.caller.endswith(tuple("0123456789")) # "file:line" of the calling code +``` + +`AuditRecord` fields: `function`, `timestamp` (UTC, timezone-aware), `caller` (`"file:line"`), `arguments`, `status` (`"success"` or `"error"`), `error` (the `repr` of the exception, or `None`), `duration` and `result` (only with `include_result=True`). + +An exception raised by the sink is logged and swallowed, so a broken audit store never breaks the audited function. Exceptions from the function itself are recorded and re-raised. + +To read the record from a log handler instead, use `record.audit` on the `LogRecord`. + +## `trace` + +Log entry, the call stack that led to the call, and exit. Three messages per call: `Entering f`, the stack (python-wrap's own frames left out, outermost first), and `Exiting f` (or `Exiting f (raised ValueError)`). + +```python +from python_wrap import trace + +@trace(limit=5) # keep the 5 innermost frames +def handler() -> int: + return 1 + +assert handler() == 1 +``` + +Use it to answer "who is calling this?" while debugging. It defaults to `DEBUG`, so it is silent unless you enable that level. + +## `log_context` + +Bind fields such as a request id once, and have every python-wrap log line made underneath carry them. It works as a context manager (`with` and `async with`) and as a decorator, and blocks nest. + +```python +import logging +from python_wrap import log_args +from python_wrap.logs import current_log_context, log_context + +class ListHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(logging.DEBUG) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + +log = logging.getLogger("context-demo") +log.setLevel(logging.DEBUG) +handler = ListHandler() +log.addHandler(handler) + +@log_args(logger=log) +def charge(amount: int) -> None: ... + +with log_context(request_id="r-42"): + with log_context(user="ana"): + assert current_log_context() == {"request_id": "r-42", "user": "ana"} + charge(5) + +record = handler.records[0] +assert record.getMessage() == "Calling charge(amount=5) [request_id=r-42 user=ana]" +assert record.context == {"request_id": "r-42", "user": "ana"} # type: ignore[attr-defined] +assert current_log_context() == {} +``` + +`log_args`, `log_return`, `audit`, `trace` and `timing` append ` [key=value ...]` to their message and attach the dict as `LogRecord.context`, so a JSON formatter can emit the fields as proper keys. `AuditRecord.context` holds the fields bound when the call started (an empty dict when none). + +As a decorator, the fields are bound for each call: + +```python +import asyncio +from python_wrap.logs import current_log_context, log_context + +@log_context(job="nightly") +async def run() -> dict[str, object]: + return current_log_context() + +assert asyncio.run(run()) == {"job": "nightly"} +``` + +The fields live in a `contextvars.ContextVar`: every asyncio task and thread sees its own. A new thread starts **empty**; to carry the fields over, start it in a copied context with `contextvars.copy_context().run(...)` (`run_in_thread` and `asyncio.to_thread` already do this). diff --git a/docs/guides/policies.md b/docs/guides/policies.md new file mode 100644 index 0000000..8d96539 --- /dev/null +++ b/docs/guides/policies.md @@ -0,0 +1,77 @@ +# Policies + +A **policy** is a reusable, named bundle of decorators. You say *which* protections a call needs; the policy applies them in the [recommended order](../stacking.md), however you list them. + +```python +from python_wrap import CircuitBreaker, policy, retry, timeout + +payments = policy( + timeout=timeout(2), # listed first on purpose + retry=retry(max_attempts=3, delay=0.01, on=ConnectionError), + breaker=CircuitBreaker(failure_threshold=5, on=ConnectionError), + name="payments", +) + +@payments +def charge(amount: int) -> int: + return amount + +assert charge(5) == 5 +print(payments) # Policy('payments', breaker > retry > timeout) +``` + +## Why keyword roles + +Each decorator goes into a named **role**. The role decides the position, so the code cannot be mis-ordered, and the arguments are checked by the type checker. Guessing a role from a decorator object would be fragile (factories return plain closures), so python-wrap does not guess. + +Outermost first: + +| Role | Typical decorators | Why here | +| --- | --- | --- | +| `observe` | `timing`, `audit`, `log_args` | See the outcome the caller gets, retries included. | +| `validate` | `validate_args`, `enforce_types` | Bad input is rejected before anything else runs. | +| `fallback` | `fallback`, `suppress` | Catches what the layers below could not fix. | +| `cache` | `memoize`, `once`, `singleflight` | A hit skips everything below. | +| `limiter` | `rate_limit`, `throttle` | One token per logical call, not per attempt. | +| `bulkhead` | `Bulkhead`, `limit_concurrency` | Caps concurrent logical calls. | +| `breaker` | `CircuitBreaker`, `circuit_breaker` | Counts calls whose retries were exhausted. | +| `retry` | `retry` | Repeats everything below. | +| `timeout` | `timeout` | A deadline per attempt. | +| `guard` | `revert_on_failure`, `transactional` | Each attempt starts from clean state. | + +A role accepts one decorator or a list (outermost first). For a decorator with no role, `outer=` places it outside every role and `inner=` just above the function. + +## Deriving variants + +Policies are immutable. `with_()` returns a changed copy; `None` removes a role. + +```python +from python_wrap import policy, retry, timeout + +base = policy(retry=retry(max_attempts=3, delay=0), timeout=timeout(2), name="io") +patient = base.with_(timeout=timeout(30), name="io-patient") +no_retry = base.with_(retry=None) + +assert base.order == ("retry", "timeout") +assert no_retry.order == ("timeout",) +``` + +## Async and methods + +A policy is a decorator for plain, `async def`, instance, static and class methods, and keeps the function's type. Combine it with [`wrap_methods`](devtools.md#wrap_methods) to protect a whole class. + +```python +import asyncio +from python_wrap import policy, retry + +p = policy(retry=retry(max_attempts=2, delay=0)) + +@p +async def load(x: int) -> int: + return x + +assert asyncio.run(load(1)) == 1 +``` + +!!! note "Shared state" + A `CircuitBreaker` instance is shared by everything the policy decorates, which is usually what you want for one dependency. Other decorators create state per decorated function. diff --git a/docs/guides/resilience.md b/docs/guides/resilience.md new file mode 100644 index 0000000..7fbd2bf --- /dev/null +++ b/docs/guides/resilience.md @@ -0,0 +1,579 @@ +# Resilience + +Calls to networks, disks and other services fail. These decorators decide what happens next: try again, return a default, give up quietly, stop calling a broken dependency, or stop waiting. + +## `retry` + +Call the function again when it raises. The last exception is re-raised unchanged, with its original traceback, once attempts run out. + +=== "Sync" + + ```python + from python_wrap import retry + + calls = [] + + @retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError) + def fetch() -> str: + calls.append(1) + if len(calls) < 3: + raise ConnectionError("down") + return "payload" + + assert fetch() == "payload" + assert len(calls) == 3 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import retry + + calls = [] + + @retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError) + async def fetch() -> str: + calls.append(1) + if len(calls) < 3: + raise ConnectionError("down") + return "payload" + + assert asyncio.run(fetch()) == "payload" + assert len(calls) == 3 + ``` + +The wait before attempt `n + 1` is `min(delay * backoff ** (n - 1), max_delay) + random.uniform(0, jitter)`. + +| Option | Default | Meaning | +| --- | --- | --- | +| `max_attempts` | `3` | Total calls allowed. `None` retries forever. | +| `delay` | `1.0` | Base wait in seconds. | +| `backoff` | `1.0` | Multiplier applied after each failure (use `2` for exponential). | +| `max_delay` | `None` | Cap on the backoff wait (before jitter). | +| `jitter` | `0.0` | Up to this many random extra seconds, so many clients do not retry in lockstep. | +| `max_time` | `None` | Total time budget in seconds from the first attempt. | +| `on` | `Exception` | Exception type(s) that trigger a retry. Anything else propagates immediately. | +| `retry_if` | `None` | Extra predicate on the exception; retry only if it returns `True`. | +| `on_retry` | `None` | Called with a `RetryState` before each wait. | +| `delay_from` | `None` | `exc -> seconds or None`: exact wait for this attempt (e.g. `Retry-After`). | +| `logger` | package logger | Logger or logger name for the per-retry message. | +| `log_level` | `WARNING` | Level of the per-retry message; `None` disables it. | +| `log_exc_info` | `False` | Attach the traceback to the per-retry log record. | + +`retry` works bare (`@retry`), with options, and as a plain call: `retry(func, max_attempts=5)`. + +!!! warning "Retry only what is safe to repeat" + Retrying a non-idempotent call (charging a card, sending an email) can do it twice. Narrow `on=` to errors that mean "it did not happen", and use `retry_if` to inspect the exception. + +### Deciding what is retryable + +```python +from python_wrap import RetryState, retry + +class HttpError(Exception): + def __init__(self, status: int) -> None: + super().__init__(f"HTTP {status}") + self.status = status + +seen: list[RetryState] = [] +responses = iter([503, 502, 200]) + +@retry( + max_attempts=5, + delay=0.01, + on=HttpError, + retry_if=lambda exc: exc.status >= 500, + on_retry=seen.append, +) +def get() -> int: + status = next(responses) + if status != 200: + raise HttpError(status) + return status + +assert get() == 200 +assert [s.attempt for s in seen] == [1, 2] +assert isinstance(seen[0].exception, HttpError) +``` + +`RetryState` carries `attempt`, `exception`, `next_delay` and `elapsed`, which is enough to feed a metric or a custom log line. + +### Honour `Retry-After` + +Servers that answer 429 or 503 often say how long to wait. Pass `delay_from` a function that maps the exception to a number of seconds, or to `None` to fall back to the normal backoff. A number is the exact wait for that attempt (no backoff, no jitter), still capped by `max_delay` and `max_time`. + +`retry_after_header` reads `exc.retry_after` or `exc.response.headers["Retry-After"]` (seconds or an HTTP-date) and never raises, so it works with `httpx.HTTPStatusError`, `requests.HTTPError` and similar without importing them. The example uses a stand-in exception with the same shape: + +```python +from python_wrap import retry +from python_wrap import retry_after_header + +class Response: + def __init__(self, headers: dict[str, str]) -> None: + self.headers = headers + +class HTTPStatusError(Exception): # shaped like httpx / requests errors + def __init__(self, headers: dict[str, str]) -> None: + super().__init__("429 Too Many Requests") + self.response = Response(headers) + +replies = iter([{"Retry-After": "0.01"}, {}, None]) + +@retry(on=HTTPStatusError, delay=0.01, delay_from=retry_after_header, max_delay=30) +def fetch() -> str: + headers = next(replies) + if headers is not None: + raise HTTPStatusError(headers) + return "payload" + +assert fetch() == "payload" +``` + +Any function works as `delay_from`, e.g. `delay_from=lambda exc: getattr(exc, "wait", None)`. If it raises, the normal backoff is used. + +### Time budget + +`max_time` is a total budget in seconds, measured from the first attempt. `retry` never sleeps past it: if the next wait would end after the deadline, the last exception is re-raised at once. + +```python +from python_wrap import retry + +calls = [] + +@retry(max_attempts=None, delay=0.01, backoff=2, max_time=0.05) +def flaky() -> None: + calls.append(1) + raise ConnectionError("down") + +try: + flaky() +except ConnectionError: + pass +assert len(calls) >= 2 +``` + +`retry` also honours an enclosing [`deadline`](../api/deadline.md): it never sleeps past `remaining()` (from `python_wrap.deadline`). If the next wait would outlast the deadline, or none is left, the last exception is re-raised at once (the same rule as `max_time`). + +```python +from python_wrap import retry +from python_wrap.deadline import deadline + +calls = [] + +@retry(max_attempts=None, delay=0.02) +def flaky() -> None: + calls.append(1) + raise ConnectionError("down") + +with deadline(0.05): + try: + flaky() + except ConnectionError: + pass +assert 1 <= len(calls) <= 4 +``` + +### Project-wide defaults + +Set `max_attempts`, `delay`, `backoff`, `max_delay`, `jitter`, `max_time` and `log_level` once for the whole project with `python_wrap.config.configure(retry={...})`. See [Project-wide configuration](configuration.md). + +### Cancellation and shutdown + +`asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` are **never retried**, even with `on=BaseException` or when you list them in `on`: retrying them would make tasks and processes impossible to stop. Cancelling an async call while it waits between attempts cancels it immediately. + +### Logging + +Each retry logs one message at `log_level` (default `WARNING`) on `logger`. Use `log_level=logging.INFO` to quiet it, `log_level=None` to turn it off, and `log_exc_info=True` to attach the traceback. + +### Statistics + +`retry_stats(func)` returns a `RetryStats` with thread-safe counters: `calls`, `attempts_total`, `retries`, `gave_up`, `total_sleep` and `last_exception`. Counters are per decorated function (shared by all instances for methods); `snapshot()` copies them and `reset()` clears them. + +```python +from python_wrap import retry +from python_wrap.retry import retry_stats + +n = [] + +@retry(delay=0.01) +def fetch() -> str: + n.append(1) + if len(n) < 3: + raise OSError("flaky") + return "ok" + +assert fetch() == "ok" +stats = retry_stats(fetch) +assert stats is not None +assert (stats.calls, stats.attempts_total, stats.retries, stats.gave_up) == (1, 3, 2, 0) +``` + +### Retrying a block of code + +`retrying(...)` takes the same options as `retry` and lets you retry code that is not a function of its own. Put the risky part in `with attempt:`; a retryable exception is swallowed and the loop waits and continues, success ends the loop, and the last failure propagates. `attempt.number` is the 1-based attempt number. + +=== "Sync" + + ```python + from python_wrap.retry import retrying + + tries = [] + + for attempt in retrying(max_attempts=3, delay=0.01, on=ConnectionError): + with attempt: + tries.append(attempt.number) + if len(tries) < 3: + raise ConnectionError("down") + + assert tries == [1, 2, 3] + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap.retry import retrying + + async def main() -> list[int]: + tries: list[int] = [] + async for attempt in retrying(max_attempts=3, delay=0.01, on=ConnectionError): + with attempt: + tries.append(attempt.number) + if len(tries) < 3: + raise ConnectionError("down") + return tries + + assert asyncio.run(main()) == [1, 2, 3] + ``` + +Use `async for` inside coroutines: a plain `for` would block the event loop while waiting. + +### Metrics and tests + +`retry` emits the events `retry.attempt_failed` (`attempt`, `exception`, `next_delay`), `retry.succeeded` (`attempts`) and `retry.gave_up` (`attempts`, `exception`) for `python_wrap.events` listeners. In tests, the `max_attempts` override from `python_wrap.testing` caps the attempts of every `retry` and `retrying` at call time. + +## `fallback` + +Return a value, or the result of a handler, when the function raises. + +=== "Sync" + + ```python + from python_wrap import fallback + + @fallback(0, on=KeyError) + def stock(sku: str) -> int: + return {"a": 5}[sku] + + assert stock("a") == 5 + assert stock("missing") == 0 + + @fallback(handler=lambda exc: f"failed: {exc!r}") + def risky() -> str: + raise RuntimeError("boom") + + assert risky() == "failed: RuntimeError('boom')" + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import fallback + + @fallback(0, on=KeyError) + async def stock(sku: str) -> int: + return {"a": 5}[sku] + + assert asyncio.run(stock("missing")) == 0 + ``` + +Give exactly one of `value` or `handler=`; giving both or neither raises `ValueError`. The exception is logged at WARNING with its traceback. For async functions the handler is still a regular function. + +!!! tip + Pass a fresh object with `handler=lambda exc: []` if the default is mutable, so callers do not share one list. + +## `suppress` + +Swallow matching exceptions; the call returns `None`. Suppressed errors are logged at DEBUG. + +```python +from python_wrap import suppress + +@suppress(KeyError) +def first(d: dict[str, int]) -> int: + return d["x"] + +assert first({"x": 1}) == 1 +assert first({}) is None + +@suppress # bare form suppresses Exception +def best_effort() -> None: + raise RuntimeError + +assert best_effort() is None +``` + +Use `fallback` when you need a value other than `None`. Use `suppress` for best-effort work such as cleanup or telemetry. + +## Circuit breaker + +When a dependency is down, retrying makes things worse. A circuit breaker counts consecutive failures and, past a threshold, rejects calls immediately with `CircuitOpenError` for a while. + +| State | Behaviour | Moves on when | +| --- | --- | --- | +| `closed` | Calls run normally. | `failure_threshold` consecutive failures: becomes `open`. | +| `open` | Calls are rejected with `CircuitOpenError`. | `recovery_timeout` seconds pass: becomes `half_open`. | +| `half_open` | A limited number of trial calls run. | A trial succeeds: `closed`. A trial fails: `open` again. | + +```python +import time +from python_wrap import CircuitOpenError, CircuitState, circuit_breaker + +@circuit_breaker(failure_threshold=2, recovery_timeout=0.02, on=ConnectionError) +def call_api() -> str: + raise ConnectionError("down") + +for _ in range(2): + try: + call_api() + except ConnectionError: + pass + +assert call_api.breaker.state is CircuitState.OPEN +try: + call_api() +except CircuitOpenError as exc: + assert exc.retry_after <= 0.02 + +time.sleep(0.03) +assert call_api.breaker.state is CircuitState.HALF_OPEN +call_api.breaker.reset() +assert call_api.breaker.state is CircuitState.CLOSED +``` + +`@circuit_breaker(...)` gives the function its own breaker, exposed as `wrapper.breaker`. To **share one breaker across several functions** (they all hit the same service), create a `CircuitBreaker` and use the instance as the decorator: + +```python +from python_wrap import CircuitBreaker, CircuitOpenError + +payments = CircuitBreaker(failure_threshold=1, recovery_timeout=60, name="payments") + +@payments +def charge() -> None: + raise RuntimeError("provider down") + +@payments +def refund() -> None: + ... + +try: + charge() +except RuntimeError: + pass + +try: + refund() # never runs: the shared circuit is open +except CircuitOpenError: + pass +else: + raise AssertionError("expected the circuit to be open") + +assert payments.failure_count == 1 +``` + +You can also call through a breaker without decorating: `breaker.call(func, *args, **kwargs)` (for an `async def` function it returns a coroutine to await). + +Only exceptions matching `on=` count as failures; others pass through uncounted. `half_open_max_calls` limits how many trial calls may run at once while half-open. + +### Tuning recovery, exclusions and monitoring + +| Option | Default | What it does | +| --- | --- | --- | +| `success_threshold` | `1` | Consecutive successful trial calls needed to close a half-open circuit. Any counted failure during the trials re-opens it. | +| `exclude` | `()` | Exception types that are never counted, even when they match `on=` (for example a "not found" error that proves the service is up). They propagate unchanged. | +| `on_state_change` | `None` | `listener(breaker, old_state, new_state)`, called after every transition, outside the breaker's lock. An exception from the listener is logged, never raised. | +| `name` | function name | Used in log messages, `CircuitOpenError` messages and events. | + +`breaker.stats` returns a frozen `CircuitStats` snapshot: `state`, total `failures`, `successes` and `rejected` calls (since creation or the last `reset()`), `consecutive_failures`, and `opened_at` (the `monotonic()` time the circuit last opened, `None` while closed). + +```python +from python_wrap import CircuitBreaker, CircuitState + +class NotFound(Exception): + pass + +changes = [] +inventory = CircuitBreaker( + failure_threshold=2, + recovery_timeout=0, # half-open again straight away, for the example + success_threshold=2, # two good trial calls before trusting it again + exclude=NotFound, # a 404 means the service is up + on_state_change=lambda b, old, new: changes.append(new.value), + name="inventory", +) + +@inventory +def lookup(sku: str) -> int: + if sku == "missing": + raise NotFound(sku) + if sku == "broken": + raise ConnectionError("down") + return 3 + +for sku in ("missing", "broken", "broken"): + try: + lookup(sku) + except (NotFound, ConnectionError): + pass + +assert inventory.stats.failures == 2 # NotFound was not counted +assert lookup("ok") == 3 # first trial: still half-open +assert inventory.state is CircuitState.HALF_OPEN +assert lookup("ok") == 3 # second trial closes it +assert changes == ["open", "half_open", "closed"] +``` + +The breaker also emits the `circuit.state_changed` (`old`, `new`) and `circuit.rejected` (`retry_after`) [events](observability.md), reported under the breaker's name. + +## `timeout` + +Raise `TimeoutExpired` (a subclass of the built-in `TimeoutError`) if a call runs too long. + +=== "Sync" + + ```python + import time + from python_wrap import TimeoutExpired, timeout + + @timeout(0.02) + def slow() -> None: + time.sleep(1) + + try: + slow() + except TimeoutExpired as exc: + assert exc.seconds == 0.02 + assert isinstance(exc, TimeoutError) + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import TimeoutExpired, timeout + + @timeout(0.02) + async def slow() -> None: + await asyncio.sleep(1) + + async def main() -> None: + try: + await slow() + except TimeoutExpired: + pass + else: + raise AssertionError("expected a timeout") + + asyncio.run(main()) + ``` + +How the deadline is enforced depends on the function and the platform: + +| Situation | Strategy | Stops the work? | +| --- | --- | --- | +| `async def` | `asyncio.timeout` (3.11+) or a cancellation timer (3.10) cancels the coroutine | Yes | +| Sync, Unix, main thread | `SIGALRM` timer interrupts the call | Yes | +| Sync on Windows, in a non-main thread, or `use_signals=False` | Runs in a daemon worker thread; the caller stops waiting | **No** | + +!!! warning "The thread fallback cannot stop the work" + When signals are unavailable, the function keeps running in the background after the timeout, and its result is discarded. Do not rely on it to cancel side effects. See [Limitations](../limitations.md#timeout). + +The worker threads are pooled: a finished worker waits (as an idle daemon thread) for the next call instead of a new thread being started per call. At most 8 idle workers are kept, an idle worker exits after about 5 seconds without work, and a worker still running a call that timed out is abandoned: it exits when that call returns and is never reused. The caller's `contextvars` are copied into the worker on every call. + +Nested timeouts work: the previous `SIGALRM` handler and any already-armed timer are restored afterwards. `use_signals=True` forces signals (and raises `RuntimeError` where unavailable); `use_signals=False` forces the thread strategy. + +Every expiry emits the `timeout.expired` (`seconds`) [event](observability.md). + +### Timing out a block + +The object `timeout(...)` returns is also a context manager, so you can limit a block of code without wrapping it in a function. The same object still works as a decorator. + +=== "Async" + + ```python + import asyncio + from python_wrap import TimeoutExpired, timeout + + async def main() -> None: + try: + async with timeout(0.02): + await asyncio.sleep(1) + except TimeoutExpired as exc: + assert exc.seconds == 0.02 + else: + raise AssertionError("expected a timeout") + + asyncio.run(main()) + ``` + +=== "Sync" + + + ```python + import time + from python_wrap import TimeoutExpired, timeout + + try: + with timeout(0.02): # Unix, main thread only + time.sleep(1) + except TimeoutExpired: + pass + ``` + +`async with timeout(...)` uses `asyncio.timeout` on Python 3.11+ and a cancellation timer on 3.10; as with the decorator, a `TimeoutError` raised by your own code inside the block propagates unchanged. + +The sync `with timeout(...)` form only exists on top of `SIGALRM`: it needs Unix and the main thread. Elsewhere (Windows, worker threads, `use_signals=False`) entering it raises `RuntimeError`, because a block of code cannot be moved to a worker thread. Decorate a function instead, which falls back to the thread strategy. + +### Deadlines: one budget for many steps + +A `timeout` limits one call. A **deadline** limits everything inside a block — several calls, retries, sub-steps — to one shared budget. `deadline(seconds)` stores an absolute expiry in a context variable, so it is visible to every function called inside the block, sync or async, and to tasks started from it. Nested deadlines never extend an outer one: the earliest wins. + +A deadline does not interrupt anything by itself. Instead: + +* `timeout` (decorator or block) clamps its limit to the time the deadline has left. If the deadline has already passed, the call is not started and `TimeoutExpired` is raised at once. +* Import the helpers from the module: `from python_wrap.deadline import check, deadline, remaining`. The package attribute `python_wrap.deadline` is the `deadline` function itself (the same is true of `retry` and `policy`), so `import python_wrap.deadline as m` does not give you the module. +* `remaining()` returns the seconds left (or `None` outside any deadline). Pass it on, for example as an HTTP client's timeout, or use it to cap your own retry loop. +* `check()` raises `TimeoutExpired` once the deadline has passed. Call it between steps of a long job. + +```python +import asyncio +from python_wrap import TimeoutExpired, timeout +from python_wrap.deadline import check, deadline, remaining + +@timeout(30) # generous per-call limit... +async def fetch(part: int) -> int: + await asyncio.sleep(0.01 if part < 2 else 1) + return part + +async def handle_request() -> list[int]: + results = [] + async with deadline(0.1): # ...but the whole request gets 0.1s + for part in range(5): + check() # stop early once the budget is spent + left = remaining() + assert left is not None and left <= 0.1 + results.append(await fetch(part)) # clamped to what is left + return results + +async def main() -> None: + try: + await handle_request() + except TimeoutExpired as exc: + assert exc.seconds <= 0.1 + else: + raise AssertionError("expected the deadline to run out") + +asyncio.run(main()) +``` diff --git a/docs/guides/state.md b/docs/guides/state.md new file mode 100644 index 0000000..62f3f49 --- /dev/null +++ b/docs/guides/state.md @@ -0,0 +1,214 @@ +# State safety + +Keep objects consistent when something goes wrong halfway. + +## `revert_on_failure` + +If the function raises, restore the object's attributes to what they were before the call, then re-raise. + +```python +from python_wrap import revert_on_failure + +class Account: + def __init__(self) -> None: + self.balance = 10 + self.history: list[str] = [] + + @revert_on_failure + def withdraw(self, amount: int) -> None: + self.history.append(f"withdraw {amount}") + self.balance -= amount + if self.balance < 0: + raise ValueError("overdrawn") + +account = Account() +try: + account.withdraw(50) +except ValueError: + pass + +assert account.balance == 10 # restored +assert account.history == [] # the append was undone too +``` + +By default it snapshots `vars(self)`: the first argument of the call. Attributes created during the call are deleted again on failure. + +| Option | Meaning | +| --- | --- | +| `target` | Index or parameter name of the argument to protect (default `0`, normally `self`). | +| `attrs` | Snapshot only these attribute names. **Required for classes using `__slots__`.** | +| `deep` | Use `copy.deepcopy` for the snapshot instead of `copy.copy`. | + +```python +from python_wrap import revert_on_failure + +class Cart: + def __init__(self) -> None: + self.items: list[dict] = [{"qty": 1}] + self.coupon = None + + @revert_on_failure(attrs=["items"], deep=True) + def bump(self) -> None: + self.items[0]["qty"] += 1 # a nested object + self.coupon = "TEN" # not protected: not in attrs + raise RuntimeError + +cart = Cart() +try: + cart.bump() +except RuntimeError: + pass + +assert cart.items[0]["qty"] == 1 # deep=True undoes nested mutation +assert cart.coupon == "TEN" # outside attrs, so not restored +``` + +!!! warning "Copy semantics" + Each value is copied (`copy.copy`, or `copy.deepcopy` with `deep=True`) and the **copy** is restored. In-place mutation of a container attribute (`self.items.append(x)`) is undone, but the attribute ends up referring to a new list, so other references to the old list still see the mutation. A shallow copy does not protect nested objects: `self.items[0].count += 1` is only undone with `deep=True`. Values that cannot be copied are restored by reference. See [Limitations](../limitations.md#revert_on_failure). + +It works on `async def` methods too. It undoes attribute changes only, not side effects such as files written or requests sent; for those, use a transaction. + +## Transactions + +A `Transaction` collects undo steps (`on_rollback`) and follow-up steps (`on_commit`) and runs the right set exactly once. + +- On success, `on_commit` callbacks run **in order**. +- On exception, `on_rollback` callbacks run **in reverse registration order**; a failing rollback callback is logged and the rest still run; then the original exception propagates. +- A transaction completes once; a second `commit()` or `rollback()` raises `RuntimeError`. + +=== "Sync" + + ```python + from python_wrap import Transaction + + log: list[str] = [] + + try: + with Transaction() as tx: + log.append("created file") + tx.on_rollback(log.append, "removed file") + log.append("charged card") + tx.on_rollback(log.append, "refunded card") + tx.on_commit(log.append, "sent receipt") + raise RuntimeError("shipping failed") + except RuntimeError: + pass + + assert tx.state == "rolled_back" + assert log == ["created file", "charged card", "refunded card", "removed file"] + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import Transaction + + log: list[str] = [] + + async def undo() -> None: + log.append("undone") + + async def main() -> None: + try: + async with Transaction() as tx: + tx.on_rollback(undo) # coroutine callbacks need `async with` + raise RuntimeError + except RuntimeError: + pass + assert log == ["undone"] + + asyncio.run(main()) + ``` + +`tx.state` is `"pending"`, `"committed"` or `"rolled_back"`. Outside a context manager you can call `tx.commit()` / `tx.rollback()`, or `await tx.acommit()` / `await tx.arollback()` when callbacks are coroutine functions. + +## `transactional` + +Wrap a function in a fresh `Transaction` per call. Returning normally commits; raising rolls back and re-raises. Receive the transaction as a keyword parameter named `tx` declared as `tx: Transaction = INJECTED`; `INJECTED` is a typed placeholder, so type checkers let callers omit `tx` (passing it yourself raises `TypeError`). Or fetch it anywhere in the call tree with `require_transaction()`, which raises `RuntimeError` outside a transaction, or `current_transaction()`, which returns `None` there. + +=== "Sync" + + ```python + from python_wrap.state import INJECTED, Transaction, transactional + + steps: list[str] = [] + + @transactional + def transfer(amount: int, *, tx: Transaction = INJECTED) -> str: + steps.append("debit") + tx.on_rollback(steps.append, "undo debit") + if amount > 100: + raise ValueError("limit") + tx.on_commit(steps.append, "notify") + return "ok" + + assert transfer(50) == "ok" + assert steps == ["debit", "notify"] + + steps.clear() + try: + transfer(500) + except ValueError: + pass + assert steps == ["debit", "undo debit"] + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap.state import INJECTED, Transaction, transactional + + steps: list[str] = [] + + @transactional + async def transfer(amount: int, *, tx: Transaction = INJECTED) -> str: + steps.append("debit") + tx.on_rollback(steps.append, "undo debit") + if amount > 100: + raise ValueError("limit") + return "ok" + + async def main() -> None: + try: + await transfer(500) + except ValueError: + pass + assert steps == ["debit", "undo debit"] + + asyncio.run(main()) + ``` + +!!! warning "Make `tx` keyword-only" + When the function has a parameter named `tx`, it is passed as the keyword argument `tx=`. Callers must not pass it themselves (a `TypeError` says so). Declaring it keyword-only, with the `INJECTED` default, keeps callers from colliding with it. + +### Without a `tx` parameter + +`require_transaction()` returns the innermost running transaction and raises `RuntimeError` outside one; `current_transaction()` returns `None` instead. It is backed by a `ContextVar`, so it is correct across threads and asyncio tasks, and lets helper functions register undo steps without the transaction being passed through every layer. + +```python +from python_wrap.state import current_transaction, require_transaction, transactional + +undone: list[str] = [] + +def create_resource(name: str) -> None: + tx = require_transaction() + tx.on_rollback(undone.append, f"delete {name}") + +@transactional +def provision() -> None: + create_resource("bucket") + create_resource("queue") + raise RuntimeError + +try: + provision() +except RuntimeError: + pass + +assert undone == ["delete queue", "delete bucket"] +assert current_transaction() is None +``` + +Nested `@transactional` calls each get their own transaction. diff --git a/docs/guides/validation.md b/docs/guides/validation.md new file mode 100644 index 0000000..653babe --- /dev/null +++ b/docs/guides/validation.md @@ -0,0 +1,219 @@ +# Validation + +Reject bad input at the door, with an error that names the parameter. + +| Decorator | Checks | Raises | +| --- | --- | --- | +| [`validate_args`](#validate_args) | Your predicate per argument | `ValidationError` (a `ValueError`) | +| [`check_type`](#check_type) | `isinstance` per argument | `ArgumentTypeError` (a `TypeError`) | +| [`enforce_types`](#enforce_types) | Arguments and return value against the function's own annotations | `ArgumentTypeError` (a `TypeError`) | + +All three work on plain functions, `async def` functions and methods, can be stacked, and keep the wrapped function's signature. A parameter name that does not exist raises `TypeError` **when you decorate**, so typos fail at import time rather than in production. + +## `validate_args` + +Give each parameter a predicate. A falsy result raises `ValidationError`. + +=== "Sync" + + ```python + from python_wrap import ValidationError, validate_args + + @validate_args( + quantity=lambda q: q > 0, + sku=(lambda s: s.startswith("SKU-"), "sku must start with 'SKU-'"), + ) + def order(sku: str, quantity: int) -> str: + return f"{quantity} x {sku}" + + assert order("SKU-1", 2) == "2 x SKU-1" + + try: + order("SKU-1", 0) + except ValidationError as exc: + assert exc.parameter == "quantity" and exc.value == 0 + assert isinstance(exc, ValueError) + + try: + order("bad", 1) + except ValidationError as exc: + assert "must start with" in str(exc) + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import ValidationError, validate_args + + @validate_args(n=lambda n: n > 0) + async def double(n: int) -> int: + return n * 2 + + async def main() -> None: + assert await double(2) == 4 + try: + await double(-1) + except ValidationError: + pass + else: + raise AssertionError("expected a ValidationError") + + asyncio.run(main()) + ``` + +Details: + +- Use a `(predicate, "message")` tuple for a custom message. +- Default values are applied first, so defaults are validated too. +- A validator may raise its own exception instead of returning falsy; it propagates unchanged. +- For `*args` / `**kwargs` parameters the validator receives the whole tuple / dict. + +## `check_type` + +`isinstance` checks, one per argument. The value can be a class or a tuple of classes. + +```python +from python_wrap import ArgumentTypeError, check_type + +@check_type(x=int, label=(str, bytes)) +def tag(x, label): + return f"{label}:{x}" + +assert tag(1, "a") == "a:1" + +try: + tag("1", "a") +except ArgumentTypeError as exc: + assert exc.parameter == "x" and exc.expected is int and exc.value == "1" + assert isinstance(exc, TypeError) +``` + +Only arguments that are actually passed are checked (defaults are not). For `*args` / `**kwargs` the type applies to every element / value. + +## `enforce_types` + +Check the arguments and the return value against the function's own annotations. No extra configuration: your type hints become runtime checks. + +=== "Sync" + + ```python + from python_wrap import ArgumentTypeError, enforce_types + + @enforce_types + def total(items: list[int], scale: float = 1.0) -> float: + return sum(items) * scale + + assert total([1, 2, 3]) == 6.0 + + try: + total([1, 2, "3"]) + except ArgumentTypeError as exc: + assert exc.parameter == "items" + assert "index 2" in str(exc) + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import ArgumentTypeError, enforce_types + + @enforce_types + async def name_length(name: str) -> int: + return len(name) + + async def main() -> None: + assert await name_length("ana") == 3 + try: + await name_length(42) # type: ignore[arg-type] + except ArgumentTypeError: + pass + else: + raise AssertionError("expected an ArgumentTypeError") + + asyncio.run(main()) + ``` + +The error message says what was expected and what was found, for example `parameter 'items' expected list[int], got list containing str at index 2`. For a bad return value `error.parameter` is `"return"`; turn that off with `@enforce_types(check_return=False)`. + +```python +from python_wrap import ArgumentTypeError, enforce_types + +@enforce_types +def broken() -> int: + return "not an int" # type: ignore[return-value] + +try: + broken() +except ArgumentTypeError as exc: + assert exc.parameter == "return" +``` + +Annotations are resolved lazily on the first call with `typing.get_type_hints`, so forward references work. For async functions the awaited result is checked. + +### Supported annotations + +| Annotation | How it is checked | +| --- | --- | +| Plain classes (`int`, `str`, your own) | `isinstance`. `int` also accepts `bool`; `float` accepts `int`; `complex` accepts `int` and `float` (PEP 484). | +| `Any`, `None` | Anything / `None` only. | +| `Optional[T]`, `Union[A, B]`, PEP 604 unions (the `X` bar `Y` syntax) | Any member matches. | +| `Literal[...]` | The value is one of the literals. | +| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]`, `tuple[A, B]`, `dict[K, V]` | Container type **and each element**. | +| `Sequence[T]`, `MutableSequence[T]`, `Set[T]`, `Mapping[K, V]` | ABC and each element. | +| `Iterable[T]`, `Iterator[T]`, `Generator` | `isinstance` only. Iterators are never consumed. | +| `Callable[...]` | `callable()` only. | +| `type[T]` | The value is a subclass of `T`. | +| `TypeVar` | Its bound or constraints; unbound means anything. | +| `Annotated[T, ...]` | Checks `T`. | +| `NewType` | Checks the supertype. | +| `runtime_checkable` protocols | `isinstance`. | +| `TypedDict`, non-runtime-checkable protocols, unresolved forward references, anything else | **Accepted without checking.** | + +A check that cannot be performed never raises: unsupported annotations are accepted rather than rejected. + +```python +from typing import Annotated, Literal, Optional +from python_wrap import ArgumentTypeError, enforce_types + +@enforce_types +def configure( + mode: Literal["fast", "safe"], + retries: Optional[int] = None, + tags: dict[str, list[int]] | None = None, + level: Annotated[int, "0 to 9"] = 0, +) -> None: ... + +configure("fast", tags={"a": [1, 2]}) + +for bad in ({"mode": "turbo"}, {"mode": "fast", "tags": {"a": ["x"]}}): + try: + configure(**bad) + except ArgumentTypeError: + pass + else: + raise AssertionError(f"{bad} should have been rejected") +``` + +!!! warning "A runtime cost" + Checking a `list[int]` inspects every element on every call. Use `enforce_types` at boundaries (public API, request handlers), not inside hot loops. + +## Choosing between them + +- Business rules (`amount > 0`, "not empty", "valid email"): `validate_args`. +- "This must be an `int` or `str`": `check_type` is the lightest. +- "My annotations should be true at runtime": `enforce_types`. + +They stack: `enforce_types` for shape, `validate_args` for rules. + +```python +from python_wrap import enforce_types, validate_args + +@enforce_types +@validate_args(amount=(lambda a: a > 0, "amount must be positive")) +def charge(account: str, amount: float) -> str: + return f"{account}: {amount}" + +assert charge("acme", 5) == "acme: 5" +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5fc0592 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,192 @@ +# python-wrap + +**The resilience and concurrency toolkit you add to every project.** Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhead, batching, async helpers, metrics and test helpers, in one typed, async-aware package with no dependencies. + +```python +from python_wrap import CircuitBreaker, memoize, policy, retry, retry_after_header, timeout + +http = policy( + cache=memoize(ttl=60), + breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30), + retry=retry(max_attempts=3, delay=0.5, backoff=2, on=ConnectionError, delay_from=retry_after_header), + timeout=timeout(5), +) + +@http +def load_profile(user_id: int) -> dict: + ... +``` + +`policy(...)` applies the protections in the [recommended order](stacking.md) whatever order you list them in. + +## Install + +=== "pip" + + ```bash + pip install python-wrap + ``` + +=== "uv" + + ```bash + uv add python-wrap + ``` + +Python 3.10 or newer. Linux, macOS and Windows. + +## A 30-second tour + +Every decorator works on plain functions, `async def` functions and methods, and keeps the wrapped function's signature for your type checker. + +=== "Sync" + + ```python + from python_wrap import retry, fallback, memoize + + attempts = [] + + @retry(max_attempts=3, delay=0.01) + def flaky() -> str: + attempts.append(1) + if len(attempts) < 3: + raise ConnectionError("try again") + return "ok" + + assert flaky() == "ok" and len(attempts) == 3 + + @fallback("unknown", on=KeyError) + def lookup(key: str) -> str: + return {"a": "alpha"}[key] + + assert lookup("a") == "alpha" + assert lookup("zzz") == "unknown" + + @memoize(maxsize=128, ttl=60) + def square(n: int) -> int: + return n * n + + square(4) + square(n=4) # same cache entry as square(4) + assert square.cache_info().hits == 1 + ``` + +=== "Async" + + ```python + import asyncio + from python_wrap import retry, fallback, memoize + + attempts = [] + + @retry(max_attempts=3, delay=0.01) + async def flaky() -> str: + attempts.append(1) + if len(attempts) < 3: + raise ConnectionError("try again") + return "ok" + + @fallback("unknown", on=KeyError) + async def lookup(key: str) -> str: + return {"a": "alpha"}[key] + + @memoize(ttl=60) + async def square(n: int) -> int: + return n * n + + async def main() -> None: + assert await flaky() == "ok" and len(attempts) == 3 + assert await lookup("zzz") == "unknown" + assert await asyncio.gather(square(4), square(4)) == [16, 16] + + asyncio.run(main()) + ``` + +More of the toolkit, in a few lines each: + +=== "Limits and async" + + ```python + import asyncio + from python_wrap import Bulkhead, gather_limit, rate_limit + + db = Bulkhead(2, max_waiting=10, name="db") # at most 2 concurrent calls + + @db + @rate_limit(100, 1.0, block=True) # at most 100 calls a second + async def fetch(n: int) -> int: + await asyncio.sleep(0) + return n + + async def main() -> None: + results = await gather_limit(*(fetch(i) for i in range(20)), limit=5) + assert results == list(range(20)) + + asyncio.run(main()) + ``` + +=== "Batching" + + ```python + import asyncio + from python_wrap import batch_calls + + calls: list[list[int]] = [] + + @batch_calls(max_size=100, max_wait=0.005) # many single lookups, one bulk call + async def get_user(ids: list[int]) -> dict[int, str]: + calls.append(ids) + return {i: f"user{i}" for i in ids} + + async def main() -> None: + assert await asyncio.gather(get_user(1), get_user(2)) == ["user1", "user2"] + assert calls == [[1, 2]] + + asyncio.run(main()) + ``` + +=== "Fast tests" + + ```python + from python_wrap import retry + from python_wrap.testing import fast_mode + + calls: list[int] = [] + + @retry(max_attempts=4, delay=30, on=ConnectionError) + def flaky() -> str: + calls.append(1) + if len(calls) < 4: + raise ConnectionError + return "ok" + + with fast_mode(): # waits are instant, retries still run + assert flaky() == "ok" + ``` + +Not sure which decorator you need? Start with [Choose a decorator](choosing.md). + +## Principles + +**Zero dependencies.** `dependencies = []` is a feature: python-wrap is safe to add to any project, including libraries. + +**Typed.** The package ships `py.typed`, passes `mypy --strict`, and preserves signatures with `ParamSpec`, so your editor still knows what the decorated function takes and returns. + +**Async is first class.** Async functions get `async def` wrappers (so `inspect.iscoroutinefunction` stays true), retries and rate limits wait with `asyncio` instead of blocking the loop, and caches de-duplicate concurrent awaits. See [Async support](async.md). + +**It never configures logging.** Importing python-wrap does not touch the root logger. Decorators log to the `python_wrap` logger (or the `logger=` you give them), which carries a `NullHandler`, so nothing is printed until your application configures logging. See [Observability](guides/observability.md#configuring-logging). + +**Observable and testable.** `count_calls` and one [event hook](guides/metrics.md) cover retries, caches, circuits and limiters, and [`python_wrap.testing`](testing.md) makes tests instant and deterministic. See [Performance](performance.md) for overhead numbers. + +**Failures are explicit.** The package raises its own exceptions (`TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, `BatchError`, `ValidationError`, `ArgumentTypeError`), each also a subclass of the closest built-in, so `except TimeoutError` and `except ValueError` keep working. + +**Honest about limits.** Some of these tools cannot do everything (a thread timeout cannot stop the work, for example). They are documented on one page: [Limitations](limitations.md). + +## Where to go next + +- [Choose a decorator](choosing.md): a table by problem. +- Guides: [Resilience](guides/resilience.md), [Caching](guides/caching.md), [Concurrency](guides/concurrency.md), [Async toolkit](guides/async-toolkit.md), [Batching and hedging](guides/batching.md), [Observability](guides/observability.md), [Metrics and events](guides/metrics.md), [Validation](guides/validation.md), [State safety](guides/state.md), [Policies](guides/policies.md), [Developer tools](guides/devtools.md), [Configuration](guides/configuration.md). +- [Cookbook](cookbook/index.md): complete recipes for HTTP clients, crawlers, FastAPI, Django, CLIs and tests. +- [Testing](testing.md) and [Performance](performance.md). +- [Stacking decorators](stacking.md): order matters. +- [Migrating from 0.1](migration.md): every breaking change, with before and after code. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 70809f8..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. PythonWrap documentation master file, created by - sphinx-quickstart on Tue Jan 21 20:45:34 2025. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to Python-Wrap's documentation! -======================================= - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - performance - error_handling - caching - debugging - type_safety - development_tools - concurrency - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..a52f7ea --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,78 @@ +# Known limitations + +python-wrap tries to do the obvious thing, and where it cannot, to say so. These are the places where a decorator does less than you might assume, and what to do instead. + +## `timeout` + +**The thread fallback cannot stop the work.** How a sync function is interrupted depends on where it runs: + +| Where | Mechanism | Stops the function? | +| --- | --- | --- | +| `async def` | `asyncio.wait_for` | Yes (the coroutine is cancelled) | +| Unix, main thread | `SIGALRM` timer | Yes (the call is interrupted) | +| Windows, non-main thread, or `use_signals=False` | Daemon worker thread; the caller stops waiting | **No** | + +With the thread fallback the caller gets `TimeoutExpired` at the deadline, but the function keeps running in the background until it returns on its own, and its result is discarded. Side effects (writes, requests) still happen. If you need real cancellation, make the function cooperative (check a flag, use the library's own timeout parameter such as `requests.get(timeout=...)`), or use `async def`. + +Related details: + +- Signal-based timeouts need the **main thread**. `use_signals=True` raises `RuntimeError` anywhere else. +- A C call that does not release the interpreter (a long regex, a blocking C extension) is interrupted only when control returns to Python. +- `TimeoutExpired` is an ordinary exception. A `retry` with the default `on=Exception` that wraps the function inside an outer `timeout` can catch it. See [Stacking](stacking.md#timeout-above-or-below-retry). + +## Shared state on methods + +`rate_limit`, `throttle` and `debounce` keep their state on the **decorated function**, not on the instance. When you decorate a method, every instance shares one limiter, one throttle window and one pending debounced call. + +```python +from python_wrap import throttle + +class Sensor: + def __init__(self, name: str) -> None: + self.name = name + + @throttle(60) + def read(self) -> str: + return self.name + +a, b = Sensor("a"), Sensor("b") +assert a.read() == "a" +assert b.read() == "a" # skipped: shares the window with `a`, returns its result +``` + +What to do: + +- `rate_limit` and `singleflight` accept `key=`; use it to split the state, for example `key=lambda self, *a, **kw: id(self)`. +- For `throttle` and `debounce`, create the decorated callable per instance in `__init__` (`self.read = throttle(60)(self._read)`). + +## `memoize` on methods + +`self` is part of the cache key, so results are per instance, but the cache holds a **strong reference** to every instance it has seen. Instances are therefore never garbage collected while their entries are cached. + +Mitigations: + +- Set `maxsize` and/or `ttl` so entries (and the instances they pin) are eventually dropped. +- Use `key=` that ignores `self` (for results that do not depend on the instance), so the instance is not stored. +- Call `cache_clear()` when a long-lived cache has served its purpose. + +Also note that for sync functions two threads that miss on the same key at the same moment may both compute the value (the function is never called while holding the lock); the last stored result wins. Put `singleflight` under `memoize` if the computation must not run twice. + +## `measure_memory` and `profile` + +- **`measure_memory`** uses `tracemalloc`, which is process-global. While a call runs, allocations made by other threads or asyncio tasks are counted too, and the peak counter is reset at the start of every measured call, so **concurrent measured calls share one tracker** and their numbers are not independent. Only memory allocated through Python's allocator is seen (C extensions that bypass it are invisible), and tracing slows the program. +- **`profile`** on an `async def` function enables the profiler for the whole awaited coroutine, so work the event loop does for **other tasks** while this one is suspended is included in the results. If another profiler is already active, python-wrap logs a warning once and runs the function unprofiled. + +## `revert_on_failure` + +It restores attribute values from a copy. In-place mutation of a container (`self.items.append(x)`) is undone, but the attribute ends up pointing at a **new** object, so other references to the old container still see the mutation. A shallow copy does not protect nested objects; use `deep=True`. Values that cannot be copied are restored by reference. It undoes attribute changes only, not external side effects, and `__slots__` classes need `attrs=`. + +## `transactional` + +If your function has a `tx` parameter, it is passed as the keyword `tx=`. Put `tx` **last or keyword-only** so callers cannot supply it positionally by accident. A rollback callback that raises is logged, and the remaining callbacks still run; a commit callback that raises propagates and skips the remaining commit callbacks. + +## Other things to know + +- **`debounce`** (sync) runs the function on a daemon timer thread, so it may be abandoned at interpreter exit with a call still pending. Call `.flush()` before shutting down if the last call matters. +- **`enforce_types`** accepts, rather than rejects, annotations it cannot check (`TypedDict`, non-runtime-checkable protocols, unresolved forward references). It adds per-call cost proportional to container size. See [Validation](guides/validation.md#supported-annotations). +- **`retry`** with `max_attempts=None` retries forever. Pair it with `max_delay` and an `on=` that names only transient errors. +- **Signal timers are process-wide.** `timeout` restores the previous `SIGALRM` handler and any armed timer afterwards, but other code that installs its own `SIGALRM` handler concurrently can conflict. diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 32bb245..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..75ec8d1 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,254 @@ +# Migrating from 0.1 to 0.2 + +Version 0.2 is a rewrite of the internals with a handful of deliberate breaking changes. Most v0.1 code keeps working: `from python_wrap.decorators import x` still resolves (a compatibility module re-exports everything), and the new exceptions subclass the closest built-ins. + +**Checklist** + +- [ ] Python 3.10 or newer ([Python 3.8 and 3.9 dropped](#python-38-and-39-are-dropped)). +- [ ] `run_in_thread` callers: `.join()` becomes `.result()`. +- [ ] `benchmark` is now a function, not a decorator. +- [ ] Replace `retry_on_exception` with `retry(on=...)`. +- [ ] Anything catching a bare `Exception` from `rate_limit`: catch `RateLimitExceeded`. +- [ ] Logging: configure it yourself, and expect `DEBUG` as the default level for `log_args`, `log_return` and `trace`. +- [ ] Remove `no_debug`. +- [ ] `validate_args` / `check_type` with a misspelled parameter name now fail at import time. + +Run your test suite with `-W error::DeprecationWarning` to find deprecated calls. + +## Breaking changes + +### `run_in_thread` returns a `Future` + +It used to return a `threading.Thread`, with no way to get the function's result. It now returns a `concurrent.futures.Future`. + +=== "0.1" + + + ```python + @run_in_thread + def work(): + ... + + thread = work() + thread.join() + ``` + +=== "0.2" + + ```python + from python_wrap import run_in_thread + + @run_in_thread + def work() -> int: + return 42 + + future = work() + assert future.result() == 42 # was thread.join(); also returns the value + ``` + +`future.result()` waits like `join()`, returns the value, and re-raises the function's exception. Use `future.done()` where you used `thread.is_alive()` (inverted). + +### `benchmark` is a function {#benchmark-is-a-function} + +The v0.1 decorator silently ran your function three times on every call. In 0.2 you call `benchmark` explicitly and get a `BenchmarkResult` back. Use `benchmark_async` for `async def` functions. + +=== "0.1" + + + ```python + @benchmark + def work(): + ... + + work() # ran three times, printed timings + ``` + +=== "0.2" + + ```python + from python_wrap import benchmark + + def work(n: int) -> int: + return sum(range(n)) + + result = benchmark(work, args=(1000,), runs=5, warmup=1) + assert result.runs == 5 + print(result) + ``` + +### `retry_on_exception` is deprecated and bounded + +`retry_on_exception` still works but emits a `DeprecationWarning`, and its default changed: it used to retry **forever with no delay**; it now stops after 3 attempts. Switch to `retry(on=...)`. + +=== "0.1" + + + ```python + @retry_on_exception(ConnectionError) + def fetch(): + ... + ``` + +=== "0.2" + + ```python + from python_wrap import retry + + @retry(on=ConnectionError, max_attempts=3, delay=0.5, backoff=2) + def fetch() -> None: + ... + ``` + +If you relied on unlimited retries, say so explicitly with `max_attempts=None`, and add a `delay` so you do not spin: + +```python +from python_wrap import retry + +@retry(on=ConnectionError, max_attempts=None, delay=1.0, max_delay=30, backoff=2) +def wait_for_service() -> None: + ... +``` + +Note that `retry` defaults to `delay=1.0` while the deprecated `retry_on_exception` defaults to `delay=0.0`. + +### `rate_limit` raises `RateLimitExceeded` + +It used to raise a bare `Exception`. `RateLimitExceeded` is a `PythonWrapError` (so it is still an `Exception`) and carries `retry_after`. + +=== "0.1" + + + ```python + try: + send() + except Exception: + ... # could be anything + ``` + +=== "0.2" + + ```python + from python_wrap import RateLimitExceeded, rate_limit + + @rate_limit(1, 60) + def send() -> None: ... + + send() + try: + send() + except RateLimitExceeded as exc: + wait = exc.retry_after + ``` + +New in 0.2: `rate_limit(block=True)` waits instead of raising, and `key=` gives each user their own limit. + +### Logging defaults changed + +Two changes you may notice as "my logs disappeared": + +1. **Importing python-wrap no longer calls `logging.basicConfig`.** v0.1 configured the root logger as a side effect. 0.2 never configures logging; the `python_wrap` logger has a `NullHandler`. Configure logging in your application. +2. **`log_args`, `log_return` and `trace` log at `DEBUG`** (`log_args` used to use `INFO`). Set the level, or pass `level=logging.INFO` to restore the old output. + +=== "0.1" + + + ```python + # Logged to the root logger at INFO with no setup of your own. + @log_args + def work(x): ... + ``` + +=== "0.2" + + ```python + import logging + from python_wrap import log_args + + logging.basicConfig(level=logging.DEBUG) # you configure logging + + @log_args(level=logging.INFO) # or keep the old level + def work(x: int) -> None: ... + + work(1) + ``` + +Every logging decorator also accepts `logger=` (a logger or a name) and `level=`. See [Observability](guides/observability.md#configuring-logging). + +### `no_debug` is deprecated + +`no_debug` is now a no-op that warns and will be removed in 0.3. Delete it, and use logging levels to hide debug output. + +### Python 3.8 and 3.9 are dropped + +python-wrap 0.2 requires Python 3.10 or newer. Stay on 0.1 if you cannot upgrade. The two runtime dependencies v0.1 declared but did not use (`wrapt`, `typing-extensions`) are gone: 0.2 has no dependencies. + +### Unknown parameter names fail at decoration time + +`validate_args` and `check_type` now raise `TypeError` when you decorate if a keyword is not a parameter of the function, so a typo is found at import rather than silently never validating. + +=== "0.1" + + + ```python + @validate_args(amout=lambda a: a > 0) # typo: silently validated nothing + def charge(amount): ... + ``` + +=== "0.2" + + ```python + from python_wrap import validate_args + + try: + @validate_args(amout=lambda a: a > 0) + def charge(amount: int) -> None: ... + except TypeError as exc: + print(exc) # names the unknown parameter + ``` + +## Behaviour details to know + +These are not breaking for most code, but they can surprise you if you read the early 0.2 notes or wrote your own wrappers around python-wrap. + +- **`timeout(...)` returns a `Timeout` object.** It is still a decorator (`@timeout(5)` and `@timeout(5)` on `async def` are unchanged), and is now also a context manager: `with timeout(5):` and `async with timeout(5):`. +- **`retry` never retries cancellation-type exceptions.** `asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` propagate immediately, even if you list them (or `BaseException`) in `on=`. Shutdown and task cancellation are no longer swallowed by a retry loop. +- **`redact=` on `log_args` and `audit` defaults to `None`.** `None` means "use the value from `configure()`, or `DEFAULT_REDACT`". Passing `redact=()` still turns redaction off, and `redact_extra=` adds fragments to whichever list is in force. +- **`audit` may skip building a record.** With no `sink=` and the log level disabled, nobody could see the `AuditRecord`, so none is built (no work, no `include_result` evaluation). Pass a `sink=` if you always need the record. + +## Behaviour that was broken and is now fixed + +These were bugs in v0.1. Code that worked around them may need to change. + +| Area | v0.1 | 0.2 | +| --- | --- | --- | +| `revert_on_failure` | Claimed to revert, but its snapshot was always empty. | Actually restores `vars(obj)`, or `attrs=`; `deep=` and `target=` available. | +| `transactional` | A no-op. | A real `Transaction` with `on_commit` / `on_rollback`, `tx=` injection and `current_transaction()`. | +| `memoize` / `cache` keys | Built from `str(args)`, so `f(1)` and `f(x=1)` were different entries. | Signature-bound, defaults applied, unhashable lists/dicts/sets handled. `cache()` no longer shares one store across functions. | +| `timing` on `async def` | Measured about 0 seconds. | Times the whole awaited coroutine; uses `perf_counter`. | +| `measure_memory` | `ru_maxrss`: Unix-only, wrong units. | `tracemalloc`; works everywhere. | +| `timeout` | Thread fallback undocumented. | `SIGALRM` interruption on Unix main thread, thread fallback documented, `asyncio.wait_for` for async; raises `TimeoutExpired` (a `TimeoutError`). | +| `deprecate` | The warning pointed into the library. | Points at the caller; sets `__deprecated__` (PEP 702); works on classes. | +| `audit` | Reported the wrapper's frame as the caller. | Reports the real caller; returns an `AuditRecord`. | +| `once` | Not thread-safe. | Thread-safe; retries after an exception; `.reset()` and `.called`. | +| `validate_args` | Rebuilt the signature on every call. | Binds once at decoration. | +| Windows | Import failed (imported Unix-only `resource`). | Works. | + +If you depended on `revert_on_failure` or `transactional` doing nothing, check those call sites: they now do what they say. + +## New in 0.2 + +You do not need these to migrate, but they replace common hand-written code: + +- `retry`: `backoff`, `max_delay`, `jitter`, `on=`, `retry_if=`, `on_retry=`, unlimited attempts. +- `fallback`, `suppress`, `CircuitBreaker` / `circuit_breaker`. +- `memoize(maxsize=, ttl=, typed=, key=)` with `cache_info`, `cache_clear`, `cache_delete`. +- `throttle`, `debounce`, `singleflight`. +- `enforce_types` for runtime checks from annotations. +- `compose`, `mock_data(enabled=...)`, `deprecate(since=, removed_in=, alternative=)`. +- `retry(delay_from=retry_after_header, max_time=)`, `retrying(...)` blocks, `deadline()` and `with timeout(...)`. +- `Bulkhead`, `RateLimiter`, `gather_limit` / `map_async` / `race`, `batch_calls` and `hedge`. +- `cached_property(ttl=)`, `count_calls`, the `python_wrap.events` hook and `log_context`. +- `policy(...)` and `configure(...)` for reusable stacks and project-wide defaults. +- `python_wrap.testing`: `fast_mode`, `FakeClock`, `spy` and pytest fixtures. + +See the [changelog](changelog.md) for the full list. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..aa2c593 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,142 @@ +# Performance + +What does a decorator cost on every call? For most real code the answer is "nothing you +will notice": a network call, a database query or a disk read costs microseconds to +milliseconds, and the decorators below add well under a few microseconds each. They matter +only when you wrap something that itself takes a microsecond or less, such as a pure +function in a tight loop. This page shows the numbers so you can judge for yourself. + +## Method + +`benchmarks/run.py` (standard library only) wraps a trivial function, `add(a, b)`, with +each decorator and calls it in a loop. Sync decorators run in a plain `for` loop. Async +decorators run `await f(1, 2)` N times inside one event loop. The reported figure is +the best of several repeats, in nanoseconds per call, loop overhead included. The bare +function goes through the identical loop, so the `x bare` columns are slowdown factors. + +Each decorator is measured on its cheap, steady-state path: + +* `timing`, `log_args`, `audit`, `retry` with a logger whose level is disabled (nothing is + emitted) and a call that succeeds first time. +* `memoize`, `cache`, `once` on a cache hit. +* `rate_limit` far below its limit, `circuit_breaker` closed, `throttle` inside its window. +* `deprecate` with the warning filtered out. + +Absolute numbers depend on the machine. Compare rows with each other, not with your +laptop. + +## Results + +Measured on an Apple M2 Pro (10 cores, 16 GB), macOS, CPython 3.14.7, full run +(`python benchmarks/run.py`, a `--quick` run agrees within about 5 percent). + +| Decorator | sync ns/call | async ns/call | sync x bare | async x bare | +| --- | ---: | ---: | ---: | ---: | +| bare call | 20 | 55 | 1.0x | 1.0x | +| timing (logging off) | 507 | 586 | 25.2x | 10.6x | +| retry (success path) | 428 | 516 | 21.3x | 9.3x | +| memoize (hit) | 221 | 452 | 11.0x | 8.2x | +| memoize(maxsize=128) (hit) | 253 | 480 | 12.6x | 8.7x | +| functools.lru_cache(128) (hit) | 42 | - | 2.1x | - | +| functools.cache (hit) | 40 | - | 2.0x | - | +| once (hit) | 66 | 147 | 3.3x | 2.7x | +| cache(60) (hit) | 331 | 498 | 16.4x | 9.0x | +| rate_limit (under limit) | 646 | 759 | 32.1x | 13.7x | +| throttle (skipped) | 259 | 302 | 12.9x | 5.5x | +| singleflight (uncontended) | 1,427 | 1,646 | 70.9x | 29.7x | +| circuit_breaker (closed) | 417 | 540 | 20.7x | 9.8x | +| timeout (thread fallback) | 8,689 | - | 432.0x | - | +| timeout (signal) | 2,588 | - | 128.7x | - | +| timeout (async) | - | 1,442 | - | 26.0x | +| log_args (level off) | 182 | 260 | 9.0x | 4.7x | +| audit (logging off) | 198 | 278 | 9.8x | 5.0x | +| validate_args | 234 | 312 | 11.6x | 5.6x | +| check_type | 207 | 291 | 10.3x | 5.3x | +| enforce_types | 308 | 393 | 15.3x | 7.1x | +| deprecate (warning ignored) | 376 | 473 | 18.7x | 8.5x | +| mock_data (disabled) | 94 | 176 | 4.7x | 3.2x | +| mock_data (enabled) | 82 | 127 | 4.1x | 2.3x | +| limit_concurrency(8) | 777 | 1,434 | 38.6x | 25.9x | +| Bulkhead(8) | 787 | 1,457 | 39.1x | 26.3x | +| batch_calls (max_size=1) | - | 2,678 | - | 48.4x | +| retry+timeout+circuit_breaker | 10,914 | 2,520 | 542.6x | 45.5x | + +The "x bare" factors look dramatic because the bare function is nearly free. Read the +nanosecond columns: 2,000 ns is two microseconds, a thousandth of a typical 2 ms +HTTP request. + +## Interpretation + +**Cheap (under about 300 ns).** `once`, `throttle`, `log_args` with its level disabled, +`audit` with logging off, `mock_data`. These do a few attribute reads and a +single lock or flag check. `log_args` asks the logger whether its level is enabled and skips +the argument formatting when it is not. `audit` returns before it builds its record when +the logger is disabled and nothing else is listening. + +**Moderate (300 ns to 1.5 microseconds).** `retry` on the success path, `rate_limit`, +`deprecate`, `circuit_breaker`, `limit_concurrency` / `Bulkhead`, `timing`, async `timeout`, +`singleflight`. The cost is a lock +acquire/release plus bookkeeping (a deque of timestamps, a state machine, a semaphore) and +the monotonic clock read. Async variants of lock-based decorators are not always cheaper: +`limit_concurrency` pays for an `asyncio` semaphore round trip. + +**Signature binding (about 200 to 350 ns).** `memoize` / `cache`, `validate_args`, +`check_type` and `enforce_types` need to know which argument is which, so that +`f(1)`, `f(a=1)` and `f(1, b=)` agree. They call `inspect.Signature.bind` and +`apply_defaults` on every call. That is why a `memoize` hit costs about 220 ns while +`functools.lru_cache`, which hashes the raw `args` and `kwargs`, costs 40 ns. `enforce_types` +adds a recursive `isinstance` walk per argument and the return value on top. + +**Expensive.** + +* `timeout` on a sync function without signals starts a daemon worker thread for every + call, about 9 microseconds. The signal path (Unix, main thread) is about 2.6 microseconds, + from several `signal` and `setitimer` system calls. Use `timeout` around work measured + in milliseconds, not in a hot loop. A stack like `retry + timeout + circuit_breaker` on + sync code inherits the thread cost (about 11 microseconds in the table). +* `audit` with logging enabled builds an `AuditRecord` for every call (timestamp, caller + location from a frame walk, bound and redacted arguments). That costs far more than the + about 200 ns it takes with logging off. Use it on boundaries (admin actions, API handlers), + not helpers. +* `singleflight` and `batch_calls` on `async def` create tasks and futures so that callers + can share one execution. The uncontended case costs about 1.6 microseconds for + `singleflight` and 2.7 microseconds for `batch_calls` (`max_size=1`). They exist to save + far more than that by avoiding duplicate work. + +## Tips + +* **Do not worry about decorators on I/O-bound code.** Optimise only if a profile shows + the wrapper in the hot path. +* **Disable what you do not read.** `log_args` skips its formatting when the logger's level + is disabled, so raising the package logger above its `level` makes it nearly free: + `logging.getLogger("python_wrap").setLevel(logging.WARNING)`. `timing` still reads the + clock twice and builds its record (about 0.5 microseconds) either way. +* **Prefer `maxsize`.** Bounded `memoize(maxsize=...)` has about the same hit cost as unbounded + and keeps memory flat. +* **Keep cache keys simple.** Hashable scalars freeze fastest; lists, dicts and sets + arguments are converted to hashable form on every call. For a hot function with expensive + arguments, pass a `key=` function. +* **Call style does not matter.** `f(1, 2)`, `f(a=1, b=2)` and mixed calls bind at the same + cost. +* **Place cheap, selective decorators on the outside.** A cache hit should short-circuit + before `retry`, `timeout` or logging decorators run: put `memoize` outermost. +* **Pure CPU functions in tight loops:** `functools.lru_cache` is faster than `memoize` + (about 40 ns against 220 ns). `memoize` buys argument normalisation, TTL, + `typed`, async support and thread-safe stats. +* **Test suites:** `python_wrap.testing.fast_mode()` removes sleeps; it does not change + the per-call costs above. + +## Reproduce + +From a checkout: + +```bash +python benchmarks/run.py # full run, about 15 seconds +python benchmarks/run.py --quick # at most 5 seconds, noisier +python benchmarks/run.py --filter memoize +python benchmarks/run.py --json > bench.json +``` + +Run it on an idle machine, with the power-saving mode off. For a stable comparison run it +twice and compare the better numbers. Which hot spots are worth optimising is +tracked in `benchmarks/FINDINGS.md` in the repository. diff --git a/docs/performance.rst b/docs/performance.rst deleted file mode 100644 index c78e881..0000000 --- a/docs/performance.rst +++ /dev/null @@ -1,113 +0,0 @@ -Performance & Profiling -======================= - -This section covers tools for measuring and optimizing the performance of your functions. - -@timing -------- -Measure the execution time of a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @timing - def my_function(): - # Code to measure - pass - -**Usage as a Function:** - -.. code-block:: python - - def my_function(): - # Code to measure - pass - - timed_function = timing(my_function) - timed_function() # Logs execution time - -@profile --------- -Profile the performance of a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @profile - def profile_function(): - # Function code to profile - pass - -**Usage as a Function:** - -.. code-block:: python - - def profile_function(): - # Function code to profile - pass - - profiled_function = profile(profile_function) - profiled_function() # Generates a performance profile - -@benchmark ----------- -Run a function multiple times and calculate the average execution time. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. -- **iterations (int):** The number of times to run the function. Default is ``10``. - -**Usage as a Decorator:** - -.. code-block:: python - - @benchmark - def benchmark_function(): - # Function code to benchmark - pass - -**Usage as a Function:** - -.. code-block:: python - - def benchmark_function(): - # Function code to benchmark - pass - - benchmarked_function = benchmark(benchmark_function, iterations=5) - benchmarked_function() # Calculates average execution time - -@measure_memory ---------------- -Track the memory usage of a function. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @measure_memory - def memory_intensive_function(): - # Function code that uses a lot of memory - pass - -**Usage as a Function:** - -.. code-block:: python - - def memory_intensive_function(): - # Function code that uses a lot of memory - pass - - memory_measured_function = measure_memory(memory_intensive_function) - memory_measured_function() # Logs memory usage \ No newline at end of file diff --git a/docs/stacking.md b/docs/stacking.md new file mode 100644 index 0000000..f08a992 --- /dev/null +++ b/docs/stacking.md @@ -0,0 +1,171 @@ +# Stacking decorators + +Decorators apply bottom to top: the one nearest the `def` wraps the function first, and the one at the top is the **outermost** layer, the first to see a call and the last to see the result. + +```python +from python_wrap import retry, timeout + +@retry(max_attempts=3, delay=0.01) # outer: sees the final result of all attempts +@timeout(5) # inner: bounds each single attempt +def fetch() -> str: + return "ok" +``` + +Order changes behaviour, not just style. A good rule: **put a decorator outside whatever it should observe or protect as a whole, and inside whatever should happen once per attempt.** + +## Order matters: four examples + +### `timing` above or below `retry` + +`timing` above `retry` times the whole call, including every attempt and every wait. Below it, `timing` measures each attempt separately. + +```python +from python_wrap import TimingRecord, retry, timing + +outer: list[TimingRecord] = [] +inner: list[TimingRecord] = [] + +@timing(callback=outer.append) # one record for the whole call +@retry(max_attempts=3, delay=0.01, on=ValueError) +@timing(callback=inner.append) # one record per attempt +def flaky() -> None: + raise ValueError + +try: + flaky() +except ValueError: + pass + +assert len(outer) == 1 and outer[0].success is False +assert len(inner) == 3 +assert outer[0].elapsed >= 0.02 # includes the two 10 ms waits +``` + +### `timeout` above or below `retry` + +`retry` above `timeout`: each attempt gets its own deadline. `timeout` above `retry`: one deadline covers all attempts and the waits between them. + +=== "Per attempt (retry above)" + + ```python + import time + from python_wrap import TimeoutExpired, retry, timeout + + attempts = [] + + @retry(max_attempts=3, delay=0.001, on=TimeoutExpired) + @timeout(0.01) + def slow() -> None: + attempts.append(1) + time.sleep(1) + + try: + slow() + except TimeoutExpired: + pass + + assert len(attempts) == 3 # three separate 10 ms deadlines + ``` + +=== "Overall (timeout above)" + + ```python + import time + from python_wrap import TimeoutExpired, retry, timeout + + attempts = [] + + @timeout(0.03) + @retry(max_attempts=100, delay=0.01, on=ConnectionError) + def always_fails() -> None: + attempts.append(1) + raise ConnectionError + + try: + always_fails() + except TimeoutExpired: + pass + + assert 1 <= len(attempts) < 100 # cut off by the overall deadline + ``` + +!!! warning "Narrow `on=` when an outer `timeout` wraps a `retry`" + `TimeoutExpired` is an ordinary exception. A `retry` left at its default `on=Exception` can catch the timeout from an outer `timeout` and keep going, so the overall deadline is swallowed. Name the transient errors you want retried (`on=ConnectionError`) and the timeout passes through. + +### `circuit_breaker` above or below `retry` + +Above `retry`, the breaker counts one failure per call whose retries were all used up. Below it, every single attempt counts, so one unlucky call can trip the breaker, and `retry` then sees `CircuitOpenError`. + +```python +from python_wrap import CircuitOpenError, circuit_breaker, retry + +@circuit_breaker(failure_threshold=2, recovery_timeout=60, on=ConnectionError) +@retry(max_attempts=3, delay=0, on=ConnectionError) +def call() -> None: + raise ConnectionError + +for _ in range(2): + try: + call() + except ConnectionError: + pass + +assert call.breaker.failure_count == 2 # two calls, six attempts + +try: + call() +except CircuitOpenError: + pass +``` + +Usually you want the breaker **above** `retry`: "this dependency has failed N calls in a row, after retrying". + +### `rate_limit` above or below `retry` + +Above `retry`: one count per logical call. Below it: every attempt counts against the limit, which is right when the limit is the remote service's quota and wrong when it protects your own callers. + +## A recommended order + +Outermost first. Skip the layers you do not need. + +| # | Decorator | Why here | +| --- | --- | --- | +| 1 | `deprecate`, `mock_data` | Warn or short-circuit before anything else runs. | +| 2 | `audit`, `log_args`, `log_return`, `timing` | Observe the whole call, including retries and fallbacks. | +| 3 | `validate_args`, `check_type`, `enforce_types` | Reject bad input immediately; do not retry or cache it. | +| 4 | `fallback`, `suppress` | Catch whatever the resilience layers below could not fix. | +| 5 | `memoize`, `once` | A cache hit skips everything below it. Exceptions are not cached. | +| 6 | `singleflight` | Concurrent cache misses share one execution. | +| 7 | `rate_limit`, `throttle`, `debounce` | Cap calls per logical request. | +| 8 | `circuit_breaker` | Counts calls whose retries were exhausted. | +| 9 | `retry` | Repeats everything below it. | +| 10 | `timeout` | A deadline per attempt. | +| 11 | `revert_on_failure`, `transactional` | Each attempt starts from clean state and undoes itself. | +| 12 | `timing` (optional second one) | Per-attempt durations. | + +```python +from python_wrap import ( + circuit_breaker, enforce_types, fallback, memoize, retry, timeout, timing, +) + +@timing(threshold=1.0) +@enforce_types +@fallback({}, on=ConnectionError) +@memoize(ttl=30) +@circuit_breaker(failure_threshold=5, recovery_timeout=30, on=ConnectionError) +@retry(max_attempts=3, delay=0.2, backoff=2, on=ConnectionError) +@timeout(5) +def load_profile(user_id: int) -> dict: + ... +``` + +Put the same stack on many functions with [`compose`](guides/devtools.md#compose). + +## Rules of thumb + +- **`retry(on=...)` should name transient errors.** With the default `on=Exception` it also retries `ValidationError`, `CircuitOpenError` and `TimeoutExpired`, which rarely helps. +- **Observers go outside, guards go inside.** An `audit` or `timing` outside the retry loop records the outcome the caller experienced. +- **Caches go outside the things that are expensive to repeat.** `memoize` above `retry` caches the final success and skips the retry loop on a hit. +- **`singleflight` goes below `memoize`.** Cache first; only a miss needs de-duplicating. +- **Decorators that hold state belong to the function.** Stacking never merges state, but see [shared state on methods](limitations.md#shared-state-on-methods). +- **Async and sync stack the same way.** Every decorator preserves whether the function is `async def`, so the order rules are identical. See [Async support](async.md). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..6485ef0 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,406 @@ +# Testing code that uses these decorators + +Decorators that wait or remember make tests slow or order-dependent. python-wrap is built so you can fix both without touching production code. + +python-wrap ships its own testing helpers in `python_wrap.testing` (standard library only; pytest is optional). They cover the common jobs: skip the waiting, cap retries, switch off rate limits and caches, control time, observe calls and wait for background work. + +## Fast mode: instant waits, retries still run + +`fast_mode()` makes every wait inside python-wrap instant (`no_sleep`). Retries still run the configured number of times and limiters still count; nothing actually sleeps. Use it as a context manager or a decorator on sync and async tests: + +```python +import time +from python_wrap import retry +from python_wrap.testing import fast_mode + +calls = [] + +@retry(max_attempts=4, delay=30, backoff=2, on=ConnectionError) +def flaky() -> str: + calls.append(1) + if len(calls) < 4: + raise ConnectionError + return "ok" + +start = time.monotonic() +with fast_mode(): + assert flaky() == "ok" # would take 30 + 60 + 120 seconds +assert time.monotonic() - start < 5 +``` + +The other switches work the same way: + +| Helper | Effect | +| --- | --- | +| `fast_mode()` | `_core.sleep` / `_core.async_sleep` return immediately | +| `disable_retries()` | `retry` makes a single attempt | +| `disable_rate_limits()` | `rate_limit` / `throttle` let every call through | +| `disable_caching()` | `memoize` / `cache` always call the wrapped function | +| `overrides(no_sleep=..., max_attempts=..., ...)` | Any combination, in one scope | + +```python +import asyncio +from python_wrap import _core +from python_wrap.testing import disable_retries, fast_mode, overrides + +with overrides(no_sleep=True, max_attempts=3): + with disable_retries(): + assert _core.overrides.max_attempts == 1 + assert _core.overrides.max_attempts == 3 # restored on exit +assert _core.overrides.max_attempts is None + +@fast_mode() +async def test_something() -> None: + assert _core.overrides.no_sleep + +asyncio.run(test_something()) +``` + +!!! note "Nesting and threads" + The overrides are one process-wide state, not context variables, so worker threads started by your test see them. Each scope restores exactly the fields it changed, to the values they had on entry, so scopes nest. They must exit in reverse order of entry (`with` blocks and decorators always do). Overlapping scopes from different threads that touch the same field are not isolated from each other; enter them from the test, not from concurrent workers. + +## Fake clock + +`fake_clock()` replaces `_core.monotonic`, `_core.perf_counter`, `_core.sleep` and `_core.async_sleep`. Sleeping advances the clock and records the duration, so you can assert the exact backoff schedule. The async sleep yields to the event loop, so other tasks still run. + +```python +from python_wrap import retry +from python_wrap.testing import fake_clock + +calls = [] + +@retry(max_attempts=4, delay=1.0, backoff=2, on=ConnectionError) +def flaky() -> str: + calls.append(1) + if len(calls) < 4: + raise ConnectionError + return "ok" + +with fake_clock() as clock: + start = clock.now + assert flaky() == "ok" # returns at once; no real waiting + +assert clock.sleeps == [1.0, 2.0, 4.0] # the backoff schedule you configured +assert clock.now == start + 7.0 +``` + +`FakeClock()` starts at the real `time.monotonic()` of the moment it is created, rounded up to a whole second (so differences stay exact), so entries stamped before it was installed are in its past and still expire; pass `start=` for a fixed reading. It has `.now`, `.advance(seconds)`, `.sleeps` and `.total_slept`; pass your own instance with `fake_clock(clock)`. + +## What the overrides affect + +python-wrap separates two kinds of waiting: + +- **Backoff waits** go through `_core.sleep` / `_core.async_sleep`: `retry` delays and a blocking rate limiter (`block=True`) waiting for a slot. `fast_mode()` makes them instant; `fake_clock()` turns them into clock advances you can assert on. +- **Limits on how long to wait** use real timers: the deadline of `timeout` (signal timer, worker thread, `asyncio.timeout`), `Bulkhead(timeout=...)` / `limit_concurrency` waiting for a slot, `race(timeout=...)`, the `hedge` delay before a backup attempt, the `batch_calls` window and the `debounce` quiet period. Neither `fast_mode()` nor `fake_clock()` changes them, and sync and async code behave the same. Making them instant would turn "wait at most 5 s" into "fail at once" (or "hedge at once"). Test them with tiny real durations, or call `.flush()` / `.aflush()` on `debounce` and `batch_calls`. + +| Behaviour | `fast_mode()` | `fake_clock()` | +| --- | --- | --- | +| `retry` backoff | instant | advances the clock | +| `rate_limit(block=True)` / `RateLimiter(block=True)` waiting | the call goes through at once and is counted | advances the clock, then goes through | +| `rate_limit` / `throttle` non-blocking rejection | still rejects | windows follow the fake clock | +| TTLs, circuit breaker recovery, `deadline` remaining time | real time | follow the fake clock | +| `timeout`, `Bulkhead` / `race` timeouts, `hedge` delay, `batch_calls` / `debounce` windows | real time | real time | + +Bulkhead and other concurrency limits are not rate limits: `disable_rate_limits()` leaves them on. + +## pytest fixtures + +Enable the plugin in your top-level `conftest.py`: + + +```python +# conftest.py +pytest_plugins = ["python_wrap.testing"] +``` + +You get two fixtures: + +- `pw_fake_clock`: installs a `FakeClock` for the test and yields it. +- `pw_fast_mode`: runs the test under `fast_mode()`. + + +```python +def test_backoff(pw_fake_clock): + assert flaky() == "ok" + assert pw_fake_clock.sleeps == [1.0, 2.0, 4.0] + +def test_quick(pw_fast_mode): + ... +``` + +`python_wrap.testing` imports without pytest installed; the fixtures only exist when it is. + +## Spies + +`spy(func)` records every call without changing behaviour, for sync and async functions. `spy_on(obj, name)` patches a module, class or instance attribute for a block and restores it. + +```python +import math +from python_wrap.testing import spy, spy_on + +@spy +def add(a: int, b: int) -> int: + return a + b + +add(1, b=2) +assert add.call_count == 1 and add.called +add.assert_called_with(1, b=2) +call = add.calls[0] +assert (call.args, call.kwargs, call.result, call.exception) == ((1,), {"b": 2}, 3, None) +add.reset() +assert add.call_count == 0 + +with spy_on(math, "floor") as floor: + math.floor(2.5) +assert floor.call_count == 1 # and math.floor is the original again +``` + +Each `Call` holds `args`, `kwargs`, `result`, `exception` and `duration`. On a class, a spy records `self` as the first argument. + +## Waiting for background work + +`eventually` (async) and `eventually_sync` retry an assertion until it passes or `timeout` runs out, then raise the last `AssertionError` annotated with how long it waited. They use real time unless a fake clock is installed. + +```python +import asyncio +import threading +from python_wrap.testing import eventually, eventually_sync + +done = threading.Event() +threading.Timer(0.02, done.set).start() + +def finished() -> None: + assert done.is_set() + +eventually_sync(finished, timeout=2.0, interval=0.005) + +async def main() -> None: + state = {"ready": False} + asyncio.get_running_loop().call_later(0.02, state.update, {"ready": True}) + + async def check() -> None: # sync or async assertions both work + assert state["ready"] + + await eventually(check, timeout=2.0, interval=0.005) + +asyncio.run(main()) +``` + +!!! tip "Under `fast_mode()`" + `eventually` waits through `_core`, so inside `fast_mode()` its polling interval is zero and it spins until `timeout`. Leave it outside fast mode when you wait on real threads. + +## Patching the clock by hand + +Everything above is built on four module attributes of `python_wrap._core`, always read as attributes so they can be patched: + +| Attribute | Used for | +| --- | --- | +| `_core.monotonic()` | Rate-limit windows, throttle, cache TTL, circuit recovery, retry `elapsed` | +| `_core.perf_counter()` | `timing`, `benchmark`, `audit` durations | +| `_core.sleep(seconds)` | Waiting in sync `retry` and `rate_limit(block=True)` | +| `_core.async_sleep(seconds)` | The same, for `async def` functions | + +If you prefer `unittest.mock`, patch them directly: + +```python +from unittest import mock +from python_wrap import _core, retry + +sleeps: list[float] = [] +calls = [] + +@retry(max_attempts=4, delay=1.0, backoff=2, on=ConnectionError) +def flaky() -> str: + calls.append(1) + if len(calls) < 4: + raise ConnectionError + return "ok" + +with mock.patch.object(_core, "sleep", sleeps.append): + assert flaky() == "ok" + +assert sleeps == [1.0, 2.0, 4.0] +``` + +With it, you can test expiry and recovery without waiting: + +```python +from unittest import mock +from python_wrap import _core, memoize + +now = [1000.0] +calls = [] + +@memoize(ttl=60) +def load() -> int: + calls.append(1) + return len(calls) + +with mock.patch.object(_core, "monotonic", lambda: now[0]): + assert load() == 1 + now[0] += 59 + assert load() == 1 # still fresh + now[0] += 2 + assert load() == 2 # expired: recomputed +``` + +The same trick moves a circuit breaker from open to half-open: + +```python +from unittest import mock +from python_wrap import CircuitBreaker, CircuitOpenError, CircuitState, _core + +now = [0.0] +breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=30) + +@breaker +def call() -> None: + raise ConnectionError + +with mock.patch.object(_core, "monotonic", lambda: now[0]): + try: + call() + except ConnectionError: + pass + assert breaker.state is CircuitState.OPEN + try: + call() + except CircuitOpenError as exc: + assert exc.retry_after == 30 + + now[0] += 31 + assert breaker.state is CircuitState.HALF_OPEN +``` + +!!! tip "Async" + With `mock`, patch `_core.async_sleep` with an `async def` that records and returns (as `FakeClock.async_sleep` does) so `async def` retries do not wait either. + +## Reset state between tests + +State lives on the decorated function, so it outlives a single test. Reset it in a fixture. + +| State | Reset with | +| --- | --- | +| `memoize` / `cache` entries and statistics | `func.cache_clear()` (or `func.cache_delete(*args)` for one entry) | +| `once` result | `func.reset()` | +| Circuit breaker | `breaker.reset()` (or `func.breaker.reset()` for `@circuit_breaker`) | +| `debounce` pending call | `func.cancel()` | + +```python +from python_wrap import CircuitState, circuit_breaker, memoize, once + +@memoize +def lookup(n: int) -> int: + return n + +@once +def setup() -> object: + return object() + +@circuit_breaker(failure_threshold=1) +def call() -> None: + raise RuntimeError + +lookup(1); setup() +try: + call() +except RuntimeError: + pass +assert call.breaker.state is CircuitState.OPEN + +# What an autouse fixture would do after every test: +lookup.cache_clear() +setup.reset() +call.breaker.reset() + +assert lookup.cache_info().currsize == 0 +assert setup.called is False +assert call.breaker.state is CircuitState.CLOSED +``` + + +```python +# conftest.py +import pytest + +from myapp.api import call_api, lookup, setup_once + + +@pytest.fixture(autouse=True) +def reset_python_wrap_state(): + yield + lookup.cache_clear() + setup_once.reset() + call_api.breaker.reset() +``` + +`rate_limit`, `throttle` and `singleflight` have no reset method. Use the fake clock to move past the window, or build a fresh decorated function inside the test. + +## Disabling `mock_data` + +`mock_data(enabled=...)` is evaluated on **every call**, so a test only has to flip whatever it reads: + +```python +import os +from python_wrap import mock_data + +@mock_data({"id": 1}, enabled=lambda: os.environ.get("USE_MOCKS") == "1") +def fetch() -> dict: + return {"id": 2} + +os.environ["USE_MOCKS"] = "1" +assert fetch() == {"id": 1} + +os.environ["USE_MOCKS"] = "0" # tests of the real behaviour +assert fetch() == {"id": 2} +``` + +In pytest, use `monkeypatch.setenv("USE_MOCKS", "0")` so the change is undone afterwards. + +## Bypass a decorator + +Wrapped functions keep `__wrapped__` (they use `functools.wraps`), so you can call the undecorated function in a unit test: + +```python +from python_wrap import retry + +calls = [] + +@retry(max_attempts=3, delay=0) +def flaky() -> None: + calls.append(1) + raise ValueError + +try: + flaky.__wrapped__() # skips the retry loop +except ValueError: + pass +assert len(calls) == 1 +``` + +## Capturing logs + +python-wrap logs to the `python_wrap` logger (or your `logger=`), so pytest's `caplog` works as usual: + + +```python +import logging + +def test_retry_logs_a_warning(caplog): + caplog.set_level(logging.WARNING, logger="python_wrap") + ... + assert "retrying in" in caplog.text +``` + +## Silence deprecation warnings you expect + + +```python +import pytest + +def test_old_api_warns(): + with pytest.warns(DeprecationWarning, match="old_func"): + old_func() +``` + +`deprecate` points the warning at the caller's line, so `-W error::DeprecationWarning` shows which test line used the old API. diff --git a/docs/type_safety.rst b/docs/type_safety.rst deleted file mode 100644 index 75636fe..0000000 --- a/docs/type_safety.rst +++ /dev/null @@ -1,58 +0,0 @@ -Type Safety & Validation -======================== - -This section covers tools for runtime type checking and argument validation. - -@check_type ------------ -Enable runtime type checking for function arguments and return values. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @check_type - def add_numbers(a: int, b: int) -> int: - return a + b - -**Usage as a Function:** - -.. code-block:: python - - def add_numbers(a: int, b: int) -> int: - return a + b - - checked_function = check_type(add_numbers) - result = checked_function(10, 20) # Ensures types are checked at runtime - -@validate_args --------------- -Add custom validation logic for function arguments. - -**Parameters:** -- **func (Callable):** The function to be wrapped. Automatically passed when used as a decorator. - -**Usage as a Decorator:** - -.. code-block:: python - - @validate_args - def create_user(username: str, age: int): - if age < 18: - raise ValueError("Age must be 18 or above") - print(f"User {username} created") - -**Usage as a Function:** - -.. code-block:: python - - def create_user(username: str, age: int): - if age < 18: - raise ValueError("Age must be 18 or above") - print(f"User {username} created") - - validated_function = validate_args(create_user) - validated_function("JohnDoe", 25) # Validates arguments before execution \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d034c2d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,22 @@ +# Examples + +Runnable, standalone scripts that mirror the [cookbook](../docs/cookbook/index.md) recipes. They need only python-wrap (no third-party packages), print what they do, and finish in well under a second. Each one asserts its own results, so a clean exit means the behaviour described in the docstring really happened. + +```console +$ pip install python-wrap +$ python examples/resilient_http.py +``` + +| Script | Shows | Recipe | +| --- | --- | --- | +| [`resilient_http.py`](resilient_http.py) | Retry with backoff and jitter, circuit breaker per host, rate limit, overall timeout, against a fake transport | [Resilient HTTP client](../docs/cookbook/resilient-http-client.md) | +| [`async_crawler.py`](async_crawler.py) | `map_async`, a `Bulkhead` per host, `race` over mirrors, `run_sync` | [Async crawler](../docs/cookbook/async-crawler.md) | +| [`web_caching.py`](web_caching.py) | `memoize(ttl=)`, `singleflight` against thundering herds, per-user `rate_limit` (framework-free) | [FastAPI](../docs/cookbook/fastapi.md) | +| [`service_layer.py`](service_layer.py) | Cached service functions, `transactional` commit/rollback hooks, `deprecate` | [Django](../docs/cookbook/django.md) | +| [`cli_tools.py`](cli_tools.py) | `timing`, `profile`, `benchmark`, `run_sync`, `debounce`, `throttle` | [CLI tools](../docs/cookbook/cli-and-scripts.md) | +| [`dataloader.py`](dataloader.py) | `batch_calls` turning 20 queries into 1 | [DataLoader batching](../docs/cookbook/dataloader-batching.md) | +| [`audit_logging.py`](audit_logging.py) | `log_args` and `audit` with shared redaction, JSON log lines | [Observability](../docs/cookbook/observability.md) | + +Several scripts use `python_wrap.testing.fake_clock` so that backoff sleeps and cache expiry take no real time. That is a handy trick in your own scripts and tests too; see [Testing code that uses python-wrap](../docs/cookbook/testing-your-code.md). + +`tests/test_examples.py` runs every script in this directory in a subprocess, so the examples cannot rot. diff --git a/examples/async_crawler.py b/examples/async_crawler.py new file mode 100644 index 0000000..8e69410 --- /dev/null +++ b/examples/async_crawler.py @@ -0,0 +1,100 @@ +"""Bulk-fetch many URLs politely: a global cap, a bulkhead per host and mirror racing. + +``map_async`` keeps at most N fetches in flight overall, one ``Bulkhead`` per host keeps +any single site from being hammered, failures are collected instead of aborting the +batch, and ``race`` asks several mirrors at once and takes the first good answer. The +network is simulated with ``asyncio.sleep``. + +Run: python examples/async_crawler.py +""" + +from __future__ import annotations + +import asyncio +from collections import Counter +from urllib.parse import urlsplit + +from python_wrap.aio import map_async, race, run_sync +from python_wrap.bulkhead import Bulkhead + +# --- simulated network ------------------------------------------------------------- + +LATENCY = {"fast.example": 0.002, "slow.example": 0.01, "mirror-a.example": 0.3} +in_flight: Counter[str] = Counter() +peak: Counter[str] = Counter() + + +async def http_get(url: str) -> str: + host = urlsplit(url).netloc + in_flight[host] += 1 + peak[host] = max(peak[host], in_flight[host]) + try: + await asyncio.sleep(LATENCY.get(host, 0.005)) + if "broken" in url: + raise ConnectionError(f"cannot reach {url}") + return f"{url}" + finally: + in_flight[host] -= 1 + + +# --- the crawler ------------------------------------------------------------------- + +PER_HOST = 2 +_host_bulkheads: dict[str, Bulkhead] = {} + + +def bulkhead_for(host: str) -> Bulkhead: + if host not in _host_bulkheads: + _host_bulkheads[host] = Bulkhead(PER_HOST, timeout=5, name=host) + return _host_bulkheads[host] + + +async def fetch(url: str) -> str: + async with bulkhead_for(urlsplit(url).netloc): + return await http_get(url) + + +async def fetch_from_mirrors(path: str, mirrors: list[str]) -> str: + """First mirror to answer wins; the others are cancelled.""" + return await race(*(fetch(f"https://{m}{path}") for m in mirrors), timeout=2) + + +async def crawl( + urls: list[str], *, limit: int = 8 +) -> tuple[dict[str, str], dict[str, BaseException]]: + results = await map_async(fetch, urls, limit=limit, return_exceptions=True) + pages: dict[str, str] = {} + errors: dict[str, BaseException] = {} + for url, result in zip(urls, results, strict=True): + if isinstance(result, BaseException): + errors[url] = result + else: + pages[url] = result + return pages, errors + + +# --- demo --- + + +async def amain() -> None: + urls = [f"https://{host}/page/{i}" for i in range(10) for host in LATENCY] + urls.append("https://fast.example/broken") + + pages, errors = await crawl(urls) + print(f"fetched {len(pages)} pages, {len(errors)} failed") + assert len(pages) == 30 + assert list(errors) == ["https://fast.example/broken"] + assert all(count <= PER_HOST for count in peak.values()), peak + print("peak concurrency per host:", dict(peak)) + + body = await fetch_from_mirrors("/file", ["mirror-a.example", "fast.example"]) + assert body == "https://fast.example/file" + print("mirror race won by the fast mirror") + + +def main() -> None: + run_sync(amain()) # the entry point of a sync script + + +if __name__ == "__main__": + main() diff --git a/examples/audit_logging.py b/examples/audit_logging.py new file mode 100644 index 0000000..f697943 --- /dev/null +++ b/examples/audit_logging.py @@ -0,0 +1,86 @@ +"""Structured, redacted logs and an audit trail with ``log_args`` and ``audit``. + +``log_args`` logs every call's arguments with secrets masked; ``audit`` produces a +structured ``AuditRecord`` per call (who called, with what, how it ended). Both feed +ordinary ``logging``, here through a tiny JSON formatter, and ``audit`` can also write to +a sink of your choice. A library never configures logging for you, so this script does it +explicitly with a handler on its own logger. + +Run: python examples/audit_logging.py +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import io +import json +import logging + +from python_wrap import DEFAULT_REDACT, AuditRecord, audit, log_args + + +class JsonFormatter(logging.Formatter): + """One JSON object per line; includes the AuditRecord when python-wrap attached one.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, object] = { + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + audit_record = getattr(record, "audit", None) + if isinstance(audit_record, AuditRecord): + payload["audit"] = dataclasses.asdict(audit_record) + return json.dumps(payload, default=str) + + +stream = io.StringIO() +handler = logging.StreamHandler(stream) +handler.setFormatter(JsonFormatter()) +log = logging.getLogger("myapp") +log.addHandler(handler) +log.setLevel(logging.DEBUG) +log.propagate = False # keep this demo's output out of the root logger + +audit_trail: list[AuditRecord] = [] # stand-in for a database table or a log shipper + + +# `redact=` REPLACES the defaults, so extend DEFAULT_REDACT instead of overwriting it, and +# pass the same list to every decorator: each one masks on its own. +REDACT = (*DEFAULT_REDACT, "ssn") + + +@log_args(logger=log, redact=REDACT) +@audit(logger=log, sink=audit_trail.append, redact=REDACT) +def change_email(user_id: int, new_email: str, password: str, ssn: str = "") -> bool: + if "@" not in new_email: + raise ValueError("invalid email") + return True + + +# --- demo --- + + +def main() -> None: + change_email(7, "ana@example.com", password="hunter2", ssn="123-45-6789") + with contextlib.suppress(ValueError): + change_email(7, "not-an-email", password="hunter2") + + lines = [json.loads(line) for line in stream.getvalue().splitlines()] + text = stream.getvalue() + assert "hunter2" not in text and "123-45-6789" not in text # secrets never reach the log + print(lines[0]["message"]) + + ok, failed = audit_trail + assert ok.status == "success" and failed.status == "error" + assert ok.arguments["password"] == "***" + print(json.dumps({"function": ok.function, "status": ok.status, "args": ok.arguments})) + print(json.dumps({"function": failed.function, "status": failed.status, "error": failed.error})) + + audited = [line["audit"] for line in lines if "audit" in line] + assert [a["status"] for a in audited] == ["success", "error"] + + +if __name__ == "__main__": + main() diff --git a/examples/cli_tools.py b/examples/cli_tools.py new file mode 100644 index 0000000..d7229c5 --- /dev/null +++ b/examples/cli_tools.py @@ -0,0 +1,116 @@ +"""Decorators for command-line tools and scripts. + +Time a step and collect the numbers, profile a hot function, benchmark two +implementations, call async code from a sync ``main`` with ``run_sync``, rebuild once +after a burst of file-change events with ``debounce`` and keep a progress line from +flooding the terminal with ``throttle``. + +Run: python examples/cli_tools.py +""" + +from __future__ import annotations + +import asyncio +import pstats +import time + +from python_wrap import ( + BenchmarkResult, + TimingRecord, + benchmark, + debounce, + profile, + throttle, + timing, +) +from python_wrap.aio import run_sync + +# 1. Time a step. The callback receives a record for every call (feed a metrics system). +timings: list[TimingRecord] = [] + + +@timing(callback=timings.append, threshold=10.0) # only *log* calls slower than 10 s +def load_data() -> list[int]: + return list(range(50_000)) + + +# 2. Profile a function and keep the pstats object instead of printing. +profiles: list[pstats.Stats] = [] + + +@profile(sort="tottime", limit=5, callback=profiles.append) +def crunch(data: list[int]) -> int: + return sum(x * x for x in data) + + +# 3. Async code behind a sync CLI entry point. +async def fetch_remote_total() -> int: + await asyncio.sleep(0.001) + return 7 + + +# 4. File watcher: a save usually fires several events; rebuild once, after they stop. +builds: list[str] = [] + + +@debounce(0.2) +def rebuild(path: str) -> None: + builds.append(path) + + +# 5. Progress output at most once per interval; skipped calls return the last result. +printed: list[int] = [] + + +@throttle(0.05) +def show_progress(done: int) -> None: + printed.append(done) + + +# --- demo --- + + +def main() -> None: + data = load_data() + print(f"load_data took {timings[0].elapsed * 1000:.1f} ms") + assert timings[0].success and timings[0].name.endswith("load_data") + + assert crunch(data) == sum(x * x for x in data) + assert isinstance(profiles[0], pstats.Stats) + + # Compare two implementations with real statistics. + def slow() -> int: + return sum(list(range(2_000))) + + def fast() -> int: + return sum(range(2_000)) + + results: list[BenchmarkResult] = [benchmark(f, runs=20, warmup=3) for f in (slow, fast)] + for r in results: + print(f"{r.name}: median {r.median * 1e6:.1f} us over {r.runs} runs") + assert all(r.min <= r.median <= r.max for r in results) + + assert run_sync(fetch_remote_total) == 7 + print("run_sync returned 7") + + for _ in range(5): # five events from one save + rebuild("src/app.py") + assert builds == [] + deadline = time.monotonic() + 5 # the quiet period; a real watcher just keeps running + while not builds and time.monotonic() < deadline: + time.sleep(0.05) + assert builds == ["src/app.py"] + print("5 file events -> 1 rebuild") + + rebuild("final.py") + rebuild.flush() # at shutdown: run the pending call now instead of losing it + assert builds[-1] == "final.py" + + for done in range(100): + show_progress(done) + assert printed == [0] + print("100 progress updates -> printed", printed) + + +if __name__ == "__main__": + main() diff --git a/examples/dataloader.py b/examples/dataloader.py new file mode 100644 index 0000000..9d89193 --- /dev/null +++ b/examples/dataloader.py @@ -0,0 +1,60 @@ +"""Fix the N+1 query problem with ``batch_calls`` (the DataLoader pattern). + +Rendering 20 posts needs 20 authors. Written naively that is one query per post. With +``batch_calls`` every ``get_author(id)`` call made at about the same time is collected +into a single ``WHERE id IN (...)`` query, and callers still write simple per-item code. +The database is simulated with a list that records every query. + +Run: python examples/dataloader.py +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from python_wrap.batching import batch_calls + +QUERIES: list[str] = [] +AUTHORS = {i: {"id": i, "name": f"author{i}"} for i in range(1, 6)} +POSTS = [{"id": n, "author_id": n % 5 + 1, "title": f"post {n}"} for n in range(20)] + + +@batch_calls(max_size=100, max_wait=0.005) +async def get_author(ids: list[int]) -> dict[int, dict[str, Any]]: + """The one place that talks to the database. Receives distinct ids.""" + QUERIES.append(f"SELECT * FROM authors WHERE id IN ({', '.join(map(str, ids))})") + await asyncio.sleep(0.001) + return {i: AUTHORS[i] for i in ids if i in AUTHORS} # missing ids get a BatchError + + +async def render_post(post: dict[str, Any]) -> str: + author = await get_author(post["author_id"]) # looks like one query per post... + return f"{post['title']} by {author['name']}" + + +async def render_all(posts: list[dict[str, Any]]) -> list[str]: + return list(await asyncio.gather(*(render_post(p) for p in posts))) # ...but is 1 in total + + +# --- demo --- + + +async def amain() -> None: + lines = await render_all(POSTS) + assert lines[0] == "post 0 by author1" + print(len(lines), "posts rendered with", len(QUERIES), "query:") + print(" ", QUERIES[0]) + assert len(QUERIES) == 1 # 20 posts, 5 distinct authors, one query + + # A second wave goes out as a second batch. + await asyncio.gather(get_author(1), get_author(2)) + assert len(QUERIES) == 2 + + +def main() -> None: + asyncio.run(amain()) + + +if __name__ == "__main__": + main() diff --git a/examples/resilient_http.py b/examples/resilient_http.py new file mode 100644 index 0000000..9eb2bdf --- /dev/null +++ b/examples/resilient_http.py @@ -0,0 +1,142 @@ +"""A resilient HTTP client built from python-wrap decorators, runnable offline. + +Retries transient failures with exponential backoff and jitter, keeps one circuit +breaker per host, rate limits per host and puts an overall deadline on each call. The +network is a scripted fake transport, so nothing leaves your machine. To use it for +real, replace ``FakeTransport`` with a thin adapter around httpx or requests (see +docs/cookbook/resilient-http-client.md). + +Run: python examples/resilient_http.py +""" + +from __future__ import annotations + +import threading +from urllib.parse import urlsplit + +from python_wrap import CircuitBreaker, CircuitOpenError, rate_limit, retry, timeout +from python_wrap.testing import fake_clock + + +class Response: + def __init__(self, status: int, body: str = "") -> None: + self.status = status + self.body = body + + +class ServerError(Exception): + """A 5xx answer: worth retrying.""" + + +class ClientError(Exception): + """A 4xx answer: our fault, retrying cannot help.""" + + +TRANSIENT = (ConnectionError, TimeoutError, ServerError) + + +def host_of(url: str) -> str: + return urlsplit(url).netloc + + +class FakeTransport: + """Plays back a script per host: an int is a status code, an exception is raised.""" + + def __init__(self, script: dict[str, list[object]]) -> None: + self.script = script + self.calls: list[str] = [] + + def send(self, url: str, *, timeout: float) -> Response: + self.calls.append(url) + steps = self.script[host_of(url)] + step = steps.pop(0) if len(steps) > 1 else steps[0] # the last step repeats + if isinstance(step, BaseException): + raise step + assert isinstance(step, int) + return Response(step, f"body of {url}") + + +class ResilientClient: + def __init__(self, transport: FakeTransport) -> None: + self._transport = transport + self._breakers: dict[str, CircuitBreaker] = {} + self._lock = threading.Lock() + + def breaker_for(self, host: str) -> CircuitBreaker: + with self._lock: + if host not in self._breakers: + self._breakers[host] = CircuitBreaker( + failure_threshold=3, recovery_timeout=30, on=TRANSIENT, name=host + ) + return self._breakers[host] + + @timeout(30) # overall deadline, retries included + @retry(max_attempts=4, delay=0.2, backoff=2, max_delay=5, jitter=0.1, on=TRANSIENT) + @rate_limit(5, 1.0, block=True, key=lambda self, url: host_of(url)) + def get(self, url: str) -> Response: + # CircuitOpenError is not in TRANSIENT, so an open circuit is not retried. + return self.breaker_for(host_of(url)).call(self._send, url) + + def _send(self, url: str) -> Response: + response = self._transport.send(url, timeout=5.0) + if response.status >= 500: + raise ServerError(f"{response.status} from {url}") + if response.status >= 400: + raise ClientError(f"{response.status} from {url}") + return response + + +# --- demo --- + + +def main() -> None: + transport = FakeTransport( + { + "api.example.com": [503, ConnectionError("reset"), 200], + "down.example.com": [503], + "missing.example.com": [404], + } + ) + client = ResilientClient(transport) + + with fake_clock() as clock: # backoff sleeps are instant and recorded + # 1. Transient failures are retried with growing, jittered waits. + assert client.get("https://api.example.com/users").status == 200 + print("retried twice, waits:", [round(s, 2) for s in clock.sleeps]) + + # 2. A 404 is not transient: it is raised at once, not retried. + before = len(transport.calls) + try: + client.get("https://missing.example.com/x") + except ClientError: + assert len(transport.calls) == before + 1 + print("404 raised without retrying") + + # 3. A dead host opens its breaker; other hosts are unaffected. + try: + client.get("https://down.example.com/a") + except CircuitOpenError as exc: + print(f"circuit for down.example.com open, retry in {exc.retry_after:.0f}s") + before = len(transport.calls) + try: + client.get("https://down.example.com/b") + except CircuitOpenError: + assert len(transport.calls) == before # rejected without touching the network + assert client.get("https://api.example.com/users").status == 200 + + # 4. After the recovery timeout one trial call is allowed through. + transport.script["down.example.com"] = [200] + clock.advance(31) + assert client.get("https://down.example.com/c").status == 200 + print("circuit closed again after recovery") + + # 5. The rate limit waits (it does not fail) once a host's budget is used up. + clock.sleeps.clear() + for i in range(7): + client.get(f"https://api.example.com/page/{i}") + assert any(s > 0 for s in clock.sleeps) + print("rate limit waited:", [round(s, 2) for s in clock.sleeps]) + + +if __name__ == "__main__": + main() diff --git a/examples/service_layer.py b/examples/service_layer.py new file mode 100644 index 0000000..3ee901c --- /dev/null +++ b/examples/service_layer.py @@ -0,0 +1,80 @@ +"""A service layer with cached reads, commit hooks and a deprecation path. + +This is the framework-free shape of what a Django (or any ORM) service module needs: +cached read functions with explicit invalidation, side effects that must only happen +after the work succeeded (the job of ``transaction.on_commit``) and a renamed function +kept alive with a warning. ``Transaction`` here is python-wrap's in-process transaction: +it runs ``on_commit`` callbacks on success and ``on_rollback`` callbacks on failure. It is +not a database transaction; with Django you would still wrap the writes in +``transaction.atomic()`` (see docs/cookbook/django.md). + +Run: python examples/service_layer.py +""" + +from __future__ import annotations + +import contextlib +import warnings + +from python_wrap import current_transaction, deprecate, memoize, transactional + +DB: dict[int, str] = {1: "free", 2: "pro"} +OUTBOX: list[str] = [] # emails "sent" +QUERIES: list[int] = [] + + +# Cached read. cache_delete(user_id) is the explicit invalidation hook. +@memoize(ttl=300, maxsize=10_000) +def get_plan(user_id: int) -> str: + QUERIES.append(user_id) + return DB[user_id] + + +@deprecate(since="2.0", removed_in="3.0", alternative="get_plan") +def fetch_plan(user_id: int) -> str: + return get_plan(user_id) + + +@transactional +def upgrade(user_id: int, plan: str, *, fail: bool = False) -> None: + tx = current_transaction() # typed alternative to a `tx` parameter + assert tx is not None + previous = DB[user_id] + DB[user_id] = plan + tx.on_rollback(DB.__setitem__, user_id, previous) # undo the write if anything fails + if fail: + raise RuntimeError("payment declined") + # Runs only if the whole function succeeds, like transaction.on_commit(). + tx.on_commit(get_plan.cache_delete, user_id) + tx.on_commit(OUTBOX.append, f"user {user_id} is now on {plan}") + + +# --- demo --- + + +def main() -> None: + assert get_plan(1) == "free" + assert get_plan(1) == "free" + assert QUERIES == [1] # the second read came from the cache + + with contextlib.suppress(RuntimeError): + upgrade(1, "pro", fail=True) + assert DB[1] == "free" and OUTBOX == [] # rolled back, nobody was emailed + assert get_plan(1) == "free" + print("failed upgrade: database restored, no email, cache untouched") + + upgrade(1, "pro") + assert OUTBOX == ["user 1 is now on pro"] + assert get_plan(1) == "pro" # the commit hook invalidated the stale entry + assert QUERIES == [1, 1] + print("successful upgrade:", OUTBOX[0]) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert fetch_plan(2) == "pro" + assert caught[0].category is DeprecationWarning + print("deprecation:", caught[0].message) + + +if __name__ == "__main__": + main() diff --git a/examples/web_caching.py b/examples/web_caching.py new file mode 100644 index 0000000..b58ff25 --- /dev/null +++ b/examples/web_caching.py @@ -0,0 +1,94 @@ +"""The caching and rate limiting a web endpoint needs, with no web framework. + +Three decorators protect a slow backend: ``memoize(ttl=...)`` caches an expensive lookup, +``singleflight`` stops a crowd of simultaneous cache misses from all hitting the database +(the thundering herd), and ``rate_limit(key=...)`` gives every user their own request +budget. The handlers below are plain functions; docs/cookbook/fastapi.md shows how to +mount the same functions in FastAPI. + +Run: python examples/web_caching.py +""" + +from __future__ import annotations + +import asyncio +import time +from concurrent.futures import ThreadPoolExecutor + +from python_wrap import RateLimitExceeded, memoize, rate_limit, singleflight +from python_wrap.testing import fake_clock + +DB_QUERIES: list[int] = [] + + +# Order matters: memoize outside (check the cache first), singleflight inside (only one +# miss per key goes to the database at a time). +@memoize(ttl=60, maxsize=1000) +@singleflight +def load_profile(user_id: int) -> dict[str, object]: + DB_QUERIES.append(user_id) + time.sleep(0.03) # a slow query + return {"id": user_id, "name": f"user{user_id}"} + + +# Async code: memoize already shares one in-flight task between concurrent callers. +@memoize(ttl=60) +async def load_feed(user_id: int) -> list[str]: + DB_QUERIES.append(-user_id) + await asyncio.sleep(0.02) + return [f"post {n} for {user_id}" for n in range(3)] + + +@rate_limit(3, 60, key=lambda user, *args, **kwargs: user) # 3 searches/minute/user +def search(user: str, query: str) -> list[str]: + return [f"{query} result {n}" for n in range(2)] + + +def search_endpoint(user: str, query: str) -> tuple[int, dict[str, object]]: + """What an HTTP layer does with the exceptions: map them to status codes.""" + try: + return 200, {"results": search(user, query)} + except RateLimitExceeded as exc: + return 429, {"error": "slow down", "retry_after": round(exc.retry_after)} + + +# --- demo --- + + +async def amain() -> None: + feeds = await asyncio.gather(*(load_feed(7) for _ in range(50))) + assert all(feed == feeds[0] for feed in feeds) + assert DB_QUERIES.count(-7) == 1 # 50 concurrent requests, one query + print("async: 50 concurrent feed requests ->", DB_QUERIES.count(-7), "query") + + +def main() -> None: + # Install the fake clock before the first call: cache entries remember the clock + # that was running when they were stored. + with fake_clock() as clock: + # Thundering herd: 20 threads miss the cache at the same instant. + with ThreadPoolExecutor(20) as pool: + profiles = list(pool.map(load_profile, [42] * 20)) + assert all(p["id"] == 42 for p in profiles) + assert DB_QUERIES.count(42) == 1 + print("threads: 20 concurrent profile requests ->", DB_QUERIES.count(42), "query") + + # Later calls are served from the cache until the TTL passes. + load_profile(42) + assert DB_QUERIES.count(42) == 1 + clock.advance(61) + load_profile(42) + assert DB_QUERIES.count(42) == 2 + print("cache info:", load_profile.cache_info()) + + asyncio.run(amain()) + + # Per-user limits: ana runs out, ben is unaffected. + statuses = [search_endpoint("ana", "cats")[0] for _ in range(4)] + assert statuses == [200, 200, 200, 429] + assert search_endpoint("ben", "dogs")[0] == 200 + print("ana's statuses:", statuses, "| ben: 200") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..8e2d4fd --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,146 @@ +site_name: python-wrap +site_description: Everyday Python decorators that just work. Retry, cache, time out, rate limit and more. Typed, async-aware, zero dependencies. +site_author: Mahmoud Ahmed +site_url: https://python-wrap.readthedocs.io/ +repo_url: https://github.com/LitePy/PythonWrap +repo_name: LitePy/PythonWrap +edit_uri: edit/main/docs/ +copyright: Copyright © 2026 Mahmoud Ahmed · MIT licence + +strict: true +validation: + links: + anchors: warn + +theme: + name: material + icon: + repo: fontawesome/brands/github + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-night + name: Follow the system + features: + - navigation.tabs + - navigation.sections + - navigation.top + - navigation.footer + - navigation.indexes + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.tabs.link + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/LitePy/PythonWrap + - icon: fontawesome/brands/python + link: https://pypi.org/project/python-wrap/ + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + show_signature_annotations: true + separate_signature: true + members_order: source + show_root_heading: true + show_root_full_path: false + show_source: false + heading_level: 2 + merge_init_into_class: true + +markdown_extensions: + - admonition + - attr_list + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets: + base_path: [.] + check_paths: true + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + +nav: + - Home: index.md + - Choose a decorator: choosing.md + - Guides: + - Resilience: guides/resilience.md + - Caching: guides/caching.md + - Concurrency and rate limits: guides/concurrency.md + - Async toolkit and bulkheads: guides/async-toolkit.md + - Batching and hedging: guides/batching.md + - Observability: guides/observability.md + - Metrics and events: guides/metrics.md + - Validation: guides/validation.md + - State safety: guides/state.md + - Policies: guides/policies.md + - Developer tools: guides/devtools.md + - Project-wide configuration: guides/configuration.md + - Cookbook: + - cookbook/index.md + - Resilient HTTP client: cookbook/resilient-http-client.md + - Async crawler and bulk fetch: cookbook/async-crawler.md + - FastAPI: cookbook/fastapi.md + - Django: cookbook/django.md + - CLI tools and scripts: cookbook/cli-and-scripts.md + - DataLoader-style batching: cookbook/dataloader-batching.md + - Testing your code: cookbook/testing-your-code.md + - Observability: cookbook/observability.md + - Concepts: + - Async support: async.md + - Stacking decorators: stacking.md + - Testing: testing.md + - Limitations: limitations.md + - Performance: performance.md + - Migrating to 0.2: migration.md + - API reference: + - api/index.md + - Errors: api/errors.md + - Retry and fallback: api/retry.md + - Circuit breaker: api/circuit.md + - Deadline: api/deadline.md + - Configuration: api/config.md + - Caching: api/caching.md + - Concurrency: api/concurrency.md + - Async helpers: api/aio.md + - Bulkhead: api/bulkhead.md + - Batching: api/batching.md + - Hedging: api/hedging.md + - Timing and profiling: api/timing.md + - Logging and audit: api/logs.md + - Metrics: api/metrics.md + - Events: api/events.md + - Validation: api/validation.md + - State: api/state.md + - Policies: api/policy.md + - Developer tools: api/devtools.md + - Testing helpers: api/testing.md + - Changelog: changelog.md diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index b553044..0000000 --- a/poetry.lock +++ /dev/null @@ -1,324 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.6.1" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, - {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, - {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, - {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, - {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, - {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, - {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, - {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, - {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, - {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, - {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, - {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, - {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, - {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, - {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, - {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, - {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, - {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pytest" -version = "7.4.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "4.1.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "wrapt" -version = "1.17.2" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.8" -files = [ - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62"}, - {file = "wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563"}, - {file = "wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72"}, - {file = "wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317"}, - {file = "wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9"}, - {file = "wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9"}, - {file = "wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504"}, - {file = "wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a"}, - {file = "wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f"}, - {file = "wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555"}, - {file = "wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c803c401ea1c1c18de70a06a6f79fcc9c5acfc79133e9869e730ad7f8ad8ef9"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f917c1180fdb8623c2b75a99192f4025e412597c50b2ac870f156de8fb101119"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ecc840861360ba9d176d413a5489b9a0aff6d6303d7e733e2c4623cfa26904a6"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb87745b2e6dc56361bfde481d5a378dc314b252a98d7dd19a651a3fa58f24a9"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58455b79ec2661c3600e65c0a716955adc2410f7383755d537584b0de41b1d8a"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e42a40a5e164cbfdb7b386c966a588b1047558a990981ace551ed7e12ca9c2"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:91bd7d1773e64019f9288b7a5101f3ae50d3d8e6b1de7edee9c2ccc1d32f0c0a"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:bb90fb8bda722a1b9d48ac1e6c38f923ea757b3baf8ebd0c82e09c5c1a0e7a04"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:08e7ce672e35efa54c5024936e559469436f8b8096253404faeb54d2a878416f"}, - {file = "wrapt-1.17.2-cp38-cp38-win32.whl", hash = "sha256:410a92fefd2e0e10d26210e1dfb4a876ddaf8439ef60d6434f21ef8d87efc5b7"}, - {file = "wrapt-1.17.2-cp38-cp38-win_amd64.whl", hash = "sha256:95c658736ec15602da0ed73f312d410117723914a5c91a14ee4cdd72f1d790b3"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9"}, - {file = "wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb"}, - {file = "wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb"}, - {file = "wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8"}, - {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "8cbe0a84999f2ad27d0d3fd420c6711c6e9c213f3ff59323826856614c8e427e" diff --git a/pyproject.toml b/pyproject.toml index 1d05100..5a6df3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,90 +1,115 @@ -[tool.poetry] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] name = "python-wrap" -version = "0.1.0" -description = "A comprehensive collection of Python decorators and utility functions for enhanced development workflows" -authors = ["Mahmoud Ahmed "] -maintainers = ["Mahmoud Ahmed "] -license = "MIT" +dynamic = ["version"] +description = "Everyday Python decorators that just work: retry, memoize, timeout, rate limiting, circuit breakers and more. Typed, async-aware, zero dependencies." readme = "README.md" -homepage = "https://github.com/Pythondeveloper6/PythonWrap" -repository = "https://github.com/Pythondeveloper6/PythonWrap" -documentation = "https://python-wrap.readthedocs.io" -keywords = ["decorators", "utilities", "python", "development", "tools","python"] +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Mahmoud Ahmed", email = "pythondeveloper6@gmail.com" }] +maintainers = [{ name = "Mahmoud Ahmed", email = "pythondeveloper6@gmail.com" }] +keywords = [ + "decorators", + "retry", + "memoize", + "cache", + "timeout", + "rate-limit", + "circuit-breaker", + "debounce", + "throttle", + "asyncio", + "utilities", +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Framework :: AsyncIO", "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Typing :: Typed" + "Topic :: Utilities", + "Typing :: Typed", ] -packages = [{include = "python_wrap"}] +# Zero runtime dependencies is a feature: this package should be safe to add to any project. +dependencies = [] -[tool.poetry.dependencies] -python = "^3.8" -wrapt = "^1.15.0" -typing-extensions = "^4.7.1" # For better type hints support +[project.urls] +Homepage = "https://github.com/LitePy/PythonWrap" +Documentation = "https://python-wrap.readthedocs.io" +Source = "https://github.com/LitePy/PythonWrap" +Issues = "https://github.com/LitePy/PythonWrap/issues" +Changelog = "https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md" -[tool.poetry.group.dev.dependencies] -pytest = "^7.4.0" -pytest-cov = "^4.1.0" -black = "^23.7.0" # Code formatting -isort = "^5.12.0" # Import sorting -mypy = "^1.4.1" # Type checking -pylint = "^2.17.5" # Code linting -pre-commit = "^3.3.3" # Git hooks -sphinx = "^7.1.1" # Documentation -sphinx-rtd-theme = "^1.2.2" # Documentation theme - -[tool.poetry.urls] -"Bug Tracker" = "https://github.com/Pythondeveloper6/PythonWrap/issues" -"Changelog" = "https://github.com/Pythondeveloper6/PythonWrap/blob/main/CHANGELOG.md" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-cov>=6.0", + "pytest-asyncio>=0.24", + "hypothesis>=6.100", + "ruff>=0.8", + "mypy>=1.13", +] +docs = [ + "mkdocs-material>=9.5", + "mkdocstrings[python]>=0.27", +] +release = ["build>=1.2", "twine>=6.0"] -[tool.black] -line-length = 88 -target-version = ["py38"] -include = '\.pyi?$' +[tool.hatch.version] +path = "src/python_wrap/__init__.py" -[tool.isort] -profile = "black" -multi_line_output = 3 -line_length = 88 +[tool.hatch.build.targets.wheel] +packages = ["src/python_wrap"] +exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"] -[tool.mypy] -python_version = "3.8" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true -check_untyped_defs = true +[tool.hatch.build.targets.sdist] +include = [ + "/src/python_wrap", + "/tests", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", +] +exclude = ["**/__pycache__", "**/*.py[cod]", "**/.DS_Store"] [tool.pytest.ini_options] -minversion = "7.0" -addopts = "-ra -q --cov=python_wrap --cov-report=term-missing" testpaths = ["tests"] +addopts = "--strict-markers -q" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = ["error::pytest.PytestUnraisableExceptionWarning"] [tool.coverage.run] source = ["python_wrap"] branch = true [tool.coverage.report] -exclude_lines = [ - "pragma: no cover", - "def __repr__", - "if self.debug:", - "raise NotImplementedError", - "if __name__ == .__main__.:", - "pass", - "raise ImportError" -] -ignore_errors = true -omit = ["tests/*", "setup.py"] \ No newline at end of file +show_missing = true +exclude_also = ["if TYPE_CHECKING:", "@overload", "raise NotImplementedError", "\\.\\.\\."] + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "C4", "SIM", "RUF"] + +[tool.ruff.lint.isort] +known-first-party = ["python_wrap"] + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src/python_wrap"] diff --git a/python_wrap/__init__.py b/python_wrap/__init__.py deleted file mode 100644 index f652bd6..0000000 --- a/python_wrap/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -PythonWrap - A comprehensive collection of Python decorators and utility functions. -""" - -from .decorators import ( - timing, - retry, - memoize, - log_args, - log_return, - validate_args, - cache, - once, - deprecate, - check_type, - retry_on_exception, - measure_memory, - profile, - rate_limit, - mock_data, - benchmark, - run_in_thread, - no_debug, - transactional, - revert_on_failure, - audit, - trace, - timeout, -) - -__version__ = "0.1.0" -__all__ = [ - "timing", - "retry", - "memoize", - "log_args", - "log_return", - "validate_args", - "cache", - "once", - "deprecate", - "check_type", - "retry_on_exception", - "measure_memory", - "profile", - "rate_limit", - "mock_data", - "benchmark", - "run_in_thread", - "no_debug", - "transactional", - "revert_on_failure", - "audit", - "trace", - "timeout", -] \ No newline at end of file diff --git a/python_wrap/decorators.py b/python_wrap/decorators.py deleted file mode 100644 index 261627a..0000000 --- a/python_wrap/decorators.py +++ /dev/null @@ -1,702 +0,0 @@ -""" -Core decorators module containing all decorator implementations. -""" - -import time -import logging -import functools -import threading -import warnings -import inspect -import traceback -from typing import Any, Callable, Dict, List, Type, Union -from datetime import datetime, timedelta -import threading -import queue -import signal -import resource -import cProfile -import pstats -import io -from contextlib import contextmanager - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -def _timing_impl(func, *args, **kwargs): - """Implementation of timing functionality.""" - start_time = time.time() - result = func(*args, **kwargs) - end_time = time.time() - logger.info(f"{func.__name__} took {end_time - start_time:.4f} seconds to execute") - return result - -def timing(func: Union[Callable, None] = None) -> Callable: - """ - Measures and logs the execution time of a function. - Can be used as a decorator or function: - - @timing - def func(): pass - - # or - result = timing(func)() - """ - if func is None: - return timing - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _timing_impl(func, *args, **kwargs) - return wrapper - -def _retry_impl(func, max_attempts, delay, *args, **kwargs): - """Implementation of retry functionality.""" - attempts = 0 - while attempts < max_attempts: - try: - return func(*args, **kwargs) - except Exception as e: - attempts += 1 - if attempts == max_attempts: - raise - logger.warning(f"Attempt {attempts} failed, retrying in {delay} seconds...") - time.sleep(delay) - return None - -def retry(func: Union[Callable, None] = None, *, max_attempts: int = 3, delay: float = 1.0) -> Callable: - """ - Retries a function in case of failure. - Can be used as a decorator or function: - - @retry(max_attempts=3) - def func(): pass - - # or - result = retry(func, max_attempts=3)() - """ - if func is None: - return lambda f: retry(f, max_attempts=max_attempts, delay=delay) - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _retry_impl(func, max_attempts, delay, *args, **kwargs) - return wrapper - -def _memoize_impl(func, cache, *args, **kwargs): - """Implementation of memoize functionality.""" - key = str(args) + str(kwargs) - if key not in cache: - cache[key] = func(*args, **kwargs) - return cache[key] - -def memoize(func: Union[Callable, None] = None) -> Callable: - """ - Caches the result of a function to prevent redundant calculations. - Can be used as a decorator or function: - - @memoize - def func(): pass - - # or - result = memoize(func)() - """ - if func is None: - return memoize - - cache = {} - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _memoize_impl(func, cache, *args, **kwargs) - return wrapper - -def _log_args_impl(func, *args, **kwargs): - """Implementation of log_args functionality.""" - logger.info(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}") - return func(*args, **kwargs) - -def log_args(func: Union[Callable, None] = None) -> Callable: - """ - Logs the arguments passed to a function. - Can be used as a decorator or function: - - @log_args - def func(): pass - - # or - result = log_args(func)() - """ - if func is None: - return log_args - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _log_args_impl(func, *args, **kwargs) - return wrapper - -def _log_return_impl(func, *args, **kwargs): - """Implementation of log_return functionality.""" - result = func(*args, **kwargs) - logger.info(f"{func.__name__} returned: {result}") - return result - -def log_return(func: Union[Callable, None] = None) -> Callable: - """ - Logs the return value of a function after execution. - Can be used as a decorator or function: - - @log_return - def func(): pass - - # or - result = log_return(func)() - """ - if func is None: - return log_return - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _log_return_impl(func, *args, **kwargs) - return wrapper - -def _validate_args_impl(func, validators, *args, **kwargs): - """Implementation of validate_args functionality.""" - sig = inspect.signature(func) - bound_args = sig.bind(*args, **kwargs) - for param_name, validator in validators.items(): - if param_name in bound_args.arguments: - value = bound_args.arguments[param_name] - if not validator(value): - raise ValueError(f"Invalid value for {param_name}: {value}") - return func(*args, **kwargs) - -def validate_args(**validators): - """ - Validates the arguments passed to a function based on custom rules. - Can be used as a decorator or function: - - @validate_args(x=lambda x: x > 0) - def func(): pass - - # or - result = validate_args(x=lambda x: x > 0)(func)() - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _validate_args_impl(func, validators, *args, **kwargs) - return wrapper - return decorator - -def _cache_impl(func, duration, cache_data, *args, **kwargs): - """Implementation of cache functionality.""" - key = str(args) + str(kwargs) - now = datetime.now() - if key in cache_data: - result, timestamp = cache_data[key] - if now - timestamp < duration: - return result - result = func(*args, **kwargs) - cache_data[key] = (result, now) - return result - -def cache(duration: timedelta): - """ - Caches the result of a function call for a specified duration. - Can be used as a decorator or function: - - @cache(duration=timedelta(minutes=5)) - def func(): pass - - # or - result = cache(duration=timedelta(minutes=5))(func)() - """ - cache_data = {} - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _cache_impl(func, duration, cache_data, *args, **kwargs) - return wrapper - return decorator - -def once(func: Union[Callable, None] = None) -> Callable: - """ - Ensures the function runs only once, even across multiple calls. - Can be used as a decorator or function: - - @once - def func(): pass - - # or - result = once(func)() - """ - if func is None: - return once - - # Store state in a dictionary to maintain closure state - state = {'has_run': False, 'result': None} - - @functools.wraps(func) - def wrapper(*args, **kwargs): - if not state['has_run']: - state['result'] = func(*args, **kwargs) - state['has_run'] = True - return state['result'] - - return wrapper - -def _deprecate_impl(func, message, *args, **kwargs): - """Implementation of deprecate functionality.""" - warn_msg = message or f"{func.__name__} is deprecated and will be removed in a future version." - warnings.warn(warn_msg, DeprecationWarning, stacklevel=2) - return func(*args, **kwargs) - -def deprecate(message: str = None): - """ - Marks a function as deprecated and provides a warning when used. - Can be used as a decorator or function: - - @deprecate("Use new_func instead") - def func(): pass - - # or - result = deprecate("Use new_func instead")(func)() - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _deprecate_impl(func, message, *args, **kwargs) - return wrapper - return decorator - -def _check_type_impl(func, type_hints, *args, **kwargs): - """Implementation of check_type functionality.""" - sig = inspect.signature(func) - bound_args = sig.bind(*args, **kwargs) - for param_name, expected_type in type_hints.items(): - if param_name in bound_args.arguments: - value = bound_args.arguments[param_name] - if not isinstance(value, expected_type): - raise TypeError(f"Parameter {param_name} must be of type {expected_type}") - return func(*args, **kwargs) - -def check_type(**type_hints): - """ - Verifies that function arguments are of the correct type. - Can be used as a decorator or function: - - @check_type(x=int, y=str) - def func(): pass - - # or - result = check_type(x=int, y=str)(func)() - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _check_type_impl(func, type_hints, *args, **kwargs) - return wrapper - return decorator - -def _retry_on_exception_impl(func, exceptions, *args, **kwargs): - """Implementation of retry_on_exception functionality.""" - while True: - try: - return func(*args, **kwargs) - except exceptions as e: - logger.warning(f"Caught exception {type(e).__name__}, retrying...") - continue - except Exception as e: - raise - -def retry_on_exception(*exceptions): - """ - Retries the function when specific exceptions are raised. - Can be used as a decorator or function: - - @retry_on_exception(ValueError, KeyError) - def func(): pass - - # or - result = retry_on_exception(ValueError, KeyError)(func)() - """ - if not exceptions: - exceptions = (Exception,) - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _retry_on_exception_impl(func, exceptions, *args, **kwargs) - return wrapper - return decorator - -def _measure_memory_impl(func, *args, **kwargs): - """Implementation of measure_memory functionality.""" - initial_memory = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - result = func(*args, **kwargs) - final_memory = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - memory_used = final_memory - initial_memory - logger.info(f"{func.__name__} used {memory_used} KB of memory") - return result - -def measure_memory(func: Union[Callable, None] = None) -> Callable: - """ - Measures the memory usage of a function while it runs. - Can be used as a decorator or function: - - @measure_memory - def func(): pass - - # or - result = measure_memory(func)() - """ - if func is None: - return measure_memory - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _measure_memory_impl(func, *args, **kwargs) - return wrapper - -def _profile_impl(func, *args, **kwargs): - """Implementation of profile functionality.""" - profiler = cProfile.Profile() - try: - return profiler.runcall(func, *args, **kwargs) - finally: - s = io.StringIO() - stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative') - stats.print_stats() - logger.info(f"Profile for {func.__name__}:\n{s.getvalue()}") - -def profile(func: Union[Callable, None] = None) -> Callable: - """ - Profiles the performance of a function to aid in optimization. - Can be used as a decorator or function: - - @profile - def func(): pass - - # or - result = profile(func)() - """ - if func is None: - return profile - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _profile_impl(func, *args, **kwargs) - return wrapper - -def _rate_limit_impl(func, calls, period, call_times, *args, **kwargs): - """Implementation of rate_limit functionality.""" - now = time.time() - call_times[:] = [t for t in call_times if now - t < period] - if len(call_times) >= calls: - raise Exception(f"Rate limit exceeded: {calls} calls per {period} seconds") - call_times.append(now) - return func(*args, **kwargs) - -def rate_limit(calls: int, period: float): - """ - Limits the frequency of function calls to avoid overload. - Can be used as a decorator or function: - - @rate_limit(calls=100, period=60) - def func(): pass - - # or - result = rate_limit(calls=100, period=60)(func)() - """ - call_times = [] - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _rate_limit_impl(func, calls, period, call_times, *args, **kwargs) - return wrapper - return decorator - -def _mock_data_impl(func, data, *args, **kwargs): - """Implementation of mock_data functionality.""" - return data() if callable(data) else data - -def mock_data(data: Any): - """ - Replaces a function's output with mock data, useful for testing. - Can be used as a decorator or function: - - @mock_data(42) - def func(): pass - - # or - result = mock_data(42)(func)() - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _mock_data_impl(func, data, *args, **kwargs) - return wrapper - return decorator - -def _benchmark_impl(func, *args, **kwargs): - """Implementation of benchmark functionality.""" - times = [] - for _ in range(3): # Run 3 times for averaging - start_time = time.time() - result = func(*args, **kwargs) - end_time = time.time() - times.append(end_time - start_time) - avg_time = sum(times) / len(times) - logger.info(f"Benchmark {func.__name__}: avg={avg_time:.4f}s over {len(times)} runs") - return result - -def benchmark(func: Union[Callable, None] = None) -> Callable: - """ - Compares the execution times of different functions. - Can be used as a decorator or function: - - @benchmark - def func(): pass - - # or - result = benchmark(func)() - """ - if func is None: - return benchmark - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _benchmark_impl(func, *args, **kwargs) - return wrapper - -def _run_in_thread_impl(func, *args, **kwargs): - """Implementation of run_in_thread functionality.""" - thread = threading.Thread(target=func, args=args, kwargs=kwargs) - thread.start() - return thread - -def run_in_thread(func: Union[Callable, None] = None) -> Callable: - """ - Executes a function asynchronously in a separate thread. - Can be used as a decorator or function: - - @run_in_thread - def func(): pass - - # or - result = run_in_thread(func)() - """ - if func is None: - return run_in_thread - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _run_in_thread_impl(func, *args, **kwargs) - return wrapper - -def _no_debug_impl(func, *args, **kwargs): - """Implementation of no_debug functionality.""" - current_level = logger.getEffectiveLevel() - logger.setLevel(logging.INFO) - try: - return func(*args, **kwargs) - finally: - logger.setLevel(current_level) - -def no_debug(func: Union[Callable, None] = None) -> Callable: - """ - Disables debug output in production environments. - Can be used as a decorator or function: - - @no_debug - def func(): pass - - # or - result = no_debug(func)() - """ - if func is None: - return no_debug - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _no_debug_impl(func, *args, **kwargs) - return wrapper - -def _transactional_impl(func, changes, *args, **kwargs): - """Implementation of transactional functionality.""" - try: - result = func(*args, **kwargs) - return result - except Exception: - for change in reversed(changes): - change.rollback() - raise - -def transactional(func: Union[Callable, None] = None) -> Callable: - """ - Ensures that function changes are committed or rolled back atomically. - Can be used as a decorator or function: - - @transactional - def func(): pass - - # or - result = transactional(func)() - """ - if func is None: - return transactional - - @functools.wraps(func) - def wrapper(*args, **kwargs): - changes = [] - return _transactional_impl(func, changes, *args, **kwargs) - return wrapper - -def _revert_on_failure_impl(func, state, *args, **kwargs): - """Implementation of revert_on_failure functionality.""" - try: - return func(*args, **kwargs) - except Exception: - # Restore initial state - for key, value in state.items(): - setattr(args[0], key, value) - raise - -def revert_on_failure(func: Union[Callable, None] = None) -> Callable: - """ - Reverts all changes made by the function if an exception occurs. - Can be used as a decorator or function: - - @revert_on_failure - def func(): pass - - # or - result = revert_on_failure(func)() - """ - if func is None: - return revert_on_failure - - @functools.wraps(func) - def wrapper(*args, **kwargs): - state = {} # Store initial state - return _revert_on_failure_impl(func, state, *args, **kwargs) - return wrapper - -def _audit_impl(func, *args, **kwargs): - """Implementation of audit functionality.""" - timestamp = datetime.now() - caller_frame = inspect.currentframe().f_back - caller_info = f"{caller_frame.f_code.co_filename}:{caller_frame.f_lineno}" - - audit_data = { - "function": func.__name__, - "timestamp": timestamp, - "caller": caller_info, - "args": args, - "kwargs": kwargs - } - - try: - result = func(*args, **kwargs) - audit_data["status"] = "success" - audit_data["result"] = result - return result - except Exception as e: - audit_data["status"] = "error" - audit_data["error"] = str(e) - raise - finally: - logger.info(f"Audit log: {audit_data}") - -def audit(func: Union[Callable, None] = None) -> Callable: - """ - Audits and logs function calls with metadata. - Can be used as a decorator or function: - - @audit - def func(): pass - - # or - result = audit(func)() - """ - if func is None: - return audit - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _audit_impl(func, *args, **kwargs) - return wrapper - -def _trace_impl(func, *args, **kwargs): - """Implementation of trace functionality.""" - stack = traceback.extract_stack()[:-1] # Exclude current frame - logger.debug(f"Call trace for {func.__name__}:") - for filename, lineno, name, line in stack: - logger.debug(f" File {filename}, line {lineno}, in {name}") - if line: - logger.debug(f" {line.strip()}") - return func(*args, **kwargs) - -def trace(func: Union[Callable, None] = None) -> Callable: - """ - Logs function calls, including call stack traces for debugging. - Can be used as a decorator or function: - - @trace - def func(): pass - - # or - result = trace(func)() - """ - if func is None: - return trace - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _trace_impl(func, *args, **kwargs) - return wrapper - -def _timeout_impl(func, seconds, *args, **kwargs): - """Implementation of timeout functionality.""" - result = queue.Queue() - - def target(): - try: - result.put(func(*args, **kwargs)) - except Exception as e: - result.put(e) - - thread = threading.Thread(target=target) - thread.daemon = True - thread.start() - thread.join(seconds) - - if thread.is_alive(): - raise TimeoutError(f"Function {func.__name__} timed out after {seconds} seconds") - - value = result.get() - if isinstance(value, Exception): - raise value - return value - -def timeout(seconds: int): - """ - Sets a timeout limit on function execution. - Can be used as a decorator or function: - - @timeout(5) - def func(): pass - - # or - result = timeout(5)(func)() - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - return _timeout_impl(func, seconds, *args, **kwargs) - return wrapper - return decorator \ No newline at end of file diff --git a/scripts/check_docs_examples.py b/scripts/check_docs_examples.py new file mode 100644 index 0000000..0ccc8e8 --- /dev/null +++ b/scripts/check_docs_examples.py @@ -0,0 +1,89 @@ +"""Run every ```python block found in docs/ and fail if any of them raises. + +Each block runs in a fresh namespace, so every example must be self-contained. A block +preceded by a line containing only ```` is not run; one preceded by +```` is skipped on Windows. Each block runs as a real module registered in +``sys.modules`` (so dataclasses, pickling and ``typing.get_type_hints`` work). Blocks inside tabs or +admonitions (indented fences) are dedented first. ``docs/api`` is generated from docstrings +and is not scanned. + +Usage: + uv run python scripts/check_docs_examples.py [path ...] +""" + +from __future__ import annotations + +import sys +import textwrap +import time +import traceback +import types +import warnings +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DOCS = ROOT / "docs" +SKIP_MARK = "" +UNIX_ONLY_MARK = "" + + +def extract(text: str) -> list[tuple[int, str, bool]]: + """Return ``(first_line_number, source, skipped)`` for every python fence.""" + lines = text.splitlines() + blocks: list[tuple[int, str, bool]] = [] + i = 0 + while i < len(lines): + stripped = lines[i].lstrip() + if stripped.startswith("```python"): + indent = len(lines[i]) - len(stripped) + j = i - 1 + while j >= 0 and not lines[j].strip(): + j -= 1 + mark = lines[j].strip() if j >= 0 else "" + skipped = mark == SKIP_MARK or (mark == UNIX_ONLY_MARK and sys.platform == "win32") + body: list[str] = [] + i += 1 + while i < len(lines) and not lines[i].lstrip().startswith("```"): + body.append(lines[i]) + i += 1 + source = textwrap.dedent("\n".join(body)) if indent else "\n".join(body) + blocks.append((i - len(body) + 1, source + "\n", skipped)) + i += 1 + return blocks + + +def main(argv: list[str]) -> int: + paths = [Path(a) for a in argv] or sorted( + p for p in DOCS.rglob("*.md") if "api" not in p.relative_to(DOCS).parts + ) + ran = failed = skipped_count = 0 + started = time.perf_counter() + for path in paths: + for line, source, skipped in extract(path.read_text(encoding="utf-8")): + where = f"{path.relative_to(ROOT) if path.is_absolute() else path}:{line}" + if skipped: + skipped_count += 1 + continue + ran += 1 + name = f"_docs_example_{ran}" + module = types.ModuleType(name) + module.__file__ = where + sys.modules[name] = module + try: + code = compile(source, where, "exec") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + exec(code, module.__dict__) + except BaseException: + failed += 1 + print(f"FAILED {where}", file=sys.stderr) + traceback.print_exc() + finally: + sys.modules.pop(name, None) + elapsed = time.perf_counter() - started + print(f"{ran} examples run, {skipped_count} skipped, {failed} failed in {elapsed:.2f}s") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/src/python_wrap/__init__.py b/src/python_wrap/__init__.py new file mode 100644 index 0000000..f4aa547 --- /dev/null +++ b/src/python_wrap/__init__.py @@ -0,0 +1,258 @@ +"""python-wrap: everyday Python decorators that just work. + +Every decorator works on plain functions, ``async def`` functions and methods, keeps +the wrapped signature for type checkers, and never configures logging for you. + +Testing helpers live in ``python_wrap.testing`` and the event catalogue in +``python_wrap.events``; they are not re-exported here. +""" + +import logging + +from .aio import ( + RaceFailed, + amap_iter, + gather_limit, + map_async, + race, + run_sync, +) +from .batching import ( + AsyncBatched, + Batched, + batch_calls, +) +from .bulkhead import ( + Bulkhead, + Limited, + limit_concurrency, +) +from .caching import ( + CachedProperty, + CacheInfo, + Memoized, + Once, + cache, + cached_property, + invalidate, + memoize, + once, +) +from .circuit import ( + CircuitBreaker, + CircuitProtected, + CircuitState, + CircuitStats, + circuit_breaker, +) +from .concurrency import ( + AsyncDebounced, + Debounced, + RateLimiter, + Timeout, + debounce, + limiter_of, + rate_limit, + run_in_thread, + singleflight, + throttle, + timeout, +) +from .config import ( + configure, + configured, + get_config, + reset_config, +) +from .deadline import ( + Deadline, + deadline, +) +from .devtools import ( + compose, + deprecate, + mock_data, + no_debug, + wrap_methods, +) +from .errors import ( + ArgumentTypeError, + BatchError, + BulkheadFull, + CircuitOpenError, + PythonWrapError, + RateLimitExceeded, + TimeoutExpired, + ValidationError, +) +from .events import ( + listening, + subscribe, +) +from .hedging import ( + hedge, +) +from .logs import ( + DEFAULT_REDACT, + AuditRecord, + audit, + current_log_context, + log_args, + log_context, + log_return, + trace, +) +from .metrics import ( + CallStats, + Counted, + count_calls, +) +from .policy import ( + Policy, + policy, +) +from .retry import ( + Attempt, + Retrying, + RetryState, + RetryStats, + fallback, + retry, + retry_after_header, + retry_on_exception, + retry_stats, + retrying, + suppress, +) +from .state import ( + INJECTED, + Transaction, + current_transaction, + require_transaction, + revert_on_failure, + transactional, +) +from .timing import ( + BenchmarkResult, + MemoryRecord, + TimingRecord, + benchmark, + benchmark_async, + measure_memory, + profile, + timing, +) +from .validation import ( + check_type, + enforce_types, + validate_args, +) + +__version__ = "0.2.0" + +# A library must never configure logging; this only silences "no handler" warnings. +logging.getLogger("python_wrap").addHandler(logging.NullHandler()) + +__all__ = [ + "DEFAULT_REDACT", + "INJECTED", + "ArgumentTypeError", + "AsyncBatched", + "AsyncDebounced", + "Attempt", + "AuditRecord", + "BatchError", + "Batched", + "BenchmarkResult", + "Bulkhead", + "BulkheadFull", + "CacheInfo", + "CachedProperty", + "CallStats", + "CircuitBreaker", + "CircuitOpenError", + "CircuitProtected", + "CircuitState", + "CircuitStats", + "Counted", + "Deadline", + "Debounced", + "Limited", + "Memoized", + "MemoryRecord", + "Once", + "Policy", + "PythonWrapError", + "RaceFailed", + "RateLimitExceeded", + "RateLimiter", + "RetryState", + "RetryStats", + "Retrying", + "Timeout", + "TimeoutExpired", + "TimingRecord", + "Transaction", + "ValidationError", + "__version__", + "amap_iter", + "audit", + "batch_calls", + "benchmark", + "benchmark_async", + "cache", + "cached_property", + "check_type", + "circuit_breaker", + "compose", + "configure", + "configured", + "count_calls", + "current_log_context", + "current_transaction", + "deadline", + "debounce", + "deprecate", + "enforce_types", + "fallback", + "gather_limit", + "get_config", + "hedge", + "invalidate", + "limit_concurrency", + "limiter_of", + "listening", + "log_args", + "log_context", + "log_return", + "map_async", + "measure_memory", + "memoize", + "mock_data", + "no_debug", + "once", + "policy", + "profile", + "race", + "rate_limit", + "require_transaction", + "reset_config", + "retry", + "retry_after_header", + "retry_on_exception", + "retry_stats", + "retrying", + "revert_on_failure", + "run_in_thread", + "run_sync", + "singleflight", + "subscribe", + "suppress", + "throttle", + "timeout", + "timing", + "trace", + "transactional", + "validate_args", + "wrap_methods", +] diff --git a/src/python_wrap/_core.py b/src/python_wrap/_core.py new file mode 100644 index 0000000..89caa6e --- /dev/null +++ b/src/python_wrap/_core.py @@ -0,0 +1,188 @@ +"""Shared plumbing for every decorator in the package. + +Rules every module follows: + +* Never configure logging. Log through `get_logger`; the package logger carries a + ``NullHandler`` so nothing is printed unless the application configures logging. +* Support ``async def`` functions: branch on `is_async` and return an ``async def`` + wrapper, so ``inspect.iscoroutinefunction(wrapped)`` stays true. +* Read time through ``_core.monotonic()`` / ``_core.perf_counter()`` and wait through + ``_core.sleep()`` / ``_core.async_sleep()`` — always as module attributes, never + imported by name — so tests can patch one place to get a fake clock. +* ``_core.sleep`` / ``_core.async_sleep`` are only for deliberate backoff waits (retry + delays, a blocked rate limiter). A limit on how long to wait — a timeout, a deadline + for a slot, a hedge delay, a batching or debounce window — runs on real time + (``asyncio.timeout`` / ``asyncio.sleep`` timers, ``threading.Timer``, ``wait(timeout=)`` + and ``time.monotonic()``), so the test overrides and the fake clock never shorten + it, and sync and async code behave the same. +* Honour the process-wide test overrides (see below). + +Test overrides contract +----------------------- +``_core.overrides`` is a single process-wide `Overrides` instance, mutated in +place (never replaced), so it may be read as ``_core.overrides.`` at any time. +Reads are plain attribute reads (thread-safe under the GIL); changing it is a test-time +operation done through `set_overrides` (which holds ``overrides_lock``) or the +public helpers in `python_wrap.testing`. Modules must read the field **at call +time**, not at decoration time, so a test can flip it after import: + +* ``no_sleep``: `sleep` / `async_sleep` return immediately (the async one + still yields to the event loop once). Handled here; other modules need do nothing as + long as they wait only through ``_core``. A fake clock installed by ``fake_clock()`` + replaces the sleeps and so still advances time. A blocking rate limiter that would + have to wait lets the call through at once (still counted) instead of spinning. +* ``max_attempts``: when not ``None``, ``retry`` uses + ``min(configured_max_attempts, _core.overrides.max_attempts)`` attempts. +* ``disable_rate_limits``: when true, rate limiters (``rate_limit``, ``RateLimiter``) + and ``throttle`` let every call through immediately. Concurrency limits + (``Bulkhead``, ``limit_concurrency``) are not affected. +* ``disable_caching``: when true, ``memoize`` / ``cache`` / ``once``-style caches + neither read nor write their stores and always call the wrapped function. +""" + +from __future__ import annotations + +import asyncio +import functools +import inspect +import logging +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass, fields +from typing import Any, ParamSpec, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") +F = TypeVar("F", bound=Callable[..., Any]) + +LOGGER_NAME = "python_wrap" + +LoggerLike = logging.Logger | str | None + +# Time sources, patched by tests. Call them as ``_core.monotonic()``. +monotonic: Callable[[], float] = time.monotonic +perf_counter: Callable[[], float] = time.perf_counter + + +@dataclass +class Overrides: + """Process-wide switches for tests. See the module docstring for the contract.""" + + no_sleep: bool = False + max_attempts: int | None = None + disable_rate_limits: bool = False + disable_caching: bool = False + + +overrides = Overrides() +overrides_lock = threading.RLock() + + +def set_overrides(**values: Any) -> dict[str, Any]: + """Set override fields atomically and return their previous values. + + Raises: + TypeError: If a name is not a field of `Overrides`. + ValueError: If ``max_attempts`` is not ``None`` or a positive integer. + """ + names = {f.name for f in fields(Overrides)} + unknown = set(values) - names + if unknown: + raise TypeError(f"unknown override(s): {', '.join(sorted(unknown))}") + attempts = values.get("max_attempts") + if attempts is not None and (not isinstance(attempts, int) or attempts < 1): + raise ValueError("max_attempts override must be None or an integer >= 1") + with overrides_lock: + previous = {name: getattr(overrides, name) for name in values} + for name, value in values.items(): + setattr(overrides, name, value) + return previous + + +def sleep(seconds: float) -> None: + """Blocking sleep; a no-op while ``overrides.no_sleep`` is set.""" + if overrides.no_sleep: + return + time.sleep(seconds) + + +async def async_sleep(seconds: float) -> None: + """Indirection over `asyncio.sleep` so tests can patch it. + + While ``overrides.no_sleep`` is set it only yields to the event loop once. + """ + await asyncio.sleep(0 if overrides.no_sleep else seconds) + + +_default_logger: LoggerLike = None + + +def get_logger(logger: LoggerLike = None) -> logging.Logger: + """Resolve a ``logger=`` argument: a Logger, a logger name, or the package logger.""" + if logger is None: + logger = _default_logger # set by python_wrap.config.configure(logger=...) + if isinstance(logger, logging.Logger): + return logger + return logging.getLogger(logger or LOGGER_NAME) + + +def is_async(func: Callable[..., Any]) -> bool: + """True if calling ``func`` returns a coroutine. + + Covers ``async def`` functions and methods, ``functools.partial`` objects around + them, and callable instances whose class defines ``async def __call__``. A class is + never async: calling it constructs an instance, even if the class has an async + ``__call__``. + """ + while isinstance(func, functools.partial): + func = func.func + if inspect.iscoroutinefunction(func): + return True + if inspect.isclass(func) or inspect.isfunction(func) or inspect.ismethod(func): + return False + # Look the method up on the type, as the call itself does. + call = inspect.getattr_static(type(func), "__call__", None) + return call is not None and inspect.iscoroutinefunction(call) + + +def qualname(func: Callable[..., Any]) -> str: + """A readable name for log messages.""" + return getattr(func, "__qualname__", None) or getattr(func, "__name__", None) or repr(func) + + +# ---------------------------------------------------------------------------- events +# One process-wide hook for metrics. Decorators call ``_core.emit(...)`` at interesting +# moments (a retry, a cache hit, a circuit opening); ``python_wrap.events`` is the public +# face. With no listener registered, ``emit`` is a single list check. + + +@dataclass(frozen=True) +class Event: + """Something a decorator did, for metrics and tracing. + + Attributes: + name: Dotted event name, e.g. ``"retry.attempt_failed"``. + function: Qualified name of the decorated function (or the breaker/limiter name). + fields: Event-specific data, e.g. ``{"attempt": 2, "next_delay": 0.5}``. + """ + + name: str + function: str + fields: dict[str, Any] + + +listeners: list[Callable[[Event], None]] = [] +listeners_lock = threading.Lock() + + +def emit(name: str, function: str, **fields: Any) -> None: + """Deliver an event to every listener. A failing listener is logged, never raised.""" + if not listeners: + return + event = Event(name, function, fields) + for listener in tuple(listeners): + try: + listener(event) + except Exception: + get_logger().exception("python_wrap event listener %r failed", listener) diff --git a/src/python_wrap/aio.py b/src/python_wrap/aio.py new file mode 100644 index 0000000..eb6b9c9 --- /dev/null +++ b/src/python_wrap/aio.py @@ -0,0 +1,620 @@ +"""Small asyncio helpers that every async project ends up writing. + +* `gather_limit` — `asyncio.gather` with at most `limit` awaitables running at once. +* `map_async` — apply an async function to every item of a (possibly huge or async) + iterable, at most `limit` at a time, and collect the results. +* `amap_iter` — the streaming form of `map_async`: an async generator of results. +* `race` — the first awaitable to succeed wins; the others are cancelled. +* `run_sync` — run a coroutine from synchronous code (never inside a running loop). + +Every helper owns the tasks it creates: when it returns, raises, is cancelled or (for +`amap_iter`) is closed early, every task it started has finished, so no task is left +pending behind your back. +""" + +from __future__ import annotations + +import asyncio +import inspect +import sys +from collections.abc import ( + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Awaitable, + Callable, + Coroutine, + Iterable, + Iterator, +) +from contextlib import aclosing +from typing import Any, Literal, ParamSpec, TypeVar, overload + +from python_wrap import _core +from python_wrap.errors import PythonWrapError, TimeoutExpired + +__all__ = [ + "RaceFailed", + "amap_iter", + "gather_limit", + "map_async", + "race", + "run_sync", +] + +T = TypeVar("T") +R = TypeVar("R") +P = ParamSpec("P") + +# --------------------------------------------------------------------------------------- +# shared plumbing +# --------------------------------------------------------------------------------------- + + +def _check_limit(limit: int) -> None: + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise ValueError(f"limit must be a positive integer, got {limit!r}") + + +async def _await(aw: Awaitable[T]) -> T: + return await aw + + +def _start(aw: Awaitable[T]) -> asyncio.Future[T]: + """Schedule ``aw``. Coroutines become tasks; anything else is awaited in a new task. + + Wrapping futures in a task of our own means cancelling that task also cancels the + future, and the same future may appear more than once. + """ + if asyncio.iscoroutine(aw): + return asyncio.ensure_future(aw) + return asyncio.ensure_future(_await(aw)) + + +async def _settle(futures: Iterable[asyncio.Future[Any]]) -> None: + """Wait until every future is done, even if we are cancelled meanwhile. + + A cancellation that arrives while waiting is re-raised once everything has finished, + so cleanup never leaves tasks behind. Exceptions of the futures are marked as + retrieved, so asyncio does not log "exception was never retrieved". + """ + futures = list(futures) + cancelled = False + while True: + waiting = [f for f in futures if not f.done()] + if not waiting: + break + try: + await asyncio.wait(waiting) + except asyncio.CancelledError: + cancelled = True + for f in futures: + if not f.cancelled(): + f.exception() + if cancelled: + raise asyncio.CancelledError + + +async def _cancel_and_settle(futures: Iterable[asyncio.Future[Any]]) -> None: + futures = list(futures) + for f in futures: + f.cancel() + await _settle(futures) + + +_Next = Callable[[], Awaitable["Awaitable[Any] | None"]] + + +async def _drive( + next_awaitable: _Next, limit: int, ordered: bool +) -> AsyncGenerator[tuple[int, asyncio.Future[Any]], None]: + """Start awaitables pulled from ``next_awaitable`` and yield ``(index, done_future)``. + + At most ``limit`` futures are in flight. With ``ordered`` they are yielded in input + order and finished-but-not-yet-yielded futures count towards ``limit``, so the reorder + buffer is bounded. Without it they are yielded as they finish (ties in input order). + Closing the generator cancels and awaits everything still running. + """ + pending: dict[asyncio.Future[Any], int] = {} + ready: dict[int, asyncio.Future[Any]] = {} + count = 0 + next_out = 0 + exhausted = False + try: + while True: + while not exhausted and len(pending) + len(ready) < limit: + aw = await next_awaitable() + if aw is None: + exhausted = True + else: + pending[_start(aw)] = count + count += 1 + while next_out in ready: + yield next_out, ready.pop(next_out) + next_out += 1 + if not pending: + if exhausted: + return + continue # everything finished was yielded: pull more input + done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + for fut in sorted(done, key=pending.__getitem__): + index = pending.pop(fut) + if ordered: + ready[index] = fut + else: + yield index, fut + finally: + await _cancel_and_settle([*pending, *ready.values()]) + + +def _outcome(fut: asyncio.Future[Any], return_exceptions: bool) -> Any: + if not return_exceptions: + return fut.result() + if fut.cancelled(): + return asyncio.CancelledError() + return fut.exception() or fut.result() + + +def _discard(aw: Awaitable[Any], leftovers: list[asyncio.Future[Any]]) -> None: + """Dispose of an awaitable that was never started.""" + if asyncio.iscoroutine(aw): + aw.close() # no "coroutine was never awaited" warning + elif asyncio.isfuture(aw): + leftovers.append(aw) + + +# --------------------------------------------------------------------------------------- +# gather_limit +# --------------------------------------------------------------------------------------- + + +@overload +async def gather_limit( + *aws: Awaitable[T], limit: int, return_exceptions: Literal[False] = False +) -> list[T]: ... +@overload +async def gather_limit( + *aws: Awaitable[T], limit: int, return_exceptions: bool +) -> list[T | BaseException]: ... +async def gather_limit( + *aws: Awaitable[Any], limit: int, return_exceptions: bool = False +) -> list[Any]: + """Like `asyncio.gather`, but at most `limit` awaitables run at the same time. + + Coroutines are started only when a slot is free. Tasks and futures are already + running when you pass them, so `limit` cannot hold them back; they are simply awaited + (and cancelled with the rest on failure). + + Args: + *aws: Coroutines, tasks or futures (any awaitable). + limit: Maximum number of awaitables running at once (>= 1). + return_exceptions: If true, exceptions (and `asyncio.CancelledError` for a child + cancelled from outside) are returned in the result list instead of raised. + + Returns: + The results, in the order of `aws` (not completion order). + + Raises: + ValueError: `limit` is not a positive integer. + Exception: With `return_exceptions=False`, the first exception raised by any + awaitable. Everything still running is cancelled and awaited first, and + coroutines that never started are closed. + + Example: + ```python + import asyncio + from python_wrap.aio import gather_limit + + async def square(n: int) -> int: + await asyncio.sleep(0) + return n * n + + async def main() -> None: + results = await gather_limit(*(square(n) for n in range(10)), limit=3) + assert results == [n * n for n in range(10)] + + asyncio.run(main()) + ``` + """ + _check_limit(limit) + results: list[Any] = [None] * len(aws) + source: Iterator[Awaitable[Any]] = iter(aws) + + async def next_awaitable() -> Awaitable[Any] | None: + return next(source, None) + + leftovers: list[asyncio.Future[Any]] = [] + try: + async with aclosing(_drive(next_awaitable, limit, ordered=False)) as finished: + async for index, fut in finished: + results[index] = _outcome(fut, return_exceptions) + finally: + for aw in source: + _discard(aw, leftovers) + if leftovers: + await _cancel_and_settle(leftovers) + return results + + +# --------------------------------------------------------------------------------------- +# map_async / amap_iter +# --------------------------------------------------------------------------------------- + + +async def _call(fn: Callable[[T], Awaitable[R]], item: T) -> R: + return await fn(item) + + +def _items_source(fn: Callable[[T], Awaitable[R]], items: Iterable[T] | AsyncIterable[T]) -> _Next: + if isinstance(items, AsyncIterable): + ait: AsyncIterator[T] = aiter(items) + + async def next_async() -> Awaitable[Any] | None: + try: + item = await anext(ait) + except StopAsyncIteration: + return None + return _call(fn, item) + + return next_async + + it: Iterator[T] = iter(items) + sentinel = object() + + async def next_sync() -> Awaitable[Any] | None: + item = next(it, sentinel) + return None if item is sentinel else _call(fn, item) # type: ignore[arg-type] + + return next_sync + + +@overload +def amap_iter( + fn: Callable[[T], Awaitable[R]], + items: Iterable[T] | AsyncIterable[T], + *, + limit: int, + ordered: bool = True, + return_exceptions: Literal[False] = False, +) -> AsyncGenerator[R, None]: ... +@overload +def amap_iter( + fn: Callable[[T], Awaitable[R]], + items: Iterable[T] | AsyncIterable[T], + *, + limit: int, + ordered: bool = True, + return_exceptions: bool, +) -> AsyncGenerator[R | BaseException, None]: ... +def amap_iter( + fn: Callable[[T], Awaitable[R]], + items: Iterable[T] | AsyncIterable[T], + *, + limit: int, + ordered: bool = True, + return_exceptions: bool = False, +) -> AsyncGenerator[Any, None]: + """Apply `fn` to every item, at most `limit` at a time, yielding results as they come. + + The input is consumed lazily: an item is pulled only when a slot is free, so an + infinite generator is fine. `fn` is called inside the new task, so even an exception + raised synchronously by `fn` counts as that item's failure. + + * `ordered=True`: results come out in input order. Results that finish early wait in + a buffer, and buffered results count towards `limit`, so memory stays bounded; a + slow item holds back the ones behind it. An exception surfaces when its turn comes. + * `ordered=False`: results come out as soon as they finish; an exception surfaces at + once. + + Breaking out of the loop, calling `aclose()`, or cancelling the consuming task cancels + every call still running and waits for it. To make cleanup immediate, close the + generator explicitly (`contextlib.aclosing`) rather than relying on garbage + collection. The input iterator itself is not closed. + + Args: + fn: Async function (or any callable returning an awaitable) of one item. + items: A sync or async iterable. + limit: Maximum number of calls in flight (>= 1). + ordered: Yield in input order (default) or in completion order. + return_exceptions: Yield exceptions as values instead of raising them. + + Returns: + An async generator of results. + + Raises: + ValueError: `limit` is not a positive integer. + + Example: + ```python + import asyncio + from contextlib import aclosing + from itertools import count + from python_wrap.aio import amap_iter + + async def double(n: int) -> int: + await asyncio.sleep(0) + return 2 * n + + async def main() -> None: + seen = [] + async with aclosing(amap_iter(double, count(), limit=4)) as results: + async for value in results: # an infinite input is fine + seen.append(value) + if len(seen) == 5: + break + assert seen == [0, 2, 4, 6, 8] + + asyncio.run(main()) + ``` + """ + _check_limit(limit) + source = _items_source(fn, items) + + async def generate() -> AsyncGenerator[Any, None]: + async with aclosing(_drive(source, limit, ordered)) as finished: + async for _, fut in finished: + yield _outcome(fut, return_exceptions) + + return generate() + + +@overload +async def map_async( + fn: Callable[[T], Awaitable[R]], + items: Iterable[T] | AsyncIterable[T], + *, + limit: int, + ordered: bool = True, + return_exceptions: Literal[False] = False, +) -> list[R]: ... +@overload +async def map_async( + fn: Callable[[T], Awaitable[R]], + items: Iterable[T] | AsyncIterable[T], + *, + limit: int, + ordered: bool = True, + return_exceptions: bool, +) -> list[R | BaseException]: ... +async def map_async( + fn: Callable[[T], Awaitable[R]], + items: Iterable[T] | AsyncIterable[T], + *, + limit: int, + ordered: bool = True, + return_exceptions: bool = False, +) -> list[Any]: + """Apply `fn` to every item, at most `limit` at a time, and return all the results. + + Items are pulled lazily, so at most `limit` calls (and items) are in flight however + long the input is. Unlike `amap_iter`, an exception is raised as soon as any call + fails (with `return_exceptions=False`): the other calls are cancelled and awaited and + no further items are pulled. + + Args: + fn: Async function (or any callable returning an awaitable) of one item. + items: A sync or async iterable. + limit: Maximum number of calls in flight (>= 1). + ordered: Return results in input order (default) or in completion order. + return_exceptions: Put exceptions in the list instead of raising them. + + Returns: + A list with one result per item. + + Raises: + ValueError: `limit` is not a positive integer. + Exception: The first exception raised by `fn` (with `return_exceptions=False`). + + Example: + ```python + import asyncio + from python_wrap.aio import map_async + + async def fetch(url: str) -> int: + await asyncio.sleep(0) + return len(url) + + async def main() -> None: + urls = ["https://a.example", "https://bb.example"] + sizes = await map_async(fetch, urls, limit=10) + assert sizes == [17, 18] + + asyncio.run(main()) + ``` + """ + _check_limit(limit) + source = _items_source(fn, items) + by_index: dict[int, Any] = {} + async with aclosing(_drive(source, limit, ordered=False)) as finished: + async for index, fut in finished: + by_index[index] = _outcome(fut, return_exceptions) + if ordered: + return [by_index[i] for i in range(len(by_index))] + return list(by_index.values()) + + +# --------------------------------------------------------------------------------------- +# race +# --------------------------------------------------------------------------------------- + +if sys.version_info >= (3, 11): + + class RaceFailed(ExceptionGroup[Exception], PythonWrapError): # noqa: F821 + """Every contestant of `race` failed. + + On Python 3.11+ this is an `ExceptionGroup` (so `except*` works) and a + `PythonWrapError`. On 3.10 it is a plain `PythonWrapError`. On every version, + `.exceptions` is a tuple of the errors in input order. + """ + + def __new__(cls, message: str, exceptions: Iterable[Exception]) -> RaceFailed: + return super().__new__(cls, message, list(exceptions)) + + def __init__(self, message: str, exceptions: Iterable[Exception]) -> None: + super().__init__(message, list(exceptions)) + + def derive(self, excs: Any) -> Any: + return RaceFailed(self.message, excs) + +else: + + class RaceFailed(PythonWrapError): + """Every contestant of `race` failed. + + On Python 3.11+ this is an `ExceptionGroup` (so `except*` works) and a + `PythonWrapError`. On 3.10 it is a plain `PythonWrapError`. On every version, + `.exceptions` is a tuple of the errors in input order. + """ + + def __init__(self, message: str, exceptions: Iterable[Exception]) -> None: + super().__init__(message) + self.message = message + self.exceptions: tuple[Exception, ...] = tuple(exceptions) + + +async def race(*aws: Awaitable[T], timeout: float | None = None) -> T: + """Run every awaitable at once and return the result of the first to succeed. + + As soon as one succeeds, the others are cancelled and awaited before `race` + returns. A contestant that fails does not end the race; the others keep running. + If several finish in the same event-loop step, the earliest in `aws` wins. + + Args: + *aws: Coroutines, tasks or futures; at least one. + timeout: Give up after this many seconds of real time (`fast_mode()` and + `fake_clock()` do not shorten it). `None` waits forever. + + Returns: + The winner's result. + + Raises: + ValueError: No awaitables were given. + RaceFailed: Every contestant raised an exception; `.exceptions` holds them in + input order. Contestants cancelled from outside are left out of it (if all + of them were cancelled, `asyncio.CancelledError` is raised instead). + TimeoutExpired: Nobody succeeded within `timeout` (also a `TimeoutError`). + BaseException: A contestant raised `KeyboardInterrupt`/`SystemExit` (or similar): + the others are cancelled and it propagates at once. + + Example: + ```python + import asyncio + from python_wrap.aio import race + + async def mirror(name: str, delay: float) -> str: + await asyncio.sleep(delay) + return name + + async def main() -> None: + winner = await race(mirror("slow", 1), mirror("fast", 0), timeout=5) + assert winner == "fast" + + asyncio.run(main()) + ``` + """ + if not aws: + raise ValueError("race() needs at least one awaitable") + contestants: list[asyncio.Future[T]] = [] + timer: asyncio.Future[None] | None = None + errors: dict[int, Exception] = {} + try: + for aw in aws: + contestants.append(_start(aw)) + order: dict[asyncio.Future[Any], int] = {fut: i for i, fut in enumerate(contestants)} + if timeout is not None: + timer = asyncio.ensure_future(asyncio.sleep(timeout)) # real time + pending: set[asyncio.Future[Any]] = set(contestants) + while pending: + watch = pending if timer is None else pending | {timer} + done, _ = await asyncio.wait(watch, return_when=asyncio.FIRST_COMPLETED) + finished = sorted((f for f in done if f is not timer), key=order.__getitem__) + for fut in finished: + pending.discard(fut) + if fut.cancelled(): + continue + exc = fut.exception() + if exc is None: + return contestants[order[fut]].result() + if not isinstance(exc, Exception): + raise exc + errors[order[fut]] = exc + if timer is not None and timer in done and pending: + raise TimeoutExpired( + f"race() found no winner within {timeout}s", seconds=float(timeout or 0) + ) + finally: + await _cancel_and_settle([*contestants, *([timer] if timer else [])]) + if not errors: + raise asyncio.CancelledError + failures = [errors[i] for i in sorted(errors)] + raise RaceFailed(f"all {len(failures)} contestants of race() failed", failures) + + +# --------------------------------------------------------------------------------------- +# run_sync +# --------------------------------------------------------------------------------------- + + +@overload +def run_sync(coro: Coroutine[Any, Any, T], /) -> T: ... +@overload +def run_sync(fn: Callable[P, Awaitable[T]], /, *args: P.args, **kwargs: P.kwargs) -> T: ... +def run_sync(coro_or_fn: Any, /, *args: Any, **kwargs: Any) -> Any: + """Run async code from synchronous code and return its result. + + Uses `asyncio.run`, so a fresh event loop is created and closed for the call. There + is deliberately no "nested event loop" support: calling this while an event loop is + running in the current thread raises `RuntimeError` (use `await` there instead, or + run the sync caller in a worker thread with `asyncio.to_thread`). + + Args: + coro_or_fn: A coroutine object, or an async function to call with `args`/`kwargs`. + *args: Positional arguments for the function. + **kwargs: Keyword arguments for the function. + + Returns: + Whatever the coroutine returns. + + Raises: + RuntimeError: An event loop is already running in this thread. + TypeError: Arguments were given with a coroutine object, or the function did not + return an awaitable. + + Example: + ```python + import asyncio + from python_wrap.aio import run_sync + + async def add(a: int, b: int) -> int: + await asyncio.sleep(0) + return a + b + + assert run_sync(add, 1, 2) == 3 + assert run_sync(add(3, 4)) == 7 + ``` + """ + is_coro = asyncio.iscoroutine(coro_or_fn) + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + if is_coro: + coro_or_fn.close() + raise RuntimeError( + "run_sync() cannot be called while an event loop is running in this thread; " + "await the coroutine instead" + ) + if is_coro: + if args or kwargs: + coro_or_fn.close() + raise TypeError("run_sync() takes no extra arguments when given a coroutine") + return asyncio.run(coro_or_fn) + if not callable(coro_or_fn): + raise TypeError(f"run_sync() needs a coroutine or an async callable, got {coro_or_fn!r}") + result = coro_or_fn(*args, **kwargs) + if asyncio.iscoroutine(result): + return asyncio.run(result) + if inspect.isawaitable(result): + return asyncio.run(_await(result)) + raise TypeError(f"{_core.qualname(coro_or_fn)}() did not return an awaitable") diff --git a/src/python_wrap/batching.py b/src/python_wrap/batching.py new file mode 100644 index 0000000..925ad94 --- /dev/null +++ b/src/python_wrap/batching.py @@ -0,0 +1,556 @@ +"""Coalesce many single-item calls into one batched call: `batch_calls`. + +This is the DataLoader pattern. You write a function that takes a *list* of items and +returns their results; `batch_calls` turns it into a function that takes *one* item. Calls +that arrive close together are collected and sent to your function as a single batch. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextlib +import functools +import threading +import warnings +import weakref +from collections.abc import Awaitable, Callable, Coroutine, Hashable, Mapping, Sequence +from typing import Any, Concatenate, ParamSpec, Protocol, TypeGuard, TypeVar, cast, overload + +from python_wrap import _core +from python_wrap.errors import BatchError + +__all__ = ["AsyncBatched", "Batched", "batch_calls"] + +P = ParamSpec("P") +P2 = ParamSpec("P2") +T = TypeVar("T") +R = TypeVar("R") +S = TypeVar("S") +_O = TypeVar("_O") +R_co = TypeVar("R_co", covariant=True) + +_GroupKey = tuple[int, ...] + + +class Batched(Protocol[P, R_co]): + """A sync function wrapped by `batch_calls`: call it with one item, get its result.""" + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + @overload + def __get__( + self: Batched[Concatenate[type[_O], P2], R_co], instance: None, owner: type[_O] + ) -> Batched[P2, R_co]: ... # under @classmethod: bind the class + @overload + def __get__(self, instance: None, owner: type | None = None) -> Batched[P, R_co]: ... + @overload + def __get__( + self: Batched[Concatenate[type[_O], P2], R_co], instance: _O, owner: type | None = None + ) -> Batched[P2, R_co]: ... + @overload + def __get__( + self: Batched[Concatenate[_O, P2], R_co], instance: _O, owner: type | None = None + ) -> Batched[P2, R_co]: ... + @overload + def __get__(self, instance: object, owner: type | None = None) -> Batched[P, R_co]: ... + + @property + def pending(self) -> int: + """Number of distinct items queued and not yet sent to the batch function.""" + ... + + def flush(self) -> None: + """Send every queued batch now, in the calling thread, and wait for it.""" + ... + + +class AsyncBatched(Protocol[P, R_co]): + """An `async def` function wrapped by `batch_calls`: `await loader(item)`.""" + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R_co]: ... + + @overload + def __get__( + self: AsyncBatched[Concatenate[type[_O], P2], R_co], instance: None, owner: type[_O] + ) -> AsyncBatched[P2, R_co]: ... # under @classmethod: bind the class + @overload + def __get__(self, instance: None, owner: type | None = None) -> AsyncBatched[P, R_co]: ... + @overload + def __get__( + self: AsyncBatched[Concatenate[type[_O], P2], R_co], instance: _O, owner: type | None = None + ) -> AsyncBatched[P2, R_co]: ... + @overload + def __get__( + self: AsyncBatched[Concatenate[_O, P2], R_co], instance: _O, owner: type | None = None + ) -> AsyncBatched[P2, R_co]: ... + @overload + def __get__(self, instance: object, owner: type | None = None) -> AsyncBatched[P, R_co]: ... + + @property + def pending(self) -> int: + """Number of distinct items queued and not yet sent to the batch function.""" + ... + + def aflush(self) -> Coroutine[Any, Any, None]: + """Send every batch queued on the running loop now and wait for them to finish.""" + ... + + +class _BatchDecorator(Protocol): + @overload + def __call__( + self, func: Callable[[list[T]], Awaitable[Sequence[R] | Mapping[Any, R]]], / + ) -> AsyncBatched[[T], R]: ... + @overload + def __call__( + self, func: Callable[[S, list[T]], Awaitable[Sequence[R] | Mapping[Any, R]]], / + ) -> AsyncBatched[[S, T], R]: ... + @overload + def __call__( + self, func: Callable[[list[T]], Sequence[R] | Mapping[Any, R]], / + ) -> Batched[[T], R]: ... + @overload + def __call__( + self, func: Callable[[S, list[T]], Sequence[R] | Mapping[Any, R]], / + ) -> Batched[[S, T], R]: ... + + +# --------------------------------------------------------------------------- shared + + +def _is_mapping(value: object) -> TypeGuard[Mapping[Any, Any]]: + return isinstance(value, Mapping) + + +def _is_sequence(value: object) -> TypeGuard[Sequence[Any]]: + return isinstance(value, Sequence) + + +def _split(name: str, result: object, keys: list[Hashable]) -> list[tuple[bool, Any]]: + """Match a batch result to its keys: ``(True, value)`` or ``(False, BatchError)``.""" + if _is_mapping(result): + mapping = result + return [ + (True, mapping[k]) + if k in mapping + else (False, BatchError(f"{name}: the batch result has no entry for key {k!r}")) + for k in keys + ] + if isinstance(result, (str, bytes, bytearray)) or not _is_sequence(result): + message = ( + f"{name}: the batch function must return a sequence or a mapping, " + f"got {type(result).__name__}" + ) + return [(False, BatchError(message)) for _ in keys] + values = result + if len(values) != len(keys): + message = f"{name}: the batch function returned {len(values)} results for {len(keys)} items" + return [(False, BatchError(message)) for _ in keys] + return [(True, v) for v in values] + + +def _split_args(name: str, args: tuple[Any, ...]) -> tuple[tuple[Any, ...], Any]: + if not args: + raise TypeError(f"{name}() takes one item to load") + return args[:-1], args[-1] + + +class _Counter: + """Thread-safe count of queued items, mirrored onto ``wrapper.pending``.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.value = 0 + self.wrapper: Any = None + + def add(self, n: int) -> None: + with self.lock: + self.value += n + self.wrapper.pending = self.value + + +# --------------------------------------------------------------------------- sync + + +class _SyncEntry: + __slots__ = ("future", "item") + + def __init__(self, item: Any) -> None: + self.item = item + self.future: concurrent.futures.Future[Any] = concurrent.futures.Future() + + +class _SyncBatch: + __slots__ = ("entries", "group", "prefix") + + def __init__(self, group: _GroupKey, prefix: tuple[Any, ...]) -> None: + self.group = group + self.prefix = prefix + self.entries: dict[Hashable, _SyncEntry] = {} + + +def _batch_sync( + func: Callable[..., Any], + max_size: int, + max_wait: float, + key_of: Callable[[Any], Hashable], +) -> Callable[..., Any]: + name = _core.qualname(func) + lock = threading.Lock() + open_batches: dict[_GroupKey, _SyncBatch] = {} + counter = _Counter() + warned = False + + def warn_if_in_event_loop() -> None: + nonlocal warned + if asyncio._get_running_loop() is None: + return + warned = True + warnings.warn( + f"{name} is a sync batch_calls loader called from a running event loop: it " + "blocks the loop while it waits for its batch (up to max_wait) and returns a " + "plain value, not a coroutine, so it cannot be awaited or passed to " + "asyncio.create_task(); decorate an `async def` batch function instead", + RuntimeWarning, + stacklevel=3, + ) + + def detach(batch: _SyncBatch) -> bool: + """Close ``batch`` to new items. Call with ``lock`` held. False if already closed.""" + if open_batches.get(batch.group) is not batch: + return False + del open_batches[batch.group] + counter.add(-len(batch.entries)) + return True + + def run(batch: _SyncBatch) -> None: + keys = list(batch.entries) + entries = list(batch.entries.values()) + try: + result = func(*batch.prefix, [e.item for e in entries]) + outcomes = _split(name, result, keys) + except BaseException as exc: + for e in entries: + e.future.set_exception(exc) + if not isinstance(exc, Exception): + raise + return + for e, (ok, value) in zip(entries, outcomes, strict=True): + if ok: + e.future.set_result(value) + else: + e.future.set_exception(value) + + @functools.wraps(func) + def wrapper(*args: Any) -> Any: + if not warned: + warn_if_in_event_loop() + prefix, item = _split_args(name, args) + k = key_of(item) + group = tuple(map(id, prefix)) + with lock: + batch = open_batches.get(group) + leader = batch is None + if batch is None: + batch = open_batches[group] = _SyncBatch(group, prefix) + entry = batch.entries.get(k) + if entry is None: + entry = batch.entries[k] = _SyncEntry(item) + counter.add(1) + future = entry.future + full = len(batch.entries) >= max_size and detach(batch) + if full: + run(batch) + elif leader: + # The first caller of a window keeps time for it: it waits up to max_wait for + # its result (another thread may fill the batch and run it meanwhile), then + # runs the batch itself. No timer thread is involved. + done, _ = concurrent.futures.wait([future], timeout=max_wait) + if not done: + with lock: + mine = detach(batch) + if mine: + run(batch) + return future.result() + + def flush() -> None: + with lock: + batches = list(open_batches.values()) + for batch in batches: + detach(batch) + for batch in batches: + run(batch) + + counter.wrapper = wrapper + wrapper.pending = 0 # type: ignore[attr-defined] + wrapper.flush = flush # type: ignore[attr-defined] + return wrapper + + +# --------------------------------------------------------------------------- async + + +class _AsyncEntry: + __slots__ = ("future", "item", "waiters") + + def __init__(self, item: Any, loop: asyncio.AbstractEventLoop) -> None: + self.item = item + self.future: asyncio.Future[Any] = loop.create_future() + self.waiters = 0 + + +class _AsyncBatch: + __slots__ = ("entries", "group", "prefix", "timer") + + def __init__(self, group: _GroupKey, prefix: tuple[Any, ...]) -> None: + self.group = group + self.prefix = prefix + self.entries: dict[Hashable, _AsyncEntry] = {} + self.timer: asyncio.Task[None] | None = None + + +class _LoopState: + """Per-event-loop bookkeeping: open batches and batches being run.""" + + def __init__(self) -> None: + self.open: dict[_GroupKey, _AsyncBatch] = {} + self.running: set[asyncio.Future[None]] = set() + + +def _consume(task: asyncio.Task[Any]) -> None: + if not task.cancelled(): + task.exception() + + +def _batch_async( + func: Callable[..., Awaitable[Any]], + max_size: int, + max_wait: float, + key_of: Callable[[Any], Hashable], +) -> Callable[..., Any]: + name = _core.qualname(func) + loops: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _LoopState] = ( + weakref.WeakKeyDictionary() + ) + counter = _Counter() + + async def run(batch: _AsyncBatch) -> None: + keys = list(batch.entries) + entries = list(batch.entries.values()) + try: + result = await func(*batch.prefix, [e.item for e in entries]) + outcomes = _split(name, result, keys) + except asyncio.CancelledError: + for e in entries: + e.future.cancel() + raise + except BaseException as exc: + for e in entries: + e.future.set_exception(exc) + e.future.exception() # mark retrieved: every waiter may be gone + if not isinstance(exc, Exception): + raise + return + # The entries' futures are still pending: callers that cancel only detach. + for e, (ok, value) in zip(entries, outcomes, strict=True): + if ok: + e.future.set_result(value) + else: + e.future.set_exception(value) + e.future.exception() + + def start(state: _LoopState, batch: _AsyncBatch) -> None: + """Close ``batch`` and run it in its own task (shared by all its callers).""" + if state.open.get(batch.group) is not batch: # pragma: no cover - defensive + return + del state.open[batch.group] + counter.add(-len(batch.entries)) + timer, batch.timer = batch.timer, None + if timer is not None and timer is not asyncio.current_task(): + timer.cancel() + task = asyncio.get_running_loop().create_task(run(batch)) + state.running.add(task) + task.add_done_callback(state.running.discard) + task.add_done_callback(_consume) + + async def run_inline(state: _LoopState, batch: _AsyncBatch, entry: _AsyncEntry) -> Any: + """Close ``batch`` and run it in the calling task; return ``entry``'s result.""" + del state.open[batch.group] + counter.add(-len(batch.entries)) + # Stands in for a batch task, so `aflush()` waits for this run too. + running = asyncio.get_running_loop().create_future() + state.running.add(running) + try: + with contextlib.suppress(Exception): # delivered through entry.future + await run(batch) + finally: + state.running.discard(running) + running.set_result(None) + return entry.future.result() + + async def wait_then_start(state: _LoopState, batch: _AsyncBatch) -> None: + await asyncio.sleep(max_wait) # a window, not a backoff: real time + start(state, batch) + + @functools.wraps(func) + async def wrapper(*args: Any) -> Any: + prefix, item = _split_args(name, args) + k = key_of(item) + group = tuple(map(id, prefix)) + loop = asyncio.get_running_loop() + state = loops.get(loop) + if state is None: + state = loops[loop] = _LoopState() + batch = state.open.get(group) + fresh = batch is None + if batch is None: + batch = state.open[group] = _AsyncBatch(group, prefix) + entry = batch.entries.get(k) + if entry is None: + entry = batch.entries[k] = _AsyncEntry(item, loop) + counter.add(1) + entry.waiters += 1 + if len(batch.entries) >= max_size: + if len(batch.entries) == 1 and entry.waiters == 1: + # This caller is the batch's only waiter (e.g. max_size=1): run it right + # here, in the calling task, with no extra task and no loop hops. + # Cancelling this caller cancels only its own batch. + return await run_inline(state, batch, entry) + start(state, batch) + elif fresh: + batch.timer = loop.create_task(wait_then_start(state, batch)) + try: + return await asyncio.shield(entry.future) + except asyncio.CancelledError: + entry.waiters -= 1 + if entry.waiters == 0 and state.open.get(group) is batch: + # Nobody wants this item any more and it has not been sent: drop it. + del batch.entries[k] + counter.add(-1) + entry.future.cancel() + if not batch.entries: + del state.open[group] + if batch.timer is not None: + batch.timer.cancel() + raise + + async def aflush() -> None: + state = loops.get(asyncio.get_running_loop()) + if state is None: + return + for batch in list(state.open.values()): + start(state, batch) + if state.running: + await asyncio.wait(list(state.running)) + + counter.wrapper = wrapper + wrapper.pending = 0 # type: ignore[attr-defined] + wrapper.aflush = aflush # type: ignore[attr-defined] + return wrapper + + +# --------------------------------------------------------------------------- public + + +def batch_calls( + *, + max_size: int = 100, + max_wait: float = 0.01, + key: Callable[[Any], Hashable] | None = None, +) -> _BatchDecorator: + """Coalesce concurrent single-item calls into one call of a batch function. + + Decorate a function that takes a **list of items** and returns their results. The + decorated object is called with **one item** and returns that item's result. Calls + made within `max_wait` seconds of the first call of a window — or until `max_size` + distinct items are queued — are sent to the function together, as one list. + + **Output contract.** The batch function receives the distinct items in arrival order + and must return either: + + * a **sequence** (list, tuple, ...; not `str`/`bytes`) with exactly one result per + item, in the same order; or + * a **mapping** from each item's key to its result. The key is `key(item)` when + `key=` is given, otherwise the item itself. Extra entries are ignored. + + If the sequence has the wrong length, or the return value is neither a sequence nor a + mapping, every caller in the batch gets a `BatchError`. If a mapping lacks a key, only + the callers of that item get a `BatchError`; the others get their results. If the + batch function raises, every caller in the batch gets that exception. + + **Deduplication.** Items with the same key in one window are sent once and their + callers share the result. The key must be hashable. + + * `async def` batch functions: callers on one event loop `await loader(item)`. A + window starts with the first call and closes after `max_wait`; the batch runs in its + own task, so cancelling one caller does not cancel it for the others. A cancelled + caller whose item has not been sent yet removes it from the batch (unless another + caller is waiting for the same key). Batches never span event loops. + * Sync batch functions: callers are threads, each blocking until its result is ready. + The first caller of a window waits up to `max_wait` (or until the batch fills) and + then runs the batch in its own thread; no timer thread is started. If a caller + fills the batch to `max_size`, it runs the batch at once. Calling a sync loader + sequentially from one thread therefore costs up to `max_wait` per call. + A sync loader is **not** awaitable: `await loader(1)` or + `asyncio.create_task(loader(1))` fails (the call returns a plain value, after + blocking the event loop while it waits). Called from a running event loop, it + emits a one-time `RuntimeWarning`; in async code decorate an `async def` batch + function instead. + * When a call fills an async batch to `max_size` and its caller is the batch's only + waiter (always the case with `max_size=1`), the batch runs right in the calling + task, without an extra task or event-loop round trip. + + On a method, the batch function is called as `func(self, items)`, and calls on + different instances (or classes, for a class method) go to separate batches. + + The wrapper also has: + + * `.pending` — the number of distinct items queued and not yet sent. + * `.flush()` (sync) — send every queued batch now, in the calling thread. + * `await .aflush()` (async) — send the batches queued on the running loop now and + wait until every batch running on that loop has finished. + + Args: + max_size: Send a batch as soon as it holds this many distinct items (>= 1). + max_wait: Seconds to wait for more items after the first call of a window (>= 0). + key: Function of an item returning the hashable key used for deduplication and + for looking results up in a mapping. Defaults to the item itself. + + Returns: + A decorator producing a `Batched` or `AsyncBatched` wrapper. + + Raises: + ValueError: If `max_size` < 1 or `max_wait` < 0. + TypeError: When called, if the item's key is not hashable. + BatchError: When called, if the batch function broke the output contract. + + Example: + >>> @batch_calls(max_size=50, max_wait=0.005) + ... async def load_users(ids: list[int]) -> dict[int, str]: + ... return {i: f"user{i}" for i in ids} + >>> # await load_users(1), from many tasks at once -> one call with [1, ...] + """ + if type(max_size) is not int or max_size < 1: + raise ValueError(f"batch_calls max_size must be an integer >= 1, got {max_size!r}") + if max_wait < 0: + raise ValueError(f"batch_calls max_wait must be non-negative, got {max_wait!r}") + user_key = key + + def key_of(item: Any) -> Hashable: + k = item if user_key is None else user_key(item) + try: + hash(k) + except TypeError: + raise TypeError( + f"batch_calls: key of type {type(k).__name__!r} is not hashable; " + "pass key= returning a hashable key" + ) from None + return cast(Hashable, k) + + def decorate(func: Callable[..., Any]) -> Callable[..., Any]: + if _core.is_async(func): + return _batch_async(func, max_size, max_wait, key_of) + return _batch_sync(func, max_size, max_wait, key_of) + + return cast(_BatchDecorator, decorate) diff --git a/src/python_wrap/bulkhead.py b/src/python_wrap/bulkhead.py new file mode 100644 index 0000000..e18f878 --- /dev/null +++ b/src/python_wrap/bulkhead.py @@ -0,0 +1,428 @@ +"""Bulkheads: cap how many calls run at once, and how many may queue for a slot. + +* `Bulkhead` — a reusable limit, used as a decorator, `with bulkhead:` or + `async with bulkhead:`. +* `limit_concurrency` — decorator giving each function its own `Bulkhead`. + +A bulkhead protects something with limited capacity (a database pool, a slow API, a +CPU-heavy section) and fails fast with `BulkheadFull` instead of letting an unbounded +queue build up. +""" + +from __future__ import annotations + +import asyncio +import functools +import threading +import weakref +from collections.abc import Callable +from types import TracebackType +from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, cast, overload + +from python_wrap import _core +from python_wrap._core import F, P, R +from python_wrap.aio import _cancel_and_settle +from python_wrap.errors import BulkheadFull + +__all__ = ["Bulkhead", "Limited", "limit_concurrency"] + +R_co = TypeVar("R_co", covariant=True) +T = TypeVar("T") +P2 = ParamSpec("P2") + + +class _Pool: + """Counters for one semaphore (the threads' one, or one event loop's).""" + + __slots__ = ("active", "waiting") + + def __init__(self) -> None: + self.active = 0 + self.waiting = 0 + + +class _AsyncPool(_Pool): + __slots__ = ("semaphore",) + + def __init__(self, size: int) -> None: + super().__init__() + self.semaphore = asyncio.Semaphore(size) + + +async def _acquire_within(semaphore: asyncio.Semaphore, seconds: float) -> bool: + """Acquire ``semaphore`` unless ``seconds`` pass first (real time). + + The limit is a deadline, not a backoff wait, so it uses `asyncio.sleep` directly: + `fast_mode()` and `fake_clock()` do not shorten it, matching the sync side. + + Never leaks a permit: if the acquisition wins at the last moment while we time out or + are cancelled, the permit is either kept (timeout) or released (cancellation). + """ + acquire = asyncio.ensure_future(semaphore.acquire()) + timer = asyncio.ensure_future(asyncio.sleep(seconds)) + try: + await asyncio.wait((acquire, timer), return_when=asyncio.FIRST_COMPLETED) + except BaseException: + await _cancel_and_settle((acquire, timer)) + if _won(acquire): + semaphore.release() + raise + if not acquire.done(): + acquire.cancel() + await _cancel_and_settle((timer, acquire)) + return _won(acquire) + + +def _won(acquire: asyncio.Future[Any]) -> bool: + return acquire.done() and not acquire.cancelled() and acquire.exception() is None + + +class Bulkhead: + """Allow at most `max_concurrent` calls at once; reject when too many are waiting. + + Use one instance to guard several functions or code blocks together: + + * as a decorator, on sync or `async def` functions (and methods); + * as `with bulkhead:` in threaded code; + * as `async with bulkhead:` in async code. + + Sync callers share a `threading.BoundedSemaphore`. Async callers use an + `asyncio.Semaphore` per event loop (asyncio primitives cannot be shared across + loops). So the limit applies separately to threads and to each running loop; use + one bulkhead per side if you mix them. Never use `with bulkhead:` inside a coroutine: + waiting for a slot would block the event loop. + + A caller that finds no free slot queues for one, unless `max_waiting` callers are + already queued (then it gets `BulkheadFull` immediately). A queued caller gives up + with `BulkheadFull` after `timeout` seconds. Each rejection emits the + `bulkhead.rejected` event with the `active` and `waiting` counts at that moment. + + Args: + max_concurrent: Maximum number of calls running at once (>= 1). + max_waiting: Maximum number of callers queued for a slot; `None` means no + limit, `0` means never wait (fail fast when full). + timeout: Maximum seconds to wait for a slot; `None` waits forever. + name: Label used in error messages and `repr`. + + Raises: + ValueError: An argument is out of range. + + Example: + ```python + import asyncio + from python_wrap.bulkhead import Bulkhead + from python_wrap.errors import BulkheadFull + + db = Bulkhead(2, max_waiting=0, name="db") + + @db + async def query(n: int) -> int: + await asyncio.sleep(0.01) + return n + + async def main() -> None: + results = await asyncio.gather(*(query(n) for n in range(3)), + return_exceptions=True) + assert results[:2] == [0, 1] + assert isinstance(results[2], BulkheadFull) # two running, none may wait + + asyncio.run(main()) + ``` + """ + + def __init__( + self, + max_concurrent: int, + *, + max_waiting: int | None = None, + timeout: float | None = None, + name: str | None = None, + ) -> None: + if isinstance(max_concurrent, bool) or not isinstance(max_concurrent, int): + raise ValueError(f"max_concurrent must be an integer, got {max_concurrent!r}") + if max_concurrent < 1: + raise ValueError(f"max_concurrent must be >= 1, got {max_concurrent}") + if max_waiting is not None and max_waiting < 0: + raise ValueError(f"max_waiting must be >= 0 or None, got {max_waiting}") + if timeout is not None and timeout < 0: + raise ValueError(f"timeout must be >= 0 or None, got {timeout}") + self.max_concurrent = max_concurrent + self.max_waiting = max_waiting + self.timeout = timeout + self.name = name + self._lock = threading.Lock() + self._thread_semaphore = threading.BoundedSemaphore(max_concurrent) + self._thread_pool = _Pool() + self._loop_pools: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _AsyncPool] = ( + weakref.WeakKeyDictionary() + ) + + # -- introspection ------------------------------------------------------------------ + + def _pools(self) -> list[_Pool]: + return [self._thread_pool, *self._loop_pools.values()] + + @property + def active(self) -> int: + """Number of calls currently holding a slot (threads and every loop together).""" + with self._lock: + return sum(pool.active for pool in self._pools()) + + @property + def waiting(self) -> int: + """Number of callers currently queued for a slot (threads and every loop).""" + with self._lock: + return sum(pool.waiting for pool in self._pools()) + + def __repr__(self) -> str: + return ( + f"Bulkhead(name={self.name!r}, max_concurrent={self.max_concurrent}, " + f"active={self.active}, waiting={self.waiting})" + ) + + # -- bookkeeping -------------------------------------------------------------------- + + def _full(self, why: str, active: int, waiting: int) -> BulkheadFull: + """Build the rejection and emit ``bulkhead.rejected`` (call without the lock).""" + _core.emit("bulkhead.rejected", self.name or "bulkhead", active=active, waiting=waiting) + label = f"bulkhead {self.name!r}" if self.name else "bulkhead" + return BulkheadFull( + f"{label} is full: {why}", name=self.name, max_concurrent=self.max_concurrent + ) + + def _start_waiting(self, pool: _Pool) -> None: + with self._lock: + active, waiting = pool.active, pool.waiting + full = self.max_waiting is not None and waiting >= self.max_waiting + if not full: + pool.waiting += 1 + if full: + raise self._full( + f"{self.max_concurrent} calls running and " + f"{waiting} waiting (max_waiting={self.max_waiting})", + active, + waiting, + ) + + def _stop_waiting(self, pool: _Pool) -> None: + with self._lock: + pool.waiting -= 1 + + def _enter(self, pool: _Pool) -> None: + with self._lock: + pool.active += 1 + + def _exit(self, pool: _Pool) -> None: + with self._lock: + pool.active -= 1 + + def _timed_out(self, pool: _Pool) -> BulkheadFull: + with self._lock: + active, waiting = pool.active, pool.waiting + return self._full(f"no slot freed up within {self.timeout}s", active, waiting) + + # -- sync --------------------------------------------------------------------------- + + def acquire(self) -> None: + """Take a slot, blocking the thread while waiting. Prefer `with bulkhead:`. + + Raises: + BulkheadFull: The queue is full, or `timeout` expired. + """ + pool = self._thread_pool + semaphore = self._thread_semaphore + if not semaphore.acquire(blocking=False): + self._start_waiting(pool) + try: + if self.timeout is None: + got = semaphore.acquire() + else: + got = semaphore.acquire(timeout=self.timeout) + finally: + self._stop_waiting(pool) + if not got: + raise self._timed_out(pool) + self._enter(pool) + + def release(self) -> None: + """Give back a slot taken with `acquire`.""" + # Decrement `active` before freeing the permit, so a caller that takes the slot + # right away never sees more than `max_concurrent` active. + pool = self._thread_pool + self._exit(pool) + try: + self._thread_semaphore.release() # ValueError if nothing was acquired + except ValueError: + self._enter(pool) + raise + + def __enter__(self) -> Bulkhead: + self.acquire() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.release() + + # -- async -------------------------------------------------------------------------- + + def _loop_pool(self) -> _AsyncPool: + loop = asyncio.get_running_loop() + with self._lock: + pool = self._loop_pools.get(loop) + if pool is None: + pool = self._loop_pools[loop] = _AsyncPool(self.max_concurrent) + return pool + + async def acquire_async(self) -> None: + """Take a slot in the running event loop. Prefer `async with bulkhead:`. + + Raises: + BulkheadFull: The queue is full, or `timeout` expired. + """ + pool = self._loop_pool() + semaphore = pool.semaphore + if semaphore.locked(): + self._start_waiting(pool) + try: + if self.timeout is None: + await semaphore.acquire() + elif not await _acquire_within(semaphore, self.timeout): + raise self._timed_out(pool) + finally: + self._stop_waiting(pool) + else: + await semaphore.acquire() # a slot is free: returns without suspending + self._enter(pool) + + def release_async(self) -> None: + """Give back a slot taken with `acquire_async` (from the same event loop).""" + pool = self._loop_pool() + self._exit(pool) + pool.semaphore.release() + + async def __aenter__(self) -> Bulkhead: + await self.acquire_async() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.release_async() + + # -- decorator ---------------------------------------------------------------------- + + def __call__(self, func: F, /) -> F: + """Guard every call of `func` with this bulkhead (sync or `async def`).""" + if _core.is_async(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async with self: + return await func(*args, **kwargs) + + return cast(F, async_wrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with self: + return func(*args, **kwargs) + + return cast(F, wrapper) + + +class Limited(Protocol[P, R_co]): + """A function wrapped by `limit_concurrency`; `.bulkhead` is its own `Bulkhead`. + + Binds `self` and `cls` (including under a `@classmethod` stacked above) like + `python_wrap.caching.Memoized`, with the same typing limitations. + """ + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + @overload + def __get__( + self: Limited[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _LimitedOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> Limited[P, R_co]: ... + @overload + def __get__( + self: Limited[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None + ) -> Limited[P2, R_co]: ... + + @property + def bulkhead(self) -> Bulkhead: + """The function's own `Bulkhead` (inspect `.active` and `.waiting`).""" + ... + + +class _LimitedOnClass(Limited[P, R_co], Protocol[P, P2, R_co]): + """`Limited` read from a class: accepts the call with and without the first argument. + + Returned for class access (`Cls.attr`) when the first parameter could be `cls`. + pyright only picks it under `@classmethod`; mypy cannot tell a classmethod from an + instance or static method there, so both call forms are accepted. + """ + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + +def limit_concurrency(n: int, /) -> Callable[[Callable[P, R]], Limited[P, R]]: + """Let at most `n` calls of the decorated function run at once; the rest wait. + + Each decorated function gets its own `Bulkhead(n)` (no queue limit, no timeout), + exposed as `wrapped.bulkhead` for introspection (`.active`, `.waiting`). For a limit + shared by several functions, or for `max_waiting`/`timeout`, use a `Bulkhead` + directly. Works on sync and `async def` functions and on methods (the limit is then + shared by every instance). + + Args: + n: Maximum number of concurrent calls (>= 1). + + Returns: + A decorator. + + Raises: + ValueError: `n` is not a positive integer. + + Example: + ```python + import asyncio + from python_wrap.bulkhead import limit_concurrency + + peak = 0 + + @limit_concurrency(2) + async def crawl(page: int) -> int: + global peak + peak = max(peak, crawl.bulkhead.active) + await asyncio.sleep(0) + return page + + async def main() -> None: + assert await asyncio.gather(*(crawl(p) for p in range(6))) == list(range(6)) + assert peak == 2 + + asyncio.run(main()) + ``` + """ + Bulkhead(n) # validate eagerly, at decoration time + + def decorate(func: Callable[P, R]) -> Limited[P, R]: + bulkhead = Bulkhead(n, name=_core.qualname(func)) + wrapped = cast(Any, bulkhead(func)) + wrapped.bulkhead = bulkhead + return cast(Limited[P, R], wrapped) + + return decorate diff --git a/src/python_wrap/caching.py b/src/python_wrap/caching.py new file mode 100644 index 0000000..54fc181 --- /dev/null +++ b/src/python_wrap/caching.py @@ -0,0 +1,1176 @@ +"""Caching decorators: `memoize`, `cache`, `once` and `cached_property`.""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import datetime +import functools +import inspect +import threading +import weakref +from collections import OrderedDict +from collections.abc import Awaitable, Callable, Coroutine, Hashable +from typing import Any, Concatenate, Generic, NamedTuple, ParamSpec, Protocol, TypeVar, overload + +from python_wrap import _core + +__all__ = [ + "CacheInfo", + "CachedProperty", + "Memoized", + "Once", + "cache", + "cached_property", + "invalidate", + "memoize", + "once", +] + +P = ParamSpec("P") +R = TypeVar("R") +R_co = TypeVar("R_co", covariant=True) +T = TypeVar("T") +P2 = ParamSpec("P2") + +_MISSING: Any = object() + + +class CacheInfo(NamedTuple): + """Statistics returned by ``wrapped.cache_info()``.""" + + hits: int + misses: int + maxsize: int | None + currsize: int + + +class Memoized(Protocol[P, R_co]): + """A function wrapped by `memoize` (callable plus cache controls). + + Use it to annotate decorated functions, e.g. ``Memoized[[int], str]``. The + ``__get__`` overloads let type checkers bind ``self`` on instance access + (``obj.method(x)``) and ``cls`` under a ``@classmethod`` stacked above + (``Cls.method(x)``). + + Typing limitations (runtime behaviour is unaffected): + + * Read from the class (``Cls.method``), the wrapper accepts the call both with and + without its first argument, because mypy cannot tell a classmethod from an + instance or static method there. So ``Cls.method(x)`` on an instance method is + not flagged by mypy (pyright flags it), and ``Cls.cm(Cls, x)`` is accepted by both. + * Under a ``@staticmethod`` stacked above, ``Cls.method(x)`` type-checks but + ``obj.method(x)`` is reported as an error by both mypy and pyright. + """ + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + @overload + def __get__( + self: Memoized[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _MemoizedOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> Memoized[P, R_co]: ... + @overload + def __get__( + self: Memoized[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None + ) -> Memoized[P2, R_co]: ... + + def cache_clear(self) -> None: + """Drop every cached entry and reset the statistics.""" + + def cache_info(self) -> CacheInfo: + """Return hits, misses, maxsize and the current number of live entries.""" + + # The three lookups below take the full argument list of the decorated function. + # On a method that includes the instance (``obj.method.cache_get(obj, x)``): + # ``obj.method`` is a plain bound method, so its attributes are not bound. + + def cache_delete(self, *args: Any, **kwargs: Any) -> bool: + """Drop the entry for these arguments. Returns True if one existed.""" + + def cache_contains(self, *args: Any, **kwargs: Any) -> bool: + """True if a live entry exists for these arguments (nothing is computed or counted).""" + + def cache_get(self, *args: Any, **kwargs: Any) -> Any: + """Return the cached result for these arguments without computing it. + + Raises ``KeyError`` when there is no live entry. A cached exception is raised. + For ``async def`` functions this is the awaited result, not a coroutine. + """ + + def cache_parameters(self) -> dict[str, Any]: + """The options: ``maxsize``, ``ttl``, ``typed``, ``cache_exceptions``, ``exception_ttl``.""" + + +class _MemoizedOnClass(Memoized[P, R_co], Protocol[P, P2, R_co]): + """`Memoized` read from a class: accepts the call with and without the first argument. + + Returned for class access (``Cls.attr``) when the first parameter could be ``cls``. + pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an + instance or static method there, so both call forms are accepted. + """ + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + +class Once(Protocol[P, R_co]): + """A function wrapped by `once` (callable plus ``called`` and ``reset()``). + + Binds ``self`` and ``cls`` like `Memoized`, with the same typing limitations. + """ + + called: bool + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + @overload + def __get__( + self: Once[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _OnceOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> Once[P, R_co]: ... + @overload + def __get__( + self: Once[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None + ) -> Once[P2, R_co]: ... + + def reset(self) -> None: + """Forget the stored result so the next call runs the function again.""" + + +class _OnceOnClass(Once[P, R_co], Protocol[P, P2, R_co]): + """`Once` read from a class: accepts the call with and without the first argument. + + Returned for class access (``Cls.attr``) when the first parameter could be ``cls``. + pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an + instance or static method there, so both call forms are accepted. + """ + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + +# --------------------------------------------------------------------------- keys + + +class _Tag: + """Marker that keeps ``[1]`` and ``(1,)`` (or ``{1}``) from sharing a key.""" + + __slots__ = ("name",) + + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return f"<{self.name}>" + + +_LIST, _DICT, _SET = _Tag("list"), _Tag("dict"), _Tag("set") +_KWARGS = _Tag("kwargs") # separates keyword arguments in keys built without a signature + + +def _freeze(value: Any) -> Hashable: + """Make ``value`` hashable: builtin containers are converted recursively.""" + try: + hash(value) + except TypeError: + pass + else: + return value # type: ignore[no-any-return] + if isinstance(value, list): + return (_LIST, tuple(_freeze(v) for v in value)) + if isinstance(value, tuple): + return tuple(_freeze(v) for v in value) + if isinstance(value, dict): + return (_DICT, frozenset((_freeze(k), _freeze(v)) for k, v in value.items())) + if isinstance(value, (set, frozenset)): + return (_SET, frozenset(_freeze(v) for v in value)) + raise _Unhashable(type(value).__name__) + + +class _Unhashable(TypeError): + """Raised by `_freeze`; carries the offending type name.""" + + def __init__(self, type_name: str) -> None: + super().__init__(type_name) + self.type_name = type_name + + +def _freeze_arg(name: str, value: Any) -> Hashable: + try: + return _freeze(value) + except _Unhashable as exc: + raise TypeError( + f"memoize: argument {name!r} (of type {exc.type_name!r}) is not hashable; " + "pass key= to build the cache key yourself (for methods, a key " + "that ignores or replaces `self`), or use functools.cached_property" + ) from None + + +def _make_key_builder( + func: Callable[..., Any], typed: bool, key: Callable[..., Hashable] | None +) -> Callable[[tuple[Any, ...], dict[str, Any]], Hashable]: + if key is not None: + user_key = key + + def custom(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable: + return user_key(*args, **kwargs) + + return custom + + try: + sig: inspect.Signature | None = inspect.signature(func) + except (TypeError, ValueError): + sig = None + + def build(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable: + if sig is not None: + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + named = list(bound.arguments.items()) + frozen = tuple(_freeze_arg(n, v) for n, v in named) + else: + named = [(f"#{i}", v) for i, v in enumerate(args)] + frozen = tuple(_freeze_arg(n, v) for n, v in named) + if kwargs: + # Keep the names (and a marker), or f(1, b=2) would equal f(1, 2). + items = sorted(kwargs.items()) + named += items + frozen += (_KWARGS, *((n, _freeze_arg(n, v)) for n, v in items)) + values = [v for _, v in named] + if typed: + return (frozen, tuple(type(v) for v in values)) + return frozen + + return build + + +# ------------------------------------------------------------------------ memoize + + +def _retrieve(task: asyncio.Future[Any]) -> None: + """Mark a shared task's exception as retrieved (all callers may have been cancelled).""" + if not task.cancelled(): + task.exception() + + +async def _await_shared(task: asyncio.Future[Any]) -> Any: + """Await a shared task without letting one caller's cancellation cancel it. + + Unlike ``asyncio.shield`` this never logs "exception in shielded future" when every + caller was cancelled before a failing task finished. + """ + outer: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + + def forward(t: asyncio.Future[Any]) -> None: + if outer.done(): + if not t.cancelled(): + t.exception() + elif t.cancelled(): + outer.cancel() + elif (exc := t.exception()) is not None: + outer.set_exception(exc) + else: + outer.set_result(t.result()) + + task.add_done_callback(forward) + return await outer + + +def _shared_future() -> asyncio.Future[Any]: + """A future of the running loop that in-flight callers share (created under a lock).""" + fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + fut.add_done_callback(_retrieve) + return fut + + +def _copy_outcome(source: asyncio.Future[Any], dest: asyncio.Future[Any]) -> None: + if dest.done(): + _retrieve(source) + elif source.cancelled(): + dest.cancel() + elif (exc := source.exception()) is not None: + dest.set_exception(exc) + else: + dest.set_result(source.result()) + + +def _start( + coro: Coroutine[Any, Any, Any], + shared: asyncio.Future[Any], + on_done: Callable[[asyncio.Future[Any] | None], None], +) -> None: + """Run ``coro`` as a task and copy its outcome into ``shared``. + + Call it WITHOUT holding a lock: under an eager task factory (Python 3.12+) the + coroutine may run to completion inside ``ensure_future``. The caller registers + ``shared`` in its in-flight bookkeeping (under its lock) before calling this, so the + bookkeeping is correct whether the task starts lazily or eagerly. ``on_done`` gets + the finished task (or None if no task could be created) and must drop that entry. + """ + try: + task = asyncio.ensure_future(coro) + except BaseException as exc: + coro.close() + on_done(None) + if not shared.done(): + shared.set_exception(exc) + raise + + def finish(t: asyncio.Future[Any]) -> None: + try: + on_done(t) + finally: + _copy_outcome(t, shared) + + task.add_done_callback(finish) + + +def _ttl_seconds(ttl: float | datetime.timedelta | None) -> float | None: + if ttl is None: + return None + seconds = ttl.total_seconds() if isinstance(ttl, datetime.timedelta) else float(ttl) + if seconds <= 0: + raise ValueError("ttl must be positive") + return seconds + + +def _exception_types( + value: type[BaseException] | tuple[type[BaseException], ...], +) -> tuple[type[BaseException], ...]: + types = value if isinstance(value, tuple) else (value,) + for t in types: + if not (isinstance(t, type) and issubclass(t, BaseException)): + raise TypeError("cache_exceptions must be exception classes") + return types + + +def _fast_arity(func: Callable[..., Any], typed: bool, key: Callable[..., Hashable] | None) -> int: + """Number of parameters if ``func`` takes only plain positional ones, else -1. + + For such functions a call that passes every argument positionally can use the + ``args`` tuple itself as the key (when hashable), skipping signature binding. + """ + if typed or key is not None: + return -1 + try: + params = inspect.signature(func).parameters.values() + except (TypeError, ValueError): + return -1 + plain = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + if any(p.kind not in plain for p in params): + return -1 + return len(params) + + +# lookup() returns a stored entry ``(value, expires, is_exception)``, ``None`` (absent) or +# this falsy marker (the entry had expired and was just removed). +_EXPIRED: Any = () + + +@overload +def memoize(func: Callable[P, R], /) -> Memoized[P, R]: ... +@overload +def memoize( + func: None = None, + /, + *, + maxsize: int | None = None, + ttl: float | datetime.timedelta | Callable[[Any], float | None] | None = None, + typed: bool = False, + key: Callable[..., Hashable] | None = None, + cache_exceptions: type[BaseException] | tuple[type[BaseException], ...] = (), + exception_ttl: float | datetime.timedelta | None = None, +) -> Callable[[Callable[P, R]], Memoized[P, R]]: ... +def memoize( + func: Callable[..., Any] | None = None, + /, + *, + maxsize: int | None = None, + ttl: float | datetime.timedelta | Callable[[Any], float | None] | None = None, + typed: bool = False, + key: Callable[..., Hashable] | None = None, + cache_exceptions: type[BaseException] | tuple[type[BaseException], ...] = (), + exception_ttl: float | datetime.timedelta | None = None, +) -> Any: + """Cache a function's results per argument values. + + Works bare (``@memoize``) or with options. Arguments are bound to the function + signature, so ``f(1)``, ``f(x=1)`` and ``f(1, y=)`` share one entry. + Arguments must be hashable; lists, dicts and sets (also nested) are converted + automatically. Anything else unhashable raises ``TypeError`` -- pass ``key=``. + + Exceptions are not cached unless listed in ``cache_exceptions``; a matching exception + is stored and re-raised on every hit as the very same instance (so its traceback + keeps growing; raise a fresh exception from a ``except`` clause if that matters). + The cache is guarded by a lock. For sync functions two threads that miss on the same + key at the same time may both compute the value (the function itself is never called + under the lock); the last result stored wins. For ``async def`` functions the awaited + result is cached and concurrent awaits of the same missing key share a single + in-flight task per event loop, so it runs once. + + A method of a class whose instances are unhashable (for example an ``eq=True`` + dataclass) fails with a ``TypeError`` naming ``self``: ``id(self)`` is deliberately not + used as a fallback because ids are reused after garbage collection. Pass ``key=`` or + use `cached_property` instead. + + Methods: ``self`` is part of the key, so results are per instance. The cache holds a + strong reference to every instance it has seen, keeping it alive; use ``maxsize`` or + ``ttl`` (or ``key=`` ignoring ``self``) on methods of many short-lived objects. + + The wrapper has ``cache_clear()``, ``cache_info()`` (a `CacheInfo`), + ``cache_delete(*args, **kwargs)``, ``cache_contains(*args, **kwargs)``, + ``cache_get(*args, **kwargs)`` and ``cache_parameters()``. ``cache_contains`` and + ``cache_get`` look an entry up without computing, counting a hit or miss, or + refreshing its LRU position. All three take the decorated function's full argument + list, so on a method pass the instance too: ``obj.method.cache_get(obj, x)``. + + While ``python_wrap.testing.disable_caching()`` is active the cache is neither read + nor written. Listeners registered with `python_wrap.events` receive ``cache.hit``, + ``cache.miss`` and ``cache.evicted`` (field ``reason``: ``"lru"`` or ``"ttl"``). + + Args: + func: The function (when used bare). + maxsize: Maximum entries; least recently used are evicted. None is unbounded. + ttl: Seconds (or a ``timedelta``) an entry stays valid. None never expires. May + also be a callable ``ttl(result) -> float | None`` evaluated when a result + is stored (None: never expires; zero or negative: not stored). + typed: If True, ``f(1)`` and ``f(1.0)`` are cached separately. + key: Custom ``key(*args, **kwargs)`` returning a hashable cache key. + cache_exceptions: Exception class (or tuple of classes) whose instances are + cached and re-raised like results. ``asyncio.CancelledError`` never is. + exception_ttl: Seconds a cached exception stays valid. Defaults to ``ttl``; it + must be given when ``ttl`` is a callable and ``cache_exceptions`` is set. + + Returns: + The wrapped function, or a decorator when called with options. + + Raises: + ValueError: If ``maxsize`` is below 1 or a ttl is not positive. + TypeError: For a non-exception in ``cache_exceptions``; at call time, for an + unhashable argument (see above). + + Example: + >>> @memoize(maxsize=128, ttl=60) + ... def square(n): + ... return n * n + >>> square(4), square(n=4) + (16, 16) + >>> square.cache_info().hits + 1 + """ + if maxsize is not None and maxsize < 1: + raise ValueError("maxsize must be a positive integer or None") + ttl_fn: Callable[[Any], float | None] | None = None + ttl_s: float | None = None + if callable(ttl): + ttl_fn = ttl + else: + ttl_s = _ttl_seconds(ttl) + exc_types = _exception_types(cache_exceptions) + exc_ttl_s = _ttl_seconds(exception_ttl) if exception_ttl is not None else ttl_s + if exc_types and ttl_fn is not None and exception_ttl is None: + raise ValueError("exception_ttl is required when ttl is a callable") + lru = maxsize is not None + ov = _core.overrides + + def decorate(f: Callable[..., Any]) -> Any: + build_key = _make_key_builder(f, typed, key) + fast_n = _fast_arity(f, typed, key) + name = _core.qualname(f) + store: OrderedDict[Hashable, tuple[Any, float | None, bool]] = OrderedDict() + # In-flight tasks are per event loop: a task cannot be awaited from another loop. + inflight: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, dict[Hashable, asyncio.Future[Any]] + ] = weakref.WeakKeyDictionary() + store_get = store.get + lock = threading.Lock() + hits = misses = gen = inserts = live = 0 + + def key_of(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable: + if len(args) == fast_n and not kwargs: + try: + hash(args) + except TypeError: + pass + else: + return args + return build_key(args, kwargs) + + def sweep() -> int: + # Caller holds the lock. Drop every expired entry; returns how many. + now = _core.monotonic() + dead = [k for k, (_, exp, _e) in store.items() if exp is not None and now >= exp] + for k in dead: + del store[k] + return len(dead) + + def lookup(k: Hashable) -> Any: + # Caller holds the lock. + entry = store.get(k) + if entry is None: + return None + expires = entry[1] + if expires is not None and _core.monotonic() >= expires: + del store[k] + return _EXPIRED + if lru: + store.move_to_end(k) + return entry + + def peek(k: Hashable) -> tuple[Any, float | None, bool] | None: + with lock: + entry = store.get(k) + if entry is None or (entry[1] is not None and _core.monotonic() >= entry[1]): + return None + return entry + + def report_expired() -> None: + _core.emit("cache.evicted", name, reason="ttl") + + def save(k: Hashable, value: Any, g: int, is_exc: bool = False) -> None: + nonlocal inserts, live + if is_exc: + seconds = exc_ttl_s + elif ttl_fn is not None: + seconds = ttl_fn(value) + if seconds is not None and seconds <= 0: + return # not worth storing + else: + seconds = ttl_s + ttl_evicted = lru_evicted = 0 + with lock: + if g != gen: + return # cache_clear() ran while computing + expires = None if seconds is None else _core.monotonic() + seconds + store[k] = (value, expires, is_exc) + if lru: + store.move_to_end(k) + if ttl_s is not None or ttl_fn is not None or exc_ttl_s is not None: + # Amortised O(1): a full sweep only after at least + # max(64, entries left by the previous sweep) inserts. + inserts += 1 + if inserts >= max(64, live): + ttl_evicted = sweep() + inserts, live = 0, len(store) + if maxsize is not None: + while len(store) > maxsize: + store.popitem(last=False) + lru_evicted += 1 + if _core.listeners: + for _ in range(ttl_evicted): + report_expired() + for _ in range(lru_evicted): + _core.emit("cache.evicted", name, reason="lru") + + def cache_clear() -> None: + nonlocal hits, misses, inserts, live, gen + with lock: + store.clear() + inflight.clear() + hits = misses = inserts = live = 0 + gen += 1 + + def cache_info() -> CacheInfo: + with lock: + expired = sweep() + info = CacheInfo(hits, misses, maxsize, len(store)) + if _core.listeners: + for _ in range(expired): + report_expired() + return info + + def cache_delete(*args: Any, **kwargs: Any) -> bool: + k = key_of(args, kwargs) + with lock: + return store.pop(k, _MISSING) is not _MISSING + + def cache_contains(*args: Any, **kwargs: Any) -> bool: + return peek(key_of(args, kwargs)) is not None + + def cache_get(*args: Any, **kwargs: Any) -> Any: + entry = peek(key_of(args, kwargs)) + if entry is None: + raise KeyError("no cached entry for these arguments") + if entry[2]: + raise entry[0] + return entry[0] + + def cache_parameters() -> dict[str, Any]: + return { + "maxsize": maxsize, + "ttl": ttl_fn if ttl_fn is not None else ttl_s, + "typed": typed, + "cache_exceptions": exc_types, + "exception_ttl": exc_ttl_s, + } + + if _core.is_async(f): + + @functools.wraps(f) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + nonlocal hits, misses + if ov.disable_caching: + return await f(*args, **kwargs) + k = args if len(args) == fast_n and not kwargs else build_key(args, kwargs) + try: + with lock: + entry = lookup(k) + except TypeError: + if k is not args: + raise + k = build_key(args, kwargs) + with lock: + entry = lookup(k) + if entry: + with lock: + hits += 1 + if _core.listeners: + _core.emit("cache.hit", name) + if entry[2]: + raise entry[0] + return entry[0] + if _core.listeners and entry is not None: + report_expired() + with lock: + loop = asyncio.get_running_loop() + loop_inflight = inflight.setdefault(loop, {}) + shared_fut = loop_inflight.get(k) + if shared_fut is None: + misses += 1 + g = gen + shared = False + shared_fut = loop_inflight[k] = _shared_future() + else: + hits += 1 + shared = True + if not shared: + + async def run() -> Any: + try: + result = await f(*args, **kwargs) + except exc_types as exc: + if not isinstance(exc, asyncio.CancelledError): + save(k, exc, g, True) + raise + save(k, result, g) + return result + + def done(_t: asyncio.Future[Any] | None, k: Hashable = k) -> None: + with lock: + if loop_inflight.get(k) is shared_fut: + del loop_inflight[k] + + _start(run(), shared_fut, done) + if _core.listeners: + _core.emit("cache.hit" if shared else "cache.miss", name) + # one cancelled caller must not cancel the shared computation + return await _await_shared(shared_fut) + + wrapper: Any = awrapper + else: + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + nonlocal hits, misses + if ov.disable_caching: + return f(*args, **kwargs) + k = args if len(args) == fast_n and not kwargs else build_key(args, kwargs) + try: + with lock: + entry = store_get(k) + if entry is not None and entry[1] is None: + # hot path: a live entry that never expires + if lru: + store.move_to_end(k) + hits += 1 + else: + entry = lookup(k) + if entry: + hits += 1 + else: + misses += 1 + g = gen + except TypeError: + if k is not args: + raise + k = build_key(args, kwargs) + with lock: + entry = lookup(k) + if entry: + hits += 1 + else: + misses += 1 + g = gen + if entry: + if _core.listeners: + _core.emit("cache.hit", name) + if entry[2]: + raise entry[0] + return entry[0] + if _core.listeners: + if entry is not None: + report_expired() + _core.emit("cache.miss", name) + try: + result = f(*args, **kwargs) + except exc_types as exc: + save(k, exc, g, True) + raise + save(k, result, g) + return result + + wrapper.cache_clear = cache_clear + wrapper.cache_info = cache_info + wrapper.cache_delete = cache_delete + wrapper.cache_contains = cache_contains + wrapper.cache_get = cache_get + wrapper.cache_parameters = cache_parameters + return wrapper + + return decorate if func is None else decorate(func) + + +def cache( + duration: datetime.timedelta | float, *, maxsize: int | None = None +) -> Callable[[Callable[P, R]], Memoized[P, R]]: + """Cache results for ``duration``; an alias of ``memoize(ttl=duration, maxsize=maxsize)``. + + Kept for v0.1 compatibility. Prefer ``memoize(ttl=...)`` in new code; note this name + also clashes with `functools.cache`. Every decorated function gets its own + store, even when one ``cache(...)`` result is applied to several functions. + + Args: + duration: Seconds (or a ``timedelta``) an entry stays valid. + maxsize: Maximum entries (LRU). None is unbounded. + + Returns: + A decorator. + + Example: + >>> @cache(datetime.timedelta(minutes=5)) + ... def load(): ... + """ + + def decorator(func: Callable[P, R]) -> Memoized[P, R]: + return memoize(ttl=duration, maxsize=maxsize)(func) + + return decorator + + +# --------------------------------------------------------------------------- once + +_RETRY: Any = _Tag("retry") + + +class _Flight: + """An async `once` run in progress, shared by every thread and event loop. + + ``shared`` belongs to the owning loop (callers on that loop await it, so they see + the task's own outcome, cancellation included); ``done`` is a thread-level future + that callers on other loops await through `asyncio.wrap_future`. A cancelled run + resolves ``done`` with ``_RETRY`` so those callers start a new run themselves. + """ + + __slots__ = ("done", "loop", "shared") + + def __init__(self, loop: asyncio.AbstractEventLoop, shared: asyncio.Future[Any]) -> None: + self.loop = loop + self.shared = shared + self.done: concurrent.futures.Future[Any] = concurrent.futures.Future() + + def publish(self, task: asyncio.Future[Any] | None) -> None: + if self.done.done(): + return + if task is None or task.cancelled(): + self.abandon() + elif (exc := task.exception()) is not None: + self.done.set_exception(exc) + else: + self.done.set_result(task.result()) + + def abandon(self) -> None: + if not self.done.done(): + self.done.set_result(_RETRY) + + +@overload +def once(func: Callable[P, R], /) -> Once[P, R]: ... +@overload +def once(func: None = None, /) -> Callable[[Callable[P, R]], Once[P, R]]: ... +def once(func: Callable[..., Any] | None = None, /) -> Any: + """Run the function a single time; later calls return the first result. + + Arguments of later calls are ignored. Thread-safe (double-checked lock) and + async-safe: concurrent first awaits share one run, even across threads that each + run their own event loop (it runs once per process). If the first call raises, the + function is not marked done and the next call tries again. The wrapper has + ``.reset()`` and a ``.called`` flag. While ``python_wrap.testing.disable_caching()`` + is active it runs the function on every call and stores nothing. + + Args: + func: The function (the decorator is used bare). + + Returns: + The wrapped function. + + Example: + >>> @once + ... def setup(): + ... return object() + >>> setup() is setup() + True + """ + + def decorate(f: Callable[..., Any]) -> Any: + lock = threading.RLock() + # "flight" is the run in progress (shared by every thread and event loop). + state: dict[str, Any] = {"done": False, "result": None, "gen": 0, "flight": None} + + if _core.is_async(f): + + def finish(flight: _Flight, task: asyncio.Future[Any] | None) -> None: + with lock: + if state["flight"] is flight: + state["flight"] = None + flight.publish(task) # under the lock: abandon() may race from a thread + + @functools.wraps(f) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + if _core.overrides.disable_caching: + return await f(*args, **kwargs) + while True: + if state["done"]: + return state["result"] + with lock: + if state["done"]: + return state["result"] + loop = asyncio.get_running_loop() + flight: _Flight | None = state["flight"] + if flight is not None and flight.loop.is_closed(): + flight.abandon() # its loop died: waiters elsewhere retry + flight = None + owner = flight is None + if flight is None: + flight = state["flight"] = _Flight(loop, _shared_future()) + gen = state["gen"] + if owner: + + async def run(gen: int = gen) -> Any: + result = await f(*args, **kwargs) + with lock: + if gen == state["gen"]: # reset() did not run meanwhile + state.update(done=True, result=result) + wrapper.called = True + return result + + _start(run(), flight.shared, functools.partial(finish, flight)) + if flight.loop is loop: + return await _await_shared(flight.shared) + # Another event loop runs it: wait on the thread-level future. + result = await _await_shared(asyncio.wrap_future(flight.done)) + if result is not _RETRY: + return result + + wrapper: Any = awrapper + else: + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if _core.overrides.disable_caching: + return f(*args, **kwargs) + if state["done"]: + return state["result"] + with lock: + if not state["done"]: + state["result"] = f(*args, **kwargs) + state["done"] = True + wrapper.called = True + return state["result"] + + wrapper.called = False + + def reset() -> None: + with lock: + state.update(done=False, result=None, gen=state["gen"] + 1, flight=None) + wrapper.called = False + + wrapper.reset = reset + return wrapper + + return decorate if func is None else decorate(func) + + +# ------------------------------------------------------------------ cached_property + +_STATE_KEY = "_python_wrap_cached_property_state" + + +def _new_state() -> _PropState: + return _PropState(0) + + +class _PropState: + """Per-instance bookkeeping for every `cached_property` of that instance. + + Lives in the instance ``__dict__``. It remembers ``id(owner)`` so a shallow copy of the + instance (which shares this object) notices and starts with its own state, and it + pickles as an empty state so locks never end up in a pickle. + """ + + __slots__ = ("entries", "gens", "locks", "owner_id", "tasks") + + def __init__(self, owner_id: int) -> None: + self.owner_id = owner_id + self.entries: dict[str, tuple[Any, float | None]] = {} + self.locks: dict[str, threading.RLock] = {} + self.gens: dict[str, int] = {} + self.tasks: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, dict[str, asyncio.Future[Any]] + ] = weakref.WeakKeyDictionary() + + def __reduce__(self) -> tuple[Callable[[], _PropState], tuple[()]]: + return (_new_state, ()) + + def lock_for(self, name: str) -> threading.RLock: + lock = self.locks.get(name) + if lock is None: + lock = self.locks.setdefault(name, threading.RLock()) # atomic: one winner + return lock + + def drop(self, name: str) -> bool: + with self.lock_for(name): + self.gens[name] = self.gens.get(name, 0) + 1 # discards results still in flight + for per_loop in tuple(self.tasks.values()): + per_loop.pop(name, None) + return self.entries.pop(name, None) is not None + + +def _state_of(instance: object, *, create: bool = True) -> _PropState | None: + try: + d = instance.__dict__ + except AttributeError: + raise TypeError( + f"cached_property needs an instance __dict__, but {type(instance).__name__!r} " + "has none (a __slots__ class must also list '__dict__')" + ) from None + state: _PropState | None = d.get(_STATE_KEY) + if state is None or state.owner_id != id(instance): + if not create: + return None + state = d.setdefault(_STATE_KEY, _PropState(id(instance))) + if state.owner_id != id(instance): # a copy still holding its original's state + state = d[_STATE_KEY] = _PropState(id(instance)) + return state + + +class CachedProperty(Generic[T]): + """The descriptor returned by `cached_property`; ``obj.prop`` has type ``T``. + + For an ``async def`` getter ``T`` is ``Awaitable[X]``: ``await obj.prop`` gives ``X``. + Instances are read-only data descriptors: assigning to the attribute raises + ``AttributeError``; ``del obj.prop`` invalidates the cached value. + """ + + def __init__(self, func: Callable[[Any], Any], ttl: float | datetime.timedelta | None) -> None: + self.func = func + self.ttl = _ttl_seconds(ttl) + self.name = func.__name__ + self._named = False + self.is_async = _core.is_async(func) + self.__doc__ = func.__doc__ + self.__wrapped__ = func + + def __set_name__(self, owner: type, name: str) -> None: + if self._named and name != self.name: + raise TypeError( + f"cannot use one cached_property under two names ({self.name!r} and {name!r})" + ) + self.name = name + self._named = True + + @overload + def __get__(self, instance: None, owner: type | None = None) -> CachedProperty[T]: ... + @overload + def __get__(self, instance: object, owner: type | None = None) -> T: ... + def __get__(self, instance: object | None, owner: type | None = None) -> Any: + if instance is None: + return self + if _core.overrides.disable_caching: + return self.func(instance) + state = _state_of(instance) + assert state is not None + if self.is_async: + return self._aget(instance, state) + entry = state.entries.get(self.name) + if entry is not None and (entry[1] is None or _core.monotonic() < entry[1]): + return entry[0] + with state.lock_for(self.name): + entry = state.entries.get(self.name) + if entry is not None and (entry[1] is None or _core.monotonic() < entry[1]): + return entry[0] + gen = state.gens.get(self.name, 0) + value = self.func(instance) + if state.gens.get(self.name, 0) == gen: # not invalidated by the getter itself + self._store(state, value) + return value + + def _store(self, state: _PropState, value: Any) -> None: + expires = None if self.ttl is None else _core.monotonic() + self.ttl + state.entries[self.name] = (value, expires) + + async def _aget(self, instance: object, state: _PropState) -> Any: + name = self.name + lock = state.lock_for(name) + with lock: + entry = state.entries.get(name) + if entry is not None and (entry[1] is None or _core.monotonic() < entry[1]): + return entry[0] + per_loop = state.tasks.setdefault(asyncio.get_running_loop(), {}) + shared = per_loop.get(name) + owner = shared is None + if shared is None: + shared = per_loop[name] = _shared_future() + gen = state.gens.get(name, 0) + if owner: + + async def run() -> Any: + value = await self.func(instance) + with lock: + if state.gens.get(name, 0) == gen: # not invalidated meanwhile + self._store(state, value) + return value + + def done(_t: asyncio.Future[Any] | None) -> None: + with lock: + if per_loop.get(name) is shared: + del per_loop[name] + + _start(run(), shared, done) + return await _await_shared(shared) + + def __delete__(self, instance: object) -> None: + state = _state_of(instance, create=False) + if state is not None: + state.drop(self.name) + + +class _CachedPropertyDecorator(Protocol): + @overload + def __call__(self, func: Callable[[Any], Awaitable[T]], /) -> CachedProperty[Awaitable[T]]: ... + @overload + def __call__(self, func: Callable[[Any], T], /) -> CachedProperty[T]: ... + + +@overload +def cached_property(func: Callable[[Any], Awaitable[T]], /) -> CachedProperty[Awaitable[T]]: ... +@overload +def cached_property(func: Callable[[Any], T], /) -> CachedProperty[T]: ... +@overload +def cached_property( + func: None = None, /, *, ttl: float | datetime.timedelta | None = None +) -> _CachedPropertyDecorator: ... +def cached_property( + func: Callable[[Any], Any] | None = None, + /, + *, + ttl: float | datetime.timedelta | None = None, +) -> Any: + """Compute an attribute once per instance and cache it, like `functools.cached_property`. + + Differences from the standard library version: + + * ``ttl`` makes the value expire (seconds or a ``timedelta``, read through the + package clock so ``fake_clock`` works). + * Thread-safe without a global lock: each instance and property has its own lock, + created lazily, so a slow getter blocks only other readers of the same value. + * ``async def`` getters work: ``await obj.prop``. Concurrent awaits on one instance + share a single task per event loop, and a failure is not cached. + * Explicit invalidation: ``del obj.prop`` or `invalidate` ``(obj, "prop")``. + The attribute is read-only otherwise. + * Needs an instance ``__dict__``; on a ``__slots__`` class without one, access raises + ``TypeError``. Values are kept in a hidden ``__dict__`` entry that copies and + pickles do not carry over (a copy recomputes). + + With ``python_wrap.testing.disable_caching()`` active the getter runs on every access. + + Args: + func: The getter (when used bare). + ttl: Seconds (or a ``timedelta``) the value stays valid. None never expires. + + Returns: + A `CachedProperty` descriptor, or a decorator when called with options. + + Raises: + ValueError: If ``ttl`` is not positive. + TypeError: On access, if the instance has no ``__dict__``. + + Example: + >>> class Report: + ... @cached_property(ttl=60) + ... def totals(self): + ... return sum(range(10)) + >>> r = Report() + >>> r.totals + 45 + >>> del r.totals # next access recomputes + """ + + _ttl_seconds(ttl) # fail early, at decoration + + def decorate(f: Callable[[Any], Any]) -> CachedProperty[Any]: + return CachedProperty(f, ttl) + + return decorate if func is None else decorate(func) + + +def invalidate(instance: object, *names: str) -> int: + """Forget cached `cached_property` values of ``instance``. + + Args: + instance: The object that owns the properties. + *names: Property names to invalidate. With none given, every cached property of + the instance is invalidated. + + Returns: + The number of cached values that were dropped. + + Raises: + AttributeError: If a name is not a `cached_property` of the instance's class. + TypeError: If the instance has no ``__dict__``. + + Example: + >>> class C: + ... @cached_property + ... def x(self): + ... return 1 + >>> c = C(); c.x + 1 + >>> invalidate(c, "x") + 1 + """ + for name in names: + for klass in type(instance).__mro__: + attr = klass.__dict__.get(name) + if attr is not None: + if isinstance(attr, CachedProperty): + break + raise AttributeError(f"{name!r} is not a cached_property") + else: + raise AttributeError(f"{type(instance).__name__!r} has no cached_property {name!r}") + state = _state_of(instance, create=False) + if state is None: + return 0 + if names: + targets = names + else: + targets = tuple( + set(state.entries).union(*(set(per_loop) for per_loop in tuple(state.tasks.values()))) + ) + return sum(state.drop(n) for n in targets) diff --git a/src/python_wrap/circuit.py b/src/python_wrap/circuit.py new file mode 100644 index 0000000..6fa5eb9 --- /dev/null +++ b/src/python_wrap/circuit.py @@ -0,0 +1,550 @@ +"""Circuit breaker decorator and class.""" + +from __future__ import annotations + +import enum +import functools +import threading +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, cast, overload + +from python_wrap import _core +from python_wrap._core import F, LoggerLike, P, R +from python_wrap.errors import CircuitOpenError + +__all__ = [ + "CircuitBreaker", + "CircuitProtected", + "CircuitState", + "CircuitStats", + "StateChangeListener", + "circuit_breaker", +] + +R_co = TypeVar("R_co", covariant=True) +P2 = ParamSpec("P2") +T = TypeVar("T") + +ExceptionTypes = type[BaseException] | tuple[type[BaseException], ...] + + +class CircuitState(str, enum.Enum): + """The three states of a circuit breaker.""" + + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +StateChangeListener = Callable[["CircuitBreaker", CircuitState, CircuitState], None] +"""Signature of ``on_state_change``: ``listener(breaker, old_state, new_state)``.""" + + +@dataclass(frozen=True) +class CircuitStats: + """A point-in-time snapshot of a `CircuitBreaker`, from its ``.stats`` property. + + Attributes: + name: The breaker's name. + state: Current state. + failures: Calls that failed with a counted exception (since creation or the last + ``reset()``). + successes: Calls that returned normally (same period). + rejected: Calls refused with `CircuitOpenError` (same period). + consecutive_failures: Counted failures since the last success. + opened_at: ``_core.monotonic()`` time at which the circuit last opened, or + ``None`` while it is closed. + """ + + name: str + state: CircuitState + failures: int + successes: int + rejected: int + consecutive_failures: int + opened_at: float | None + + +class CircuitBreaker: + """Stop calling a failing dependency until it has had time to recover. + + The breaker is closed at first. After ``failure_threshold`` consecutive matching + failures it opens and rejects calls with `CircuitOpenError`. + After ``recovery_timeout`` seconds it goes half-open and lets up to + ``half_open_max_calls`` trial calls run at once: ``success_threshold`` consecutive + successful trials close it, any counted failure re-opens it. One instance can + decorate several functions to share state. + + Exceptions matching ``on`` are counted as failures, unless they also match + ``exclude``. Excluded and non-matching exceptions propagate to the caller without + affecting the breaker (they count neither as a failure nor as a success). + + Each transition calls ``on_state_change(breaker, old, new)`` (outside the breaker's + lock; a failing listener is logged, never raised) and emits the + ``circuit.state_changed`` event. Every rejected call emits ``circuit.rejected``. + + Args: + failure_threshold: Consecutive failures that open the circuit (>= 1). + recovery_timeout: Seconds to stay open before allowing trial calls. + half_open_max_calls: Concurrent trial calls allowed while half-open (>= 1). + success_threshold: Consecutive successful trial calls needed to close a + half-open circuit (>= 1). + on: Exception type(s) that count as failures; others pass through uncounted. + exclude: Exception type(s) never counted, even when they match ``on``, e.g. + ``exclude=NotFound`` with ``on=Exception``. + on_state_change: Listener called with ``(breaker, old_state, new_state)``. + name: Name used in log messages, errors and events. + logger: Logger (or name) for state transitions. + + Raises: + ValueError: If a numeric option is out of range. + + Example: + ```python + from python_wrap.circuit import CircuitBreaker, CircuitState + + changes = [] + breaker = CircuitBreaker( + failure_threshold=2, + exclude=KeyError, + on_state_change=lambda b, old, new: changes.append((old.value, new.value)), + name="payments", + ) + + @breaker + def charge(fail: bool) -> str: + if fail: + raise ConnectionError("down") + return "ok" + + for _ in range(2): + try: + charge(True) + except ConnectionError: + pass + assert breaker.state is CircuitState.OPEN + assert changes == [("closed", "open")] + assert breaker.stats.failures == 2 + ``` + """ + + def __init__( + self, + *, + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + half_open_max_calls: int = 1, + success_threshold: int = 1, + on: ExceptionTypes = Exception, + exclude: ExceptionTypes = (), + on_state_change: StateChangeListener | None = None, + name: str | None = None, + logger: LoggerLike = None, + ) -> None: + if failure_threshold < 1: + raise ValueError(f"failure_threshold must be >= 1, got {failure_threshold!r}") + if recovery_timeout < 0: + raise ValueError(f"recovery_timeout must be >= 0, got {recovery_timeout!r}") + if half_open_max_calls < 1: + raise ValueError(f"half_open_max_calls must be >= 1, got {half_open_max_calls!r}") + if success_threshold < 1: + raise ValueError(f"success_threshold must be >= 1, got {success_threshold!r}") + self.failure_threshold = failure_threshold + self.recovery_timeout = recovery_timeout + self.half_open_max_calls = half_open_max_calls + self.success_threshold = success_threshold + self.on = on + self.exclude = exclude + self.on_state_change = on_state_change + self.name = name or "circuit" + self._log = _core.get_logger(logger) + self._lock = threading.Lock() + self._state = CircuitState.CLOSED + self._failures = 0 + self._opened_at = 0.0 + self._trials = 0 + self._trial_successes = 0 + self._generation = 0 + self._total_failures = 0 + self._total_successes = 0 + self._total_rejected = 0 + # Transitions made under the lock, delivered to listeners after releasing it. + self._pending: list[tuple[CircuitState, CircuitState]] = [] + + # -- introspection ------------------------------------------------------------- + @property + def state(self) -> CircuitState: + """Current state (an open circuit past its timeout reports ``half_open``).""" + with self._lock: + self._maybe_half_open() + state = self._state + self._notify() + return state + + @property + def failure_count(self) -> int: + """Consecutive failures counted so far.""" + with self._lock: + return self._failures + + @property + def stats(self) -> CircuitStats: + """A consistent `CircuitStats` snapshot of the counters and state.""" + with self._lock: + self._maybe_half_open() + snapshot = CircuitStats( + name=self.name, + state=self._state, + failures=self._total_failures, + successes=self._total_successes, + rejected=self._total_rejected, + consecutive_failures=self._failures, + opened_at=None if self._state is CircuitState.CLOSED else self._opened_at, + ) + self._notify() + return snapshot + + def reset(self) -> None: + """Force the breaker back to closed and zero every counter.""" + with self._lock: + if self._state is not CircuitState.CLOSED: + self._log.info("circuit %s reset to closed", self.name) + self._set_state(CircuitState.CLOSED) + self._failures = 0 + self._trials = 0 + self._trial_successes = 0 + self._total_failures = 0 + self._total_successes = 0 + self._total_rejected = 0 + self._generation += 1 + self._notify() + + # -- internals (call with the lock held) --------------------------------------- + def _set_state(self, new: CircuitState) -> None: + old = self._state + self._state = new + if old is not new: + self._pending.append((old, new)) + + def _maybe_half_open(self) -> None: + if ( + self._state is CircuitState.OPEN + and _core.monotonic() - self._opened_at >= self.recovery_timeout + ): + self._set_state(CircuitState.HALF_OPEN) + self._trials = 0 + self._trial_successes = 0 + self._generation += 1 + self._log.info("circuit %s is half-open; allowing trial calls", self.name) + + def _open(self) -> None: + self._set_state(CircuitState.OPEN) + self._opened_at = _core.monotonic() + self._trials = 0 + self._trial_successes = 0 + self._generation += 1 + self._log.warning( + "circuit %s opened after %d failure(s); rejecting calls for %.1fs", + self.name, + self._failures, + self.recovery_timeout, + ) + + # -- internals (call WITHOUT the lock) ----------------------------------------- + def _notify(self) -> None: + """Deliver pending transitions to the listener and the event hook.""" + if not self._pending: + return + with self._lock: + pending, self._pending = self._pending, [] + for old, new in pending: + _core.emit("circuit.state_changed", self.name, old=old, new=new) + listener = self.on_state_change + if listener is None: + continue + try: + listener(self, old, new) + except Exception: + self._log.exception("circuit %s on_state_change listener failed", self.name) + + def _acquire(self) -> tuple[int, bool]: + """Admit a call. Returns ``(generation, is_trial)`` or raises CircuitOpenError.""" + error: CircuitOpenError | None = None + with self._lock: + self._maybe_half_open() + admitted = (self._generation, self._state is CircuitState.HALF_OPEN) + if self._state is CircuitState.OPEN: + remaining = max(self.recovery_timeout - (_core.monotonic() - self._opened_at), 0) + error = CircuitOpenError( + f"circuit {self.name} is open; retry in {remaining:.1f}s", + retry_after=remaining, + ) + elif self._state is CircuitState.HALF_OPEN: + if self._trials >= self.half_open_max_calls: + error = CircuitOpenError( + f"circuit {self.name} is half-open and its trial calls are in use", + retry_after=0.0, + ) + else: + self._trials += 1 + if error is not None: + self._total_rejected += 1 + self._notify() + if error is not None: + _core.emit("circuit.rejected", self.name, retry_after=error.retry_after) + raise error + return admitted + + def _success(self, generation: int, trial: bool) -> None: + with self._lock: + self._total_successes += 1 + if generation != self._generation: + return + if trial: + self._trials = max(self._trials - 1, 0) + self._trial_successes += 1 + if self._trial_successes >= self.success_threshold: + self._set_state(CircuitState.CLOSED) + self._trials = 0 + self._trial_successes = 0 + self._generation += 1 + self._log.info( + "circuit %s closed after %d successful trial call(s)", + self.name, + self.success_threshold, + ) + self._failures = 0 + self._notify() + + def _failure(self, generation: int, trial: bool) -> None: + with self._lock: + self._total_failures += 1 + if generation != self._generation: + return + self._failures += 1 + if trial or self._failures >= self.failure_threshold: + self._open() + self._notify() + + def _release(self, generation: int, trial: bool) -> None: + """A non-counted exception ended the call: free its trial slot, if any.""" + if not trial: + return + with self._lock: + if generation == self._generation: + self._trials = max(self._trials - 1, 0) + + def _settle(self, generation: int, trial: bool, exc: BaseException) -> None: + """Book a call that raised ``exc``: a failure, or uncounted.""" + if isinstance(exc, self.on) and not isinstance(exc, self.exclude): + self._failure(generation, trial) + else: + self._release(generation, trial) + + # -- public call API ----------------------------------------------------------- + @overload + def call( + self, func: Callable[P, Coroutine[Any, Any, R]], /, *args: P.args, **kwargs: P.kwargs + ) -> Coroutine[Any, Any, R]: ... + @overload + def call(self, func: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R: ... + def call(self, func: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any: + """Call ``func(*args, **kwargs)`` through the breaker. + + For an ``async def`` function this returns a coroutine to await. + + Args: + func: The function to call. + *args: Positional arguments for ``func``. + **kwargs: Keyword arguments for ``func``. + + Returns: + Whatever ``func`` returns (a coroutine if it is async). + + Raises: + CircuitOpenError: If the circuit is open (``func`` is not called). + """ + if _core.is_async(func): + return self._acall(func, args, kwargs) + return self._call_sync(func, args, kwargs) + + def _call_sync(self, func: Callable[..., Any], args: Any, kwargs: Any) -> Any: + generation, trial = self._acquire() + try: + result = func(*args, **kwargs) + except BaseException as exc: + self._settle(generation, trial, exc) + raise + self._success(generation, trial) + return result + + async def _acall(self, func: Callable[..., Any], args: Any, kwargs: Any) -> Any: + # The lock is only taken inside the sync helpers, never across the await. + generation, trial = self._acquire() + try: + result = await func(*args, **kwargs) + except BaseException as exc: + self._settle(generation, trial, exc) + raise + self._success(generation, trial) + return result + + def __call__(self, func: F) -> F: + """Decorate ``func`` so every call goes through this breaker. + + The wrapper keeps the exact type of ``func``, so attributes added by decorators + applied underneath (such as ``cache_info()`` from ``memoize``) stay visible. + """ + if _core.is_async(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + return await self._acall(func, args, kwargs) + + return cast(F, async_wrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + # Sync vs async was decided above, once: no per-call detection. + return self._call_sync(func, args, kwargs) + + return cast(F, wrapper) + + def __repr__(self) -> str: + return f"CircuitBreaker(name={self.name!r}, state={self.state.value!r})" + + +class CircuitProtected(Protocol[P, R_co]): + """A function wrapped by `circuit_breaker`: callable, with its own breaker attached. + + Use it to annotate a decorated function, e.g. ``CircuitProtected[[int], str]``. + Binds ``self`` and ``cls`` like `python_wrap.caching.Memoized`, with the same typing + limitations. + """ + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + @overload + def __get__( + self: CircuitProtected[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _CircuitProtectedOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> CircuitProtected[P, R_co]: ... + @overload + def __get__( + self: CircuitProtected[Concatenate[Any, P2], R_co], + instance: object, + owner: type | None = None, + ) -> CircuitProtected[P2, R_co]: ... + + @property + def breaker(self) -> CircuitBreaker: + """The dedicated `CircuitBreaker` (inspect ``.state`` or call ``.reset()``).""" + ... + + +class _CircuitProtectedOnClass(CircuitProtected[P, R_co], Protocol[P, P2, R_co]): + """`CircuitProtected` read from a class: accepts the call with and without the first argument. + + Returned for class access (``Cls.attr``) when the first parameter could be ``cls``. + pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an + instance or static method there, so both call forms are accepted. + """ + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + +@overload +def circuit_breaker( + func: Callable[P, R], + /, + *, + failure_threshold: int = ..., + recovery_timeout: float = ..., + half_open_max_calls: int = ..., + success_threshold: int = ..., + on: ExceptionTypes = ..., + exclude: ExceptionTypes = ..., + on_state_change: StateChangeListener | None = ..., + name: str | None = ..., + logger: LoggerLike = ..., +) -> CircuitProtected[P, R]: ... +@overload +def circuit_breaker( + func: None = None, + /, + *, + failure_threshold: int = ..., + recovery_timeout: float = ..., + half_open_max_calls: int = ..., + success_threshold: int = ..., + on: ExceptionTypes = ..., + exclude: ExceptionTypes = ..., + on_state_change: StateChangeListener | None = ..., + name: str | None = ..., + logger: LoggerLike = ..., +) -> Callable[[Callable[P, R]], CircuitProtected[P, R]]: ... +def circuit_breaker( + func: Callable[..., Any] | None = None, + /, + *, + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + half_open_max_calls: int = 1, + success_threshold: int = 1, + on: ExceptionTypes = Exception, + exclude: ExceptionTypes = (), + on_state_change: StateChangeListener | None = None, + name: str | None = None, + logger: LoggerLike = None, +) -> Any: + """Decorate a function with its own dedicated `CircuitBreaker`. + + The wrapper exposes the breaker as ``wrapper.breaker``. To share one breaker + between several functions, create a `CircuitBreaker` and use it as a + decorator instead. + + Args: + func: The function to wrap (when used without parentheses). + failure_threshold: Consecutive failures that open the circuit. + recovery_timeout: Seconds to stay open before allowing trial calls. + half_open_max_calls: Trial calls allowed while half-open. + success_threshold: Consecutive successful trials needed to close again. + on: Exception type(s) counted as failures. + exclude: Exception type(s) never counted, even when they match ``on``. + on_state_change: Listener called with ``(breaker, old_state, new_state)``. + name: Name for log messages, errors and events (defaults to the function's qualified name). + logger: Logger (or name) for state transitions. + + Returns: + The wrapped function, or a decorator when ``func`` is omitted. + + Raises: + ValueError: If a numeric option is out of range. + + Example: + >>> @circuit_breaker(failure_threshold=3, recovery_timeout=10) + ... def call_api() -> None: ... + """ + + def decorate(f: Callable[..., Any]) -> Any: + breaker = CircuitBreaker( + failure_threshold=failure_threshold, + recovery_timeout=recovery_timeout, + half_open_max_calls=half_open_max_calls, + success_threshold=success_threshold, + on=on, + exclude=exclude, + on_state_change=on_state_change, + name=name or _core.qualname(f), + logger=logger, + ) + wrapped: Any = breaker(f) + wrapped.breaker = breaker + return wrapped + + return decorate if func is None else decorate(func) diff --git a/src/python_wrap/concurrency.py b/src/python_wrap/concurrency.py new file mode 100644 index 0000000..704d2df --- /dev/null +++ b/src/python_wrap/concurrency.py @@ -0,0 +1,1707 @@ +"""Concurrency and flow-control decorators. + +* `timeout` — bound how long a call (or a `with` / `async with` block) may run. +* `rate_limit` — at most *N* calls per period, rejecting or waiting. +* `RateLimiter` — a rate limit shared by several functions and blocks. +* `run_in_thread` — start a call in a new thread and get a ``Future`` back. +* `throttle` — run at most once per interval; calls in between are skipped. +* `debounce` — run once activity has stopped for a while. +* `singleflight` — concurrent identical calls share one execution. + +Every decorator works on plain functions, ``async def`` functions and methods. State +kept by a decorator (rate-limit buckets, throttle windows, pending debounced calls) belongs +to the decorated function, so on a method it is shared by every instance unless a ``key`` +argument says otherwise. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextlib +import contextvars +import functools +import os +import queue +import signal +import sys +import threading +import time +import weakref +from collections import deque +from collections.abc import Awaitable, Callable, Coroutine, Hashable, Iterable +from types import FrameType, TracebackType +from typing import ( + Any, + Concatenate, + Final, + Generic, + ParamSpec, + Protocol, + TypeVar, + cast, + overload, +) + +from python_wrap import _core +from python_wrap import deadline as _deadline +from python_wrap._core import F, P, R +from python_wrap.errors import RateLimitExceeded, TimeoutExpired + +__all__ = [ + "AsyncDebounced", + "Debounced", + "Limit", + "RateLimiter", + "Timeout", + "debounce", + "limiter_of", + "rate_limit", + "run_in_thread", + "singleflight", + "throttle", + "timeout", +] + +T = TypeVar("T") +R_co = TypeVar("R_co", covariant=True) +P2 = ParamSpec("P2") + +# --------------------------------------------------------------------------------------- +# timeout +# --------------------------------------------------------------------------------------- + +_HAS_SETITIMER = hasattr(signal, "setitimer") and hasattr(signal, "SIGALRM") +_BLOCK = "" # the "function" name used in messages and events for `with timeout()` + + +def _signals_usable() -> bool: + return _HAS_SETITIMER and threading.current_thread() is threading.main_thread() + + +def _timeout_message(label: str, seconds: float) -> str: + return f"{label} did not finish within {seconds:g}s" + + +def _expired(label: str, seconds: float) -> TimeoutExpired: + """Build the error for an expiry and emit ``timeout.expired``.""" + _core.emit("timeout.expired", label, seconds=seconds) + return TimeoutExpired(_timeout_message(label, seconds), seconds=seconds) + + +class _Alarm: + """One SIGALRM/``setitimer`` deadline, cooperating with enclosing ones. + + Enter it in the main thread only. The previous handler and any armed timer are + restored on exit; an enclosing timer whose deadline comes first still fires. + Entering costs two system calls (install the handler, arm the timer — arming also + reports the enclosing timer, so no separate ``getitimer``) and so does exiting. + """ + + def __init__(self, label: str, seconds: float) -> None: + self.label = label + self.seconds = seconds + self.error: TimeoutExpired | None = None # built only when the alarm fires + self._outer_fired = False + # The enclosing timer's delay; None until known (our timer is not armed yet). + self._previous_delay: float | None = None + self._previous_interval = 0.0 + + def __enter__(self) -> None: + # The itimer counts real time, so the deadline arithmetic does too (a fake clock + # installed by tests must not move it). + self._start = time.monotonic() + deadline = self._start + self.seconds + self._previous_handler = previous_handler = signal.getsignal(signal.SIGALRM) + + def handler(signum: int, frame: FrameType | None) -> None: + previous_delay = self._previous_delay + if previous_delay is not None and ( + not previous_delay or time.monotonic() >= deadline - 1e-3 + ): + self.error = TimeoutExpired( + _timeout_message(self.label, self.seconds), seconds=self.seconds + ) + raise self.error + # The alarm belongs to an enclosing timeout whose deadline comes first (or + # fired before ours was even armed). + self._outer_fired = True + signal.signal(signal.SIGALRM, previous_handler) + if callable(previous_handler): + previous_handler(signum, frame) + + signal.signal(signal.SIGALRM, handler) + previous_delay, self._previous_interval = signal.setitimer(signal.ITIMER_REAL, self.seconds) + if previous_delay and previous_delay < self.seconds: + # An enclosing timer is due first: keep its deadline. + signal.setitimer(signal.ITIMER_REAL, previous_delay) + self._previous_delay = previous_delay + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + signal.setitimer(signal.ITIMER_REAL, 0) + if not self._outer_fired: + signal.signal(signal.SIGALRM, self._previous_handler) + if self._previous_delay: + remaining = self._previous_delay - (time.monotonic() - self._start) + # Re-arm the enclosing timer; if its deadline already passed, fire ASAP. + signal.setitimer(signal.ITIMER_REAL, max(remaining, 1e-6), self._previous_interval) + if exc is not None and exc is self.error: + _core.emit("timeout.expired", self.label, seconds=self.seconds) + + +# -- thread fallback: a small pool of idle daemon workers --------------------------------- + +_WORKER_IDLE_SECONDS = 5.0 # an idle worker exits after this long (real time) +_MAX_IDLE_WORKERS = 8 # idle workers kept for reuse; extra ones exit when done + + +class _Job: + """One call handed to a worker; ``done`` is released when it has finished.""" + + __slots__ = ( + "abandoned", + "args", + "context", + "done", + "error", + "finished", + "func", + "kwargs", + "result", + ) + + def __init__(self, func: Callable[..., Any], args: Any, kwargs: Any) -> None: + self.func = func + self.args = args + self.kwargs = kwargs + self.context = contextvars.copy_context() # per call, as with a fresh thread + self.done = threading.Lock() + self.done.acquire() + self.result: Any = None + self.error: BaseException | None = None + self.abandoned = False # the caller timed out and stopped waiting + self.finished = 0.0 # `time.monotonic()` when the call returned (real time) + + def run(self) -> None: + try: + self.result = self.context.run(self.func, *self.args, **self.kwargs) + except BaseException as exc: + self.error = exc + self.func = self.args = self.kwargs = None # type: ignore[assignment] + self.finished = time.monotonic() + self.done.release() + + +class _WorkerPool: + """Reusable daemon threads for the `timeout` thread fallback. + + A finished worker goes back to the idle list (up to ``_MAX_IDLE_WORKERS``); a worker + whose call timed out is abandoned — it exits when that call eventually returns and + is never reused. An idle worker exits after ``_WORKER_IDLE_SECONDS`` without work. + """ + + def __init__(self) -> None: + self.lock = threading.Lock() + self.idle: list[queue.SimpleQueue[_Job]] = [] # inboxes of idle workers (LIFO) + self.count = 0 + + def submit(self, job: _Job) -> None: + with self.lock: + inbox = self.idle.pop() if self.idle else None + if inbox is None: + self.count += 1 + number = self.count + if inbox is not None: + inbox.put(job) + return + inbox = queue.SimpleQueue() + inbox.put(job) + threading.Thread( + target=self._work, args=(inbox,), name=f"timeout-worker-{number}", daemon=True + ).start() + + def _work(self, inbox: queue.SimpleQueue[_Job]) -> None: + while True: + try: + job = inbox.get(timeout=_WORKER_IDLE_SECONDS) + except queue.Empty: + with self.lock: + if inbox in self.idle: + self.idle.remove(inbox) + return + continue # claimed just as we gave up waiting: the job is on its way + job.run() + with self.lock: + if job.abandoned or len(self.idle) >= _MAX_IDLE_WORKERS: + return + self.idle.append(inbox) + del job + + def abandon(self, job: _Job) -> None: + with self.lock: + job.abandoned = True + + def reset(self) -> None: + """After `os.fork`: the parent's workers do not exist in the child.""" + self.lock = threading.Lock() + self.idle = [] + + +_workers = _WorkerPool() +if hasattr(os, "register_at_fork"): # pragma: no branch - absent on Windows only + os.register_at_fork(after_in_child=_workers.reset) + + +def _call_in_worker( + func: Callable[P, R], label: str, seconds: float, *args: P.args, **kwargs: P.kwargs +) -> R: + """Run ``func`` in a pooled daemon thread and stop waiting for it after ``seconds``.""" + job = _Job(func, args, kwargs) + # Real time, like the wait itself: a fake clock advanced by sleeps inside the call + # (e.g. a retry) must not turn a quick call into a late one. + start = time.monotonic() + _workers.submit(job) + if not job.done.acquire(timeout=seconds): + _workers.abandon(job) + raise _expired(label, seconds) from None + if job.finished - start > seconds: + # The wait overslept (seen with free-threaded builds) and the call finished + # late: it still ran over its limit. + raise _expired(label, seconds) from None + error = job.error + if error is not None: + job.error = None + try: + raise error + finally: + del error + return cast(R, job.result) + + +if sys.version_info >= (3, 11): + _async_timer = asyncio.timeout + +else: # pragma: no cover - exercised by the Python 3.10 CI job + + class _CancelTimer: + """`asyncio.timeout` stand-in for 3.10: cancel the task at the deadline. + + Like `asyncio.timeout`, the cancellation that it caused is turned into a + ``TimeoutError`` on exit; any other cancellation propagates unchanged. + """ + + def __init__(self, seconds: float) -> None: + self._seconds = seconds + self._fired = False + + def expired(self) -> bool: + return self._fired + + async def __aenter__(self) -> _CancelTimer: + task = asyncio.current_task() + if task is None: + raise RuntimeError("timeout() must be used inside a task") + + def fire() -> None: + self._fired = True + task.cancel() + + self._handle = asyncio.get_running_loop().call_later(self._seconds, fire) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self._handle.cancel() + if self._fired and exc_type is asyncio.CancelledError: + raise TimeoutError from exc + + _async_timer = _CancelTimer + + +async def _await_with_deadline(awaitable: Awaitable[T], label: str, seconds: float) -> T: + """Await under a timer; convert only *our* expiry to TimeoutExpired.""" + timer = _async_timer(seconds) + try: + async with timer: + return await awaitable + except TimeoutError as exc: + if not timer.expired(): + raise # raised by the wrapped function itself: propagate unchanged + raise _expired(label, seconds) from exc + + +class Timeout: + """A time limit usable as a decorator, `with` block or `async with` block. + + Created by `timeout`; see there for the strategies and caveats. One instance can + decorate several functions and be entered several times (also concurrently from + different tasks); every use gets its own timer. + + Attributes: + seconds: The configured limit. + use_signals: The configured sync strategy (`None` means automatic). + """ + + def __init__(self, seconds: float, *, use_signals: bool | None = None) -> None: + if seconds <= 0: + raise ValueError(f"timeout seconds must be positive, got {seconds!r}") + if use_signals and not _HAS_SETITIMER: + raise RuntimeError("use_signals=True needs signal.setitimer, unavailable here") + self.seconds = seconds + self.use_signals = use_signals + self._alarms: list[_Alarm] = [] + self._timers: dict[asyncio.Task[Any], list[Any]] = {} + + def _limit(self, label: str) -> float: + """The limit for a call starting now: ``seconds`` clamped to the deadline.""" + left = _deadline.remaining() + if left is None or left >= self.seconds: + return self.seconds + if left <= 0: + _core.emit("timeout.expired", label, seconds=0.0) + raise TimeoutExpired(f"{label} not started: the deadline has passed", seconds=0.0) + return left + + # -- decorator ---------------------------------------------------------------------- + + def __call__(self, func: F, /) -> F: + """Apply the limit to every call of ``func`` (sync or ``async def``).""" + label = _core.qualname(func) + use_signals = self.use_signals + + if _core.is_async(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + seconds = self._limit(label) + return await _await_with_deadline(func(*args, **kwargs), label, seconds) + + return cast(F, async_wrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if use_signals and not _signals_usable(): + raise RuntimeError( + "use_signals=True: signal-based timeouts only work in the main thread" + ) + seconds = self._limit(label) + if use_signals or (use_signals is None and _signals_usable()): + with _Alarm(label, seconds): + return func(*args, **kwargs) + return _call_in_worker(func, label, seconds, *args, **kwargs) + + return cast(F, wrapper) + + # -- sync context manager ----------------------------------------------------------- + + def __enter__(self) -> Timeout: + if self.use_signals is False or not _signals_usable(): + raise RuntimeError( + "`with timeout(...)` needs SIGALRM (Unix) and the main thread, because a " + "block of code cannot be moved to a worker thread; decorate a function " + "with @timeout instead, or use `async with timeout(...)` in async code" + ) + alarm = _Alarm(_BLOCK, self._limit(_BLOCK)) + alarm.__enter__() + self._alarms.append(alarm) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self._alarms.pop().__exit__(exc_type, exc, tb) + + # -- async context manager ---------------------------------------------------------- + + async def __aenter__(self) -> Timeout: + task = asyncio.current_task() + if task is None: # pragma: no cover - asyncio always runs coroutines in a task + raise RuntimeError("`async with timeout(...)` must run inside an asyncio task") + seconds = self._limit(_BLOCK) + timer = _async_timer(seconds) + await timer.__aenter__() + self._timers.setdefault(task, []).append((timer, seconds)) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + task = asyncio.current_task() + assert task is not None + stack = self._timers[task] + timer, seconds = stack.pop() + if not stack: + del self._timers[task] + try: + await timer.__aexit__(exc_type, exc, tb) + except TimeoutError as error: + if not timer.expired(): # pragma: no cover - only our own expiry is converted + raise + raise _expired(_BLOCK, seconds) from error + + def __repr__(self) -> str: + return f"Timeout(seconds={self.seconds!r}, use_signals={self.use_signals!r})" + + +def timeout(seconds: float, *, use_signals: bool | None = None) -> Timeout: + """Raise `TimeoutExpired` if a call or block runs longer than ``seconds``. + + ``TimeoutExpired`` subclasses the built-in ``TimeoutError``. The returned `Timeout` + works three ways: + + * ``@timeout(5)`` decorates sync and ``async def`` functions. + * ``async with timeout(5):`` limits a block in async code (`asyncio.timeout` on + Python 3.11+, a cancellation timer on 3.10). + * ``with timeout(5):`` limits a block in sync code, with signals only: it needs + ``signal.setitimer`` (Unix) and the main thread, otherwise entering it raises + ``RuntimeError`` (a block cannot be moved to a worker thread; decorate a function + instead). + + How each kind of code is stopped: + + * ``async def`` code is cancelled at the deadline. Only the timeout's own expiry is + turned into ``TimeoutExpired``: a ``TimeoutError`` raised by the wrapped code + itself propagates unchanged. + * Sync code, with signals (the default on Unix when called from the main thread): + a ``SIGALRM`` timer from `signal.setitimer` interrupts the call. The previous + ``SIGALRM`` handler and any already-armed timer are restored afterwards, so nested + timeouts work. + * Sync functions, without signals (Windows, non-main threads, or + ``use_signals=False``): the call runs in a daemon worker thread and the caller stops + waiting at the deadline. **This fallback cannot stop the work** — the function keeps + running in the background until it returns on its own, and its result is discarded. + Workers are pooled daemon threads: an idle one is reused for the next call (at most + 8 are kept idle, each exits after about 5 s without work), a worker whose call timed + out is abandoned and never reused, and the caller's ``contextvars`` are copied into + the worker on every call. + + Inside a `python_wrap.deadline.deadline` block the limit is clamped to the time the + deadline has left; if the deadline has already passed, the call (or block) is not + started and ``TimeoutExpired`` is raised at once. Every expiry emits the + ``timeout.expired`` event. + + Warning: + With the signal strategy, ``TimeoutExpired`` is raised *inside* the wrapped + code, at whatever line it is executing when the alarm fires. A broad + ``except Exception:`` (or ``except TimeoutError:``) in that code catches it like + any other error; if the code then carries on, the time limit is silently lost and + the call returns normally. ``TimeoutExpired`` deliberately stays an ``Exception`` + (it subclasses ``TimeoutError``), so re-raise it explicitly in such handlers:: + + except TimeoutExpired: + raise + except Exception: + ... + + The same applies to context managers that suppress exceptions and to + ``finally`` blocks that ``return``. ``async def`` code has the analogous + caveat for code that swallows ``asyncio.CancelledError``. + + Args: + seconds: Time limit in seconds; must be positive. + use_signals: ``True`` forces the signal strategy, ``False`` forces the thread + fallback (and so rules out ``with timeout(...)``), ``None`` (default) picks + signals when they are usable. + + Returns: + A `Timeout`: a decorator that preserves the wrapped function's type, and a sync + and async context manager. + + Raises: + ValueError: If ``seconds`` is not positive. + RuntimeError: If ``use_signals=True`` but ``signal.setitimer`` does not exist on + this platform (at creation time); if ``use_signals=True`` and the call is not + made from the main thread (at call time); or on entering ``with`` where + signals cannot be used. + TimeoutExpired: From the wrapped call or block, when the time limit is exceeded. + + Example: + ```python + import asyncio + from python_wrap.concurrency import timeout + from python_wrap.errors import TimeoutExpired + + @timeout(5) + def fetch() -> str: + return "done" + + assert fetch() == "done" + + async def main() -> None: + try: + async with timeout(0.01): + await asyncio.sleep(1) + except TimeoutExpired as exc: + assert exc.seconds == 0.01 + + asyncio.run(main()) + ``` + """ + return Timeout(seconds, use_signals=use_signals) + + +# --------------------------------------------------------------------------------------- +# rate_limit +# --------------------------------------------------------------------------------------- + +_SWEEP_EVERY = 1024 + +Limit = tuple[int, float] +"""One rate limit: ``(calls, period_seconds)``.""" + + +def _limits( + calls: int | None, period: float | None, limits: Iterable[Limit] | None +) -> tuple[Limit, ...]: + """Validate and combine the ``calls``/``period`` pair and ``limits``.""" + result: list[Limit] = [] + if calls is not None or period is not None: + if calls is None or period is None: + raise TypeError("rate limit: give both calls and period") + result.append((calls, period)) + if limits is not None: + result.extend((c, p) for c, p in limits) + if not result: + raise TypeError("rate limit: give calls and period, or limits=[(calls, period), ...]") + for c, p in result: + if c < 1: + raise ValueError(f"rate_limit calls must be >= 1, got {c!r}") + if p <= 0: + raise ValueError(f"rate_limit period must be positive, got {p!r}") + return tuple(result) + + +def _describe(limits: tuple[Limit, ...]) -> str: + return " and ".join(f"{c} calls per {p:g}s" for c, p in limits) + + +class _SlidingWindow: + """Thread-safe sliding-window logs of call timestamps: per key, one deque per limit.""" + + def __init__(self, limits: tuple[Limit, ...]) -> None: + self.limits = limits + self._lock = threading.Lock() + self._buckets: dict[Hashable, tuple[deque[float], ...]] = {} + self._since_sweep = 0 + + def acquire(self, key: Hashable) -> float: + """Record a call and return 0.0, or return how long to wait before retrying. + + A call is recorded only if *every* limit allows it. + """ + with self._lock: + # Read the clock under the lock: a call that queued for the lock must be + # recorded at the time it is admitted, not the time it arrived. + now = _core.monotonic() + self._since_sweep += 1 + if self._since_sweep >= _SWEEP_EVERY: + self._sweep(now) + buckets = self._buckets.get(key) + if buckets is None: + buckets = self._buckets[key] = tuple(deque() for _ in self.limits) + wait = 0.0 + for (calls, period), bucket in zip(self.limits, buckets, strict=True): + horizon = now - period + while bucket and bucket[0] <= horizon: + bucket.popleft() + if len(bucket) >= calls: + wait = max(wait, bucket[0] + period - now) + if wait > 0: + return wait + for bucket in buckets: + bucket.append(now) + return 0.0 + + def admit(self, key: Hashable) -> None: + """Record a call now even if a limit is full (for `fast_mode()`'s blocked calls).""" + with self._lock: + now = _core.monotonic() + buckets = self._buckets.get(key) + if buckets is None: + buckets = self._buckets[key] = tuple(deque() for _ in self.limits) + for bucket in buckets: + bucket.append(now) + + def clear(self) -> None: + with self._lock: + self._buckets.clear() + self._since_sweep = 0 + + def _sweep(self, now: float) -> None: + self._since_sweep = 0 + periods = [period for _, period in self.limits] + + def is_stale(buckets: tuple[deque[float], ...]) -> bool: + return all(not b or b[-1] <= now - p for p, b in zip(periods, buckets, strict=True)) + + stale = [k for k, buckets in self._buckets.items() if is_stale(buckets)] + for k in stale: + del self._buckets[k] + + +class RateLimiter: + """A reusable sliding-window rate limit, shared by everything that uses it. + + Use one instance to give several functions or code blocks a single budget: + + * as a decorator (on sync or ``async def`` functions and methods); + * as ``with limiter:`` / ``async with limiter:`` around a block; + * through ``acquire()`` / ``try_acquire()`` / ``acquire_async()``. + + A call is allowed when fewer than ``calls`` calls started in the last ``period`` + seconds (a sliding-window log, so no ``2 * calls`` burst at a window boundary). With + ``limits=[(5, 1.0), (600, 900.0)]`` every limit must allow the call. Rejected or + still-waiting calls are not counted. Thread-safe; async callers wait with ``await`` + and never block the event loop. While ``python_wrap.testing`` disables rate limits, + every call goes through and nothing is recorded. Under ``fast_mode()`` (``no_sleep``) + a blocking call that would wait is let through at once and still recorded, so later + non-blocking calls see the limit; under ``fake_clock()`` the wait advances the fake + clock instead. + + Events: ``rate_limit.rejected(retry_after)`` for each rejected call and + ``rate_limit.waited(seconds)`` after a call had to wait, reported under ``name`` + (or the decorated function's qualified name). + + Args: + calls: Maximum number of calls per ``period`` (>= 1). + period: Window length in seconds (> 0). + limits: More ``(calls, period)`` pairs; may replace or add to ``calls``/``period``. + block: What the decorator and ``with`` do when over the limit: ``False`` + (default) raises `RateLimitExceeded`, ``True`` waits for a slot. + name: Label for error messages and events. + + Raises: + TypeError: If no limit is given, or only one of ``calls`` and ``period``. + ValueError: If a limit is out of range. + + Example: + ```python + from python_wrap.concurrency import RateLimiter + + github = RateLimiter(limits=[(4, 1.0), (5000, 3600.0)], name="github") + + @github + def get_repo(name: str) -> str: + return name + + @github + def get_user(name: str) -> str: + return name + + assert get_repo("a") == "a" and get_user("b") == "b" # one shared budget + with github: # a plain block counts too + pass + assert github.try_acquire() + assert not github.try_acquire() # 4 calls in the last second + ``` + """ + + def __init__( + self, + calls: int | None = None, + period: float | None = None, + *, + limits: Iterable[Limit] | None = None, + block: bool = False, + name: str | None = None, + ) -> None: + self.limits = _limits(calls, period, limits) + self.block = block + self.name = name + self._window = _SlidingWindow(self.limits) + + def __repr__(self) -> str: + return f"RateLimiter(name={self.name!r}, limits={list(self.limits)!r})" + + # -- core --------------------------------------------------------------------------- + + def _rejected(self, label: str, retry_after: float) -> RateLimitExceeded: + _core.emit("rate_limit.rejected", label, retry_after=retry_after) + return RateLimitExceeded( + f"{label} rate limit of {_describe(self.limits)} exceeded; " + f"retry after {retry_after:.3g}s", + retry_after=retry_after, + ) + + def _gives_up(self, label: str, wait: float, give_up: float | None) -> bool: + """True (after emitting the rejection) if ``wait`` overshoots ``give_up``.""" + if give_up is not None and _core.monotonic() + wait > give_up: + _core.emit("rate_limit.rejected", label, retry_after=wait) + return True + return False + + def _take( + self, key: Hashable, label: str, *, block: bool, timeout: float | None, strict: bool + ) -> bool: + """Take a slot in a thread. ``strict`` raises on rejection instead of returning False.""" + if _core.overrides.disable_rate_limits: + return True + wait = self._window.acquire(key) + if wait <= 0: + return True + if not block: + error = self._rejected(label, wait) + if strict: + raise error + return False + start = _core.monotonic() + give_up = None if timeout is None else start + timeout + while wait > 0: + if self._gives_up(label, wait, give_up): + return False + _core.sleep(wait) + if _core.overrides.disable_rate_limits: + break + wait = self._window.acquire(key) + if wait > 0 and _core.overrides.no_sleep: + # fast_mode() and the sleep did not move the clock: admit the call + # instead of spinning (it still counts toward the limit). + self._window.admit(key) + break + _core.emit("rate_limit.waited", label, seconds=_core.monotonic() - start) + return True + + async def _atake( + self, key: Hashable, label: str, *, block: bool, timeout: float | None, strict: bool + ) -> bool: + """`_take` for coroutines: waits with ``await``.""" + if _core.overrides.disable_rate_limits: + return True + wait = self._window.acquire(key) + if wait <= 0: + return True + if not block: + error = self._rejected(label, wait) + if strict: + raise error + return False + start = _core.monotonic() + give_up = None if timeout is None else start + timeout + while wait > 0: + if self._gives_up(label, wait, give_up): + return False + await _core.async_sleep(wait) + if _core.overrides.disable_rate_limits: + break + wait = self._window.acquire(key) + if wait > 0 and _core.overrides.no_sleep: + self._window.admit(key) # as in `_take`: never spin under fast_mode() + break + _core.emit("rate_limit.waited", label, seconds=_core.monotonic() - start) + return True + + def _label(self) -> str: + return self.name or "rate_limiter" + + # -- explicit API ------------------------------------------------------------------- + + def reset(self) -> None: + """Forget every recorded call (for every key), as if the limiter were new. + + Meant for tests: share one `RateLimiter` (or reach a `rate_limit` limiter with + `limiter_of`) and reset it between tests. Thread-safe; calls waiting for a slot + re-check and may then go through. + """ + self._window.clear() + + def try_acquire(self) -> bool: + """Take a slot if one is free right now. + + Returns: + ``True`` if the call may proceed (it is counted), ``False`` otherwise. + """ + return self._take(None, self._label(), block=False, timeout=None, strict=False) + + def acquire(self, block: bool = True, timeout: float | None = None) -> bool: + """Take a slot, waiting for one in this thread if ``block`` is true. + + Never call it from a coroutine; use `acquire_async` there. + + Args: + block: Wait for a slot (``True``) or return at once (``False``). + timeout: Give up after this many seconds (``None`` waits as long as needed). + Slots only free up as time passes, so the call returns ``False`` right + away when the next slot is further away than ``timeout``. + + Returns: + ``True`` if a slot was taken, ``False`` if not. + """ + return self._take(None, self._label(), block=block, timeout=timeout, strict=False) + + async def acquire_async(self, block: bool = True, timeout: float | None = None) -> bool: + """`acquire` for async code: waits with ``await``, never blocking the loop. + + Args: + block: Wait for a slot (``True``) or return at once (``False``). + timeout: Give up after this many seconds (``None`` waits as long as needed). + + Returns: + ``True`` if a slot was taken, ``False`` if not. + """ + return await self._atake(None, self._label(), block=block, timeout=timeout, strict=False) + + def __enter__(self) -> RateLimiter: + self._take(None, self._label(), block=self.block, timeout=None, strict=True) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + return None + + async def __aenter__(self) -> RateLimiter: + await self._atake(None, self._label(), block=self.block, timeout=None, strict=True) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + return None + + # -- decorator ---------------------------------------------------------------------- + + def __call__(self, func: F, /) -> F: + """Count every call of ``func`` against this limiter (sync or ``async def``).""" + return self._wrap(func, None) + + def _wrap(self, func: F, key: Callable[..., Hashable] | None) -> F: + label = self.name or _core.qualname(func) + block = self.block + + if _core.is_async(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + k = None if key is None else key(*args, **kwargs) + await self._atake(k, label, block=block, timeout=None, strict=True) + return await func(*args, **kwargs) + + async_wrapper.__rate_limiter__ = self # type: ignore[attr-defined] + return cast(F, async_wrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + k = None if key is None else key(*args, **kwargs) + self._take(k, label, block=block, timeout=None, strict=True) + return func(*args, **kwargs) + + wrapper.__rate_limiter__ = self # type: ignore[attr-defined] + return cast(F, wrapper) + + +def limiter_of(func: Callable[..., Any], /) -> RateLimiter | None: + """The `RateLimiter` behind a function decorated with `rate_limit` (or a limiter). + + `rate_limit` keeps the decorated function's exact type, so its limiter is reached + through this function rather than an attribute. Decorators applied on top with + `functools.wraps` keep it reachable; bound methods work too. + + Args: + func: A function decorated with `rate_limit` or with a `RateLimiter`. + + Returns: + Its limiter (the outermost one, if several), or `None` if it has none. + + Example: + ```python + from python_wrap.concurrency import limiter_of, rate_limit + + @rate_limit(1, 60.0) + def ping() -> str: + return "pong" + + assert ping() == "pong" + limiter = limiter_of(ping) + assert limiter is not None and not limiter.try_acquire() + limiter.reset() # e.g. between tests + assert ping() == "pong" + ``` + """ + limiter = getattr(func, "__rate_limiter__", None) + return limiter if isinstance(limiter, RateLimiter) else None + + +def rate_limit( + calls: int | None = None, + period: float | None = None, + *, + block: bool = False, + key: Callable[..., Hashable] | None = None, + limits: Iterable[Limit] | None = None, +) -> Callable[[F], F]: + """Allow at most ``calls`` calls per ``period`` seconds. + + Uses a sliding-window log: a call is allowed when fewer than ``calls`` calls started + in the last ``period`` seconds. Unlike a fixed window, this never lets a burst of + ``2 * calls`` through at a window boundary. Rejected (or waiting) calls are not + counted. The limiter is thread-safe and is shared by every caller of the decorated + function — including every instance, for methods — unless ``key`` splits it. + + Each decorated function gets its own `RateLimiter`, reachable with + ``limiter_of(func)`` (e.g. to ``.reset()`` it between tests). To share one budget + between several functions, create a `RateLimiter` and use it as the decorator. Pass + ``limits=[(5, 1.0), (600, 900.0)]`` to enforce several limits at once (all must + allow a call). While ``python_wrap.testing`` disables rate limits, every call goes + through; under ``fast_mode()`` a ``block=True`` call that would wait goes through at + once (and is counted). Emits ``rate_limit.rejected`` and ``rate_limit.waited``. + + Args: + calls: Maximum number of calls per window; at least 1. + period: Window length in seconds; must be positive. + block: If ``False`` (default) an over-limit call raises ``RateLimitExceeded``. + If ``True`` it waits until allowed (``async def`` functions wait with + ``await``, never blocking the event loop). + key: Optional function called with the call's arguments (including ``self`` for + methods) that returns a hashable bucket key, e.g. a user id. Each key gets + its own limit. + limits: Further ``(calls, period)`` pairs, alone or on top of ``calls``/``period``. + + Returns: + A decorator that preserves the wrapped function's signature. + + Raises: + TypeError: If no limit is given, or only one of ``calls`` and ``period``. + ValueError: If ``calls < 1`` or ``period <= 0`` (for any limit). + RateLimitExceeded: From the wrapped call when over the limit and ``block`` is + false. Its ``retry_after`` attribute says how many seconds until a slot frees. + + Example: + >>> @rate_limit(2, 1.0, key=lambda user, *_: user) + ... def send(user: str, text: str) -> None: ... + >>> @rate_limit(limits=[(5, 1.0), (600, 900.0)]) + ... def strava_call() -> None: ... + """ + all_limits = _limits(calls, period, limits) + + def decorate(func: F) -> F: + return RateLimiter(limits=all_limits, block=block)._wrap(func, key) + + return decorate + + +# --------------------------------------------------------------------------------------- +# run_in_thread +# --------------------------------------------------------------------------------------- + + +class _RunInThreadDecorator(Protocol): + @overload + def __call__( + self, func: Callable[P, Coroutine[Any, Any, T]], / + ) -> Callable[P, concurrent.futures.Future[T]]: ... + @overload + def __call__(self, func: Callable[P, T], /) -> Callable[P, concurrent.futures.Future[T]]: ... + + +@overload +def run_in_thread( + func: Callable[P, Coroutine[Any, Any, T]], / +) -> Callable[P, concurrent.futures.Future[T]]: ... +@overload +def run_in_thread(func: Callable[P, T], /) -> Callable[P, concurrent.futures.Future[T]]: ... +@overload +def run_in_thread( + func: None = None, /, *, daemon: bool = False, name: str | None = None +) -> _RunInThreadDecorator: ... +def run_in_thread( + func: Callable[..., Any] | None = None, + /, + *, + daemon: bool = False, + name: str | None = None, +) -> Any: + """Run each call in a new thread and return a `concurrent.futures.Future`. + + The future resolves to the function's return value, or raises its exception from + ``future.result()``. For ``async def`` functions the coroutine is run with + `asyncio.run` inside the new thread (so it gets its own event loop), and the + wrapper itself is a plain function returning a ``Future``. The caller's + ``contextvars`` are copied into the thread. + + Changed in 0.2.0: the wrapper used to return the ``threading.Thread``, with no way to + get the result. Call ``.result()`` on the returned future to wait (the equivalent of + the old ``thread.join()``). + + Args: + func: The function to wrap (bare ``@run_in_thread`` form). + daemon: Start daemon threads, which do not keep the interpreter alive. + name: Thread name; defaults to ``run_in_thread-``. + + Returns: + The wrapped function, or a decorator when called with options only. + + Example: + >>> @run_in_thread + ... def add(a: int, b: int) -> int: + ... return a + b + >>> add(1, 2).result() + 3 + """ + + def decorate(f: Callable[..., Any]) -> Callable[..., concurrent.futures.Future[Any]]: + thread_name = name or f"run_in_thread-{_core.qualname(f)}" + is_coro = _core.is_async(f) + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> concurrent.futures.Future[Any]: + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + context = contextvars.copy_context() + + def target() -> Any: + if is_coro: + return asyncio.run(f(*args, **kwargs)) + return f(*args, **kwargs) + + def runner() -> None: + if not future.set_running_or_notify_cancel(): + return + try: + future.set_result(context.run(target)) + except BaseException as exc: + future.set_exception(exc) + + threading.Thread(target=runner, name=thread_name, daemon=daemon).start() + return future + + return wrapper + + return decorate if func is None else decorate(func) + + +# --------------------------------------------------------------------------------------- +# throttle +# --------------------------------------------------------------------------------------- + + +class _ThrottleDecorator(Protocol): + @overload + def __call__( + self, func: Callable[P, Coroutine[Any, Any, T]], / + ) -> Callable[P, Coroutine[Any, Any, T | None]]: ... + @overload + def __call__(self, func: Callable[P, T], /) -> Callable[P, T | None]: ... + + +def throttle(interval: float) -> _ThrottleDecorator: + """Run the function at most once per ``interval`` seconds. + + The first call runs and opens a window of ``interval`` seconds (measured from when + the call started). Calls inside the window do not run; they return the result of the + most recent run that *returned* (``None`` if none has returned yet). A call that + raises propagates its exception to its own caller and still uses up its window, but + does not replace the remembered result: calls skipped inside that window get the + previous successful result (or ``None``), not the error. Thread-safe; the function + itself runs outside the lock, so a slow call does not block skipped callers. + ``async def`` functions get the same semantics with an ``async`` wrapper. + + Args: + interval: Minimum seconds between runs; must be positive. + + Returns: + A decorator; the wrapped function returns ``R | None``. + + Raises: + ValueError: If ``interval`` is not positive. + + Example: + >>> @throttle(1.0) + ... def refresh() -> str: + ... return "fresh" + """ + if interval <= 0: + raise ValueError(f"throttle interval must be positive, got {interval!r}") + + def decorate(func: Callable[..., Any]) -> Callable[..., Any]: + lock = threading.Lock() + last_start: float | None = None + last_result: Any = None + + name = _core.qualname(func) + + def should_run() -> tuple[bool, Any]: + nonlocal last_start + if _core.overrides.disable_rate_limits: + return True, None + now = _core.monotonic() + with lock: + if last_start is None or now - last_start >= interval: + last_start = now + return True, None + previous = last_result + _core.emit("throttle.skipped", name) + return False, previous + + def store(result: Any) -> Any: + nonlocal last_result + with lock: + last_result = result + return result + + if _core.is_async(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + run, previous = should_run() + if not run: + return previous + return store(await func(*args, **kwargs)) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + run, previous = should_run() + if not run: + return previous + return store(func(*args, **kwargs)) + + return wrapper + + return cast(_ThrottleDecorator, decorate) + + +# --------------------------------------------------------------------------------------- +# debounce +# --------------------------------------------------------------------------------------- + + +class Debounced(Protocol[P, R_co]): + """A debounced sync function: calls return ``None``; the real call happens later. + + Binds ``self`` and ``cls`` like `python_wrap.caching.Memoized`, with the same typing + limitations. + """ + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + + @overload + def __get__( + self: Debounced[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _DebouncedOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> Debounced[P, R_co]: ... + @overload + def __get__( + self: Debounced[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None + ) -> Debounced[P2, R_co]: ... + + def cancel(self) -> None: + """Drop the pending call, if any.""" + + def flush(self) -> R_co | None: + """Run the pending call now and return its result (``None`` if nothing pending).""" + + +class _DebouncedOnClass(Debounced[P, R_co], Protocol[P, P2, R_co]): + """`Debounced` read from a class: accepts the call with and without the first argument. + + Returned for class access (``Cls.attr``) when the first parameter could be ``cls``. + pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an + instance or static method there, so both call forms are accepted. + """ + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> None: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + + +class AsyncDebounced(Protocol[P, R_co]): + """A debounced ``async def`` function; see `debounce` for the await semantics. + + Binds ``self`` and ``cls`` like `python_wrap.caching.Memoized`, with the same typing + limitations. + """ + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R_co]: ... + + @overload + def __get__( + self: AsyncDebounced[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _AsyncDebouncedOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> AsyncDebounced[P, R_co]: ... + @overload + def __get__( + self: AsyncDebounced[Concatenate[Any, P2], R_co], + instance: object, + owner: type | None = None, + ) -> AsyncDebounced[P2, R_co]: ... + + def cancel(self) -> None: + """Drop the pending call; callers awaiting it get ``CancelledError``.""" + + def flush(self) -> Coroutine[Any, Any, R_co | None]: + """Run the pending call now; awaiting returns its result (``None`` if none).""" + + +class _AsyncDebouncedOnClass(AsyncDebounced[P, R_co], Protocol[P, P2, R_co]): + """`AsyncDebounced` read from a class: accepts the call with and without the first argument. + + Returned for class access (``Cls.attr``) when the first parameter could be ``cls``. + pyright only picks it under ``@classmethod``; mypy cannot tell a classmethod from an + instance or static method there, so both call forms are accepted. + """ + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> Coroutine[Any, Any, R_co]: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R_co]: ... + + +class _DebounceDecorator(Protocol): + @overload + def __call__( # type: ignore[overload-overlap] + self, func: Callable[P, Coroutine[Any, Any, T]], / + ) -> AsyncDebounced[P, T]: ... + @overload + def __call__(self, func: Callable[P, T], /) -> Debounced[P, T]: ... + + +_Pending = tuple[tuple[Any, ...], dict[str, Any]] + + +def _debounce_sync(func: Callable[..., Any], wait: float) -> Callable[..., None]: + lock = threading.Lock() + timer: threading.Timer | None = None + pending: _Pending | None = None + + def take() -> _Pending | None: + nonlocal timer, pending + if timer is not None: + timer.cancel() + call, timer, pending = pending, None, None + return call + + def fire(me: list[threading.Timer]) -> None: + with lock: + if timer is not me[0]: # superseded or cancelled meanwhile + return + call = take() + if call is not None: + func(*call[0], **call[1]) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> None: + nonlocal timer, pending + with lock: + take() + me: list[threading.Timer] = [] + timer = threading.Timer(wait, fire, args=(me,)) + me.append(timer) + timer.daemon = True + pending = (args, kwargs) + timer.start() + + def cancel() -> None: + with lock: + take() + + def flush() -> Any: + with lock: + call = take() + return None if call is None else func(*call[0], **call[1]) + + wrapper.cancel = cancel # type: ignore[attr-defined] + wrapper.flush = flush # type: ignore[attr-defined] + return wrapper + + +class _AsyncBurst: + """One burst of async debounced calls: the latest arguments and a shared outcome.""" + + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + self.outcome: asyncio.Future[Any] = loop.create_future() + self.timer: asyncio.Task[None] | None = None + self.call: _Pending | None = None + + +def _debounce_async(func: Callable[..., Awaitable[Any]], wait: float) -> Callable[..., Any]: + bursts: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _AsyncBurst] = ( + weakref.WeakKeyDictionary() + ) + # Several loops (in several threads) share `bursts`; each burst itself is only ever + # touched from its own loop's thread. + bursts_lock = threading.Lock() + + async def run(burst: _AsyncBurst) -> Any: + loop = asyncio.get_running_loop() + with bursts_lock: + if bursts.get(loop) is burst: + del bursts[loop] + call = burst.call + if call is None or burst.outcome.done(): + return None + try: + result = await func(*call[0], **call[1]) + except asyncio.CancelledError: + burst.outcome.cancel() + raise + except BaseException as exc: + if not burst.outcome.done(): + burst.outcome.set_exception(exc) + burst.outcome.exception() # mark retrieved: callers may all be gone + raise + if not burst.outcome.done(): + burst.outcome.set_result(result) + return result + + async def sleep_then_run(burst: _AsyncBurst) -> None: + await asyncio.sleep(wait) # real time, like the sync `threading.Timer` + burst.timer = None # past the cancellation point: run even if called again now + with contextlib.suppress(Exception): # delivered to callers via burst.outcome + await run(burst) + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + loop = asyncio.get_running_loop() + with bursts_lock: + burst = bursts.get(loop) + if burst is None: + burst = bursts[loop] = _AsyncBurst(loop) + if burst.timer is not None: + burst.timer.cancel() + burst.call = (args, kwargs) + burst.timer = loop.create_task(sleep_then_run(burst)) + return await asyncio.shield(burst.outcome) + + def cancel_burst(burst: _AsyncBurst) -> None: + if burst.timer is not None: + burst.timer.cancel() + burst.outcome.cancel() + + def cancel() -> None: + try: + running: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() + except RuntimeError: + running = None + with bursts_lock: + if running is not None: + taken = [(running, bursts.pop(running, None))] + else: + taken = [(lp, bursts.pop(lp)) for lp in list(bursts)] + for lp, burst in taken: + if burst is None: + continue + if lp.is_closed(): + continue + if lp is running or not lp.is_running(): + cancel_burst(burst) + else: + # Another thread's loop: futures are not thread-safe, so hand the + # cancellation to that loop (which also wakes it up). + with contextlib.suppress(RuntimeError): # closed meanwhile + lp.call_soon_threadsafe(cancel_burst, burst) + + async def flush() -> Any: + with bursts_lock: + burst = bursts.get(asyncio.get_running_loop()) + if burst is None: + return None + if burst.timer is not None: + burst.timer.cancel() + burst.timer = None + return await run(burst) + + wrapper.cancel = cancel # type: ignore[attr-defined] + wrapper.flush = flush # type: ignore[attr-defined] + return wrapper + + +def debounce(wait: float) -> _DebounceDecorator: + """Postpone running until ``wait`` seconds have passed since the last call. + + Each call restarts the countdown and replaces the pending arguments, so only the last + call of a burst runs. + + * Sync functions: the countdown is a daemon `threading.Timer`, so the function + runs in the timer's thread. The wrapper returns ``None`` immediately. + * ``async def`` functions: a call schedules a task on the running loop (cancelling the + previously scheduled one) and then waits for the burst to finish. **Every caller in + a burst awaits the same outcome** — the result (or exception) of the final call — + so superseded callers are not left hanging or cancelled. Calls must be awaited (or + wrapped in a task) to take effect; a single task awaiting sequentially simply waits + ``wait`` seconds per call. Bursts are tracked per event loop. + + The wrapper also has: + + * ``.cancel()`` — drop the pending call (async awaiters get ``CancelledError``). + Thread-safe. For ``async def`` functions, inside a running loop it cancels that + loop's pending call; from a thread with no running loop it cancels the pending + call of every loop (handing the cancellation to each loop's own thread). + * ``.flush()`` — run the pending call right now and return its result, or ``None`` + if nothing is pending. For ``async def`` functions it is a coroutine to ``await``. + + State belongs to the decorated function, so on a method it is shared by all + instances: calls on different instances debounce each other. + + Args: + wait: Quiet period in seconds; must be non-negative. + + Returns: + A decorator producing a `Debounced` or `AsyncDebounced` wrapper. + + Raises: + ValueError: If ``wait`` is negative. + + Example: + >>> @debounce(0.3) + ... def save(text: str) -> None: ... + >>> save("a"); save("ab"); save.flush() # runs once, with "ab" + """ + if wait < 0: + raise ValueError(f"debounce wait must be non-negative, got {wait!r}") + + def decorate(func: Callable[..., Any]) -> Callable[..., Any]: + if _core.is_async(func): + return _debounce_async(func, wait) + return _debounce_sync(func, wait) + + return cast(_DebounceDecorator, decorate) + + +# --------------------------------------------------------------------------------------- +# singleflight +# --------------------------------------------------------------------------------------- + + +class _KwargsMark: + """Private marker in keys built from keyword arguments (never equal to user data).""" + + __slots__ = () + + def __repr__(self) -> str: + return "" + + +_KWARGS: Final = _KwargsMark() + + +def _default_key(*args: Any, **kwargs: Any) -> Hashable: + # The marker keeps ``f(1, b=2)`` and ``f((1,), (("b", 2),))`` apart. + return (args, _KWARGS, tuple(sorted(kwargs.items()))) if kwargs else args + + +class _Flights(Generic[T]): + """Thread-side bookkeeping for singleflight: key -> (in-flight future, leader thread).""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.inflight: dict[Hashable, tuple[concurrent.futures.Future[T], int]] = {} + + +class _AsyncFlight: + """One in-flight async singleflight run: its task and the callers waiting for it.""" + + __slots__ = ("task", "waiters") + + def __init__(self) -> None: + self.task: asyncio.Task[None] | None = None + self.waiters: list[asyncio.Future[Any]] = [] + + def deliver(self, result: Any = None, exc: BaseException | None = None) -> None: + """Hand the outcome to every caller still waiting (``exc=None`` means success).""" + waiters, self.waiters = self.waiters, [] + for waiter in waiters: + if waiter.done(): + continue + if exc is None: + waiter.set_result(result) + elif isinstance(exc, asyncio.CancelledError): + waiter.cancel() + else: + waiter.set_exception(exc) + + +def _start_task( + loop: asyncio.AbstractEventLoop, coro: Coroutine[Any, Any, None] +) -> asyncio.Task[None]: + """Start the shared run, eagerly where possible. + + On Python 3.12+ with no custom task factory the task starts eagerly: it runs right + away, up to its first suspension, so a run that never suspends (an in-memory hit, + say) has finished before the caller would have waited at all. Otherwise (3.10/3.11, + or a task factory is installed, which is respected) it starts on the next loop + iteration, as `loop.create_task` does. + """ + if _EAGER_TASKS and loop.get_task_factory() is None: + return asyncio.Task(coro, loop=loop, eager_start=True) # type: ignore[call-arg,unused-ignore] + return loop.create_task(coro) + + +_EAGER_TASKS = sys.version_info >= (3, 12) + + +async def _await_flight(flight: _AsyncFlight, waiter: asyncio.Future[T]) -> T: + """Wait for a flight's outcome; a cancelled caller detaches, the run carries on. + + The run resolves ``waiter`` itself (no done-callback relay), so an uncontended call + costs one loop iteration for the run plus the wake-up, and none at all when the + task ran eagerly to completion. + """ + try: + return await waiter + except asyncio.CancelledError: + if waiter.done() and not waiter.cancelled(): + waiter.exception() # outcome arrived just as we were cancelled: retrieved + else: + with contextlib.suppress(ValueError): + flight.waiters.remove(waiter) + raise + + +@overload +def singleflight(func: F, /) -> F: ... +@overload +def singleflight( + func: None = None, /, *, key: Callable[..., Hashable] | None = None +) -> Callable[[F], F]: ... +def singleflight( + func: Callable[..., Any] | None = None, + /, + *, + key: Callable[..., Hashable] | None = None, +) -> Any: + """Let concurrent calls with the same arguments share a single execution. + + While a call for a given key is running, further calls with that key do not start + the function again; they wait and receive the same result, or the same exception. + Once it finishes, the next call runs fresh — this de-duplicates work in flight, it + is not a cache. + + Sync functions de-duplicate across threads. ``async def`` functions de-duplicate + across tasks on the same event loop: the work runs in a shared task, so cancelling + one waiting caller does not cancel it for the others. + + A re-entrant call — the function calling itself with the same key, on the leader's + own thread (sync) or from inside the shared task (async) — cannot wait for its own + result, so it runs the function directly, as if it were not decorated. + + Args: + func: The function to wrap (bare ``@singleflight`` form). + key: Function of the call's arguments returning a hashable key. Defaults to the + positional arguments plus sorted keyword arguments (including ``self`` for + methods), which must then be hashable. + + Returns: + The wrapped function, or a decorator when called with options only. + + Raises: + TypeError: If the default key is used and an argument is unhashable. + + Example: + >>> @singleflight + ... def load_config(path: str) -> str: + ... return open(path).read() + """ + key_func = key or _default_key + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + if _core.is_async(f): + async_f = cast(Callable[P, Awaitable[Any]], f) + flights_by_loop: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, dict[Hashable, _AsyncFlight] + ] = weakref.WeakKeyDictionary() + loops_lock = threading.Lock() # loops in several threads share the mapping + + @functools.wraps(f) + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: + k = key_func(*args, **kwargs) + loop = asyncio.get_running_loop() + inflight = flights_by_loop.get(loop) + if inflight is None: + with loops_lock: + inflight = flights_by_loop.setdefault(loop, {}) + flight = inflight.get(k) + if flight is not None and flight.task is asyncio.current_task(): + # Re-entrant call from inside the shared task: waiting for ourselves + # would deadlock, so run the function directly. + return await async_f(*args, **kwargs) + waiter: asyncio.Future[Any] = loop.create_future() + if flight is not None: + flight.waiters.append(waiter) + return await _await_flight(flight, waiter) + + new = inflight[k] = _AsyncFlight() + new.waiters.append(waiter) # before the task starts: it may run eagerly + + async def run() -> None: + new.task = asyncio.current_task() + try: + result = await async_f(*args, **kwargs) + except BaseException as exc: + if inflight.get(k) is new: + del inflight[k] + new.deliver(exc=exc) + if not isinstance(exc, Exception): + raise # CancelledError, KeyboardInterrupt, SystemExit + # Delivered to the callers; the task itself ends quietly, so an + # orphaned failure is never logged as "never retrieved". + else: + if inflight.get(k) is new: + del inflight[k] + new.deliver(result) + + # Keep a strong reference: the event loop only holds tasks weakly. + new.task = _start_task(loop, run()) + return await _await_flight(new, waiter) + + return cast(Callable[P, R], async_wrapper) + + flights: _Flights[R] = _Flights() + + @functools.wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + k = key_func(*args, **kwargs) + me = threading.get_ident() + with flights.lock: + entry = flights.inflight.get(k) + if entry is None: + future: concurrent.futures.Future[R] = concurrent.futures.Future() + flights.inflight[k] = (future, me) + leader = True + else: + future, owner = entry + leader = False + if not leader: + if owner == me: + # Re-entrant call on the leader's own thread: waiting for our own + # result would deadlock, so run the function directly. + return f(*args, **kwargs) + return future.result() + try: + result = f(*args, **kwargs) + except BaseException as exc: + with flights.lock: + del flights.inflight[k] + future.set_exception(exc) + raise + with flights.lock: + del flights.inflight[k] + future.set_result(result) + return result + + return wrapper + + return decorate if func is None else decorate(func) diff --git a/src/python_wrap/config.py b/src/python_wrap/config.py new file mode 100644 index 0000000..17a2863 --- /dev/null +++ b/src/python_wrap/config.py @@ -0,0 +1,211 @@ +"""Project-wide defaults for python-wrap decorators. + +Call `configure` once at application start-up, **before** the decorators you want to +affect are applied. Values are read when a decorator is *applied* (at import time for +module-level functions), so a decorator applied earlier keeps the settings it saw. +Arguments passed explicitly to a decorator always win over configured defaults. +""" + +from __future__ import annotations + +import contextlib +import difflib +import logging +import threading +from collections.abc import Iterable, Iterator, Mapping +from typing import Any + +from python_wrap import _core + +__all__ = ["configure", "configured", "get_config", "reset_config"] + +UNSET: Any = object() +"""Sentinel meaning "argument not passed"; resolved against the configured defaults.""" + +_KEYS = ("logger", "retry", "redact", "redact_extra") +_RETRY_KEYS = ( + "max_attempts", + "delay", + "backoff", + "max_delay", + "jitter", + "max_time", + "log_level", +) + +_lock = threading.RLock() +_state: dict[str, Any] = {} + + +def _hint(name: str, valid: tuple[str, ...], what: str) -> TypeError: + close = difflib.get_close_matches(name, valid, n=1) + tip = f" Did you mean {close[0]!r}?" if close else "" + return TypeError(f"Unknown {what} {name!r}.{tip} Valid: {', '.join(valid)}.") + + +def _names(value: Any, key: str) -> tuple[str, ...]: + if isinstance(value, str) or not isinstance(value, Iterable): + raise TypeError(f"{key} must be an iterable of strings, got {value!r}") + names = tuple(value) + if not all(isinstance(n, str) for n in names): + raise TypeError(f"{key} must contain only strings, got {value!r}") + return names + + +def _validate(key: str, value: Any) -> Any: + if key == "logger": + if value is not None and not isinstance(value, (str, logging.Logger)): + raise TypeError(f"logger must be a logging.Logger, a name or None, got {value!r}") + return value + if key == "retry": + if not isinstance(value, Mapping): + raise TypeError(f"retry must be a mapping of retry defaults, got {value!r}") + for name in value: + if name not in _RETRY_KEYS: + raise _hint(str(name), _RETRY_KEYS, "retry default") + from python_wrap.retry import _validate as validate_retry # avoids an import cycle + + validate_retry( + value.get("max_attempts", 3), + value.get("delay", 1.0), + value.get("backoff", 1.0), + value.get("max_delay"), + value.get("jitter", 0.0), + value.get("max_time"), + ) + return dict(value) + if key == "redact": + return None if value is None else _names(value, "redact") + return _names(value, "redact_extra") + + +def configure(**defaults: Any) -> None: + """Set project-wide defaults for python-wrap decorators. + + Call it once at start-up, before decorating: values are read when a decorator is + applied, so decorators applied earlier keep their settings. Keys not mentioned keep + their current value; each key you pass replaces that key entirely (`retry={...}` + replaces the previous retry defaults). Explicit decorator arguments always win. + + Args: + **defaults: Any of: + + - `logger`: logger (or name) used by every decorator that logs when it + is not given `logger=`. `None` restores the package logger. + - `retry`: dict of defaults for `retry`/`retrying`; keys `max_attempts`, + `delay`, `backoff`, `max_delay`, `jitter`, `max_time`, `log_level`. + - `redact`: default redaction fragments for `log_args` and `audit` + (replaces `DEFAULT_REDACT`); `None` restores it. + - `redact_extra`: fragments added to the redaction list (the built-ins + stay), for `log_args` and `audit`. + + Raises: + TypeError: For an unknown key (with a "did you mean" hint) or a wrongly typed value. + ValueError: If a retry default is out of range. + + Example: + ```python + import logging + from python_wrap.config import configure, reset_config + + configure(logger=logging.getLogger("app"), retry={"max_attempts": 5, "delay": 0.5}) + reset_config() + ``` + """ + for key in defaults: + if key not in _KEYS: + raise _hint(key, _KEYS, "configuration key") + clean = {k: _validate(k, v) for k, v in defaults.items()} + with _lock: + _state.update(clean) + if "logger" in clean: + _core._default_logger = clean["logger"] + + +def get_config() -> dict[str, Any]: + """Return a copy of the current configuration. + + Returns: + A dict with keys `logger`, `retry`, `redact` and `redact_extra`, holding the + defaults in force (`None`, `{}`, `None` and `()` when nothing is configured). + + Example: + ```python + from python_wrap.config import get_config + + assert get_config()["retry"] == {} + ``` + """ + with _lock: + return { + "logger": _state.get("logger"), + "retry": dict(_state.get("retry", {})), + "redact": _state.get("redact"), + "redact_extra": _state.get("redact_extra", ()), + } + + +def reset_config() -> None: + """Forget every configured default, restoring the built-in behaviour. + + Example: + ```python + from python_wrap.config import configure, get_config, reset_config + + configure(retry={"max_attempts": 9}) + reset_config() + assert get_config()["retry"] == {} + ``` + """ + with _lock: + _state.clear() + _core._default_logger = None + + +@contextlib.contextmanager +def configured(**defaults: Any) -> Iterator[None]: + """Apply `configure(**defaults)` for a `with` block, then restore the previous state. + + Meant for tests. Remember that decorators read the defaults when applied, so apply + them inside the block. + + Args: + **defaults: Same keys as `configure`. + + Example: + ```python + from python_wrap.config import configured, get_config + + with configured(retry={"max_attempts": 2}): + assert get_config()["retry"] == {"max_attempts": 2} + assert get_config()["retry"] == {} + ``` + """ + with _lock: + saved = dict(_state) + configure(**defaults) + try: + yield + finally: + with _lock: + _state.clear() + _state.update(saved) + _core._default_logger = saved.get("logger") + + +def retry_default(name: str, value: Any, fallback: Any) -> Any: + """Resolve a retry argument: explicit value, else configured default, else `fallback`.""" + if value is not UNSET: + return value + with _lock: + retry_defaults: dict[str, Any] = _state.get("retry", {}) + return retry_defaults.get(name, fallback) + + +def redaction(explicit: Iterable[str] | None, builtin: tuple[str, ...]) -> tuple[str, ...]: + """Final redaction fragments: explicit list or configured/built-in, plus `redact_extra`.""" + with _lock: + base: tuple[str, ...] | None = _state.get("redact") + extra: tuple[str, ...] = _state.get("redact_extra", ()) + chosen = tuple(explicit) if explicit is not None else (builtin if base is None else base) + return chosen + extra diff --git a/src/python_wrap/deadline.py b/src/python_wrap/deadline.py new file mode 100644 index 0000000..a785ba9 --- /dev/null +++ b/src/python_wrap/deadline.py @@ -0,0 +1,168 @@ +"""Deadlines: one time budget shared by every call inside a block. + +`deadline(seconds)` records an absolute point in time in a `contextvars.ContextVar`, +so it follows the code it wraps across function calls, `await`s and tasks created +inside the block (each task copies the context it was created in). Inner code asks +`remaining()` how much time is left, or calls `check()` to fail fast once it is gone. + +A deadline is cooperative: it never interrupts anything by itself. The toolkit's +`timeout` (decorator or context manager) clamps its own limit to the remaining deadline, +so a `timeout(30)` inside `deadline(5)` gives up after at most 5 seconds. Your own code +can do the same, e.g. pass `remaining()` as an HTTP client timeout. + +Nested deadlines never extend an outer one: the earliest wins. +""" + +from __future__ import annotations + +import contextvars +from types import TracebackType + +from python_wrap import _core +from python_wrap.errors import TimeoutExpired + +__all__ = ["Deadline", "check", "deadline", "remaining"] + +# (absolute expiry on the ``_core.monotonic()`` clock, seconds the setting block asked for) +_current: contextvars.ContextVar[tuple[float, float] | None] = contextvars.ContextVar( + "python_wrap_deadline", default=None +) + + +class Deadline: + """A time budget for a block of code; create it with `deadline`. + + Use as `with deadline(5):` or `async with deadline(5):`. Entering computes the + absolute expiry; if an enclosing deadline expires sooner, that one stays in force. + One instance may be entered several times, but each `with` must exit in the reverse + order of entering (as `with` blocks naturally do in one thread or task). + + Attributes: + seconds: The budget this deadline was created with. + """ + + def __init__(self, seconds: float) -> None: + if seconds < 0: + raise ValueError(f"deadline seconds must be >= 0, got {seconds!r}") + self.seconds = seconds + self._tokens: list[contextvars.Token[tuple[float, float] | None]] = [] + + @property + def expires_at(self) -> float | None: + """The effective expiry (on the `_core.monotonic()` clock) in the current context. + + `None` when no deadline is active here. + """ + current = _current.get() + return None if current is None else current[0] + + def __enter__(self) -> Deadline: + expiry = _core.monotonic() + self.seconds + outer = _current.get() + # An enclosing deadline that comes first stays in force. + keep_outer = outer is not None and outer[0] <= expiry + value = outer if keep_outer and outer is not None else (expiry, self.seconds) + self._tokens.append(_current.set(value)) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + _current.reset(self._tokens.pop()) + + async def __aenter__(self) -> Deadline: + return self.__enter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.__exit__(exc_type, exc, tb) + + def __repr__(self) -> str: + return f"Deadline(seconds={self.seconds!r}, remaining={remaining()!r})" + + +def deadline(seconds: float) -> Deadline: + """Give the enclosed block (and everything it calls) at most ``seconds`` seconds. + + The deadline is stored in a context variable, so it is visible to every function + called inside the block, sync or async, and to tasks started from it. Nesting never + extends time: an inner `deadline(10)` inside `deadline(2)` still ends after 2 seconds. + + Nothing is interrupted automatically. `timeout` clamps to the remaining time, and + your code can call `remaining()` or `check()`. + + Args: + seconds: The budget in seconds (>= 0). + + Returns: + A `Deadline` to use with `with` or `async with`. + + Raises: + ValueError: If ``seconds`` is negative. + + Example: + ```python + from python_wrap.deadline import deadline, remaining + + assert remaining() is None + with deadline(5): + with deadline(60): # cannot extend the outer budget + left = remaining() + assert left is not None and left <= 5 + assert remaining() is None + ``` + """ + return Deadline(seconds) + + +def remaining() -> float | None: + """Seconds left before the current deadline, or `None` if there is none. + + Never negative: an expired deadline reports `0.0`. + + Returns: + The remaining time in seconds, or `None` outside any `deadline` block. + + Example: + ```python + from python_wrap.deadline import deadline, remaining + + with deadline(2): + budget = remaining() # e.g. pass as an HTTP client timeout + assert budget is not None and 0 < budget <= 2 + ``` + """ + current = _current.get() + if current is None: + return None + return max(current[0] - _core.monotonic(), 0.0) + + +def check() -> None: + """Raise `TimeoutExpired` if the current deadline has passed; otherwise do nothing. + + Call it between steps of a long job to stop as soon as the budget is gone. + + Raises: + TimeoutExpired: The deadline has passed. Its ``seconds`` is the budget of the + `deadline` block that set it. + + Example: + ```python + from python_wrap.deadline import check, deadline + + with deadline(5): + for step in range(3): + check() # raises TimeoutExpired once the 5 seconds are used up + ``` + """ + current = _current.get() + if current is not None and _core.monotonic() >= current[0]: + raise TimeoutExpired(f"deadline of {current[1]:g}s has passed", seconds=current[1]) diff --git a/src/python_wrap/decorators.py b/src/python_wrap/decorators.py new file mode 100644 index 0000000..e5e93ba --- /dev/null +++ b/src/python_wrap/decorators.py @@ -0,0 +1,7 @@ +"""Compatibility module: v0.1 imported everything from ``python_wrap.decorators``. + +New code should import from ``python_wrap`` directly. +""" + +from . import * # noqa: F403 +from . import __all__ as __all__ diff --git a/src/python_wrap/devtools.py b/src/python_wrap/devtools.py new file mode 100644 index 0000000..292029e --- /dev/null +++ b/src/python_wrap/devtools.py @@ -0,0 +1,385 @@ +"""Developer tools: deprecation warnings, mock data and decorator composition.""" + +from __future__ import annotations + +import functools +import inspect +import warnings +from collections.abc import Awaitable, Callable, Iterable +from typing import Any, TypeVar, cast, overload + +from python_wrap import _core +from python_wrap._core import P, R + +F = TypeVar("F", bound=Callable[..., Any]) +T = TypeVar("T") + +__all__ = ["compose", "deprecate", "mock_data", "no_debug", "wrap_methods"] + + +def _mark(obj: T, text: str) -> T: + """Set the PEP 702 style ``__deprecated__`` attribute.""" + obj.__deprecated__ = text # type: ignore[attr-defined] + return obj + + +def _message_for( + target: Any, + message: str | None, + since: str | None, + removed_in: str | None, + alternative: str | None, +) -> str: + """Return an explicit message, or build one from the parts that were given.""" + if message is not None: + return message + func = target.__func__ if isinstance(target, (staticmethod, classmethod)) else target + text = f"{_core.qualname(func)} is deprecated" + if since: + text += f" since {since}" + if removed_in: + text += f" and will be removed in {removed_in}" + if alternative: + text += f"; use {alternative} instead" + return text + "." + + +def _wrap_class(cls: type, text: str, category: type[Warning]) -> type: + """Wrap ``__init__`` (or ``__new__``) in place, so the class stays the same object. + + ``__new__`` is wrapped instead when the class has no ``__init__`` of its own but + a custom ``__new__`` (``float`` subclasses, ``NamedTuple``...): installing an + ``__init__`` there would make ``object.__init__`` reject the arguments. + """ + current_new: Any = cls.__new__ + use_new = "__init__" not in cls.__dict__ and current_new is not object.__new__ + if use_new: + original_new = current_new + + def warned_new(klass: Any, *args: Any, **kwargs: Any) -> Any: + warnings.warn(text, category, stacklevel=2) + return original_new(klass, *args, **kwargs) + + warned_new.__name__ = "__new__" + warned_new.__qualname__ = f"{cls.__qualname__}.__new__" + cls.__new__ = staticmethod(warned_new) # type: ignore[method-assign,assignment] + return _mark(cls, text) + + original_init: Any = cls.__init__ # type: ignore[misc] + + @functools.wraps(original_init) + def warned_init(self: Any, *args: Any, **kwargs: Any) -> None: + warnings.warn(text, category, stacklevel=2) + original_init(self, *args, **kwargs) + + cls.__init__ = warned_init # type: ignore[misc] + return _mark(cls, text) + + +def _wrap_callable(func: Any, text: str, category: type[Warning]) -> Any: + """Wrap a plain or async function so each call (or await) warns.""" + if _core.is_async(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + warnings.warn(text, category, stacklevel=2) + return await func(*args, **kwargs) + + return _mark(async_wrapper, text) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + warnings.warn(text, category, stacklevel=2) + return func(*args, **kwargs) + + return _mark(wrapper, text) + + +def _wrap(target: Any, text: str, category: type[Warning]) -> Any: + """Dispatch on the kind of object being deprecated.""" + if isinstance(target, (staticmethod, classmethod)): + # Keep the descriptor type so the method still binds the same way. + return type(target)(_wrap(target.__func__, text, category)) + if isinstance(target, type): + return _wrap_class(target, text, category) + return _wrap_callable(target, text, category) + + +@overload +def deprecate(target: F, /) -> F: ... +@overload +def deprecate( + message: str | None = None, + *, + since: str | None = None, + removed_in: str | None = None, + alternative: str | None = None, + category: type[Warning] = DeprecationWarning, +) -> Callable[[F], F]: ... +def deprecate( + message: str | Callable[..., Any] | None = None, + *, + since: str | None = None, + removed_in: str | None = None, + alternative: str | None = None, + category: type[Warning] = DeprecationWarning, +) -> Any: + """Mark a function, method, or class as deprecated. + + Every call (or, for ``async def``, every await) emits a warning that points at + the caller's line. Classes are changed in place: their ``__init__`` warns, and + they stay classes, so ``isinstance`` keeps working. + + Works bare, with a message (the v0.1 form), or with structured parts. The parts + are combined into ``" is deprecated since ... and will be removed in ...; + use ... instead."``. An explicit ``message`` replaces that generated text. + + Args: + message: Full warning text. Replaces the generated text when given. + since: Version in which the deprecation started. + removed_in: Version in which the target will be removed. + alternative: Name of the replacement to use instead. + category: Warning class to emit. + + Returns: + The decorated object when used bare, otherwise a decorator. + + Example: + ```python + @deprecate(since="0.2", removed_in="1.0", alternative="new_func") + def old_func() -> None: ... + + @deprecate("Use Widget2 instead") + class Widget: ... + ``` + """ + if callable(message): + text = _message_for(message, None, since, removed_in, alternative) + return _wrap(message, text, category) + + def decorate(target: Any) -> Any: + text = _message_for(target, message, since, removed_in, alternative) + return _wrap(target, text, category) + + return decorate + + +def mock_data(data: Any, *, enabled: bool | Callable[[], bool] = True) -> Callable[[F], F]: + """Replace a function's result with fixed data, for tests and local runs. + + When enabled, the wrapped function is not called and ``data`` is returned. If + ``data`` is callable it is called on each call and its result is returned, so use + ``lambda: fn`` to return a callable itself. When disabled, the real function runs. + + Args: + data: Value to return, or a zero-argument callable producing it. + enabled: ``True``/``False``, or a zero-argument callable that returns one. + It is evaluated on every call, so it can read an environment variable. + + Returns: + A decorator that works on plain and ``async def`` functions. + + Example: + ```python + @mock_data({"id": 1}, enabled=lambda: os.environ.get("MOCK") == "1") + def fetch_user(user_id: int) -> dict[str, int]: ... + ``` + """ + + def decorate(func: Callable[P, R]) -> Callable[P, R]: + if _core.is_async(func): + async_func = cast("Callable[P, Awaitable[Any]]", func) + + @functools.wraps(func) + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: + if _is_enabled(enabled): + return _mock_value(data) + return await async_func(*args, **kwargs) + + return cast("Callable[P, R]", async_wrapper) + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: + if _is_enabled(enabled): + return _mock_value(data) + return func(*args, **kwargs) + + return cast("Callable[P, R]", wrapper) + + return cast("Callable[[F], F]", decorate) + + +def _is_enabled(enabled: bool | Callable[[], bool]) -> bool: + return enabled() if callable(enabled) else enabled + + +def _mock_value(data: Any) -> Any: + return data() if callable(data) else data + + +@overload +def no_debug(func: F, /) -> F: ... +@overload +def no_debug(func: None = None, /) -> Callable[[F], F]: ... +def no_debug(func: Callable[..., Any] | None = None, /) -> Any: + """Deprecated no-op, kept only so v0.1 code keeps importing. + + It returns the function unchanged and emits a ``DeprecationWarning`` when applied. + It will be removed in 0.3. To hide debug output, configure logging levels instead. + + Args: + func: The function to return unchanged. Omit it to get a decorator. + + Returns: + ``func`` itself, or an identity decorator when ``func`` is omitted. + + Example: + ```python + @no_debug # warns: removed in 0.3, use logging levels instead + def compute() -> int: + return 1 + ``` + """ + warnings.warn( + "no_debug() does nothing and will be removed in 0.3; " + "configure logging levels instead (see logging.Logger.setLevel).", + DeprecationWarning, + stacklevel=2, + ) + if func is None: + + def decorate(target: F) -> F: + return target + + return decorate + return func + + +def compose(*decorators: Callable[[Any], Any]) -> Callable[[F], F]: + """Combine decorators into one, applied as if stacked from top to bottom. + + ``compose(a, b, c)(f)`` is the same as ``a(b(c(f)))``. With no decorators it + returns the function unchanged. + + Args: + *decorators: Decorators to combine, outermost first. + + Returns: + A single decorator that applies all of them. + + Raises: + TypeError: If any argument is not callable. + + Example: + ```python + @compose(deprecate("old api"), mock_data(7)) + def value() -> int: + return 1 + ``` + """ + for index, decorator in enumerate(decorators): + if not callable(decorator): + raise TypeError( + f"compose() arguments must be callable; argument {index} is {decorator!r}" + ) + + def decorate(func: F) -> F: + result: Any = func + for decorator in reversed(decorators): + result = decorator(result) + return cast(F, result) + + return decorate + + +C = TypeVar("C", bound=type) + + +def _is_dunder(name: str) -> bool: + return name.startswith("__") and name.endswith("__") + + +def wrap_methods( + *decorators: Callable[[Any], Any], + include: Iterable[str] | Callable[[str], bool] | None = None, + exclude: Iterable[str] | Callable[[str], bool] | None = None, + include_private: bool = False, +) -> Callable[[C], C]: + """Class decorator that applies decorators to the methods of a class. + + Decorators are given outermost first, like `compose`; a `Policy` works too. + Plain, ``async def``, static and class methods are all handled: the underlying + function is decorated and re-wrapped in ``staticmethod``/``classmethod``. Only + methods defined on the class itself are touched, not inherited ones. + + By default dunder methods, names starting with ``_`` and properties are skipped. + Naming one in `include` (a collection of names, not a predicate) opts it in; + for a property, the getter, setter and deleter are all decorated. + + Args: + *decorators: Decorators to apply, outermost first. + include: Names (or a predicate on the name) to restrict the selection to. + ``None`` selects every eligible method. + exclude: Names (or a predicate) to leave alone. Wins over `include`. + include_private: Also select single-underscore names such as ``_helper``. + + Returns: + A class decorator that modifies and returns the same class. + + Raises: + TypeError: If any decorator is not callable. + + Example: + ```python + @wrap_methods(retry(max_attempts=3, on=ConnectionError), exclude=["close"]) + class Client: + def get(self, url: str) -> str: ... + def close(self) -> None: ... + ``` + """ + for index, decorator in enumerate(decorators): + if not callable(decorator): + raise TypeError( + f"wrap_methods() arguments must be callable; argument {index} is {decorator!r}" + ) + + def matcher(spec: Iterable[str] | Callable[[str], bool]) -> Callable[[str], bool]: + if callable(spec): + return spec + names = {spec} if isinstance(spec, str) else set(spec) + return names.__contains__ + + explicit: frozenset[str] = frozenset() + if include is not None and not callable(include): + explicit = frozenset({include} if isinstance(include, str) else include) + include_ok = matcher(include) if include is not None else (lambda _n: True) + exclude_ok = matcher(exclude) if exclude is not None else (lambda _n: False) + + def decorate_func(func: Any) -> Any: + result = func + for decorator in reversed(decorators): + result = decorator(result) + return result + + def decorate(cls: C) -> C: + for name, attr in list(vars(cls).items()): + if exclude_ok(name) or not include_ok(name): + continue + named = name in explicit + if not named and (_is_dunder(name) or (name.startswith("_") and not include_private)): + continue + if isinstance(attr, property): + if not named: + continue + fget, fset, fdel = ( + decorate_func(p) if p is not None else None + for p in (attr.fget, attr.fset, attr.fdel) + ) + setattr(cls, name, property(fget, fset, fdel, attr.__doc__)) + elif isinstance(attr, (staticmethod, classmethod)): + setattr(cls, name, type(attr)(decorate_func(attr.__func__))) + elif inspect.isfunction(attr): + setattr(cls, name, decorate_func(attr)) + return cls + + return decorate diff --git a/src/python_wrap/errors.py b/src/python_wrap/errors.py new file mode 100644 index 0000000..0818fb3 --- /dev/null +++ b/src/python_wrap/errors.py @@ -0,0 +1,84 @@ +"""Exceptions raised by python-wrap. + +Each one also subclasses the closest built-in, so ``except TimeoutError`` or +``except ValueError`` keeps working for code written against v0.1. +""" + +from __future__ import annotations + + +class PythonWrapError(Exception): + """Base class for every exception this package raises.""" + + +class TimeoutExpired(PythonWrapError, TimeoutError): + """The wrapped call did not finish within its time limit.""" + + def __init__(self, message: str, *, seconds: float) -> None: + super().__init__(message) + self.seconds = seconds + + +class RateLimitExceeded(PythonWrapError): + """The call was rejected because its rate limit is used up.""" + + def __init__(self, message: str, *, retry_after: float) -> None: + super().__init__(message) + self.retry_after = retry_after + + +class CircuitOpenError(PythonWrapError): + """The circuit breaker is open, so the call was not attempted.""" + + def __init__(self, message: str, *, retry_after: float) -> None: + super().__init__(message) + self.retry_after = retry_after + + +class BulkheadFull(PythonWrapError): + """A bulkhead is at capacity: its wait queue is full, or waiting for a slot timed out.""" + + def __init__(self, message: str, *, name: str | None, max_concurrent: int) -> None: + super().__init__(message) + self.name = name + self.max_concurrent = max_concurrent + + +class ValidationError(PythonWrapError, ValueError): + """An argument failed a validator.""" + + def __init__(self, message: str, *, parameter: str, value: object) -> None: + super().__init__(message) + self.parameter = parameter + self.value = value + + +class ArgumentTypeError(PythonWrapError, TypeError): + """An argument does not match its expected type.""" + + def __init__(self, message: str, *, parameter: str, expected: object, value: object) -> None: + super().__init__(message) + self.parameter = parameter + self.expected = expected + self.value = value + + +class BatchError(PythonWrapError): + """A batch function broke its output contract for this caller's item. + + Raised by ``batch_calls`` when the batch function returns the wrong number of + results, a mapping without this item's key, or something that is neither a sequence + nor a mapping. + """ + + +__all__ = [ + "ArgumentTypeError", + "BatchError", + "BulkheadFull", + "CircuitOpenError", + "PythonWrapError", + "RateLimitExceeded", + "TimeoutExpired", + "ValidationError", +] diff --git a/src/python_wrap/events.py b/src/python_wrap/events.py new file mode 100644 index 0000000..30c8787 --- /dev/null +++ b/src/python_wrap/events.py @@ -0,0 +1,138 @@ +"""Observe what python-wrap decorators do: retries, cache hits, circuit changes, ... + +Decorators publish `Event` objects to a process-wide list of listeners. With no +listener registered, publishing costs a single list check. Use `subscribe` (or the +`listening` context manager) to feed Prometheus, OpenTelemetry, statsd or your logs. +`EVENT_NAMES` is the catalogue of every event and its fields. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from types import MappingProxyType + +from python_wrap import _core +from python_wrap._core import Event + +__all__ = ["EVENT_NAMES", "Event", "Listener", "filter_events", "listening", "subscribe"] + +Listener = Callable[[Event], None] + +#: Event name -> description of its ``fields`` (``Event.function`` is the decorated +#: function's qualified name unless noted). The docs page renders this mapping. +EVENT_NAMES: Mapping[str, str] = MappingProxyType( + { + "retry.attempt_failed": ( + "An attempt failed and another will follow. Fields: attempt (int, 1-based), " + "exception (BaseException), next_delay (float seconds before the next attempt)." + ), + "retry.succeeded": "The call finally succeeded. Fields: attempts (int, total made).", + "retry.gave_up": ( + "Attempts were exhausted. Fields: attempts (int), exception (BaseException)." + ), + "timeout.expired": "A call exceeded its time limit. Fields: seconds (float, the limit).", + "circuit.state_changed": ( + "A circuit breaker moved between states. function is the breaker name. " + "Fields: old (str), new (str): 'closed', 'open' or 'half_open'." + ), + "circuit.rejected": ( + "A call was refused because the circuit is open. Fields: retry_after (float seconds)." + ), + "rate_limit.rejected": ( + "A call was refused by a rate limiter. Fields: retry_after (float seconds)." + ), + "rate_limit.waited": ( + "A call was delayed by a rate limiter. Fields: seconds (float, time waited)." + ), + "throttle.skipped": "A call was skipped by throttle. No fields.", + "cache.hit": "A cached value was returned. No fields.", + "cache.miss": "No cached value; the function ran. No fields.", + "cache.evicted": "An entry left the cache. Fields: reason (str): 'lru' or 'ttl'.", + "bulkhead.rejected": ( + "A call was refused because the bulkhead is full. Fields: active (int), waiting (int)." + ), + } +) + + +def subscribe(listener: Listener) -> Callable[[], None]: + """Register ``listener`` to receive every `Event`. + + Listeners run synchronously in the thread that triggered the event, so keep them + fast. An exception raised by a listener is logged and swallowed; it never reaches the + decorated function. Subscribing the same callable twice delivers each event twice. + + Args: + listener: Callable taking one `Event`. + + Returns: + A function that removes this registration. Calling it again does nothing. + + Example: + >>> seen = [] + >>> unsubscribe = subscribe(seen.append) + >>> unsubscribe() + """ + with _core.listeners_lock: + _core.listeners.append(listener) + removed = False + + def unsubscribe() -> None: + nonlocal removed + with _core.listeners_lock: + if removed: + return + removed = True + # ``emit`` iterates over a tuple snapshot, so deleting in place is safe. + for i, item in enumerate(_core.listeners): + if item is listener: + del _core.listeners[i] + break + + return unsubscribe + + +@contextmanager +def listening(listener: Listener) -> Iterator[Listener]: + """Subscribe ``listener`` for the duration of a ``with`` block. + + Args: + listener: Callable taking one `Event`. + + Yields: + The listener, so ``with listening(events.append) as handler`` works. + + Example: + >>> events = [] + >>> with listening(events.append): + ... pass + """ + unsubscribe = subscribe(listener) + try: + yield listener + finally: + unsubscribe() + + +def filter_events(prefix: str | tuple[str, ...], listener: Listener) -> Listener: + """Wrap ``listener`` so it only receives events whose name starts with ``prefix``. + + Args: + prefix: A name prefix such as ``"retry."``, or a tuple of prefixes. + listener: The listener to call for matching events. + + Returns: + A new listener; pass it to `subscribe`. + + Example: + >>> retries = [] + >>> unsubscribe = subscribe(filter_events("retry.", retries.append)) + >>> unsubscribe() + """ + + def filtered(event: Event) -> None: + if event.name.startswith(prefix): + listener(event) + + return filtered diff --git a/src/python_wrap/hedging.py b/src/python_wrap/hedging.py new file mode 100644 index 0000000..00a39e4 --- /dev/null +++ b/src/python_wrap/hedging.py @@ -0,0 +1,192 @@ +"""Hedged requests: `hedge` starts a backup attempt when the first one is slow.""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextvars +import functools +import threading +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar, cast + +from python_wrap import _core + +__all__ = ["hedge"] + +F = TypeVar("F", bound=Callable[..., Any]) + +ExceptionTypes = type[BaseException] | tuple[type[BaseException], ...] + + +def _start_thread( + func: Callable[..., Any], args: Any, kwargs: Any +) -> concurrent.futures.Future[Any]: + """Run one attempt in a new daemon thread (with the caller's context variables).""" + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + context = contextvars.copy_context() + + def target() -> None: + try: + result = context.run(func, *args, **kwargs) + except BaseException as exc: + future.set_exception(exc) + else: + future.set_result(result) + + threading.Thread(target=target, name=f"hedge-{_core.qualname(func)}", daemon=True).start() + return future + + +def hedge( + delay: float, + *, + max_extra: int = 1, + on: ExceptionTypes = Exception, +) -> Callable[[F], F]: + """Start a backup attempt when a call is slow, and return the first success. + + !!! warning "Only for idempotent operations" + The function may run up to `1 + max_extra` times for one call, concurrently. + Use it only where running the operation more than once is harmless: reads, + lookups, idempotent writes. Never on payments, sends, or increments. + + The call starts once. If it has not finished after `delay` seconds, a second attempt + starts alongside it, and so on, up to `max_extra` extra attempts (each `delay` after + the previous one). The first attempt to **succeed** wins. If an attempt fails with an + exception matching `on` while others are still running, the next attempt starts + immediately instead of waiting out the delay. An exception not matching `on` is + raised at once. If every attempt fails, the last exception is raised. + + * `async def` functions: each attempt is a task. As soon as there is a winner (or a + non-matching error), the other attempts are cancelled and awaited, so the wrapper + never leaves tasks behind. Cancelling the caller cancels every attempt. + * Sync functions: each attempt runs in its own daemon thread and the caller blocks + until one succeeds. **Threads cannot be cancelled**: losing attempts run to + completion in the background and their results are discarded. Their side effects + still happen, and they keep the thread (and whatever it holds) busy until done. + + Context variables are copied into each attempt. + + Args: + delay: Seconds to wait before starting each extra attempt (>= 0). + max_extra: Maximum number of extra attempts (>= 0; 0 disables hedging). + on: Exception type(s) that count as a failed attempt and trigger the next hedge. + Anything else propagates at once. + + Returns: + A decorator that keeps the function's signature. + + Raises: + ValueError: If `delay` < 0 or `max_extra` < 0. + + Example: + >>> @hedge(0.05, max_extra=2) + ... async def get_price(sku: str) -> float: + ... ... # a read from a service with an occasional slow replica + """ + if delay < 0: + raise ValueError(f"hedge delay must be non-negative, got {delay!r}") + if type(max_extra) is not int or max_extra < 0: + raise ValueError(f"hedge max_extra must be an integer >= 0, got {max_extra!r}") + retry_on = on + max_attempts = 1 + max_extra + + def decorate(func: F) -> F: + if _core.is_async(func): + async_func = cast(Callable[..., Awaitable[Any]], func) + + async def attempt(args: Any, kwargs: Any) -> Any: + return await async_func(*args, **kwargs) + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + loop = asyncio.get_running_loop() + attempts: list[asyncio.Task[Any]] = [] # in start order + running: set[asyncio.Task[Any]] = set() + timer: asyncio.Task[None] | None = None + last_exc: BaseException | None = None + + def launch() -> None: + nonlocal timer + task = loop.create_task(attempt(args, kwargs)) + attempts.append(task) + running.add(task) + if timer is not None: + timer.cancel() + timer = ( + loop.create_task(asyncio.sleep(delay)) # real time + if len(attempts) < max_attempts + else None + ) + + try: + launch() + while running: + waiting: set[asyncio.Future[Any]] = set(running) + if timer is not None: + waiting.add(timer) + done, _ = await asyncio.wait(waiting, return_when=asyncio.FIRST_COMPLETED) + for task in [t for t in attempts if t in done]: + running.discard(task) + if task.cancelled(): # cancelled by someone else + raise asyncio.CancelledError + exc = task.exception() + if exc is None: + return task.result() + if not isinstance(exc, retry_on): + raise exc + last_exc = exc + if len(attempts) < max_attempts: + launch() + if timer is not None and timer in done: + timer = None + if len(attempts) < max_attempts: + launch() + assert last_exc is not None + raise last_exc + finally: + leftovers: list[asyncio.Future[Any]] = list(running) + if timer is not None: + leftovers.append(timer) + for t in leftovers: + t.cancel() + if leftovers: + await asyncio.wait(leftovers) + for t in leftovers: + if not t.cancelled(): + t.exception() # retrieve: a loser's late error is moot + + return cast(F, async_wrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + attempts: list[concurrent.futures.Future[Any]] = [_start_thread(func, args, kwargs)] + running = set(attempts) + last_exc: BaseException | None = None + timeout: float | None = delay if max_attempts > 1 else None + while running: + done, _ = concurrent.futures.wait( + running, timeout=timeout, return_when=concurrent.futures.FIRST_COMPLETED + ) + launches = 0 if done else 1 # nothing finished: the delay expired + for future in [f for f in attempts if f in done]: + running.discard(future) + exc = future.exception() + if exc is None: + return future.result() + if not isinstance(exc, retry_on): + raise exc + last_exc = exc + launches += 1 + for _ in range(min(launches, max_attempts - len(attempts))): + future = _start_thread(func, args, kwargs) + attempts.append(future) + running.add(future) + timeout = delay if len(attempts) < max_attempts else None + assert last_exc is not None + raise last_exc + + return cast(F, wrapper) + + return decorate diff --git a/src/python_wrap/logs.py b/src/python_wrap/logs.py new file mode 100644 index 0000000..b5d372b --- /dev/null +++ b/src/python_wrap/logs.py @@ -0,0 +1,697 @@ +"""Logging decorators: argument/return logging, audit records and call-stack tracing. + +All of them log through `python_wrap._core.get_logger`, never configure logging, +and support plain functions, ``async def`` functions and methods. +""" + +from __future__ import annotations + +import contextvars +import functools +import inspect +import logging +import os +import reprlib +import sys +import traceback +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from types import FrameType, MappingProxyType, TracebackType +from typing import Any, TypeVar, cast, overload + +from python_wrap import _core, config +from python_wrap._core import F, LoggerLike, P, R + +__all__ = [ + "DEFAULT_REDACT", + "AuditRecord", + "audit", + "current_log_context", + "log_args", + "log_context", + "log_return", + "trace", +] + +S = TypeVar("S") + +DEFAULT_REDACT: tuple[str, ...] = ( + "password", + "passwd", + "secret", + "token", + "api_key", + "apikey", + "authorization", + "auth", + "credential", + "private_key", + "cookie", + "session", +) + +_MASK = "***" +_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +# --------------------------------------------------------------------------- helpers + + +def _safe_repr(value: object) -> str: + """``repr(value)`` that never raises.""" + try: + return repr(value) + except BaseException: + return f"" + + +def _instrument( + func: Callable[P, R], + start: Callable[[tuple[Any, ...], dict[str, Any]], S], + finish: Callable[[S, bool, Any], None], +) -> Callable[P, R]: + """Wrap ``func`` (sync or async) with a start hook and a finish hook. + + ``start(args, kwargs)`` runs before the call and returns a state object. + ``finish(state, ok, value)`` runs afterwards with the return value (``ok=True``) or the + raised exception (``ok=False``); the exception is then re-raised. For ``async def`` + functions the hooks surround the awaited coroutine. A failing ``finish`` is logged and + swallowed: it never replaces the function's result or exception. + """ + + def run_finish(state: S, ok: bool, value: Any) -> None: + try: + finish(state, ok, value) + except Exception: + _core.get_logger(None).exception("%s: logging hook failed", _core.qualname(func)) + + if _core.is_async(func): + + @functools.wraps(func) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + state = start(args, kwargs) + try: + result = await func(*args, **kwargs) # type: ignore[misc] + except BaseException as exc: + run_finish(state, False, exc) + raise + run_finish(state, True, result) + return result + + return cast("Callable[P, R]", awrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + state = start(args, kwargs) + try: + result = func(*args, **kwargs) + except BaseException as exc: + run_finish(state, False, exc) + raise + run_finish(state, True, result) + return result + + return cast("Callable[P, R]", wrapper) + + +def _redact_predicate(redact: Iterable[str] | None) -> Callable[[str], bool]: + """Build a case-insensitive "does this name look sensitive" test.""" + needles = tuple(n.lower() for n in (DEFAULT_REDACT if redact is None else redact) if n) + if not needles: + return lambda name: False + return lambda name: any(n in name.lower() for n in needles) + + +def _short(value: object, max_length: int) -> str: + """``repr`` with container truncation and a hard character limit.""" + r = reprlib.Repr() + r.maxstring = r.maxother = max_length + r.maxlist = r.maxtuple = r.maxset = r.maxdict = r.maxdeque = r.maxarray = 6 + text = r.repr(value) # reprlib already survives a failing __repr__ + if max_length >= 0 and len(text) > max_length: + text = text[: max(max_length - 3, 0)] + "..." + return text + + +class _ArgBinder: + """Maps a call's arguments to parameter names, masking sensitive ones. + + The signature is analysed once. A call that passes plain positional parameters only + (no keywords, no ``*args`` / ``**kwargs`` in the signature, no excess arguments) is + named straight from a precomputed name tuple; everything else goes through + ``Signature.bind``. Redaction is decided once per parameter name. + """ + + def __init__(self, sig: inspect.Signature | None, is_secret: Callable[[str], bool]) -> None: + self.sig = sig + self.is_secret = is_secret + self.names: tuple[str, ...] = () + self.flags: tuple[bool, ...] = () + self.secret_names: dict[str, bool] = {} + self.fast_max = -1 + if sig is not None: + params = list(sig.parameters.values()) + self.secret_names = {p.name: is_secret(p.name) for p in params} + plain = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + if all(p.kind in plain for p in params): + self.names = tuple(p.name for p in params) + self.flags = tuple(self.secret_names[n] for n in self.names) + self.fast_max = len(params) + + def _secret(self, name: str) -> bool: + known = self.secret_names.get(name) + return self.is_secret(name) if known is None else known + + def __call__(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + if not kwargs and len(args) <= self.fast_max: + return { + n: (_MASK if secret else v) + for n, secret, v in zip(self.names, self.flags, args, strict=False) + } + sig = self.sig + bound = None + if sig is not None: + try: + bound = sig.bind(*args, **kwargs) + except TypeError: + bound = None + named: dict[str, Any] = {} + if sig is None or bound is None: # the call is invalid anyway; show what we were given + named = {f"arg{i}": v for i, v in enumerate(args)} + named.update(kwargs) + else: + for name, value in bound.arguments.items(): + if sig.parameters[name].kind is inspect.Parameter.VAR_KEYWORD: + named.update(value) + else: + named[name] = value + return {k: (_MASK if self._secret(k) else v) for k, v in named.items()} + + +def _signature(func: Callable[..., Any]) -> inspect.Signature | None: + try: + return inspect.signature(func) + except (TypeError, ValueError): + return None + + +def _format_call(name: str, arguments: Mapping[str, Any], max_length: int) -> str: + parts = [ + f"{k}={_short(v, max_length) if v is not _MASK else repr(_MASK)}" + for k, v in arguments.items() + ] + return f"{name}({', '.join(parts)})" + + +_internal_files: dict[str, bool] = {} + + +def _is_internal(filename: str) -> bool: + try: + return _internal_files[filename] + except KeyError: + result = os.path.dirname(os.path.abspath(filename)) == _PACKAGE_DIR + if len(_internal_files) < 4096: # co_filename values are few; stay bounded anyway + _internal_files[filename] = result + return result + + +def _external_frame() -> FrameType | None: + """The innermost frame of the current stack that is outside python_wrap.""" + frame: FrameType | None = sys._getframe(1) + while frame is not None and _is_internal(frame.f_code.co_filename): + frame = frame.f_back + return frame + + +def _caller_location() -> str: + frame = _external_frame() + if frame is None: + return "" # pragma: no cover + return f"{frame.f_code.co_filename}:{frame.f_lineno}" + + +# ----------------------------------------------------------------------- log context + +_log_fields: contextvars.ContextVar[Mapping[str, Any]] = contextvars.ContextVar( + "python_wrap_log_fields", default=MappingProxyType({}) +) + + +def current_log_context() -> dict[str, Any]: + """Return a copy of the fields bound by the enclosing `log_context` blocks. + + Returns: + A new dict (empty when no `log_context` is active); changing it has no effect. + + Example: + >>> with log_context(request_id="r1"): + ... current_log_context() + {'request_id': 'r1'} + """ + return dict(_log_fields.get()) + + +def _safe_str(value: object) -> str: + try: + return str(value) + except BaseException: + return f"" + + +def _log( + lg: logging.Logger, + level: int, + msg: str, + *args: Any, + context: Mapping[str, Any] | None = None, + suffix: bool = True, + extra: dict[str, Any] | None = None, +) -> None: + """``lg.log`` plus the bound context: ``extra["context"]`` and a ``[k=v]`` suffix.""" + fields = _log_fields.get() if context is None else context + if fields: + extra = {**(extra or {}), "context": dict(fields)} + if suffix: + text = " ".join(f"{k}={_safe_str(v)}" for k, v in fields.items()) + msg = f"{msg} [{text.replace('%', '%%')}]" + lg.log(level, msg, *args, extra=extra) + + +class _LogContext: + """Context manager (sync and async) and decorator returned by `log_context`.""" + + def __init__(self, fields: dict[str, Any]) -> None: + self._fields = fields + self._tokens: list[contextvars.Token[Mapping[str, Any]]] = [] + + def __enter__(self) -> dict[str, Any]: + merged = {**_log_fields.get(), **self._fields} + self._tokens.append(_log_fields.set(merged)) + return dict(merged) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + _log_fields.reset(self._tokens.pop()) + + async def __aenter__(self) -> dict[str, Any]: + return self.__enter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.__exit__(exc_type, exc, tb) + + def __call__(self, func: F) -> F: + fields = self._fields + if _core.is_async(func): + + @functools.wraps(func) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + async with _LogContext(fields): + return await func(*args, **kwargs) + + return cast("F", awrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with _LogContext(fields): + return func(*args, **kwargs) + + return cast("F", wrapper) + + +def log_context(**fields: Any) -> _LogContext: + """Bind extra fields to every python-wrap log line made inside a block or call. + + Works as a context manager (``with`` and ``async with``) and as a decorator for plain + and ``async def`` functions. Blocks nest: inner fields add to, and override, outer ones. + The fields live in a `contextvars.ContextVar`, so each thread and asyncio task sees its + own; a new thread starts empty unless you run it in a copied context + (``contextvars.copy_context().run(...)``; `run_in_thread` does this for you). + + `log_args`, `log_return`, `audit`, `trace` and `timing` append `` [key=value ...]`` to + their message and attach the dict as ``LogRecord.context``; `AuditRecord.context` + holds it too. + + Args: + **fields: The fields to bind, e.g. ``request_id="abc"``. + + Returns: + An object usable as ``with log_context(...) as fields``, ``async with`` or ``@``. + The ``as`` target is the merged dict of fields. + + Example: + >>> @log_return(logger="app") + ... def handle(): ... + >>> with log_context(request_id="r1"): + ... handle() + """ + return _LogContext(dict(fields)) + + +# ---------------------------------------------------------------------------- log_args + + +@overload +def log_args(func: F, /) -> F: ... +@overload +def log_args( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.DEBUG, + redact: Iterable[str] | None = None, + redact_extra: Iterable[str] = (), + max_length: int = 200, +) -> Callable[[F], F]: ... +def log_args( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.DEBUG, + redact: Iterable[str] | None = None, + redact_extra: Iterable[str] = (), + max_length: int = 200, +) -> Any: + """Log each call's arguments, by parameter name, before running it. + + The message looks like ``Calling f(x=1, password='***')``. Arguments are bound to the + function's signature so positional values are named too. + + Args: + func: The function, when used as a bare ``@log_args``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level. Defaults to ``DEBUG`` (v0.1 logged at ``INFO``). + redact: Parameter-name fragments whose values are shown as ``'***'`` + (case-insensitive substring match). ``None`` (default) uses + `DEFAULT_REDACT` or the list set by `python_wrap.config.configure`. A list + given here **replaces** that; pass ``()`` to disable redaction. + redact_extra: Fragments to mask **in addition** to the list above (never + replaces it). Added to any `redact_extra` set via `configure`. + max_length: Longest rendering of a single value before it is truncated. + + Returns: + The decorated function, with its signature preserved. + + Example: + >>> @log_args(logger="app") + ... def login(user, password): ... + """ + secret = _redact_predicate(config.redaction(redact, DEFAULT_REDACT) + tuple(redact_extra)) + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + binder = _ArgBinder(_signature(f), secret) + + def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + if lg.isEnabledFor(level): + bound = binder(args, kwargs) + _log(lg, level, "Calling %s", _format_call(name, bound, max_length)) + + return _instrument(f, start, lambda state, ok, value: None) + + return decorate if func is None else decorate(func) + + +# -------------------------------------------------------------------------- log_return + + +@overload +def log_return(func: F, /) -> F: ... +@overload +def log_return( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.DEBUG, + max_length: int = 200, +) -> Callable[[F], F]: ... +def log_return( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.DEBUG, + max_length: int = 200, +) -> Any: + """Log the value a function returns, or the exception type it raises. + + Logs ``f returned ``; if the function raises, logs ``f raised ValueError`` and + re-raises the exception unchanged. + + Args: + func: The function, when used as a bare ``@log_return``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level. Defaults to ``DEBUG``. + max_length: Longest rendering of the return value before it is truncated. + + Returns: + The decorated function, with its signature preserved. + + Example: + >>> @log_return + ... def add(a, b): + ... return a + b + """ + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + + def finish(state: None, ok: bool, value: Any) -> None: + if not lg.isEnabledFor(level): + return + if ok: + _log(lg, level, "%s returned %s", name, _short(value, max_length)) + else: + _log(lg, level, "%s raised %s", name, type(value).__name__) + + return _instrument(f, lambda args, kwargs: None, finish) + + return decorate if func is None else decorate(func) + + +# ------------------------------------------------------------------------------ audit + + +@dataclass(frozen=True) +class AuditRecord: + """One audited call. + + Attributes: + function: Qualified name of the audited function. + timestamp: When the call started (timezone-aware, UTC). + caller: ``"file:line"`` of the code that called the function. + arguments: Parameter name to value; sensitive parameters hold ``'***'``. + status: ``"success"`` or ``"error"``. + error: ``repr`` of the exception, or ``None``. + duration: Seconds the call took. + result: The return value, only when ``include_result=True`` (else ``None``). + context: Fields bound by `log_context` when the call started (empty if none). + """ + + function: str + timestamp: datetime + caller: str + arguments: dict[str, Any] + status: str + error: str | None + duration: float + result: Any = None + context: dict[str, Any] = field(default_factory=dict) + + +@overload +def audit(func: F, /) -> F: ... +@overload +def audit( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + redact: Iterable[str] | None = None, + redact_extra: Iterable[str] = (), + include_result: bool = False, + sink: Callable[[AuditRecord], None] | None = None, +) -> Callable[[F], F]: ... +def audit( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + redact: Iterable[str] | None = None, + redact_extra: Iterable[str] = (), + include_result: bool = False, + sink: Callable[[AuditRecord], None] | None = None, +) -> Any: + """Record who called a function, with what, and how it ended. + + Every call produces an `AuditRecord`. One log line is emitted with the record + attached as ``extra={"audit": record}`` (read it from ``LogRecord.audit``), and + ``sink(record)`` is called if given. Exceptions are recorded and re-raised. An + exception raised by ``sink`` is logged and swallowed. + + Args: + func: The function, when used as a bare ``@audit``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level of the audit line. + redact: Parameter-name fragments whose values are masked as ``'***'``; + ``None`` (default) uses `DEFAULT_REDACT` or the list set by + `python_wrap.config.configure`; a list given here replaces it and ``()`` + disables redaction. + redact_extra: Fragments to mask in addition to the list above (never + replaces it). Added to any `redact_extra` set via `configure`. + include_result: Store the return value in ``record.result``. + sink: Callable receiving every record, e.g. to write to a database. + + Returns: + The decorated function, with its signature preserved. + + Example: + >>> records = [] + >>> @audit(sink=records.append) + ... def delete_user(user_id, token): ... + """ + secret = _redact_predicate(config.redaction(redact, DEFAULT_REDACT) + tuple(redact_extra)) + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + binder = _ArgBinder(_signature(f), secret) + + def start( + args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> tuple[datetime, str, dict[str, Any], float, dict[str, Any]] | None: + if sink is None and not lg.isEnabledFor(level): + return None # nobody would see the record: skip building it + return ( + datetime.now(timezone.utc), + _caller_location(), + binder(args, kwargs), + _core.perf_counter(), + current_log_context(), + ) + + def finish( + state: tuple[datetime, str, dict[str, Any], float, dict[str, Any]] | None, + ok: bool, + value: Any, + ) -> None: + if state is None: + return + timestamp, caller, arguments, began, context = state + record = AuditRecord( + function=name, + timestamp=timestamp, + caller=caller, + arguments=arguments, + status="success" if ok else "error", + error=None if ok else _safe_repr(value), + duration=_core.perf_counter() - began, + result=value if (ok and include_result) else None, + context=context, + ) + _log( + lg, + level, + "audit %s called from %s: %s%s (%.3f s)", + _format_call(name, arguments, 200), + caller, + record.status, + f" {record.error}" if record.error else "", + record.duration, + context=context, + extra={"audit": record}, + ) + if sink is not None: + try: + sink(record) + except Exception: + lg.exception("audit sink for %s failed", name) + + return _instrument(f, start, finish) + + return decorate if func is None else decorate(func) + + +# ------------------------------------------------------------------------------ trace + + +@overload +def trace(func: F, /) -> F: ... +@overload +def trace( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.DEBUG, + limit: int | None = None, +) -> Callable[[F], F]: ... +def trace( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.DEBUG, + limit: int | None = None, +) -> Any: + """Log entry, the call stack that led to the call, and exit. + + Three messages per call: ``Entering f``, the call stack (python_wrap's own frames are + left out, outermost first), and ``Exiting f`` (or ``Exiting f (raised ValueError)``). + + Args: + func: The function, when used as a bare ``@trace``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level. Defaults to ``DEBUG``. + limit: Keep only this many innermost frames; ``None`` keeps all. + + Returns: + The decorated function, with its signature preserved. + + Example: + >>> @trace(limit=5) + ... def handler(): ... + """ + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + + def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + if not lg.isEnabledFor(level): + return + _log(lg, level, "Entering %s", name) + frame = _external_frame() + if frame is not None: + entries: list[traceback.FrameSummary] = list(traceback.extract_stack(frame)) + if limit is not None: + entries = entries[-limit:] if limit > 0 else [] + text = "".join(traceback.format_list(entries)).rstrip() + _log(lg, level, "Call stack for %s:\n%s", name, text, suffix=False) + + def finish(state: None, ok: bool, value: Any) -> None: + if not lg.isEnabledFor(level): + return + suffix = "" if ok else f" (raised {type(value).__name__})" + _log(lg, level, "Exiting %s%s", name, suffix) + + return _instrument(f, start, finish) + + return decorate if func is None else decorate(func) diff --git a/src/python_wrap/metrics.py b/src/python_wrap/metrics.py new file mode 100644 index 0000000..76369bb --- /dev/null +++ b/src/python_wrap/metrics.py @@ -0,0 +1,242 @@ +"""In-process call metrics: `count_calls` tracks calls, errors, concurrency and latency.""" + +from __future__ import annotations + +import functools +import math +import threading +from collections import deque +from collections.abc import Callable +from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, cast, overload + +from python_wrap import _core + +__all__ = ["CallStats", "Counted", "count_calls"] + +P = ParamSpec("P") +P2 = ParamSpec("P2") +R = TypeVar("R") +R_co = TypeVar("R_co", covariant=True) +T = TypeVar("T") + + +def _percentile(sorted_values: list[float], q: float) -> float: + """Nearest-rank percentile of an already sorted list (0.0 when empty).""" + if not sorted_values: + return 0.0 + rank = max(math.ceil(q / 100 * len(sorted_values)), 1) + return sorted_values[rank - 1] + + +class CallStats: + """Live statistics of a function wrapped by `count_calls`. + + Every attribute is read under a lock at access time, so ``f.stats.calls`` always + reflects the present. Latency figures (``mean``, ``p50``, ``p95``, ``p99``) cover the + last ``window`` finished calls, successful or not, in seconds; they are ``0.0`` before + the first call finishes. ``calls`` and ``errors`` count every call since the last + reset, not just the window. + + Attributes: + calls: Calls that have finished (returned or raised). + errors: Calls that raised an exception. + in_flight: Calls running right now. + last_duration: Seconds the most recently finished call took. + mean: Mean duration over the window. + p50: Median duration over the window (nearest rank). + p95: 95th percentile duration over the window. + p99: 99th percentile duration over the window. + """ + + def __init__(self, window: int) -> None: + self._window = window + self._lock = threading.Lock() + self._durations: deque[float] = deque(maxlen=window) + self._calls = 0 + self._errors = 0 + self._in_flight = 0 + self._last = 0.0 + + def _begin(self) -> None: + with self._lock: + self._in_flight += 1 + + def _end(self, duration: float, ok: bool) -> None: + with self._lock: + self._in_flight -= 1 + self._calls += 1 + if not ok: + self._errors += 1 + self._last = duration + self._durations.append(duration) + + def _reset(self) -> None: + with self._lock: + self._durations.clear() + self._calls = self._errors = 0 + self._last = 0.0 + # in_flight is left alone: those calls are still running and will finish. + + def _sorted(self) -> list[float]: + with self._lock: + return sorted(self._durations) + + @property + def calls(self) -> int: + with self._lock: + return self._calls + + @property + def errors(self) -> int: + with self._lock: + return self._errors + + @property + def in_flight(self) -> int: + with self._lock: + return self._in_flight + + @property + def last_duration(self) -> float: + with self._lock: + return self._last + + @property + def mean(self) -> float: + values = self._sorted() + return sum(values) / len(values) if values else 0.0 + + @property + def p50(self) -> float: + return _percentile(self._sorted(), 50) + + @property + def p95(self) -> float: + return _percentile(self._sorted(), 95) + + @property + def p99(self) -> float: + return _percentile(self._sorted(), 99) + + def __repr__(self) -> str: + return ( + f"CallStats(calls={self.calls}, errors={self.errors}, in_flight={self.in_flight}, " + f"mean={self.mean:.6f}, p95={self.p95:.6f})" + ) + + +class Counted(Protocol[P, R_co]): + """A function wrapped by `count_calls` (callable plus ``stats`` and ``reset()``). + + Use it to annotate decorated functions, e.g. ``Counted[[int], str]``. The ``__get__`` + overloads let type checkers bind ``self`` on instance access and ``cls`` under a + ``@classmethod`` stacked above, with the same typing limitations as `Memoized` + (see `python_wrap.caching`). The statistics belong to the function, so they are + shared by every instance of a class. + """ + + stats: CallStats + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + @overload + def __get__( + self: Counted[Concatenate[type[T], P2], R_co], instance: None, owner: type[T] + ) -> _CountedOnClass[P, P2, R_co]: ... + @overload + def __get__(self, instance: None, owner: type | None = None) -> Counted[P, R_co]: ... + @overload + def __get__( + self: Counted[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None + ) -> Counted[P2, R_co]: ... + + def reset(self) -> None: + """Zero the counters and clear the duration window.""" + + +class _CountedOnClass(Counted[P, R_co], Protocol[P, P2, R_co]): + """`Counted` read from a class: accepts the call with and without the first argument.""" + + @overload + def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + + +@overload +def count_calls(func: Callable[P, R], /) -> Counted[P, R]: ... +@overload +def count_calls( + func: None = None, /, *, window: int = 1000 +) -> Callable[[Callable[P, R]], Counted[P, R]]: ... +def count_calls(func: Callable[..., Any] | None = None, /, *, window: int = 1000) -> Any: + """Track how often a function runs, fails, and how long it takes. + + The wrapper exposes ``.stats`` (a live `CallStats`: ``calls``, ``errors``, + ``in_flight``, ``last_duration``, ``mean``, ``p50``, ``p95``, ``p99``) and + ``.reset()``. Sync and ``async def`` functions are supported (async calls are timed + across the whole awaited coroutine), as are methods; statistics are per function, not + per instance. A call that raises counts as an error and its exception propagates. + Cancellation counts as an error too. Updates are thread-safe. + + Args: + func: The function, when used as a bare ``@count_calls``. + window: How many of the most recent durations feed ``mean`` and the percentiles. + + Returns: + The wrapped function, a `Counted`. + + Raises: + ValueError: If ``window`` is less than 1. + + Example: + >>> @count_calls(window=500) + ... def work() -> int: + ... return 1 + >>> work() + 1 + >>> work.stats.calls + 1 + """ + if window < 1: + raise ValueError("window must be >= 1") + + def decorate(f: Callable[P, R]) -> Counted[P, R]: + stats = CallStats(window) + + if _core.is_async(f): + + @functools.wraps(f) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + stats._begin() + began = _core.perf_counter() + ok = False + try: + result = await f(*args, **kwargs) # type: ignore[misc] + ok = True + return result + finally: + stats._end(_core.perf_counter() - began, ok) + + wrapper: Any = awrapper + else: + + @functools.wraps(f) + def swrapper(*args: Any, **kwargs: Any) -> Any: + stats._begin() + began = _core.perf_counter() + ok = False + try: + result = f(*args, **kwargs) + ok = True + return result + finally: + stats._end(_core.perf_counter() - began, ok) + + wrapper = swrapper + + wrapper.stats = stats + wrapper.reset = stats._reset + return cast("Counted[P, R]", wrapper) + + return decorate if func is None else decorate(func) diff --git a/src/python_wrap/policy.py b/src/python_wrap/policy.py new file mode 100644 index 0000000..d985cbe --- /dev/null +++ b/src/python_wrap/policy.py @@ -0,0 +1,256 @@ +"""Reusable, named bundles of decorators that always stack in the right order.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import Any, TypeVar, cast + +F = TypeVar("F", bound=Callable[..., Any]) + +__all__ = ["ROLES", "Policy", "policy"] + +_Decorator = Callable[[Any], Any] + +#: Role names, outermost first. The canonical order, with the reason for each position: +#: +#: - ``observe``: timing, logging, audit see the outcome the caller experiences. +#: - ``validate``: reject bad input before anything is retried, cached or counted. +#: - ``fallback``: catches whatever the layers below could not fix. +#: - ``cache``: a hit skips everything below; failures are never cached. +#: - ``limiter``: one rate-limit token per logical call, not per attempt. +#: - ``bulkhead``: caps concurrent logical calls into the dependency. +#: - ``breaker``: counts calls whose retries were exhausted. +#: - ``retry``: repeats everything below it. +#: - ``timeout``: a deadline per attempt. +#: - ``guard``: per-attempt state protection (revert_on_failure, transactional). +ROLES: tuple[str, ...] = ( + "observe", + "validate", + "fallback", + "cache", + "limiter", + "bulkhead", + "breaker", + "retry", + "timeout", + "guard", +) + + +def _as_tuple(role: str, value: Any) -> tuple[_Decorator, ...]: + if value is None: + return () + items = tuple(value) if isinstance(value, (list, tuple)) else (value,) + for item in items: + if not callable(item): + raise TypeError(f"policy {role}= must be a decorator or a list of them; got {item!r}") + return items + + +def _apply_one(decorator: _Decorator, target: Any) -> Any: + """Apply a decorator, looking through staticmethod/classmethod descriptors.""" + if isinstance(target, (staticmethod, classmethod)): + return type(target)(decorator(target.__func__)) + return decorator(target) + + +class Policy: + """An immutable, named bundle of decorators applied in a fixed, correct order. + + Create one with `policy`. A `Policy` is itself a decorator, so it works on + plain functions, ``async def`` functions and methods, and keeps the + decorated function's type. Roles run outermost first in the order of `ROLES`, + wherever you pass them; `outer` decorators wrap all roles and `inner` + decorators sit inside all roles, each group in the order given. + + Example: + ```python + resilient = policy(retry=retry(max_attempts=3), timeout=timeout(2), name="io") + + @resilient + def fetch() -> str: ... + ``` + """ + + __slots__ = ("_inner", "_name", "_outer", "_roles") + + _name: str | None + _outer: tuple[_Decorator, ...] + _inner: tuple[_Decorator, ...] + _roles: dict[str, tuple[_Decorator, ...]] + + def __init__( + self, + *, + name: str | None, + outer: tuple[_Decorator, ...], + inner: tuple[_Decorator, ...], + roles: Mapping[str, tuple[_Decorator, ...]], + ) -> None: + object.__setattr__(self, "_name", name) + object.__setattr__(self, "_outer", outer) + object.__setattr__(self, "_inner", inner) + object.__setattr__(self, "_roles", dict(roles)) + + def __setattr__(self, key: str, value: Any) -> None: + raise AttributeError("Policy is immutable; use .with_(...) to derive a variant") + + def __delattr__(self, key: str) -> None: + raise AttributeError("Policy is immutable; use .with_(...) to derive a variant") + + @property + def name(self) -> str | None: + """The policy name, if any.""" + return self._name + + @property + def order(self) -> tuple[str, ...]: + """The final layer names, outermost first (``outer``, roles, ``inner``).""" + names: list[str] = ["outer"] * len(self._outer) + for role in ROLES: + names += [role] * len(self._roles.get(role, ())) + names += ["inner"] * len(self._inner) + return tuple(names) + + def _layers(self) -> list[_Decorator]: + layers: list[_Decorator] = list(self._outer) + for role in ROLES: + layers.extend(self._roles.get(role, ())) + layers.extend(self._inner) + return layers + + def apply(self, func: F) -> F: + """Decorate `func` with every layer in the policy's order. + + Args: + func: A function, ``async def`` function, or static/class method. + + Returns: + The decorated function (same type as `func`). + """ + result: Any = func + for decorator in reversed(self._layers()): + result = _apply_one(decorator, result) + return cast(F, result) + + def __call__(self, func: F) -> F: + return self.apply(func) + + def with_(self, **changes: Any) -> Policy: + """Return a new policy with some roles replaced. + + Args: + **changes: Any role name, `outer`, `inner` or `name`. A value of + ``None`` removes that role; otherwise it replaces it. + + Returns: + A new `Policy`; this one is unchanged. + + Raises: + TypeError: For an unknown keyword or a non-callable decorator. + """ + known = {*ROLES, "outer", "inner", "name"} + unknown = set(changes) - known + if unknown: + raise TypeError(f"unknown policy option(s): {', '.join(sorted(unknown))}") + roles = dict(self._roles) + for role in ROLES: + if role in changes: + roles[role] = _as_tuple(role, changes[role]) + return Policy( + name=changes.get("name", self._name), + outer=_as_tuple("outer", changes["outer"]) if "outer" in changes else self._outer, + inner=_as_tuple("inner", changes["inner"]) if "inner" in changes else self._inner, + roles=roles, + ) + + def __repr__(self) -> str: + order = " > ".join(self.order) or "(empty)" + label = f"{self._name!r}, " if self._name else "" + return f"Policy({label}{order})" + + +def policy( + *, + name: str | None = None, + observe: _Decorator | Sequence[_Decorator] | None = None, + validate: _Decorator | Sequence[_Decorator] | None = None, + fallback: _Decorator | Sequence[_Decorator] | None = None, + cache: _Decorator | Sequence[_Decorator] | None = None, + limiter: _Decorator | Sequence[_Decorator] | None = None, + bulkhead: _Decorator | Sequence[_Decorator] | None = None, + breaker: _Decorator | Sequence[_Decorator] | None = None, + retry: _Decorator | Sequence[_Decorator] | None = None, + timeout: _Decorator | Sequence[_Decorator] | None = None, + guard: _Decorator | Sequence[_Decorator] | None = None, + outer: _Decorator | Sequence[_Decorator] | None = None, + inner: _Decorator | Sequence[_Decorator] | None = None, +) -> Policy: + """Build a reusable `Policy` from decorators placed in named roles. + + Each keyword is a role, and the role (not the keyword order) decides where the + decorator sits. The canonical order, outermost first, is `ROLES`: ``observe`` + (timing, logging), ``validate``, ``fallback``, ``cache``, ``limiter`` + (rate limits), ``bulkhead``, ``breaker`` (a `CircuitBreaker` or + `circuit_breaker(...)`), ``retry``, ``timeout``, ``guard`` + (revert_on_failure and similar). The reasons are in the stacking guide. + + A role takes one decorator or a list of them (outermost first). Use `outer` + and `inner` for any decorator that has no role: `outer` ones wrap every role, + `inner` ones sit just above the function. + + Args: + name: Label shown in ``repr``. + observe: Observers such as `timing` or `audit`. + validate: Input checks such as `validate_args`. + fallback: `fallback` or `suppress`. + cache: `memoize`, `once`, `singleflight`. + limiter: `rate_limit`, `throttle`. + bulkhead: a `Bulkhead` or `limit_concurrency(n)`. + breaker: A `CircuitBreaker` or `circuit_breaker(...)`. + retry: `retry(...)`. + timeout: `timeout(...)`. + guard: Per-attempt state protection. + outer: Unclassified decorators placed outside every role. + inner: Unclassified decorators placed inside every role. + + Returns: + An immutable `Policy`, usable as a decorator. + + Raises: + TypeError: If an argument is not callable (or a list of callables). + + Example: + ```python + from python_wrap import CircuitBreaker, retry, timeout, policy + + resilient = policy( + timeout=timeout(2), + retry=retry(max_attempts=3, on=ConnectionError), + breaker=CircuitBreaker(failure_threshold=5, on=ConnectionError), + name="payments", + ) + + @resilient + def charge(amount: int) -> int: + return amount + ``` + """ + given = { + "observe": observe, + "validate": validate, + "fallback": fallback, + "cache": cache, + "limiter": limiter, + "bulkhead": bulkhead, + "breaker": breaker, + "retry": retry, + "timeout": timeout, + "guard": guard, + } + return Policy( + name=name, + outer=_as_tuple("outer", outer), + inner=_as_tuple("inner", inner), + roles={role: _as_tuple(role, value) for role, value in given.items()}, + ) diff --git a/src/python_wrap/py.typed b/src/python_wrap/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/python_wrap/retry.py b/src/python_wrap/retry.py new file mode 100644 index 0000000..22e8968 --- /dev/null +++ b/src/python_wrap/retry.py @@ -0,0 +1,932 @@ +"""Retry, fallback and suppress decorators.""" + +from __future__ import annotations + +import asyncio +import datetime +import functools +import logging +import math +import random +import sys +import threading +import time +import warnings +from collections.abc import AsyncIterator, Callable, Coroutine, Iterator +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Protocol, TypeVar, cast, overload + +from python_wrap import _core, config +from python_wrap._core import F, LoggerLike, P, R +from python_wrap.deadline import remaining as _deadline_remaining + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) + +ExceptionTypes = type[BaseException] | tuple[type[BaseException], ...] + +__all__ = [ + "Attempt", + "RetryState", + "RetryStats", + "Retrying", + "fallback", + "retry", + "retry_after_header", + "retry_on_exception", + "retry_stats", + "retrying", + "suppress", +] + +_MISSING: Any = object() +_UNSET: Any = config.UNSET # resolved by `_resolve_defaults` + + +@dataclass(frozen=True) +class RetryState: + """Snapshot passed to the ``on_retry`` callback before each wait. + + Attributes: + attempt: Number of the attempt that just failed (1-based). + exception: The exception that attempt raised. + next_delay: Seconds about to be slept before the next attempt (jitter included). + elapsed: Seconds since the first attempt started. + """ + + attempt: int + exception: BaseException + next_delay: float + elapsed: float + + +def _validate( + max_attempts: int | None, + delay: float, + backoff: float, + max_delay: float | None, + jitter: float, + max_time: float | None = None, +) -> None: + if max_attempts is not None and max_attempts < 1: + raise ValueError(f"max_attempts must be >= 1 or None, got {max_attempts!r}") + if delay < 0: + raise ValueError(f"delay must be >= 0, got {delay!r}") + if backoff < 1: + raise ValueError(f"backoff must be >= 1, got {backoff!r}") + if jitter < 0: + raise ValueError(f"jitter must be >= 0, got {jitter!r}") + if max_delay is not None and max_delay < 0: + raise ValueError(f"max_delay must be >= 0, got {max_delay!r}") + if max_time is not None and max_time < 0: + raise ValueError(f"max_time must be >= 0 or None, got {max_time!r}") + + +def _compute_delay( + attempt: int, delay: float, backoff: float, max_delay: float | None, jitter: float +) -> float: + try: + wait = delay * backoff ** (attempt - 1) + except OverflowError: + wait = sys.float_info.max + if max_delay is not None: + wait = min(wait, max_delay) + if jitter: + wait += random.uniform(0, jitter) + return wait + + +class RetryStats: + """Thread-safe counters for one retried function. Read them with `retry_stats`. + + Attributes: + calls: Times the wrapped function was called (each call may make several attempts). + attempts_total: Individual attempts made across all calls. + retries: Times an attempt failed and another one was scheduled. + gave_up: Calls that ended by re-raising a retryable exception (attempts, time + budget or `retry_if` said stop). + total_sleep: Seconds spent waiting between attempts. + last_exception: The most recent exception a retried attempt raised, if any. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self.calls = 0 + self.attempts_total = 0 + self.retries = 0 + self.gave_up = 0 + self.total_sleep = 0.0 + self.last_exception: BaseException | None = None + + def snapshot(self) -> dict[str, Any]: + """Return a consistent copy of all counters as a dict.""" + with self._lock: + return { + "calls": self.calls, + "attempts_total": self.attempts_total, + "retries": self.retries, + "gave_up": self.gave_up, + "total_sleep": self.total_sleep, + "last_exception": self.last_exception, + } + + def reset(self) -> None: + """Set every counter back to zero.""" + with self._lock: + self.calls = self.attempts_total = self.retries = self.gave_up = 0 + self.total_sleep = 0.0 + self.last_exception = None + + def __repr__(self) -> str: + s = self.snapshot() + return ( + f"RetryStats(calls={s['calls']}, attempts_total={s['attempts_total']}, " + f"retries={s['retries']}, gave_up={s['gave_up']}, total_sleep={s['total_sleep']:g})" + ) + + +_STATS_ATTR = "__retry_stats__" + + +def retry_stats(func: Callable[..., Any]) -> RetryStats | None: + """Return the `RetryStats` of a function decorated with `retry`. + + Works through other decorators that use `functools.wraps` and on bound methods + (the counters are shared by all instances of the class). + + Args: + func: A function wrapped by `retry`. + + Returns: + Its statistics, or `None` if `func` is not retried. + + Example: + >>> @retry + ... def f() -> None: ... + >>> retry_stats(f).calls + 0 + """ + stats = getattr(func, _STATS_ATTR, None) + return stats if isinstance(stats, RetryStats) else None + + +def _parse_seconds(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + if isinstance(value, (int, float)): + seconds = float(value) + else: + text = str(value).strip() + try: + seconds = float(text) + except ValueError: + import email.utils # only HTTP-date headers need it; keeps import fast + + moment = email.utils.parsedate_to_datetime(text) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=datetime.timezone.utc) + seconds = moment.timestamp() - time.time() + if math.isnan(seconds) or math.isinf(seconds): + return None + return max(seconds, 0.0) + + +def retry_after_header(exc: BaseException) -> float | None: + """Extract a server-requested wait from an exception, for `delay_from=`. + + Looks at `exc.retry_after` first, then at `exc.response.headers["Retry-After"]` + (header names are matched case-insensitively). Values may be seconds or an HTTP-date; + dates are converted to seconds from now. Works with `httpx`, `requests` and + `aiohttp`-style exceptions without importing them, and never raises. + + Args: + exc: Any exception. + + Returns: + Seconds to wait (never negative), or `None` if nothing usable was found. + + Example: + >>> class Err(Exception): + ... retry_after = 3 + >>> retry_after_header(Err()) + 3.0 + """ + try: + found = _parse_seconds(getattr(exc, "retry_after", None)) + if found is not None: + return found + headers = exc.response.headers # type: ignore[attr-defined] + value = headers.get("Retry-After") + if value is None: + for key in headers: + if str(key).lower() == "retry-after": + value = headers[key] + break + return _parse_seconds(value) + except Exception: + return None + + +# Never retried, even with ``on=BaseException``: retrying these hangs shutdown/cancellation. +_NEVER_RETRY = (asyncio.CancelledError, KeyboardInterrupt, SystemExit, GeneratorExit) + + +def _parse_hint(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or math.isnan(value): + return None + return max(float(value), 0.0) + + +def _resolve_defaults(**given: Any) -> dict[str, Any]: + """Fill arguments left unset from `python_wrap.config` (read at decoration time).""" + return {k: config.retry_default(k, v, _RETRY_FALLBACK[k]) for k, v in given.items()} + + +_RETRY_FALLBACK: dict[str, Any] = { + "max_attempts": 3, + "delay": 1.0, + "backoff": 1.0, + "max_delay": None, + "jitter": 0.0, + "max_time": None, + "log_level": logging.WARNING, +} + + +class _Engine: + """The retry state machine shared by `retry` and `retrying`.""" + + def __init__( + self, + name: str, + *, + max_attempts: int | None, + delay: float, + backoff: float, + max_delay: float | None, + jitter: float, + max_time: float | None, + on: ExceptionTypes, + retry_if: Callable[[BaseException], bool] | None, + on_retry: Callable[[RetryState], None] | None, + delay_from: Callable[[BaseException], float | None] | None, + logger: LoggerLike, + log_level: int | None, + log_exc_info: bool, + stats: RetryStats, + ) -> None: + _validate(max_attempts, delay, backoff, max_delay, jitter, max_time) + self.name = name + self.max_attempts = max_attempts + self.delay = delay + self.backoff = backoff + self.max_delay = max_delay + self.jitter = jitter + self.max_time = max_time + self.on = on + self.retry_if = retry_if + self.on_retry = on_retry + self.delay_from = delay_from + self.log = _core.get_logger(logger) + self.log_level = log_level + self.log_exc_info = log_exc_info + self.stats = stats + + def limit(self) -> int | None: + """Attempts allowed for this call, honouring the test override at call time.""" + cap = _core.overrides.max_attempts + if cap is None: + return self.max_attempts + return cap if self.max_attempts is None else min(self.max_attempts, cap) + + def start_call(self) -> float: + with self.stats._lock: + self.stats.calls += 1 + return _core.monotonic() + + def start_attempt(self) -> None: + with self.stats._lock: + self.stats.attempts_total += 1 + + def success(self, attempts: int) -> None: + _core.emit("retry.succeeded", self.name, attempts=attempts) + + def slept(self, seconds: float) -> None: + with self.stats._lock: + self.stats.total_sleep += seconds + + def _wait_for(self, attempt: int, exc: BaseException) -> float: + if self.delay_from is not None: + try: + hint = _parse_hint(self.delay_from(exc)) + except Exception: + self.log.debug("%s: delay_from raised; using backoff", self.name, exc_info=True) + hint = None + if hint is not None: + return hint if self.max_delay is None else min(hint, self.max_delay) + return _compute_delay(attempt, self.delay, self.backoff, self.max_delay, self.jitter) + + def failure(self, attempt: int, exc: BaseException, started: float) -> float | None: + """Decide after a failed attempt: the wait in seconds, or None to re-raise.""" + if isinstance(exc, _NEVER_RETRY): + return None + with self.stats._lock: + self.stats.last_exception = exc + wait: float | None = None + limit = self.limit() + if (self.retry_if is None or self.retry_if(exc)) and (limit is None or attempt < limit): + wait = self._wait_for(attempt, exc) + if self.max_time is not None and _core.monotonic() - started + wait > self.max_time: + wait = None + left = _deadline_remaining() + if wait is not None and left is not None and wait > left: + wait = None # never sleep past the surrounding `deadline` + if wait is None: + with self.stats._lock: + self.stats.gave_up += 1 + _core.emit("retry.gave_up", self.name, attempts=attempt, exception=exc) + return None + with self.stats._lock: + self.stats.retries += 1 + if self.on_retry is not None: + self.on_retry(RetryState(attempt, exc, wait, _core.monotonic() - started)) + if self.log_level is not None: + self.log.log( + self.log_level, + "%s failed (attempt %d/%s): %r; retrying in %.3fs", + self.name, + attempt, + "inf" if limit is None else limit, + exc, + wait, + exc_info=(type(exc), exc, exc.__traceback__) if self.log_exc_info else None, + ) + _core.emit( + "retry.attempt_failed", self.name, attempt=attempt, exception=exc, next_delay=wait + ) + return wait + + +@overload +def retry( + func: F, + /, + *, + max_attempts: int | None = ..., + delay: float = ..., + backoff: float = ..., + max_delay: float | None = ..., + jitter: float = ..., + max_time: float | None = ..., + on: ExceptionTypes = ..., + retry_if: Callable[[BaseException], bool] | None = ..., + on_retry: Callable[[RetryState], None] | None = ..., + delay_from: Callable[[BaseException], float | None] | None = ..., + logger: LoggerLike = ..., + log_level: int | None = ..., + log_exc_info: bool = ..., +) -> F: ... +@overload +def retry( + func: None = None, + /, + *, + max_attempts: int | None = ..., + delay: float = ..., + backoff: float = ..., + max_delay: float | None = ..., + jitter: float = ..., + max_time: float | None = ..., + on: ExceptionTypes = ..., + retry_if: Callable[[BaseException], bool] | None = ..., + on_retry: Callable[[RetryState], None] | None = ..., + delay_from: Callable[[BaseException], float | None] | None = ..., + logger: LoggerLike = ..., + log_level: int | None = ..., + log_exc_info: bool = ..., +) -> Callable[[F], F]: ... +def retry( + func: Callable[..., Any] | None = None, + /, + *, + max_attempts: int | None = _UNSET, + delay: float = _UNSET, + backoff: float = _UNSET, + max_delay: float | None = _UNSET, + jitter: float = _UNSET, + max_time: float | None = _UNSET, + on: ExceptionTypes = Exception, + retry_if: Callable[[BaseException], bool] | None = None, + on_retry: Callable[[RetryState], None] | None = None, + delay_from: Callable[[BaseException], float | None] | None = None, + logger: LoggerLike = None, + log_level: int | None = _UNSET, + log_exc_info: bool = False, +) -> Any: + """Retry a function when it raises, optionally with exponential backoff. + + Works bare (`@retry`), with options (`@retry(max_attempts=5)`) and as a plain + call (`retry(func, max_attempts=5)`). Async functions are awaited and slept + with `asyncio` so the event loop is never blocked. + + The wait before attempt `n + 1` is + `min(delay * backoff ** (n - 1), max_delay) + random.uniform(0, jitter)`. + The last exception is re-raised unchanged, with its original traceback, once + attempts or the time budget are exhausted. + + `asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` are + never retried, even with `on=BaseException` or when listed in `on`. Cancelling an + async call during the backoff sleep cancels it immediately. + + Arguments you leave out fall back to `python_wrap.config.configure(retry={...})` + (read when the decorator is applied), then to the values shown here. Inside a + `python_wrap.deadline` block, it never sleeps past the time remaining: if the next + wait would outlast the deadline the last exception is re-raised instead. + + Statistics are available through `retry_stats`. The events `retry.attempt_failed`, + `retry.succeeded` and `retry.gave_up` are emitted for `python_wrap.events`. Under + `python_wrap.testing`, an `overrides.max_attempts` cap lowers `max_attempts`. + + Args: + func: The function to wrap (when used without parentheses). + max_attempts: Total number of calls allowed, or `None` for no limit. + delay: Base wait in seconds before the second attempt. + backoff: Multiplier applied to the wait after each failure (>= 1). + max_delay: Upper bound for the backoff wait (before jitter is added); also caps + waits returned by `delay_from`. + jitter: Up to this many random extra seconds are added to each wait. + max_time: Total time budget in seconds, measured from the first attempt. If the + next wait would run past it, the last exception is re-raised instead. + on: Exception type(s) that trigger a retry; anything else propagates at once. + retry_if: Extra predicate; the exception is retried only if it returns True. + on_retry: Called with a `RetryState` before each wait. + delay_from: Called with the exception; a number is the exact wait for this + attempt (no backoff, no jitter; still capped by `max_delay` and `max_time`), + `None` means use the normal backoff. See `retry_after_header`. + logger: Logger (or logger name) for the per-retry message. + log_level: Level of the per-retry message, or `None` to not log. + log_exc_info: Attach the traceback to the per-retry log record. + + Returns: + The wrapped function, or a decorator when `func` is omitted. + + Raises: + ValueError: If an option is out of range (raised when decorating). + + Example: + >>> @retry(max_attempts=5, delay=0.5, backoff=2, on=ConnectionError) + ... def fetch() -> str: ... + """ + ds = _resolve_defaults( + max_attempts=max_attempts, + delay=delay, + backoff=backoff, + max_delay=max_delay, + jitter=jitter, + max_time=max_time, + log_level=log_level, + ) + max_attempts, delay, backoff = ds["max_attempts"], ds["delay"], ds["backoff"] + max_delay, jitter, max_time, log_level = ( + ds["max_delay"], + ds["jitter"], + ds["max_time"], + ds["log_level"], + ) + # Validate eagerly so a bad option fails at decoration time, even with no function yet. + _validate(max_attempts, delay, backoff, max_delay, jitter, max_time) + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + stats = RetryStats() + engine = _Engine( + _core.qualname(f), + max_attempts=max_attempts, + delay=delay, + backoff=backoff, + max_delay=max_delay, + jitter=jitter, + max_time=max_time, + on=on, + retry_if=retry_if, + on_retry=on_retry, + delay_from=delay_from, + logger=logger, + log_level=log_level, + log_exc_info=log_exc_info, + stats=stats, + ) + + if _core.is_async(f): + af = cast("Callable[..., Coroutine[Any, Any, Any]]", f) + + @functools.wraps(af) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + started = engine.start_call() + attempt = 1 + while True: + engine.start_attempt() + try: + result = await af(*args, **kwargs) + except on as exc: + wait = engine.failure(attempt, exc, started) + if wait is None: + raise + else: + engine.success(attempt) + return result + await _core.async_sleep(wait) + engine.slept(wait) + attempt += 1 + + setattr(async_wrapper, _STATS_ATTR, stats) + return cast("Callable[P, R]", async_wrapper) + + @functools.wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + started = engine.start_call() + attempt = 1 + while True: + engine.start_attempt() + try: + result = f(*args, **kwargs) + except on as exc: + wait = engine.failure(attempt, exc, started) + if wait is None: + raise + else: + engine.success(attempt) + return result + _core.sleep(wait) + engine.slept(wait) + attempt += 1 + + setattr(wrapper, _STATS_ATTR, stats) + return wrapper + + return decorate if func is None else decorate(func) + + +class _Run: + def __init__(self, engine: _Engine) -> None: + self.engine = engine + self.started = engine.start_call() + + +class Attempt: + """One attempt of a `retrying` loop; use it as a context manager. + + Exceptions that `retry` would retry are swallowed by `with attempt:` (the loop then + waits and continues); other exceptions, and the failure of the final attempt, + propagate. + + Attributes: + number: 1-based number of this attempt. + """ + + def __init__(self, number: int, run: _Run) -> None: + self.number = number + self._run = run + self._wait: float | None = None + + def __enter__(self) -> Attempt: + self._run.engine.start_attempt() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: + engine = self._run.engine + if exc is None or not isinstance(exc, engine.on): + return False + self._wait = engine.failure(self.number, exc, self._run.started) + return self._wait is not None + + +class Retrying: + """Iterable of `Attempt` objects returned by `retrying`. + + Iterate it with `for` (sync, sleeps with `time.sleep`) or `async for` (sleeps with + `asyncio`). Each iteration over a `Retrying` is an independent run. + """ + + def __init__(self, engine: _Engine) -> None: + self._engine = engine + + @property + def stats(self) -> RetryStats: + """Counters accumulated over all runs of this object.""" + return self._engine.stats + + def __iter__(self) -> Iterator[Attempt]: + run = _Run(self._engine) + number = 1 + while True: + attempt = Attempt(number, run) + yield attempt + wait = attempt._wait + if wait is None: + run.engine.success(number) + return + _core.sleep(wait) + run.engine.slept(wait) + number += 1 + + async def __aiter__(self) -> AsyncIterator[Attempt]: + run = _Run(self._engine) + number = 1 + while True: + attempt = Attempt(number, run) + yield attempt + wait = attempt._wait + if wait is None: + run.engine.success(number) + return + await _core.async_sleep(wait) + run.engine.slept(wait) + number += 1 + + +def retrying( + *, + max_attempts: int | None = _UNSET, + delay: float = _UNSET, + backoff: float = _UNSET, + max_delay: float | None = _UNSET, + jitter: float = _UNSET, + max_time: float | None = _UNSET, + on: ExceptionTypes = Exception, + retry_if: Callable[[BaseException], bool] | None = None, + on_retry: Callable[[RetryState], None] | None = None, + delay_from: Callable[[BaseException], float | None] | None = None, + logger: LoggerLike = None, + log_level: int | None = _UNSET, + log_exc_info: bool = False, + name: str = "retrying", +) -> Retrying: + """Retry an inline block of code instead of a whole function. + + Takes the same options as `retry` and behaves identically (backoff, jitter, + `max_time`, `delay_from`, cancellation rules, events, test overrides). Put the + risky code in `with attempt:`: a retryable exception is swallowed and the loop + waits and continues; success ends the loop; the final failure propagates. + + Use `for` in sync code and `async for` in async code. Do not use `for` inside a + coroutine, it would block the event loop while waiting. + + Args: + max_attempts: Total attempts allowed, or `None` for no limit. + delay: Base wait in seconds before the second attempt. + backoff: Multiplier applied to the wait after each failure (>= 1). + max_delay: Upper bound for the backoff wait (before jitter). + jitter: Up to this many random extra seconds are added to each wait. + max_time: Total time budget in seconds from the first attempt. + on: Exception type(s) that trigger a retry. + retry_if: Extra predicate; retry only if it returns True. + on_retry: Called with a `RetryState` before each wait. + delay_from: Exact wait derived from the exception, or `None` for backoff. + logger: Logger (or logger name) for the per-retry message. + log_level: Level of the per-retry message, or `None` to not log. + log_exc_info: Attach the traceback to the per-retry log record. + name: Name used in log messages and events. + + Returns: + A `Retrying` object to iterate over. + + Raises: + ValueError: If an option is out of range. + + Example: + >>> for attempt in retrying(max_attempts=3, delay=0.1, on=ConnectionError): + ... with attempt: + ... do_work() # doctest: +SKIP + """ + ds = _resolve_defaults( + max_attempts=max_attempts, + delay=delay, + backoff=backoff, + max_delay=max_delay, + jitter=jitter, + max_time=max_time, + log_level=log_level, + ) + max_attempts, delay, backoff = ds["max_attempts"], ds["delay"], ds["backoff"] + max_delay, jitter, max_time, log_level = ( + ds["max_delay"], + ds["jitter"], + ds["max_time"], + ds["log_level"], + ) + return Retrying( + _Engine( + name, + max_attempts=max_attempts, + delay=delay, + backoff=backoff, + max_delay=max_delay, + jitter=jitter, + max_time=max_time, + on=on, + retry_if=retry_if, + on_retry=on_retry, + delay_from=delay_from, + logger=logger, + log_level=log_level, + log_exc_info=log_exc_info, + stats=RetryStats(), + ) + ) + + +def retry_on_exception( + *exceptions: type[BaseException], + max_attempts: int | None = 3, + delay: float = 0.0, + backoff: float = 1.0, + max_delay: float | None = None, + jitter: float = 0.0, + retry_if: Callable[[BaseException], bool] | None = None, + on_retry: Callable[[RetryState], None] | None = None, + logger: LoggerLike = None, +) -> Callable[[F], F]: + """Deprecated alias for ``retry(on=exceptions or Exception, ...)``. + + Args: + *exceptions: Exception types to retry on (default ``Exception``). + max_attempts: Total calls allowed, or ``None`` for no limit. + delay: Base wait in seconds between attempts. + backoff: Multiplier applied to the wait after each failure. + max_delay: Upper bound for the wait. + jitter: Random extra seconds added to each wait. + retry_if: Extra predicate deciding whether to retry. + on_retry: Callback invoked before each wait. + logger: Logger (or name) for retry messages. + + Returns: + A decorator. Applying it emits a ``DeprecationWarning``. + + Raises: + ValueError: If an option is out of range. + + Example: + >>> @retry_on_exception(ValueError) # use retry(on=ValueError) instead + ... def parse() -> None: ... + """ + on: ExceptionTypes = exceptions or Exception + _validate(max_attempts, delay, backoff, max_delay, jitter) + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + warnings.warn( + "retry_on_exception is deprecated; use retry(on=...) instead", + DeprecationWarning, + stacklevel=2, + ) + return retry( + f, + max_attempts=max_attempts, + delay=delay, + backoff=backoff, + max_delay=max_delay, + jitter=jitter, + on=on, + retry_if=retry_if, + on_retry=on_retry, + logger=logger, + ) + + return cast("Callable[[F], F]", decorate) + + +class _Decorator(Protocol[T_co]): + """A decorator that maps ``f`` to ``f`` with ``T_co`` added to the result type.""" + + @overload + def __call__( + self, func: Callable[P, Coroutine[Any, Any, R]], / + ) -> Callable[P, Coroutine[Any, Any, R | T_co]]: ... + @overload + def __call__(self, func: Callable[P, R], /) -> Callable[P, R | T_co]: ... + + +def _make_catcher( + on: ExceptionTypes, + on_error: Callable[[Callable[..., Any], BaseException], Any], +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorate(f: Callable[..., Any]) -> Callable[..., Any]: + if _core.is_async(f): + + @functools.wraps(f) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await f(*args, **kwargs) + except on as exc: + return on_error(f, exc) + + return async_wrapper + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return f(*args, **kwargs) + except on as exc: + return on_error(f, exc) + + return wrapper + + return decorate + + +@overload +def fallback( + value: T, /, *, on: ExceptionTypes = ..., logger: LoggerLike = ... +) -> _Decorator[T]: ... +@overload +def fallback( + *, handler: Callable[[BaseException], T], on: ExceptionTypes = ..., logger: LoggerLike = ... +) -> _Decorator[T]: ... +def fallback( + value: Any = _MISSING, + /, + *, + handler: Callable[[BaseException], Any] | None = None, + on: ExceptionTypes = Exception, + logger: LoggerLike = None, +) -> Any: + """Return a default value (or a handler's result) when the function raises. + + Args: + value: The value to return on a matching exception. + handler: Alternatively, a callable ``handler(exc)`` whose result is returned. + on: Exception type(s) that trigger the fallback. + logger: Logger (or name) for the WARNING message (logged with traceback). + + Returns: + A decorator for sync or async functions. For async functions the handler is + still a regular (sync) function. + + Raises: + ValueError: If neither or both of ``value`` and ``handler`` are given. + + Example: + >>> @fallback(0, on=KeyError) + ... def lookup(key: str) -> int: ... + """ + if (value is _MISSING) == (handler is None): + raise ValueError("fallback requires exactly one of `value` or `handler`") + log = _core.get_logger(logger) + + def on_error(f: Callable[..., Any], exc: BaseException) -> Any: + log.warning( + "%s raised %r; using fallback", + _core.qualname(f), + exc, + exc_info=(type(exc), exc, exc.__traceback__), + ) + return handler(exc) if handler is not None else value + + return _make_catcher(on, on_error) + + +@overload +def suppress( # type: ignore[overload-overlap] + exc: type[BaseException], /, *more: type[BaseException], logger: LoggerLike = ... +) -> _Decorator[None]: ... +@overload +def suppress( + func: Callable[P, Coroutine[Any, Any, R]], / +) -> Callable[P, Coroutine[Any, Any, R | None]]: ... +@overload +def suppress(func: Callable[P, R], /) -> Callable[P, R | None]: ... +@overload +def suppress(*, logger: LoggerLike = ...) -> _Decorator[None]: ... +def suppress(*exceptions: Any, logger: LoggerLike = None) -> Any: + """Swallow matching exceptions; the call then returns ``None``. + + Usable bare (``@suppress``, which suppresses ``Exception``) or with exception + types (``@suppress(KeyError)``). Suppressed errors are logged at DEBUG. + + Args: + *exceptions: Exception types to swallow (default ``Exception``). + logger: Logger (or name) for the DEBUG message. + + Returns: + The wrapped function, or a decorator when exception types are given. + + Example: + >>> @suppress(KeyError) + ... def first(d: dict[str, int]) -> int: ... + """ + log = _core.get_logger(logger) + + def make(on: ExceptionTypes) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def on_error(f: Callable[..., Any], exc: BaseException) -> None: + log.debug("%s raised %r; suppressed", _core.qualname(f), exc) + + return _make_catcher(on, on_error) + + if len(exceptions) == 1 and not isinstance(exceptions[0], type) and callable(exceptions[0]): + return make(Exception)(exceptions[0]) + return make(tuple(exceptions) or Exception) diff --git a/src/python_wrap/state.py b/src/python_wrap/state.py new file mode 100644 index 0000000..29bdc10 --- /dev/null +++ b/src/python_wrap/state.py @@ -0,0 +1,497 @@ +"""State safety: `revert_on_failure` and transactions.""" + +from __future__ import annotations + +import contextlib +import contextvars +import copy +import functools +import inspect +import threading +from collections.abc import Callable, Iterable +from types import TracebackType +from typing import Any, Final, ParamSpec, TypeVar, cast, overload + +from python_wrap import _core +from python_wrap._core import F, LoggerLike + +__all__ = [ + "INJECTED", + "Transaction", + "current_transaction", + "require_transaction", + "revert_on_failure", + "transactional", +] + +P = ParamSpec("P") +R = TypeVar("R") + +_NO_VALUE: Any = object() + + +# ------------------------------------------------------------------ revert_on_failure + + +def _copy_value(value: Any, deep: bool) -> Any: + try: + return copy.deepcopy(value) if deep else copy.copy(value) + except Exception: + return value # not copyable (locks, sockets...): restored by reference + + +class _Snapshot: + def __init__(self, obj: Any, attrs: tuple[str, ...] | None, deep: bool) -> None: + self.obj = obj + self.attrs = attrs + self.values: dict[str, Any] = {} + if attrs is None: + try: + current = vars(obj) + except TypeError: + raise TypeError( + f"revert_on_failure: {type(obj).__name__!r} has no __dict__ " + "(it uses __slots__?); pass attrs=[...] to name the attributes" + ) from None + names: Iterable[str] = [n for n in current if self._wanted(n)] + else: + names = attrs + for name in names: + try: + # Classes: take the raw entry so classmethods/properties stay descriptors. + value = ( + vars(obj)[name] + if isinstance(obj, type) and name in vars(obj) + else getattr(obj, name) + ) + except AttributeError: + self.values[name] = _NO_VALUE + else: + self.values[name] = _copy_value(value, deep) + + def _wanted(self, name: str) -> bool: + # Classes: leave dunders (``__dict__``, ``__weakref__``, ``__module__``...) alone. + return not (isinstance(self.obj, type) and name.startswith("__") and name.endswith("__")) + + def restore(self) -> None: + """Put the attributes back; never raises (failures are logged).""" + obj = self.obj + log = _core.get_logger(None) + if self.attrs is None: + try: + extra = [n for n in vars(obj) if n not in self.values and self._wanted(n)] + except Exception: + extra = [] + for name in extra: + try: + delattr(obj, name) + except Exception: + log.warning("revert_on_failure: could not delete %r", name, exc_info=True) + for name, value in self.values.items(): + try: + if value is _NO_VALUE: + with contextlib.suppress(AttributeError): + delattr(obj, name) + else: + setattr(obj, name, value) + except Exception: + log.warning("revert_on_failure: could not restore %r", name, exc_info=True) + + +def _target_resolver( + func: Callable[..., Any], target: str | int +) -> Callable[[tuple[Any, ...], dict[str, Any]], Any]: + sig = inspect.signature(func) + if isinstance(target, str): + if target not in sig.parameters: + raise ValueError(f"revert_on_failure: {func!r} has no parameter {target!r}") + + def by_name(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: + return sig.bind(*args, **kwargs).arguments[target] + + return by_name + + def by_index(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: + return args[target] + + return by_index + + +@overload +def revert_on_failure(func: F, /) -> F: ... +@overload +def revert_on_failure( + func: None = None, + /, + *, + target: str | int = 0, + attrs: Iterable[str] | None = None, + deep: bool = False, +) -> Callable[[F], F]: ... +def revert_on_failure( + func: Callable[..., Any] | None = None, + /, + *, + target: str | int = 0, + attrs: Iterable[str] | None = None, + deep: bool = False, +) -> Any: + """Restore an object's attributes if the function raises. + + Before the call, the attributes of the argument ``target`` (a positional index or a + parameter name; the first argument, normally ``self``, by default) are snapshotted: + all of ``vars(obj)``, or only ``attrs`` (required for ``__slots__`` classes). If the + function raises, every snapshotted attribute is set back, attributes created during + the call are deleted (when snapshotting all of ``vars``; with ``attrs`` an attribute + that did not exist before is deleted), and the exception is re-raised. + + Copy semantics: each value is copied with ``copy.copy`` (``copy.deepcopy`` with + ``deep=True``) and the *copy* is what gets restored. Therefore in-place mutation of + a container attribute (``self.items.append(x)``) IS undone, but the attribute ends + up referring to a new list object, so other references to the old list still see + the mutation. A shallow copy does not protect the elements: mutating an object + nested inside the list (``self.items[0].count += 1``) is only undone with + ``deep=True``. Values that cannot be copied are restored by reference. + + v0.1 reported to revert but its snapshot was always empty, so nothing was restored. + + Args: + func: The function (when used bare). + target: Index or name of the argument whose attributes are protected. + attrs: Restrict the snapshot to these attribute names. + deep: Use ``copy.deepcopy`` for the snapshot. + + Returns: + The wrapped function, or a decorator when called with options. + + Raises: + ValueError: If ``target`` names a parameter the function does not have. + TypeError: At call time, if no ``attrs`` are given and the object has no + ``__dict__``. + + Example: + >>> class Account: + ... def __init__(self): + ... self.balance = 10 + ... @revert_on_failure + ... def withdraw(self, n): + ... self.balance -= n + ... if self.balance < 0: + ... raise ValueError("overdrawn") + """ + attr_names = None if attrs is None else tuple(attrs) + + def decorate(f: Callable[..., Any]) -> Any: + resolve = _target_resolver(f, target) + + def begin(args: tuple[Any, ...], kwargs: dict[str, Any]) -> _Snapshot | None: + try: + obj = resolve(args, kwargs) + except (IndexError, KeyError, TypeError): + return None # bad call: let the function raise its own TypeError + return _Snapshot(obj, attr_names, deep) + + if _core.is_async(f): + + @functools.wraps(f) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + snap = begin(args, kwargs) + try: + return await f(*args, **kwargs) + except BaseException: + if snap is not None: + snap.restore() + raise + + return awrapper + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + snap = begin(args, kwargs) + try: + return f(*args, **kwargs) + except BaseException: + if snap is not None: + snap.restore() + raise + + return wrapper + + return decorate if func is None else decorate(func) + + +# --------------------------------------------------------------------- transactions + +_current: contextvars.ContextVar[Transaction | None] = contextvars.ContextVar( + "python_wrap_current_transaction", default=None +) + + +def current_transaction() -> Transaction | None: + """Return the transaction of the innermost running ``@transactional`` call, if any.""" + return _current.get() + + +def require_transaction() -> Transaction: + """Return the current transaction, raising if there is none. + + Like `current_transaction` but typed as `Transaction`, so there is no + ``assert tx is not None`` boilerplate. + + Returns: + The transaction of the innermost running ``@transactional`` call. + + Raises: + RuntimeError: If called outside a ``@transactional`` function. + + Example: + >>> @transactional + ... def charge(): + ... require_transaction().on_rollback(print, "undo") + """ + tx = _current.get() + if tx is None: + raise RuntimeError("no active transaction: call this from a @transactional function") + return tx + + +class Transaction: + """Collects commit and rollback callbacks and runs the right set once. + + Use as a context manager (``with`` or ``async with``). On exception the rollback + callbacks run in reverse registration order; a failing one is logged and the rest + still run; then the original exception propagates. On success the commit callbacks + run in order (an exception from one propagates and skips the remaining ones). + A transaction completes only once: a second ``commit()``/``rollback()`` raises + ``RuntimeError``. Callbacks that are coroutine functions need ``async with`` (or + ``await tx.acommit()`` / ``await tx.arollback()``). + + Args: + logger: Logger (or name) used to report failing rollback callbacks. + + Example: + >>> with Transaction() as tx: + ... tx.on_rollback(print, "undo") + """ + + def __init__(self, logger: LoggerLike = None) -> None: + self._logger = _core.get_logger(logger) + self._commit: list[tuple[Callable[..., Any], tuple[Any, ...], dict[str, Any]]] = [] + self._rollback: list[tuple[Callable[..., Any], tuple[Any, ...], dict[str, Any]]] = [] + self._state = "pending" + self._state_lock = threading.Lock() + + @property + def state(self) -> str: + """``"pending"``, ``"committed"`` or ``"rolled_back"``.""" + return self._state + + def _check_open(self) -> None: + if self._state != "pending": + raise RuntimeError(f"transaction already {self._state}") + + def _complete(self, state: str) -> None: + """Atomically move from pending to ``state``; only one caller can succeed.""" + with self._state_lock: + self._check_open() + self._state = state + + def on_rollback(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None: + """Register ``fn(*args, **kwargs)`` to run if the transaction rolls back.""" + self._check_open() + self._rollback.append((fn, args, kwargs)) + + def on_commit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None: + """Register ``fn(*args, **kwargs)`` to run if the transaction commits.""" + self._check_open() + self._commit.append((fn, args, kwargs)) + + @staticmethod + def _call_sync(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + result = fn(*args, **kwargs) + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + result.close() + raise TypeError( + f"transaction callback {_core.qualname(fn)} is async; " + "use 'async with' or await acommit()/arollback()" + ) + + def commit(self) -> None: + """Mark committed and run the commit callbacks in order.""" + self._complete("committed") + for fn, args, kwargs in self._commit: + self._call_sync(fn, args, kwargs) + + def rollback(self) -> None: + """Mark rolled back and run the rollback callbacks in reverse order.""" + self._complete("rolled_back") + for fn, args, kwargs in reversed(self._rollback): + try: + self._call_sync(fn, args, kwargs) + except Exception: + self._logger.error("rollback callback %s failed", _core.qualname(fn), exc_info=True) + + async def acommit(self) -> None: + """Like `commit`, awaiting async callbacks.""" + self._complete("committed") + for fn, args, kwargs in self._commit: + result = fn(*args, **kwargs) + if inspect.isawaitable(result): + await result + + async def arollback(self) -> None: + """Like `rollback`, awaiting async callbacks.""" + self._complete("rolled_back") + for fn, args, kwargs in reversed(self._rollback): + try: + result = fn(*args, **kwargs) + if inspect.isawaitable(result): + await result + except Exception: + self._logger.error("rollback callback %s failed", _core.qualname(fn), exc_info=True) + + def __enter__(self) -> Transaction: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._state != "pending": + return + if exc_type is None: + self.commit() + else: + self.rollback() + + async def __aenter__(self) -> Transaction: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._state != "pending": + return + if exc_type is None: + await self.acommit() + else: + await self.arollback() + + +class _Injected: + __slots__ = () + + def __repr__(self) -> str: + return "INJECTED" + + +INJECTED: Final[Transaction] = cast(Transaction, _Injected()) +"""Typed placeholder default for a ``tx`` parameter that `transactional` fills in. + +Write ``def charge(amount: int, *, tx: Transaction = INJECTED) -> None`` and callers +type-check without passing ``tx``. Never use the value itself.""" + + +@overload +def transactional(func: F, /) -> F: ... +@overload +def transactional(func: None = None, /, *, logger: LoggerLike = None) -> Callable[[F], F]: ... +def transactional(func: Callable[..., Any] | None = None, /, *, logger: LoggerLike = None) -> Any: + """Run the function inside a fresh `Transaction` per call. + + If the function has a parameter named ``tx`` it receives the transaction as the + keyword argument ``tx=``. Declare it as ``tx: Transaction = INJECTED`` (keyword-only is + cleanest) so callers type-check without passing it; passing ``tx`` yourself raises + ``TypeError``. Otherwise use `require_transaction` / `current_transaction` (a + ``ContextVar``, correct across threads and asyncio tasks). Returning normally + commits, raising rolls back and re-raises. Nested ``@transactional`` calls each get + their own transaction. Works for sync and ``async def`` functions (async callbacks + are awaited). + + v0.1's ``transactional`` was a no-op. + + Args: + func: The function (when used bare). + logger: Logger (or name) for failing rollback callbacks. + + Returns: + The wrapped function, or a decorator when called with options. + + Raises: + TypeError: At decoration time, if ``tx`` is positional-only, ``*tx`` or ``**tx`` + (it is injected by keyword); at call time, if the caller passes ``tx``. + + Example: + >>> @transactional + ... def transfer(tx: Transaction = INJECTED): + ... tx.on_rollback(print, "undone") + """ + + def decorate(f: Callable[..., Any]) -> Any: + tx_pos: int | None = None + try: + params = inspect.signature(f).parameters + except (TypeError, ValueError): + params = None + wants_tx = params is not None and "tx" in params + if params is not None and wants_tx: + kind = params["tx"].kind + if kind is not kind.POSITIONAL_OR_KEYWORD and kind is not kind.KEYWORD_ONLY: + raise TypeError( + f"@transactional cannot inject 'tx' into {_core.qualname(f)}(): it is " + f"{kind.description}; declare it as a normal or keyword-only parameter " + "(tx: Transaction = INJECTED)" + ) + if kind is kind.POSITIONAL_OR_KEYWORD: + tx_pos = list(params).index("tx") + + def check_not_passed(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + if ("tx" in kwargs and kwargs["tx"] is not INJECTED) or ( + tx_pos is not None and len(args) > tx_pos and args[tx_pos] is not INJECTED + ): + raise TypeError( + f"{_core.qualname(f)}() received 'tx' from the caller, but @transactional " + "supplies it; do not pass tx" + ) + + if _core.is_async(f): + + @functools.wraps(f) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + if wants_tx: + check_not_passed(args, kwargs) + tx = Transaction(logger) + if wants_tx: + kwargs["tx"] = tx + token = _current.set(tx) + try: + async with tx: + return await f(*args, **kwargs) + finally: + _current.reset(token) + + return awrapper + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if wants_tx: + check_not_passed(args, kwargs) + tx = Transaction(logger) + if wants_tx: + kwargs["tx"] = tx + token = _current.set(tx) + try: + with tx: + return f(*args, **kwargs) + finally: + _current.reset(token) + + return wrapper + + return decorate if func is None else decorate(func) diff --git a/src/python_wrap/testing.py b/src/python_wrap/testing.py new file mode 100644 index 0000000..a0615ab --- /dev/null +++ b/src/python_wrap/testing.py @@ -0,0 +1,659 @@ +"""Helpers for testing code that uses python-wrap (and code that waits or retries). + +Everything here is standard library only. ``pytest`` is optional: the fixtures at the +bottom exist only when it can be imported, so this module imports fine without it. + +Enable the fixtures with ``pytest_plugins = ["python_wrap.testing"]`` in a top-level +``conftest.py``; they are ``pw_fake_clock`` and ``pw_fast_mode``. + +Nesting semantics of the override helpers +----------------------------------------- +Overrides are one process-wide state (tests use threads, so context variables would +hide them from worker threads). Each field keeps the value it had before the first +active scope touched it, plus a stack of the values of the scopes currently active on +it. When a scope exits, the field takes the value of the most recent scope still active, +or its original value once none is left. So nested scopes restore correctly, and scopes +from different threads that overlap (enter A, enter B, exit A, exit B) cannot leak a +value: the field is back to its original value after the last one exits. While scopes +overlap, the most recently entered one that is still active wins. +""" + +from __future__ import annotations + +import asyncio +import functools +import inspect +import math +import threading +import time +import types +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, Generic, ParamSpec, TypeVar, overload + +from python_wrap import _core + +try: + import pytest as _pytest +except ImportError: # pragma: no cover - exercised only without pytest installed + _pytest = None # type: ignore[assignment] + +__all__ = [ + "Call", + "FakeClock", + "Spy", + "disable_caching", + "disable_rate_limits", + "disable_retries", + "eventually", + "eventually_sync", + "fake_clock", + "fast_mode", + "overrides", + "spy", + "spy_on", +] + +P = ParamSpec("P") +R = TypeVar("R") +F = TypeVar("F", bound=Callable[..., Any]) + + +class _Unset: + def __repr__(self) -> str: + return "UNSET" + + +_UNSET = _Unset() + + +# --------------------------------------------------------------------------- overrides + + +_scope_lock = threading.Lock() +# Per overridden field: the value before the first active scope, and the stack of +# (scope token, value) for every scope currently active on it. +_baseline: dict[str, Any] = {} +_active: dict[str, list[tuple[object, Any]]] = {} + + +class _Scope: + """Context manager and decorator that applies override fields and restores them.""" + + def __init__(self, values: dict[str, Any]) -> None: + self._values = values + self._tokens: list[object] = [] + + def __enter__(self) -> None: + token = object() + with _scope_lock: + # set_overrides validates first, so a bad value leaves no bookkeeping behind. + previous = _core.set_overrides(**self._values) + for name, value in self._values.items(): + stack = _active.setdefault(name, []) + if not stack: + _baseline[name] = previous[name] + stack.append((token, value)) + self._tokens.append(token) + + def __exit__(self, *exc: object) -> None: + token = self._tokens.pop() + with _scope_lock: + restore: dict[str, Any] = {} + for name in self._values: + stack = _active[name] + stack[:] = [entry for entry in stack if entry[0] is not token] + if stack: + restore[name] = stack[-1][1] # a scope is still active: its value wins + else: + restore[name] = _baseline.pop(name) + del _active[name] + _core.set_overrides(**restore) + + def __call__(self, func: F) -> F: + values = self._values + if _core.is_async(func): + + @functools.wraps(func) + async def awrapper(*args: Any, **kwargs: Any) -> Any: + with _Scope(values): + return await func(*args, **kwargs) + + return awrapper # type: ignore[return-value] + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with _Scope(values): + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def overrides( + *, + no_sleep: bool | _Unset = _UNSET, + max_attempts: int | _Unset | None = _UNSET, + disable_rate_limits: bool | _Unset = _UNSET, + disable_caching: bool | _Unset = _UNSET, +) -> _Scope: + """Temporarily change the process-wide test overrides. + + Use it as a context manager or as a decorator on sync and async functions. Only the + fields you pass are changed; on exit exactly those are restored. See the module + docstring for nesting semantics. + + Args: + no_sleep: Make `_core.sleep` / `_core.async_sleep` return immediately. + max_attempts: Cap `retry` at this many attempts (`None` removes the cap). + disable_rate_limits: Let every call through rate limiters and throttles. + disable_caching: Make caches always call the wrapped function. + + Raises: + ValueError: If `max_attempts` is not `None` or an integer >= 1. + + Example: + ```python + from python_wrap import _core + from python_wrap.testing import overrides + + with overrides(no_sleep=True): + assert _core.overrides.no_sleep + assert not _core.overrides.no_sleep + ``` + """ + given = { + "no_sleep": no_sleep, + "max_attempts": max_attempts, + "disable_rate_limits": disable_rate_limits, + "disable_caching": disable_caching, + } + values = {k: v for k, v in given.items() if not isinstance(v, _Unset)} + attempts = values.get("max_attempts") + if attempts is not None and (not isinstance(attempts, int) or attempts < 1): + # Validate eagerly so a bad value fails at the call, not on entry. + raise ValueError("max_attempts must be None or an integer >= 1") + return _Scope(values) + + +def fast_mode() -> _Scope: + """Make every wait instant while keeping retries running. + + Sets `no_sleep=True` only: `retry` still retries the configured number of times and + rate limiters still count, but nothing actually sleeps. A blocking rate limiter + (`block=True`) that would have to wait lets the call through at once and records it, + so a later non-blocking call still sees the limit; under `fake_clock()` it waits on + the fake clock instead. Limits on how long to wait — `timeout`, `Bulkhead(timeout=)`, + `race(timeout=)`, `hedge` delays, `batch_calls` and `debounce` windows — run on real + time and are not shortened. Combine with `disable_retries()` if you also want a + single attempt. Context manager and decorator. + + Example: + ```python + import time + from python_wrap import retry + from python_wrap.testing import fast_mode + + calls = [] + + @retry(max_attempts=3, delay=60) + def flaky() -> str: + calls.append(1) + if len(calls) < 3: + raise ConnectionError + return "ok" + + start = time.monotonic() + with fast_mode(): + assert flaky() == "ok" + assert time.monotonic() - start < 5 + ``` + """ + return _Scope({"no_sleep": True}) + + +def disable_retries() -> _Scope: + """Cap `retry` at one attempt (`max_attempts=1`). Context manager and decorator.""" + return _Scope({"max_attempts": 1}) + + +def disable_rate_limits() -> _Scope: + """Let every call through rate limiters and throttles. Context manager and decorator.""" + return _Scope({"disable_rate_limits": True}) + + +def disable_caching() -> _Scope: + """Make memoize/cache always call the wrapped function. Context manager and decorator.""" + return _Scope({"disable_caching": True}) + + +# ------------------------------------------------------------------------------ clock + + +@dataclass +class FakeClock: + """A controllable clock. Sleeping advances it instead of waiting. + + Attributes: + start: The initial reading. Defaults to the real `time.monotonic()` at creation, + rounded up to a whole second, so entries stamped before the fake clock was + installed (a cache filled at import time, say) are in the fake clock's past and + still expire as expected. The rounding keeps arithmetic exact: with an + arbitrary float start, ``(start + 10) - start`` can come out as ``9.999...`` + and a 10 second threshold would not be reached. Pass `start=` for a fixed + reading. + now: The current reading (starts at `start`). + sleeps: Every duration passed to `sleep` / `async_sleep`, in order. + + Example: + ```python + from python_wrap.testing import FakeClock + + clock = FakeClock(start=10.0) + clock.advance(5) + clock.sleep(2) + assert clock.now == 17.0 + assert clock.sleeps == [2] + ``` + """ + + start: float = field(default_factory=lambda: float(math.ceil(time.monotonic()))) + now: float = field(init=False) + sleeps: list[float] = field(default_factory=list) + _lock: threading.Lock = field( + default_factory=threading.Lock, init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + self.now = self.start + + @property + def total_slept(self) -> float: + """Sum of all recorded sleeps.""" + return sum(self.sleeps) + + def time(self) -> float: + """The current reading (usable as `monotonic` / `perf_counter`).""" + return self.now + + def advance(self, seconds: float) -> None: + """Move the clock forward without recording a sleep. + + Raises: + ValueError: If `seconds` is negative. + """ + if seconds < 0: + raise ValueError("cannot advance a clock backwards") + with self._lock: + self.now += seconds + + def sleep(self, seconds: float) -> None: + """Record a sleep and advance the clock by it (negative values advance nothing).""" + with self._lock: + self.sleeps.append(seconds) + self.now += max(seconds, 0.0) + + async def async_sleep(self, seconds: float) -> None: + """Like `sleep`, then yield to the event loop so other tasks can run.""" + self.sleep(seconds) + await asyncio.sleep(0) + + +@contextmanager +def fake_clock(clock: FakeClock | None = None) -> Iterator[FakeClock]: + """Patch `_core.monotonic/perf_counter/sleep/async_sleep` with a `FakeClock`. + + The previous time sources are restored on exit, so it nests. + + Args: + clock: An existing clock to install; a fresh `FakeClock()` by default. + + Yields: + The installed clock. + + Example: + ```python + from python_wrap import retry + from python_wrap.testing import fake_clock + + calls = [] + + @retry(max_attempts=3, delay=1.0, backoff=2, on=ValueError) + def flaky() -> str: + calls.append(1) + if len(calls) < 3: + raise ValueError + return "ok" + + with fake_clock() as clock: + assert flaky() == "ok" + assert clock.sleeps == [1.0, 2.0] + ``` + """ + clock = clock if clock is not None else FakeClock() + names = ("monotonic", "perf_counter", "sleep", "async_sleep") + with _core.overrides_lock: + saved = {name: getattr(_core, name) for name in names} + _core.monotonic = clock.time + _core.perf_counter = clock.time + _core.sleep = clock.sleep + _core.async_sleep = clock.async_sleep + try: + yield clock + finally: + with _core.overrides_lock: + for name, value in saved.items(): + setattr(_core, name, value) + + +# ------------------------------------------------------------------------------- spy + + +@dataclass(frozen=True) +class Call: + """One recorded call: arguments, outcome and duration (via `_core.perf_counter`).""" + + args: tuple[Any, ...] + kwargs: dict[str, Any] + result: Any = None + exception: BaseException | None = None + duration: float = 0.0 + + +class Spy(Generic[P, R]): + """A recording wrapper around a function. Create with `spy`. + + Works as a plain callable, as a decorator and on methods (bound access records + `self` as the first argument). For `async def` functions the spy is awaitable. + + Attributes: + calls: Recorded `Call` objects, oldest first. + """ + + def __init__(self, func: Callable[P, R]) -> None: + self._func = func + self.calls: list[Call] = [] + self._lock = threading.Lock() + functools.update_wrapper(self, func) + + @property + def call_count(self) -> int: + """Number of recorded calls.""" + return len(self.calls) + + @property + def called(self) -> bool: + """True once the spy has been called.""" + return bool(self.calls) + + def reset(self) -> None: + """Forget recorded calls.""" + with self._lock: + self.calls.clear() + + def assert_called_with(self, *args: Any, **kwargs: Any) -> None: + """Assert the most recent call used exactly these arguments. + + Raises: + AssertionError: If never called or the last call differs. + """ + if not self.calls: + raise AssertionError("expected a call, but the spy was never called") + last = self.calls[-1] + if last.args != args or last.kwargs != kwargs: + raise AssertionError( + f"expected last call with args={args!r} kwargs={kwargs!r}, " + f"got args={last.args!r} kwargs={last.kwargs!r}" + ) + + def _record(self, call: Call) -> None: + with self._lock: + self.calls.append(call) + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: + start = _core.perf_counter() + try: + result = self._func(*args, **kwargs) + except BaseException as exc: + self._record(Call(args, dict(kwargs), None, exc, _core.perf_counter() - start)) + raise + self._record(Call(args, dict(kwargs), result, None, _core.perf_counter() - start)) + return result + + def __get__(self, obj: object, owner: type | None = None) -> Any: + if obj is None: + return self + return types.MethodType(self, obj) + + +class _AsyncSpy(Spy[P, R]): + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # type: ignore[override] + start = _core.perf_counter() + try: + result: R = await self._func(*args, **kwargs) # type: ignore[misc] + except BaseException as exc: + self._record(Call(args, dict(kwargs), None, exc, _core.perf_counter() - start)) + raise + self._record(Call(args, dict(kwargs), result, None, _core.perf_counter() - start)) + return result + + +@overload +def spy(func: Callable[P, Awaitable[R]]) -> Spy[P, Awaitable[R]]: ... +@overload +def spy(func: Callable[P, R]) -> Spy[P, R]: ... +def spy(func: Callable[P, Any]) -> Spy[P, Any]: + """Wrap `func` so every call is recorded, without changing its behaviour. + + Sync and async functions are supported (an `async def` spy records the awaited + result when the coroutine finishes). Exceptions are recorded and re-raised. + + Args: + func: The function to observe. + + Returns: + A `Spy` with `.calls`, `.call_count`, `.called`, `.reset()` and + `.assert_called_with(...)`. + + Example: + ```python + from python_wrap.testing import spy + + @spy + def add(a: int, b: int) -> int: + return a + b + + assert add(1, b=2) == 3 + assert add.call_count == 1 + add.assert_called_with(1, b=2) + assert add.calls[0].result == 3 + ``` + """ + if _core.is_async(func): + return _AsyncSpy(func) + return Spy(func) + + +@contextmanager +def spy_on(obj: object, name: str) -> Iterator[Spy[..., Any]]: + """Replace `obj.name` with a spy for the duration of the block, then restore it. + + Works on modules, classes (including static and class methods) and instances. + + Args: + obj: The object holding the attribute. + name: The attribute (function or method) name. + + Yields: + The `Spy` now installed. + + Raises: + AttributeError: If `obj` has no attribute `name`. + + Example: + ```python + import math + from python_wrap.testing import spy_on + + with spy_on(math, "floor") as s: + assert math.floor(1.5) == 1 + assert s.call_count == 1 + ``` + """ + raw = inspect.getattr_static(obj, name) + had_own = name in getattr(obj, "__dict__", {}) + s = spy(getattr(obj, name)) + plain = isinstance(raw, (staticmethod, classmethod)) + setattr(obj, name, staticmethod(s) if plain else s) + try: + yield s + finally: + if had_own: + setattr(obj, name, raw) + else: + delattr(obj, name) + + +# ------------------------------------------------------------------------- eventually + + +def _timeout_error(last: AssertionError, waited: float, attempts: int) -> AssertionError: + return AssertionError( + f"{last}\n(eventually: still failing after {waited:.3f}s and {attempts} attempt(s))" + ) + + +# The unpatched sleeps, so polling can tell "fast_mode without a fake clock" apart. +_REAL_SLEEP = _core.sleep +_REAL_ASYNC_SLEEP = _core.async_sleep + + +def _poll_sleep(interval: float) -> None: + if _core.overrides.no_sleep and _core.sleep is _REAL_SLEEP: + time.sleep(interval) # fast_mode must not turn polling into a busy loop + else: + _core.sleep(interval) + + +async def _poll_async_sleep(interval: float) -> None: + if _core.overrides.no_sleep and _core.async_sleep is _REAL_ASYNC_SLEEP: + await asyncio.sleep(interval) + else: + await _core.async_sleep(interval) + + +async def eventually( + assertion: Callable[[], object | Awaitable[object]], + *, + timeout: float = 1.0, + interval: float = 0.01, +) -> None: + """Retry `assertion` until it stops raising `AssertionError`, or time runs out. + + `assertion` may be sync or `async def`. Time is read through `_core.monotonic` and + waited through `_core.async_sleep`, so it is real time by default (it is meant for + integration tests) and follows `fake_clock()` if one is installed. Under + `fast_mode()` (and no fake clock) it still pauses `interval` of real time between + attempts, because the condition it waits for usually needs real time to pass. + + Args: + assertion: Callable that raises `AssertionError` while the condition is false. + timeout: Seconds to keep trying. + interval: Seconds between attempts. + + Raises: + AssertionError: The last failure, annotated with how long it waited. Other + exceptions propagate immediately. + + Example: + ```python + import asyncio + from python_wrap.testing import eventually + + state = {"ready": False} + + def check() -> None: + assert state["ready"] + + async def main() -> None: + asyncio.get_running_loop().call_later(0.02, state.update, {"ready": True}) + await eventually(check) + + asyncio.run(main()) + ``` + """ + start = _core.monotonic() + attempts = 0 + while True: + attempts += 1 + try: + outcome = assertion() + if inspect.isawaitable(outcome): + await outcome + return + except AssertionError as exc: + waited = _core.monotonic() - start + if waited >= timeout: + raise _timeout_error(exc, waited, attempts) from exc + await _poll_async_sleep(interval) + + +def eventually_sync( + assertion: Callable[[], object], + *, + timeout: float = 1.0, + interval: float = 0.01, +) -> None: + """Blocking version of `eventually` for plain sync tests. + + Args: + assertion: Callable that raises `AssertionError` while the condition is false. + timeout: Seconds to keep trying. + interval: Seconds between attempts. + + Raises: + AssertionError: The last failure, annotated with how long it waited. + + Example: + ```python + import threading + from python_wrap.testing import eventually_sync + + done = threading.Event() + threading.Timer(0.02, done.set).start() + + def check() -> None: + assert done.is_set() + + eventually_sync(check) + ``` + """ + start = _core.monotonic() + attempts = 0 + while True: + attempts += 1 + try: + assertion() + return + except AssertionError as exc: + waited = _core.monotonic() - start + if waited >= timeout: + raise _timeout_error(exc, waited, attempts) from exc + _poll_sleep(interval) + + +# ------------------------------------------------------------------------ pytest plugin + +if _pytest is not None: + + @_pytest.fixture + def pw_fake_clock() -> Iterator[FakeClock]: + """Install a `FakeClock` for the test and yield it.""" + with fake_clock() as clock: + yield clock + + @_pytest.fixture + def pw_fast_mode() -> Iterator[None]: + """Run the test under `fast_mode()`.""" + with fast_mode(): + yield diff --git a/src/python_wrap/timing.py b/src/python_wrap/timing.py new file mode 100644 index 0000000..1e1a259 --- /dev/null +++ b/src/python_wrap/timing.py @@ -0,0 +1,535 @@ +"""Performance decorators: timing, benchmarking, memory measurement and profiling.""" + +from __future__ import annotations + +import io +import logging +import statistics +import sys +import threading +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, overload + +from python_wrap import _core +from python_wrap._core import F, LoggerLike, P, R +from python_wrap.logs import _instrument, _log + +if TYPE_CHECKING: + import cProfile + import pstats + +# cProfile, pstats and tracemalloc are imported on first use: they are only needed +# by profile and measure_memory, and importing them eagerly slows down every import. + +__all__ = [ + "BenchmarkResult", + "MemoryRecord", + "TimingRecord", + "benchmark", + "benchmark_async", + "measure_memory", + "profile", + "timing", +] + + +# --------------------------------------------------------------------------- helpers + + +def _format_duration(seconds: float) -> str: + """Pick readable units: ``850.00 us``, ``12.34 ms``, ``1.50 s``, ``2 min 03.0 s``.""" + if seconds < 1e-3: + return f"{seconds * 1e6:.2f} us" + if seconds < 1: + return f"{seconds * 1e3:.2f} ms" + if seconds < 60: + return f"{seconds:.2f} s" + minutes, rest = divmod(seconds, 60) + return f"{int(minutes)} min {rest:04.1f} s" + + +def _format_bytes(count: int, *, signed: bool = False) -> str: + """Human-readable byte count (binary units).""" + sign = "-" if count < 0 else ("+" if signed else "") + value = float(abs(count)) + for unit in ("B", "KiB", "MiB", "GiB"): + if value < 1024 or unit == "GiB": + text = f"{value:.0f} {unit}" if unit == "B" else f"{value:.2f} {unit}" + return sign + text + value /= 1024 + raise AssertionError("unreachable") # pragma: no cover + + +def _safe_callback( + lg: logging.Logger, callback: Callable[[Any], None] | None, record: Any, name: str +) -> None: + """Call a user callback; a failing metrics hook must not break the call.""" + if callback is None: + return + try: + callback(record) + except Exception: + lg.exception("callback for %s failed", name) + + +# ---------------------------------------------------------------------------- timing + + +@dataclass(frozen=True) +class TimingRecord: + """Result of one timed call, passed to ``timing(callback=...)``. + + Attributes: + name: Qualified name of the function. + elapsed: Seconds the call took. + success: ``False`` if the call raised. + """ + + name: str + elapsed: float + success: bool + + +@overload +def timing(func: F, /) -> F: ... +@overload +def timing( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + threshold: float | None = None, + callback: Callable[[TimingRecord], None] | None = None, +) -> Callable[[F], F]: ... +def timing( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + threshold: float | None = None, + callback: Callable[[TimingRecord], None] | None = None, +) -> Any: + """Measure and log how long each call takes. + + Logs `` took 12.34 ms``. If the call raises, logs + `` raised ValueError after 12.34 ms`` and re-raises. Async functions are timed + across the whole awaited coroutine (v0.1 measured about 0 s for them). + + Args: + func: The function, when used as a bare ``@timing``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level. + threshold: If set, successful calls are only logged when slower than this many + seconds. Failed calls are always logged. + callback: Called with a `TimingRecord` for *every* call, regardless of + ``threshold``; use it to feed a metrics system. Errors in the callback are + logged and swallowed. + + Returns: + The decorated function, with its signature preserved. + + Example: + >>> @timing(threshold=0.5, callback=lambda r: print(r.name, r.elapsed)) + ... def slow(): ... + """ + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + + def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> float: + return _core.perf_counter() + + def finish(began: float, ok: bool, value: Any) -> None: + elapsed = _core.perf_counter() - began + if lg.isEnabledFor(level): + if not ok: + _log( + lg, + level, + "%s raised %s after %s", + name, + type(value).__name__, + _format_duration(elapsed), + ) + elif threshold is None or elapsed > threshold: + _log(lg, level, "%s took %s", name, _format_duration(elapsed)) + _safe_callback(lg, callback, TimingRecord(name, elapsed, ok), name) + + return _instrument(f, start, finish) + + return decorate if func is None else decorate(func) + + +# ------------------------------------------------------------------------- benchmark + + +@dataclass(frozen=True) +class BenchmarkResult: + """Statistics from `benchmark` or `benchmark_async`; all times are in seconds. + + Warm-up calls are not included. ``str(result)`` gives a one-line summary. + + Attributes: + name: Qualified name of the benchmarked function. + runs: Number of measured calls. + timings: Duration of each measured call, in call order. + mean: Arithmetic mean of ``timings``. + median: Median of ``timings``. + min: Fastest call. + max: Slowest call. + stdev: Sample standard deviation of ``timings`` (``0.0`` when ``runs`` is 1). + """ + + name: str + runs: int + timings: tuple[float, ...] + mean: float + median: float + min: float + max: float + stdev: float + + def __str__(self) -> str: + return ( + f"{self.name}: {self.runs} runs, " + f"mean {_format_duration(self.mean)}, median {_format_duration(self.median)}, " + f"min {_format_duration(self.min)}, max {_format_duration(self.max)}, " + f"stdev {_format_duration(self.stdev)}" + ) + + +def _summarise(name: str, timings: list[float]) -> BenchmarkResult: + return BenchmarkResult( + name=name, + runs=len(timings), + timings=tuple(timings), + mean=statistics.fmean(timings), + median=statistics.median(timings), + min=min(timings), + max=max(timings), + stdev=statistics.stdev(timings) if len(timings) > 1 else 0.0, + ) + + +def _check_bench_args(runs: int, warmup: int) -> None: + if runs < 1: + raise ValueError(f"runs must be >= 1, got {runs}") + if warmup < 0: + raise ValueError(f"warmup must be >= 0, got {warmup}") + + +def benchmark( + func: Callable[..., Any], + /, + *, + args: tuple[Any, ...] = (), + kwargs: Mapping[str, Any] | None = None, + runs: int = 5, + warmup: int = 1, +) -> BenchmarkResult: + """Run ``func`` repeatedly and return timing statistics. + + BREAKING vs v0.1: this is a plain function, not a decorator. (The v0.1 decorator + silently ran the function three times on every call.) + + Args: + func: The synchronous callable to measure. + args: Positional arguments for each call. + kwargs: Keyword arguments for each call. + runs: Number of measured runs (>= 1). + warmup: Unmeasured runs executed first (>= 0). + + Returns: + A `BenchmarkResult`. + + Raises: + TypeError: If ``func`` is an ``async def`` function; use `benchmark_async`. + ValueError: If ``runs`` or ``warmup`` is out of range. + + Example: + >>> result = benchmark(sorted, args=([3, 1, 2],), runs=10) + >>> print(result) # doctest: +SKIP + """ + if _core.is_async(func): + raise TypeError( + f"{_core.qualname(func)} is a coroutine function; use benchmark_async() instead" + ) + _check_bench_args(runs, warmup) + kw = dict(kwargs or {}) + for _ in range(warmup): + func(*args, **kw) + timings: list[float] = [] + for _ in range(runs): + began = _core.perf_counter() + func(*args, **kw) + timings.append(_core.perf_counter() - began) + return _summarise(_core.qualname(func), timings) + + +async def benchmark_async( + func: Callable[..., Any], + /, + *, + args: tuple[Any, ...] = (), + kwargs: Mapping[str, Any] | None = None, + runs: int = 5, + warmup: int = 1, +) -> BenchmarkResult: + """Like `benchmark`, for ``async def`` functions (each run is awaited). + + Args: + func: The coroutine function to measure. + args: Positional arguments for each call. + kwargs: Keyword arguments for each call. + runs: Number of measured runs (>= 1). + warmup: Unmeasured runs executed first (>= 0). + + Returns: + A `BenchmarkResult`. + + Raises: + TypeError: If ``func`` is not an ``async def`` function; use `benchmark`. + ValueError: If ``runs`` or ``warmup`` is out of range. + + Example: + >>> result = await benchmark_async(fetch, args=(url,), runs=3) # doctest: +SKIP + """ + if not _core.is_async(func): + raise TypeError(f"{_core.qualname(func)} is not a coroutine function; use benchmark()") + _check_bench_args(runs, warmup) + kw = dict(kwargs or {}) + for _ in range(warmup): + await func(*args, **kw) + timings: list[float] = [] + for _ in range(runs): + began = _core.perf_counter() + await func(*args, **kw) + timings.append(_core.perf_counter() - began) + return _summarise(_core.qualname(func), timings) + + +# --------------------------------------------------------------------- measure_memory + + +@dataclass(frozen=True) +class MemoryRecord: + """Memory used by one call, passed to ``measure_memory(callback=...)``. + + Attributes: + name: Qualified name of the function. + peak_bytes: Highest traced allocation during the call, relative to its start. + net_bytes: Traced memory still held at the end minus at the start (can be negative). + """ + + name: str + peak_bytes: int + net_bytes: int + + +_trace_lock = threading.Lock() +_trace_users = 0 +_trace_started_by_us = False + + +def _trace_acquire() -> None: + global _trace_users, _trace_started_by_us + import tracemalloc + + with _trace_lock: + if _trace_users == 0 and not tracemalloc.is_tracing(): + tracemalloc.start() + _trace_started_by_us = True + _trace_users += 1 + + +def _trace_release() -> None: + global _trace_users, _trace_started_by_us + import tracemalloc + + with _trace_lock: + _trace_users -= 1 + if _trace_users == 0 and _trace_started_by_us: + tracemalloc.stop() + _trace_started_by_us = False + + +@overload +def measure_memory(func: F, /) -> F: ... +@overload +def measure_memory( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + callback: Callable[[MemoryRecord], None] | None = None, +) -> Callable[[F], F]: ... +def measure_memory( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + callback: Callable[[MemoryRecord], None] | None = None, +) -> Any: + """Measure the Python memory a call allocates, using `tracemalloc`. + + Works on every OS (v0.1 used ``resource.ru_maxrss``, which is Unix-only and reports + process-wide peak in inconsistent units). Tracing is started if it is not already + running and stopped afterwards only if this decorator started it. Logs + `` peak memory 1.00 MiB (net +12.00 KiB)``. + + Note: + ``tracemalloc`` is process-global: allocations made by other threads or asyncio + tasks while the call runs are counted too, and the peak counter is reset at the + start of every measured call. Only memory allocated by Python (not by C + extensions that bypass its allocator) is seen, and tracing slows the program. + + Args: + func: The function, when used as a bare ``@measure_memory``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level. + callback: Called with a `MemoryRecord` for every call. + + Returns: + The decorated function, with its signature preserved. + + Example: + >>> @measure_memory + ... def build(): + ... return [0] * 1_000_000 + """ + + import tracemalloc + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + + def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> int: + _trace_acquire() + tracemalloc.reset_peak() + return tracemalloc.get_traced_memory()[0] + + def finish(baseline: int, ok: bool, value: Any) -> None: + current, peak = tracemalloc.get_traced_memory() + _trace_release() + record = MemoryRecord(name, max(peak - baseline, 0), current - baseline) + lg.log( + level, + "%s peak memory %s (net %s)", + name, + _format_bytes(record.peak_bytes), + _format_bytes(record.net_bytes, signed=True), + ) + _safe_callback(lg, callback, record, name) + + return _instrument(f, start, finish) + + return decorate if func is None else decorate(func) + + +# ------------------------------------------------------------------------- profile + + +def _another_profiler_active() -> bool: + # Before 3.12 enabling a second cProfile silently replaces the first one. + return sys.version_info < (3, 12) and sys.getprofile() is not None + + +@overload +def profile(func: F, /) -> F: ... +@overload +def profile( + func: None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + sort: str = "cumulative", + limit: int | None = 20, + callback: Callable[[pstats.Stats], None] | None = None, +) -> Callable[[F], F]: ... +def profile( + func: Callable[..., Any] | None = None, + /, + *, + logger: LoggerLike = None, + level: int = logging.INFO, + sort: str = "cumulative", + limit: int | None = 20, + callback: Callable[[pstats.Stats], None] | None = None, +) -> Any: + """Profile each call with `cProfile` and log the top rows. + + If another profiler is already active (Python 3.12+ raises ``ValueError`` when + profilers nest), a warning is logged once and the function simply runs unprofiled. + + Note: + For ``async def`` functions the profiler is enabled for the whole awaited + coroutine, so work the event loop does for *other* tasks while this one is + suspended is included in the results. + + Args: + func: The function, when used as a bare ``@profile``. + logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger. + level: Log level. + sort: A `pstats.SortKey` value such as ``"cumulative"`` or ``"tottime"``. + limit: Number of rows to log; ``None`` logs all. + callback: Called with the `pstats.Stats` of every profiled call. + + Returns: + The decorated function, with its signature preserved. + + Raises: + ValueError: If ``sort`` is not a valid `pstats.SortKey`. + + Example: + >>> @profile(sort="tottime", limit=10) + ... def work(): ... + """ + import cProfile + import pstats + + sort_key = pstats.SortKey(sort) # validate eagerly + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + lg = _core.get_logger(logger) + name = _core.qualname(f) + warned = False + + def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> cProfile.Profile | None: + nonlocal warned + profiler = cProfile.Profile() + try: + if _another_profiler_active(): + raise ValueError("another profiler is active") + profiler.enable() + except ValueError: + if not warned: + warned = True + lg.warning("Another profiler is active; running %s unprofiled", name) + return None + return profiler + + def finish(profiler: cProfile.Profile | None, ok: bool, value: Any) -> None: + if profiler is None: + return + profiler.disable() + stream = io.StringIO() + stats = pstats.Stats(profiler, stream=stream).sort_stats(sort_key) + if lg.isEnabledFor(level): + if limit is None: + stats.print_stats() + else: + stats.print_stats(limit) + lg.log(level, "Profile of %s:\n%s", name, stream.getvalue().rstrip()) + _safe_callback(lg, callback, stats, name) + + return _instrument(f, start, finish) + + return decorate if func is None else decorate(func) diff --git a/src/python_wrap/validation.py b/src/python_wrap/validation.py new file mode 100644 index 0000000..e887fee --- /dev/null +++ b/src/python_wrap/validation.py @@ -0,0 +1,662 @@ +"""Argument validation and runtime type checking. + +* `validate_args` - run a predicate per argument (``x=lambda x: x > 0``). +* `check_type` - ``isinstance`` check per argument (``x=int``). +* `enforce_types` - check arguments and the return value against the function's own + annotations. + +All three work on plain functions, ``async def`` functions and methods, can be stacked, and +keep the wrapped function's signature. + +**Annotations supported by `enforce_types`.** + +Checked: plain classes (``int`` also accepts ``bool``; ``float`` accepts ``int`` and +``complex`` accepts ``int``/``float``, following PEP 484), ``Any``, ``None``, +``Optional`` / ``Union`` / ``X | Y``, ``Literal``, ``list[T]``, ``set[T]``, ``frozenset[T]``, +``tuple[T, ...]``, ``tuple[A, B]``, ``dict[K, V]`` (elements are checked), +``collections.abc`` ABCs such as ``Sequence[T]``, ``MutableSequence[T]``, ``Set[T]``, +``Mapping[K, V]`` (elements checked) and ``Iterable[T]`` / ``Iterator[T]`` / ``Generator`` +(``isinstance`` only, iterators are never consumed), ``Callable`` (``callable()`` only), +``type[T]``, ``TypeVar`` (bound or constraints, otherwise anything), ``Annotated[T, ...]`` +(checks ``T``), ``NewType`` (checks the supertype) and ``runtime_checkable`` protocols. + +Accepted without checking: ``TypedDict``, protocols that are not ``runtime_checkable``, +forward references that cannot be resolved and any other construct this module does not +understand. A check that cannot be performed never raises. +""" + +from __future__ import annotations + +import abc +import collections.abc as cabc +import functools +import inspect +import reprlib +import types +import typing +from collections.abc import Callable +from typing import Any, TypeVar, Union, cast, overload + +from python_wrap import _core, errors +from python_wrap._core import F, P, R + +__all__ = ["check_type", "enforce_types", "validate_args"] + +Validator = Callable[[Any], object] | tuple[Callable[[Any], object], str] + +_NoneType = type(None) +_UnionType = getattr(types, "UnionType", None) + + +def _repr(value: object) -> str: + return reprlib.repr(value) + + +def _wrap( + func: Callable[P, R], + before: Callable[[tuple[Any, ...], dict[str, Any]], None], + after: Callable[[Any], None] | None = None, +) -> Callable[P, R]: + """Build a sync or async wrapper that runs ``before`` (and optionally ``after``).""" + if _core.is_async(func): + afunc: Any = func + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + before(args, kwargs) + result = await afunc(*args, **kwargs) + if after is not None: + after(result) + return result + + return async_wrapper # type: ignore[return-value] + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + before(args, kwargs) + result = func(*args, **kwargs) + if after is not None: + after(result) + return result + + return wrapper + + +_MISSING: Any = object() + + +def _plain_layout(sig: inspect.Signature) -> tuple[tuple[str, ...], int, tuple[Any, ...]] | None: + """``(names, required count, defaults)`` if every parameter is a plain positional one. + + Calls that pass only positional arguments to such a function (``min <= len(args) <= + len(names)``) can be checked without ``Signature.bind``; ``defaults[i]`` is the default + of parameter ``i`` (``_MISSING`` when it has none). Returns ``None`` when the + signature has keyword-only or variadic parameters. + """ + params = list(sig.parameters.values()) + plain = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + if any(p.kind not in plain for p in params): + return None + defaults = tuple( + _MISSING if p.default is inspect.Parameter.empty else p.default for p in params + ) + required = sum(1 for d in defaults if d is _MISSING) + return tuple(p.name for p in params), required, defaults + + +def _check_names( + func: Callable[..., Any], sig: inspect.Signature, names: typing.Iterable[str], who: str +) -> None: + unknown = [n for n in names if n not in sig.parameters] + if unknown: + raise TypeError( + f"{who}() got unknown parameter name(s) {', '.join(map(repr, unknown))} " + f"for {_core.qualname(func)}{sig}" + ) + + +# -------------------------------------------------------------------------------------- +# validate_args +# -------------------------------------------------------------------------------------- + + +def validate_args(**validators: Validator) -> Callable[[F], F]: + """Validate arguments with predicates. + + Each keyword names a parameter and gives a callable that receives the argument value + (defaults are applied first, so default values are validated too). A falsy result raises + `ValidationError`, which is a ``ValueError``. A validator may + instead raise its own exception, which propagates unchanged. Pass a + ``(callable, "message")`` tuple for a custom error message. Validators for ``*args`` / + ``**kwargs`` parameters receive the whole tuple / dict. + + Args: + **validators: ``parameter_name=predicate`` or ``parameter_name=(predicate, message)``. + + Returns: + A decorator that preserves the function's signature. + + Raises: + TypeError: At decoration time, if a name is not a parameter of the function or a + validator is not callable. + python_wrap.errors.ValidationError: At call time, when a validator returns falsy. + + Example: + >>> @validate_args(x=lambda x: x > 0, name=(lambda s: s != "", "name must not be empty")) + ... def greet(x, name): + ... return name * x + >>> greet(2, "ab") + 'abab' + """ + normalised: dict[str, tuple[Callable[[Any], object], str | None]] = {} + for name, spec in validators.items(): + if isinstance(spec, tuple): + if len(spec) != 2 or not callable(spec[0]) or not isinstance(spec[1], str): + raise TypeError(f"validator for {name!r} must be a callable or (callable, str)") + normalised[name] = (spec[0], spec[1]) + elif callable(spec): + normalised[name] = (spec, None) + else: + raise TypeError(f"validator for {name!r} must be callable, got {type(spec).__name__}") + + def decorate(func: Callable[P, R]) -> Callable[P, R]: + sig = inspect.signature(func) + _check_names(func, sig, normalised, "validate_args") + + layout = _plain_layout(sig) + n_params = -1 + min_args = 0 + defaults: tuple[Any, ...] = () + plan: list[tuple[int, str, Callable[[Any], object], str | None]] = [] + if layout is not None: + names, min_args, defaults = layout + n_params = len(names) + plan = [(names.index(k), k, pred, msg) for k, (pred, msg) in normalised.items()] + + def fail(name: str, message: str | None, value: Any) -> typing.NoReturn: + raise errors.ValidationError( + message or f"parameter {name!r} failed validation (got {_repr(value)})", + parameter=name, + value=value, + ) + + def before(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + nargs = len(args) + if not kwargs and min_args <= nargs <= n_params: + for idx, name, predicate, message in plan: + value = args[idx] if idx < nargs else defaults[idx] + if not predicate(value): + fail(name, message, value) + return + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + for name, (predicate, message) in normalised.items(): + value = bound.arguments[name] + if not predicate(value): + fail(name, message, value) + + return _wrap(func, before) + + return cast("Callable[[F], F]", decorate) + + +# -------------------------------------------------------------------------------------- +# check_type +# -------------------------------------------------------------------------------------- + + +def _type_name(tp: object) -> str: + if isinstance(tp, tuple): + return " | ".join(_type_name(t) for t in tp) + if isinstance(tp, type): + return tp.__name__ + return str(tp).replace("typing.", "") + + +def check_type(**type_hints: type | tuple[type, ...]) -> Callable[[F], F]: + """Check argument types with ``isinstance``. + + Each keyword names a parameter and gives a class (or a tuple of classes). Defaults are + not checked, only the arguments that are actually passed. For ``*args`` / ``**kwargs`` + parameters the type applies to every element / value. + + Args: + **type_hints: ``parameter_name=type`` (anything ``isinstance`` accepts). + + Returns: + A decorator that preserves the function's signature. + + Raises: + TypeError: At decoration time, if a name is not a parameter of the function. + python_wrap.errors.ArgumentTypeError: At call time, on a mismatch (a ``TypeError``). + + Example: + >>> @check_type(x=int, y=str) + ... def f(x, y): + ... return y * x + >>> f(2, "a") + 'aa' + """ + + def decorate(func: Callable[P, R]) -> Callable[P, R]: + sig = inspect.signature(func) + _check_names(func, sig, type_hints, "check_type") + + def check(label: str, value: Any, expected: Any) -> None: + if not isinstance(value, expected): + raise errors.ArgumentTypeError( + f"parameter {label!r} expected {_type_name(expected)}, " + f"got {type(value).__name__}", + parameter=label, + expected=expected, + value=value, + ) + + layout = _plain_layout(sig) + n_params = -1 + min_args = 0 + plan: list[tuple[int, str, Any]] = [] + if layout is not None: + names, min_args, _ = layout + n_params = len(names) + plan = [(names.index(k), k, expected) for k, expected in type_hints.items()] + + def before(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + nargs = len(args) + if not kwargs and min_args <= nargs <= n_params: + for idx, name, expected in plan: + if idx < nargs: + value = args[idx] + if not isinstance(value, expected): + check(name, value, expected) + return + bound = sig.bind(*args, **kwargs) + for name, expected in type_hints.items(): + if name not in bound.arguments: + continue + value = bound.arguments[name] + kind = sig.parameters[name].kind + if kind is inspect.Parameter.VAR_POSITIONAL: + for i, item in enumerate(value): + check(f"{name}[{i}]", item, expected) + elif kind is inspect.Parameter.VAR_KEYWORD: + for key, item in value.items(): + check(f"{name}[{key!r}]", item, expected) + else: + check(name, value, expected) + + return _wrap(func, before) + + return cast("Callable[[F], F]", decorate) + + +# -------------------------------------------------------------------------------------- +# enforce_types +# -------------------------------------------------------------------------------------- + +_ELEMENT_CHECKED_ABCS: tuple[type, ...] = ( + cabc.Sequence, + cabc.MutableSequence, + cabc.Set, + cabc.MutableSet, +) +_MAPPING_ABCS: tuple[type, ...] = (cabc.Mapping, cabc.MutableMapping) + + +def _fmt(hint: object) -> str: + """Readable name for an annotation, e.g. ``list[int]`` or ``int | None``.""" + if hint is None or hint is _NoneType: + return "None" + if isinstance(hint, type) and typing.get_origin(hint) is None: + return hint.__name__ + if isinstance(hint, typing.ForwardRef): + return hint.__forward_arg__ + text = repr(hint) + for prefix in ("typing.", "collections.abc."): + text = text.replace(prefix, "") + return text + + +def _got(value: object) -> str: + return type(value).__name__ + + +def _nested(outer: str, inner: str, where: str, element: object) -> str: + if " " not in inner: + return f"{outer} containing {inner} {where}" + return f"{outer} containing {_got(element)} {where} ({inner})" + + +def _isinstance(value: object, cls: Any) -> bool: + try: + return isinstance(value, cls) + except TypeError: + return True # cannot be checked at runtime, so accept + + +def _check_items(value: Any, hint_args: tuple[Any, ...]) -> str | None: + """Element checks for list/set/frozenset/Sequence/Set (``origin`` already matched).""" + if len(hint_args) != 1: + return None + (item_hint,) = hint_args + if item_hint is Any: + return None + is_seq = isinstance(value, cabc.Sequence) + for i, item in enumerate(value): + problem = _mismatch(item, item_hint) + if problem is not None: + where = f"at index {i}" if is_seq else "" + return _nested(_got(value), problem, where, item).rstrip() + return None + + +def _check_mapping(value: Any, hint_args: tuple[Any, ...]) -> str | None: + if len(hint_args) != 2: + return None + key_hint, value_hint = hint_args + for key, item in value.items(): + problem = _mismatch(key, key_hint) + if problem is not None: + return ( + f"{_got(value)} with key {problem}" + if " " not in problem + else (f"{_got(value)} with {_got(key)} key {_repr(key)} ({problem})") + ) + problem = _mismatch(item, value_hint) + if problem is not None: + where = f"at key {_repr(key)}" + if " " not in problem: + return f"{_got(value)} with {problem} value {where}" + return f"{_got(value)} with {_got(item)} value {where} ({problem})" + return None + + +def _check_tuple(value: Any, hint_args: tuple[Any, ...]) -> str | None: + if len(hint_args) == 2 and hint_args[1] is Ellipsis: + for i, item in enumerate(value): + problem = _mismatch(item, hint_args[0]) + if problem is not None: + return _nested("tuple", problem, f"at index {i}", item) + return None + if hint_args == ((),): + hint_args = () + if len(value) != len(hint_args): + return f"tuple of length {len(value)}" + for i, (item, item_hint) in enumerate(zip(value, hint_args, strict=True)): + problem = _mismatch(item, item_hint) + if problem is not None: + if " " not in problem: + return f"tuple with {problem} at index {i}" + return f"tuple with {_got(item)} at index {i} ({problem})" + return None + + +def _mismatch(value: Any, hint: Any) -> str | None: + """Return a description of what ``value`` is if it violates ``hint``, else ``None``.""" + if hint is Any or hint is object or isinstance(hint, (str, typing.ForwardRef)): + return None + if hint is None or hint is _NoneType: + return None if value is None else _got(value) + if isinstance(hint, TypeVar): + if hint.__bound__ is not None: + return _mismatch(value, hint.__bound__) + if hint.__constraints__: + if any(_mismatch(value, c) is None for c in hint.__constraints__): + return None + return _got(value) + return None + supertype = getattr(hint, "__supertype__", None) + if supertype is not None: # NewType + return _mismatch(value, supertype) + + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + if origin is typing.Annotated: + return _mismatch(value, args[0]) + if origin is Union or (_UnionType is not None and origin is _UnionType): + members = [a for a in args if a is not _NoneType] + if any(_mismatch(value, a) is None for a in args): + return None + if value is not None and len(members) == 1: + return _mismatch(value, members[0]) + return _got(value) + if origin is typing.Literal: + for lit in args: + if type(value) is type(lit) and value == lit: + return None + return _got(value) if not isinstance(value, (str, int, bytes, bool)) else _repr(value) + if origin is None: + if typing.is_typeddict(hint): + return None + if hint is typing.Callable or hint is cabc.Callable: + return None if callable(value) else _got(value) + if isinstance(hint, type): + if getattr(hint, "_is_protocol", False): + if not getattr(hint, "_is_runtime_protocol", False): + return None + return None if _isinstance(value, hint) else _got(value) + if hint is float and isinstance(value, int) and not isinstance(value, bool): + return None + if hint is float and isinstance(value, bool): + return None + if hint is complex and isinstance(value, (int, float)): + return None + return None if _isinstance(value, hint) else _got(value) + return None + + # Parameterised generics from here on. + if origin is cabc.Callable: + return None if callable(value) else _got(value) + if origin is type: + if not isinstance(value, type): + return _got(value) + if not args: + return None + target = args[0] + if target is Any or isinstance(target, TypeVar): + return None + is_union = typing.get_origin(target) in (Union, _UnionType) + targets = typing.get_args(target) if is_union else (target,) + for t in targets: + # On 3.10 ``isinstance(list[int], type)`` is True, so rule out generic aliases too. + if not isinstance(t, type) or typing.get_origin(t) is not None: + return None + if issubclass(value, t): + return None + return f"type {value.__name__}" + if not isinstance(origin, type): + return None + if getattr(origin, "_is_protocol", False): + return None + if not _isinstance(value, origin): + return _got(value) + + if origin in (list, set, frozenset) or origin in _ELEMENT_CHECKED_ABCS: + return _check_items(value, args) + if origin is dict or origin in _MAPPING_ABCS: + return _check_mapping(value, args) + if origin is tuple: + return _check_tuple(value, args) + return None # Iterable/Iterator/other generics: isinstance only + + +_QUICK_METAS: tuple[type, ...] = (type, abc.ABCMeta) + + +def _quick_class(hint: Any) -> Any: + """A ``isinstance`` classinfo that is sufficient (not necessary) for ``hint``, or ``None``. + + If ``isinstance(value, quick)`` holds, `_mismatch` would also accept the value, so the + full check (and its error message) is only needed when it does not. + """ + if hint is None or hint is _NoneType: + return _NoneType + if ( + type(hint) not in _QUICK_METAS + or typing.get_origin(hint) is not None + or getattr(hint, "_is_protocol", False) + or typing.is_typeddict(hint) + or hint is typing.Callable + or hint is cabc.Callable + ): + return None + if hint is float: + return (float, int) # bool is an int + if hint is complex: + return (complex, float, int) + return hint + + +def _skippable(hint: Any) -> bool: + return hint is Any or hint is object or isinstance(hint, (str, typing.ForwardRef)) + + +class _Plan: + """Per-function checks compiled from resolved annotations (see `_Checker`).""" + + __slots__ = ("checks", "hints", "min_args", "n_params", "ret_hint", "ret_quick") + + def __init__(self, hints: dict[str, Any], layout: tuple[tuple[str, ...], int] | None) -> None: + self.hints = hints + self.checks: tuple[tuple[int, str, Any, Any], ...] = () + self.n_params = -1 + self.min_args = 0 + if layout is not None: + names, self.min_args = layout + self.n_params = len(names) + self.checks = tuple( + (i, n, hints[n], _quick_class(hints[n])) + for i, n in enumerate(names) + if n in hints and not _skippable(hints[n]) + ) + self.ret_hint = hints.get("return", _MISSING) + self.ret_quick = None if self.ret_hint is _MISSING else _quick_class(self.ret_hint) + + +class _Checker: + """Lazily resolves annotations on first call and checks calls against them.""" + + def __init__(self, func: Callable[..., Any], check_return: bool) -> None: + self.func = func + self.check_return = check_return + self.sig = inspect.signature(func) + layout = _plain_layout(self.sig) + self.layout = None if layout is None else (layout[0], layout[1]) + self.hints: dict[str, Any] | None = None + self.plan: _Plan | None = None + + def _get_plan(self) -> _Plan: + plan = self.plan + if plan is not None: + return plan + try: + hints = typing.get_type_hints(self.func) + except Exception: + # Unresolvable forward reference (so far): fall back to raw annotations, where + # strings are accepted, and try again on the next call. + return _Plan(dict(getattr(self.func, "__annotations__", {})), self.layout) + self.hints = hints + self.plan = plan = _Plan(hints, self.layout) + return plan + + @staticmethod + def _fail(label: str, hint: Any, value: Any, problem: str, what: str) -> typing.NoReturn: + raise errors.ArgumentTypeError( + f"{what} {label!r} expected {_fmt(hint)}, got {problem}" + if what == "parameter" + else f"{what} expected {_fmt(hint)}, got {problem}", + parameter=label, + expected=hint, + value=value, + ) + + def _check_one(self, label: str, value: Any, hint: Any) -> None: + problem = _mismatch(value, hint) + if problem is not None: + self._fail(label, hint, value, problem, "parameter") + + def before(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + plan = self._get_plan() + nargs = len(args) + if not kwargs and plan.min_args <= nargs <= plan.n_params: + for idx, name, hint, quick in plan.checks: + if idx < nargs: + value = args[idx] + if quick is not None and isinstance(value, quick): + continue + problem = _mismatch(value, hint) + if problem is not None: + self._fail(name, hint, value, problem, "parameter") + return + hints = plan.hints + if not hints: + return + bound = self.sig.bind(*args, **kwargs) + for name, value in bound.arguments.items(): + if name not in hints: + continue + hint = hints[name] + kind = self.sig.parameters[name].kind + if kind is inspect.Parameter.VAR_POSITIONAL: + for i, item in enumerate(value): + self._check_one(f"{name}[{i}]", item, hint) + elif kind is inspect.Parameter.VAR_KEYWORD: + for key, item in value.items(): + self._check_one(f"{name}[{key!r}]", item, hint) + else: + self._check_one(name, value, hint) + + def after(self, result: Any) -> None: + plan = self._get_plan() + hint = plan.ret_hint + if hint is _MISSING: + return + quick = plan.ret_quick + if quick is not None and isinstance(result, quick): + return + problem = _mismatch(result, hint) + if problem is not None: + self._fail("return", hint, result, problem, "return value") + + +@overload +def enforce_types(func: F, /) -> F: ... +@overload +def enforce_types(func: None = None, /, *, check_return: bool = True) -> Callable[[F], F]: ... +def enforce_types(func: Callable[..., Any] | None = None, /, *, check_return: bool = True) -> Any: + """Check arguments and the return value against the function's own annotations. + + Annotations are resolved lazily on the first call with `typing.get_type_hints`, so + forward references work. Only arguments that are actually passed are checked (defaults + are not). For ``async def`` functions the awaited result is checked. The supported + subset of annotations is listed under "Annotations supported by `enforce_types`" in + the `python_wrap.validation` module docstring (the top of its API reference page) and + in the [validation guide](../guides/validation.md#supported-annotations); anything + unsupported is accepted rather than rejected. + + Args: + func: The function (when used as a bare ``@enforce_types``). + check_return: Also check the return value against the return annotation. + + Returns: + The wrapped function, or a decorator when called with options. + + Raises: + python_wrap.errors.ArgumentTypeError: On a mismatch (a ``TypeError``), with a message + such as ``parameter 'items' expected list[int], got list containing str at + index 2``. ``error.parameter`` is ``"return"`` for the return value. + + Example: + >>> @enforce_types + ... def total(items: list[int]) -> int: + ... return sum(items) + >>> total([1, 2]) + 3 + """ + + def decorate(f: Callable[P, R]) -> Callable[P, R]: + checker = _Checker(f, check_return) + return _wrap(f, checker.before, checker.after if check_return else None) + + return decorate if func is None else decorate(func) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8a09f5f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Shared fixtures. + +``fake_clock`` replaces every time source in ``python_wrap._core`` (via the public +``python_wrap.testing`` helpers) so time-based decorators can be tested instantly: +``sleep``/``async_sleep`` advance the clock instead of waiting. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from python_wrap.testing import FakeClock as FakeClock +from python_wrap.testing import fake_clock as _fake_clock + + +@pytest.fixture +def fake_clock() -> Iterator[FakeClock]: + with _fake_clock() as clock: + yield clock diff --git a/tests/stress/test_thread_safety.py b/tests/stress/test_thread_safety.py new file mode 100644 index 0000000..49fff0b --- /dev/null +++ b/tests/stress/test_thread_safety.py @@ -0,0 +1,1188 @@ +"""Thread-safety stress tests for every stateful decorator. + +Skipped unless ``PW_STRESS=1`` is set, so the normal test run stays fast:: + + PW_STRESS=1 uv run pytest tests/stress -q + PYTHON_GIL=0 PW_STRESS=1 uv run --python 3.14t --isolated --group dev \\ + pytest tests/stress -q -p no:cacheprovider + +Each test starts 16-64 threads behind a `threading.Barrier` (so they all hit the +decorator at the same moment) and checks an invariant the decorator documents. Async +paths are exercised with several event loops, each running in its own thread. On a +GIL build the switch interval is lowered for the duration of each test to force more +interleavings. + +Tests marked ``xfail(strict=True)`` reproduce a known race deterministically (by +forcing the bad interleaving) and document it until the code is fixed; fixing the +bug turns them into an XPASS failure, which is the cue to drop the marker. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import sys +import threading +import time +import tracemalloc +from collections import Counter +from collections.abc import Callable, Coroutine, Iterator +from typing import Any + +import pytest + +from python_wrap import _core +from python_wrap.batching import batch_calls +from python_wrap.bulkhead import Bulkhead +from python_wrap.caching import memoize, once +from python_wrap.circuit import CircuitBreaker, CircuitState +from python_wrap.concurrency import debounce, rate_limit, singleflight, throttle +from python_wrap.errors import BulkheadFull, CircuitOpenError, RateLimitExceeded +from python_wrap.retry import RetryState, retry +from python_wrap.state import Transaction, current_transaction, transactional +from python_wrap.testing import FakeClock, Spy, fake_clock, overrides +from python_wrap.timing import MemoryRecord, measure_memory + +pytestmark = pytest.mark.skipif( + os.environ.get("PW_STRESS") != "1", reason="stress suite; set PW_STRESS=1 to run" +) + +GIL_ENABLED: bool = getattr(sys, "_is_gil_enabled", lambda: True)() +JOIN_TIMEOUT = 10.0 + + +# --------------------------------------------------------------------------- helpers + + +@pytest.fixture(autouse=True) +def _max_contention() -> Iterator[None]: + """Switch threads as often as possible on GIL builds (no-op without a GIL).""" + previous = sys.getswitchinterval() + sys.setswitchinterval(1e-6) + try: + yield + finally: + sys.setswitchinterval(previous) + + +def hammer(n: int, target: Callable[[int], Any], *, timeout: float = JOIN_TIMEOUT) -> list[Any]: + """Run ``target(i)`` in ``n`` threads released together; return results in order. + + Re-raises the first exception from a worker; fails if a worker hangs. + """ + barrier = threading.Barrier(n) + results: list[Any] = [None] * n + errors: list[BaseException] = [] + + def run(i: int) -> None: + try: + barrier.wait() + results[i] = target(i) + except BaseException as exc: + errors.append(exc) + + threads = [threading.Thread(target=run, args=(i,), daemon=True) for i in range(n)] + for t in threads: + t.start() + deadline = time.monotonic() + timeout + for t in threads: + t.join(max(deadline - time.monotonic(), 0.0)) + hung = sum(t.is_alive() for t in threads) + assert hung == 0, f"{hung} worker thread(s) hung" + if errors: + raise errors[0] + return results + + +def in_loops(n: int, main: Callable[[int], Coroutine[Any, Any, Any]]) -> list[Any]: + """Run ``asyncio.run(main(i))`` in ``n`` threads at once: n event loops in parallel.""" + return hammer(n, lambda i: asyncio.run(main(i))) + + +class AtomicCounter: + def __init__(self) -> None: + self._lock = threading.Lock() + self.counts: Counter[Any] = Counter() + + def add(self, key: Any = None, n: int = 1) -> int: + with self._lock: + self.counts[key] += n + return self.counts[key] + + def __getitem__(self, key: Any) -> int: + with self._lock: + return self.counts[key] + + @property + def total(self) -> int: + with self._lock: + return sum(self.counts.values()) + + +class Peak: + """Tracks how many threads are inside a section at once.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self.current = 0 + self.peak = 0 + + def __enter__(self) -> None: + with self._lock: + self.current += 1 + self.peak = max(self.peak, self.current) + + def __exit__(self, *exc: object) -> None: + with self._lock: + self.current -= 1 + + +def wait_for(condition: Callable[[], bool], timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while not condition(): + assert time.monotonic() < deadline, "condition not reached in time" + time.sleep(0.001) + + +def test_report_interpreter(capsys: pytest.CaptureFixture[str]) -> None: + with capsys.disabled(): + print(f"\n[stress] Python {sys.version.split()[0]} GIL enabled: {GIL_ENABLED}") + assert bool(getattr(sys, "_is_gil_enabled", lambda: True)()) == GIL_ENABLED + + +# --------------------------------------------------------------------------- memoize + + +def test_memoize_sync_counters_add_up() -> None: + computed = AtomicCounter() + threads, calls, keys = 32, 400, 16 + + @memoize + def square(k: int) -> int: + computed.add(k) + return k * k + + def work(i: int) -> None: + for j in range(calls): + k = (i + j) % keys + assert square(k) == k * k + + hammer(threads, work) + info = square.cache_info() + assert info.hits + info.misses == threads * calls + assert info.misses == computed.total + assert info.currsize == keys + # Documented: concurrent misses on one key may both compute, at most once per thread. + assert all(1 <= computed[k] <= threads for k in range(keys)) + + +def test_memoize_sync_lru_bound_holds_under_contention() -> None: + @memoize(maxsize=4, ttl=60) + def ident(k: int) -> int: + return k + + def work(i: int) -> None: + for j in range(300): + k = (i * 7 + j) % 32 + assert ident(k) == k + assert ident.cache_info().currsize <= 4 + + hammer(32, work) + info = ident.cache_info() + assert info.hits + info.misses == 32 * 300 + assert info.currsize <= 4 + + +def test_memoize_sync_cache_clear_and_delete_while_calling() -> None: + @memoize + def triple(k: int) -> int: + return 3 * k + + def work(i: int) -> None: + for j in range(300): + if i == 0 and j % 10 == 0: + triple.cache_clear() + elif i == 1: + triple.cache_delete(j % 8) + else: + assert triple(j % 8) == 3 * (j % 8) + + hammer(24, work) + assert triple.cache_info().currsize <= 8 + + +def test_memoize_async_across_event_loops() -> None: + computed = AtomicCounter() + loops, tasks, keys = 8, 64, 8 + + @memoize + async def ident(k: int) -> int: + computed.add(k) + await asyncio.sleep(0.01) + return k + + async def main(i: int) -> None: + got = await asyncio.gather(*(ident(t % keys) for t in range(tasks))) + assert got == [t % keys for t in range(tasks)] + + in_loops(loops, main) + info = ident.cache_info() + assert info.hits + info.misses == loops * tasks + assert info.misses == computed.total + # In-flight sharing is per event loop: each key is computed at most once per loop. + assert all(1 <= computed[k] <= loops for k in range(keys)) + + +needs_eager = pytest.mark.skipif( + not hasattr(asyncio, "eager_task_factory"), reason="needs asyncio.eager_task_factory" +) + + +def _run_in_daemon(fn: Callable[[], Any], timeout: float = 2.0) -> Any: + box: dict[str, Any] = {} + + def run() -> None: + try: + box["result"] = fn() + except BaseException as exc: + box["error"] = exc + + t = threading.Thread(target=run, daemon=True) + t.start() + t.join(timeout) + assert not t.is_alive(), "deadlocked" + if "error" in box: + raise box["error"] + return box.get("result") + + +@needs_eager +def test_memoize_async_with_eager_task_factory() -> None: + @memoize + async def double(k: int) -> int: + return 2 * k # finishes without suspending: runs entirely inside ensure_future + + async def main() -> int: + asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory) # type: ignore[attr-defined] + return await double(4) + + assert _run_in_daemon(lambda: asyncio.run(main())) == 8 + + +# ------------------------------------------------------------------------------ once + + +def test_once_sync_runs_exactly_once() -> None: + runs = AtomicCounter() + + @once + def setup() -> object: + runs.add() + time.sleep(0.01) + return object() + + results = hammer(64, lambda i: [setup() for _ in range(50)]) + first = results[0][0] + assert all(r is first for rs in results for r in rs) + assert runs.total == 1 + assert setup.called + + +def test_once_sync_reset_between_rounds() -> None: + runs = AtomicCounter() + + @once + def setup() -> int: + return runs.add() + + for round_no in range(1, 31): + results = hammer(16, lambda i: setup()) + assert results == [round_no] * 16 + setup.reset() + assert runs.total == 30 + + +def test_once_async_across_event_loops_runs_once() -> None: + runs = AtomicCounter() + + @once + async def setup() -> str: + runs.add() + await asyncio.sleep(0.05) + return "ready" + + async def main(i: int) -> None: + assert await asyncio.gather(*(setup() for _ in range(16))) == ["ready"] * 16 + + in_loops(8, main) + assert runs.total == 1 + + +# -------------------------------------------------------------------------- throttle + + +def test_throttle_sync_runs_once_per_window() -> None: + runs = AtomicCounter() + + @throttle(1000) + def refresh() -> str: + runs.add() + return "fresh" + + results = hammer(64, lambda i: {refresh() for _ in range(50)}) + assert runs.total == 1 + assert set().union(*results) <= {None, "fresh"} + + +def test_throttle_async_across_event_loops() -> None: + runs = AtomicCounter() + + @throttle(1000) + async def refresh() -> str: + runs.add() + await asyncio.sleep(0) + return "fresh" + + async def main(i: int) -> None: + got = await asyncio.gather(*(refresh() for _ in range(32))) + assert set(got) <= {None, "fresh"} + + in_loops(8, main) + assert runs.total == 1 + + +# ------------------------------------------------------------------------ rate_limit + + +def _attempts(fn: Callable[[], Any], n: int) -> int: + admitted = 0 + for _ in range(n): + try: + fn() + except RateLimitExceeded as exc: + assert exc.retry_after > 0 + else: + admitted += 1 + return admitted + + +def test_rate_limit_never_admits_more_than_calls() -> None: + ran = AtomicCounter() + + @rate_limit(50, 1000) + def send() -> None: + ran.add() + + admitted = hammer(64, lambda i: _attempts(send, 20)) + assert sum(admitted) == ran.total == 50 + + +def test_rate_limit_per_key_buckets() -> None: + ran = AtomicCounter() + + @rate_limit(10, 1000, key=lambda user: user) + def send(user: int) -> None: + ran.add(user) + + hammer(64, lambda i: _attempts(lambda: send(i % 8), 20)) + assert all(ran[u] == 10 for u in range(8)) + + +def test_rate_limit_sweep_with_many_keys() -> None: + # 64 * 64 distinct keys > the internal sweep threshold: sweeping must not drop or + # double-count live buckets while other threads use them. + @rate_limit(1, 1000, key=lambda k: k) + def send(k: tuple[int, int]) -> None: + pass + + def work(i: int) -> int: + return sum(_attempts(lambda j=j: send((i, j)), 2) for j in range(64)) + + admitted = hammer(64, work) + assert admitted == [64] * 64 + + +def test_rate_limit_async_across_event_loops() -> None: + ran = AtomicCounter() + + @rate_limit(25, 1000) + async def send() -> None: + ran.add() + + async def main(i: int) -> int: + outcomes = await asyncio.gather(*(send() for _ in range(20)), return_exceptions=True) + assert all(o is None or isinstance(o, RateLimitExceeded) for o in outcomes) + return sum(o is None for o in outcomes) + + assert sum(in_loops(8, main)) == ran.total == 25 + + +def test_rate_limit_stale_clock_reading_admits_extra_call(monkeypatch: pytest.MonkeyPatch) -> None: + """A call delayed on the limiter's lock is recorded with the time it *arrived*. + + Force it: one thread holds the limiter's lock (its key's ``__hash__`` blocks, and the + key is hashed under the lock) while another reads the clock and queues for the lock. + A full period later the queued call is admitted but logged a period in the past, so + a second call at the same instant is admitted too: 2 calls inside one window with + ``calls=1``. + """ + now = [0.0] + waiter_read_clock = threading.Event() + waiter_ident: list[int] = [] + + def clock() -> float: + value = now[0] + if waiter_ident and threading.get_ident() == waiter_ident[0]: + waiter_read_clock.set() + return value + + monkeypatch.setattr(_core, "monotonic", clock) + release_lock = threading.Event() + + class BlockingKey: + def __hash__(self) -> int: + release_lock.wait(5) + return 1 + + admitted: list[tuple[str, float]] = [] + + @rate_limit(1, 1.0, key=lambda k: k) + def send(k: object) -> None: + admitted.append((repr(k), now[0])) + + holder = threading.Thread(target=send, args=(BlockingKey(),)) + holder.start() + time.sleep(0.05) # the holder is now inside the limiter's lock + + def waiter() -> None: + waiter_ident.append(threading.get_ident()) + send("a") + + w = threading.Thread(target=waiter) + w.start() + # Before the fix the waiter read t=0.0 here, before queueing for the lock; now it + # reads the clock only once it holds the lock, so give it time to get blocked. + waiter_read_clock.wait(0.2) + time.sleep(0.05) # the waiter is blocked on the lock + now[0] = 1.0 + release_lock.set() + holder.join(5) + w.join(5) + assert ("'a'", 1.0) in admitted # admitted at t=1.0 + with pytest.raises(RateLimitExceeded): + send("a") # still t=1.0: the window already holds one call for "a" + + +# -------------------------------------------------------------------------- debounce + + +def test_debounce_sync_burst_runs_once() -> None: + runs: list[tuple[int, int]] = [] + lock = threading.Lock() + + @debounce(0.05) + def save(i: int, j: int) -> None: + with lock: + runs.append((i, j)) + + def work(i: int) -> None: + for j in range(30): + save(i, j) + + hammer(32, work) + time.sleep(0.25) + assert len(runs) == 1 + assert runs[0][1] == 29 # some thread's last call + assert save.flush() is None + + +def test_debounce_sync_flush_and_cancel_while_calling() -> None: + runs = AtomicCounter() + + @debounce(0.02) + def save(i: int) -> None: + runs.add() + + def work(i: int) -> None: + for _ in range(50): + if i == 0: + save.flush() + elif i == 1: + save.cancel() + else: + save(i) + + hammer(24, work) + save.flush() + time.sleep(0.1) + before = runs.total + time.sleep(0.1) + assert runs.total == before # nothing left pending, nothing fires twice + assert before <= 22 * 50 + + +def test_debounce_async_one_run_per_loop_burst() -> None: + runs = AtomicCounter() + + @debounce(0.02) + async def save(loop_no: int, n: int) -> tuple[int, int]: + runs.add(loop_no) + return loop_no, n + + async def main(i: int) -> None: + got = await asyncio.gather(*(save(i, n) for n in range(40))) + assert got == [(i, 39)] * 40 # every caller of the burst shares the final outcome + + in_loops(8, main) + assert all(runs[i] == 1 for i in range(8)) + + +def test_debounce_async_cancel_from_another_thread() -> None: + """``cancel()`` called outside the loop touches that loop's futures cross-thread. + + `asyncio.Future.cancel` is not thread-safe: the callbacks are queued with + ``call_soon`` without waking the loop, so the awaiting caller stays blocked until + something else wakes the loop (here: the 30 s debounce timer). + """ + started = threading.Event() + outcome: dict[str, BaseException | None] = {} + + @debounce(30) + async def save() -> None: + pass + + async def main() -> None: + loop = asyncio.get_running_loop() + loop.call_soon(started.set) + try: + await save() + outcome["error"] = None + except BaseException as exc: + outcome["error"] = exc + + t = threading.Thread(target=lambda: asyncio.run(main()), daemon=True) + t.start() + assert started.wait(5) + time.sleep(0.05) + save.cancel() + t.join(1.0) + assert not t.is_alive(), "awaiting caller was not woken by cancel()" + assert isinstance(outcome["error"], asyncio.CancelledError) + + +# ---------------------------------------------------------------------- singleflight + + +def test_singleflight_sync_one_run_per_key_per_flight() -> None: + runs = AtomicCounter() + + @singleflight + def load(k: int) -> object: + runs.add(k) + time.sleep(0.2) + return object() + + results = hammer(64, lambda i: (i % 4, load(i % 4))) + for k in range(4): + assert runs[k] == 1 + assert len({id(r) for kk, r in results if kk == k}) == 1 + + +def test_singleflight_sync_shares_the_exception() -> None: + runs = AtomicCounter() + + @singleflight + def load() -> None: + runs.add() + time.sleep(0.2) + raise LookupError("boom") + + def work(i: int) -> BaseException | None: + try: + load() + except LookupError as exc: + return exc + return None + + errors = hammer(48, work) + assert runs.total == 1 + assert len({id(e) for e in errors}) == 1 and errors[0] is not None + + +def test_singleflight_sync_sequential_flights() -> None: + runs = AtomicCounter() + + @singleflight + def load(k: int) -> int: + runs.add(k) + return k + + hammer(32, lambda i: [load(j % 4) for j in range(200)]) + # Not a cache: many flights; every call got the right value and nothing hung. + assert 4 <= runs.total <= 32 * 200 + + +def test_singleflight_async_across_event_loops() -> None: + runs = AtomicCounter() + + @singleflight + async def load(k: int) -> int: + runs.add(k) + await asyncio.sleep(0.05) + return k * 10 + + async def main(i: int) -> None: + assert await asyncio.gather(*(load(t % 4) for t in range(64))) == [ + (t % 4) * 10 for t in range(64) + ] + + in_loops(8, main) + # De-duplication is per event loop: at most one run per key per loop. + assert all(1 <= runs[k] <= 8 for k in range(4)) + + +@needs_eager +def test_singleflight_async_with_eager_task_factory() -> None: + runs = AtomicCounter() + + @singleflight + async def load(k: int) -> int: + return runs.add() # finishes without suspending + + async def main() -> list[int]: + asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory) # type: ignore[attr-defined] + return [await load(1) for _ in range(3)] + + assert asyncio.run(main()) == [1, 2, 3] + + +# -------------------------------------------------------------------- CircuitBreaker + + +@pytest.mark.parametrize("max_trials", [1, 3]) +def test_circuit_half_open_trial_slots_never_exceeded( + fake_clock: FakeClock, max_trials: int +) -> None: + breaker = CircuitBreaker( + failure_threshold=1, recovery_timeout=30, half_open_max_calls=max_trials + ) + gate = threading.Event() + entered = AtomicCounter() + rejected = AtomicCounter() + threads = 64 + + @breaker + def call(fail: bool) -> str: + if fail: + raise ValueError("down") + entered.add() + assert gate.wait(5) + return "ok" + + with pytest.raises(ValueError): + call(True) + assert breaker.state is CircuitState.OPEN + fake_clock.advance(31) + + def work(i: int) -> str: + try: + return call(False) + except CircuitOpenError: + rejected.add() + return "rejected" + + results: list[str] = [] + runner = threading.Thread(target=lambda: results.extend(hammer(threads, work))) + runner.start() + wait_for(lambda: entered.total + rejected.total == threads) + assert entered.total == max_trials # never more trial calls than slots + gate.set() + runner.join(JOIN_TIMEOUT) + assert results.count("ok") == max_trials + assert breaker.state is CircuitState.CLOSED + + +def test_circuit_concurrent_failures_open_once() -> None: + breaker = CircuitBreaker(failure_threshold=20, recovery_timeout=1000) + reached = AtomicCounter() + transitions: list[_core.Event] = [] + + def listener(event: _core.Event) -> None: + if event.name == "circuit.state_changed": + transitions.append(event) + + @breaker + def call() -> None: + reached.add() + raise ValueError("down") + + def work(i: int) -> Counter[str]: + seen: Counter[str] = Counter() + for _ in range(20): + try: + call() + except ValueError: + seen["failed"] += 1 + except CircuitOpenError: + seen["rejected"] += 1 + return seen + + _core.listeners.append(listener) + try: + outcomes = hammer(64, work) + finally: + _core.listeners.remove(listener) + total: Counter[str] = sum(outcomes, Counter()) + assert breaker.state is CircuitState.OPEN + assert total["failed"] == reached.total >= 20 + assert total["failed"] + total["rejected"] == 64 * 20 + opened = [e for e in transitions if e.fields.get("new") in ("open", CircuitState.OPEN)] + assert len(opened) <= 1 # events are optional here, but must not report duplicates + + +def test_circuit_async_trial_slots_across_event_loops() -> None: + breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=0.05, half_open_max_calls=2) + peak = Peak() + + @breaker + async def call(fail: bool) -> str: + if fail: + raise ValueError("down") + with peak: + await asyncio.sleep(0.2) + return "ok" + + with pytest.raises(ValueError): + asyncio.run(call(True)) + time.sleep(0.06) + + async def main(i: int) -> list[Any]: + return await asyncio.gather(*(call(False) for _ in range(8)), return_exceptions=True) + + outcomes = [o for loop_out in in_loops(8, main) for o in loop_out] + assert peak.peak <= 2 + assert sum(o == "ok" for o in outcomes) <= 2 + assert all(o == "ok" or isinstance(o, CircuitOpenError) for o in outcomes) + + +# -------------------------------------------------------------------------- Bulkhead + + +def test_bulkhead_sync_never_runs_more_than_max() -> None: + bulkhead = Bulkhead(4) + peak = Peak() + + @bulkhead + def work_unit() -> None: + with peak: + pass + + hammer(64, lambda i: [work_unit() for _ in range(200)]) + assert peak.peak <= 4 + assert bulkhead.active == 0 and bulkhead.waiting == 0 + + +def test_bulkhead_sync_queue_bound() -> None: + bulkhead = Bulkhead(2, max_waiting=8) + peak = Peak() + worst_waiting = [0] + + @bulkhead + def work_unit() -> None: + with peak: + worst_waiting[0] = max(worst_waiting[0], bulkhead.waiting) + time.sleep(0.0005) + + def work(i: int) -> int: + full = 0 + for _ in range(30): + try: + work_unit() + except BulkheadFull: + full += 1 + return full + + rejected = sum(hammer(48, work)) + assert peak.peak <= 2 + assert worst_waiting[0] <= 8 + assert rejected > 0 + assert bulkhead.active == 0 and bulkhead.waiting == 0 + + +def test_bulkhead_active_never_exceeds_max() -> None: + """``release()`` returns the semaphore permit before decrementing ``active``. + + Force the window: pause the releasing thread right after the permit is returned; a + second caller takes the slot and then sees ``active == 2`` with ``max_concurrent=1``. + """ + bulkhead = Bulkhead(1) + inner = bulkhead._thread_semaphore + paused = threading.Event() + resume = threading.Event() + pausing: list[int] = [] + + class PausingSemaphore: + def acquire(self, *args: Any, **kwargs: Any) -> bool: + return inner.acquire(*args, **kwargs) + + def release(self) -> None: + inner.release() + if threading.get_ident() in pausing: + paused.set() + resume.wait(5) + + bulkhead._thread_semaphore = PausingSemaphore() # type: ignore[assignment] + + def first() -> None: + pausing.append(threading.get_ident()) + with bulkhead: + pass + + t = threading.Thread(target=first) + t.start() + try: + if paused.wait(1): # only reached while release() frees the permit first + with bulkhead: + assert bulkhead.active <= bulkhead.max_concurrent + finally: + resume.set() + t.join(5) + assert bulkhead.active == 0 + + +def test_bulkhead_async_per_loop_limit() -> None: + bulkhead = Bulkhead(3) + peaks: dict[int, Peak] = {i: Peak() for i in range(8)} + + async def unit(i: int) -> None: + async with bulkhead: + with peaks[i]: + await asyncio.sleep(0.002) + + async def main(i: int) -> None: + await asyncio.gather(*(unit(i) for _ in range(40))) + + in_loops(8, main) + assert all(p.peak <= 3 for p in peaks.values()) + assert bulkhead.active == 0 and bulkhead.waiting == 0 + + +# ----------------------------------------------------------------------- batch_calls + + +def test_batch_calls_sync_every_caller_gets_its_own_result() -> None: + batches: list[list[tuple[int, int]]] = [] + lock = threading.Lock() + + @batch_calls(max_size=16, max_wait=0.005) + def load(items: list[tuple[int, int]]) -> list[str]: + with lock: + batches.append(list(items)) + return [f"{i}:{j}" for i, j in items] + + def work(i: int) -> None: + for j in range(40): + assert load((i, j)) == f"{i}:{j}" + + hammer(64, work) + sent = [item for batch in batches for item in batch] + assert len(sent) == len(set(sent)) == 64 * 40 # each item sent exactly once + assert all(1 <= len(b) <= 16 for b in batches) + assert load.pending == 0 + + +def test_batch_calls_sync_dedupes_shared_keys() -> None: + sent = AtomicCounter() + + @batch_calls(max_size=8, max_wait=0.01) + def load(items: list[int]) -> dict[int, int]: + for item in items: + sent.add(item) + return {item: item * 2 for item in items} + + hammer(64, lambda i: [load(j % 8) == (j % 8) * 2 or pytest.fail("wrong") for j in range(20)]) + assert load.pending == 0 + assert sent.total <= 64 * 20 + + +def test_batch_calls_sync_flush_while_calling() -> None: + @batch_calls(max_size=1000, max_wait=0.05) + def load(items: list[int]) -> list[int]: + return [-i for i in items] + + def work(i: int) -> None: + for j in range(30): + if i == 0: + load.flush() + else: + assert load(i * 1000 + j) == -(i * 1000 + j) + + hammer(32, work) + assert load.pending == 0 + + +def test_batch_calls_async_across_event_loops() -> None: + batches: list[list[tuple[int, int]]] = [] + lock = threading.Lock() + + @batch_calls(max_size=32, max_wait=0.005) + async def load(items: list[tuple[int, int]]) -> list[int]: + with lock: + batches.append(list(items)) + await asyncio.sleep(0) + return [i * 1000 + j for i, j in items] + + async def main(i: int) -> None: + got = await asyncio.gather(*(load((i, j)) for j in range(200))) + assert got == [i * 1000 + j for j in range(200)] + + in_loops(8, main) + sent = [item for batch in batches for item in batch] + assert len(sent) == len(set(sent)) == 8 * 200 + assert all(len({i for i, _ in batch}) == 1 for batch in batches) # never spans loops + assert load.pending == 0 + + +# ----------------------------------------------------------------------- Transaction + + +def test_transactional_isolated_per_thread() -> None: + committed = AtomicCounter() + + @transactional + def unit(i: int, *, tx: Transaction) -> None: + assert current_transaction() is tx + tx.on_commit(committed.add, i) + if i % 3 == 0: + tx.on_rollback(committed.add, ("rolled", i)) + raise KeyError(i) + + def work(i: int) -> None: + for _ in range(50): + try: + unit(i) + except KeyError: + assert i % 3 == 0 + assert current_transaction() is None + + hammer(48, work) + for i in range(48): + if i % 3 == 0: + assert committed[i] == 0 and committed[("rolled", i)] == 50 + else: + assert committed[i] == 50 + + +def test_transaction_completes_only_once() -> None: + """Two threads committing one transaction must run the callbacks once. + + ``commit()`` checks ``_state`` and then sets it without a lock. Force the window by + pausing the first committer right after its check. + """ + tx = Transaction() + runs = AtomicCounter() + tx.on_commit(runs.add) + first_checked = threading.Event() + resume = threading.Event() + first: list[int] = [] + original_check = tx._check_open + + def check_then_pause() -> None: + original_check() + if threading.get_ident() in first: + first_checked.set() + resume.wait(1) + + tx._check_open = check_then_pause # type: ignore[method-assign] + errors: list[BaseException] = [] + + def commit(is_first: bool) -> None: + if is_first: + first.append(threading.get_ident()) + try: + tx.commit() + except RuntimeError as exc: + errors.append(exc) + + a = threading.Thread(target=commit, args=(True,)) + a.start() + assert first_checked.wait(5) + b = threading.Thread(target=commit, args=(False,)) + b.start() + b.join(0.2) + resume.set() + a.join(5) + b.join(5) + assert runs.total == 1 + assert len(errors) == 1 + + +def test_transaction_concurrent_commit_hammer() -> None: + # Probabilistic companion of the test above; only checks that nothing crashes and + # the state ends committed. Double runs are reported, not asserted. + doubles = 0 + for _ in range(200): + tx = Transaction() + runs = AtomicCounter() + tx.on_commit(runs.add) + + def work(i: int, tx: Transaction = tx) -> None: + with contextlib.suppress(RuntimeError): + tx.commit() + + hammer(16, work) + assert tx.state == "committed" + assert runs.total >= 1 + doubles += runs.total > 1 + if doubles: + print(f"\n[stress] Transaction.commit ran callbacks more than once in {doubles}/200") + + +# ---------------------------------------------------------------- retry / RetryState + + +def test_retry_state_is_per_call() -> None: + local = threading.local() + states: dict[int, list[int]] = {} + lock = threading.Lock() + + def record(state: RetryState) -> None: + with lock: + states.setdefault(threading.get_ident(), []).append(state.attempt) + + @retry(max_attempts=3, delay=0, on_retry=record) + def flaky() -> str: + local.n = getattr(local, "n", 0) + 1 + if local.n % 3: + raise ConnectionError("try again") + return "ok" + + results = hammer(48, lambda i: [flaky() for _ in range(20)]) + assert all(r == ["ok"] * 20 for r in results) + assert len(states) == 48 + assert all(seq == [1, 2] * 20 for seq in states.values()) + + +def test_retry_async_across_event_loops() -> None: + attempts = AtomicCounter() + + @retry(max_attempts=4, delay=0) + async def flaky(key: tuple[int, int]) -> tuple[int, int]: + if attempts.add(key) < 3: + raise ConnectionError("try again") + return key + + async def main(i: int) -> None: + keys = [(i, j) for j in range(32)] + assert await asyncio.gather(*(flaky(k) for k in keys)) == keys + + in_loops(8, main) + assert all(attempts[(i, j)] == 3 for i in range(8) for j in range(32)) + + +# --------------------------------------------------------- measure_memory refcounting + + +def test_measure_memory_tracing_refcount() -> None: + if tracemalloc.is_tracing(): + pytest.skip("tracemalloc already running") + records: list[MemoryRecord] = [] + lock = threading.Lock() + + def keep(record: MemoryRecord) -> None: + with lock: + records.append(record) + + @measure_memory(callback=keep) + def build(n: int) -> list[int]: + assert tracemalloc.is_tracing() # nobody stops tracing under a running call + return [0] * n + + hammer(32, lambda i: [build(1000) for _ in range(20)]) + assert len(records) == 32 * 20 + assert not tracemalloc.is_tracing() + + +def test_measure_memory_leaves_external_tracing_running() -> None: + if tracemalloc.is_tracing(): + pytest.skip("tracemalloc already running") + + @measure_memory + def build() -> list[int]: + return [0] * 100 + + tracemalloc.start() + try: + hammer(32, lambda i: [build() for _ in range(20)]) + assert tracemalloc.is_tracing() # started by us, so the decorator must not stop it + finally: + tracemalloc.stop() + + +# --------------------------------------------------------------- testing utilities + + +def test_fake_clock_sleep_is_thread_safe() -> None: + with fake_clock() as clock: + hammer(64, lambda i: [_core.sleep(0.5) for _ in range(100)]) + assert len(clock.sleeps) == 64 * 100 + assert clock.now - clock.start == pytest.approx(64 * 100 * 0.5) + + +def test_spy_counts_every_call() -> None: + spy = Spy(lambda x: x) + hammer(64, lambda i: [spy(i) for _ in range(100)]) + assert spy.call_count == 64 * 100 + + +def test_override_scopes_nested_in_one_thread_while_others_read() -> None: + stop = threading.Event() + seen: set[bool] = set() + + def reader() -> None: + while not stop.is_set(): + seen.add(_core.overrides.disable_caching) + + r = threading.Thread(target=reader) + r.start() + try: + for _ in range(500): + with overrides(disable_caching=True), overrides(no_sleep=True): + assert _core.overrides.disable_caching and _core.overrides.no_sleep + finally: + stop.set() + r.join(5) + assert not _core.overrides.disable_caching and not _core.overrides.no_sleep + assert seen <= {True, False} + + +def test_override_scopes_overlapping_across_threads_restore() -> None: + """Scopes entered A, B and exited A, B (from two threads) leave the field set. + + Documented in `python_wrap.testing` ("the last one to exit wins"), but it means a + ``@fast_mode()``-decorated helper called from worker threads leaks overrides into + later tests. + """ + a_in, b_in, a_out = threading.Event(), threading.Event(), threading.Event() + + def a() -> None: + with overrides(disable_rate_limits=True): + a_in.set() + assert b_in.wait(5) + a_out.set() + + def b() -> None: + assert a_in.wait(5) + with overrides(disable_rate_limits=True): + b_in.set() + assert a_out.wait(5) + + ta, tb = threading.Thread(target=a), threading.Thread(target=b) + ta.start() + tb.start() + ta.join(5) + tb.join(5) + leaked = _core.overrides.disable_rate_limits + _core.set_overrides(disable_rate_limits=False) + assert not leaked diff --git a/tests/test_aio.py b/tests/test_aio.py new file mode 100644 index 0000000..23b574a --- /dev/null +++ b/tests/test_aio.py @@ -0,0 +1,521 @@ +"""Tests for python_wrap.aio.""" + +from __future__ import annotations + +import asyncio +import sys +import warnings +from collections.abc import AsyncIterator, Iterator +from contextlib import aclosing +from typing import Any + +import pytest + +from python_wrap.aio import RaceFailed, amap_iter, gather_limit, map_async, race, run_sync +from python_wrap.errors import PythonWrapError, TimeoutExpired +from python_wrap.testing import fast_mode + + +def other_tasks() -> set[asyncio.Task[Any]]: + return {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + + +async def assert_no_tasks_left() -> None: + await asyncio.sleep(0) + assert other_tasks() == set() + + +class Tracker: + """Records how many calls run at once; each call waits for its own gate.""" + + def __init__(self) -> None: + self.active = 0 + self.peak = 0 + self.started: list[int] = [] + self.cancelled: list[int] = [] + self.gates: dict[int, asyncio.Event] = {} + + def gate(self, i: int) -> asyncio.Event: + return self.gates.setdefault(i, asyncio.Event()) + + async def run(self, i: int, *, fail: bool = False) -> int: + self.active += 1 + self.peak = max(self.peak, self.active) + self.started.append(i) + try: + await self.gate(i).wait() + except asyncio.CancelledError: + self.cancelled.append(i) + raise + finally: + self.active -= 1 + if fail: + raise ValueError(i) + return i * 10 + + def open(self, *indices: int) -> None: + for i in indices: + self.gate(i).set() + + +async def spin(n: int = 5) -> None: + for _ in range(n): + await asyncio.sleep(0) + + +# --------------------------------------------------------------------------------------- +# gather_limit +# --------------------------------------------------------------------------------------- + + +async def test_gather_limit_keeps_input_order_and_caps_concurrency() -> None: + tr = Tracker() + job = asyncio.ensure_future(gather_limit(*(tr.run(i) for i in range(6)), limit=2)) + await spin() + assert tr.started == [0, 1] # later coroutines not started yet + tr.open(1) + await spin() + assert tr.started == [0, 1, 2] + tr.open(5, 4, 3, 2, 0) + assert await job == [0, 10, 20, 30, 40, 50] + assert tr.peak == 2 + await assert_no_tasks_left() + + +async def test_gather_limit_empty_and_validation() -> None: + assert await gather_limit(limit=3) == [] + for bad in (0, -1, True, 1.5): + with pytest.raises(ValueError, match="limit"): + await gather_limit(limit=bad) # type: ignore[arg-type] + + +async def test_gather_limit_first_error_cancels_rest_and_closes_unstarted() -> None: + tr = Tracker() + tr.open(1) + with warnings.catch_warnings(): + warnings.simplefilter("error") # "coroutine was never awaited" would fail + with pytest.raises(ValueError, match="1"): + await gather_limit(tr.run(0), tr.run(1, fail=True), tr.run(2), tr.run(3), limit=2) + assert tr.started == [0, 1] + assert tr.cancelled == [0] + await assert_no_tasks_left() + + +async def test_gather_limit_return_exceptions() -> None: + tr = Tracker() + tr.open(0, 1) + victim = asyncio.ensure_future(tr.run(2)) + await spin() + victim.cancel() + results = await gather_limit( + tr.run(0), tr.run(1, fail=True), victim, limit=3, return_exceptions=True + ) + assert results[0] == 0 + assert isinstance(results[1], ValueError) + assert isinstance(results[2], asyncio.CancelledError) + await assert_no_tasks_left() + + +async def test_gather_limit_accepts_tasks_futures_and_duplicates() -> None: + loop = asyncio.get_running_loop() + fut: asyncio.Future[int] = loop.create_future() + loop.call_soon(fut.set_result, 7) + + async def five() -> int: + return 5 + + task = asyncio.ensure_future(five()) + assert await gather_limit(task, fut, fut, five(), limit=1) == [5, 7, 7, 5] + await assert_no_tasks_left() + + +async def test_gather_limit_cancels_unstarted_futures_on_error() -> None: + tr = Tracker() + tr.open(0) + late = asyncio.ensure_future(tr.run(9)) + with pytest.raises(ValueError): + await gather_limit(tr.run(0, fail=True), late, limit=1) + assert late.cancelled() + await assert_no_tasks_left() + + +async def test_gather_limit_caller_cancellation_cancels_everything() -> None: + tr = Tracker() + job = asyncio.ensure_future(gather_limit(*(tr.run(i) for i in range(4)), limit=3)) + await spin() + job.cancel() + with pytest.raises(asyncio.CancelledError): + await job + assert sorted(tr.cancelled) == [0, 1, 2] + await assert_no_tasks_left() + + +# --------------------------------------------------------------------------------------- +# map_async +# --------------------------------------------------------------------------------------- + + +async def double(n: int) -> int: + await asyncio.sleep(0) + return 2 * n + + +async def agen(n: int) -> AsyncIterator[int]: + for i in range(n): + await asyncio.sleep(0) + yield i + + +async def test_map_async_sync_and_async_iterables() -> None: + assert await map_async(double, range(5), limit=2) == [0, 2, 4, 6, 8] + assert await map_async(double, agen(5), limit=2) == [0, 2, 4, 6, 8] + assert await map_async(double, [], limit=2) == [] + await assert_no_tasks_left() + + +async def test_map_async_unordered_is_completion_order() -> None: + tr = Tracker() + job = asyncio.ensure_future(map_async(tr.run, range(3), limit=3, ordered=False)) + await spin() + tr.open(2) + await spin() + tr.open(0) + await spin() + tr.open(1) + assert await job == [20, 0, 10] + + +async def test_map_async_pulls_input_lazily_and_stops_on_error() -> None: + pulled: list[int] = [] + tr = Tracker() + + def items() -> Iterator[int]: + i = 0 + while True: # infinite + pulled.append(i) + yield i + i += 1 + + async def work(i: int) -> int: + return await tr.run(i, fail=i == 1) + + job = asyncio.ensure_future(map_async(work, items(), limit=3)) + await spin() + assert pulled == [0, 1, 2] + tr.open(1) + with pytest.raises(ValueError): + await job + assert sorted(tr.cancelled) == [0, 2] + assert len(pulled) <= 4 + await assert_no_tasks_left() + + +async def test_map_async_sync_raise_in_fn_and_return_exceptions() -> None: + def fn(i: int) -> Any: + if i == 1: + raise KeyError(i) + return double(i) + + out = await map_async(fn, range(3), limit=2, return_exceptions=True) + assert out[0] == 0 and out[2] == 4 and isinstance(out[1], KeyError) + with pytest.raises(KeyError): + await map_async(fn, range(3), limit=2) + await assert_no_tasks_left() + + +async def test_map_async_input_error_cleans_up() -> None: + tr = Tracker() + + async def broken() -> AsyncIterator[int]: + yield 0 + raise OSError("input") + + with pytest.raises(OSError, match="input"): + await map_async(tr.run, broken(), limit=5) + assert tr.cancelled == tr.started # cancelled (if it even started) + await assert_no_tasks_left() + + +# --------------------------------------------------------------------------------------- +# amap_iter +# --------------------------------------------------------------------------------------- + + +async def test_amap_iter_ordered_buffer_is_bounded() -> None: + tr = Tracker() + pulled: list[int] = [] + + def items() -> Iterator[int]: + for i in range(10): + pulled.append(i) + yield i + + out: list[int] = [] + + async def consume() -> None: + async with aclosing(amap_iter(tr.run, items(), limit=2)) as results: + async for value in results: + out.append(value) + + job = asyncio.ensure_future(consume()) + await spin() + tr.open(1) # finishes early but must wait for 0 in the buffer + await spin() + assert pulled == [0, 1] and out == [] + tr.open(0) + await spin() + assert out == [0, 10] + tr.open(*range(2, 10)) + await job + assert out == [i * 10 for i in range(10)] + assert tr.peak == 2 + await assert_no_tasks_left() + + +async def test_amap_iter_unordered_yields_as_completed() -> None: + tr = Tracker() + out: list[int] = [] + + async def consume() -> None: + async for value in amap_iter(tr.run, range(3), limit=3, ordered=False): + out.append(value) + + job = asyncio.ensure_future(consume()) + await spin() + tr.open(2) + await spin() + assert out == [20] + tr.open(0, 1) + await job + assert out == [20, 0, 10] + + +async def test_amap_iter_early_break_cancels_in_flight() -> None: + tr = Tracker() + tr.open(0) + source = agen(100) + async with aclosing(amap_iter(tr.run, source, limit=3)) as results: + async for value in results: + assert value == 0 + break + await source.aclose() # the input belongs to the caller + assert sorted(tr.cancelled) == sorted(set(tr.started) - {0}) + assert len(tr.cancelled) >= 2 + await assert_no_tasks_left() + + +async def test_amap_iter_consumer_cancellation_cleans_up() -> None: + tr = Tracker() + + async def consume() -> None: + async with aclosing(amap_iter(tr.run, range(5), limit=2)) as results: + async for _ in results: + pass + + job = asyncio.ensure_future(consume()) + await spin() + job.cancel() + with pytest.raises(asyncio.CancelledError): + await job + assert sorted(tr.cancelled) == [0, 1] + await assert_no_tasks_left() + + +async def test_amap_iter_errors_in_order_or_as_values() -> None: + async def maybe_fail(i: int) -> int: + if i == 2: + raise ValueError(i) + return i + + out: list[int] = [] + with pytest.raises(ValueError): + async for value in amap_iter(maybe_fail, range(5), limit=5): + out.append(value) + assert out == [0, 1] + got = [v async for v in amap_iter(maybe_fail, range(4), limit=2, return_exceptions=True)] + assert got[:2] == [0, 1] and isinstance(got[2], ValueError) and got[3] == 3 + await assert_no_tasks_left() + + +async def test_amap_iter_validates_limit_eagerly() -> None: + with pytest.raises(ValueError): + amap_iter(double, range(3), limit=0) + + +# --------------------------------------------------------------------------------------- +# race +# --------------------------------------------------------------------------------------- + + +async def test_race_first_success_wins_and_losers_are_cancelled() -> None: + tr = Tracker() + job = asyncio.ensure_future(race(tr.run(0), tr.run(1), tr.run(2))) + await spin() + tr.open(1) + assert await job == 10 + assert sorted(tr.cancelled) == [0, 2] + await assert_no_tasks_left() + + +async def test_race_failures_do_not_end_the_race() -> None: + tr = Tracker() + tr.open(0) + job = asyncio.ensure_future(race(tr.run(0, fail=True), tr.run(1))) + await spin() + assert not job.done() + tr.open(1) + assert await job == 10 + + +async def test_race_ties_go_to_the_earliest_awaitable() -> None: + async def value(v: int) -> int: + return v + + assert await race(value(1), value(2)) == 1 + await assert_no_tasks_left() + + +async def test_race_all_fail_raises_group_in_input_order() -> None: + tr = Tracker() + job = asyncio.ensure_future(race(tr.run(0, fail=True), tr.run(1, fail=True))) + await spin() + tr.open(1) + await spin() + tr.open(0) + with pytest.raises(RaceFailed) as info: + await job + assert [e.args for e in info.value.exceptions] == [(0,), (1,)] + assert isinstance(info.value, PythonWrapError) + if sys.version_info >= (3, 11): + assert isinstance(info.value, ExceptionGroup) # noqa: F821 + match, rest = info.value.split(ValueError) + assert isinstance(match, RaceFailed) and rest is None + await assert_no_tasks_left() + + +async def test_race_timeout(fake_clock: Any) -> None: + tr = Tracker() + with pytest.raises(TimeoutExpired) as info: + await race(tr.run(0), tr.run(1), timeout=0.01) + assert info.value.seconds == 0.01 + assert isinstance(info.value, TimeoutError) + assert fake_clock.sleeps == [] # a deadline runs on real time, not the fake clock + assert sorted(tr.cancelled) == [0, 1] + await assert_no_tasks_left() + + +async def test_race_timeout_is_not_instant_under_fast_mode() -> None: + async def slow() -> str: + await asyncio.sleep(0.02) # real I/O, well inside the timeout + return "ok" + + with fast_mode(): + assert await race(slow(), timeout=5) == "ok" + await assert_no_tasks_left() + + +async def test_race_winner_before_timeout() -> None: + async def value() -> str: + return "ok" + + assert await race(value(), timeout=60) == "ok" + await assert_no_tasks_left() + + +async def test_race_base_exception_propagates_at_once() -> None: + class Stop(BaseException): + pass + + async def stop() -> None: + raise Stop + + tr = Tracker() + with pytest.raises(Stop): + await race(tr.run(0), stop()) + assert tr.cancelled == [0] + await assert_no_tasks_left() + + +async def test_race_all_cancelled_and_empty() -> None: + tr = Tracker() + a = asyncio.ensure_future(tr.run(0)) + await spin() + a.cancel() + with pytest.raises(asyncio.CancelledError): + await race(a) + with pytest.raises(ValueError): + await race() + + +async def test_race_caller_cancellation_cancels_contestants() -> None: + tr = Tracker() + job = asyncio.ensure_future(race(tr.run(0), tr.run(1), timeout=60)) + await spin() + job.cancel() + with pytest.raises(asyncio.CancelledError): + await job + assert sorted(tr.cancelled) == [0, 1] + await assert_no_tasks_left() + + +async def test_cleanup_finishes_even_if_cancelled_again() -> None: + """A second cancel during cleanup still waits for the children.""" + finished: list[str] = [] + + async def stubborn() -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + await asyncio.sleep(0) + await asyncio.sleep(0) + finished.append("child") + raise + + job = asyncio.ensure_future(race(stubborn())) + await spin(2) + job.cancel() + await asyncio.sleep(0) + job.cancel() + with pytest.raises(asyncio.CancelledError): + await job + assert finished == ["child"] + await assert_no_tasks_left() + + +# --------------------------------------------------------------------------------------- +# run_sync +# --------------------------------------------------------------------------------------- + + +async def add(a: int, b: int = 0) -> int: + await asyncio.sleep(0) + return a + b + + +class Awaitable42: + def __await__(self) -> Any: + return add(40, 2).__await__() + + +def test_run_sync_forms() -> None: + assert run_sync(add(1, 2)) == 3 + assert run_sync(add, 1, b=5) == 6 + assert run_sync(lambda: Awaitable42()) == 42 + + +def test_run_sync_rejects_bad_input() -> None: + with pytest.raises(TypeError, match="no extra arguments"): + run_sync(add(1), 2) # type: ignore[call-overload] + with pytest.raises(TypeError, match="coroutine or an async callable"): + run_sync(42) # type: ignore[call-overload] + with pytest.raises(TypeError, match="did not return an awaitable"): + run_sync(lambda: 1) # type: ignore[arg-type, return-value] + + +async def test_run_sync_refuses_inside_a_running_loop() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(RuntimeError, match="event loop is running"): + run_sync(add(1, 2)) + with pytest.raises(RuntimeError, match="event loop is running"): + run_sync(add, 1, 2) diff --git a/tests/test_batching.py b/tests/test_batching.py new file mode 100644 index 0000000..6d6910c --- /dev/null +++ b/tests/test_batching.py @@ -0,0 +1,612 @@ +from __future__ import annotations + +import asyncio +import gc +import inspect +import threading +import time + +import pytest + +from python_wrap.batching import batch_calls +from python_wrap.errors import BatchError, PythonWrapError +from python_wrap.testing import fast_mode + + +def wait_until(predicate, timeout=2.0): + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise AssertionError("condition not reached") + time.sleep(0.001) + + +def only_current_task(): + return asyncio.all_tasks() == {asyncio.current_task()} + + +# --------------------------------------------------------------------------- options + + +@pytest.mark.parametrize( + "kwargs", + [{"max_size": 0}, {"max_size": 1.5}, {"max_size": True}, {"max_wait": -1}], +) +def test_validates_options(kwargs): + with pytest.raises(ValueError): + batch_calls(**kwargs) + + +def test_batch_error_is_a_python_wrap_error(): + assert issubclass(BatchError, PythonWrapError) + + +# --------------------------------------------------------------------------- async + + +async def test_async_coalesces_concurrent_calls(fake_clock): + calls = [] + + @batch_calls() + async def load(ids: list[int]) -> list[str]: + calls.append(list(ids)) + return [f"u{i}" for i in ids] + + assert inspect.iscoroutinefunction(load) + assert load.__name__ == "load" + results = await asyncio.gather(load(1), load(2), load(3)) + assert results == ["u1", "u2", "u3"] + assert calls == [[1, 2, 3]] + assert fake_clock.sleeps == [] # the window runs on real time + assert load.pending == 0 + assert only_current_task() + + +async def test_async_mapping_result_with_key_and_deduplication(fake_clock): + calls = [] + + @batch_calls(key=lambda user: user["id"]) + async def load(users: list[dict]) -> dict[int, str]: + calls.append([u["id"] for u in users]) + return {u["id"]: u["name"].upper() for u in users} | {99: "extra is ignored"} + + a, b, c = await asyncio.gather( + load({"id": 1, "name": "ann"}), + load({"id": 2, "name": "bob"}), + load({"id": 1, "name": "ann"}), + ) + assert (a, b, c) == ("ANN", "BOB", "ANN") + assert calls == [[1, 2]] + + +async def test_async_max_size_splits_batches(fake_clock): + calls = [] + + @batch_calls(max_size=2) + async def load(ids: list[int]) -> tuple[int, ...]: + calls.append(list(ids)) + return tuple(i * 10 for i in ids) + + assert await asyncio.gather(*(load(i) for i in range(5))) == [0, 10, 20, 30, 40] + assert calls == [[0, 1], [2, 3], [4]] + await asyncio.sleep(0) # let the cancelled window timers finish + assert only_current_task() + + +async def test_async_exception_reaches_every_caller(fake_clock): + @batch_calls() + async def load(ids: list[int]) -> list[int]: + raise KeyError("down") + + results = await asyncio.gather(load(1), load(2), return_exceptions=True) + assert all(isinstance(r, KeyError) for r in results) + assert results[0] is results[1] + + +async def test_async_wrong_length_is_a_batch_error_for_everyone(fake_clock): + @batch_calls() + async def load(ids: list[int]) -> list[int]: + return [1] + + results = await asyncio.gather(load(1), load(2), return_exceptions=True) + assert all(isinstance(r, BatchError) for r in results) + assert "returned 1 results for 2 items" in str(results[0]) + + +async def test_async_missing_key_only_fails_that_caller(fake_clock): + @batch_calls() + async def load(ids: list[int]) -> dict[int, int]: + return {i: i for i in ids if i != 2} + + results = await asyncio.gather(load(1), load(2), load(3), return_exceptions=True) + assert results[0] == 1 and results[2] == 3 + assert isinstance(results[1], BatchError) + assert "no entry for key 2" in str(results[1]) + + +@pytest.mark.parametrize("bad", ["ab", 42, None, iter([1, 2])]) +async def test_async_non_sequence_result_is_a_batch_error(fake_clock, bad): + @batch_calls() + async def load(ids: list[int]): + return bad + + results = await asyncio.gather(load(1), load(2), return_exceptions=True) + assert all(isinstance(r, BatchError) for r in results) + assert "must return a sequence or a mapping" in str(results[0]) + + +async def test_async_window_is_not_instant_under_fast_mode(): + calls = [] + + @batch_calls(max_wait=0.5) + async def load(ids: list[int]) -> list[int]: + calls.append(list(ids)) + return ids + + with fast_mode(): + first = asyncio.ensure_future(load(1)) + await asyncio.sleep(0.01) # real time, well inside the window + second = asyncio.ensure_future(load(2)) + await asyncio.sleep(0) + await load.aflush() # close the window now instead of waiting it out + assert await asyncio.gather(first, second) == [1, 2] + assert calls == [[1, 2]] # one window: fast_mode does not close it at once + + +async def test_async_cancelled_caller_drops_its_unsent_item(): + calls = [] + + @batch_calls(max_wait=10) + async def load(ids: list[int]) -> list[int]: + calls.append(list(ids)) + return ids + + a = asyncio.create_task(load(1)) + b = asyncio.create_task(load(2)) + b2 = asyncio.create_task(load(2)) + await asyncio.sleep(0) + assert load.pending == 2 + b.cancel() + await asyncio.sleep(0) + assert load.pending == 2 # b2 still wants item 2 + b2.cancel() + await asyncio.sleep(0) + assert load.pending == 1 + await load.aflush() + assert await a == 1 + assert calls == [[1]] + with pytest.raises(asyncio.CancelledError): + await b + + +async def test_async_all_callers_cancelled_means_no_batch(): + calls = [] + + @batch_calls(max_wait=10) + async def load(ids: list[int]) -> list[int]: + calls.append(ids) + return ids + + task = asyncio.create_task(load(1)) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0) + assert calls == [] + assert load.pending == 0 + assert only_current_task() + + +async def test_async_cancelling_one_caller_after_dispatch_spares_the_others(fake_clock): + started = asyncio.Event() + release = asyncio.Event() + + @batch_calls() + async def load(ids: list[int]) -> list[int]: + started.set() + await release.wait() + raise ValueError("late failure") + + a = asyncio.create_task(load(1)) + b = asyncio.create_task(load(2)) + await started.wait() + a.cancel() + await asyncio.sleep(0) + release.set() + with pytest.raises(ValueError): + await b + with pytest.raises(asyncio.CancelledError): + await a + gc.collect() # an unretrieved exception would be reported here (filterwarnings=error) + + +async def test_async_aflush_sends_now_and_waits(): + @batch_calls(max_wait=10) + async def load(ids: list[int]) -> list[int]: + await asyncio.sleep(0) + return [i + 1 for i in ids] + + await load.aflush() # nothing pending on this loop: no-op + tasks = [asyncio.create_task(load(i)) for i in range(3)] + await asyncio.sleep(0) + assert load.pending == 3 + await load.aflush() + assert load.pending == 0 + assert all(t.done() for t in tasks) + assert [t.result() for t in tasks] == [1, 2, 3] + assert only_current_task() + + +async def test_async_aflush_on_a_fresh_loop_is_a_noop(): + @batch_calls() + async def load(ids: list[int]) -> list[int]: + return ids + + await load.aflush() + assert await load(1) == 1 + await load.aflush() # loop state exists, nothing queued or running + + +async def test_async_methods_batch_per_instance(fake_clock): + seen = [] + + class Repo: + def __init__(self, name): + self.name = name + + @batch_calls() + async def get(self, ids: list[int]) -> list[str]: + seen.append((self.name, list(ids))) + return [f"{self.name}{i}" for i in ids] + + @classmethod + @batch_calls() + async def cget(cls, ids: list[int]) -> list[str]: + return [f"{cls.__name__}{i}" for i in ids] + + a, b = Repo("a"), Repo("b") + results = await asyncio.gather(a.get(1), b.get(2), a.get(3), Repo.cget(4)) + assert results == ["a1", "b2", "a3", "Repo4"] + assert sorted(seen) == [("a", [1, 3]), ("b", [2])] + assert a.get.pending == 0 + + +async def test_async_base_exception_propagates_from_the_batch_task(fake_clock): + class Stop(BaseException): + pass + + @batch_calls() + async def load(ids: list[int]) -> list[int]: + raise Stop + + with pytest.raises(Stop): + await load(1) + await asyncio.sleep(0) + + +async def test_async_batch_task_cancelled_cancels_callers(): + started = asyncio.Event() + + @batch_calls(max_wait=10) + async def load(ids: list[int]) -> list[int]: + started.set() + await asyncio.Event().wait() + return ids + + caller = asyncio.create_task(load(1)) + await asyncio.sleep(0) + flush = asyncio.create_task(load.aflush()) + await started.wait() + for task in asyncio.all_tasks(): + if task not in (asyncio.current_task(), caller, flush): + task.cancel() # the batch task itself + with pytest.raises(asyncio.CancelledError): + await caller + await flush + + +def test_async_batches_are_per_event_loop(): + calls = [] + + @batch_calls(max_wait=0) + async def load(ids: list[int]) -> list[int]: + calls.append(list(ids)) + return ids + + async def main(base): + return await asyncio.gather(load(base), load(base + 1)) + + assert asyncio.run(main(1)) == [1, 2] + assert asyncio.run(main(10)) == [10, 11] + assert calls == [[1, 2], [10, 11]] + + +async def test_async_unhashable_item_needs_a_key(): + @batch_calls() + async def load(items: list[list[int]]) -> list[int]: + return [len(i) for i in items] + + with pytest.raises(TypeError, match="not hashable"): + await load([1, 2]) + + +# --------------------------------------------------------------------------- sync + + +def run_threads(n, target): + results = [None] * n + errors = [None] * n + + def worker(i): + try: + results[i] = target(i) + except BaseException as exc: + errors[i] = exc + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)] + for t in threads: + t.start() + for t in threads: + t.join(5) + assert not t.is_alive() + return results, errors + + +def test_sync_threads_fill_a_batch_and_share_one_call(): + calls = [] + before = threading.active_count() + + @batch_calls(max_size=4, max_wait=10) + def load(ids: list[int]) -> list[int]: + calls.append(sorted(ids)) + return [i * 2 for i in ids] + + results, errors = run_threads(4, load) + assert errors == [None] * 4 + assert results == [0, 2, 4, 6] + assert calls == [[0, 1, 2, 3]] + assert load.pending == 0 + assert threading.active_count() == before + + +def test_sync_window_closes_after_max_wait(): + calls = [] + + @batch_calls(max_wait=0.01) + def load(ids: list[int]) -> list[str]: + calls.append((list(ids), threading.current_thread())) + return [str(i) for i in ids] + + assert load(7) == "7" + assert calls == [([7], threading.current_thread())] # ran by the caller itself + + +def test_sync_flush_sends_now_from_the_calling_thread(): + ran_in = [] + + @batch_calls(max_wait=10) + def load(ids: list[int]) -> dict[int, int]: + ran_in.append(threading.current_thread()) + return {i: -i for i in ids} + + load.flush() # nothing pending: no-op + box = [] + worker = threading.Thread(target=lambda: box.append(load(5))) + worker.start() + wait_until(lambda: load.pending == 1) + load.flush() + worker.join(5) + assert box == [-5] + assert ran_in == [threading.current_thread()] + + +def test_sync_leader_timing_out_after_a_flush_started_waits_for_it(): + release = threading.Event() + calls = [] + + @batch_calls(max_wait=0.01) + def load(ids: list[int]) -> list[int]: + calls.append(list(ids)) + release.wait(5) + return ids + + box = [] + worker = threading.Thread(target=lambda: box.append(load(1))) + worker.start() + wait_until(lambda: load.pending == 1) + flusher = threading.Thread(target=load.flush) + flusher.start() + wait_until(lambda: calls) + time.sleep(0.03) # the worker's window expires while the flush is running + release.set() + worker.join(5) + flusher.join(5) + assert box == [1] + assert calls == [[1]] + + +def test_sync_duplicates_share_a_result(): + calls = [] + + @batch_calls(max_size=2, max_wait=10, key=str.lower) + def load(names: list[str]) -> dict[str, int]: + calls.append(list(names)) + return {n.lower(): len(calls) for n in names} + + results, _ = run_threads(3, lambda i: load(["A", "a", "b"][i])) + assert results == [1, 1, 1] + assert len(calls) == 1 + assert sorted(n.lower() for n in calls[0]) == ["a", "b"] + + +def test_sync_exception_reaches_every_caller(): + @batch_calls(max_size=3, max_wait=10) + def load(ids: list[int]) -> list[int]: + raise RuntimeError("db down") + + _, errors = run_threads(3, load) + assert all(isinstance(e, RuntimeError) for e in errors) + + +def test_sync_contract_violations(): + @batch_calls(max_size=2, max_wait=10) + def short(ids: list[int]) -> list[int]: + return [] + + _, errors = run_threads(2, short) + assert all(isinstance(e, BatchError) for e in errors) + + @batch_calls(max_size=2, max_wait=10) + def partial(ids: list[int]) -> dict[int, int]: + return {0: 0} + + results, errors = run_threads(2, partial) + assert results[0] == 0 and errors[0] is None + assert isinstance(errors[1], BatchError) + + @batch_calls(max_size=1) + def text(ids: list[int]) -> str: + return "x" + + with pytest.raises(BatchError, match="got str"): + text(1) + + +def test_sync_base_exception_is_raised_in_the_running_thread_too(): + class Stop(BaseException): + pass + + @batch_calls(max_size=1) + def load(ids: list[int]) -> list[int]: + raise Stop + + with pytest.raises(Stop): + load(1) + + +def test_sync_methods_batch_per_instance(): + seen = [] + + class Repo: + def __init__(self, name): + self.name = name + + @batch_calls(max_size=1) + def get(self, ids: list[int]) -> list[str]: + seen.append(self.name) + return [f"{self.name}{i}" for i in ids] + + @classmethod + @batch_calls(max_size=1) + def cget(cls, ids: list[int]) -> list[str]: + return [cls.__name__ for _ in ids] + + @staticmethod + @batch_calls(max_size=1) + def sget(ids: list[int]) -> list[int]: + return ids + + assert Repo("a").get(1) == "a1" + assert Repo("b").get(2) == "b2" + assert Repo.cget(0) == "Repo" + assert Repo("c").cget(0) == "Repo" + assert Repo.sget(3) == 3 + assert Repo("d").sget(4) == 4 + assert seen == ["a", "b"] + assert Repo("e").get.pending == 0 + + +def test_sync_call_needs_exactly_one_item(): + @batch_calls() + def load(ids: list[int]) -> list[int]: + return ids + + with pytest.raises(TypeError, match="one item"): + load() + with pytest.raises(TypeError): + load(item=1) + with pytest.raises(TypeError, match="not hashable"): + load([1]) + + +# --------------------------------------------------------------------------- 0.2 perf + + +async def test_async_max_size_one_runs_in_the_calling_task(): + seen = [] + + @batch_calls(max_size=1, max_wait=10) + async def load(items): + seen.append((items, asyncio.current_task())) + return [i * 2 for i in items] + + assert await load(3) == 6 + assert seen == [([3], asyncio.current_task())] # no extra task, no timer + assert only_current_task() + assert load.pending == 0 + + +async def test_async_inline_batch_errors_and_contract(): + @batch_calls(max_size=1) + async def boom(items): + raise KeyError("x") + + with pytest.raises(KeyError): + await boom(1) + + @batch_calls(max_size=1) + async def wrong(items): + return [] + + with pytest.raises(BatchError): + await wrong(1) + + +async def test_async_inline_batch_caller_cancel_and_aflush(): + started = asyncio.Event() + release = asyncio.Event() + + @batch_calls(max_size=1) + async def load(items): + started.set() + await release.wait() + return items + + caller = asyncio.create_task(load(1)) + await started.wait() + flusher = asyncio.create_task(load.aflush()) + await asyncio.sleep(0) + assert not flusher.done() # aflush waits for the batch run in the caller's task + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + await asyncio.wait_for(flusher, 1) + release.set() + assert await load(2) == 2 + + +async def test_async_max_size_reached_by_follower_still_shares_one_task(fake_clock): + calls = [] + + @batch_calls(max_size=2, max_wait=0.01) + async def load(items): + calls.append(items) + return items + + assert await asyncio.gather(load(1), load(2), load(1)) == [1, 2, 1] + assert calls == [[1, 2], [1]] # the full batch closed; the late duplicate opens a new one + + +async def test_sync_loader_in_event_loop_warns_once(): + @batch_calls(max_size=1) + def load(items): + return items + + with pytest.warns(RuntimeWarning, match="blocks the loop"): + assert load(1) == 1 + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert load(2) == 2 # only once per loader diff --git a/tests/test_benchmarks_smoke.py b/tests/test_benchmarks_smoke.py new file mode 100644 index 0000000..9c978fb --- /dev/null +++ b/tests/test_benchmarks_smoke.py @@ -0,0 +1,47 @@ +"""Keep ``benchmarks/run.py`` from rotting: run a tiny slice of it in a subprocess.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "benchmarks" / "run.py" + + +def test_benchmark_script_runs_quickly() -> None: + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--quick", "--filter", "memoize", "--iterations", "200"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert proc.returncode == 0, proc.stderr + assert "| memoize (hit) |" in proc.stdout + assert "| bare call |" in proc.stdout + + +def test_benchmark_json_output() -> None: + proc = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--quick", + "--filter", + "once", + "--iterations", + "100", + "--json", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert proc.returncode == 0, proc.stderr + data = json.loads(proc.stdout) + names = [r["name"] for r in data["results"]] + assert names == ["bare call", "once (hit)"] + assert data["results"][1]["sync_ns"] > 0 diff --git a/tests/test_bulkhead.py b/tests/test_bulkhead.py new file mode 100644 index 0000000..aa838eb --- /dev/null +++ b/tests/test_bulkhead.py @@ -0,0 +1,435 @@ +"""Tests for python_wrap.bulkhead.""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +from typing import Any + +import pytest + +from python_wrap import _core +from python_wrap.bulkhead import Bulkhead, limit_concurrency +from python_wrap.errors import BulkheadFull, PythonWrapError +from python_wrap.testing import fast_mode + + +def other_tasks() -> set[asyncio.Task[Any]]: + return {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + + +async def spin(n: int = 5) -> None: + for _ in range(n): + await asyncio.sleep(0) + + +def wait_until(predicate: Any, timeout: float = 2.0) -> None: + done = threading.Event() + deadline = 0.0 + while not predicate(): + if deadline > timeout: + raise AssertionError("condition not reached") + done.wait(0.001) + deadline += 0.001 + + +# --------------------------------------------------------------------------------------- +# construction +# --------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_concurrent": 0}, + {"max_concurrent": True}, + {"max_concurrent": 1.5}, + {"max_concurrent": 1, "max_waiting": -1}, + {"max_concurrent": 1, "timeout": -1}, + ], +) +def test_invalid_arguments(kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError): + Bulkhead(**kwargs) + + +def test_repr_and_error_details() -> None: + bh = Bulkhead(1, max_waiting=0, name="db") + assert repr(bh) == "Bulkhead(name='db', max_concurrent=1, active=0, waiting=0)" + with bh, pytest.raises(BulkheadFull, match="'db' is full") as info: + bh.acquire() + assert info.value.name == "db" + assert info.value.max_concurrent == 1 + assert isinstance(info.value, PythonWrapError) + with Bulkhead(1, max_waiting=0) as anon, pytest.raises(BulkheadFull, match=r"^bulkhead is"): + anon.acquire() + + +def test_release_without_acquire_raises() -> None: + bh = Bulkhead(1) + with pytest.raises(ValueError): + bh.release() + assert bh.active == 0 + + +# --------------------------------------------------------------------------------------- +# sync +# --------------------------------------------------------------------------------------- + + +def test_sync_caps_concurrency_and_queues() -> None: + bh = Bulkhead(2) + gate = threading.Event() + peak = 0 + lock = threading.Lock() + + @bh + def work(i: int) -> int: + nonlocal peak + with lock: + peak = max(peak, bh.active) + gate.wait(2) + return i + + results: list[int] = [] + threads = [threading.Thread(target=lambda i=i: results.append(work(i))) for i in range(4)] + for t in threads: + t.start() + wait_until(lambda: bh.active == 2 and bh.waiting == 2) + gate.set() + for t in threads: + t.join(2) + assert sorted(results) == [0, 1, 2, 3] + assert peak == 2 + assert (bh.active, bh.waiting) == (0, 0) + + +def test_sync_max_waiting_rejects_immediately() -> None: + bh = Bulkhead(1, max_waiting=1) + gate = threading.Event() + with bh: + waiter = threading.Thread(target=lambda: bh(gate.wait)(2)) + waiter.start() + wait_until(lambda: bh.waiting == 1) + with pytest.raises(BulkheadFull, match="max_waiting=1"): + bh.acquire() + gate.set() + waiter.join(2) + assert (bh.active, bh.waiting) == (0, 0) + + +def test_sync_timeout() -> None: + bh = Bulkhead(1, timeout=0.01) + with bh, pytest.raises(BulkheadFull, match=r"within 0\.01s"): + bh.acquire() + assert (bh.active, bh.waiting) == (0, 0) + with bh: # the slot is usable again + assert bh.active == 1 + + +def test_sync_timeout_acquires_when_slot_frees() -> None: + bh = Bulkhead(1, timeout=5) + bh.acquire() + got = threading.Event() + + def take() -> None: + with bh: + got.set() + + t = threading.Thread(target=take) + t.start() + wait_until(lambda: bh.waiting == 1) + bh.release() + t.join(2) + assert got.is_set() + + +def test_sync_exception_releases_slot() -> None: + bh = Bulkhead(1) + + @bh + def boom() -> None: + raise KeyError("x") + + for _ in range(3): + with pytest.raises(KeyError): + boom() + assert bh.active == 0 + + +def test_decorator_on_methods_keeps_metadata() -> None: + bh = Bulkhead(1) + + class Repo: + @bh + def get(self, key: str) -> str: + """Docs.""" + return key.upper() + + @bh + async def aget(self, key: str) -> str: + return key + + assert Repo().get("a") == "A" + assert Repo.get.__doc__ == "Docs." + assert inspect.iscoroutinefunction(Repo.aget) + assert asyncio.run(Repo().aget("b")) == "b" + + +# --------------------------------------------------------------------------------------- +# async +# --------------------------------------------------------------------------------------- + + +async def test_async_caps_concurrency() -> None: + bh = Bulkhead(2) + gate = asyncio.Event() + peak = 0 + + @bh + async def work(i: int) -> int: + nonlocal peak + peak = max(peak, bh.active) + await gate.wait() + return i + + assert inspect.iscoroutinefunction(work) + job = asyncio.gather(*(work(i) for i in range(5))) + await spin() + assert (bh.active, bh.waiting) == (2, 3) + gate.set() + assert await job == [0, 1, 2, 3, 4] + assert peak == 2 + assert (bh.active, bh.waiting) == (0, 0) + + +async def test_async_timeout_is_not_instant_under_fast_mode() -> None: + bh = Bulkhead(1, timeout=5) + + @bh + async def work() -> int: + await asyncio.sleep(0.02) # real I/O, well inside the timeout + return 1 + + with fast_mode(): + assert await asyncio.gather(work(), work()) == [1, 1] + assert (bh.active, bh.waiting) == (0, 0) + + +async def test_async_max_waiting_zero_fails_fast() -> None: + bh = Bulkhead(1, max_waiting=0) + async with bh: + with pytest.raises(BulkheadFull): + await bh.acquire_async() + assert (bh.active, bh.waiting) == (0, 0) + + +async def test_async_timeout_is_real_time(fake_clock: Any) -> None: + bh = Bulkhead(1, timeout=0.01) + async with bh: + with pytest.raises(BulkheadFull, match=r"within 0\.01s"): + await bh.acquire_async() + assert fake_clock.sleeps == [] # a deadline, not a backoff: the fake clock is not used + assert (bh.active, bh.waiting) == (0, 0) + async with bh: + pass + await spin() + assert other_tasks() == set() + + +async def test_async_timeout_acquires_when_slot_frees() -> None: + bh = Bulkhead(1, timeout=60) + await bh.acquire_async() + job = asyncio.ensure_future(bh.acquire_async()) + await spin() + assert bh.waiting == 1 + bh.release_async() + await job + assert (bh.active, bh.waiting) == (1, 0) + bh.release_async() + await spin() + assert other_tasks() == set() + + +@pytest.mark.parametrize("timeout", [None, 60]) +async def test_async_cancel_while_waiting_does_not_leak(timeout: float | None) -> None: + bh = Bulkhead(1, timeout=timeout) + await bh.acquire_async() + job = asyncio.ensure_future(bh.acquire_async()) + await spin() + job.cancel() + with pytest.raises(asyncio.CancelledError): + await job + assert bh.waiting == 0 + bh.release_async() + async with bh: # the permit was not lost + assert bh.active == 1 + await spin() + assert other_tasks() == set() + + +async def test_async_cancel_racing_with_release_returns_permit() -> None: + """The slot frees up in the same step the waiter is cancelled: no permit is lost.""" + bh = Bulkhead(1, timeout=60) + await bh.acquire_async() + job = asyncio.ensure_future(bh.acquire_async()) + await spin() + bh.release_async() + job.cancel() + with pytest.raises(asyncio.CancelledError): + await job + assert (bh.active, bh.waiting) == (0, 0) + async with bh: + pass + async with bh: + pass + + +async def test_async_exception_releases_slot() -> None: + bh = Bulkhead(1) + + @bh + async def boom() -> None: + raise KeyError("x") + + for _ in range(3): + with pytest.raises(KeyError): + await boom() + assert bh.active == 0 + + +def test_async_works_across_event_loops() -> None: + bh = Bulkhead(1) + + @bh + async def work() -> int: + await asyncio.sleep(0) + return bh.active + + assert asyncio.run(work()) == 1 + assert asyncio.run(work()) == 1 # a new loop gets its own semaphore + + +def test_sync_and_async_sides_are_separate() -> None: + bh = Bulkhead(1, max_waiting=0) + + async def inside() -> int: + async with bh: + return bh.active + + with bh: + assert asyncio.run(inside()) == 2 + + +# --------------------------------------------------------------------------------------- +# limit_concurrency +# --------------------------------------------------------------------------------------- + + +async def test_limit_concurrency_async() -> None: + gate = asyncio.Event() + + @limit_concurrency(2) + async def fetch(i: int) -> int: + await gate.wait() + return i + + assert inspect.iscoroutinefunction(fetch) + assert fetch.bulkhead.max_concurrent == 2 + assert fetch.bulkhead.name is not None and fetch.bulkhead.name.endswith("fetch") + job = asyncio.gather(*(fetch(i) for i in range(4))) + await spin() + assert (fetch.bulkhead.active, fetch.bulkhead.waiting) == (2, 2) + gate.set() + assert await job == [0, 1, 2, 3] + + +def test_limit_concurrency_sync_and_per_function() -> None: + @limit_concurrency(1) + def a() -> int: + return a.bulkhead.active + + @limit_concurrency(1) + def b() -> int: + return b.bulkhead.active + + assert a.bulkhead is not b.bulkhead + assert a() == 1 and b() == 1 + + +def test_limit_concurrency_on_method() -> None: + class Client: + @limit_concurrency(3) + def call(self, x: int) -> int: + return x + 1 + + assert Client().call(1) == 2 + assert Client().call.bulkhead is Client.call.bulkhead + + +def test_limit_concurrency_validates() -> None: + with pytest.raises(ValueError): + limit_concurrency(0) + + +# ------------------------------------------------------------------ events and classmethods + + +@pytest.fixture +def events() -> Any: + seen: list[_core.Event] = [] + _core.listeners.append(seen.append) + try: + yield seen + finally: + _core.listeners.remove(seen.append) + + +def test_rejection_emits_event_with_counts(events: list[_core.Event]) -> None: + bh = Bulkhead(1, max_waiting=0, name="db") + reads: list[int] = [] + + def listener_reads_bulkhead(event: _core.Event) -> None: + reads.append(bh.active) # the lock is not held while listeners run + + _core.listeners.append(listener_reads_bulkhead) + try: + with bh, pytest.raises(BulkheadFull): + bh.acquire() + finally: + _core.listeners.remove(listener_reads_bulkhead) + assert reads == [1] + assert [(e.name, e.function, e.fields) for e in events] == [ + ("bulkhead.rejected", "db", {"active": 1, "waiting": 0}) + ] + + +def test_timeout_rejection_emits_event(events: list[_core.Event]) -> None: + bh = Bulkhead(1, timeout=0.01) + with bh, pytest.raises(BulkheadFull): + bh.acquire() + assert [(e.name, e.function, e.fields) for e in events] == [ + ("bulkhead.rejected", "bulkhead", {"active": 1, "waiting": 0}) + ] + + +async def test_async_timeout_rejection_emits_event(events: list[_core.Event]) -> None: + bh = Bulkhead(1, timeout=0.01, name="api") + async with bh: + with pytest.raises(BulkheadFull): + await bh.acquire_async() + assert [e.name for e in events] == ["bulkhead.rejected"] + assert events[0].fields["active"] == 1 + + +def test_limit_concurrency_under_classmethod() -> None: + class Pool: + @classmethod + @limit_concurrency(1) + def make(cls, x: int) -> tuple[type, int]: + return cls, x + + assert Pool.make(2) == (Pool, 2) + assert Pool().make(3) == (Pool, 3) + assert Pool.make.bulkhead.max_concurrent == 1 diff --git a/tests/test_caching.py b/tests/test_caching.py new file mode 100644 index 0000000..3daf4a7 --- /dev/null +++ b/tests/test_caching.py @@ -0,0 +1,1415 @@ +from __future__ import annotations + +import asyncio +import copy +import datetime +import inspect +import pickle +import threading +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.caching import CacheInfo, cache, cached_property, invalidate, memoize, once +from python_wrap.testing import overrides + + +def test_bare_and_call_forms() -> None: + calls: list[int] = [] + + def f(x: int) -> int: + calls.append(x) + return x * 2 + + g = memoize(f) + assert g(2) == 4 and g(2) == 4 + assert calls == [2] + assert g.__name__ == "f" + + @memoize + def h(x: int) -> int: + return x + + assert h(1) == 1 + + +def test_key_normalizes_defaults_and_kwargs() -> None: + calls = [] + + @memoize + def f(x: int, y: int = 5) -> int: + calls.append((x, y)) + return x + y + + assert f(1) == f(x=1) == f(1, 5) == f(1, y=5) == 6 + assert len(calls) == 1 + assert f.cache_info() == CacheInfo(hits=3, misses=1, maxsize=None, currsize=1) + + +def test_unhashable_builtins_are_converted() -> None: + calls = [] + + @memoize + def f(*args: Any, **kwargs: Any) -> int: + calls.append(1) + return len(calls) + + assert f([1, [2]], {"a": [1]}, {1, 2}) == f([1, [2]], {"a": [1]}, {2, 1}) + assert len(calls) == 1 + assert f([1]) != f((1,)) # list and tuple do not collide + assert f({"a": 1, "b": 2}) == f({"b": 2, "a": 1}) + assert f((1, [2])) == f((1, [2])) + + +def test_unhashable_other_raises_type_error() -> None: + class Bad: + __hash__ = None # type: ignore[assignment] + + @memoize + def f(x: object) -> None: ... + + with pytest.raises(TypeError, match="key="): + f(Bad()) + + +def test_custom_key() -> None: + calls = [] + + @memoize(key=lambda a, b: a) + def f(a: int, b: int) -> int: + calls.append(1) + return b + + assert f(1, 10) == 10 + assert f(1, 20) == 10 + assert f(2, 20) == 20 + + +def test_typed() -> None: + @memoize(typed=True) + def f(x: Any) -> str: + return type(x).__name__ + + assert f(1) == "int" + assert f(1.0) == "float" + + @memoize + def g(x: Any) -> str: + return type(x).__name__ + + assert g(1) == "int" + assert g(1.0) == "int" + + +def test_lru_eviction() -> None: + calls = [] + + @memoize(maxsize=2) + def f(x: int) -> int: + calls.append(x) + return x + + f(1) + f(2) + f(1) # 1 is now most recent + f(3) # evicts 2 + assert f.cache_info().currsize == 2 + f(1) + assert calls == [1, 2, 3] + f(2) + assert calls == [1, 2, 3, 2] + + +def test_ttl(fake_clock: FakeClock) -> None: + calls = [] + + @memoize(ttl=10) + def f(x: int) -> int: + calls.append(x) + return x + + f(1) + fake_clock.advance(9) + f(1) + assert calls == [1] + fake_clock.advance(1) + f(1) + assert calls == [1, 1] + fake_clock.advance(20) + assert f.cache_info().currsize == 0 + + +def test_ttl_timedelta(fake_clock: FakeClock) -> None: + calls = [] + + @memoize(ttl=datetime.timedelta(seconds=5)) + def f() -> int: + calls.append(1) + return 1 + + f() + fake_clock.advance(6) + f() + assert len(calls) == 2 + + +def test_invalid_options() -> None: + with pytest.raises(ValueError): + memoize(maxsize=0) + with pytest.raises(ValueError): + memoize(ttl=0) + + +def test_exceptions_not_cached() -> None: + calls = [] + + @memoize + def f() -> int: + calls.append(1) + if len(calls) == 1: + raise RuntimeError + return 7 + + with pytest.raises(RuntimeError): + f() + assert f() == 7 + assert f() == 7 + assert len(calls) == 2 + + +def test_cache_clear_and_delete() -> None: + calls = [] + + @memoize + def f(x: int, y: int = 0) -> int: + calls.append(x) + return x + + f(1) + f(2) + assert f.cache_delete(1) is True + assert f.cache_delete(x=1, y=0) is False + f(1) + assert calls == [1, 2, 1] + f.cache_clear() + assert f.cache_info() == CacheInfo(0, 0, None, 0) + + +def test_methods_are_per_instance() -> None: + class A: + def __init__(self, n: int) -> None: + self.n = n + + @memoize + def get(self, x: int) -> int: + return self.n + x + + a, b = A(1), A(10) + assert a.get(1) == 2 + assert b.get(1) == 11 + assert a.get(1) == 2 + assert A.get.cache_info().hits == 1 + + +def test_thread_safety() -> None: + calls = [] + + @memoize + def f(x: int) -> int: + calls.append(x) + return x * x + + def work() -> None: + for i in range(50): + assert f(i) == i * i + + threads = [threading.Thread(target=work) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert f.cache_info().currsize == 50 + + +def test_cache_alias_and_independent_stores(fake_clock: FakeClock) -> None: + deco = cache(10) + calls = [] + + @deco + def a(x: int) -> int: + calls.append("a") + return x + + @deco + def b(x: int) -> int: + calls.append("b") + return x + + a(1) + b(1) + a(1) + assert calls == ["a", "b"] + fake_clock.advance(11) + a(1) + assert calls == ["a", "b", "a"] + + @cache(datetime.timedelta(seconds=1), maxsize=1) + def c(x: int) -> int: + return x + + c(1) + c(2) + assert c.cache_info().currsize == 1 + + +async def test_async_memoize_and_concurrent_sharing() -> None: + calls = [] + gate = asyncio.Event() + + @memoize + async def f(x: int) -> int: + calls.append(x) + await gate.wait() + return x + 1 + + assert inspect.iscoroutinefunction(f) + tasks = [asyncio.ensure_future(f(1)) for _ in range(5)] + await asyncio.sleep(0) + gate.set() + assert await asyncio.gather(*tasks) == [2] * 5 + assert calls == [1] + assert await f(x=1) == 2 + assert calls == [1] + assert f.cache_info().misses == 1 + + +async def test_async_exception_not_cached_and_cancel_isolated() -> None: + calls = [] + + @memoize + async def f() -> int: + calls.append(1) + await asyncio.sleep(0) + if len(calls) == 1: + raise RuntimeError + return 3 + + with pytest.raises(RuntimeError): + await f() + assert await f() == 3 + + gate = asyncio.Event() + + @memoize + async def g() -> int: + await gate.wait() + return 9 + + t1 = asyncio.ensure_future(g()) + t2 = asyncio.ensure_future(g()) + await asyncio.sleep(0) + t1.cancel() + await asyncio.sleep(0) + gate.set() + assert await t2 == 9 + + +async def test_async_cache_clear_during_flight() -> None: + gate = asyncio.Event() + + @memoize + async def f() -> int: + await gate.wait() + return 1 + + t = asyncio.ensure_future(f()) + await asyncio.sleep(0) + f.cache_clear() + gate.set() + assert await t == 1 + assert f.cache_info().currsize == 0 + + +# ------------------------------------------------------------------------------ once + + +def test_once_sync() -> None: + calls = [] + + @once + def init(x: int = 0) -> list[int]: + calls.append(x) + return [x] + + assert init.called is False + first = init(1) + assert init(2) is first + assert calls == [1] + assert init.called is True + init.reset() + assert init.called is False + assert init(3) == [3] + assert once()(lambda: 5)() == 5 + + +def test_once_failure_retries() -> None: + calls = [] + + @once + def f() -> int: + calls.append(1) + if len(calls) == 1: + raise RuntimeError + return 4 + + with pytest.raises(RuntimeError): + f() + assert f.called is False + assert f() == 4 + assert f() == 4 + assert len(calls) == 2 + + +def test_once_threads() -> None: + calls = [] + barrier = threading.Barrier(8) + + @once + def f() -> int: + calls.append(1) + return 1 + + def work() -> None: + barrier.wait() + f() + + threads = [threading.Thread(target=work) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(calls) == 1 + + +async def test_once_async() -> None: + calls = [] + gate = asyncio.Event() + + @once + async def f() -> int: + calls.append(1) + await gate.wait() + return 8 + + assert inspect.iscoroutinefunction(f) + tasks = [asyncio.ensure_future(f()) for _ in range(4)] + await asyncio.sleep(0) + gate.set() + assert await asyncio.gather(*tasks) == [8] * 4 + assert await f() == 8 + assert calls == [1] + assert f.called is True + f.reset() + assert f.called is False + assert await f() == 8 + assert calls == [1, 1] + + +async def test_once_async_failure_retries() -> None: + calls = [] + + @once + async def f() -> int: + calls.append(1) + await asyncio.sleep(0) + if len(calls) == 1: + raise ValueError + return 2 + + with pytest.raises(ValueError): + await f() + assert f.called is False + assert await f() == 2 + + +# ------------------------------------------------------------- review regressions + + +def _run_in_threads(make_coro: Any, count: int = 2) -> list[Any]: + out: list[Any] = [] + + def worker() -> None: + try: + out.append(asyncio.run(make_coro())) + except Exception as exc: # pragma: no cover - only on regression + out.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(count)] + for t in threads: + t.start() + for t in threads: + t.join() + return out + + +def test_async_memoize_concurrent_across_event_loops() -> None: + @memoize + async def slow(x: int) -> int: + await asyncio.sleep(0.03) + return x + + assert _run_in_threads(lambda: slow(1)) == [1, 1] + + +def test_async_once_concurrent_across_event_loops() -> None: + @once + async def init() -> int: + await asyncio.sleep(0.03) + return 7 + + assert _run_in_threads(init) == [7, 7] + + +def test_memoize_ttl_without_maxsize_sweeps_expired_entries(fake_clock: FakeClock) -> None: + @memoize(ttl=1) + def f(x: int) -> int: + return x + + for i in range(1000): + f(i) + fake_clock.advance(2) + sweep = next( + c.cell_contents + for c in f.cache_info.__closure__ # type: ignore[attr-defined] + if getattr(c.cell_contents, "__name__", "") == "sweep" + ) + store = next( + c.cell_contents for c in sweep.__closure__ if hasattr(c.cell_contents, "move_to_end") + ) + assert len(store) <= 130 + + +def test_memoize_without_signature_keeps_keyword_names_in_the_key() -> None: + class NoSignature: + __signature__ = "not a signature" # inspect.signature() raises TypeError + + def __call__(self, *args: Any, **kwargs: Any) -> tuple[Any, ...]: + return (args, kwargs) + + m = memoize(NoSignature()) + assert m(1, b=2) == ((1,), {"b": 2}) + assert m(1, 2) == ((1, 2), {}) # not the cached f(1, b=2) + assert m(1, c=2) == ((1,), {"c": 2}) + assert m(1, b=2) == ((1,), {"b": 2}) + assert m.cache_info().hits == 1 + + +def test_memoized_bound_method_keeps_parameter_types() -> None: + class C: + @memoize + def m(self, x: int) -> int: + return x + + assert C().m(2) == 2 + # mypy (warn_unused_ignores) fails if bound methods lose their parameter types + assert C().m("wrong") == "wrong" # type: ignore[arg-type] + + +def test_async_memoize_failure_after_all_callers_cancelled_is_retrieved( + caplog: pytest.LogCaptureFixture, +) -> None: + import gc + + @memoize + async def boom() -> None: + await asyncio.sleep(0.02) + raise ValueError("down") + + handler_calls: list[dict[str, Any]] = [] + + async def main() -> None: + asyncio.get_running_loop().set_exception_handler(lambda _l, ctx: handler_calls.append(ctx)) + t = asyncio.create_task(boom()) + await asyncio.sleep(0.005) + t.cancel() + await asyncio.sleep(0.05) + gc.collect() + + asyncio.run(main()) + assert handler_calls == [] + + +def test_memoize_dataclass_method_error_names_self() -> None: + from dataclasses import dataclass + + @dataclass + class Repo: + name: str + + @memoize + def size(self) -> int: + return len(self.name) + + with pytest.raises(TypeError, match=r"argument 'self'.*key=.*cached_property"): + Repo("x").size() + + +def test_async_once_reset_in_flight_does_not_run_twice() -> None: + calls = 0 + + @once + async def init() -> int: + nonlocal calls + calls += 1 + n = calls + await asyncio.sleep(0.02) + if n == 1: + raise ValueError("old run fails") + return n + + async def main() -> list[int]: + first = asyncio.create_task(init()) + await asyncio.sleep(0.005) + init.reset() + second = asyncio.create_task(init()) + with pytest.raises(ValueError): + await first # old run ends first and must not clear the new run's task + third = asyncio.create_task(init()) + return [await second, await third] + + assert asyncio.run(main()) == [2, 2] + assert calls == 2 + + +# ------------------------------------------------------- memoize: new options (0.2) + + +def test_cache_exceptions_cached_and_reraised_same_instance(fake_clock: FakeClock) -> None: + calls = [] + + @memoize(cache_exceptions=ValueError, exception_ttl=5) + def f(x: int) -> int: + calls.append(x) + raise ValueError(x) + + with pytest.raises(ValueError) as first: + f(1) + with pytest.raises(ValueError) as second: + f(1) + assert first.value is second.value + assert calls == [1] + assert f.cache_contains(1) + with pytest.raises(ValueError): + f.cache_get(1) + fake_clock.advance(6) + assert not f.cache_contains(1) + with pytest.raises(ValueError): + f(1) + assert calls == [1, 1] + + +def test_cache_exceptions_only_matching_and_defaults_to_ttl(fake_clock: FakeClock) -> None: + calls = [] + + @memoize(ttl=10, cache_exceptions=(KeyError, ValueError)) + def f(kind: str) -> None: + calls.append(kind) + raise {"key": KeyError, "type": TypeError}[kind]() + + with pytest.raises(KeyError): + f("key") + with pytest.raises(KeyError): + f("key") + for _ in range(2): + with pytest.raises(TypeError): + f("type") + assert calls == ["key", "type", "type"] + fake_clock.advance(11) + with pytest.raises(KeyError): + f("key") + assert calls == ["key", "type", "type", "key"] + + +def test_cache_exceptions_validation() -> None: + with pytest.raises(TypeError): + memoize(cache_exceptions=int) # type: ignore[arg-type] + with pytest.raises(ValueError): + memoize(ttl=lambda r: 1.0, cache_exceptions=ValueError) + with pytest.raises(ValueError): + memoize(exception_ttl=0, cache_exceptions=ValueError) + # allowed: explicit exception_ttl + memoize(ttl=lambda r: 1.0, cache_exceptions=ValueError, exception_ttl=1) + + +async def test_async_cache_exceptions_shared_and_cached() -> None: + calls = [] + + @memoize(cache_exceptions=RuntimeError) + async def f() -> int: + calls.append(1) + await asyncio.sleep(0) + raise RuntimeError("boom") + + results = await asyncio.gather(f(), f(), return_exceptions=True) + assert results[0] is results[1] + with pytest.raises(RuntimeError): + await f() + assert calls == [1] + + +async def test_async_cancelled_error_never_cached() -> None: + calls = [] + + @memoize(cache_exceptions=BaseException) + async def f() -> int: + calls.append(1) + if len(calls) == 1: + raise asyncio.CancelledError + return 5 + + with pytest.raises(asyncio.CancelledError): + await f() + assert await f() == 5 + + +def test_per_item_ttl(fake_clock: FakeClock) -> None: + @memoize(ttl=lambda r: None if r == "forever" else (0 if r == "skip" else r)) + def f(x: Any) -> Any: + return x + + f(3) + f("forever") + f("skip") + assert f.cache_info().currsize == 2 + assert f.cache_contains(3) and not f.cache_contains("skip") + fake_clock.advance(4) + assert not f.cache_contains(3) + assert f.cache_contains("forever") + assert callable(f.cache_parameters()["ttl"]) + + +def test_cache_contains_get_do_not_count_or_compute() -> None: + calls = [] + + @memoize(maxsize=2) + def f(x: Any, y: int = 0) -> Any: + calls.append(x) + return x * 10 + + assert not f.cache_contains(1) + with pytest.raises(KeyError): + f.cache_get(1) + f(1) + f(2) + assert f.cache_contains(1, y=0) and f.cache_get(x=1) == 10 + f(3) # evicts 1: cache_get above did not refresh its LRU position + assert not f.cache_contains(1) + assert f.cache_info().hits == 0 and f.cache_info().misses == 3 + assert f.cache_contains([1]) is False # unhashable list args are frozen, not errors + + +def test_cache_parameters() -> None: + @memoize(maxsize=3, ttl=datetime.timedelta(seconds=2), typed=True) + def f(x: int) -> int: + return x + + assert f.cache_parameters() == { + "maxsize": 3, + "ttl": 2.0, + "typed": True, + "cache_exceptions": (), + "exception_ttl": 2.0, + } + + +def test_fast_key_path_matches_general_path() -> None: + calls = [] + + @memoize + def f(a: Any, b: Any) -> int: + calls.append(1) + return 1 + + f(1, 2) + f(a=1, b=2) + f(1, b=2) + f((1, 2), 3) + f((1, 2), 3) + f([1], 2) # unhashable positional: falls back to freezing + f([1], 2) + assert len(calls) == 3 + with pytest.raises(TypeError, match="not hashable"): + f(object.__new__(type("U", (), {"__hash__": None})), 1) + + +def test_disable_caching_memoize_cache_once() -> None: + calls = [] + + @memoize + def m(x: int) -> int: + calls.append(x) + return x + + @cache(60) + def c(x: int) -> int: + calls.append(x) + return x + + @once + def o() -> int: + calls.append(0) + return 1 + + m(1) + with overrides(disable_caching=True): + m(1) + m(1) + c(1) + c(1) + o() + o() + assert m.cache_info().currsize == 1 # nothing written + assert calls == [1, 1, 1, 1, 1, 0, 0] + m(1) + assert calls == [1, 1, 1, 1, 1, 0, 0] # earlier entry still intact + assert o() == 1 and o() == 1 + assert calls.count(0) == 3 + + +async def test_disable_caching_async() -> None: + calls = [] + + @memoize + async def m(x: int) -> int: + calls.append(x) + return x + + @once + async def o() -> int: + calls.append(0) + return 1 + + with overrides(disable_caching=True): + await m(1) + await m(1) + await o() + await o() + assert calls == [1, 1, 0, 0] + assert m.cache_info().currsize == 0 + + +def _collect() -> tuple[list[_core.Event], Callable[[_core.Event], None]]: + seen: list[_core.Event] = [] + _core.listeners.append(seen.append) + return seen, seen.append + + +def test_events_hit_miss_evicted(fake_clock: FakeClock) -> None: + seen, listener = _collect() + try: + + @memoize(maxsize=1, ttl=5) + def f(x: int) -> int: + return x + + f(1) + f(1) + f(2) # evicts 1 (lru) + fake_clock.advance(6) + f(2) # expired entry removed on lookup (ttl) + finally: + _core.listeners.remove(listener) + names = [(e.name, e.fields) for e in seen] + assert names == [ + ("cache.miss", {}), + ("cache.hit", {}), + ("cache.miss", {}), + ("cache.evicted", {"reason": "lru"}), + ("cache.evicted", {"reason": "ttl"}), + ("cache.miss", {}), + ] + assert all(e.function.endswith("f") for e in seen) + + +async def test_events_async() -> None: + seen, listener = _collect() + try: + + @memoize + async def f(x: int) -> int: + await asyncio.sleep(0) + return x + + await asyncio.gather(f(1), f(1)) + await f(1) + finally: + _core.listeners.remove(listener) + assert [e.name for e in seen] == ["cache.miss", "cache.hit", "cache.hit"] + + +def test_events_ttl_eviction_from_cache_info(fake_clock: FakeClock) -> None: + @memoize(ttl=1) + def f(x: int) -> int: + return x + + f(1) + fake_clock.advance(2) + seen, listener = _collect() + try: + assert f.cache_info().currsize == 0 + finally: + _core.listeners.remove(listener) + assert [(e.name, e.fields) for e in seen] == [("cache.evicted", {"reason": "ttl"})] + + +# ------------------------------------------------------------------ cached_property + + +def test_cached_property_basic_and_class_access() -> None: + calls = [] + + class A: + @cached_property + def v(self) -> int: + """doc""" + calls.append(1) + return 42 + + a, b = A(), A() + assert a.v == 42 and a.v == 42 and b.v == 42 + assert len(calls) == 2 + assert A.v is A.__dict__["v"] + assert A.v.__doc__ == "doc" + with pytest.raises(AttributeError): + a.v = 1 # type: ignore[misc] + + +def test_cached_property_ttl_and_invalidation(fake_clock: FakeClock) -> None: + calls = [] + + class A: + @cached_property(ttl=datetime.timedelta(seconds=10)) + def v(self) -> int: + calls.append(1) + return len(calls) + + @cached_property + def w(self) -> int: + return 7 + + plain = 1 + + a = A() + assert a.v == 1 and a.v == 1 + fake_clock.advance(11) + assert a.v == 2 + del a.v + assert a.v == 3 + assert invalidate(a, "v") == 1 + assert invalidate(a, "v") == 0 + assert a.v == 4 + assert a.w == 7 + assert invalidate(a) == 2 + del a.w # deleting something never cached is a no-op + with pytest.raises(AttributeError, match="no cached_property"): + invalidate(a, "nope") + with pytest.raises(AttributeError, match="not a cached_property"): + invalidate(a, "plain") + with pytest.raises(ValueError): + cached_property(ttl=0) + + +def test_cached_property_slots_without_dict() -> None: + class S: + __slots__ = () + + @cached_property + def v(self) -> int: + return 1 + + with pytest.raises(TypeError, match="__dict__"): + _ = S().v + with pytest.raises(TypeError, match="__dict__"): + invalidate(S()) + + class S2: + __slots__ = ("__dict__",) + + @cached_property + def v(self) -> int: + return 1 + + assert S2().v == 1 + + +def test_cached_property_thread_safety_single_compute() -> None: + calls = [] + gate = threading.Barrier(8) + + class A: + @cached_property + def v(self) -> int: + calls.append(1) + threading.Event().wait(0.02) + return 5 + + a = A() + out: list[int] = [] + + def work() -> None: + gate.wait() + out.append(a.v) + + threads = [threading.Thread(target=work) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert out == [5] * 8 and len(calls) == 1 + + +class _Picklable: + calls = 0 + + @cached_property + def v(self) -> int: + type(self).calls += 1 + return type(self).calls + + +def test_cached_property_copy_and_pickle_do_not_share_state() -> None: + a = _Picklable() + assert a.v == 1 + b = copy.copy(a) + assert b.v == 2 and a.v == 1 + q = pickle.loads(pickle.dumps(a)) + assert q.v == 3 + del q.v + assert invalidate(q) == 0 + + +def test_cached_property_set_name_conflict_and_failure_not_cached() -> None: + with pytest.raises((RuntimeError, TypeError)): + + class _Bad: + a = cached_property(lambda self: 1) + b = a + + calls = [] + + class A: + @cached_property + def v(self) -> int: + calls.append(1) + if len(calls) == 1: + raise RuntimeError + return 2 + + a = A() + with pytest.raises(RuntimeError): + _ = a.v + assert a.v == 2 + + +def test_cached_property_disable_caching() -> None: + calls = [] + + class A: + @cached_property + def v(self) -> int: + calls.append(1) + return 1 + + a = A() + with overrides(disable_caching=True): + _ = a.v + _ = a.v + assert len(calls) == 2 + _ = a.v + _ = a.v + assert len(calls) == 3 + + +async def _await(aw: Awaitable[Any]) -> Any: + return await aw + + +async def test_async_cached_property_shares_task_and_caches() -> None: + calls = [] + gate = asyncio.Event() + + class A: + @cached_property(ttl=60) + async def v(self) -> int: + calls.append(1) + await gate.wait() + return 9 + + a = A() + tasks = [asyncio.ensure_future(_await(a.v)) for _ in range(4)] + await asyncio.sleep(0) + gate.set() + assert await asyncio.gather(*tasks) == [9] * 4 + assert await a.v == 9 + assert calls == [1] + del a.v + assert await a.v == 9 + assert calls == [1, 1] + + +async def test_async_cached_property_failure_not_cached_and_invalidate_in_flight() -> None: + calls = [] + gate = asyncio.Event() + + class A: + @cached_property + async def v(self) -> int: + calls.append(1) + await gate.wait() + if len(calls) == 1: + raise RuntimeError + return len(calls) + + a = A() + t = asyncio.ensure_future(_await(a.v)) + await asyncio.sleep(0) + gate.set() + with pytest.raises(RuntimeError): + await t + assert await a.v == 2 + + # invalidated while computing: the in-flight result is returned but not stored + gate.clear() + b = A() + calls.clear() + calls.append(0) # next call returns 2 + t2 = asyncio.ensure_future(_await(b.v)) + await asyncio.sleep(0) + assert invalidate(b) == 0 # nothing cached yet, but the in-flight task is dropped + gate.set() + assert await t2 == 2 + assert await b.v == 3 # recomputed: nothing was stored + + +def test_async_cached_property_across_event_loops() -> None: + class A: + @cached_property + async def v(self) -> int: + await asyncio.sleep(0.02) + return 4 + + a = A() + assert _run_in_threads(lambda: a.v) == [4, 4] + + +def test_async_cached_property_disable_caching() -> None: + calls = [] + + class A: + @cached_property + async def v(self) -> int: + calls.append(1) + return 1 + + async def main() -> None: + a = A() + with overrides(disable_caching=True): + await a.v + await a.v + assert len(calls) == 2 + + asyncio.run(main()) + + +# ------------------------------------------------- eager task factory / multiple loops + +needs_eager = pytest.mark.skipif( + not hasattr(asyncio, "eager_task_factory"), reason="needs asyncio.eager_task_factory (3.12+)" +) + + +def _run_eager(main: Callable[[], Awaitable[Any]]) -> Any: + """``asyncio.run(main())`` with the eager task factory, failing fast instead of hanging.""" + + async def eager_main() -> Any: + asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory) # type: ignore[attr-defined] + return await main() + + out: list[Any] = [] + t = threading.Thread(target=lambda: out.append(asyncio.run(eager_main())), daemon=True) + t.start() + t.join(5) + assert not t.is_alive(), "deadlocked under eager_task_factory" + return out[0] + + +@needs_eager +def test_eager_memoize_no_deadlock_and_no_stale_inflight() -> None: + calls: list[int] = [] + + @memoize(cache_exceptions=KeyError) + async def f(x: int) -> int: + calls.append(x) + if x < 0: + raise ValueError("no") + return x * 2 + + async def main() -> list[Any]: + out: list[Any] = [await f(1), await f(1)] + for _ in range(2): # a failure is not cached and leaves no finished task behind + with pytest.raises(ValueError): + await f(-1) + f.cache_clear() + out.append(await f(1)) + out += await asyncio.gather(f(2), f(2)) # suspending-free runs still share/cache + return out + + assert _run_eager(main) == [2, 2, 2, 4, 4] + assert calls == [1, -1, -1, 1, 2] + + +@needs_eager +def test_eager_memoize_suspending_run_is_shared() -> None: + calls = 0 + + @memoize + async def f() -> int: + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return calls + + async def main() -> list[int]: + return list(await asyncio.gather(f(), f(), f())) + + assert _run_eager(main) == [1, 1, 1] + assert calls == 1 + + +@needs_eager +def test_eager_once_failure_retries_and_success_sticks() -> None: + calls = 0 + + @once + async def init() -> int: + nonlocal calls + calls += 1 + if calls == 1: + raise ValueError("first fails") + return calls + + async def main() -> list[int]: + with pytest.raises(ValueError): + await init() + return [await init(), await init()] + + assert _run_eager(main) == [2, 2] + assert calls == 2 + + +@needs_eager +def test_eager_cached_property_failure_retries_and_caches() -> None: + calls = 0 + + class C: + @cached_property + async def v(self) -> int: + nonlocal calls + calls += 1 + if calls == 1: + raise ValueError("first fails") + return calls + + async def main() -> list[int]: + c = C() + with pytest.raises(ValueError): + await c.v + return [await c.v, await c.v] + + assert _run_eager(main) == [2, 2] + assert calls == 2 + + +def _in_loops(n: int, main: Callable[[], Awaitable[Any]]) -> list[Any]: + """Run ``asyncio.run(main())`` in ``n`` threads that start together.""" + barrier = threading.Barrier(n) + out: list[Any] = [] + errors: list[BaseException] = [] + + def worker() -> None: + barrier.wait() + try: + out.append(asyncio.run(main())) # type: ignore[arg-type] + except BaseException as exc: + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(n)] + for t in threads: + t.start() + for t in threads: + t.join(5) + return out + errors + + +def test_async_once_runs_once_across_event_loops() -> None: + runs = 0 + runs_lock = threading.Lock() + + @once + async def setup() -> str: + nonlocal runs + with runs_lock: + runs += 1 + await asyncio.sleep(0.03) + return "ready" + + async def main() -> list[str]: + return list(await asyncio.gather(setup(), setup())) + + assert _in_loops(4, main) == [["ready", "ready"]] * 4 + assert runs == 1 and setup.called + + +def test_async_once_failure_shared_across_loops_then_retries() -> None: + runs = 0 + runs_lock = threading.Lock() + + @once + async def setup() -> int: + nonlocal runs + with runs_lock: + runs += 1 + n = runs + await asyncio.sleep(0.03) + if n == 1: + raise ValueError("first fails") + return n + + results = _in_loops(3, setup) + assert runs == 1 + assert len(results) == 3 and all(isinstance(r, ValueError) for r in results) + assert not setup.called + assert asyncio.run(setup()) == 2 + + +def test_async_once_cancelled_owner_lets_other_loop_take_over() -> None: + runs = 0 + started = threading.Event() + owner_cancelled = threading.Event() + + @once + async def setup() -> int: + nonlocal runs + runs += 1 + n = runs + started.set() + await asyncio.sleep(0.03) + return n + + async def owner() -> None: + t = asyncio.ensure_future(setup()) + await asyncio.sleep(0.005) + t.cancel() # cancels only the caller; the run itself keeps going ... + await asyncio.sleep(0) + for task in asyncio.all_tasks(): # ... so cancel the run task too + if task is not asyncio.current_task(): + task.cancel() + with pytest.raises(asyncio.CancelledError): + await t + owner_cancelled.set() + + def other() -> int: + started.wait(1) + return asyncio.run(setup()) + + out: list[int] = [] + th = threading.Thread(target=lambda: out.append(other())) + th.start() + asyncio.run(owner()) + th.join(5) + assert owner_cancelled.is_set() + assert out == [2] and runs == 2 + assert asyncio.run(setup()) == 2 + + +def test_async_once_takes_over_from_a_closed_loop() -> None: + calls = 0 + + @once + async def setup() -> int: + nonlocal calls + calls += 1 + if calls == 1: + await asyncio.sleep(10) # stranded: its loop is closed while it waits + return calls + + dead = asyncio.new_event_loop() + dead.set_exception_handler(lambda _l, _c: None) # silence 'Task was destroyed' + stranded = dead.create_task(setup()) + dead.run_until_complete(asyncio.sleep(0.005)) # the run is now in flight on `dead` + dead.close() + assert not stranded.done() + # The stale flight must not block other loops forever: a new loop runs it again. + assert asyncio.run(asyncio.wait_for(setup(), 1)) == 2 + assert asyncio.run(setup()) == 2 and calls == 2 + + +def test_async_once_reset_while_in_flight_on_another_loop() -> None: + gate = threading.Event() + + @once + async def setup() -> int: + await asyncio.sleep(0.03) + return 1 + + out: list[int] = [] + th = threading.Thread(target=lambda: (out.append(asyncio.run(setup())), gate.set())) + th.start() + asyncio.run(asyncio.sleep(0.01)) + setup.reset() # the in-flight result is discarded for later callers + gate.wait(1) + th.join(5) + assert out == [1] + assert not setup.called + + +@needs_eager +def test_eager_memoize_task_creation_failure_cleans_up(monkeypatch: pytest.MonkeyPatch) -> None: + @memoize + async def f() -> int: + return 1 + + real = asyncio.ensure_future + fail = [True] + + def flaky(coro: Any, **kw: Any) -> Any: + if fail.pop() if fail else False: + raise RuntimeError("cannot schedule") + return real(coro, **kw) + + monkeypatch.setattr(asyncio, "ensure_future", flaky) + + async def main() -> int: + with pytest.raises(RuntimeError, match="cannot schedule"): + await f() + return await f() # nothing stale is left in flight + + assert _run_eager(main) == 1 + + +def test_cache_lookups_on_a_method_take_the_instance() -> None: + class Repo: + @memoize + def load(self, k: str) -> str: + return k.upper() + + repo = Repo() + assert repo.load("a") == "A" + assert repo.load.cache_contains(repo, "a") + assert repo.load.cache_get(repo, "a") == "A" + assert Repo.load.cache_get(repo, "a") == "A" + assert repo.load.cache_delete(repo, "a") + assert not repo.load.cache_contains(repo, "a") diff --git a/tests/test_circuit.py b/tests/test_circuit.py new file mode 100644 index 0000000..21eca45 --- /dev/null +++ b/tests/test_circuit.py @@ -0,0 +1,471 @@ +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import logging +import threading +from collections.abc import Iterator + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.circuit import CircuitBreaker, CircuitState, CircuitStats, circuit_breaker +from python_wrap.errors import CircuitOpenError + + +def boom() -> None: + raise ValueError("boom") + + +def ok() -> str: + return "ok" + + +def trip(breaker: CircuitBreaker, n: int) -> None: + for _ in range(n): + with pytest.raises(ValueError): + breaker.call(boom) + + +def test_initial_state_and_str_enum() -> None: + b = CircuitBreaker() + assert b.state is CircuitState.CLOSED + assert b.state == "closed" + assert CircuitState.HALF_OPEN.value == "half_open" + assert b.failure_count == 0 + assert "closed" in repr(b) + + +@pytest.mark.parametrize( + "kwargs", + [{"failure_threshold": 0}, {"recovery_timeout": -1}, {"half_open_max_calls": 0}], +) +def test_validation(kwargs: dict[str, float]) -> None: + with pytest.raises(ValueError): + CircuitBreaker(**kwargs) # type: ignore[arg-type] + + +def test_opens_after_threshold_and_rejects(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=3, recovery_timeout=10) + trip(b, 2) + assert b.state is CircuitState.CLOSED + assert b.failure_count == 2 + trip(b, 1) + assert b.state is CircuitState.OPEN + + calls = [] + fake_clock.advance(4) + with pytest.raises(CircuitOpenError) as info: + b.call(lambda: calls.append(1)) + assert calls == [] + assert info.value.retry_after == pytest.approx(6) + + +def test_success_resets_consecutive_count() -> None: + b = CircuitBreaker(failure_threshold=3) + trip(b, 2) + assert b.call(ok) == "ok" + assert b.failure_count == 0 + trip(b, 2) + assert b.state is CircuitState.CLOSED + + +def test_non_matching_exceptions_not_counted() -> None: + b = CircuitBreaker(failure_threshold=1, on=ValueError) + with pytest.raises(KeyError): + b.call(lambda: (_ for _ in ()).throw(KeyError())) + assert b.failure_count == 0 + assert b.state is CircuitState.CLOSED + + +def test_half_open_success_closes(fake_clock: FakeClock, caplog: pytest.LogCaptureFixture) -> None: + b = CircuitBreaker(failure_threshold=2, recovery_timeout=10, name="svc") + with caplog.at_level(logging.INFO, logger="python_wrap"): + trip(b, 2) + fake_clock.advance(10) + assert b.state is CircuitState.HALF_OPEN + assert b.call(ok) == "ok" + assert b.state is CircuitState.CLOSED + assert b.failure_count == 0 + levels = [r.levelno for r in caplog.records] + assert levels == [logging.WARNING, logging.INFO, logging.INFO] + assert "svc" in caplog.records[0].getMessage() + + +def test_half_open_failure_reopens(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=2, recovery_timeout=10) + trip(b, 2) + fake_clock.advance(10) + trip(b, 1) + assert b.state is CircuitState.OPEN + with pytest.raises(CircuitOpenError) as info: + b.call(ok) + assert info.value.retry_after == pytest.approx(10) + + +def test_half_open_limits_concurrent_trials(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, recovery_timeout=5, half_open_max_calls=2) + trip(b, 1) + fake_clock.advance(5) + + def nested() -> str: + # Two trials are in flight (this one and the outer), so a third is rejected. + with pytest.raises(CircuitOpenError): + b.call(ok) + return "inner" + + def outer() -> str: + return b.call(nested) + + assert b.call(outer) == "inner" + assert b.state is CircuitState.CLOSED + + +def test_half_open_non_counted_exception_frees_slot(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, recovery_timeout=5, on=ValueError) + trip(b, 1) + fake_clock.advance(5) + + def raise_key() -> None: + raise KeyError + + with pytest.raises(KeyError): + b.call(raise_key) + assert b.state is CircuitState.HALF_OPEN + assert b.call(ok) == "ok" + assert b.state is CircuitState.CLOSED + + +def test_reset(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1) + trip(b, 1) + b.reset() + assert b.state is CircuitState.CLOSED + assert b.failure_count == 0 + assert b.call(ok) == "ok" + b.reset() # already closed: no-op + + +def test_stale_outcome_after_reset_ignored() -> None: + b = CircuitBreaker(failure_threshold=1) + + def slow_fail() -> None: + b.reset() + raise ValueError + + with pytest.raises(ValueError): + b.call(slow_fail) + assert b.state is CircuitState.CLOSED + + +def test_shared_breaker_decorates_several_functions() -> None: + b = CircuitBreaker(failure_threshold=2, recovery_timeout=100) + + @b + def a() -> None: + raise ValueError + + @b + def c() -> str: + return "c" + + for _ in range(2): + with pytest.raises(ValueError): + a() + with pytest.raises(CircuitOpenError): + c() + assert a.__name__ == "a" + + +def test_circuit_breaker_decorator_forms(fake_clock: FakeClock) -> None: + @circuit_breaker(failure_threshold=1, recovery_timeout=5) + def f(fail: bool) -> str: + if fail: + raise ValueError + return "ok" + + @circuit_breaker + def g() -> str: + return "g" + + assert g() == "g" + assert g.breaker.state is CircuitState.CLOSED # type: ignore[attr-defined] + assert g.breaker is not f.breaker # type: ignore[attr-defined] + with pytest.raises(ValueError): + f(True) + with pytest.raises(CircuitOpenError): + f(False) + assert f.breaker.name.endswith("f") # type: ignore[attr-defined] + fake_clock.advance(5) + assert f(False) == "ok" + assert f.breaker.state is CircuitState.CLOSED # type: ignore[attr-defined] + + +def test_methods() -> None: + class Client: + @circuit_breaker(failure_threshold=1) + def get(self) -> None: + raise ValueError + + c = Client() + with pytest.raises(ValueError): + c.get() + with pytest.raises(CircuitOpenError): + c.get() + + +def test_async_flow(fake_clock: FakeClock) -> None: + @circuit_breaker(failure_threshold=2, recovery_timeout=10) + async def fetch(fail: bool) -> str: + await asyncio.sleep(0) + if fail: + raise ValueError + return "ok" + + assert inspect.iscoroutinefunction(fetch) + + async def scenario() -> None: + for _ in range(2): + with pytest.raises(ValueError): + await fetch(True) + with pytest.raises(CircuitOpenError): + await fetch(False) + fake_clock.advance(10) + assert await fetch(False) == "ok" + + asyncio.run(scenario()) + assert fetch.breaker.state is CircuitState.CLOSED # type: ignore[attr-defined] + + +def test_async_call_and_non_counted(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, on=ValueError, recovery_timeout=1) + + async def work(x: int) -> int: + if x == 1: + raise KeyError + if x == 2: + raise ValueError + return x + + async def scenario() -> None: + assert await b.call(work, 0) == 0 + with pytest.raises(KeyError): + await b.call(work, 1) + assert b.state is CircuitState.CLOSED + with pytest.raises(ValueError): + await b.call(work, 2) + assert b.state is CircuitState.OPEN + fake_clock.advance(1) + with pytest.raises(KeyError): + await b.call(work, 1) + assert b.state is CircuitState.HALF_OPEN + + asyncio.run(scenario()) + + +def test_thread_safety_exact_counts() -> None: + b = CircuitBreaker(failure_threshold=10_000) + barrier = threading.Barrier(8) + + def worker() -> None: + barrier.wait() + for _ in range(100): + with contextlib.suppress(ValueError): + b.call(boom) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert b.failure_count == 800 + assert b.state is CircuitState.CLOSED + + +# -------------------------------------------------------------------- 0.2 features + + +@pytest.fixture +def events() -> Iterator[list[_core.Event]]: + seen: list[_core.Event] = [] + _core.listeners.append(seen.append) + try: + yield seen + finally: + _core.listeners.remove(seen.append) + + +def test_success_threshold_needs_consecutive_trials(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, recovery_timeout=5, success_threshold=3) + trip(b, 1) + fake_clock.advance(5) + assert b.call(ok) == "ok" + assert b.call(ok) == "ok" + assert b.state is CircuitState.HALF_OPEN # two of three trials so far + assert b.call(ok) == "ok" + assert b.state is CircuitState.CLOSED + + +def test_success_threshold_failure_during_trials_reopens(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, recovery_timeout=5, success_threshold=2) + trip(b, 1) + fake_clock.advance(5) + assert b.call(ok) == "ok" + trip(b, 1) + assert b.state is CircuitState.OPEN + fake_clock.advance(5) + assert b.call(ok) == "ok" # the count starts again from zero + assert b.state is CircuitState.HALF_OPEN + assert b.call(ok) == "ok" + assert b.state is CircuitState.CLOSED + + +def test_success_threshold_validated() -> None: + with pytest.raises(ValueError): + CircuitBreaker(success_threshold=0) + + +def test_exclude_never_counts_and_propagates(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, recovery_timeout=5, exclude=KeyError) + + def missing() -> None: + raise KeyError("nope") + + with pytest.raises(KeyError): + b.call(missing) + assert b.state is CircuitState.CLOSED + assert b.stats.failures == 0 + trip(b, 1) + fake_clock.advance(5) + with pytest.raises(KeyError): # an excluded error frees the trial slot + b.call(missing) + assert b.state is CircuitState.HALF_OPEN + assert b.call(ok) == "ok" + assert b.state is CircuitState.CLOSED + + +def test_on_state_change_listener_and_events( + fake_clock: FakeClock, events: list[_core.Event] +) -> None: + seen: list[tuple[CircuitBreaker, CircuitState, CircuitState]] = [] + b = CircuitBreaker( + failure_threshold=1, + recovery_timeout=5, + name="db", + on_state_change=lambda br, old, new: seen.append((br, old, new)), + ) + trip(b, 1) + with pytest.raises(CircuitOpenError): + b.call(ok) + fake_clock.advance(5) + assert b.call(ok) == "ok" + assert [(old, new) for br, old, new in seen] == [ + (CircuitState.CLOSED, CircuitState.OPEN), + (CircuitState.OPEN, CircuitState.HALF_OPEN), + (CircuitState.HALF_OPEN, CircuitState.CLOSED), + ] + assert all(br is b for br, _, _ in seen) + changed = [e for e in events if e.name == "circuit.state_changed"] + assert [(e.fields["old"], e.fields["new"]) for e in changed] == [ + (old, new) for _, old, new in seen + ] + assert {e.function for e in events} == {"db"} + rejected = [e for e in events if e.name == "circuit.rejected"] + assert len(rejected) == 1 + assert rejected[0].fields["retry_after"] == pytest.approx(5) + + +def test_listener_can_read_breaker_and_errors_are_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + states: list[CircuitState] = [] + + def listener(br: CircuitBreaker, old: CircuitState, new: CircuitState) -> None: + states.append(br.state) # re-entering the breaker must not deadlock + raise RuntimeError("listener bug") + + b = CircuitBreaker(failure_threshold=1, on_state_change=listener, name="svc") + with caplog.at_level(logging.ERROR, logger="python_wrap"): + trip(b, 1) + assert states == [CircuitState.OPEN] + assert "on_state_change listener failed" in caplog.text + b.reset() + assert states == [CircuitState.OPEN, CircuitState.CLOSED] + + +def test_stats_snapshot(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=2, recovery_timeout=5, name="api") + s = b.stats + assert (s.name, s.state, s.failures, s.successes, s.rejected) == ( + "api", + CircuitState.CLOSED, + 0, + 0, + 0, + ) + assert s.opened_at is None + b.call(ok) + trip(b, 2) + opened = fake_clock.now + with pytest.raises(CircuitOpenError): + b.call(ok) + s = b.stats + assert (s.failures, s.successes, s.rejected, s.consecutive_failures) == (2, 1, 1, 2) + assert s.state is CircuitState.OPEN + assert s.opened_at == opened + fake_clock.advance(5) + assert b.stats.state is CircuitState.HALF_OPEN + b.reset() + assert b.stats == CircuitStats("api", CircuitState.CLOSED, 0, 0, 0, 0, None) + + +def test_circuit_breaker_decorator_accepts_new_options(fake_clock: FakeClock) -> None: + changes: list[str] = [] + + @circuit_breaker( + failure_threshold=1, + recovery_timeout=1, + success_threshold=2, + exclude=KeyError, + on_state_change=lambda br, old, new: changes.append(new.value), + ) + def f(x: int) -> int: + if x < 0: + raise ValueError + if x == 0: + raise KeyError + return x + + with pytest.raises(KeyError): + f(0) + with pytest.raises(ValueError): + f(-1) + fake_clock.advance(1) + assert f(1) == 1 + assert f(2) == 2 + assert changes == ["open", "half_open", "closed"] + assert f.breaker.name.endswith("f") + assert f.breaker.stats.successes == 2 + + +def test_async_success_threshold(fake_clock: FakeClock) -> None: + b = CircuitBreaker(failure_threshold=1, recovery_timeout=1, success_threshold=2) + + @b + async def work(fail: bool) -> str: + if fail: + raise ValueError + return "ok" + + async def scenario() -> None: + with pytest.raises(ValueError): + await work(True) + fake_clock.advance(1) + assert await work(False) == "ok" + assert b.state is CircuitState.HALF_OPEN + assert await work(False) == "ok" + assert b.state is CircuitState.CLOSED + + asyncio.run(scenario()) diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..c0bad59 --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,1800 @@ +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextlib +import gc +import inspect +import signal +import sys +import threading +import time +import types + +import pytest + +from python_wrap import _core, concurrency +from python_wrap.concurrency import ( + RateLimiter, + Timeout, + debounce, + rate_limit, + run_in_thread, + singleflight, + throttle, + timeout, +) +from python_wrap.errors import RateLimitExceeded, TimeoutExpired +from python_wrap.testing import disable_rate_limits, fast_mode + +HAS_SIGNALS = hasattr(signal, "setitimer") +needs_signals = pytest.mark.skipif(not HAS_SIGNALS, reason="needs signal.setitimer") + + +# -------------------------------------------------------------------------------------- +# timeout +# -------------------------------------------------------------------------------------- + + +def test_timeout_validates_seconds(): + with pytest.raises(ValueError): + timeout(0) + + +@pytest.mark.parametrize("use_signals", [None, False]) +def test_timeout_passes_result_through(use_signals): + @timeout(1, use_signals=use_signals) + def add(a, b): + return a + b + + assert add(1, b=2) == 3 + assert add.__name__ == "add" + + +@pytest.mark.parametrize("use_signals", [None, False]) +def test_timeout_propagates_exceptions(use_signals): + @timeout(1, use_signals=use_signals) + def boom(): + raise KeyError("x") + + with pytest.raises(KeyError): + boom() + + +@needs_signals +def test_timeout_signal_interrupts_and_restores_handler(): + before = signal.getsignal(signal.SIGALRM) + + @timeout(0.02) + def slow(): + time.sleep(1) + + started = time.perf_counter() + with pytest.raises(TimeoutExpired) as info: + slow() + assert time.perf_counter() - started < 0.5 + assert isinstance(info.value, TimeoutError) + assert info.value.seconds == 0.02 + assert signal.getsignal(signal.SIGALRM) is before + assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0) + + +@needs_signals +def test_timeout_nested_inner_fires_and_outer_is_rearmed(): + @timeout(0.02, use_signals=True) + def inner(): + time.sleep(1) + + @timeout(5, use_signals=True) + def outer(): + with pytest.raises(TimeoutExpired) as info: + inner() + assert info.value.seconds == 0.02 + remaining, _ = signal.getitimer(signal.ITIMER_REAL) + assert 4 < remaining <= 5 + return "ok" + + assert outer() == "ok" + assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0) + + +@needs_signals +def test_timeout_nested_outer_shorter_wins(): + @timeout(5, use_signals=True) + def inner(): + time.sleep(1) + + @timeout(0.02, use_signals=True) + def outer(): + inner() + + with pytest.raises(TimeoutExpired) as info: + outer() + assert info.value.seconds == 0.02 + assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0) + + +@needs_signals +def test_timeout_signals_required_in_main_thread(): + @timeout(1, use_signals=True) + def f(): + return 1 + + assert run_in_thread(f)().exception(timeout=1).__class__ is RuntimeError + + +def test_timeout_use_signals_unavailable(monkeypatch): + monkeypatch.setattr(concurrency, "_HAS_SETITIMER", False) + with pytest.raises(RuntimeError): + timeout(1, use_signals=True) + + @timeout(0.02) # falls back to a worker thread + def f(): + return threading.current_thread().name + + assert f().startswith("timeout-") + + +def test_timeout_thread_fallback_times_out_and_keeps_running(): + release = threading.Event() + finished = threading.Event() + + @timeout(0.02, use_signals=False) + def slow(): + release.wait(1) + finished.set() + + with pytest.raises(TimeoutExpired): + slow() + assert not finished.is_set() + release.set() + assert finished.wait(1) # the work kept running in the background + + +def test_timeout_thread_fallback_ignores_fake_clock_sleeps(fake_clock): + @timeout(1, use_signals=False) + def work(): + _core.sleep(10) # e.g. a retry backoff inside the limit: fake time only + return "ok" + + assert work() == "ok" # finished quickly in real time, so it is not late + assert fake_clock.sleeps == [10] + + +@pytest.mark.skipif(not hasattr(signal, "setitimer"), reason="needs SIGALRM") +def test_timeout_signal_nested_deadline_is_real_time_under_fake_clock(fake_clock): + @timeout(0.02) + def inner(): + time.sleep(1) # real blocking: the inner deadline must fire + + @timeout(5) + def outer(): + inner() + + start = time.monotonic() + with pytest.raises(TimeoutExpired, match=r"within 0\.02s"): + outer() + assert time.monotonic() - start < 0.5 + + +def test_timeout_in_worker_thread_uses_fallback(): + @timeout(0.02) + def slow(): + time.sleep(0.04) + + future = run_in_thread(slow)() + assert isinstance(future.exception(timeout=1), TimeoutExpired) + + +async def test_timeout_async(): + @timeout(0.02) + async def slow(): + await asyncio.sleep(1) + + @timeout(1) + async def fast(x): + return x + + assert inspect.iscoroutinefunction(slow) + assert await fast(3) == 3 + with pytest.raises(TimeoutExpired) as info: + await slow() + assert info.value.seconds == 0.02 + + +async def test_timeout_async_inner_timeout_error_propagates_unchanged(): + error = TimeoutError("db socket timed out") + + @timeout(5) + async def f(): + raise error + + with pytest.raises(TimeoutError) as info: + await f() + assert info.value is error # not converted to TimeoutExpired + assert not isinstance(info.value, TimeoutExpired) + assert info.traceback[-1].name == "f" # original traceback kept + + +async def test_timeout_async_expiry_is_chained_not_hidden(): + @timeout(0.01) + async def slow(): + await asyncio.sleep(1) + + with pytest.raises(TimeoutExpired) as info: + await slow() + if sys.version_info >= (3, 11): + assert isinstance(info.value.__cause__, TimeoutError) + + +async def test_timeout_async_inner_exception_propagates(): + @timeout(5) + async def f(): + await asyncio.sleep(0) + raise KeyError("k") + + with pytest.raises(KeyError): + await f() + + +async def test_timeout_async_caller_cancellation_is_not_a_timeout(): + started = asyncio.Event() + cancelled = asyncio.Event() + + @timeout(5) + async def f(): + started.set() + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + cancelled.set() + raise + + task = asyncio.create_task(f()) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled.is_set() # the inner call was cancelled too + + +async def test_timeout_async_callable_object(): + class Slow: + async def __call__(self): + await asyncio.sleep(1) + return "finished" + + wrapped = timeout(0.01)(Slow()) + assert inspect.iscoroutinefunction(wrapped) + with pytest.raises(TimeoutExpired): + await wrapped() + + +@needs_signals +def test_timeout_signal_swallowed_by_broad_except_in_user_code(): + # Documented limitation: TimeoutExpired is an Exception raised inside the function, + # so a broad ``except Exception`` there catches it and the call returns normally. + caught = [] + + @timeout(0.01, use_signals=True) + def work(): + try: + time.sleep(1) + except Exception as exc: + caught.append(exc) + return "returned normally" + + assert work() == "returned normally" + assert len(caught) == 1 and isinstance(caught[0], TimeoutExpired) + assert issubclass(TimeoutExpired, TimeoutError) + assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0) + + +def test_timeout_on_methods(): + class C: + @timeout(1, use_signals=False) + def m(self, x): + return x * 2 + + @classmethod + @timeout(1) + def cm(cls): + return cls + + @staticmethod + @timeout(1) + def sm(): + return "s" + + assert C().m(2) == 4 + assert C.cm() is C + assert C.sm() == "s" + + +# -------------------------------------------------------------------------------------- +# rate_limit +# -------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize(("calls", "period"), [(0, 1), (1, 0), (1, -1)]) +def test_rate_limit_validates(calls, period): + with pytest.raises(ValueError): + rate_limit(calls, period) + + +def test_rate_limit_rejects_with_retry_after(fake_clock): + @rate_limit(2, 10) + def f(x): + return x + + assert f(1) == 1 + fake_clock.advance(4) + assert f(2) == 2 + with pytest.raises(RateLimitExceeded) as info: + f(3) + assert info.value.retry_after == pytest.approx(6) + fake_clock.advance(6) + assert f(4) == 4 # first slot freed: sliding window + with pytest.raises(RateLimitExceeded) as info: + f(5) + assert info.value.retry_after == pytest.approx(4) + + +def test_rate_limit_block_sleeps(fake_clock): + @rate_limit(1, 5, block=True) + def f(): + return fake_clock.now + + start = fake_clock.now + assert f() == start + assert f() == start + 5 + assert fake_clock.sleeps == [5] + + +def test_rate_limit_block_under_fast_mode_admits_without_spinning(monkeypatch): + sleeps = [] + real_sleep = _core.sleep + monkeypatch.setattr(_core, "sleep", lambda s: (sleeps.append(s), real_sleep(s))) + limiter = RateLimiter(1, 60, block=True) + events = [] + + @limiter + def f(): + return 1 + + _core.listeners.append(lambda e: events.append(e.name)) + try: + with fast_mode(): + start = time.monotonic() + assert [f(), f(), f()] == [1, 1, 1] + assert time.monotonic() - start < 1 + assert not limiter.try_acquire() # the admitted calls were counted + finally: + _core.listeners.pop() + assert len(sleeps) == 2 # one (no-op) sleep per blocked call, no busy loop + assert events.count("rate_limit.waited") == 2 + + +async def test_rate_limit_block_async_under_fast_mode_admits_without_spinning(monkeypatch): + sleeps = [] + real_sleep = _core.async_sleep + + async def counting(s): + sleeps.append(s) + await real_sleep(s) + + monkeypatch.setattr(_core, "async_sleep", counting) + limiter = RateLimiter(1, 60, block=True) + + @limiter + async def g(): + return 1 + + with fast_mode(): + start = time.monotonic() + assert [await g(), await g(), await g()] == [1, 1, 1] + assert time.monotonic() - start < 1 + assert not limiter.try_acquire() + assert len(sleeps) == 2 + + +def test_rate_limit_block_under_fast_mode_and_fake_clock_advances_it(fake_clock): + @rate_limit(1, 5, block=True) + def f(): + return fake_clock.now + + with fast_mode(): + start = fake_clock.now + assert f() == start + assert f() == start + 5 # the wait advances the fake clock + assert fake_clock.sleeps == [5] + + +async def test_rate_limit_block_async_under_fast_mode_and_fake_clock(fake_clock): + @rate_limit(1, 5, block=True) + async def g(): + return fake_clock.now + + with fast_mode(): + start = fake_clock.now + assert await g() == start + assert await g() == start + 5 + assert fake_clock.sleeps == [5] + + +def test_rate_limit_per_key(fake_clock): + @rate_limit(1, 10, key=lambda user, **_: user) + def send(user, text=""): + return user + + assert send("a") == "a" + assert send("b") == "b" + with pytest.raises(RateLimitExceeded): + send("a", text="again") + + +def test_rate_limit_sweeps_stale_buckets(fake_clock, monkeypatch): + monkeypatch.setattr(concurrency, "_SWEEP_EVERY", 3) + window = concurrency._SlidingWindow(((1, 1.0),)) + window.acquire("a") + window.acquire("b") + fake_clock.advance(2) + window.acquire("c") # third call triggers a sweep + assert set(window._buckets) == {"c"} + + +async def test_rate_limit_async(fake_clock): + @rate_limit(1, 3) + async def f(): + return "x" + + @rate_limit(1, 3, block=True) + async def g(): + return fake_clock.now + + assert inspect.iscoroutinefunction(f) + assert await f() == "x" + with pytest.raises(RateLimitExceeded): + await f() + start = fake_clock.now + assert await g() == start + assert await g() == start + 3 + + +def test_rate_limit_thread_safe(fake_clock): + hits = [] + + @rate_limit(50, 100) + def f(): + hits.append(1) + + barrier = threading.Barrier(8) + rejected = [] + + def worker(): + barrier.wait() + for _ in range(20): + try: + f() + except RateLimitExceeded: + rejected.append(1) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(hits) == 50 + assert len(rejected) == 160 - 50 + + +def test_rate_limit_shared_across_instances(fake_clock): + class C: + @rate_limit(1, 10) + def m(self): + return self + + a, b = C(), C() + assert a.m() is a + with pytest.raises(RateLimitExceeded): + b.m() + + +# -------------------------------------------------------------------------------------- +# run_in_thread +# -------------------------------------------------------------------------------------- + + +def test_run_in_thread_returns_future_with_result(): + @run_in_thread + def work(a, b=0): + return a + b, threading.current_thread().name + + future = work(1, b=2) + assert isinstance(future, concurrent.futures.Future) + value, name = future.result(timeout=1) + assert value == 3 + assert name.startswith("run_in_thread-") + assert name != threading.current_thread().name + + +def test_run_in_thread_exception(): + @run_in_thread() + def boom(): + raise ValueError("bad") + + with pytest.raises(ValueError, match="bad"): + boom().result(timeout=1) + + +def test_run_in_thread_options(): + seen = {} + + @run_in_thread(daemon=True, name="worker-x") + def work(): + t = threading.current_thread() + seen["daemon"], seen["name"] = t.daemon, t.name + + work().result(timeout=1) + assert seen == {"daemon": True, "name": "worker-x"} + + +def test_run_in_thread_async_function(): + @run_in_thread + async def work(x): + await asyncio.sleep(0) + return x * 2 + + assert work(21).result(timeout=1) == 42 + + +def test_run_in_thread_cancelled_before_start(monkeypatch): + started = [] + + class LazyThread: + def __init__(self, target, name, daemon): + self.target = target + started.append(self) + + def start(self): + pass + + monkeypatch.setattr(concurrency.threading, "Thread", LazyThread) + calls = [] + + @run_in_thread + def work(): + calls.append(1) + + future = work() + assert future.cancel() + started[0].target() # the thread finally runs: sees the cancellation + assert calls == [] + + +def test_run_in_thread_method(): + class C: + @run_in_thread + def m(self, x): + return self, x + + c = C() + assert c.m(1).result(timeout=1) == (c, 1) + + +# -------------------------------------------------------------------------------------- +# throttle +# -------------------------------------------------------------------------------------- + + +def test_throttle_validates(): + with pytest.raises(ValueError): + throttle(0) + + +def test_throttle_skips_within_interval(fake_clock): + calls = [] + + @throttle(5) + def f(x): + calls.append(x) + return x + + assert f(1) == 1 + fake_clock.advance(4.9) + assert f(2) == 1 # skipped: last result + fake_clock.advance(0.1) + assert f(3) == 3 + assert calls == [1, 3] + + +def test_throttle_returns_none_before_first_result(fake_clock): + entered = threading.Event() + release = threading.Event() + + @throttle(5) + def f(): + entered.set() + release.wait(1) + return "done" + + future = run_in_thread(f)() + assert entered.wait(1) + assert f() is None # first call still running + release.set() + assert future.result(timeout=1) == "done" + assert f() == "done" + + +def test_throttle_exception_uses_window(fake_clock): + calls = [] + + @throttle(5) + def f(): + calls.append(1) + raise RuntimeError + + with pytest.raises(RuntimeError): + f() + assert f() is None + assert calls == [1] + + +def test_throttle_in_window_after_raise_returns_last_successful_result(fake_clock): + outcomes = iter(["a", ValueError("x")]) + + @throttle(1) + def f(): + outcome = next(outcomes) + if isinstance(outcome, Exception): + raise outcome + return outcome + + assert f() == "a" + fake_clock.advance(2) + with pytest.raises(ValueError): + f() + assert f() == "a" # the older successful result, not None + + +async def test_throttle_async(fake_clock): + calls = [] + + @throttle(2) + async def f(x): + calls.append(x) + return x + + assert inspect.iscoroutinefunction(f) + assert await f(1) == 1 + assert await f(2) == 1 + fake_clock.advance(2) + assert await f(3) == 3 + assert calls == [1, 3] + + +def test_throttle_thread_safe(fake_clock): + calls = [] + barrier = threading.Barrier(8) + + @throttle(10) + def f(): + calls.append(1) + + def worker(): + barrier.wait() + for _ in range(50): + f() + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert calls == [1] + + +def test_throttle_method(fake_clock): + class C: + @throttle(1) + def m(self): + return self + + c = C() + assert c.m() is c + + +# -------------------------------------------------------------------------------------- +# debounce +# -------------------------------------------------------------------------------------- + + +def test_debounce_validates(): + with pytest.raises(ValueError): + debounce(-1) + + +def test_debounce_runs_last_call_once(): + calls = [] + done = threading.Event() + + @debounce(0.02) + def f(x): + calls.append(x) + done.set() + + assert f(1) is None + f(2) + f(3) + assert calls == [] + assert done.wait(1) + time.sleep(0.03) + assert calls == [3] + + +def test_debounce_cancel_and_flush(): + calls = [] + + @debounce(10) + def f(x): + calls.append(x) + return x * 10 + + f(1) + f.cancel() + assert f.flush() is None + f(2) + f(3) + assert f.flush() == 30 + assert f.flush() is None + assert calls == [3] + f.cancel() # nothing pending: no-op + + +def test_debounce_superseded_timer_callback_is_ignored(monkeypatch): + timers = [] + + class ManualTimer: + def __init__(self, interval, function, args): + self.function, self.args = function, args + self.daemon = False + timers.append(self) + + def start(self): + pass + + def cancel(self): + pass + + def fire(self): + self.function(*self.args) + + monkeypatch.setattr(concurrency.threading, "Timer", ManualTimer) + calls = [] + + @debounce(1) + def f(x): + calls.append(x) + + f(1) + f(2) + timers[0].fire() # superseded: ignored + assert calls == [] + timers[1].fire() + assert calls == [2] + timers[1].fire() # already ran + assert calls == [2] + + +def test_debounce_method(): + class C: + def __init__(self): + self.seen = [] + + @debounce(10) + def m(self, x): + self.seen.append(x) + + c = C() + c.m(1) + c.m(2) + c.m.flush() + assert c.seen == [2] + + +async def test_debounce_async_burst_shares_result(): + calls = [] + + @debounce(0.02) + async def f(x): + calls.append(x) + return x * 10 + + assert inspect.iscoroutinefunction(f) + results = await asyncio.gather(f(1), f(2), f(3)) + assert results == [30, 30, 30] + assert calls == [3] + assert await f(4) == 40 # a new burst + assert calls == [3, 4] + + +async def test_debounce_async_quiet_period_is_real_time_under_fast_mode(): + calls = [] + + @debounce(0.05) + async def f(x): + calls.append(x) + return x + + async def later(x, delay): + await asyncio.sleep(delay) # real time, inside the quiet period + return await f(x) + + with fast_mode(): + assert await asyncio.gather(f(1), later(2, 0.01)) == [2, 2] + assert calls == [2] # one burst, as with the sync `threading.Timer` + + +async def test_debounce_async_exception_shared(): + @debounce(0.01) + async def f(): + raise KeyError("x") + + results = await asyncio.gather(f(), f(), return_exceptions=True) + assert all(isinstance(r, KeyError) for r in results) + + +async def test_debounce_async_flush_and_cancel(): + calls = [] + + @debounce(10) + async def f(x): + calls.append(x) + return x + + assert await f.flush() is None + t1 = asyncio.create_task(f(1)) + t2 = asyncio.create_task(f(2)) + await asyncio.sleep(0) + assert await f.flush() == 2 + assert await t1 == 2 + assert await t2 == 2 + + t3 = asyncio.create_task(f(3)) + await asyncio.sleep(0) + f.cancel() + with pytest.raises(asyncio.CancelledError): + await t3 + assert calls == [2] + f.cancel() # nothing pending + + +async def test_debounce_async_cancel_outside_loop_context(): + @debounce(10) + async def f(): + return 1 + + task = asyncio.create_task(f()) + await asyncio.sleep(0) + loop = asyncio.get_running_loop() + # cancel() from a thread with no running loop clears every loop's burst. + await loop.run_in_executor(None, f.cancel) + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_debounce_async_caller_cancel_does_not_cancel_burst(): + calls = [] + + @debounce(0.01) + async def f(x): + calls.append(x) + return x + + t1 = asyncio.create_task(f(1)) + await asyncio.sleep(0) + t1.cancel() + assert await f(2) == 2 + assert calls == [2] + + +async def test_debounce_async_function_cancelled_while_running(): + started = asyncio.Event() + + @debounce(0) + async def f(): + started.set() + await asyncio.sleep(10) + + task = asyncio.create_task(f()) + await started.wait() + timer_tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + for t in timer_tasks: + if t is not task: + t.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +# -------------------------------------------------------------------------------------- +# singleflight +# -------------------------------------------------------------------------------------- + + +def _run_concurrently(n, call): + """Start n threads that each call ``call()``; return their futures.""" + return [run_in_thread(call)() for _ in range(n)] + + +def test_singleflight_threads_share_one_execution(): + release = threading.Event() + arrived = [] + all_arrived = threading.Event() + calls = [] + n = 6 + + def key(x): + arrived.append(x) + if len(arrived) == n: + all_arrived.set() + return x + + @singleflight(key=key) + def load(x): + calls.append(x) + release.wait(1) + return object() + + futures = _run_concurrently(n, lambda: load("k")) + assert all_arrived.wait(1) + time.sleep(0.02) # let every caller register on the in-flight call + release.set() + results = [f.result(timeout=1) for f in futures] + assert len(calls) == 1 + assert all(r is results[0] for r in results) + # finished: next call runs fresh + assert load("k") is not results[0] + assert len(calls) == 2 + + +def test_singleflight_threads_share_exception(): + release = threading.Event() + entered = threading.Event() + calls = [] + + @singleflight + def boom(x): + calls.append(x) + entered.set() + release.wait(1) + raise ValueError(x) + + leader = run_in_thread(boom)(1) + assert entered.wait(1) + follower = run_in_thread(boom)(1) + time.sleep(0.02) + release.set() + with pytest.raises(ValueError): + leader.result(timeout=1) + with pytest.raises(ValueError): + follower.result(timeout=1) + assert calls == [1] + release.clear() + release.set() + with pytest.raises(ValueError): + boom(1) + assert calls == [1, 1] + + +def test_singleflight_default_key_keeps_kwargs_apart_from_lookalike_positionals(): + assert concurrency._default_key(1, b=2) != concurrency._default_key((1,), (("b", 2),)) + assert concurrency._default_key(1, b=2) == concurrency._default_key(1, b=2) + assert concurrency._default_key(1, 2) == (1, 2) # positional calls: the args tuple + + +def test_singleflight_different_keys_run_separately(): + calls = [] + + @singleflight + def f(x, *, y=0): + calls.append((x, y)) + return x + y + + assert f(1, y=2) == 3 + assert f(1) == 1 + assert calls == [(1, 2), (1, 0)] + + +def test_singleflight_method(): + class C: + @singleflight + def m(self, x): + return self, x + + c = C() + assert c.m(1) == (c, 1) + + +async def test_singleflight_async_shares_task(): + calls = [] + release = asyncio.Event() + + @singleflight + async def load(x): + calls.append(x) + await release.wait() + return object() + + assert inspect.iscoroutinefunction(load) + tasks = [asyncio.create_task(load("k")) for _ in range(5)] + other = asyncio.create_task(load("other")) + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*tasks) + await other + assert all(r is results[0] for r in results) + assert calls == ["k", "other"] + assert await load("k") is not results[0] + + +async def test_singleflight_async_exception_and_caller_cancel(): + release = asyncio.Event() + calls = [] + + @singleflight + async def boom(): + calls.append(1) + await release.wait() + raise KeyError("x") + + t1 = asyncio.create_task(boom()) + t2 = asyncio.create_task(boom()) + await asyncio.sleep(0) + t1.cancel() # cancelling one caller does not cancel the shared work + await asyncio.sleep(0) + release.set() + with pytest.raises(KeyError): + await t2 + with pytest.raises(asyncio.CancelledError): + await t1 + assert calls == [1] + + +async def test_singleflight_async_orphaned_failure_is_not_logged(): + reported = [] + loop = asyncio.get_running_loop() + loop.set_exception_handler(lambda _loop, context: reported.append(context)) + release = asyncio.Event() + + @singleflight + async def load(): + await release.wait() + raise ValueError("backend down") + + caller = asyncio.create_task(load()) + await asyncio.sleep(0) + caller.cancel() # every caller gone; the shared task keeps running + with pytest.raises(asyncio.CancelledError): + await caller + release.set() + for _ in range(3): + await asyncio.sleep(0) + gc.collect() + await asyncio.sleep(0) + loop.set_exception_handler(None) + assert reported == [] + + +def test_singleflight_sync_reentrant_same_key_runs_directly(): + depth = [] + + @singleflight + def f(x): + depth.append(x) + if len(depth) < 3: + return f(x) # same key, on the leader's own thread + return len(depth) + + future = run_in_thread(f)("k") # guard: a regression must not hang the suite + assert future.result(timeout=2) == 3 + assert f("again") == 4 # bookkeeping was cleaned up + + +async def test_singleflight_async_reentrant_same_key_runs_directly(): + depth = [] + + @singleflight + async def f(x): + depth.append(x) + if len(depth) < 3: + return await f(x) # same key, from inside the shared task + return len(depth) + + assert await asyncio.wait_for(f("k"), 2) == 3 + + +# -------------------------------------------------------------------------------------- +# 0.2 features: timeout as a context manager, RateLimiter, multiple limits, overrides +# -------------------------------------------------------------------------------------- + + +@pytest.fixture +def events(): + seen = [] + _core.listeners.append(seen.append) + try: + yield seen + finally: + _core.listeners.remove(seen.append) + + +def test_timeout_object_is_reusable_decorator_and_repr(): + limit = timeout(1) + assert isinstance(limit, Timeout) + assert limit(lambda: 1)() == 1 + assert limit(lambda: 2)() == 2 + assert "seconds=1" in repr(limit) + + +@needs_signals +def test_timeout_sync_block_interrupts_and_restores(events): + before = signal.getsignal(signal.SIGALRM) + started = time.perf_counter() + with pytest.raises(TimeoutExpired) as info, timeout(0.02): + time.sleep(1) + assert time.perf_counter() - started < 0.5 + assert info.value.seconds == 0.02 + assert "" in str(info.value) + assert signal.getsignal(signal.SIGALRM) is before + assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0) + assert [(e.name, e.function, e.fields) for e in events] == [ + ("timeout.expired", "", {"seconds": 0.02}) + ] + + +@needs_signals +def test_timeout_sync_block_passes_and_nests(): + limit = timeout(5) + with limit as entered: + assert entered is limit + with pytest.raises(TimeoutExpired), timeout(0.02): + time.sleep(1) + remaining, _ = signal.getitimer(signal.ITIMER_REAL) + assert 4 < remaining <= 5 # the outer timer was re-armed + with limit: # the same object can be entered again while active + pass + assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0) + + +def test_timeout_sync_block_needs_signals_and_main_thread(monkeypatch): + with pytest.raises(RuntimeError, match="main thread"), timeout(1, use_signals=False): + pass + + def in_thread(): + with timeout(1): + pass + + assert isinstance(run_in_thread(in_thread)().exception(timeout=1), RuntimeError) + monkeypatch.setattr(concurrency, "_HAS_SETITIMER", False) + with pytest.raises(RuntimeError), timeout(1): + pass + + +async def test_timeout_async_block(events): + started = time.perf_counter() + with pytest.raises(TimeoutExpired) as info: + async with timeout(0.02): + await asyncio.sleep(1) + assert time.perf_counter() - started < 0.5 + assert info.value.seconds == 0.02 + assert isinstance(info.value.__cause__, TimeoutError) + assert [e.name for e in events] == ["timeout.expired"] + + limit = timeout(1) + async with limit as entered: + assert entered is limit + await asyncio.sleep(0) + assert limit._timers == {} + + +async def test_timeout_async_block_inner_errors_propagate_unchanged(): + with pytest.raises(TimeoutError) as info: + async with timeout(1): + raise TimeoutError("mine") + assert type(info.value) is TimeoutError + with pytest.raises(KeyError): + async with timeout(1): + raise KeyError + + +async def test_timeout_async_block_shared_by_concurrent_tasks(): + limit = timeout(0.05) + + async def quick(n): + async with limit: + await asyncio.sleep(0) + return n + + async def slow(): + async with limit: + await asyncio.sleep(1) + + results = await asyncio.gather(quick(1), slow(), quick(2), return_exceptions=True) + assert results[0] == 1 and results[2] == 2 + assert isinstance(results[1], TimeoutExpired) + assert limit._timers == {} + + +def test_timeout_worker_expiry_emits_event(events): + @timeout(0.02, use_signals=False) + def slow(): + time.sleep(0.1) + + with pytest.raises(TimeoutExpired): + slow() + assert [e.name for e in events] == ["timeout.expired"] + assert events[0].function.endswith("slow") + + +# -- RateLimiter ----------------------------------------------------------------------- + + +def test_rate_limiter_validates(): + with pytest.raises(TypeError): + RateLimiter() + with pytest.raises(TypeError): + RateLimiter(5) + with pytest.raises(ValueError): + RateLimiter(limits=[(1, 1.0), (0, 5.0)]) + with pytest.raises(TypeError): + rate_limit() + assert "limits=[(2, 1.0)]" in repr(RateLimiter(2, 1.0)) + + +def test_rate_limiter_shared_by_several_functions(fake_clock, events): + limiter = RateLimiter(2, 10, name="api") + + @limiter + def a(): + return "a" + + @limiter + def b(): + return "b" + + assert a() == "a" + assert b() == "b" + with pytest.raises(RateLimitExceeded) as info: + a() + assert "api rate limit of 2 calls per 10s" in str(info.value) + assert info.value.retry_after == pytest.approx(10) + assert [(e.name, e.function) for e in events] == [("rate_limit.rejected", "api")] + assert events[0].fields == {"retry_after": pytest.approx(10)} + + +def test_rate_limiter_try_acquire_and_acquire(fake_clock, events): + limiter = RateLimiter(1, 5) + assert limiter.try_acquire() + assert not limiter.try_acquire() + assert not limiter.acquire(block=False) + assert not limiter.acquire(timeout=1) # next slot is 5s away: give up at once + assert fake_clock.sleeps == [] + start = fake_clock.now + assert limiter.acquire() + assert fake_clock.now == start + 5 + assert limiter.acquire(timeout=10) + assert fake_clock.now == start + 10 + names = [e.name for e in events] + assert names == ["rate_limit.rejected"] * 3 + ["rate_limit.waited"] * 2 + assert events[-1].fields == {"seconds": pytest.approx(5)} + assert events[-1].function == "rate_limiter" + + +def test_rate_limiter_with_block(fake_clock): + raising = RateLimiter(1, 5) + with raising as entered: + assert entered is raising + with pytest.raises(RateLimitExceeded), raising: + pass + waiting = RateLimiter(1, 5, block=True) + with waiting: + pass + with waiting: + pass + assert fake_clock.sleeps == [5] + + +async def test_rate_limiter_async(fake_clock, events): + limiter = RateLimiter(1, 3, block=True, name="svc") + + @limiter + async def f(): + return fake_clock.now + + assert inspect.iscoroutinefunction(f) + start = fake_clock.now + assert await f() == start + async with limiter as entered: + assert entered is limiter + assert fake_clock.now == start + 3 + assert not await limiter.acquire_async(block=False) + assert not await limiter.acquire_async(timeout=1) + assert await limiter.acquire_async() + assert fake_clock.now == start + 6 + strict = RateLimiter(1, 3) + async with strict: + pass + with pytest.raises(RateLimitExceeded): + async with strict: + pass + assert "rate_limit.waited" in [e.name for e in events] + + +def test_multiple_limits_all_must_allow(fake_clock): + @rate_limit(limits=[(2, 1.0), (3, 10.0)]) + def f(): + return fake_clock.now + + f() + f() + with pytest.raises(RateLimitExceeded) as info: + f() # the 1-second limit is full + assert info.value.retry_after == pytest.approx(1) + assert "2 calls per 1s and 3 calls per 10s" in str(info.value) + fake_clock.advance(1) + f() + with pytest.raises(RateLimitExceeded) as info: + f() # now the 10-second limit is full + assert info.value.retry_after == pytest.approx(9) + fake_clock.advance(9) + f() + + +def test_multiple_limits_rejected_call_is_not_recorded(fake_clock): + limiter = RateLimiter(5, 1.0, limits=[(1, 10.0)]) + assert limiter.try_acquire() + for _ in range(10): + assert not limiter.try_acquire() + fake_clock.advance(10) + assert limiter.try_acquire() # rejected tries did not use up the 1-second limit + + +def test_multiple_limits_block_waits_for_longest(fake_clock): + @rate_limit(1, 1.0, limits=[(2, 10.0)], block=True) + def f(): + return fake_clock.now + + start = fake_clock.now + assert [f(), f(), f()] == [start, start + 1, start + 10] + + +def test_rate_limit_key_with_multiple_limits(fake_clock): + @rate_limit(limits=[(1, 5.0)], key=lambda user: user) + def send(user): + return user + + assert send("a") == "a" + assert send("b") == "b" + with pytest.raises(RateLimitExceeded): + send("a") + + +def test_rate_limits_disabled_override(fake_clock): + limiter = RateLimiter(1, 100) + + @rate_limit(1, 100) + def f(): + return 1 + + @rate_limit(1, 100, block=True) + async def g(): + return 2 + + @throttle(100) + def t(): + return fake_clock.now + + assert f() == 1 + assert t() == fake_clock.now + assert limiter.try_acquire() + with disable_rate_limits(): + assert [f() for _ in range(5)] == [1] * 5 + assert all(limiter.try_acquire() for _ in range(5)) + with limiter: + pass + assert asyncio.run(g()) == 2 + assert asyncio.run(g()) == 2 + fake_clock.advance(1) + assert t() == fake_clock.now # throttle lets every call run + assert fake_clock.sleeps == [] + with pytest.raises(RateLimitExceeded): + f() + assert not limiter.try_acquire() + + +def test_rate_limit_override_flipped_while_waiting(fake_clock, monkeypatch): + limiter = RateLimiter(1, 100, block=True) + limiter.try_acquire() + + def sleep(seconds): + _core.overrides.disable_rate_limits = True + + monkeypatch.setattr(_core, "sleep", sleep) + try: + with limiter: + pass + finally: + _core.overrides.disable_rate_limits = False + + +async def test_rate_limit_override_flipped_while_waiting_async(fake_clock, monkeypatch): + limiter = RateLimiter(1, 100, block=True) + limiter.try_acquire() + + async def async_sleep(seconds): + _core.overrides.disable_rate_limits = True + + monkeypatch.setattr(_core, "async_sleep", async_sleep) + try: + assert await limiter.acquire_async() + finally: + _core.overrides.disable_rate_limits = False + + +def test_rate_limiter_thread_safe_shared_budget(fake_clock): + limiter = RateLimiter(30, 100) + hits = [] + + @limiter + def a(): + hits.append("a") + + @limiter + def b(): + hits.append("b") + + barrier = threading.Barrier(6) + + def worker(fn): + barrier.wait() + for _ in range(20): + with contextlib.suppress(RateLimitExceeded): + fn() + + threads = [threading.Thread(target=worker, args=(a if i % 2 else b,)) for i in range(6)] + for th in threads: + th.start() + for th in threads: + th.join() + assert len(hits) == 30 + + +def test_throttle_skipped_event(fake_clock, events): + @throttle(10) + def f(): + return 1 + + f() + f() + f() + assert [e.name for e in events] == ["throttle.skipped"] * 2 + assert events[0].function.endswith("f") + + +# -------------------------------------------------------------------------------------- +# 0.2 performance: pooled timeout workers, cheaper singleflight, limiter access +# -------------------------------------------------------------------------------------- + + +def _worker_thread(**opts): + @timeout(1, use_signals=False, **opts) + def which(): + return threading.current_thread() + + return which + + +def test_timeout_fallback_reuses_idle_worker_and_copies_context(): + import contextvars + + var = contextvars.ContextVar("var", default="unset") + + @timeout(1, use_signals=False) + def probe(): + return threading.current_thread(), var.get() + + var.set("first") + t1, v1 = probe() + var.set("second") + t2, v2 = probe() + assert t1 is t2 # the idle worker was reused, not a new thread per call + assert t1.name.startswith("timeout-") and t1.daemon + assert (v1, v2) == ("first", "second") # contextvars copied per call + + +def test_timeout_fallback_abandons_worker_of_timed_out_call(): + release = threading.Event() + stuck = [] + + @timeout(0.02, use_signals=False) + def slow(): + stuck.append(threading.current_thread()) + release.wait(2) + + with pytest.raises(TimeoutExpired): + slow() + try: + # The stuck worker is never handed out again while it is busy. + assert _worker_thread()() is not stuck[0] + assert all(t is not stuck[0] for t in (_worker_thread()() for _ in range(3))) + finally: + release.set() + stuck[0].join(2) + assert not stuck[0].is_alive() # abandoned: it exits instead of rejoining the pool + + +def test_timeout_fallback_workers_exit_when_idle(monkeypatch): + monkeypatch.setattr(concurrency, "_WORKER_IDLE_SECONDS", 0.02) + first = _worker_thread()() + second = _worker_thread()() # the same worker is reused while it is idle + assert first is second + first.join(2) # then it exits after being idle for _WORKER_IDLE_SECONDS + assert not first.is_alive() + assert _worker_thread()() is not first # a new worker is started on demand + + +def test_timeout_fallback_keeps_a_bounded_idle_pool(monkeypatch): + monkeypatch.setattr(concurrency, "_MAX_IDLE_WORKERS", 2) + n = 6 + barrier = threading.Barrier(n) + + @timeout(2, use_signals=False) + def wait_for_all(): + barrier.wait(2) + return threading.current_thread() + + futures = [run_in_thread(wait_for_all)() for _ in range(n)] + workers = {f.result(timeout=3) for f in futures} + assert len(workers) == n + with concurrency._workers.lock: + assert len(concurrency._workers.idle) <= 2 + deadline = time.monotonic() + 2 + while sum(w.is_alive() for w in workers) > 2: # the extra ones exit once done + assert time.monotonic() < deadline + time.sleep(0.001) + + +def test_timeout_fallback_propagates_exception_from_pooled_worker(): + @timeout(1, use_signals=False) + def boom(): + raise LookupError("nope") + + with pytest.raises(LookupError, match="nope"): + boom() + with pytest.raises(LookupError): + boom() # the worker survived the exception and is reused + + +def test_timeout_worker_claimed_while_giving_up_still_runs_the_job(): + import queue as queue_mod + + pool = concurrency._WorkerPool() + job = concurrency._Job(lambda: "ran", (), {}) + + class Inbox: + """The idle wait times out just as a caller claims the worker and sends a job.""" + + def __init__(self): + self.real = queue_mod.SimpleQueue() + self.gets = 0 + + def get(self, timeout): + self.gets += 1 + if self.gets == 1: + with pool.lock: + assert pool.idle.pop() is self + self.real.put(job) + raise queue_mod.Empty + return self.real.get(timeout=timeout) + + inbox = Inbox() + pool.idle.append(inbox) # type: ignore[arg-type] + job.abandoned = True # so the worker exits once the job is done + thread = threading.Thread(target=pool._work, args=(inbox,), daemon=True) + thread.start() + assert job.done.acquire(timeout=2) # it kept waiting and ran the job + assert job.result == "ran" + thread.join(2) + assert not thread.is_alive() + + +def test_timeout_worker_pool_reset_after_fork(): + pool = concurrency._WorkerPool() + pool.idle.append(object()) # type: ignore[arg-type] + pool.reset() + assert pool.idle == [] + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="eager tasks need Python 3.12+") +def test_singleflight_async_with_eager_task_factory(): + calls = [] + + @singleflight + async def load(k): + calls.append(k) + return len(calls) # finishes without suspending + + async def main(): + asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory) + first = [await load(1) for _ in range(3)] + release = asyncio.Event() + + @singleflight + async def slow(k): + calls.append(k) + await release.wait() + return k + + tasks = [asyncio.create_task(slow("s")) for _ in range(3)] + release.set() + return first, await asyncio.gather(*tasks) + + assert asyncio.run(main()) == ([1, 2, 3], ["s", "s", "s"]) + assert calls == [1, 1, 1, "s"] + + +async def test_singleflight_async_uncontended_needs_few_loop_iterations(): + @singleflight + async def load(k): + return k + + ticks = 0 + stop = False + + async def ticker(): + nonlocal ticks + while not stop: + ticks += 1 + await asyncio.sleep(0) + + t = asyncio.create_task(ticker()) + await asyncio.sleep(0) + before = ticks + assert await load(1) == 1 + spent = ticks - before + stop = True + await t + # 3.12+: the run starts eagerly and finishes before the caller waits at all. + # Before: the run's step, then the caller's wake-up (it was 3 with a relay). + assert spent <= (0 if sys.version_info >= (3, 12) else 2) + + +async def test_singleflight_async_only_caller_cancelled_run_carries_on(): + release = asyncio.Event() + finished = [] + + @singleflight + async def load(): + await release.wait() + finished.append(1) + return 1 + + caller = asyncio.create_task(load()) + await asyncio.sleep(0) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + follower = asyncio.create_task(load()) # joins the still-running flight + await asyncio.sleep(0) + release.set() + assert await follower == 1 + assert finished == [1] + + +async def test_singleflight_async_shared_run_cancelled_cancels_callers(): + started = asyncio.Event() + runs = [] + + @singleflight + async def load(): + runs.append(1) + started.set() + if len(runs) == 1: + await asyncio.sleep(10) + return len(runs) + + callers = [asyncio.create_task(load()) for _ in range(2)] + await started.wait() + me = asyncio.current_task() + (run_task,) = [t for t in asyncio.all_tasks() if t not in callers and t is not me] + run_task.cancel() + for c in callers: + with pytest.raises(asyncio.CancelledError): + await c + assert await load() == 2 # the cancelled flight was cleaned up + + +def test_rate_limiter_reset_and_limiter_of(fake_clock): + @rate_limit(1, 60.0) + def ping(): + return "pong" + + assert ping() == "pong" + with pytest.raises(RateLimitExceeded): + ping() + limiter = concurrency.limiter_of(ping) + assert isinstance(limiter, RateLimiter) + limiter.reset() + assert ping() == "pong" + + shared = RateLimiter(1, 60.0) + + @shared + async def aping(): + return "apong" + + assert concurrency.limiter_of(aping) is shared + assert asyncio.run(aping()) == "apong" + assert not shared.try_acquire() + shared.reset() + assert shared.try_acquire() + + class Client: + @rate_limit(1, 60.0) + def get(self): + return 1 + + assert concurrency.limiter_of(Client().get) is concurrency.limiter_of(Client.get) + assert concurrency.limiter_of(lambda: None) is None + + +def test_circuit_breaker_wrapper_does_not_redetect_async(monkeypatch): + from python_wrap.circuit import CircuitBreaker + + breaker = CircuitBreaker() + + @breaker + def f(): + return 1 + + def fail(_func): + raise AssertionError("is_async called per call") + + monkeypatch.setattr(_core, "is_async", fail) + assert f() == 1 + + +async def test_singleflight_async_flight_outcome_races(): + loop = asyncio.get_running_loop() + flight = concurrency._AsyncFlight() + done = loop.create_future() + done.cancel() + pending = loop.create_future() + flight.waiters += [done, pending] + flight.deliver(5) # skips the waiter that is already done + assert pending.result() == 5 and flight.waiters == [] + + # The outcome arrives in the same loop step as the caller's cancellation. + waiter = loop.create_future() + flight.waiters.append(waiter) + caller = asyncio.create_task(concurrency._await_flight(flight, waiter)) + await asyncio.sleep(0) + flight.deliver(exc=ValueError("late")) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + assert waiter.exception() is not None # retrieved, so never logged + + +def test_timeout_fallback_result_arriving_after_the_limit_is_a_timeout(monkeypatch, events): + now = [100.0] # the late-result check reads real time: fake it for this module only + monkeypatch.setattr(concurrency, "time", types.SimpleNamespace(monotonic=lambda: now[0])) + + @timeout(1, use_signals=False) + def late(): + now[0] += 2 # finishes, but only after its limit (an overslept wait) + return "late" + + with pytest.raises(TimeoutExpired): + late() + assert [e.name for e in events] == ["timeout.expired"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..79c6d27 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterator + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.config import configure, configured, get_config, reset_config +from python_wrap.deadline import deadline +from python_wrap.logs import audit, log_args +from python_wrap.retry import retry, retry_stats, retrying + + +@pytest.fixture(autouse=True) +def clean_config() -> Iterator[None]: + reset_config() + try: + yield + finally: + reset_config() + + +class Flaky: + def __init__(self, failures: int) -> None: + self.failures = failures + self.calls = 0 + + def __call__(self) -> str: + self.calls += 1 + if self.calls <= self.failures: + raise ValueError(f"fail {self.calls}") + return "ok" + + +def test_defaults_and_get_config_is_a_copy() -> None: + cfg = get_config() + assert cfg == {"logger": None, "retry": {}, "redact": None, "redact_extra": ()} + cfg["retry"]["delay"] = 5 + assert get_config()["retry"] == {} + + +def test_unknown_key_has_hint() -> None: + with pytest.raises(TypeError, match="Did you mean 'retry'"): + configure(retyr={}) + with pytest.raises(TypeError, match="Did you mean 'max_attempts'"): + configure(retry={"max_attemps": 3}) + with pytest.raises(TypeError, match="Unknown configuration key 'zzz'"): + configure(zzz=1) + + +def test_validation() -> None: + with pytest.raises(ValueError): + configure(retry={"max_attempts": 0}) + with pytest.raises(ValueError): + configure(retry={"backoff": 0.5}) + with pytest.raises(TypeError): + configure(retry=[1]) + with pytest.raises(TypeError): + configure(logger=3) + with pytest.raises(TypeError): + configure(redact="password") + with pytest.raises(TypeError): + configure(redact_extra=[1]) + assert get_config()["retry"] == {} # failed calls change nothing + + +def test_retry_defaults_apply_and_explicit_wins(fake_clock: FakeClock) -> None: + configure(retry={"max_attempts": 5, "delay": 0.5, "backoff": 2.0}) + f = Flaky(3) + assert retry(f)() == "ok" + assert fake_clock.sleeps == [0.5, 1.0, 2.0] + + g = Flaky(10) + with pytest.raises(ValueError): + retry(g, max_attempts=2)() + assert g.calls == 2 + + +def test_retry_defaults_max_delay_max_time_log_level( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + configure( + retry={ + "max_attempts": None, + "delay": 1, + "backoff": 10, + "max_delay": 3, + "jitter": 0.0, + "max_time": 7, + "log_level": logging.ERROR, + } + ) + f = Flaky(100) + with caplog.at_level(logging.ERROR, logger="python_wrap"), pytest.raises(ValueError): + retry(f)() + assert fake_clock.sleeps == [1, 3, 3] + assert caplog.records + assert all(r.levelno == logging.ERROR for r in caplog.records) + + +def test_retry_default_jitter(fake_clock: FakeClock) -> None: + configure(retry={"delay": 1, "jitter": 0.5}) + retry(Flaky(1))() + assert 1 <= fake_clock.sleeps[0] <= 1.5 + + +def test_retry_default_none_disables_log(caplog: pytest.LogCaptureFixture) -> None: + configure(retry={"delay": 0, "log_level": None}) + with caplog.at_level(logging.DEBUG, logger="python_wrap"): + assert retry(Flaky(1))() == "ok" + assert not caplog.records + + +def test_retrying_uses_defaults(fake_clock: FakeClock) -> None: + configure(retry={"max_attempts": 2, "delay": 4}) + f = Flaky(10) + with pytest.raises(ValueError): + for attempt in retrying(): + with attempt: + f() + assert f.calls == 2 + assert fake_clock.sleeps == [4] + + +def test_decorators_applied_earlier_keep_their_settings(fake_clock: FakeClock) -> None: + early = retry(Flaky(10)) + configure(retry={"max_attempts": 7}) + late = retry(Flaky(10)) + with pytest.raises(ValueError): + early() + with pytest.raises(ValueError): + late() + stats = retry_stats(early) + assert stats is not None and stats.attempts_total == 3 + stats = retry_stats(late) + assert stats is not None and stats.attempts_total == 7 + + +def test_logger_default(caplog: pytest.LogCaptureFixture) -> None: + mine = logging.getLogger("my.app") + configure(logger=mine) + assert _core.get_logger(None) is mine + assert _core.get_logger("other").name == "other" + + @log_args(level=logging.INFO) + def f(x: int) -> int: + return x + + with caplog.at_level(logging.INFO, logger="my.app"): + f(1) + assert [r.name for r in caplog.records] == ["my.app"] + + configure(logger="by.name") + assert _core.get_logger(None).name == "by.name" + configure(logger=None) + assert _core.get_logger(None).name == _core.LOGGER_NAME + + +def test_configured_restores() -> None: + mine = logging.getLogger("my.app") + configure(retry={"delay": 9}) + with configured(logger=mine, retry={"max_attempts": 2}, redact_extra=["ssn"]): + cfg = get_config() + assert cfg["retry"] == {"max_attempts": 2} + assert cfg["redact_extra"] == ("ssn",) + assert _core.get_logger(None) is mine + assert get_config()["retry"] == {"delay": 9} + assert get_config()["redact_extra"] == () + assert _core.get_logger(None).name == _core.LOGGER_NAME + + +def test_configured_restores_on_error() -> None: + with pytest.raises(RuntimeError), configured(retry={"max_attempts": 2}): + raise RuntimeError + assert get_config()["retry"] == {} + + +def test_configure_merges_keys_but_replaces_values() -> None: + configure(retry={"delay": 1}, redact_extra=["a"]) + configure(retry={"jitter": 0.1}) + cfg = get_config() + assert cfg["retry"] == {"jitter": 0.1} + assert cfg["redact_extra"] == ("a",) + + +def test_redact_config_for_log_args_and_audit(caplog: pytest.LogCaptureFixture) -> None: + configure(redact=["ssn"]) + + @log_args(level=logging.INFO) + def f(ssn: str, password: str) -> None: ... + + with caplog.at_level(logging.INFO, logger="python_wrap"): + f("123", "pw") + msg = caplog.records[-1].getMessage() + assert "ssn='***'" in msg and "password='pw'" in msg # configured list replaced defaults + + records: list[object] = [] + + @audit(sink=records.append) + def g(ssn: str, password: str) -> None: ... + + g("123", "pw") + text = repr(records[0]) + assert "123" not in text and "pw" in text + + +def test_redact_extra_adds_to_defaults_everywhere(caplog: pytest.LogCaptureFixture) -> None: + @log_args(level=logging.INFO, redact_extra=["ssn"]) + def f(ssn: str, password: str, name: str) -> None: ... + + with caplog.at_level(logging.INFO, logger="python_wrap"): + f("123", "pw", "bob") + msg = caplog.records[-1].getMessage() + assert "ssn='***'" in msg and "password='***'" in msg and "name='bob'" in msg + + configure(redact_extra=["name"]) + records: list[object] = [] + + @audit(redact_extra=["ssn"], sink=records.append) + def g(ssn: str, token: str, name: str, other: str) -> None: ... + + g("1", "s3cr3t", "bob", "keep") + text = repr(records[0]) + assert "bob" not in text and "s3cr3t" not in text and "'1'" not in text + assert "keep" in text + + +def test_redact_extra_with_explicit_redact(caplog: pytest.LogCaptureFixture) -> None: + @log_args(level=logging.INFO, redact=["xx"], redact_extra=["yy"]) + def f(xx: int, yy: int, password: str) -> None: ... + + with caplog.at_level(logging.INFO, logger="python_wrap"): + f(1, 2, "pw") + msg = caplog.records[-1].getMessage() + assert "xx='***'" in msg and "yy='***'" in msg and "password='pw'" in msg + + +def test_retry_respects_deadline(fake_clock: FakeClock) -> None: + f = Flaky(100) + with deadline(5), pytest.raises(ValueError): + retry(f, max_attempts=None, delay=2)() + # sleeps 2, 2 (t=4); the next 2s would run past the 5s deadline + assert fake_clock.sleeps == [2, 2] + assert f.calls == 3 + + +def test_retry_deadline_already_expired(fake_clock: FakeClock) -> None: + f = Flaky(100) + with deadline(0), pytest.raises(ValueError): + retry(f, delay=0.1)() + assert fake_clock.sleeps == [] + assert f.calls == 1 + + +def test_retry_without_deadline_unchanged(fake_clock: FakeClock) -> None: + f = Flaky(2) + assert retry(f, delay=100)() == "ok" + assert fake_clock.sleeps == [100, 100] + + +def test_async_retry_respects_deadline(fake_clock: FakeClock) -> None: + import asyncio + + f = Flaky(100) + + @retry(max_attempts=None, delay=2) + async def call() -> str: + return f() + + async def run() -> None: + with deadline(3): + await call() + + with pytest.raises(ValueError): + asyncio.run(run()) + assert fake_clock.sleeps == [2] + + +def test_retrying_block_respects_deadline(fake_clock: FakeClock) -> None: + f = Flaky(100) + with deadline(1), pytest.raises(ValueError): + for attempt in retrying(max_attempts=None, delay=2): + with attempt: + f() + assert f.calls == 1 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..a75de3b --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,109 @@ +import functools +import logging +import subprocess +import sys + +import python_wrap +from python_wrap import _core + + +def test_import_does_not_configure_root_logging(): + # A fresh interpreter, so handlers added by pytest or other tests cannot interfere. + code = ( + "import logging, python_wrap;" + "assert logging.getLogger().handlers == [], logging.getLogger().handlers;" + "assert any(isinstance(h, logging.NullHandler)" + " for h in logging.getLogger('python_wrap').handlers)" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_get_logger_resolution(): + custom = logging.getLogger("custom") + assert _core.get_logger(custom) is custom + assert _core.get_logger("other").name == "other" + assert _core.get_logger().name == "python_wrap" + + +def test_is_async_sees_through_partial(): + async def coro(x): + return x + + def plain(x): + return x + + assert _core.is_async(coro) + assert _core.is_async(functools.partial(coro, 1)) + assert not _core.is_async(plain) + + +def test_version_exposed(): + assert python_wrap.__version__.startswith("0.2") + + +class _AsyncCallable: + async def __call__(self, x): + return x + + +class _SyncCallable: + def __call__(self, x): + return x + + +def test_is_async_detects_callable_objects(): + assert _core.is_async(_AsyncCallable()) + assert _core.is_async(functools.partial(_AsyncCallable(), 1)) + assert _core.is_async(_AsyncCallable().__call__) + assert not _core.is_async(_SyncCallable()) + # A class is callable but calling it constructs an instance: never async. + assert not _core.is_async(_AsyncCallable) + assert not _core.is_async(_SyncCallable) + assert not _core.is_async(len) + + +def test_is_async_callable_instance_with_descriptor() -> None: + class Spyish: + async def __call__(self) -> None: ... + + def __get__(self, obj: object, owner: object = None) -> object: + return self + + assert _core.is_async(Spyish()) + + +def test_overrides_defaults_and_set_overrides_returns_previous() -> None: + assert _core.overrides == _core.Overrides() + previous = _core.set_overrides(no_sleep=True, max_attempts=3) + try: + assert previous == {"no_sleep": False, "max_attempts": None} + assert _core.overrides.no_sleep and _core.overrides.max_attempts == 3 + finally: + _core.set_overrides(**previous) + assert _core.overrides == _core.Overrides() + + +def test_set_overrides_rejects_unknown_and_invalid() -> None: + import pytest + + with pytest.raises(TypeError, match="bogus"): + _core.set_overrides(bogus=True) + with pytest.raises(ValueError): + _core.set_overrides(max_attempts=0) + assert _core.overrides == _core.Overrides() + + +def test_sleep_honours_no_sleep(monkeypatch) -> None: # type: ignore[no-untyped-def] + import asyncio + import time + + slept: list[float] = [] + monkeypatch.setattr(time, "sleep", slept.append) + _core.sleep(1) + previous = _core.set_overrides(no_sleep=True) + try: + _core.sleep(2) + asyncio.run(_core.async_sleep(3)) + finally: + _core.set_overrides(**previous) + assert slept == [1] diff --git a/tests/test_deadline.py b/tests/test_deadline.py new file mode 100644 index 0000000..f61af82 --- /dev/null +++ b/tests/test_deadline.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import asyncio +import contextvars +import signal +import time +from collections.abc import Iterator + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.concurrency import timeout +from python_wrap.deadline import Deadline, check, deadline, remaining +from python_wrap.errors import TimeoutExpired + +needs_signals = pytest.mark.skipif(not hasattr(signal, "setitimer"), reason="needs setitimer") + + +@pytest.fixture +def events() -> Iterator[list[_core.Event]]: + seen: list[_core.Event] = [] + _core.listeners.append(seen.append) + try: + yield seen + finally: + _core.listeners.remove(seen.append) + + +def test_no_deadline_by_default() -> None: + assert remaining() is None + check() # no-op + + +def test_validates() -> None: + with pytest.raises(ValueError): + deadline(-1) + with deadline(0): + assert remaining() == 0.0 + + +def test_remaining_and_check(fake_clock: FakeClock) -> None: + with deadline(10) as d: + assert isinstance(d, Deadline) + assert remaining() == pytest.approx(10) + assert d.expires_at == pytest.approx(fake_clock.now + 10) + fake_clock.advance(4) + assert remaining() == pytest.approx(6) + check() + fake_clock.advance(7) + assert remaining() == 0.0 # never negative + with pytest.raises(TimeoutExpired) as info: + check() + assert info.value.seconds == 10 + assert isinstance(info.value, TimeoutError) + assert remaining() is None + assert d.expires_at is None + assert "Deadline(seconds=10" in repr(d) + + +def test_nesting_earliest_wins(fake_clock: FakeClock) -> None: + with deadline(5): + with deadline(60): + assert remaining() == pytest.approx(5) # cannot extend the outer budget + with deadline(1): + assert remaining() == pytest.approx(1) + fake_clock.advance(1) + with pytest.raises(TimeoutExpired) as info: + check() + assert info.value.seconds == 1 + assert remaining() == pytest.approx(4) + assert remaining() is None + + +def test_same_instance_reentered(fake_clock: FakeClock) -> None: + d = deadline(3) + with d: + fake_clock.advance(1) + with d: + assert remaining() == pytest.approx(2) # the earlier expiry stays + assert remaining() == pytest.approx(2) + assert remaining() is None + + +def test_async_and_task_propagation(fake_clock: FakeClock) -> None: + async def child() -> float | None: + return remaining() + + async def scenario() -> None: + async with deadline(8): + assert await asyncio.create_task(child()) == pytest.approx(8) + assert await asyncio.create_task(child()) is None + + asyncio.run(scenario()) + + +def test_context_is_isolated_between_contexts(fake_clock: FakeClock) -> None: + ctx = contextvars.copy_context() + with deadline(2): + assert ctx.run(remaining) is None + + +async def test_timeout_decorator_clamps_to_deadline_async(events: list[_core.Event]) -> None: + @timeout(5) + async def slow() -> None: + await asyncio.sleep(1) + + started = time.perf_counter() + with deadline(0.02), pytest.raises(TimeoutExpired) as info: + await slow() + assert time.perf_counter() - started < 0.5 + assert info.value.seconds < 0.03 + assert [e.name for e in events] == ["timeout.expired"] + + +async def test_timeout_block_clamps_to_deadline_async() -> None: + with deadline(0.02), pytest.raises(TimeoutExpired) as info: + async with timeout(5): + await asyncio.sleep(1) + assert info.value.seconds < 0.03 + + +@needs_signals +def test_timeout_clamps_to_deadline_sync() -> None: + @timeout(5) + def slow() -> None: + time.sleep(1) + + with deadline(0.02), pytest.raises(TimeoutExpired) as info: + slow() + assert info.value.seconds < 0.03 + + +def test_timeout_thread_strategy_clamps_to_deadline() -> None: + @timeout(5, use_signals=False) + def slow() -> None: + time.sleep(0.2) + + with deadline(0.02), pytest.raises(TimeoutExpired) as info: + slow() + assert info.value.seconds < 0.03 + + +@needs_signals +def test_timeout_not_started_when_deadline_passed( + fake_clock: FakeClock, events: list[_core.Event] +) -> None: + calls: list[int] = [] + + @timeout(5) + def f() -> None: + calls.append(1) + + with deadline(1): + fake_clock.advance(1) + with pytest.raises(TimeoutExpired) as info: + f() + assert info.value.seconds == 0.0 + with pytest.raises(TimeoutExpired), timeout(5): + calls.append(2) + assert calls == [] + assert [e.name for e in events] == ["timeout.expired", "timeout.expired"] + + +def test_timeout_longer_deadline_keeps_configured_limit(fake_clock: FakeClock) -> None: + @timeout(0.5) + def f() -> int: + return 1 + + with deadline(100): + assert f() == 1 diff --git a/tests/test_decorators.py b/tests/test_decorators.py deleted file mode 100644 index 0703851..0000000 --- a/tests/test_decorators.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Tests for the PythonWrap decorators. -""" - -import time -import pytest -from python_wrap.decorators import ( - timing, - retry, - memoize, - log_args, - log_return, - validate_args, - cache, - once, - deprecate, - check_type, - retry_on_exception, - measure_memory, - profile, - rate_limit, - mock_data, - benchmark, - run_in_thread, - no_debug, - transactional, - revert_on_failure, - audit, - trace, - timeout, -) -from datetime import timedelta - -def test_timing(): - @timing - def slow_function(): - time.sleep(0.1) - return True - - assert slow_function() is True - -def test_retry(): - attempts = 0 - - @retry(max_attempts=3, delay=0.1) - def failing_function(): - nonlocal attempts - attempts += 1 - if attempts < 3: - raise ValueError("Temporary error") - return True - - assert failing_function() is True - assert attempts == 3 - -def test_memoize(): - calls = 0 - - @memoize - def expensive_function(x): - nonlocal calls - calls += 1 - return x * 2 - - assert expensive_function(5) == 10 - assert expensive_function(5) == 10 - assert calls == 1 - -def test_validate_args(): - @validate_args(x=lambda x: x > 0) - def positive_only(x): - return x - - assert positive_only(5) == 5 - with pytest.raises(ValueError): - positive_only(-1) - -def test_cache(): - calls = 0 - - @cache(duration=timedelta(seconds=1)) - def cached_function(): - nonlocal calls - calls += 1 - return calls - - assert cached_function() == 1 - assert cached_function() == 1 - time.sleep(1.1) - assert cached_function() == 2 - -def test_once(): - calls = 0 - - @once - def run_once(): - nonlocal calls - calls += 1 - return calls - - assert run_once() == 1 - assert run_once() == 1 - assert calls == 1 - -def test_check_type(): - @check_type(x=int, y=str) - def typed_function(x, y): - return f"{x}{y}" - - assert typed_function(1, "2") == "12" - with pytest.raises(TypeError): - typed_function("1", 2) - -def test_mock_data(): - @mock_data(42) - def real_function(): - return 0 - - assert real_function() == 42 - -def test_timeout(): - @timeout(1) - def quick_function(): - return True - - @timeout(1) - def slow_function(): - time.sleep(2) - return True - - assert quick_function() is True - with pytest.raises(TimeoutError): - slow_function() - -# Add more tests for other decorators as needed \ No newline at end of file diff --git a/tests/test_devtools.py b/tests/test_devtools.py new file mode 100644 index 0000000..0dd6415 --- /dev/null +++ b/tests/test_devtools.py @@ -0,0 +1,574 @@ +"""Tests for python_wrap.devtools.""" + +from __future__ import annotations + +import asyncio +import functools +import inspect +import os +from typing import Any + +import pytest + +from python_wrap.devtools import compose, deprecate, mock_data, no_debug, wrap_methods +from python_wrap.policy import policy + + +def _deprecation_text(obj: Any) -> str: + return str(obj.__deprecated__) + + +# --- deprecate ------------------------------------------------------------- + + +def test_legacy_message_form_warns_and_returns_value() -> None: + @deprecate("Use new_func instead") + def old(x: int) -> int: + return x * 2 + + with pytest.warns(DeprecationWarning, match="Use new_func instead") as record: + assert old(3) == 6 + assert str(record[0].message) == "Use new_func instead" + + +def test_bare_decorator_generates_message() -> None: + @deprecate + def old() -> str: + return "ok" + + with pytest.warns(DeprecationWarning) as record: + assert old() == "ok" + assert str(record[0].message) == f"{old.__qualname__} is deprecated." + + +@pytest.mark.parametrize( + ("kwargs", "suffix"), + [ + ({"since": "0.2"}, " since 0.2."), + ({"removed_in": "1.0"}, " and will be removed in 1.0."), + ({"alternative": "new_func"}, "; use new_func instead."), + ( + {"since": "0.2", "removed_in": "1.0", "alternative": "new_func"}, + " since 0.2 and will be removed in 1.0; use new_func instead.", + ), + ], +) +def test_generated_message_combines_given_parts(kwargs: dict[str, str], suffix: str) -> None: + @deprecate(**kwargs) + def old_func() -> None: + return None + + with pytest.warns(DeprecationWarning) as record: + old_func() + assert str(record[0].message) == f"{old_func.__qualname__} is deprecated{suffix}" + + +def test_explicit_message_replaces_generated_text() -> None: + @deprecate("custom text", since="0.2", alternative="new_func") + def old() -> None: + return None + + with pytest.warns(DeprecationWarning) as record: + old() + assert str(record[0].message) == "custom text" + assert _deprecation_text(old) == "custom text" + + +def test_warns_on_every_call() -> None: + @deprecate("again") + def old() -> None: + return None + + with pytest.warns(DeprecationWarning) as record: + old() + old() + assert len(record) == 2 + + +def test_warning_points_at_caller_file() -> None: + @deprecate("moved") + def old() -> None: + return None + + with pytest.warns(DeprecationWarning) as record: + old() + assert record[0].filename == __file__ + + +def test_custom_category() -> None: + @deprecate("soon", category=FutureWarning) + def old() -> None: + return None + + with pytest.warns(FutureWarning, match="soon"): + old() + + +def test_sets_pep702_attribute_and_keeps_metadata() -> None: + @deprecate("gone") + def old_named() -> int: + """Docs.""" + return 1 + + assert _deprecation_text(old_named) == "gone" + assert old_named.__name__ == "old_named" + assert old_named.__doc__ == "Docs." + + +async def test_async_function_warns_on_await_and_stays_coroutine() -> None: + @deprecate("async gone") + async def old(x: int) -> int: + return x + 1 + + assert inspect.iscoroutinefunction(old) + with pytest.warns(DeprecationWarning, match="async gone") as record: + assert await old(1) == 2 + assert record[0].filename == __file__ + + +def test_methods_instance_class_and_static() -> None: + class Service: + @deprecate("instance gone") + def run(self) -> str: + return "run" + + @classmethod + @deprecate("class gone") + def make(cls) -> str: + return cls.__name__ + + @staticmethod + @deprecate("static gone") + def helper() -> int: + return 7 + + @deprecate + @staticmethod + def helper_bare() -> int: + return 8 + + svc = Service() + with pytest.warns(DeprecationWarning, match="instance gone"): + assert svc.run() == "run" + with pytest.warns(DeprecationWarning, match="class gone"): + assert Service.make() == "Service" + with pytest.warns(DeprecationWarning, match="static gone"): + assert Service.helper() == 7 + with pytest.warns(DeprecationWarning, match="static gone"): + assert svc.helper() == 7 + with pytest.warns(DeprecationWarning, match="is deprecated"): + assert Service.helper_bare() == 8 + + +def test_class_stays_class_and_warns_on_init() -> None: + @deprecate("Widget is going away") + class Widget: + def __init__(self, size: int) -> None: + self.size = size + + assert isinstance(Widget, type) + assert _deprecation_text(Widget) == "Widget is going away" + with pytest.warns(DeprecationWarning, match="going away") as record: + widget = Widget(3) + assert widget.size == 3 + assert isinstance(widget, Widget) + assert record[0].filename == __file__ + + +def test_subclass_instances_still_pass_isinstance() -> None: + @deprecate("Base is going away") + class Base: + pass + + class Child(Base): + pass + + with pytest.warns(DeprecationWarning): + child = Child() + assert isinstance(child, Base) + + +def test_bare_class_decorator_without_init() -> None: + @deprecate + class Empty: + pass + + with pytest.warns(DeprecationWarning, match="is deprecated"): + assert isinstance(Empty(), Empty) + + +# --- mock_data ------------------------------------------------------------- + + +def test_mock_data_returns_value_without_calling_function() -> None: + calls: list[int] = [] + + @mock_data(42) + def fetch() -> int: + calls.append(1) + return 1 + + assert fetch() == 42 + assert calls == [] + + +def test_callable_data_is_called_on_each_call() -> None: + counter = iter(range(10)) + + @mock_data(lambda: next(counter)) + def value() -> int: + return -1 + + assert value() == 0 + assert value() == 1 + + +def test_lambda_wrapper_returns_a_callable_itself() -> None: + def handler() -> str: + return "handled" + + @mock_data(lambda: handler) + def get_handler() -> Any: + return None + + assert get_handler() is handler + + +def test_enabled_false_runs_real_function() -> None: + @mock_data(42, enabled=False) + def add_one(x: int) -> int: + return x + 1 + + assert add_one(1) == 2 + + +def test_enabled_callable_is_evaluated_on_each_call(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MOCK", "1") + + @mock_data("fake", enabled=lambda: os.environ.get("MOCK") == "1") + def fetch() -> str: + return "real" + + assert fetch() == "fake" + monkeypatch.setenv("MOCK", "0") + assert fetch() == "real" + monkeypatch.setenv("MOCK", "1") + assert fetch() == "fake" + + +def test_mock_preserves_metadata_and_signature() -> None: + @mock_data("fake") + def fetch(user_id: int, *, verbose: bool = False) -> str: + """Fetch a user.""" + return "real" + + assert fetch.__name__ == "fetch" + assert fetch.__doc__ == "Fetch a user." + assert list(inspect.signature(fetch).parameters) == ["user_id", "verbose"] + + +async def test_async_mock_returns_data_and_skips_real_call() -> None: + @mock_data("fake") + async def fetch() -> str: + raise AssertionError("real call should not run") + + assert inspect.iscoroutinefunction(fetch) + assert await fetch() == "fake" + + +async def test_async_disabled_awaits_real_function() -> None: + @mock_data("fake", enabled=False) + async def times_ten(x: int) -> int: + return x * 10 + + assert await times_ten(2) == 20 + + +# --- no_debug -------------------------------------------------------------- + + +def test_no_debug_is_passthrough_and_warns_at_decoration() -> None: + def func() -> int: + return 1 + + with pytest.warns(DeprecationWarning, match="removed in 0.3"): + decorated = no_debug(func) + assert decorated is func + + +def test_no_debug_bare_and_call_forms_warn_at_decoration() -> None: + with pytest.warns(DeprecationWarning, match="logging") as record: + + @no_debug + def bare() -> int: + return 1 + + assert record[0].filename == __file__ + assert bare() == 1 + + with pytest.warns(DeprecationWarning, match="removed in 0.3"): + + @no_debug() + def called() -> int: + return 2 + + assert called() == 2 + + +# --- compose --------------------------------------------------------------- + + +def _recording(log: list[str], name: str) -> Any: + def decorator(func: Any) -> Any: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + log.append(f"{name}-in") + result = func(*args, **kwargs) + log.append(f"{name}-out") + return result + + return wrapper + + return decorator + + +def test_compose_applies_like_stacked_decorators() -> None: + log: list[str] = [] + + @compose(_recording(log, "a"), _recording(log, "b"), _recording(log, "c")) + def f() -> str: + log.append("f") + return "done" + + assert f() == "done" + assert log == ["a-in", "b-in", "c-in", "f", "c-out", "b-out", "a-out"] + + +def test_compose_without_arguments_is_identity() -> None: + def f() -> int: + return 1 + + assert compose()(f) is f + + +def test_compose_combines_deprecate_and_mock_data() -> None: + @compose(deprecate("old api"), mock_data(7)) + def value() -> int: + return 1 + + with pytest.warns(DeprecationWarning, match="old api"): + assert value() == 7 + + +def test_compose_rejects_non_callable_arguments() -> None: + with pytest.raises(TypeError, match="argument 1"): + compose(lambda f: f, 42) # type: ignore[arg-type] + + +# --- deprecate on classes without their own __init__ ------------------------- + + +def test_deprecate_float_subclass_keeps_construction() -> None: + @deprecate("old") + class Meters(float): + pass + + with pytest.warns(DeprecationWarning, match="old") as rec: + m = Meters(3.0) + assert m == 3.0 and isinstance(m, float) + assert len(rec) == 1 + + +def test_deprecate_namedtuple() -> None: + from typing import NamedTuple + + @deprecate + class P(NamedTuple): + x: int + + with pytest.warns(DeprecationWarning) as rec: + p = P(1) + assert p.x == 1 + assert len(rec) == 1 + + +def test_deprecate_dataclass_and_regular_class() -> None: + import dataclasses + + @deprecate + @dataclasses.dataclass + class D: + x: int + + class Plain: + def __init__(self, v: int) -> None: + self.v = v + + Plain = deprecate(Plain) + + with pytest.warns(DeprecationWarning) as rec: + assert D(2).x == 2 + assert Plain(3).v == 3 + assert len(rec) == 2 + + +def test_deprecate_class_without_any_init_or_new() -> None: + @deprecate + class Bare: + pass + + with pytest.warns(DeprecationWarning): + Bare() + with pytest.raises(TypeError), pytest.warns(DeprecationWarning): + Bare(1) # type: ignore[call-arg] + + +def test_deprecate_subclass_of_deprecated_class_warns_once() -> None: + @deprecate("old") + class Meters(float): + pass + + class Feet(Meters): + pass + + with pytest.warns(DeprecationWarning) as rec: + f = Feet(2.0) + assert f == 2.0 + assert len(rec) == 1 + + +# --- wrap_methods ---------------------------------------------------------- + + +def _tag(log: list[str]) -> Any: + def decorator(func: Any) -> Any: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + log.append(func.__name__) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def test_wrap_methods_handles_all_method_kinds() -> None: + log: list[str] = [] + + @wrap_methods(_tag(log)) + class K: + def plain(self) -> int: + return 1 + + async def aplain(self) -> int: + return 2 + + @staticmethod + def stat() -> int: + return 3 + + @classmethod + def cls_m(cls) -> int: + return 4 + + def _private(self) -> int: + return 5 + + @property + def prop(self) -> int: + return 6 + + def __len__(self) -> int: + return 7 + + attr = 9 + + k = K() + assert (k.plain(), K.stat(), K.cls_m(), k._private(), k.prop, len(k)) == (1, 3, 4, 5, 6, 7) + assert asyncio.run(k.aplain()) == 2 + assert log == ["plain", "stat", "cls_m", "aplain"] + assert isinstance(K.__dict__["stat"], staticmethod) + assert isinstance(K.__dict__["cls_m"], classmethod) + assert K.attr == 9 + + +def test_wrap_methods_include_exclude_private() -> None: + log: list[str] = [] + + @wrap_methods(_tag(log), exclude=lambda n: n == "skip", include_private=True) + class K: + def a(self) -> None: ... + def skip(self) -> None: ... + def _p(self) -> None: ... + + k = K() + k.a() + k.skip() + k._p() + assert log == ["a", "_p"] + + log.clear() + + @wrap_methods(_tag(log), include="a") + class J: + def a(self) -> None: ... + def b(self) -> None: ... + + J().a() + J().b() + assert log == ["a"] + + +def test_wrap_methods_explicit_names_opt_in_dunders_and_properties() -> None: + log: list[str] = [] + + @wrap_methods(_tag(log), include=["__len__", "prop", "other"], exclude={"other"}) + class K: + def __len__(self) -> int: + return 1 + + def other(self) -> None: ... + + def __init__(self) -> None: + self._v = 0 + + @property + def prop(self) -> int: + return self._v + + @prop.setter + def prop(self, value: int) -> None: + self._v = value + + k = K() + k.prop = 5 + assert k.prop == 5 + assert len(k) == 1 + k.other() + assert log == ["prop", "prop", "__len__"] + + +def test_wrap_methods_works_with_policy_and_order() -> None: + log: list[str] = [] + + def named(label: str) -> Any: + def decorator(func: Any) -> Any: + def wrapper(*a: Any, **k: Any) -> Any: + log.append(label) + return func(*a, **k) + + return wrapper + + return decorator + + @wrap_methods(policy(timeout=named("t"), retry=named("r")), named("first")) + class K: + def go(self) -> None: ... + + K().go() + assert log == ["r", "t", "first"] + + +def test_wrap_methods_rejects_non_callable() -> None: + with pytest.raises(TypeError, match="callable"): + wrap_methods(3) # type: ignore[arg-type] diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..97d28a4 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterator + +import pytest + +from python_wrap import _core +from python_wrap.events import EVENT_NAMES, Event, filter_events, listening, subscribe + + +@pytest.fixture(autouse=True) +def _clean_listeners() -> Iterator[None]: + assert _core.listeners == [] + yield + _core.listeners.clear() + + +def test_subscribe_receives_and_unsubscribe_stops() -> None: + seen: list[Event] = [] + unsubscribe = subscribe(seen.append) + _core.emit("cache.hit", "f") + unsubscribe() + unsubscribe() # idempotent + _core.emit("cache.hit", "f") + assert seen == [Event("cache.hit", "f", {})] + assert _core.listeners == [] + + +def test_unsubscribe_twice_does_not_remove_a_second_registration() -> None: + seen: list[Event] = [] + first = subscribe(seen.append) + subscribe(seen.append) + first() + first() + _core.emit("cache.miss", "f") + assert len(seen) == 1 + + +def test_listening_context_manager() -> None: + seen: list[Event] = [] + with listening(seen.append): + _core.emit("retry.succeeded", "f", attempts=2) + _core.emit("retry.succeeded", "f", attempts=3) + assert [e.fields for e in seen] == [{"attempts": 2}] + + +def test_listening_unsubscribes_on_error() -> None: + with pytest.raises(RuntimeError), listening(lambda e: None): + raise RuntimeError + assert _core.listeners == [] + + +def test_filter_events_by_prefix() -> None: + seen: list[str] = [] + with listening(filter_events("retry.", lambda e: seen.append(e.name))): + _core.emit("retry.gave_up", "f") + _core.emit("cache.hit", "f") + with listening(filter_events(("cache.", "bulkhead."), lambda e: seen.append(e.name))): + _core.emit("cache.miss", "f") + _core.emit("bulkhead.rejected", "f") + _core.emit("throttle.skipped", "f") + assert seen == ["retry.gave_up", "cache.miss", "bulkhead.rejected"] + + +def test_failing_listener_is_logged_not_raised(caplog: pytest.LogCaptureFixture) -> None: + seen: list[Event] = [] + + def bad(event: Event) -> None: + raise ValueError("boom") + + with listening(bad), listening(seen.append): + _core.emit("cache.hit", "f") + assert len(seen) == 1 + assert caplog.records[0].levelno == logging.ERROR + + +def test_unsubscribe_during_emit_is_safe() -> None: + seen: list[str] = [] + unsubscribe_once = subscribe(lambda e: (seen.append("once"), unsubscribe_once())) + subscribe(lambda e: seen.append("other")) + _core.emit("cache.hit", "f") + assert seen == ["once", "other"] + + +def test_event_names_catalogue() -> None: + assert "retry.attempt_failed" in EVENT_NAMES + assert all(isinstance(v, str) and v for v in EVENT_NAMES.values()) + assert len(EVENT_NAMES) == 13 diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..e6eea1a --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,35 @@ +"""Every script in ``examples/`` must run to completion with exit code 0.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = sorted((ROOT / "examples").glob("*.py")) + + +def test_examples_exist() -> None: + assert len(EXAMPLES) >= 5 + + +@pytest.mark.parametrize("script", EXAMPLES, ids=lambda p: p.name) +def test_example_runs(script: Path) -> None: + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join([str(ROOT / "src"), os.environ.get("PYTHONPATH", "")]), + } + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=20, + env=env, + cwd=ROOT, + ) + assert result.returncode == 0, f"{script.name} failed:\n{result.stdout}\n{result.stderr}" + assert result.stdout.strip(), f"{script.name} printed nothing" diff --git a/tests/test_hedging.py b/tests/test_hedging.py new file mode 100644 index 0000000..f8d0c38 --- /dev/null +++ b/tests/test_hedging.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import asyncio +import contextvars +import gc +import inspect +import threading + +import pytest + +from python_wrap.hedging import hedge +from python_wrap.testing import fast_mode + + +def only_current_task(): + return asyncio.all_tasks() == {asyncio.current_task()} + + +def hedge_threads(): + return [t for t in threading.enumerate() if t.name.startswith("hedge-")] + + +@pytest.mark.parametrize( + "kwargs", [{"delay": -1}, {"delay": 1, "max_extra": -1}, {"delay": 1, "max_extra": True}] +) +def test_validates_options(kwargs): + with pytest.raises(ValueError): + hedge(**kwargs) + + +# --------------------------------------------------------------------------- async + + +async def test_async_fast_call_runs_once(): + calls = [] + + @hedge(10) + async def fetch(x: int) -> int: + calls.append(x) + return x * 2 + + assert inspect.iscoroutinefunction(fetch) + assert fetch.__name__ == "fetch" + assert await fetch(4) == 8 + assert calls == [4] + assert only_current_task() + + +async def test_async_slow_call_is_hedged_and_loser_cancelled(): + cancelled = [] + calls = [] + + @hedge(0.005) + async def fetch() -> str: + n = len(calls) + calls.append(n) + if n == 0: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.append(n) + raise + return f"attempt{n}" + + assert await fetch() == "attempt1" + assert cancelled == [0] + assert only_current_task() + + +async def test_async_failure_starts_the_next_attempt_immediately(): + calls = [] + + @hedge(10, max_extra=2, on=OSError) + async def fetch() -> int: + calls.append(1) + if len(calls) < 3: + raise ConnectionError("flaky") + return len(calls) + + assert await fetch() == 3 # no 10s waits: each failure triggers the next hedge + assert only_current_task() + + +async def test_async_all_fail_raises_the_last_exception(): + calls = [] + + @hedge(0.005, max_extra=2) + async def fetch() -> None: + calls.append(1) + raise ValueError(len(calls)) + + with pytest.raises(ValueError) as info: + await fetch() + assert info.value.args == (3,) + assert len(calls) == 3 + + +async def test_async_non_matching_error_is_raised_at_once(): + cancelled = [] + + @hedge(0.005, on=OSError) + async def fetch(n: list[int]) -> None: + n.append(1) + if len(n) == 1: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.append(1) + raise + raise KeyError("bug") + + with pytest.raises(KeyError): + await fetch([]) + assert cancelled == [1] + assert only_current_task() + + +async def test_async_max_extra_caps_the_attempts(): + started = [] + gate = asyncio.Event() + + @hedge(0.005, max_extra=2) + async def fetch() -> int: + started.append(1) + n = len(started) + if n == 3: + gate.set() + await gate.wait() + return n + + assert await fetch() in (1, 2, 3) + assert len(started) == 3 + assert only_current_task() + + +async def test_async_max_extra_zero_never_hedges(): + calls = [] + + @hedge(0.005, max_extra=0) + async def fetch() -> int: + calls.append(1) + await asyncio.sleep(0) + return 1 + + assert await fetch() == 1 + assert calls == [1] + + +async def test_async_cancelling_the_caller_cancels_every_attempt(): + started = [] + cancelled = [] + + @hedge(0.005, max_extra=1) + async def fetch() -> None: + started.append(1) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.append(1) + raise + + task = asyncio.create_task(fetch()) + while len(started) < 2: + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled == [1, 1] + assert only_current_task() + + +async def test_async_loser_error_while_cancelling_is_swallowed(): + calls = [] + + @hedge(0.005) + async def fetch() -> str: + calls.append(1) + if len(calls) == 1: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("cleanup failed") from None + return "ok" + + assert await fetch() == "ok" + gc.collect() # an unretrieved task exception would be reported (filterwarnings=error) + + +async def test_async_hedge_delay_is_real_time_under_fast_mode(): + calls = [] + + @hedge(5, max_extra=3) + async def fetch() -> int: + calls.append(1) + await asyncio.sleep(0.02) # real I/O, shorter than the hedge delay + return len(calls) + + with fast_mode(): + assert await fetch() == 1 + assert calls == [1] # the delay is a deadline, not a backoff: no instant hedges + assert only_current_task() + + +async def test_async_hedge_delay_ignores_the_fake_clock(fake_clock): + @hedge(5) + async def fetch() -> str: + await asyncio.sleep(0.01) + return "ok" + + assert await fetch() == "ok" + assert fake_clock.sleeps == [] + + +async def test_async_attempt_cancelled_from_inside_propagates(): + @hedge(10) + async def fetch() -> None: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await fetch() + + +async def test_async_methods_and_context(): + var = contextvars.ContextVar("var", default="unset") + + class Client: + @hedge(0.005) + async def get(self, path: str) -> str: + return f"{var.get()}:{path}" + + var.set("ctx") + assert await Client().get("/a") == "ctx:/a" + + +# --------------------------------------------------------------------------- sync + + +def test_sync_fast_call_runs_once(): + calls = [] + + @hedge(10) + def fetch(x: int) -> int: + calls.append(threading.current_thread().name) + return x + 1 + + assert fetch(1) == 2 + assert fetch.__name__ == "fetch" + assert calls == ["hedge-test_sync_fast_call_runs_once..fetch"] + + +def test_sync_slow_call_is_hedged_and_loser_finishes_in_background(): + release = threading.Event() + calls = [] + + @hedge(0.01) + def fetch() -> str: + calls.append(1) + if len(calls) == 1: + release.wait(5) + return "slow" + return "fast" + + assert fetch() == "fast" + assert len(calls) == 2 + release.set() + for t in hedge_threads(): + t.join(5) + assert hedge_threads() == [] + + +def test_sync_failure_starts_the_next_attempt_immediately(): + calls = [] + + @hedge(10, max_extra=2, on=(OSError, TimeoutError)) + def fetch() -> int: + calls.append(1) + if len(calls) < 3: + raise TimeoutError + return len(calls) + + assert fetch() == 3 + + +def test_sync_all_fail_raises_the_last_exception(): + calls = [] + lock = threading.Lock() + + @hedge(10, max_extra=1) + def fetch() -> None: + with lock: + calls.append(1) + n = len(calls) + raise ValueError(n) + + with pytest.raises(ValueError) as info: + fetch() + assert info.value.args == (2,) + + +def test_sync_non_matching_error_is_raised_at_once(): + @hedge(10, on=OSError) + def fetch() -> None: + raise KeyError("bug") + + with pytest.raises(KeyError): + fetch() + + +def test_sync_max_extra_zero_and_methods(): + var = contextvars.ContextVar("var", default="unset") + + class Client: + @hedge(0.01, max_extra=0) + def get(self, path: str) -> str: + return f"{var.get()}:{path}" + + var.set("ctx") + assert Client().get("/a") == "ctx:/a" + + +def test_sync_max_extra_caps_the_attempts(): + gate = threading.Event() + started = [] + lock = threading.Lock() + + @hedge(0.005, max_extra=2) + def fetch() -> int: + with lock: + started.append(1) + n = len(started) + if n == 3: + gate.set() + gate.wait(5) + return n + + assert fetch() in (1, 2, 3) + assert len(started) == 3 + for t in hedge_threads(): + t.join(5) diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..8f73d14 --- /dev/null +++ b/tests/test_logs.py @@ -0,0 +1,696 @@ +from __future__ import annotations + +import asyncio +import functools +import inspect +import logging +from datetime import timedelta, timezone +from typing import Any + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.logs import DEFAULT_REDACT, AuditRecord, audit, log_args, log_return, trace + + +def messages(caplog: pytest.LogCaptureFixture) -> list[str]: + return [r.getMessage() for r in caplog.records] + + +# ---------------------------------------------------------------------------- log_args + + +def test_log_args_names_positionals_and_redacts(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_args + def login(user: str, password: str, *, retries: int = 3) -> str: + return user + + assert login("bob", "hunter2", retries=2) == "bob" + assert caplog.records[0].levelno == logging.DEBUG + assert messages(caplog)[0] == "Calling login(user='bob', password='***', retries=2)" or ( + messages(caplog)[0].endswith("login(user='bob', password='***', retries=2)") + ) + assert "hunter2" not in caplog.text + + +def test_log_args_case_insensitive_substring_and_kwargs( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_args + def f(*args: Any, **kwargs: Any) -> None: ... + + f(1, API_KEY="abc", user_token="t", name="n") + msg = messages(caplog)[0] + assert "API_KEY='***'" in msg + assert "user_token='***'" in msg + assert "name='n'" in msg + assert "args=(1,)" in msg + + +def test_log_args_custom_redact_and_disable(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_args(redact=["pin"]) + def a(pin: str, password: str) -> None: ... + + @log_args(redact=()) + def b(password: str) -> None: ... + + a("1234", "pw") + b("pw") + assert "pin='***'" in messages(caplog)[0] + assert "password='pw'" in messages(caplog)[0] + assert "password='pw'" in messages(caplog)[1] + assert "password" in DEFAULT_REDACT + + +def test_log_args_truncates_and_level(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @log_args(level=logging.INFO, max_length=30) + def f(blob: str) -> None: ... + + f("x" * 500) + assert caplog.records[0].levelno == logging.INFO + assert len(messages(caplog)[0]) < 100 + assert "..." in messages(caplog)[0] + + +def test_log_args_default_level_is_debug_and_skipped_when_disabled( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @log_args + def f(x: int) -> int: + return x + + assert f(1) == 1 + assert caplog.records == [] + + +def test_log_args_bad_call_and_bad_repr(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + class Bad: + def __repr__(self) -> str: + raise RuntimeError + + @log_args + def f(x: object) -> None: ... + + with pytest.raises(TypeError): + f(1, 2) # type: ignore[call-arg] + f(Bad()) + assert "arg0=1" in messages(caplog)[0] + assert "Bad instance" in messages(caplog)[1] + + +def test_log_args_async_and_methods(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + class C: + @log_args + async def a(self, secret: str) -> int: + return 1 + + @staticmethod + @log_args + def s(x: int) -> int: + return x + + assert inspect.iscoroutinefunction(C.a) + assert asyncio.run(C().a("s")) == 1 + assert C.s(2) == 2 + assert "secret='***'" in messages(caplog)[0] + assert "s(x=2)" in messages(caplog)[1] + + +# -------------------------------------------------------------------------- log_return + + +def test_log_return_value_and_exception(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_return + def f(fail: bool) -> list[int]: + if fail: + raise ValueError("nope") + return [1, 2] + + assert f(False) == [1, 2] + with pytest.raises(ValueError, match="nope"): + f(True) + assert messages(caplog)[0].endswith("returned [1, 2]") + assert messages(caplog)[1].endswith("raised ValueError") + + +def test_log_return_truncation_and_async(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="r") + + @log_return(max_length=20, level=logging.INFO, logger="r") + async def f() -> str: + return "y" * 100 + + assert inspect.iscoroutinefunction(f) + asyncio.run(f()) + + @log_return(logger="r") + async def g() -> None: + raise KeyError + + with pytest.raises(KeyError): + asyncio.run(g()) + assert caplog.records[0].name == "r" + assert caplog.records[0].levelno == logging.INFO + assert len(messages(caplog)[0]) < 100 + assert messages(caplog)[1].endswith("raised KeyError") + + +# ------------------------------------------------------------------------------ audit + + +def test_audit_record_fields_and_real_caller( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + records: list[AuditRecord] = [] + + @audit(sink=records.append, include_result=True) + def transfer(amount: int, api_key: str) -> str: + fake_clock.advance(0.5) + return "done" + + frame = inspect.currentframe() + assert frame is not None + call_line = frame.f_lineno + 1 + assert transfer(10, "k-123") == "done" + rec = records[0] + assert rec.function.endswith("transfer") + assert rec.arguments == {"amount": 10, "api_key": "***"} + assert rec.status == "success" + assert rec.error is None + assert rec.result == "done" + assert rec.duration == pytest.approx(0.5) + assert rec.timestamp.utcoffset() == timedelta(0) + assert rec.timestamp.tzinfo == timezone.utc + # the caller is this test, not the python_wrap wrapper + assert rec.caller == f"{__file__}:{call_line}" + assert caplog.records[0].audit is rec # type: ignore[attr-defined] + assert "k-123" not in caplog.text + + +def test_audit_error_status_and_no_result_by_default(fake_clock: FakeClock) -> None: + records: list[AuditRecord] = [] + + @audit(sink=records.append) + def f(x: int) -> int: + if x: + raise ValueError("bad") + return 5 + + with pytest.raises(ValueError): + f(1) + f(0) + assert records[0].status == "error" + assert records[0].error == "ValueError('bad')" + assert records[1].result is None + + +def test_audit_record_is_frozen() -> None: + records: list[AuditRecord] = [] + + @audit(sink=records.append) + def f() -> None: ... + + f() + with pytest.raises(AttributeError): + records[0].status = "x" # type: ignore[misc] + + +def test_audit_sink_failure_is_swallowed(caplog: pytest.LogCaptureFixture) -> None: + def bad(record: AuditRecord) -> None: + raise RuntimeError + + @audit(sink=bad) + def f() -> int: + return 1 + + assert f() == 1 + assert any("sink" in m for m in messages(caplog)) + + +def test_audit_bare_and_async_caller(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @audit + async def f(session_id: str) -> int: + return 1 + + async def main() -> int: + return await f("abc") + + assert inspect.iscoroutinefunction(f) + assert asyncio.run(main()) == 1 + record: AuditRecord = caplog.records[0].audit # type: ignore[attr-defined] + assert record.arguments == {"session_id": "***"} + assert record.caller.startswith(__file__) + + +def test_audit_caller_skips_stacked_wrappers() -> None: + records: list[AuditRecord] = [] + + @log_return + @audit(sink=records.append) + def f() -> None: ... + + f() + assert records[0].caller.startswith(__file__) + + +# ------------------------------------------------------------------------------ trace + + +def outer_frame_fn(fn: Any) -> None: + fn() + + +def test_trace_logs_entry_stack_exit(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @trace + def target() -> int: + return 1 + + outer_frame_fn(target) + msgs = messages(caplog) + assert msgs[0].startswith("Entering ") and msgs[0].endswith("target") + assert "outer_frame_fn" in msgs[1] + assert "test_trace_logs_entry_stack_exit" in msgs[1] + assert "python_wrap/logs.py" not in msgs[1] + assert msgs[2].startswith("Exiting ") and msgs[2].endswith("target") + + +def test_trace_limit_and_exception(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @trace(limit=1) + def target() -> None: + raise ValueError + + with pytest.raises(ValueError): + outer_frame_fn(target) + stack = messages(caplog)[1] + assert "outer_frame_fn" in stack + assert stack.count('File "') == 1 + assert messages(caplog)[2].endswith("target (raised ValueError)") + + +def test_trace_limit_zero_and_disabled(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @trace(limit=0) + def target() -> None: ... + + target() + assert messages(caplog)[1].strip().endswith(":") + caplog.clear() + caplog.set_level(logging.INFO, logger="python_wrap") + target() + assert caplog.records == [] + + +def test_trace_async(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @trace(level=logging.INFO) + async def target() -> int: + await _core.async_sleep(0) + return 4 + + assert inspect.iscoroutinefunction(target) + assert asyncio.run(target()) == 4 + assert len(caplog.records) == 3 + assert caplog.records[0].levelno == logging.INFO + + +def test_audit_survives_broken_exception_repr() -> None: + from python_wrap.logs import audit + + class Weird(Exception): + def __repr__(self) -> str: + raise RuntimeError("repr broke") + + records: list[Any] = [] + + @audit(sink=records.append) + def f() -> None: + raise Weird("real error") + + with pytest.raises(Weird, match="real error"): + f() + assert records[0].status == "error" + assert "unrepresentable Weird" in records[0].error + + +def test_failing_finish_hook_never_replaces_result_or_exception( + caplog: pytest.LogCaptureFixture, +) -> None: + + from python_wrap.logs import _instrument + + def bad_finish(state: None, ok: bool, value: Any) -> None: + raise RuntimeError("hook broke") + + @functools.partial(_instrument, start=lambda a, k: None, finish=bad_finish) + def ok() -> int: + return 5 + + @functools.partial(_instrument, start=lambda a, k: None, finish=bad_finish) + def bad() -> int: + raise KeyError("orig") + + @functools.partial(_instrument, start=lambda a, k: None, finish=bad_finish) + async def aok() -> int: + return 6 + + with caplog.at_level(logging.ERROR): + assert ok() == 5 + with pytest.raises(KeyError): + bad() + assert asyncio.run(aok()) == 6 + assert sum("hook failed" in r.getMessage() for r in caplog.records) == 3 + + +# ------------------------------------------------------------------------- log_context + + +def test_log_context_adds_fields_to_every_decorator(caplog: pytest.LogCaptureFixture) -> None: + from python_wrap import timing + from python_wrap.logs import current_log_context, log_context + + caplog.set_level(logging.DEBUG, logger="python_wrap") + records: list[AuditRecord] = [] + + @log_args + @log_return + @audit(sink=records.append) + @trace(limit=1) + @timing + def f(x: int) -> int: + return x + + assert current_log_context() == {} + f(1) + assert not any("request_id" in m for m in messages(caplog)) + assert records[0].context == {} + caplog.clear() + with log_context(request_id="r%1") as fields: + assert fields == {"request_id": "r%1"} + with log_context(user="ana"): + assert current_log_context() == {"request_id": "r%1", "user": "ana"} + f(2) + assert current_log_context() == {"request_id": "r%1"} + assert current_log_context() == {} + assert records[1].context == {"request_id": "r%1", "user": "ana"} + assert caplog.records + for r in caplog.records: + assert r.context == {"request_id": "r%1", "user": "ana"} # type: ignore[attr-defined] + suffix = "[request_id=r%1 user=ana]" + assert next(m for m in messages(caplog) if m.startswith("Calling")).endswith(suffix) + assert any(".f took" in m and m.endswith(suffix) for m in messages(caplog)) + + +def test_log_context_decorator_and_async(caplog: pytest.LogCaptureFixture) -> None: + from python_wrap.logs import current_log_context, log_context + + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_context(job="nightly") + def sync() -> dict[str, object]: + return current_log_context() + + @log_context(job="async") + async def coro() -> dict[str, object]: + await asyncio.sleep(0) + return current_log_context() + + assert inspect.iscoroutinefunction(coro) + assert sync() == {"job": "nightly"} + assert asyncio.run(coro()) == {"job": "async"} + assert current_log_context() == {} + + @log_return + async def work() -> int: + return 1 + + async def main() -> None: + async with log_context(tenant="t1"): + await work() + + asyncio.run(main()) + assert messages(caplog)[-1].endswith("work returned 1 [tenant=t1]") + + +def test_log_context_is_isolated_per_task_and_thread() -> None: + import contextvars + import threading + + from python_wrap.logs import current_log_context, log_context + + async def worker(n: int) -> dict[str, object]: + with log_context(n=n): + await asyncio.sleep(0) + return current_log_context() + + async def main() -> list[dict[str, object]]: + return list(await asyncio.gather(*(worker(i) for i in range(3)))) + + assert asyncio.run(main()) == [{"n": 0}, {"n": 1}, {"n": 2}] + + seen: dict[str, dict[str, object]] = {} + with log_context(a=1): + t = threading.Thread(target=lambda: seen.update(plain=current_log_context())) + t.start() + t.join() + ctx = contextvars.copy_context() + t = threading.Thread(target=lambda: seen.update(copied=ctx.run(current_log_context))) + t.start() + t.join() + assert seen == {"plain": {}, "copied": {"a": 1}} + + +def test_log_context_restores_on_error() -> None: + from python_wrap.logs import current_log_context, log_context + + with pytest.raises(RuntimeError), log_context(a=1): + raise RuntimeError + assert current_log_context() == {} + + +def test_log_context_unprintable_value(caplog: pytest.LogCaptureFixture) -> None: + from python_wrap.logs import log_context + + class Bad: + def __str__(self) -> str: + raise RuntimeError + + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_return + def f() -> int: + return 1 + + with log_context(bad=Bad()): + f() + assert "" in messages(caplog)[-1] + + +# --------------------------------------------------------- fast paths (positional calls) + + +def _calls(caplog: pytest.LogCaptureFixture) -> list[str]: + return [m.split("Calling ", 1)[1].split(".")[-1] for m in messages(caplog)] + + +def test_log_args_fast_path_matches_bind_for_every_signature_shape( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_args + def plain(a: int, b: int = 2, password: str = "x") -> None: ... + + @log_args + def pos_only(a: int, token: str, /, c: int = 3) -> None: ... + + @log_args + def kw_only(a: int, *, secret: str = "s", n: int = 1) -> None: ... + + @log_args + def variadic(a: int, *rest: int, api_key: str = "k", **extra: int) -> None: ... + + plain(1) + plain(1, 5, "pw") + plain(1, b=9) + pos_only(1, "tok") + pos_only(1, "tok", c=7) + kw_only(1) + kw_only(1, secret="zz", n=4) + variadic(1, 2, 3, x=4) + variadic(1, api_key="q", y=5) + + assert _calls(caplog) == [ + "plain(a=1)", + "plain(a=1, b=5, password='***')", + "plain(a=1, b=9)", + "pos_only(a=1, token='***')", + "pos_only(a=1, token='***', c=7)", + "kw_only(a=1)", + "kw_only(a=1, secret='***', n=4)", + "variadic(a=1, rest=(2, 3), x=4)", + "variadic(a=1, api_key='***', y=5)", + ] + assert "pw" not in caplog.text.replace("password", "") + + +def test_log_args_redacts_var_kwargs_names_not_in_signature( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_args + def f(**extra: str) -> None: ... + + f(my_token="abc", plain="ok") + assert "abc" not in caplog.text + assert "my_token='***'" in caplog.text + assert "plain='ok'" in caplog.text + + +def test_log_args_too_many_positionals_falls_back(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + @log_args + def f(a: int) -> int: + return a + + with pytest.raises(TypeError): + f(1, 2) # type: ignore[call-arg] + assert "f(arg0=1, arg1=2)" in caplog.text + + +def test_log_args_methods_name_self_and_redact(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="python_wrap") + + class Svc: + @log_args + def login(self, user: str, password: str) -> None: ... + + @classmethod + @log_args + def make(cls, name: str, token: str) -> None: ... + + @staticmethod + @log_args + def util(x: int, auth: str) -> None: ... + + Svc().login("u", "pw1") + Svc.make("n", "tk1") + Svc.util(1, "au1") + for secret in ("pw1", "tk1", "au1"): + assert secret not in caplog.text + assert "user='u', password='***'" in caplog.text + assert "name='n', token='***'" in caplog.text + assert "x=1, auth='***'" in caplog.text + + +def test_audit_records_arguments_on_fast_and_slow_paths() -> None: + records: list[AuditRecord] = [] + + @audit(sink=records.append) + def f(a: int, password: str = "d", *, token: str = "t", n: int = 0) -> None: ... + + f(1) + f(1, "pw") + f(1, n=2) + f(a=3, token="x") + assert [r.arguments for r in records] == [ + {"a": 1}, + {"a": 1, "password": "***"}, + {"a": 1, "n": 2}, + {"a": 3, "token": "***"}, + ] + + +def test_audit_caller_location_stays_exact_with_cache() -> None: + records: list[AuditRecord] = [] + + @audit(sink=records.append) + def f(x: int) -> int: + return x + + for _ in range(3): # later calls hit the filename cache + frame = inspect.currentframe() + assert frame is not None + line = frame.f_lineno + 1 + f(1) + assert records[-1].caller == f"{__file__}:{line}" + + +def test_audit_with_sink_but_level_disabled_still_builds_record( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.ERROR, logger="python_wrap") + records: list[AuditRecord] = [] + + @audit(sink=records.append, level=logging.INFO, include_result=True) + def f(a: int, secret: str) -> int: + return a + + assert f(1, "s3") == 1 + assert len(records) == 1 + assert records[0].arguments == {"a": 1, "secret": "***"} + assert records[0].result == 1 + assert records[0].caller.startswith(__file__) + assert caplog.records == [] + + +def test_audit_without_sink_and_level_disabled_skips_record( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + from python_wrap import logs + + caplog.set_level(logging.ERROR, logger="python_wrap") + built: list[object] = [] + monkeypatch.setattr(logs, "AuditRecord", lambda **kw: built.append(kw)) + + @audit + def f(a: int) -> int: + return a + + @audit + def boom() -> None: + raise ZeroDivisionError + + assert f(1) == 1 + with pytest.raises(ZeroDivisionError): + boom() + assert built == [] + assert caplog.records == [] + + +def test_audit_async_with_sink_and_level_disabled() -> None: + records: list[AuditRecord] = [] + + @audit(sink=records.append, logger="pw.off", level=logging.DEBUG) + async def f(a: int, token: str) -> int: + return a + + assert asyncio.run(f(2, "t")) == 2 + assert records[0].arguments == {"a": 2, "token": "***"} diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..196215b --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import asyncio +import inspect +import threading + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.metrics import count_calls + + +def test_counts_calls_errors_and_durations(fake_clock: FakeClock) -> None: + @count_calls + def work(n: int) -> int: + _core.sleep(n) + if n == 99: + raise ValueError("bad") + return n + + assert work.stats.calls == 0 + assert work.stats.mean == work.stats.p50 == work.stats.last_duration == 0.0 + for n in (1, 2, 3, 4): + assert work(n) == n + with pytest.raises(ValueError): + work(99) + s = work.stats + assert (s.calls, s.errors, s.in_flight) == (5, 1, 0) + assert s.last_duration == pytest.approx(99) + assert s.p50 == pytest.approx(3) + assert s.p99 == pytest.approx(99) + assert s.mean == pytest.approx((1 + 2 + 3 + 4 + 99) / 5) + assert "calls=5" in repr(s) + work.reset() + assert (s.calls, s.errors, s.mean) == (0, 0, 0.0) + + +def test_window_is_a_ring_buffer(fake_clock: FakeClock) -> None: + @count_calls(window=2) + def work(n: float) -> None: + _core.sleep(n) + + for n in (10, 1, 3): + work(n) + assert work.stats.calls == 3 + assert work.stats.mean == pytest.approx(2) # only 1 and 3 remain + assert work.stats.p95 == pytest.approx(3) + + +def test_invalid_window() -> None: + with pytest.raises(ValueError): + count_calls(window=0) + + +async def test_async_in_flight_and_cancellation() -> None: + gate = asyncio.Event() + + @count_calls + async def work() -> str: + await gate.wait() + return "ok" + + assert inspect.iscoroutinefunction(work) + tasks = [asyncio.create_task(work()) for _ in range(3)] + await asyncio.sleep(0) + assert work.stats.in_flight == 3 + gate.set() + assert await asyncio.gather(*tasks) == ["ok"] * 3 + assert work.stats.in_flight == 0 and work.stats.calls == 3 + + gate.clear() + task = asyncio.create_task(work()) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert work.stats.errors == 1 and work.stats.in_flight == 0 + + +def test_methods_share_stats() -> None: + class Svc: + @count_calls + def go(self, x: int) -> int: + return x + + @classmethod + @count_calls + def make(cls, x: int) -> int: + return x + + a, b = Svc(), Svc() + assert a.go(1) + b.go(2) == 3 + assert Svc.make(5) == 5 + assert Svc.__dict__["go"].stats.calls == 2 + + +def test_thread_safety() -> None: + @count_calls + def work() -> None: + pass + + def run() -> None: + for _ in range(500): + work() + + threads = [threading.Thread(target=run) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert work.stats.calls == 4000 + assert work.stats.in_flight == 0 diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..de37318 --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,154 @@ +"""Tests for python_wrap.policy.""" + +from __future__ import annotations + +import asyncio +import inspect +from collections.abc import Callable +from typing import Any + +import pytest + +from python_wrap import CircuitBreaker, CircuitOpenError, retry +from python_wrap.policy import ROLES, Policy, policy + + +def _rec(log: list[str], label: str) -> Callable[[Any], Any]: + def decorator(func: Any) -> Any: + def wrapper(*args: Any, **kwargs: Any) -> Any: + log.append(label) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def test_order_is_canonical_regardless_of_keyword_order() -> None: + log: list[str] = [] + p = policy( + timeout=_rec(log, "timeout"), + retry=_rec(log, "retry"), + fallback=_rec(log, "fallback"), + breaker=_rec(log, "breaker"), + inner=_rec(log, "inner"), + outer=_rec(log, "outer"), + ) + + @p + def f() -> int: + log.append("body") + return 1 + + assert f() == 1 + assert log == ["outer", "fallback", "breaker", "retry", "timeout", "inner", "body"] + assert p.order == ("outer", "fallback", "breaker", "retry", "timeout", "inner") + + +def test_all_roles_in_roles_order() -> None: + log: list[str] = [] + p = policy(**{role: _rec(log, role) for role in reversed(ROLES)}) + p.apply(lambda: None)() + assert log == list(ROLES) + + +def test_lists_keep_given_order_within_role() -> None: + log: list[str] = [] + p = policy(observe=[_rec(log, "a"), _rec(log, "b")]) + p(lambda: None)() + assert log == ["a", "b"] + + +def test_real_decorators_breaker_outside_retry() -> None: + breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=60, on=ConnectionError) + calls = 0 + p = policy( + retry=retry(max_attempts=3, delay=0, on=ConnectionError), + breaker=breaker, + name="x", + ) + + @p + def f() -> None: + nonlocal calls + calls += 1 + raise ConnectionError + + with pytest.raises(ConnectionError): + f() + assert calls == 3 + with pytest.raises(CircuitOpenError): + f() + + +def test_async_function_stays_async() -> None: + @policy(retry=retry(max_attempts=2, delay=0)) + async def f(x: int) -> int: + return x + 1 + + assert inspect.iscoroutinefunction(f) + assert asyncio.run(f(1)) == 2 + + +def test_methods_static_and_class() -> None: + log: list[str] = [] + p = policy(retry=_rec(log, "r")) + + class C: + @p + def m(self, x: int) -> int: + return x + + @p + @staticmethod + def s(x: int) -> int: + return x + + @p + @classmethod + def c(cls, x: int) -> int: + return x + + assert C().m(1) == 1 + assert C.s(2) == 2 + assert C.c(3) == 3 + assert isinstance(C.__dict__["s"], staticmethod) + assert isinstance(C.__dict__["c"], classmethod) + assert log == ["r", "r", "r"] + + +def test_immutable_and_with_derives_variants() -> None: + log: list[str] = [] + base = policy(retry=_rec(log, "r"), timeout=_rec(log, "t"), name="base") + derived = base.with_(retry=None, fallback=_rec(log, "f"), name="d") + assert base.order == ("retry", "timeout") + assert derived.order == ("fallback", "timeout") + assert derived.name == "d" + assert base.with_(outer=_rec(log, "o")).order == ("outer", "retry", "timeout") + assert base.with_(inner=_rec(log, "i")).order[-1] == "inner" + with pytest.raises(AttributeError): + base.name = "x" # type: ignore[misc] + with pytest.raises(AttributeError): + del base._name + + +def test_repr_lists_order() -> None: + p = policy(timeout=_rec([], "t"), retry=_rec([], "r"), name="payments") + assert repr(p) == "Policy('payments', retry > timeout)" + assert repr(policy()) == "Policy((empty))" + assert policy().name is None + assert isinstance(p, Policy) + + +def test_validation_errors() -> None: + with pytest.raises(TypeError, match="retry="): + policy(retry=3) # type: ignore[arg-type] + with pytest.raises(TypeError, match="unknown"): + policy().with_(bogus=1) + + +def test_empty_policy_is_identity() -> None: + def f() -> int: + return 1 + + assert policy()(f) is f diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..546ab0c --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,64 @@ +"""The public surface: every name is importable, and v0.1 call forms keep working.""" + +import inspect +import warnings +from datetime import timedelta + +import pytest + +import python_wrap +from python_wrap import decorators + +V01_NAMES = [ + "timing", "retry", "memoize", "log_args", "log_return", "validate_args", "cache", + "once", "deprecate", "check_type", "retry_on_exception", "measure_memory", "profile", + "rate_limit", "mock_data", "benchmark", "run_in_thread", "no_debug", "transactional", + "revert_on_failure", "audit", "trace", "timeout", +] # fmt: skip + + +def test_all_names_resolve(): + for name in python_wrap.__all__: + assert hasattr(python_wrap, name), name + + +@pytest.mark.parametrize("name", V01_NAMES) +def test_v01_names_still_importable(name): + assert hasattr(python_wrap, name) + assert getattr(decorators, name) is getattr(python_wrap, name) + + +def test_v01_readme_forms(fake_clock): + calls = [] + + @python_wrap.retry(max_attempts=3, delay=1.0) + def flaky(): + calls.append(1) + if len(calls) < 3: + raise ValueError + return "ok" + + assert flaky() == "ok" + assert python_wrap.retry(lambda: 5, max_attempts=2, delay=0)() == 5 + assert python_wrap.timing(lambda: 1)() == 1 + assert python_wrap.memoize(lambda n: n * 2)(4) == 8 + assert python_wrap.cache(timedelta(minutes=5))(lambda: 3)() == 3 + assert python_wrap.timeout(5)(lambda: 4)() == 4 + + with pytest.raises(ValueError): + python_wrap.validate_args(x=lambda x: x > 0)(lambda x: x)(-1) + with pytest.raises(TypeError): + python_wrap.check_type(name=str)(lambda name: name)(1) + + +def test_decorators_preserve_metadata_and_async_nature(): + async def coro(x: int) -> int: + return x + + for deco in (python_wrap.timing, python_wrap.memoize, python_wrap.retry, python_wrap.once): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + wrapped = deco(coro) + assert inspect.iscoroutinefunction(wrapped), deco + assert wrapped.__name__ == "coro" + assert inspect.signature(wrapped) == inspect.signature(coro) diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..48ad8c4 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,824 @@ +from __future__ import annotations + +import asyncio +import contextlib +import email.utils +import functools +import inspect +import logging +import random +import threading +import time +import warnings +from typing import Any + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.retry import ( + RetryState, + RetryStats, + fallback, + retry, + retry_after_header, + retry_on_exception, + retry_stats, + retrying, + suppress, +) + + +class Flaky: + """Fails ``failures`` times, then returns ``"ok"``.""" + + def __init__(self, failures: int, exc: type[BaseException] = ValueError) -> None: + self.failures = failures + self.exc = exc + self.calls = 0 + + def __call__(self) -> str: + self.calls += 1 + if self.calls <= self.failures: + raise self.exc(f"fail {self.calls}") + return "ok" + + +def test_bare_and_parameterised_and_call_forms(fake_clock: FakeClock) -> None: + f1 = Flaky(1) + assert retry(f1)() == "ok" + f2 = Flaky(1) + assert retry(max_attempts=3, delay=1.0)(f2)() == "ok" + f3 = Flaky(1) + assert retry(f3, max_attempts=3, delay=0.5)() == "ok" + assert fake_clock.sleeps == [1.0, 1.0, 0.5] + + @retry + def bare() -> int: + return 1 + + assert bare() == 1 + assert bare.__name__ == "bare" + + +def test_exhaustion_reraises_last_exception_unchanged(fake_clock: FakeClock) -> None: + f = Flaky(10) + with pytest.raises(ValueError, match="fail 3") as info: + retry(f, max_attempts=3)() + assert f.calls == 3 + assert fake_clock.sleeps == [1.0, 1.0] + assert info.traceback # original traceback kept + + +def test_backoff_and_max_delay(fake_clock: FakeClock) -> None: + f = Flaky(10) + with pytest.raises(ValueError): + retry(f, max_attempts=6, delay=1, backoff=2, max_delay=5)() + assert fake_clock.sleeps == [1, 2, 4, 5, 5] + + +def test_jitter_added(fake_clock: FakeClock, monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[tuple[float, float]] = [] + + def fake_uniform(a: float, b: float) -> float: + seen.append((a, b)) + return 0.25 + + monkeypatch.setattr(random, "uniform", fake_uniform) + f = Flaky(2) + retry(f, max_attempts=3, delay=1, backoff=2, jitter=0.5)() + assert fake_clock.sleeps == [1.25, 2.25] + assert seen == [(0, 0.5), (0, 0.5)] + + +def test_unlimited_attempts(fake_clock: FakeClock) -> None: + f = Flaky(20) + assert retry(f, max_attempts=None, delay=0)() == "ok" + assert f.calls == 21 + + +def test_huge_backoff_does_not_overflow(fake_clock: FakeClock) -> None: + f = Flaky(1100) + assert retry(f, max_attempts=None, delay=1, backoff=2, max_delay=10)() == "ok" + assert max(fake_clock.sleeps) == 10 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_attempts": 0}, + {"delay": -1}, + {"backoff": 0.5}, + {"jitter": -0.1}, + {"max_delay": -1}, + ], +) +def test_validation(kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError): + retry(**kwargs) + with pytest.raises(ValueError): + retry_on_exception(**kwargs) + + +def test_on_filters_exceptions(fake_clock: FakeClock) -> None: + f = Flaky(2, KeyError) + with pytest.raises(KeyError): + retry(f, on=ValueError)() + assert f.calls == 1 + assert fake_clock.sleeps == [] + + g = Flaky(1, KeyError) + assert retry(g, on=(ValueError, KeyError))() == "ok" + + +def test_retry_if_predicate(fake_clock: FakeClock) -> None: + f = Flaky(5) + with pytest.raises(ValueError): + retry(f, retry_if=lambda e: "fail 2" not in str(e))() + assert f.calls == 2 + + +def test_baseexception_not_retried_by_default(fake_clock: FakeClock) -> None: + f = Flaky(1, KeyboardInterrupt) + with pytest.raises(KeyboardInterrupt): + retry(f)() + assert f.calls == 1 + g = Flaky(1, KeyboardInterrupt) + with pytest.raises(KeyboardInterrupt): # never retried, even when listed + retry(g, on=KeyboardInterrupt)() + assert g.calls == 1 + + +def test_on_retry_state_and_logging( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + states: list[RetryState] = [] + f = Flaky(2) + with caplog.at_level(logging.WARNING, logger="python_wrap"): + retry(f, delay=2, on_retry=states.append)() + assert [s.attempt for s in states] == [1, 2] + assert states[0].next_delay == 2 + assert isinstance(states[0].exception, ValueError) + assert states[0].elapsed == 0 + assert states[1].elapsed == 2 + assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 2 + with pytest.raises(AttributeError): + states[0].attempt = 5 # type: ignore[misc] + + +def test_custom_logger_name(fake_clock: FakeClock, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger="custom"): + retry(Flaky(1), logger="custom", max_attempts=None)() + assert caplog.records[0].name == "custom" + + +def test_method_forms(fake_clock: FakeClock) -> None: + class C: + n = 0 + + @retry(delay=0) + def inst(self) -> int: + C.n += 1 + if C.n < 2: + raise ValueError + return C.n + + @classmethod + @retry(delay=0) + def cm(cls) -> str: + return cls.__name__ + + @staticmethod + @retry(delay=0) + def sm() -> int: + return 7 + + assert C().inst() == 2 + assert C.cm() == "C" + assert C.sm() == 7 + + +def test_async_retry(fake_clock: FakeClock) -> None: + calls = 0 + + @retry(delay=1, backoff=2, max_attempts=4) + async def go() -> str: + nonlocal calls + calls += 1 + if calls < 3: + raise ValueError + return "ok" + + assert inspect.iscoroutinefunction(go) + assert asyncio.run(go()) == "ok" + assert fake_clock.sleeps == [1, 2] + + +def test_async_exhaustion_and_filter(fake_clock: FakeClock) -> None: + @retry(max_attempts=2, on=ValueError) + async def bad() -> None: + raise ValueError("x") + + @retry(on=ValueError) + async def other() -> None: + raise KeyError("y") + + with pytest.raises(ValueError): + asyncio.run(bad()) + with pytest.raises(KeyError): + asyncio.run(other()) + assert fake_clock.sleeps == [1.0] + + +# -- retry_on_exception ------------------------------------------------------------ + + +def test_retry_on_exception_deprecated_alias(fake_clock: FakeClock) -> None: + f = Flaky(2) + with pytest.warns(DeprecationWarning, match=r"retry\(on=") as rec: + wrapped = retry_on_exception(ValueError)(f) + assert rec[0].filename == __file__ + assert wrapped() == "ok" + assert fake_clock.sleeps == [0.0, 0.0] # delay defaults to 0 + + +def test_retry_on_exception_defaults_and_limits() -> None: + f = Flaky(10) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + wrapped = retry_on_exception()(f) + with pytest.raises(ValueError): + wrapped() + assert f.calls == 3 + + +# -- fallback ---------------------------------------------------------------------- + + +def test_fallback_value_and_handler(caplog: pytest.LogCaptureFixture) -> None: + @fallback(0) + def boom() -> int: + raise ValueError("x") + + @fallback(handler=lambda e: str(e)) + def boom2() -> str: + raise ValueError("msg") + + with caplog.at_level(logging.WARNING, logger="python_wrap"): + assert boom() == 0 + assert caplog.records[0].exc_info is not None + assert caplog.records[0].levelno == logging.WARNING + assert boom2() == "msg" + + +def test_fallback_none_value_and_passthrough() -> None: + @fallback(None, on=KeyError) + def f(x: int) -> int | None: + if x == 0: + raise KeyError + if x == 1: + raise ValueError + return x + + assert f(0) is None + assert f(5) == 5 + with pytest.raises(ValueError): + f(1) + + +def test_fallback_requires_exactly_one() -> None: + with pytest.raises(ValueError): + fallback() # type: ignore[call-overload] + with pytest.raises(ValueError): + fallback(1, handler=lambda e: 2) # type: ignore[call-overload] + + +def test_fallback_async() -> None: + @fallback(handler=lambda e: -1) + async def boom() -> int: + raise RuntimeError + + @fallback("d") + async def fine() -> str: + return "v" + + assert inspect.iscoroutinefunction(boom) + assert asyncio.run(boom()) == -1 + assert asyncio.run(fine()) == "v" + + +# -- suppress ---------------------------------------------------------------------- + + +def test_suppress_forms(caplog: pytest.LogCaptureFixture) -> None: + @suppress + def a() -> int: + raise ValueError + + @suppress(KeyError) + def b(x: int) -> int: + if x: + raise KeyError + raise ValueError + + @suppress(KeyError, ValueError) + def c() -> int: + raise ValueError + + with caplog.at_level(logging.DEBUG, logger="python_wrap"): + assert a() is None + assert caplog.records[0].levelno == logging.DEBUG + assert b(1) is None + with pytest.raises(ValueError): + b(0) + assert c() is None + assert a.__name__ == "a" + + +def test_suppress_async() -> None: + @suppress + async def a() -> int: + raise ValueError + + @suppress(KeyError) + async def b() -> int: + return 3 + + assert inspect.iscoroutinefunction(a) + assert asyncio.run(a()) is None + assert asyncio.run(b()) == 3 + + +# ------------------------------------------------------------------ delay_from / max_time + + +class FakeResponse: + def __init__(self, headers: dict[str, str]) -> None: + self.headers = headers + + +class RateLimited(Exception): + def __init__(self, headers: dict[str, str]) -> None: + super().__init__("429") + self.response = FakeResponse(headers) + + +def test_delay_from_overrides_backoff_and_falls_back(fake_clock: FakeClock) -> None: + hints = iter([7.0, None, 2.0]) + f = Flaky(3) + retry(f, max_attempts=5, delay=1, backoff=2, delay_from=lambda e: next(hints))() + assert fake_clock.sleeps == [7.0, 2.0, 2.0] # attempt 2 used backoff (1 * 2) + + +def test_delay_from_capped_by_max_delay_and_no_jitter(fake_clock: FakeClock) -> None: + retry(Flaky(1), delay_from=lambda e: 60, max_delay=5, jitter=3)() + assert fake_clock.sleeps == [5] + + +def test_delay_from_bad_values_use_backoff(fake_clock: FakeClock) -> None: + def boom(exc: BaseException) -> float | None: + raise RuntimeError("nope") + + retry(Flaky(1), delay=2, delay_from=boom)() + retry(Flaky(1), delay=3, delay_from=lambda e: float("nan"))() + retry(Flaky(1), delay=4, delay_from=lambda e: "x")() # type: ignore[arg-type,return-value] + retry(Flaky(1), delay_from=lambda e: -5)() + assert fake_clock.sleeps == [2, 3, 4, 0.0] + + +def test_retry_after_recipe(fake_clock: FakeClock) -> None: + calls = [] + + @retry(on=RateLimited, delay_from=retry_after_header, max_attempts=3) + def get() -> str: + calls.append(1) + if len(calls) == 1: + raise RateLimited({"retry-after": "4"}) + if len(calls) == 2: + raise RateLimited({}) + return "ok" + + assert get() == "ok" + assert fake_clock.sleeps == [4.0, 1.0] + + +def test_retry_after_header_parsing() -> None: + assert retry_after_header(RateLimited({"Retry-After": "12"})) == 12.0 + assert retry_after_header(RateLimited({"Retry-After": "1.5"})) == 1.5 + date = email.utils.formatdate(time.time() + 30, usegmt=True) + parsed = retry_after_header(RateLimited({"Retry-After": date})) + assert parsed is not None and 25 < parsed <= 30 + past = email.utils.formatdate(time.time() - 100, usegmt=True) + assert retry_after_header(RateLimited({"Retry-After": past})) == 0.0 + assert retry_after_header(RateLimited({"Retry-After": "-3"})) == 0.0 + assert retry_after_header(RateLimited({"Retry-After": "garbage"})) is None + assert retry_after_header(RateLimited({"Retry-After": "nan"})) is None + assert retry_after_header(RateLimited({})) is None + assert retry_after_header(ValueError("no response")) is None + + class WithAttr(Exception): + retry_after = 9 + + assert retry_after_header(WithAttr()) == 9.0 + + class BadAttr(Exception): + retry_after = True + + assert retry_after_header(BadAttr()) is None + + class Exploding(Exception): + @property + def response(self) -> Any: + raise RuntimeError("boom") + + assert retry_after_header(Exploding()) is None + + +def test_max_time_stops_before_overrunning(fake_clock: FakeClock) -> None: + f = Flaky(100) + with pytest.raises(ValueError, match="fail 4"): + retry(f, max_attempts=None, delay=1, backoff=2, max_time=10)() + # waits 1, 2, 4 = 7s elapsed; next wait 8 would end at 15 > 10 + assert fake_clock.sleeps == [1, 2, 4] + assert f.calls == 4 + + +def test_max_time_counts_call_duration(fake_clock: FakeClock) -> None: + calls = [] + + @retry(max_attempts=None, delay=1, max_time=5) + def slow() -> None: + calls.append(1) + fake_clock.advance(2) + raise ValueError + + with pytest.raises(ValueError): + slow() + # t=2 fail, wait to 3, t=5 fail: 5+1 > 5, stop + assert len(calls) == 2 + stats = retry_stats(slow) + assert stats is not None and stats.gave_up == 1 + + +def test_max_time_with_delay_from(fake_clock: FakeClock) -> None: + f = Flaky(5) + with pytest.raises(ValueError): + retry(f, max_attempts=5, delay_from=lambda e: 100, max_time=30)() + assert f.calls == 1 + assert fake_clock.sleeps == [] + + +def test_max_time_validation() -> None: + with pytest.raises(ValueError, match="max_time"): + retry(max_time=-1) + with pytest.raises(ValueError, match="max_time"): + retrying(max_time=-1) + retry(max_time=0)(Flaky(0))() + + +# ------------------------------------------------------------------ cancellation + + +@pytest.mark.parametrize("exc", [KeyboardInterrupt, SystemExit, GeneratorExit]) +def test_never_retries_exit_exceptions_even_with_baseexception( + fake_clock: FakeClock, exc: type[BaseException] +) -> None: + f = Flaky(1, exc) + with pytest.raises(exc): + retry(f, on=BaseException)() + assert f.calls == 1 + assert fake_clock.sleeps == [] + + +def test_baseexception_still_retries_other_baseexceptions(fake_clock: FakeClock) -> None: + class Odd(BaseException): + pass + + assert retry(Flaky(1, Odd), on=BaseException)() == "ok" + + +def test_async_cancelled_error_is_not_retried(fake_clock: FakeClock) -> None: + calls = [] + + @retry(on=BaseException, max_attempts=5) + async def f() -> None: + calls.append(1) + raise asyncio.CancelledError + + async def main() -> None: + with pytest.raises(asyncio.CancelledError): + await f() + + asyncio.run(main()) + assert calls == [1] + assert fake_clock.sleeps == [] + + +def test_cancel_during_backoff_sleep_is_immediate() -> None: + calls = [] + + @retry(max_attempts=5, delay=3600) + async def f() -> None: + calls.append(1) + raise ValueError + + async def main() -> float: + task = asyncio.ensure_future(f()) + for _ in range(5): + await asyncio.sleep(0) + assert calls == [1] # now sleeping in the backoff + started = time.perf_counter() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return time.perf_counter() - started + + assert asyncio.run(main()) < 1 + assert calls == [1] + stats = retry_stats(f) + assert stats is not None and stats.total_sleep == 0 # cancelled sleep not counted + + +def test_retrying_does_not_retry_cancellation(fake_clock: FakeClock) -> None: + runs = 0 + with pytest.raises(KeyboardInterrupt): + for attempt in retrying(on=BaseException): + runs += 1 + with attempt: + raise KeyboardInterrupt + assert runs == 1 + + +# ------------------------------------------------------------------ logging + + +def test_log_level_and_exc_info(fake_clock: FakeClock, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.DEBUG, logger="python_wrap"): + retry(Flaky(1), log_level=logging.INFO, log_exc_info=True)() + (record,) = caplog.records + assert record.levelno == logging.INFO + assert record.exc_info is not None and record.exc_info[0] is ValueError + + +def test_log_level_none_silences(fake_clock: FakeClock, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.DEBUG, logger="python_wrap"): + retry(Flaky(2), log_level=None)() + assert caplog.records == [] + + +def test_default_log_has_no_exc_info( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING, logger="python_wrap"): + retry(Flaky(1))() + assert caplog.records[0].exc_info is None + assert caplog.records[0].levelno == logging.WARNING + + +# ------------------------------------------------------------------ statistics + + +def test_stats_sync(fake_clock: FakeClock) -> None: + f = retry(Flaky(2), delay=2, max_attempts=3) + stats = retry_stats(f) + assert isinstance(stats, RetryStats) + assert (stats.calls, stats.attempts_total) == (0, 0) + f() + assert (stats.calls, stats.attempts_total, stats.retries, stats.gave_up) == (1, 3, 2, 0) + assert stats.total_sleep == 4 + assert isinstance(stats.last_exception, ValueError) + assert "calls=1" in repr(stats) + assert stats.snapshot()["retries"] == 2 + stats.reset() + assert stats.snapshot()["calls"] == 0 and stats.last_exception is None + + +def test_stats_gave_up_and_non_matching(fake_clock: FakeClock) -> None: + @retry(max_attempts=2, on=ValueError) + def f(exc: type[BaseException]) -> None: + raise exc + + with pytest.raises(ValueError): + f(ValueError) + with pytest.raises(KeyError): + f(KeyError) + stats = retry_stats(f) + assert stats is not None + assert (stats.calls, stats.attempts_total, stats.retries, stats.gave_up) == (2, 3, 1, 1) + + +def test_stats_async_methods_and_stacking(fake_clock: FakeClock) -> None: + class C: + n = 0 + + @retry(max_attempts=3, delay=1) + async def go(self) -> int: + self.n += 1 + if self.n < 2: + raise ValueError + return self.n + + c = C() + assert asyncio.run(c.go()) == 2 + stats = retry_stats(c.go) + assert stats is retry_stats(C.go) and stats is not None + assert stats.attempts_total == 2 + + def outer(fn: Any) -> Any: + @functools.wraps(fn) + def w(*a: Any, **k: Any) -> Any: + return fn(*a, **k) + + return w + + wrapped = outer(retry(Flaky(0))) + assert retry_stats(wrapped) is not None + assert retry_stats(len) is None + assert retry_stats(lambda: 1) is None + + +def test_stats_thread_safe() -> None: + @retry(max_attempts=2, delay=0) + def f() -> None: + raise ValueError + + def work() -> None: + for _ in range(200): + with contextlib.suppress(ValueError): + f() + + threads = [threading.Thread(target=work) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + stats = retry_stats(f) + assert stats is not None + assert (stats.calls, stats.attempts_total, stats.retries, stats.gave_up) == ( + 1600, + 3200, + 1600, + 1600, + ) + + +# ------------------------------------------------------------------ retrying + + +def test_retrying_sync_success_after_failures(fake_clock: FakeClock) -> None: + f = Flaky(2) + numbers = [] + result = None + for attempt in retrying(max_attempts=3, delay=0.5, backoff=2): + with attempt: + numbers.append(attempt.number) + result = f() + assert result == "ok" + assert numbers == [1, 2, 3] + assert fake_clock.sleeps == [0.5, 1.0] + + +def test_retrying_final_failure_propagates(fake_clock: FakeClock) -> None: + f = Flaky(10) + with pytest.raises(ValueError, match="fail 2"): + for attempt in retrying(max_attempts=2): + with attempt: + f() + assert f.calls == 2 + + +def test_retrying_non_matching_propagates_and_first_try_success(fake_clock: FakeClock) -> None: + with pytest.raises(KeyError): + for attempt in retrying(on=ValueError): + with attempt: + raise KeyError + runs = 0 + for attempt in retrying(): + with attempt: + runs += 1 + assert runs == 1 + assert fake_clock.sleeps == [] + + +def test_retrying_options_shared_with_retry(fake_clock: FakeClock) -> None: + states: list[RetryState] = [] + f = Flaky(100) + with pytest.raises(ValueError): + for attempt in retrying( + max_attempts=None, + delay=1, + backoff=2, + max_time=10, + on_retry=states.append, + retry_if=lambda e: True, + ): + with attempt: + f() + assert fake_clock.sleeps == [1, 2, 4] + assert [s.attempt for s in states] == [1, 2, 3] + + g = Flaky(2) + for attempt in retrying(delay_from=lambda e: 0.25, max_attempts=3): + with attempt: + g() + assert fake_clock.sleeps[-2:] == [0.25, 0.25] + + with pytest.raises(ValueError): + for attempt in retrying(retry_if=lambda e: False): + with attempt: + raise ValueError + + +def test_retrying_validation_and_reuse(fake_clock: FakeClock) -> None: + with pytest.raises(ValueError): + retrying(max_attempts=0) + r = retrying(max_attempts=2, delay=0) + for _ in range(2): # independent runs + f = Flaky(1) + for attempt in r: + with attempt: + f() + assert r.stats.calls == 2 + assert r.stats.retries == 2 + + +def test_retrying_async(fake_clock: FakeClock) -> None: + f = Flaky(2) + + async def main() -> str: + out = "" + async for attempt in retrying(max_attempts=3, delay=1): + with attempt: + out = f() + return out + + assert asyncio.run(main()) == "ok" + assert fake_clock.sleeps == [1, 1] + + +def test_retrying_async_failure_propagates(fake_clock: FakeClock) -> None: + async def main() -> None: + async for attempt in retrying(max_attempts=2): + with attempt: + raise ValueError("x") + + with pytest.raises(ValueError): + asyncio.run(main()) + assert fake_clock.sleeps == [1.0] + + +# ------------------------------------------------------------------ overrides / events + + +def test_max_attempts_override_applies_at_call_time(fake_clock: FakeClock) -> None: + f = Flaky(10) + wrapped = retry(f, max_attempts=5) + g = Flaky(10) + previous = _core.set_overrides(max_attempts=2) + try: + with pytest.raises(ValueError): + wrapped() + assert f.calls == 2 + with pytest.raises(ValueError): + for attempt in retrying(max_attempts=5): + with attempt: + g() + assert g.calls == 2 + h = Flaky(10) + with pytest.raises(ValueError): + retry(h, max_attempts=None)() + assert h.calls == 2 + _core.set_overrides(max_attempts=10) + k = Flaky(10) + with pytest.raises(ValueError): + retry(k, max_attempts=3)() + assert k.calls == 3 # override only lowers + finally: + _core.set_overrides(**previous) + + +def test_events_emitted(fake_clock: FakeClock) -> None: + seen: list[_core.Event] = [] + _core.listeners.append(seen.append) + try: + retry(Flaky(1), delay=2)() + with pytest.raises(ValueError): + retry(Flaky(5), max_attempts=2, delay=1)() + for attempt in retrying(name="block", delay=1): + with attempt: + pass + finally: + _core.listeners.remove(seen.append) + names = [e.name for e in seen] + assert names == [ + "retry.attempt_failed", + "retry.succeeded", + "retry.attempt_failed", + "retry.gave_up", + "retry.succeeded", + ] + assert seen[0].fields["attempt"] == 1 and seen[0].fields["next_delay"] == 2 + assert isinstance(seen[0].fields["exception"], ValueError) + assert seen[1].fields == {"attempts": 2} + assert seen[3].fields["attempts"] == 2 + assert seen[4].function == "block" and seen[4].fields == {"attempts": 1} diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..ccd5486 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +import asyncio +import inspect +import logging +import threading +from typing import Any + +import pytest + +from python_wrap.state import Transaction, current_transaction, revert_on_failure, transactional + + +class Box: + def __init__(self) -> None: + self.n = 1 + self.items: list[Any] = [1] + + @revert_on_failure + def fail_after_change(self) -> None: + self.n = 99 + self.extra = True + self.items.append(2) + raise RuntimeError("boom") + + @revert_on_failure + def ok(self) -> None: + self.n = 2 + + @revert_on_failure(deep=True) + def fail_nested_deep(self) -> None: + self.items[0].append("x") + raise RuntimeError + + @revert_on_failure + def fail_nested_shallow(self) -> None: + self.items[0].append("x") + raise RuntimeError + + +def test_revert_restores_values_and_removes_new_attrs() -> None: + b = Box() + with pytest.raises(RuntimeError): + b.fail_after_change() + assert b.n == 1 + assert b.items == [1] + assert not hasattr(b, "extra") + + +def test_success_keeps_changes() -> None: + b = Box() + b.ok() + assert b.n == 2 + + +def test_shallow_vs_deep_semantics() -> None: + b = Box() + original = b.items + with pytest.raises(RuntimeError): + b.fail_after_change() + # contents reverted, but the attribute now holds a copy, not the old object + assert b.items == [1] + assert b.items is not original + assert original == [1, 2] + + b = Box() + b.items = [[]] + with pytest.raises(RuntimeError): + b.fail_nested_shallow() + assert b.items == [["x"]] # nested element mutation not reverted + + b = Box() + b.items = [[]] + with pytest.raises(RuntimeError): + b.fail_nested_deep() + assert b.items == [[]] + + +def test_target_by_name_and_index() -> None: + class Obj: + v = 0 + + @revert_on_failure(target="o") + def by_name(x: int, o: Obj) -> None: + o.v = x + raise ValueError + + @revert_on_failure(target=1) + def by_index(x: int, o: Obj) -> None: + o.v = x + raise ValueError + + o = Obj() + o.v = 5 + for fn in (by_name, by_index): + with pytest.raises(ValueError): + fn(1, o) + assert o.v == 5 + with pytest.raises(ValueError): + by_name(x=1, o=o) + assert o.v == 5 + with pytest.raises(ValueError, match="no parameter"): + revert_on_failure(target="missing")(by_name.__wrapped__) # type: ignore[attr-defined] + + +def test_attrs_with_slots() -> None: + class Slotted: + __slots__ = ("a", "b") + + def __init__(self) -> None: + self.a = 1 + + @revert_on_failure(attrs=["a", "b"]) + def go(self) -> None: + self.a = 2 + self.b = 3 + raise RuntimeError + + @revert_on_failure + def no_attrs(self) -> None: ... + + s = Slotted() + with pytest.raises(RuntimeError): + s.go() + assert s.a == 1 + assert not hasattr(s, "b") + with pytest.raises(TypeError, match="attrs="): + s.no_attrs() + + +def test_attrs_subset_leaves_others() -> None: + class C: + def __init__(self) -> None: + self.a = 1 + self.b = 1 + + @revert_on_failure(attrs=("a",)) + def go(self) -> None: + self.a = 2 + self.b = 2 + raise RuntimeError + + c = C() + with pytest.raises(RuntimeError): + c.go() + assert (c.a, c.b) == (1, 2) + + +def test_uncopyable_value_restored_by_reference() -> None: + class C: + def __init__(self) -> None: + self.lock = threading.Lock() + + @revert_on_failure(deep=True) + def go(self) -> None: + self.lock = None # type: ignore[assignment] + raise RuntimeError + + c = C() + lock = c.lock + with pytest.raises(RuntimeError): + c.go() + assert c.lock is lock + + +def test_bad_call_raises_function_type_error() -> None: + @revert_on_failure + def f(o: object) -> None: ... + + with pytest.raises(TypeError): + f() # type: ignore[call-arg] + + +async def test_revert_async() -> None: + class C: + n = 0 + + @revert_on_failure + async def go(self) -> None: + self.n = 5 + await asyncio.sleep(0) + raise RuntimeError + + c = C() + assert inspect.iscoroutinefunction(C.go) + with pytest.raises(RuntimeError): + await c.go() + assert c.n == 0 + + +# ------------------------------------------------------------------------- Transaction + + +def test_commit_runs_in_order() -> None: + out: list[str] = [] + with Transaction() as tx: + tx.on_commit(out.append, "a") + tx.on_commit(out.append, "b") + tx.on_rollback(out.append, "r") + assert out == ["a", "b"] + assert tx.state == "committed" + + +def test_rollback_reverse_and_continues_after_failure(caplog: pytest.LogCaptureFixture) -> None: + out: list[str] = [] + + def bad() -> None: + raise OSError("nope") + + with ( + caplog.at_level(logging.ERROR, logger="python_wrap"), + pytest.raises(ValueError, match="orig"), + Transaction() as tx, + ): + tx.on_rollback(out.append, 1) + tx.on_rollback(bad) + tx.on_rollback(out.append, 3) + tx.on_commit(out.append, "never") + raise ValueError("orig") + assert out == [3, 1] + assert tx.state == "rolled_back" + rec = [r for r in caplog.records if "rollback callback" in r.getMessage()] + assert rec and rec[0].exc_info is not None + + +def test_completes_only_once() -> None: + tx = Transaction() + tx.commit() + with pytest.raises(RuntimeError): + tx.rollback() + with pytest.raises(RuntimeError): + tx.commit() + with pytest.raises(RuntimeError): + tx.on_commit(print) + with pytest.raises(RuntimeError): + tx.on_rollback(print) + + tx2 = Transaction() + with tx2: + tx2.rollback() # manual completion; exit does nothing + assert tx2.state == "rolled_back" + + +def test_explicit_rollback_and_kwargs() -> None: + out: dict[str, int] = {} + tx = Transaction() + tx.on_rollback(out.update, a=1) + tx.rollback() + assert out == {"a": 1} + + +def test_sync_rejects_async_callback() -> None: + async def cb() -> None: ... + + tx = Transaction() + tx.on_commit(cb) + with pytest.raises(TypeError, match="async"): + tx.commit() + + tx = Transaction() + tx.on_rollback(cb) + tx.rollback() # logged, not raised + + +def test_commit_callback_error_propagates() -> None: + def bad() -> None: + raise KeyError + + with pytest.raises(KeyError), Transaction() as tx: + tx.on_commit(bad) + + +async def test_async_transaction() -> None: + out: list[str] = [] + + async def acb(v: str) -> None: + await asyncio.sleep(0) + out.append(v) + + async with Transaction() as tx: + tx.on_commit(acb, "c1") + tx.on_commit(out.append, "c2") + assert out == ["c1", "c2"] + + out.clear() + + async def bad() -> None: + raise RuntimeError + + with pytest.raises(ValueError): + async with Transaction() as tx: + tx.on_rollback(acb, "r1") + tx.on_rollback(bad) + tx.on_rollback(acb, "r3") + raise ValueError + assert out == ["r3", "r1"] + + async with Transaction() as tx2: + await tx2.acommit() + assert tx2.state == "committed" + + +# ----------------------------------------------------------------------- transactional + + +def test_transactional_tx_param_and_current() -> None: + out: list[str] = [] + + @transactional + def work(a: int, tx: Transaction, fail: bool = False) -> int: + assert current_transaction() is tx + tx.on_commit(out.append, "commit") + tx.on_rollback(out.append, "rollback") + if fail: + raise ValueError + return a + + assert work(1) == 1 + assert out == ["commit"] + with pytest.raises(ValueError): + work(1, fail=True) # type: ignore[call-arg] + assert out == ["commit", "rollback"] + assert current_transaction() is None + + +def test_transactional_without_tx_param_and_nesting() -> None: + seen: list[Transaction | None] = [] + + @transactional(logger="x") + def inner() -> None: + tx = current_transaction() + assert tx is not None + seen.append(tx) + tx.on_rollback(seen.append, "inner-rb") + + @transactional + def outer() -> None: + tx = current_transaction() + seen.append(tx) + inner() + assert current_transaction() is tx + raise RuntimeError + + with pytest.raises(RuntimeError): + outer() + assert seen[0] is not seen[1] + assert "inner-rb" not in seen # inner committed on its own + + +def test_transactional_thread_isolation() -> None: + results: dict[str, Transaction | None] = {} + + @transactional + def work(name: str) -> None: + results[name] = current_transaction() + + ts = [threading.Thread(target=work, args=(str(i),)) for i in range(3)] + for t in ts: + t.start() + for t in ts: + t.join() + assert len({id(v) for v in results.values()}) == 3 + assert current_transaction() is None + + +async def test_transactional_async() -> None: + out: list[str] = [] + + async def undo() -> None: + out.append("undo") + + @transactional + async def work(fail: bool, *, tx: Transaction) -> str: + tx.on_rollback(undo) + await asyncio.sleep(0) + assert current_transaction() is tx + if fail: + raise RuntimeError + return "ok" + + assert inspect.iscoroutinefunction(work) + assert await work(False) == "ok" + assert out == [] + with pytest.raises(RuntimeError): + await work(True) + assert out == ["undo"] + assert current_transaction() is None + results = await asyncio.gather(work(False), work(False)) + assert results == ["ok", "ok"] + + +def test_revert_on_classmethod_target_keeps_original_exception() -> None: + class Config: + value = 1 + + @classmethod + @revert_on_failure + def update(cls, v: int) -> None: + cls.value = v + cls.extra = v # type: ignore[attr-defined] + raise ValueError("boom") + + @classmethod + def helper(cls) -> int: + return 7 + + with pytest.raises(ValueError, match="boom"): + Config.update(5) + assert Config.value == 1 + assert not hasattr(Config, "extra") + assert Config.helper() == 7 + + +def test_restore_failure_is_logged_and_original_exception_propagates( + caplog: pytest.LogCaptureFixture, +) -> None: + class Guard: + def __init__(self) -> None: + object.__setattr__(self, "a", 1) + + def __setattr__(self, name: str, value: Any) -> None: + raise RuntimeError("frozen") + + @revert_on_failure + def f(g: Guard) -> None: + object.__setattr__(g, "a", 2) + raise ValueError("orig") + + g = Guard() + with caplog.at_level(logging.WARNING), pytest.raises(ValueError, match="orig"): + f(g) + assert any("could not" in r.getMessage() for r in caplog.records) + + +def test_injected_tx_typed_default_and_explicit_tx_rejected() -> None: + from python_wrap.state import INJECTED, require_transaction + + seen: list[Transaction] = [] + + @transactional + def charge(amount: int, *, tx: Transaction = INJECTED) -> int: + seen.append(tx) + assert require_transaction() is tx + return amount + + assert charge(5) == 5 + assert isinstance(seen[0], Transaction) and seen[0].state == "committed" + with pytest.raises(TypeError, match="do not pass tx"): + charge(5, tx=Transaction()) + with pytest.raises(TypeError, match="do not pass tx"): + + @transactional + def positional(tx: Transaction = INJECTED) -> None: ... + + positional(Transaction()) + assert repr(INJECTED) == "INJECTED" + + +async def test_injected_tx_async_and_explicit_rejected() -> None: + from python_wrap.state import INJECTED + + @transactional + async def charge(*, tx: Transaction = INJECTED) -> Transaction: + return tx + + assert (await charge()).state == "committed" + with pytest.raises(TypeError): + await charge(tx=Transaction()) + + +def test_tx_that_cannot_be_injected_by_keyword_is_rejected_at_decoration() -> None: + from python_wrap.state import INJECTED + + def posonly(tx: Transaction = INJECTED, /) -> None: ... + def star(*tx: Any) -> None: ... + def starstar(**tx: Any) -> None: ... + + for func, kind in ((posonly, "positional-only"), (star, "variadic"), (starstar, "variadic")): + with pytest.raises(TypeError, match=f"cannot inject 'tx'.*{kind}"): + transactional(func) + + @transactional + def before_star(a: int, tx: Transaction = INJECTED, *rest: int) -> str: + return tx.state + + assert before_star(1) == "pending" # still open while the body runs + + +def test_require_transaction_outside_raises() -> None: + from python_wrap.state import require_transaction + + with pytest.raises(RuntimeError, match="no active transaction"): + require_transaction() diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 0000000..25ea70b --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,550 @@ +from __future__ import annotations + +import asyncio +import inspect +import subprocess +import sys +import textwrap +import threading +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from python_wrap import _core +from python_wrap.testing import ( + FakeClock, + disable_caching, + disable_rate_limits, + disable_retries, + eventually, + eventually_sync, + fake_clock, + fast_mode, + overrides, + spy, + spy_on, +) + + +@pytest.fixture(autouse=True) +def _clean_overrides() -> None: + assert _core.overrides == _core.Overrides() + + +# ------------------------------------------------------------------------ overrides + + +def test_defaults() -> None: + o = _core.overrides + assert (o.no_sleep, o.max_attempts, o.disable_rate_limits, o.disable_caching) == ( + False, + None, + False, + False, + ) + + +def test_each_helper_sets_and_restores() -> None: + with fast_mode(): + assert _core.overrides.no_sleep + with disable_retries(): + assert _core.overrides.max_attempts == 1 + with disable_rate_limits(): + assert _core.overrides.disable_rate_limits + with disable_caching(): + assert _core.overrides.disable_caching + assert _core.overrides == _core.Overrides() + + +def test_restores_on_exception() -> None: + with pytest.raises(RuntimeError), fast_mode(): + raise RuntimeError + assert not _core.overrides.no_sleep + + +def test_nesting_restores_previous_values() -> None: + with overrides(max_attempts=5, no_sleep=True): + with overrides(max_attempts=2): + assert _core.overrides.max_attempts == 2 + assert _core.overrides.no_sleep + with disable_retries(): + assert _core.overrides.max_attempts == 1 + assert _core.overrides.max_attempts == 2 + assert _core.overrides.max_attempts == 5 + with overrides(max_attempts=None): + assert _core.overrides.max_attempts is None + assert _core.overrides.max_attempts == 5 + assert _core.overrides == _core.Overrides() + + +def test_same_scope_object_is_reentrant() -> None: + scope = fast_mode() + with scope: + with scope: + assert _core.overrides.no_sleep + assert _core.overrides.no_sleep + assert not _core.overrides.no_sleep + + +def test_decorator_sync_and_async() -> None: + @fast_mode() + def sync_test(x: int) -> bool: + """doc""" + return _core.overrides.no_sleep and x == 1 + + @disable_retries() + async def async_test() -> int | None: + await asyncio.sleep(0) + return _core.overrides.max_attempts + + assert sync_test(1) + assert sync_test.__doc__ == "doc" + assert inspect.iscoroutinefunction(async_test) + assert asyncio.run(async_test()) == 1 + assert _core.overrides == _core.Overrides() + + +def test_invalid_max_attempts() -> None: + with pytest.raises(ValueError): + overrides(max_attempts=0) + with pytest.raises(ValueError): + overrides(max_attempts="x") # type: ignore[arg-type] + with pytest.raises(TypeError): + _core.set_overrides(bogus=1) + with pytest.raises(ValueError): + _core.set_overrides(max_attempts=-1) + + +def test_overrides_visible_from_other_threads() -> None: + seen: list[bool] = [] + with fast_mode(): + t = threading.Thread(target=lambda: seen.append(_core.overrides.no_sleep)) + t.start() + t.join() + assert seen == [True] + + +def test_threads_toggling_disjoint_fields_restore() -> None: + both_in = threading.Barrier(2, timeout=5) + seen: list[tuple[bool, int | None]] = [] + + def worker(scope_factory: Callable[[], Any]) -> None: + with scope_factory(): + both_in.wait() + seen.append((_core.overrides.no_sleep, _core.overrides.max_attempts)) + both_in.wait() + + threads = [ + threading.Thread(target=worker, args=(fast_mode,)), + threading.Thread(target=worker, args=(disable_retries,)), + ] + for t in threads: + t.start() + for t in threads: + t.join() + assert seen == [(True, 1), (True, 1)] + assert _core.overrides == _core.Overrides() + + +def test_no_sleep_makes_core_sleep_instant() -> None: + import time + + start = time.perf_counter() + with fast_mode(): + _core.sleep(5) + asyncio.run(_core.async_sleep(5)) + assert time.perf_counter() - start < 1 + + +async def test_no_sleep_async_still_yields() -> None: + order: list[str] = [] + + async def other() -> None: + order.append("other") + + task = asyncio.create_task(other()) + with fast_mode(): + await _core.async_sleep(10) + order.append("after") + await task + assert order == ["other", "after"] + + +# --------------------------------------------------------------------------- clock + + +def test_fakeclock_basics() -> None: + clock = FakeClock(start=5.0) + assert clock.now == 5.0 and clock.time() == 5.0 + clock.advance(2) + clock.sleep(3) + clock.sleep(-1) + assert clock.now == 10.0 + assert clock.sleeps == [3, -1] + assert clock.total_slept == 2 + before = time.monotonic() + assert before <= FakeClock().now <= time.monotonic() + 1 # rounded up to a whole second + assert FakeClock(start=1000.0).now == 1000.0 + with pytest.raises(ValueError): + clock.advance(-1) + + +def test_fake_clock_patches_and_restores() -> None: + before = (_core.monotonic, _core.perf_counter, _core.sleep, _core.async_sleep) + with fake_clock() as clock: + t0 = clock.now + assert _core.monotonic() == t0 + _core.sleep(2) + assert _core.perf_counter() == t0 + 2 + assert clock.sleeps == [2] + assert (_core.monotonic, _core.perf_counter, _core.sleep, _core.async_sleep) == before + + +def test_fake_clock_nesting_and_custom_clock() -> None: + outer_clock = FakeClock(start=1) + with fake_clock(outer_clock): + with fake_clock() as inner: + assert _core.monotonic() == inner.now + assert inner is not outer_clock + assert _core.monotonic() == 1 + + +async def test_fake_clock_async_sleep_yields_to_other_tasks() -> None: + ran: list[str] = [] + + async def other() -> None: + ran.append("other") + + with fake_clock() as clock: + task = asyncio.create_task(other()) + await _core.async_sleep(3) + assert ran == ["other"] + await task + assert clock.now == clock.start + 3 + + +def test_fake_clock_fixture_is_public_clock(fake_clock: FakeClock) -> None: + assert isinstance(fake_clock, FakeClock) + _core.sleep(1) + assert fake_clock.sleeps == [1] + + +def test_fakeclock_thread_safe_advance() -> None: + clock = FakeClock(start=0) + + def work() -> None: + for _ in range(1000): + clock.advance(1) + + threads = [threading.Thread(target=work) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert clock.now == 4000 + + +# ---------------------------------------------------------------------------- spy + + +def test_spy_records_sync_calls() -> None: + @spy + def add(a: int, b: int = 0) -> int: + """Add.""" + return a + b + + assert not add.called + assert add(1, b=2) == 3 + assert add(4) == 4 + assert add.call_count == 2 and add.called + assert add.__name__ == "add" and add.__doc__ == "Add." + first = add.calls[0] + assert (first.args, first.kwargs, first.result, first.exception) == ((1,), {"b": 2}, 3, None) + add.assert_called_with(4) + with pytest.raises(AssertionError, match="expected last call"): + add.assert_called_with(9) + add.reset() + assert add.call_count == 0 + with pytest.raises(AssertionError, match="never called"): + add.assert_called_with() + + +def test_spy_records_exceptions_and_duration(fake_clock: FakeClock) -> None: + def boom() -> None: + fake_clock.advance(2) + raise KeyError("x") + + s = spy(boom) + with pytest.raises(KeyError): + s() + call = s.calls[0] + assert isinstance(call.exception, KeyError) + assert call.result is None + assert call.duration == 2 + + +async def test_spy_async() -> None: + async def double(x: int) -> int: + return x * 2 + + async def fail() -> None: + raise ValueError + + s = spy(double) + assert _core.is_async(s) + assert await s(4) == 8 + s.assert_called_with(4) + assert s.calls[0].result == 8 + + f = spy(fail) + with pytest.raises(ValueError): + await f() + assert isinstance(f.calls[0].exception, ValueError) + + +def test_spy_as_method_decorator_records_self() -> None: + class Svc: + @spy + def go(self, x: int) -> int: + return x + 1 + + svc = Svc() + assert svc.go(1) == 2 + assert Svc.go.call_count == 1 + assert Svc.go.calls[0].args == (svc, 1) + + +def test_spy_on_module_restores() -> None: + import math + + original = math.floor + with spy_on(math, "floor") as s: + assert math.floor(2.5) == 2 + assert s.call_count == 1 + assert math.floor is original + + +def test_spy_on_class_instance_static_and_class_methods() -> None: + class Thing: + def inst(self, x: int) -> int: + return x + + @staticmethod + def stat(x: int) -> int: + return x * 2 + + @classmethod + def cls(cls, x: int) -> int: + return x * 3 + + t = Thing() + with spy_on(Thing, "inst") as s_inst: + assert t.inst(1) == 1 + assert s_inst.calls[0].args == (t, 1) + with spy_on(Thing, "stat") as s_stat: + assert Thing.stat(2) == 4 and t.stat(2) == 4 + assert s_stat.call_count == 2 + with spy_on(Thing, "cls") as s_cls: + assert Thing.cls(2) == 6 + assert s_cls.calls[0].args == (2,) + assert isinstance(Thing.__dict__["stat"], staticmethod) + assert isinstance(Thing.__dict__["cls"], classmethod) + assert Thing.__dict__["inst"] is not None and not hasattr(Thing.__dict__["inst"], "calls") + + with spy_on(t, "inst") as s_obj: + t.inst(7) + assert s_obj.calls[0].args == (7,) + assert "inst" not in t.__dict__ + + +def test_spy_on_inherited_attribute_is_deleted_on_exit() -> None: + class Base: + def f(self) -> int: + return 1 + + class Child(Base): + pass + + with spy_on(Child, "f") as s: + assert Child().f() == 1 + assert s.called + assert "f" not in Child.__dict__ + assert Child().f() == 1 + + +def test_spy_on_missing_attribute() -> None: + with pytest.raises(AttributeError), spy_on(object(), "nope"): + pass + + +def test_spy_thread_safe() -> None: + s = spy(lambda: None) + + def work() -> None: + for _ in range(500): + s() + + threads = [threading.Thread(target=work) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert s.call_count == 2000 + + +# ---------------------------------------------------------------------- eventually + + +async def test_eventually_passes_after_a_while() -> None: + state = {"n": 0} + + def check() -> None: + state["n"] += 1 + assert state["n"] >= 3 + + await eventually(check, timeout=1, interval=0.001) + assert state["n"] == 3 + + +async def test_eventually_async_assertion() -> None: + state = {"n": 0} + + async def check() -> None: + await asyncio.sleep(0) + state["n"] += 1 + assert state["n"] >= 2 + + await eventually(check, interval=0.001) + + +async def test_eventually_timeout_reports_wait_and_last_error() -> None: + def check() -> None: + assert 1 == 2, "never true" + + with fake_clock() as clock, pytest.raises(AssertionError) as info: + await eventually(check, timeout=1.0, interval=0.25) + msg = str(info.value) + assert "never true" in msg + assert "still failing after 1.000s and 5 attempt(s)" in msg + assert isinstance(info.value.__cause__, AssertionError) + assert clock.sleeps == [0.25] * 4 + + +async def test_eventually_propagates_other_exceptions() -> None: + def check() -> None: + raise KeyError("x") + + with pytest.raises(KeyError): + await eventually(check) + + +async def test_eventually_always_tries_once() -> None: + calls: list[int] = [] + await eventually(lambda: calls.append(1), timeout=0) + assert calls == [1] + + +def test_eventually_sync_with_background_thread() -> None: + done = threading.Event() + threading.Timer(0.01, done.set).start() + + def check() -> None: + assert done.is_set() + + eventually_sync(check, timeout=2, interval=0.001) + + +def test_eventually_sync_timeout(fake_clock: FakeClock) -> None: + def check() -> None: + raise AssertionError("no") + + with pytest.raises(AssertionError, match=r"still failing after 0\.500s and 3 attempt"): + eventually_sync(check, timeout=0.5, interval=0.25) + + +# ------------------------------------------------------------------- pytest plugin + + +def test_module_imports_without_pytest() -> None: + code = ( + "import sys; sys.modules['pytest'] = None;" + "import python_wrap.testing as t;" + "assert not hasattr(t, 'pw_fake_clock');" + "assert t.fast_mode" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_plugin_fixtures(tmp_path: Path) -> None: + (tmp_path / "test_plugin_demo.py").write_text( + textwrap.dedent( + """ + from python_wrap import _core + + def test_clock(pw_fake_clock): + _core.sleep(3) + assert pw_fake_clock.sleeps == [3] + assert _core.monotonic() == pw_fake_clock.now + + def test_fast(pw_fast_mode): + assert _core.overrides.no_sleep + + def test_after(): + assert not _core.overrides.no_sleep + """ + ) + ) + proc = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-p", + "python_wrap.testing", + "-p", + "no:cacheprovider", + "-p", + "no:asyncio", + "-q", + str(tmp_path), + ], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "3 passed" in proc.stdout + + +def test_eventually_sync_under_fast_mode_pauses_real_time(): + import time as _time + + from python_wrap.testing import eventually_sync, fast_mode + + calls = [] + + def never() -> None: + calls.append(1) + raise AssertionError("not yet") + + with fast_mode(): + started = _time.monotonic() + with pytest.raises(AssertionError): + eventually_sync(never, timeout=0.05, interval=0.01) + assert _time.monotonic() - started >= 0.05 + # A busy loop would make thousands of attempts in 50ms. + assert len(calls) < 20 + + +def test_fake_clock_default_start_is_a_whole_second() -> None: + import time + + clock = FakeClock() + assert clock.now == int(clock.now) + assert clock.now >= time.monotonic() - 1 + start = clock.now + clock.advance(10) + assert clock.now - start == 10 diff --git a/tests/test_timing.py b/tests/test_timing.py new file mode 100644 index 0000000..8436b52 --- /dev/null +++ b/tests/test_timing.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import asyncio +import cProfile +import importlib +import inspect +import logging +import pstats +import tracemalloc + +import pytest + +from conftest import FakeClock +from python_wrap import _core +from python_wrap.timing import ( + BenchmarkResult, + MemoryRecord, + TimingRecord, + _format_bytes, + benchmark, + benchmark_async, + measure_memory, + profile, + timing, +) + + +def messages(caplog: pytest.LogCaptureFixture) -> list[str]: + return [r.getMessage() for r in caplog.records] + + +# ----------------------------------------------------------------------------- timing + + +def test_timing_bare_and_readable_units( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @timing + def work(delay: float) -> str: + fake_clock.advance(delay) + return "ok" + + assert work(0.01234) == "ok" + assert work(0.0005) == "ok" + assert work(2.5) == "ok" + assert work(125.0) == "ok" + msgs = messages(caplog) + assert msgs[0].endswith("work took 12.34 ms") + assert msgs[1].endswith("work took 500.00 us") + assert msgs[2].endswith("work took 2.50 s") + assert msgs[3].endswith("work took 2 min 05.0 s") + + +def test_timing_call_form_and_wraps(fake_clock: FakeClock) -> None: + def f() -> int: + """doc""" + return 1 + + wrapped = timing(f) + assert wrapped() == 1 + assert wrapped.__name__ == "f" + assert wrapped.__doc__ == "doc" + + +def test_timing_error_is_logged_and_reraised( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + records: list[TimingRecord] = [] + + @timing(callback=records.append) + def boom() -> None: + fake_clock.advance(0.5) + raise ValueError("x") + + with pytest.raises(ValueError): + boom() + assert "raised ValueError after 500.00 ms" in messages(caplog)[0] + assert records == [TimingRecord(boom.__qualname__, 0.5, False)] + + +def test_timing_threshold_and_callback_always( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + records: list[TimingRecord] = [] + + @timing(threshold=0.1, callback=records.append) + def work(delay: float) -> None: + fake_clock.advance(delay) + + work(0.05) + assert caplog.records == [] + work(0.2) + assert len(caplog.records) == 1 + assert [r.elapsed for r in records] == [pytest.approx(0.05), pytest.approx(0.2)] + assert all(r.success for r in records) + + +def test_timing_logger_name_and_level( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG, logger="custom.name") + + @timing(logger="custom.name", level=logging.DEBUG) + def f() -> None: ... + + f() + assert caplog.records[0].name == "custom.name" + assert caplog.records[0].levelno == logging.DEBUG + + +def test_timing_callback_failure_is_swallowed( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + def bad(record: TimingRecord) -> None: + raise RuntimeError("metrics down") + + @timing(callback=bad) + def f() -> int: + return 3 + + assert f() == 3 + assert any("callback for" in m for m in messages(caplog)) + + +def test_timing_async_measures_awaited_time( + fake_clock: FakeClock, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @timing + async def work() -> int: + await _core.async_sleep(0.25) + return 7 + + assert inspect.iscoroutinefunction(work) + assert asyncio.run(work()) == 7 + assert messages(caplog)[0].endswith("work took 250.00 ms") + + +def test_timing_async_error(fake_clock: FakeClock) -> None: + records: list[TimingRecord] = [] + + @timing(callback=records.append) + async def work() -> None: + await _core.async_sleep(0.1) + raise KeyError("k") + + with pytest.raises(KeyError): + asyncio.run(work()) + assert records[0].success is False + assert records[0].elapsed == pytest.approx(0.1) + + +def test_timing_methods(fake_clock: FakeClock) -> None: + records: list[TimingRecord] = [] + + class C: + @timing(callback=records.append) + def inst(self) -> int: + return 1 + + @classmethod + @timing(callback=records.append) + def cls(cls) -> int: + return 2 + + @staticmethod + @timing(callback=records.append) + def stat() -> int: + return 3 + + assert (C().inst(), C.cls(), C.stat()) == (1, 2, 3) + assert len(records) == 3 + + +# --------------------------------------------------------------------------- benchmark + + +def test_benchmark_stats_and_str(fake_clock: FakeClock) -> None: + delays = iter([9.0, 1.0, 2.0, 3.0]) # first is warmup + calls: list[tuple[int, int]] = [] + + def work(a: int, b: int = 0) -> None: + calls.append((a, b)) + fake_clock.advance(next(delays)) + + result = benchmark(work, args=(1,), kwargs={"b": 2}, runs=3, warmup=1) + assert isinstance(result, BenchmarkResult) + assert len(calls) == 4 + assert calls[0] == (1, 2) + assert result.timings == (1.0, 2.0, 3.0) + assert (result.runs, result.mean, result.median) == (3, 2.0, 2.0) + assert (result.min, result.max) == (1.0, 3.0) + assert result.stdev == pytest.approx(1.0) + text = str(result) + assert "work" in text + assert "3 runs" in text + assert "mean 2.00 s" in text + + +def test_benchmark_single_run_has_zero_stdev(fake_clock: FakeClock) -> None: + result = benchmark(lambda: fake_clock.advance(0.1), runs=1, warmup=0) + assert result.stdev == 0.0 + + +def test_benchmark_validation_and_async_rejected() -> None: + async def coro() -> None: ... + + with pytest.raises(TypeError, match="benchmark_async"): + benchmark(coro) + with pytest.raises(ValueError): + benchmark(len, args=([],), runs=0) + with pytest.raises(ValueError): + benchmark(len, args=([],), warmup=-1) + + +def test_benchmark_async(fake_clock: FakeClock) -> None: + async def work(delay: float) -> None: + await _core.async_sleep(delay) + + result = asyncio.run(benchmark_async(work, args=(0.5,), runs=2, warmup=1)) + assert result.timings == (0.5, 0.5) + assert result.name.endswith("work") + assert len(fake_clock.sleeps) == 3 + + +async def _noop_async() -> None: ... + + +def test_benchmark_async_rejects_sync_and_bad_runs() -> None: + with pytest.raises(TypeError, match="benchmark"): + asyncio.run(benchmark_async(len, args=([],))) + with pytest.raises(ValueError): + asyncio.run(benchmark_async(_noop_async, runs=0)) + + +# ----------------------------------------------------------------------- measure_memory + + +def test_measure_memory_peak_and_net(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + records: list[MemoryRecord] = [] + assert not tracemalloc.is_tracing() + + @measure_memory(callback=records.append) + def alloc() -> int: + data = bytearray(2_000_000) + n = len(data) + del data + return n + + assert alloc() == 2_000_000 + assert not tracemalloc.is_tracing() # we started it, so we stopped it + rec = records[0] + assert rec.peak_bytes >= 2_000_000 + assert rec.net_bytes < 100_000 + assert "MiB" in messages(caplog)[0] + + +def test_measure_memory_keeps_existing_tracing() -> None: + tracemalloc.start() + try: + + @measure_memory + def f() -> list[int]: + return [0] * 1000 + + f() + assert tracemalloc.is_tracing() + finally: + tracemalloc.stop() + + +def test_measure_memory_net_positive_and_error() -> None: + records: list[MemoryRecord] = [] + keep: list[bytearray] = [] + + @measure_memory(callback=records.append) + def hold() -> None: + keep.append(bytearray(500_000)) + + hold() + assert records[0].net_bytes >= 500_000 + + @measure_memory(callback=records.append) + def boom() -> None: + raise RuntimeError + + with pytest.raises(RuntimeError): + boom() + assert len(records) == 2 + assert not tracemalloc.is_tracing() + + +def test_measure_memory_async() -> None: + records: list[MemoryRecord] = [] + + @measure_memory(callback=records.append) + async def work() -> int: + data = bytearray(1_000_000) + await asyncio.sleep(0) + return len(data) + + assert inspect.iscoroutinefunction(work) + assert asyncio.run(work()) == 1_000_000 + assert records[0].peak_bytes >= 1_000_000 + + +def test_measure_memory_nested_stops_once() -> None: + @measure_memory + def inner() -> None: + bytearray(1000) + + @measure_memory + def outer() -> None: + inner() + assert tracemalloc.is_tracing() + + outer() + assert not tracemalloc.is_tracing() + + +def test_format_bytes_units() -> None: + assert _format_bytes(512) == "512 B" + assert _format_bytes(2048) == "2.00 KiB" + assert _format_bytes(-2048, signed=True) == "-2.00 KiB" + assert _format_bytes(5 * 1024**3) == "5.00 GiB" + assert _format_bytes(1024**4) == "1024.00 GiB" + + +# --------------------------------------------------------------------------- profile + + +def helper_work() -> int: + return sum(i * i for i in range(2000)) + + +def test_profile_logs_top_rows(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + seen: list[pstats.Stats] = [] + + @profile(limit=5, callback=seen.append) + def run() -> int: + return helper_work() + + assert run() == helper_work() + msg = messages(caplog)[0] + assert "Profile of" in msg + assert "helper_work" in msg + assert isinstance(seen[0], pstats.Stats) + + +def test_profile_bare_no_limit_and_error(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @profile(limit=None, sort="tottime") + def boom() -> None: + raise ValueError + + with pytest.raises(ValueError): + boom() + assert "Profile of" in messages(caplog)[0] + + @profile + def ok() -> int: + return 1 + + assert ok() == 1 + + +def test_profile_invalid_sort() -> None: + with pytest.raises(ValueError): + profile(sort="nonsense") + + +def test_profile_nested_warns_once_and_runs(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @profile + def inner() -> int: + return 5 + + outer_profiler = cProfile.Profile() + outer_profiler.enable() + try: + results = [inner(), inner()] + finally: + outer_profiler.disable() + assert results == [5, 5] + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + + +def test_profile_async(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="python_wrap") + + @profile + async def work() -> int: + await asyncio.sleep(0) + return helper_work() + + assert inspect.iscoroutinefunction(work) + assert asyncio.run(work()) == helper_work() + assert "Profile of" in messages(caplog)[0] + + +def test_timing_skips_formatting_when_level_disabled( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + timing_mod = importlib.import_module("python_wrap.timing") + + caplog.set_level(logging.ERROR, logger="python_wrap") + seen: list[float] = [] + + def spy(seconds: float) -> str: + seen.append(seconds) + return "x" + + monkeypatch.setattr(timing_mod, "_format_duration", spy) + records: list[object] = [] + + @timing_mod.timing(callback=records.append) + def ok() -> int: + return 1 + + @timing_mod.timing(callback=records.append) + def bad() -> int: + raise ValueError + + ok() + with pytest.raises(ValueError): + bad() + assert seen == [] # nothing formatted, yet the callback ran for both calls + assert len(records) == 2 + assert caplog.records == [] diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..2f7efcd --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,61 @@ +"""Static typing checks, run through mypy in a subprocess. + +The cases live in ``tests/typing_cases/`` and are type-checked, never imported. mypy runs +once for all of them (``--strict`` includes ``--warn-unused-ignores``, so a deliberate +mistake that stops being flagged fails the run). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("mypy") + +CASES_DIR = Path(__file__).parent / "typing_cases" +SUPPRESS_CASES = CASES_DIR / "suppress_cases.py" +DECORATOR_CASES = CASES_DIR / "decorator_cases.py" + + +@pytest.fixture(scope="module") +def mypy_output() -> str: + proc = subprocess.run( + [ + sys.executable, + "-m", + "mypy", + "--strict", + "--no-incremental", + "--show-error-codes", + str(SUPPRESS_CASES), + str(DECORATOR_CASES), + ], + capture_output=True, + text=True, + timeout=120, + ) + return proc.stdout + proc.stderr + + +def _lines_for(output: str, path: Path) -> list[str]: + # mypy may print the path relative to the working directory. + tail = f"{path.parent.name}/{path.name}:" + return [line for line in output.splitlines() if tail in line.replace("\\", "/")] + + +def test_suppress_with_exception_types_is_typed_as_a_decorator(mypy_output: str) -> None: + out = "\n".join(_lines_for(mypy_output, SUPPRESS_CASES)) + assert "error:" not in out, out + assert out.count("-> int | None") == 3, out + assert "Coroutine[Any, Any, int | None]" in out, out + + +def test_decorators_keep_types_bind_methods_and_expose_attributes(mypy_output: str) -> None: + # Stacking keeps inner attributes, methods/classmethods bind, `.breaker` is typed, and + # every deliberate mistake is still reported (else: unused "type: ignore"). + out = "\n".join(_lines_for(mypy_output, DECORATOR_CASES)) + assert "error:" not in out, out + assert "no issues found in 2 source files" in mypy_output, mypy_output diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..8010339 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,971 @@ +import collections.abc as cabc +import inspect +import typing +from collections import deque +from collections.abc import Callable +from typing import ( + Annotated, + Any, + Literal, + NewType, + Protocol, + TypedDict, + TypeVar, + Union, + runtime_checkable, +) + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from python_wrap import errors +from python_wrap.validation import check_type, enforce_types, validate_args + +_X = TypeVar("_X") + + +class Box(typing.Generic[_X]): + pass + + +# --------------------------------------------------------------------------- validate_args + + +def test_validate_args_passes_and_fails() -> None: + @validate_args(x=lambda x: x > 0) + def f(x: int) -> int: + return x + + assert f(3) == 3 + with pytest.raises(errors.ValidationError) as exc: + f(-1) + assert exc.value.parameter == "x" + assert exc.value.value == -1 + assert isinstance(exc.value, ValueError) + + +def test_validate_args_kwargs_defaults_and_custom_message() -> None: + @validate_args(x=(lambda x: x > 0, "x must be positive"), y=lambda y: y < 10) + def f(x: int, y: int = 50) -> int: + return x + y + + with pytest.raises(errors.ValidationError, match="x must be positive"): + f(x=0, y=1) + with pytest.raises(errors.ValidationError, match="'y'"): + f(1) # default is validated + + +def test_validate_args_own_exception_propagates() -> None: + def check(v: int) -> bool: + raise KeyError("boom") + + @validate_args(x=check) + def f(x: int) -> int: + return x + + with pytest.raises(KeyError): + f(1) + + +def test_validate_args_unknown_name_and_bad_validator() -> None: + with pytest.raises(TypeError, match="'z'"): + + @validate_args(z=lambda z: True) + def f(x: int) -> int: + return x + + with pytest.raises(TypeError): + validate_args(x=5) # type: ignore[arg-type] + with pytest.raises(TypeError): + validate_args(x=(lambda x: True, 3)) # type: ignore[arg-type] + + +def test_validate_args_var_params_get_whole_collection() -> None: + @validate_args(args=lambda a: len(a) == 2, kwargs=lambda k: "k" in k) + def f(*args: int, **kwargs: int) -> int: + return len(args) + + assert f(1, 2, k=1) == 2 + with pytest.raises(errors.ValidationError): + f(1, k=1) + with pytest.raises(errors.ValidationError): + f(1, 2) + + +def test_validate_args_bad_call_raises_typeerror() -> None: + @validate_args(x=lambda x: True) + def f(x: int) -> int: + return x + + with pytest.raises(TypeError): + f() # type: ignore[call-arg] + + +async def test_validate_args_async() -> None: + @validate_args(x=lambda x: x > 0) + async def f(x: int) -> int: + return x + + assert inspect.iscoroutinefunction(f) + assert await f(1) == 1 + with pytest.raises(errors.ValidationError): + await f(0) + + +def test_validate_args_method() -> None: + class C: + @validate_args(x=lambda x: x > 0) + def m(self, x: int) -> int: + return x + + @classmethod + @validate_args(x=lambda x: x > 0) + def c(cls, x: int) -> int: + return x + + @staticmethod + @validate_args(x=lambda x: x > 0) + def s(x: int) -> int: + return x + + assert C().m(1) == C.c(1) == C.s(1) == 1 + for call in (C().m, C.c, C.s): + with pytest.raises(ValueError): + call(-1) + + +# --------------------------------------------------------------------------- check_type + + +def test_check_type_basic() -> None: + @check_type(x=int, y=(str, bytes)) + def f(x: Any, y: Any = "a") -> Any: + return x + + assert f(1) == 1 + assert f(1, b"x") == 1 + with pytest.raises(errors.ArgumentTypeError, match="parameter 'x' expected int, got str") as e: + f("no") + assert isinstance(e.value, TypeError) + assert e.value.parameter == "x" + with pytest.raises(TypeError, match="str \\| bytes"): + f(1, 5) + + +def test_check_type_unknown_name() -> None: + with pytest.raises(TypeError): + + @check_type(nope=int) + def f(x: int) -> int: + return x + + +def test_check_type_var_params() -> None: + @check_type(args=int, kwargs=str) + def f(*args: Any, **kwargs: Any) -> None: + return None + + f(1, 2, a="x") + with pytest.raises(errors.ArgumentTypeError, match=r"args\[1\]"): + f(1, "x") + with pytest.raises(errors.ArgumentTypeError, match=r"kwargs\['a'\]"): + f(a=1) + + +async def test_check_type_async_and_kwargs() -> None: + @check_type(x=int) + async def f(x: Any) -> Any: + return x + + assert await f(x=2) == 2 + with pytest.raises(TypeError): + await f(x="a") + + +def test_check_type_skips_missing_arg() -> None: + @check_type(y=int) + def f(x: int, y: Any = None) -> int: + return x + + assert f(1) == 1 # y not passed: defaults are not checked + + +# --------------------------------------------------------------------------- enforce_types + + +def fails(func: Callable[..., Any], *args: Any, **kwargs: Any) -> str: + with pytest.raises(errors.ArgumentTypeError) as exc: + func(*args, **kwargs) + return str(exc.value) + + +@enforce_types +def _list_int(items: list[int]) -> int: + return sum(items) + + +def test_precise_message() -> None: + assert fails(_list_int, [1, 2, "x"]) == ( + "parameter 'items' expected list[int], got list containing str at index 2" + ) + assert fails(_list_int, (1,)) == "parameter 'items' expected list[int], got tuple" + assert _list_int([1, 2]) == 3 + + +def test_bare_and_parameterised_forms() -> None: + @enforce_types() + def a(x: int) -> int: + return x + + @enforce_types(check_return=False) + def b(x: int) -> int: + return "bad" # type: ignore[return-value] + + assert a(1) == 1 + assert b(1) == "bad" + assert "parameter 'x'" in fails(a, "s") + + +def test_return_checked() -> None: + @enforce_types + def f(x: int) -> str: + return x # type: ignore[return-value] + + msg = fails(f, 1) + assert msg == "return value expected str, got int" + + +def test_none_return_and_defaults_not_checked() -> None: + @enforce_types + def f(x: int = None) -> None: # type: ignore[assignment] # noqa: RUF013 + return None + + f() + assert "got str" in fails(f, "s") + + @enforce_types + def g() -> None: + return 1 # type: ignore[return-value] + + assert "expected None" in fails(g) + + +def test_no_annotations_is_noop() -> None: + @enforce_types + def f(x, y=1): # type: ignore[no-untyped-def] + return x + + assert f("anything") == "anything" + + +def test_plain_classes_and_numeric_tower() -> None: + @enforce_types + def f(a: float, b: complex, c: int) -> None: + return None + + f(1, 2.5, True) + f(True, 1, 1) + assert "expected int, got float" in fails(f, 1.0, 1, 1.5) + assert "expected float, got str" in fails(f, "x", 1, 1) + assert "expected complex" in fails(f, 1.0, "x", 1) + + +def test_any_object_and_typevar() -> None: + T = TypeVar("T") + B = TypeVar("B", bound=int) + C = TypeVar("C", int, str) + + @enforce_types + def f(a: Any, b: object, c: T, d: B, e: C) -> None: + return None + + f(1, 2, 3, 4, "x") + assert "parameter 'd'" in fails(f, 1, 2, 3, "no", 1) + assert "got float" in fails(f, 1, 2, 3, 4, 1.5) + + +def test_optional_union_pipe() -> None: + @enforce_types + def f(a: int | None, b: Union[int, str], c: int | None = None, d: list[int] | None = None): # type: ignore[no-untyped-def] # noqa: UP007 + return None + + f(None, "x") + f(1, 2, None, [1]) + assert "parameter 'a' expected" in fails(f, "x", 1) + assert "got float" in fails(f, 1, 1.5) + assert "list containing str at index 0" in fails(f, 1, 1, None, ["x"]) + + +def test_literal() -> None: + @enforce_types + def f(mode: Literal["r", "w"], n: Literal[1] = 1) -> None: + return None + + f("r") + assert "expected Literal['r', 'w'], got 'x'" in fails(f, "x") + assert "got True" in fails(f, "r", True) + + +def test_containers() -> None: + @enforce_types + def f( + a: set[int] = frozenset(), # type: ignore[assignment] + b: frozenset[str] = frozenset(), + c: tuple[int, ...] = (), + d: tuple[int, str] = (1, "a"), + e: dict[str, int] | None = None, + g: tuple[()] = (), + ) -> None: + return None + + f({1}, frozenset("a"), (1, 2), (1, "x"), {"a": 1}, ()) + assert "set containing str" in fails(f, a={"x"}) + assert "frozenset containing int" in fails(f, b=frozenset({1})) + assert fails(f, c=(1, "x")) == ( + "parameter 'c' expected tuple[int, ...], got tuple containing str at index 1" + ) + assert "tuple of length 3" in fails(f, d=(1, "a", 3)) + assert "tuple with int at index 1" in fails(f, d=(1, 2)) + assert "dict with str value at key 'a'" in fails(f, e={"a": "s"}) + assert "dict with key int" in fails(f, e={1: 1}) + assert "tuple of length 1" in fails(f, g=(1,)) + + +def test_nested_messages() -> None: + @enforce_types + def f( + x: list[list[int]] = [], # noqa: B006 + y: dict[str, list[int]] = {}, # noqa: B006 + z: tuple[list[int], str] = ([], ""), + k: dict[tuple[int, int], int] = {}, # noqa: B006 + ) -> None: + return None + + assert fails(f, x=[[1], ["a"]]) == ( + "parameter 'x' expected list[list[int]], got " + "list containing list at index 1 (list containing str at index 0)" + ) + assert "dict with list value at key 'a' (list containing str at index 0)" in fails( + f, y={"a": ["s"]} + ) + assert "tuple with list at index 0 (list containing str at index 0)" in fails(f, z=(["s"], "")) + assert "dict with tuple key (1, 'a')" in fails(f, k={(1, "a"): 1}) + + +def test_abc_generics() -> None: + @enforce_types + def f( + s: cabc.Sequence[int] = (), + m: cabc.Mapping[str, int] = {}, + i: cabc.Iterable[int] = (), + it: cabc.Iterator[int] | None = None, + cl: cabc.Callable[[int], int] | None = None, + st_: cabc.Set[int] = frozenset(), + ) -> None: + return None + + f([1], {"a": 1}, iter(["not checked"]), iter([]), len, {1}) + assert "list containing str at index 0" in fails(f, s=["a"]) + assert "dict with str value" in fails(f, m={"a": "b"}) + assert "got int" in fails(f, i=5) + assert "got list" in fails(f, it=[1]) + assert "got int" in fails(f, cl=5) + assert "set containing str" in fails(f, st_={"x"}) + + +def test_iterators_not_consumed() -> None: + @enforce_types + def f(it: cabc.Iterable[int]) -> list[int]: + return list(it) + + assert f(iter([1, 2])) == [1, 2] + assert f(x for x in [3]) == [3] + + +def test_typing_callable_and_type() -> None: + @enforce_types + def f(cb: typing.Callable[..., int], bare: Callable, t: type[int] = int) -> None: # type: ignore[type-arg] + return None + + f(len, print) + assert "got str" in fails(f, "x", print) + assert "got int" in fails(f, len, 5) + f(len, print, bool) + assert "type str" in fails(f, len, print, str) + assert "got int" in fails(f, len, print, 5) + + +def test_type_variants() -> None: + T = TypeVar("T") + + @enforce_types + def f( + a: type[Any] = int, + b: type[T] = int, # type: ignore[valid-type] + c: type[int | str] = int, + d: type = int, + ) -> None: + return None + + f(float, float, str, float) + assert "type float" in fails(f, c=float) + assert "got int" in fails(f, d=1) + + +def test_annotated_newtype() -> None: + UserId = NewType("UserId", int) + + @enforce_types + def f(a: Annotated[int, "meta"], b: UserId, c: list[Annotated[int, "m"]] = []) -> None: # noqa: B006 + return None + + f(1, UserId(2), [1]) + assert "expected int, got str" in fails(f, "x", 1) + assert "got str" in fails(f, 1, "x") + assert "list containing str" in fails(f, 1, 1, ["x"]) + + +def test_protocols_and_typeddict_accepted() -> None: + @runtime_checkable + class Closer(Protocol): + def close(self) -> None: ... + + class Plain(Protocol): + def go(self) -> None: ... + + class TD(TypedDict): + a: int + + class Res: + def close(self) -> None: ... + + @enforce_types + def f(c: Closer, p: Plain, td: TD) -> None: + return None + + f(Res(), object(), {"a": 1}) + f(Res(), 1, {"wrong": "shape"}) # unsupported constructs are accepted + assert "expected Closer, got int" in fails(f, 1, 1, {}) + + +def test_user_generic_origin_isinstance_only() -> None: + @enforce_types + def f(b: Box[int], d: deque[int]) -> None: + return None + + f(Box(), deque(["x"])) + assert "got int" in fails(f, 1, deque()) + + +def test_forward_references_resolved_lazily() -> None: + @enforce_types + def f(x: "Later") -> "Later": + return x + + global Later + Later = type("Later", (), {}) # type: ignore[misc,assignment] + try: + f(Later()) + assert "expected Later" in fails(f, 1) + finally: + del Later + + +def test_unresolvable_forward_ref_is_accepted() -> None: + @enforce_types + def f(x: "DoesNotExist", y: int) -> None: # noqa: F821 + return None + + f("anything", 1) + assert "parameter 'y'" in fails(f, "anything", "no") + + +def test_string_annotation_with_class_defined_inside() -> None: + @enforce_types + def f(x: "list[int]") -> None: + return None + + f([1]) + assert "list containing str" in fails(f, ["a"]) + + +async def test_async_enforce() -> None: + @enforce_types + async def f(x: int) -> str: + return str(x) if x >= 0 else x # type: ignore[return-value] + + assert inspect.iscoroutinefunction(f) + assert await f(1) == "1" + with pytest.raises(errors.ArgumentTypeError, match="parameter 'x'"): + await f("a") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match="return value expected str, got int"): + await f(-1) + + +def test_methods() -> None: + class C: + @enforce_types + def m(self, x: int) -> int: + return x + + @classmethod + @enforce_types + def c(cls, x: int) -> int: + return x + + @staticmethod + @enforce_types + def s(x: int) -> int: + return x + + assert C().m(1) == C.c(1) == C.s(1) == 1 + for call in (C().m, C.c, C.s): + with pytest.raises(TypeError): + call("x") + + +def test_var_params() -> None: + @enforce_types + def f(*args: int, **kwargs: str) -> None: + return None + + f(1, 2, a="x") + assert "parameter 'args[1]'" in fails(f, 1, "x") + assert "parameter \"kwargs['a']\"" in fails(f, a=1) + + +# --------------------------------------------------------------------------- shared + + +def test_signature_and_metadata_preserved() -> None: + def orig(a: int, b: str = "x", *, c: float = 1.0) -> str: + """doc""" + return b + + for deco in ( + enforce_types, + validate_args(a=lambda a: True), + check_type(a=int), + ): + wrapped = deco(orig) + assert inspect.signature(wrapped) == inspect.signature(orig) + assert wrapped.__name__ == "orig" + assert wrapped.__doc__ == "doc" + + +def test_stacking() -> None: + @enforce_types + @check_type(x=int) + @validate_args(x=lambda x: x > 0, y=lambda y: y != "") + def f(x: int, y: str = "a") -> str: + return y * x + + assert f(2) == "aa" + with pytest.raises(errors.ValidationError): + f(0) + with pytest.raises(errors.ArgumentTypeError): + f("s") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match="parameter 'y'"): + f(1, 5) # type: ignore[arg-type] + assert list(inspect.signature(f).parameters) == ["x", "y"] + + +async def test_stacking_async() -> None: + @enforce_types + @validate_args(x=lambda x: x > 0) + async def f(x: int) -> int: + return x + + assert inspect.iscoroutinefunction(f) + assert await f(1) == 1 + with pytest.raises(errors.ValidationError): + await f(-1) + + +# --------------------------------------------------------------------------- hypothesis + +nested = st.recursive( + st.integers(), + lambda inner: st.lists(inner, max_size=3), + max_leaves=10, +) + + +@given(st.lists(st.integers()), st.dictionaries(st.text(), st.integers())) +def test_valid_containers_always_pass(items: list[int], mapping: dict[str, int]) -> None: + @enforce_types + def f(a: list[int], b: dict[str, int]) -> tuple[list[int], dict[str, int]]: + return a, b + + assert f(items, mapping) == (items, mapping) + + +@given( + st.lists(st.integers(), min_size=1), + st.data(), + st.sampled_from(["x", 1.5, None, b"b"]), +) +def test_single_bad_element_always_fails(items: list[int], data: st.DataObject, bad: Any) -> None: + index = data.draw(st.integers(0, len(items) - 1)) + broken: list[Any] = [*items] + broken[index] = bad + + @enforce_types + def f(a: list[int]) -> None: + return None + + with pytest.raises(errors.ArgumentTypeError) as exc: + f(broken) + assert f"at index {index}" in str(exc.value) + + @enforce_types + def g(a: dict[str, int]) -> None: + return None + + mapping = {str(i): v for i, v in enumerate(items)} + mapping[str(index)] = bad + with pytest.raises(errors.ArgumentTypeError): + g(mapping) + + +@given(nested) +def test_nested_lists_property(value: Any) -> None: + def depth(v: Any) -> int: + return 0 if isinstance(v, int) else 1 + max((depth(i) for i in v), default=0) + + @enforce_types + def f(a: Any) -> None: + return None + + f(value) + if isinstance(value, list): + + @enforce_types + def g(a: list[Any]) -> None: + return None + + g(value) + + +def test_uncheckable_constructs_are_accepted() -> None: + class Meta(type): + def __instancecheck__(cls, instance: object) -> bool: + raise TypeError("nope") + + class Weird(metaclass=Meta): + pass + + @enforce_types + def f( + a: Weird, + b: type[list[int]], + c: typing.Final[int] = 1, # type: ignore[valid-type] + d: typing.ClassVar[int] = 1, # type: ignore[valid-type] + ) -> None: + return None + + f(1, list) + f(1, dict) # type[list[int]] is not a plain class target, so it is accepted + + +def test_format_of_forward_ref_and_special_forms() -> None: + from python_wrap.validation import _fmt + + assert _fmt(typing.ForwardRef("Foo")) == "Foo" + assert _fmt(None) == "None" + assert _fmt(typing.Optional[typing.Sequence[int]]).startswith(("Optional", "Sequence")) # noqa: UP045 + + +# ------------------------------------------------------------ fast paths (positional calls) + + +def test_validate_args_fast_path_defaults_and_kwargs() -> None: + seen: list[tuple[str, object]] = [] + + def spy(name: str) -> Callable[[Any], bool]: + def check(v: Any) -> bool: + seen.append((name, v)) + return True + + return check + + @validate_args(a=spy("a"), b=spy("b"), c=spy("c")) + def f(a: int, b: int = 5, c: int = 6) -> int: + return a + b + c + + assert f(1) == 12 + assert seen == [("a", 1), ("b", 5), ("c", 6)] # defaults are validated + seen.clear() + assert f(1, 2) == 9 + assert seen == [("a", 1), ("b", 2), ("c", 6)] + seen.clear() + assert f(1, c=3) == 9 # keyword call takes the bind path, same result + assert seen == [("a", 1), ("b", 5), ("c", 3)] + seen.clear() + assert f(a=1, b=2, c=3) == 6 + assert seen == [("a", 1), ("b", 2), ("c", 3)] + + +def test_validate_args_fast_path_errors_match() -> None: + @validate_args(x=lambda x: x > 0, y=(lambda y: y != "", "y must not be empty")) + def f(x: int, y: str = "d") -> None: ... + + with pytest.raises(errors.ValidationError) as ei: + f(-1) + assert ei.value.parameter == "x" + assert ei.value.value == -1 + assert "got -1" in str(ei.value) + with pytest.raises(errors.ValidationError, match="y must not be empty"): + f(1, "") + with pytest.raises(TypeError): + f() # type: ignore[call-arg] + with pytest.raises(TypeError): + f(1, "a", 3) # type: ignore[call-arg] + + +def test_validate_args_positional_only_keyword_only_and_variadic() -> None: + @validate_args(a=lambda a: a > 0, b=lambda b: b > 0) + def pos_only(a: int, b: int = 1, /) -> None: ... + + pos_only(1) + pos_only(1, 2) + with pytest.raises(errors.ValidationError): + pos_only(1, -2) + + @validate_args(k=lambda k: k > 0) + def kw_only(a: int, *, k: int = 1) -> None: ... + + kw_only(0) + kw_only(0, k=3) + with pytest.raises(errors.ValidationError): + kw_only(0, k=-3) + + @validate_args(rest=lambda r: len(r) == 2, extra=lambda e: "z" in e) + def variadic(a: int, *rest: int, **extra: int) -> None: ... + + variadic(1, 2, 3, z=1) + with pytest.raises(errors.ValidationError): + variadic(1, 2) + + +def test_validate_args_methods() -> None: + class C: + @validate_args(x=lambda x: x > 0) + def m(self, x: int, y: int = 0) -> int: + return x + y + + @classmethod + @validate_args(x=lambda x: x > 0) + def c(cls, x: int) -> int: + return x + + @staticmethod + @validate_args(x=lambda x: x > 0) + def s(x: int) -> int: + return x + + assert C().m(1) == 1 + assert C().m(1, 2) == 3 + assert C.c(2) == 2 + assert C.s(3) == 3 + for call in (lambda: C().m(0), lambda: C.c(0), lambda: C.s(0)): + with pytest.raises(errors.ValidationError): + call() + + +def test_validate_args_mutable_default_is_the_real_default() -> None: + got: list[object] = [] + + def keep(v: Any) -> bool: + got.append(v) + return True + + @validate_args(items=keep) + def f(items: list[int] = []) -> None: ... # noqa: B006 + + f() + assert got[0] is f.__wrapped__.__defaults__[0] # type: ignore[attr-defined] + + +def test_check_type_fast_path_variants() -> None: + @check_type(a=int, c=(str, bytes)) + def f(a: int, b: object = None, c: str = "x") -> None: ... + + f(1) + f(1, 2) + f(1, 2, b"x") + f(1, c="s") + f(a=1, b=0) + with pytest.raises(errors.ArgumentTypeError) as ei: + f("no") # type: ignore[arg-type] + assert ei.value.parameter == "a" + assert "expected int, got str" in str(ei.value) + with pytest.raises(errors.ArgumentTypeError, match="c"): + f(1, 2, 3) # type: ignore[arg-type] + with pytest.raises(TypeError): + f() # type: ignore[call-arg] + with pytest.raises(TypeError): + f(1, 2, "c", 4) # type: ignore[call-arg] + + +def test_check_type_positional_only_methods_and_kwonly() -> None: + @check_type(a=int) + def pos_only(a: int, /, b: int = 0) -> None: ... + + pos_only(1) + pos_only(1, "free") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError): + pos_only("x") # type: ignore[arg-type] + + class C: + @check_type(x=int) + def m(self, x: int) -> int: + return x + + assert C().m(1) == 1 + with pytest.raises(errors.ArgumentTypeError): + C().m("1") # type: ignore[arg-type] + + @check_type(k=int) + def kw(a: int, *, k: int = 0) -> None: ... + + kw(1, k=2) + with pytest.raises(errors.ArgumentTypeError): + kw(1, k="2") # type: ignore[arg-type] + + +def test_enforce_types_fast_path_numeric_tower_and_messages() -> None: + @enforce_types + def f(a: int, b: float = 1.0, c: complex = 0j, d: str | None = None) -> bool: + return True + + assert f(1) + assert f(True, 2, 3, None) # bool is an int; int is a float and a complex + assert f(1, 2.5, 1.5, "s") + with pytest.raises(errors.ArgumentTypeError, match=r"parameter 'a' expected int, got str"): + f("x") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match=r"parameter 'b' expected float, got str"): + f(1, "x") # type: ignore[arg-type] + # 3.10 spells the hint Optional[str]; later versions print str | None. + optional_str = r"parameter 'd' expected (str \| None|Optional\[str\])" + with pytest.raises(errors.ArgumentTypeError, match=optional_str): + f(1, 1.0, 0j, 5) # type: ignore[arg-type] + with pytest.raises(TypeError): + f() # type: ignore[call-arg] + + +def test_enforce_types_kwargs_defaults_varargs_keyword_only_positional_only() -> None: + @enforce_types + def f(a: int, /, b: int = 0, *args: int, k: str = "k", **kw: float) -> int: + return a + + assert f(1) == 1 + assert f(1, 2, 3, 4, k="z", x=1.5) == 1 + with pytest.raises(errors.ArgumentTypeError, match=r"args\[1\]"): + f(1, 2, 3, "bad") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match="parameter 'k'"): + f(1, k=1) # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match=r"kw\['x'\]"): + f(1, x="s") # type: ignore[arg-type] + + @enforce_types + def g(a: int, b: int = 0) -> int: + return a + + assert g(1, b=2) == 1 + with pytest.raises(errors.ArgumentTypeError, match="parameter 'b'"): + g(1, b="x") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match="parameter 'a'"): + g(a="x") # type: ignore[arg-type] + + +def test_enforce_types_methods_and_return() -> None: + class C: + @enforce_types + def m(self, x: int) -> str: + return str(x) + + @classmethod + @enforce_types + def c(cls, x: int) -> int: + return x + + @staticmethod + @enforce_types + def s(x: int) -> int: + return "no" # type: ignore[return-value] + + assert C().m(1) == "1" + assert C.c(2) == 2 + with pytest.raises(errors.ArgumentTypeError, match="parameter 'x'"): + C().m("1") # type: ignore[arg-type] + with pytest.raises(errors.ArgumentTypeError, match=r"return value expected int, got str"): + C.s(1) + + +def test_enforce_types_return_quick_path_and_check_return_false() -> None: + @enforce_types + def f(x: int) -> float: + return x # an int is a valid float + + assert f(1) == 1 + + @enforce_types + def none_ret(x: int) -> None: + return x # type: ignore[return-value] + + with pytest.raises(errors.ArgumentTypeError, match="return value expected None, got int"): + none_ret(1) + + @enforce_types(check_return=False) + def unchecked(x: int) -> str: + return x # type: ignore[return-value] + + assert unchecked(1) == 1 + + +def test_enforce_types_forward_refs_resolved_lazily_on_fast_path() -> None: + @enforce_types + def f(a: "Later", b: int = 0) -> "Later": + return a + + # `Later` is defined only after decoration; the first call resolves it. + globals()["Later"] = int + try: + assert f(1) == 1 + assert f(2, 3) == 2 + with pytest.raises(errors.ArgumentTypeError, match="parameter 'a' expected int"): + f("x") # type: ignore[arg-type] + finally: + del globals()["Later"] + + +def test_enforce_types_unresolved_forward_ref_retried_each_call() -> None: + @enforce_types + def f(a: "Missing", b: int = 0) -> int: # noqa: F821 + return b + + assert f("anything") == 0 # unresolved: accepted, other params still checked + with pytest.raises(errors.ArgumentTypeError, match="parameter 'b'"): + f("anything", "x") # type: ignore[arg-type] + globals()["Missing"] = int + try: + assert f(1) == 0 # resolved on a later call + with pytest.raises(errors.ArgumentTypeError, match="parameter 'a'"): + f("anything") + finally: + del globals()["Missing"] + + +def test_enforce_types_complex_hints_still_checked_after_quick_miss() -> None: + @enforce_types + def f( + a: list[int], b: dict[str, int], c: type[int] = int, d: Callable[..., Any] = print + ) -> None: ... + + f([1], {"a": 1}) + with pytest.raises(errors.ArgumentTypeError, match="list containing str at index 1"): + f([1, "x"], {}) # type: ignore[list-item] + with pytest.raises(errors.ArgumentTypeError, match="parameter 'd'"): + f([1], {}, int, 3) # type: ignore[arg-type] diff --git a/tests/typing_cases/decorator_cases.py b/tests/typing_cases/decorator_cases.py new file mode 100644 index 0000000..cf4b938 --- /dev/null +++ b/tests/typing_cases/decorator_cases.py @@ -0,0 +1,359 @@ +"""Type-checking cases (not collected by pytest); verified by tests/test_typing.py. + +Every line must type-check under ``mypy --strict``. Lines ending in ``# type: ignore[...]`` +are deliberate mistakes: if a regression stops the checker from flagging one, mypy +reports the ignore as unused and the test fails. ``assert_type`` pins inferred types. + +The file is also clean under pyright strict (with ``reportUnnecessaryTypeIgnoreComment``). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar + +from python_wrap import ( + CircuitBreaker, + audit, + check_type, + circuit_breaker, + debounce, + enforce_types, + log_args, + log_return, + measure_memory, + memoize, + mock_data, + once, + profile, + rate_limit, + retry, + revert_on_failure, + singleflight, + timeout, + timing, + trace, + transactional, + validate_args, +) +from python_wrap.bulkhead import Bulkhead, Limited, limit_concurrency +from python_wrap.caching import CacheInfo, Memoized, Once +from python_wrap.circuit import CircuitProtected, CircuitState, CircuitStats +from python_wrap.concurrency import AsyncDebounced, Debounced, RateLimiter, Timeout +from python_wrap.deadline import Deadline, deadline, remaining + +if TYPE_CHECKING: + from typing_extensions import assert_type + +T = TypeVar("T") + +# --- 1. pass-through decorators keep the exact type, so inner attributes survive stacking + + +@retry(max_attempts=4, delay=0.01, on=ConnectionError) +@memoize(ttl=60, maxsize=256) +@rate_limit(10, 1.0, block=True) +@timeout(5.0) +def fetch_user(user_id: int) -> dict[str, int]: + return {"id": user_id} + + +assert_type(fetch_user(1), dict[str, int]) +assert_type(fetch_user.cache_info(), CacheInfo) +fetch_user("a") # type: ignore[arg-type] +fetch_user.no_such_attribute # type: ignore[attr-defined] # noqa: B018 + +breaker = CircuitBreaker(failure_threshold=2) + + +def plain(x: int) -> str: + return str(x) + + +m = memoize(plain) +PASS_THROUGH: list[Memoized[[int], str]] = [ + retry(m), + retry(m, max_attempts=3), + retry(max_attempts=2, delay=0)(m), + timeout(1.0)(m), + rate_limit(5, 1.0)(m), + singleflight(m), + singleflight(key=None)(m), + log_args(m), + log_args(redact=["pw"])(m), + log_return(m), + log_return(max_length=10)(m), + audit(m), + audit(sink=print)(m), + trace(m), + trace(logger="x")(m), + timing(m), + timing(threshold=0.1)(m), + profile(m), + profile(sort="time")(m), + measure_memory(m), + measure_memory(level=10)(m), + enforce_types(m), + enforce_types(check_return=False)(m), + validate_args(x=lambda v: v > 0)(m), + check_type(x=int)(m), + revert_on_failure(m), + revert_on_failure(target=0)(m), + transactional(m), + transactional(logger="x")(m), + mock_data("fixed")(m), + breaker(m), +] + + +@retry +def ident(x: T) -> T: + return x + + +@timeout(1) +def ident_t(x: T) -> T: + return x + + +assert_type(ident("s"), str) +assert_type(ident_t(1), int) + + +@retry(max_attempts=2, delay=0) +@timeout(1.0) +@breaker +async def fetch_async(x: int) -> list[int]: + return [x] + + +async def use_async() -> None: + assert_type(await fetch_async(1), list[int]) + await fetch_async("x") # type: ignore[arg-type] + + +r_call = retry(plain, max_attempts=3) +assert_type(r_call(1), str) +r_call(1).no_such_method() # type: ignore[attr-defined] + +# --- 2/3. Protocol wrappers bind self (methods) and cls (classmethod stacked above). +# Class access returns a two-form callable (with and without the first argument), so +# mistakes there are reported as call-overload errors. + + +class Service: + @memoize + def meth(self, x: int) -> int: + return x + + @classmethod + @memoize + def cm(cls, x: int) -> str: + return str(x) + + @classmethod + @once + def setup(cls) -> int: + return 1 + + @once + def om(self) -> int: + return 1 + + @debounce(0.1) + def dm(self, x: int) -> None: ... + + @classmethod + @debounce(0.1) + def dcm(cls, x: int) -> None: ... + + @debounce(0.1) + async def adm(self, x: int) -> str: + return str(x) + + @classmethod + @debounce(0.1) + async def adcm(cls, x: int) -> str: + return str(x) + + @circuit_breaker(failure_threshold=3) + def cbm(self, x: int) -> int: + return x + + @classmethod + @circuit_breaker + def cbcm(cls, x: int) -> int: + return x + + +class Helpers: + @staticmethod + @memoize + def st(x: int) -> int: + return x + + @memoize + def meth(self, x: int) -> int: + return x + + +assert_type(Helpers.st(1), int) +assert_type(Helpers.meth(Helpers(), 1), int) +assert_type(Helpers.meth.cache_info(), CacheInfo) +Helpers.st("x") # type: ignore[call-overload] + +svc = Service() +assert_type(svc.meth(1), int) +assert_type(svc.meth.cache_info(), CacheInfo) +assert_type(Service.cm(1), str) +assert_type(svc.cm(1), str) +assert_type(Service.cm.cache_info(), CacheInfo) +assert_type(Service.setup(), int) +assert_type(Service.setup.called, bool) +assert_type(svc.om(), int) +svc.om.reset() +svc.dm(1) +svc.dm.cancel() +Service.dcm(1) +Service.dcm.cancel() +assert_type(Service.dcm.flush(), None) +assert_type(svc.cbm(1), int) +assert_type(svc.cbm.breaker, CircuitBreaker) +assert_type(Service.cbcm(1), int) +assert_type(Service.cbcm.breaker, CircuitBreaker) + + +async def use_async_methods() -> None: + assert_type(await svc.adm(1), str) + assert_type(await Service.adcm(1), str) + assert_type(await svc.adm.flush(), str | None) + svc.adm.cancel() + + +svc.meth("x") # type: ignore[arg-type] +svc.meth() # type: ignore[call-arg] +Service.cm("x") # type: ignore[call-overload] +Service.cm() # type: ignore[call-overload] +Service.setup(1) # type: ignore[call-overload] +svc.dm("x") # type: ignore[arg-type] +svc.dm() # type: ignore[call-arg] +Service.dcm("x") # type: ignore[call-overload] +svc.dm.no_such_method() # type: ignore[attr-defined] +svc.cbm("x") # type: ignore[arg-type] +Service.cbcm("x") # type: ignore[call-overload] + +# --- 4. circuit_breaker exposes .breaker on both overloads + + +@circuit_breaker(failure_threshold=3) +def cb_param(a: int) -> int: + return a + + +@circuit_breaker +def cb_bare(a: int) -> int: + return a + + +@circuit_breaker +async def cb_async(a: int) -> int: + return a + + +assert_type(cb_param(1), int) +assert_type(cb_param.breaker, CircuitBreaker) +assert_type(cb_bare.breaker, CircuitBreaker) +assert_type(cb_async.breaker, CircuitBreaker) +protected: CircuitProtected[[int], int] = cb_bare +cb_param("x") # type: ignore[arg-type] +cb_bare.breaker = breaker # type: ignore[misc] + +# The exported Protocols can annotate decorated functions. +memoized: Memoized[[int], str] = memoize(plain) +first: Once[[int], str] = once(plain) +debounced: Debounced[[int], str] = debounce(0.1)(plain) + + +async def coro(x: int) -> str: + return str(x) + + +adebounced: AsyncDebounced[[int], str] = debounce(0.1)(coro) +callback: Callable[[int], Any] = memoized + +# --- 5. limit_concurrency binds self and cls (classmethod stacked above); 0.2 features + + +class Pool: + @limit_concurrency(2) + def meth(self, x: int) -> int: + return x + + @classmethod + @limit_concurrency(2) + def cm(cls, x: int) -> str: + return str(x) + + @classmethod + @limit_concurrency(2) + async def acm(cls, x: int) -> str: + return str(x) + + +pool = Pool() +assert_type(pool.meth(1), int) +assert_type(pool.meth.bulkhead, Bulkhead) +assert_type(Pool.cm(1), str) +assert_type(pool.cm(1), str) +assert_type(Pool.cm.bulkhead, Bulkhead) +pool.meth("x") # type: ignore[arg-type] +Pool.cm("x") # type: ignore[call-overload] +pool.meth.bulkhead = Bulkhead(1) # type: ignore[misc] +limited: Limited[[int], str] = limit_concurrency(3)(plain) + +shared = RateLimiter(limits=[(5, 1.0), (600, 900.0)], name="api") + + +@shared +@memoize +def shared_cached(a: int) -> int: + return a + + +@timeout(2.0) +@rate_limit(limits=[(5, 1.0)]) +def limited_twice(a: int) -> int: + return a + + +assert_type(shared_cached(1), int) +assert_type(shared_cached.cache_info(), CacheInfo) +assert_type(limited_twice(1), int) +assert_type(shared.try_acquire(), bool) +assert_type(shared.acquire(timeout=1.0), bool) +assert_type(remaining(), float | None) + + +def use_sync_blocks() -> None: + with timeout(1) as block_timeout, shared as block_limiter, deadline(3) as budget: + assert_type(block_timeout, Timeout) + assert_type(block_limiter, RateLimiter) + assert_type(budget, Deadline) + + +async def use_async_blocks() -> None: + assert_type(await Pool.acm(1), str) + async with timeout(1) as t, shared as lim, deadline(1) as d: + assert_type(t, Timeout) + assert_type(lim, RateLimiter) + assert_type(d, Deadline) + + +transitions: list[tuple[CircuitState, CircuitState]] = [] +tuned = CircuitBreaker( + success_threshold=2, + exclude=(KeyError,), + on_state_change=lambda b, old, new: transitions.append((old, new)), +) +assert_type(tuned.stats, CircuitStats) +assert_type(tuned.stats.opened_at, float | None) diff --git a/tests/typing_cases/suppress_cases.py b/tests/typing_cases/suppress_cases.py new file mode 100644 index 0000000..3591dda --- /dev/null +++ b/tests/typing_cases/suppress_cases.py @@ -0,0 +1,31 @@ +"""Type-checking cases (not collected by pytest); verified by tests/test_typing.py.""" + +from __future__ import annotations + +from python_wrap import suppress + + +@suppress +def bare(d: dict[str, int]) -> int: + return d["x"] + + +@suppress(KeyError) +def one(d: dict[str, int]) -> int: + return d["x"] + + +@suppress(KeyError, ValueError, logger="x") +async def many(x: int) -> int: + return x + + +@suppress() +def empty(x: int) -> int: + return x + + +reveal_type(bare) # noqa: F821 # R: int | None +reveal_type(one) # noqa: F821 # R: int | None +reveal_type(many) # noqa: F821 # R: Coroutine[Any, Any, int | None] +reveal_type(empty) # noqa: F821 # R: int | None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..62bb22a --- /dev/null +++ b/uv.lock @@ -0,0 +1,2357 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[[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 = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "backrefs" +version = "8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/c4b75b0279b2cf9e208c7dea5755a452d158a32abe1b7330824ff1127e86/backrefs-8.1.tar.gz", hash = "sha256:f86d9312df3ebae1241758af80809a319cafa1d8f32189edfb242c9aa55573b6", size = 6999089, upload-time = "2026-10-10T13:29:24.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/17/7be730f397b9239be5044a147f0cdfbd9605111b07af053c737b21d79d16/backrefs-8.1-py310-none-any.whl", hash = "sha256:438bd38dc7fa0cded52786a1351a2dad9efc2cda95d047284c41d916a22312a8", size = 380382, upload-time = "2026-10-10T13:29:12.764Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d1/d25387b14119b7062fead6c9b2e87c2ab286beb346df79c6a649c095e979/backrefs-8.1-py311-none-any.whl", hash = "sha256:11f61846e446fa8637af2f5c073f1083aa58dbb7460b019fd51689f564e44f7e", size = 392180, upload-time = "2026-10-10T13:29:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/7d/63/4c3078fcbb3956c55b24472d8375a1b7ad67203d4a1917512eee34d4fbee/backrefs-8.1-py312-none-any.whl", hash = "sha256:b472c76082769f86a97ffccfd1f2599b2b706610c6154955664fffe67e909035", size = 398092, upload-time = "2026-10-10T13:29:16.364Z" }, + { url = "https://files.pythonhosted.org/packages/df/22/60f30c9b8e497ca4bbac23991a726ebc267e23771a1b9d9c04b3a3c16dd6/backrefs-8.1-py313-none-any.whl", hash = "sha256:db577dbae1c61187534d35fc9bfbd0c01158fff6239a66fecf7590e5b17879a9", size = 400050, upload-time = "2026-10-10T13:29:18.09Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/80fa46ecb47717845ab9c07bce8651ef8f80aac5ec698e212d02ed2a9963/backrefs-8.1-py314-none-any.whl", hash = "sha256:1e857c32e91b8fe46ddb359ac21021a37b090fa4239566a61e9856123e334041", size = 411908, upload-time = "2026-10-10T13:29:19.876Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/a57c5a5b7461b8c88266244014aa38052d76f64b064451b1d5342eb09bf4/backrefs-8.1-py315-none-any.whl", hash = "sha256:9c8fe3e2eaae4301a9ce787341d986e5ba25a887d89da6de0de5d58eca26a949", size = 417589, upload-time = "2026-10-10T13:29:21.821Z" }, +] + +[[package]] +name = "build" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3", size = 373348, upload-time = "2026-09-30T04:34:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e", size = 260860, upload-time = "2026-09-30T04:34:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5", size = 246491, upload-time = "2026-09-30T04:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa", size = 290875, upload-time = "2026-09-30T04:34:47.691Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320", size = 286987, upload-time = "2026-09-30T04:34:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249", size = 271105, upload-time = "2026-09-30T04:34:50.957Z" }, + { url = "https://files.pythonhosted.org/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e", size = 269833, upload-time = "2026-09-30T04:34:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9", size = 263048, upload-time = "2026-09-30T04:34:54.013Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5", size = 249237, upload-time = "2026-09-30T04:34:55.373Z" }, + { url = "https://files.pythonhosted.org/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c", size = 290756, upload-time = "2026-09-30T04:34:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c", size = 269026, upload-time = "2026-09-30T04:34:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be", size = 287601, upload-time = "2026-09-30T04:34:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45", size = 272660, upload-time = "2026-09-30T04:35:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4", size = 187301, upload-time = "2026-09-30T04:35:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275", size = 215446, upload-time = "2026-09-30T04:35:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c", size = 203389, upload-time = "2026-09-30T04:35:05.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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" } +resolution-markers = [ + "python_full_version < '3.11'", +] +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/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { 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" }, +] + +[[package]] +name = "coverage" +version = "7.17.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/25/79a66272edd250031aeee6db87d500a0bf76390d0d5f8c2f431fe354715f/coverage-7.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:99a7b390cbccc6bf029ca4da3f0f607c218bb4085e9cd6b58a90d14ca12993c3", size = 225837, upload-time = "2026-10-22T16:03:55.622Z" }, + { url = "https://files.pythonhosted.org/packages/52/a6/f5f6fefb2fc1305363927edad1d0a60ce6ef80d6c1a74a79e825f0f670eb/coverage-7.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72640e02a470b57b15cd655820133f6892760209283a363a60e2df932d5f0fe", size = 226257, upload-time = "2026-10-22T16:03:57.164Z" }, + { url = "https://files.pythonhosted.org/packages/28/61/72889bac8eb44c51f8043edb227df551dfdca1fc4819a6c6d67b3f69427b/coverage-7.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2dc9233f39149b5a4f4aba17a4b6bc5cca22e52cfc4b06fdb07d014439b8b2ea", size = 258282, upload-time = "2026-10-22T16:03:58.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/03e4a5644df9b73de0d44d1b82acd18b06c42b5abc3f1433be8363fa983d/coverage-7.17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e8113d3852e9377421a61de1917df27b3abb25404a4ecf69f9e64612c4345b5", size = 260506, upload-time = "2026-10-22T16:04:00.116Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4e/949022286df7e59e8633a3a7e6635e0e6b53af7d57074cb0bf8f500373fb/coverage-7.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86a39539d10d666bb819c16c94d7edfc1aa2e0531b937f7c549b83d15177c6e", size = 262600, upload-time = "2026-10-22T16:04:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/7d67f60df081006315699b7edb9402c1b47142caf1383f4e56ae8ff1a501/coverage-7.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:559f748446ee799803686b5784e3deb54c70d655ac386a860e06b11672504ba7", size = 264609, upload-time = "2026-10-22T16:04:03.286Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/9293d94afbc099990a085e88229a51c79b00828aba0f2adda32dc4cc16d1/coverage-7.17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d65c9e8e257f48a654908005046fbf1bc48d9540ae3776ad08c0acd010e10c", size = 258382, upload-time = "2026-10-22T16:04:04.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/c8/bcd28efedf262c074dae76f61098739eb02b15f3f716f80cc86f9885b8a9/coverage-7.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b7fa0997b8eba7378ac66f31ec7f70164490353ab79b9667d37c0d365e50177", size = 260299, upload-time = "2026-10-22T16:04:06.291Z" }, + { url = "https://files.pythonhosted.org/packages/b8/dc/503f55e5e2200933403c90d619d9f779b5060ddd338b1bdc63cd235592cb/coverage-7.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ae0453a3390e67f7bfbe34417bbdf5b4ebfc21db23fb446a0a41115eeeca6ca", size = 258314, upload-time = "2026-10-22T16:04:07.729Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b1/f1a57c36077cc516240636f7eff0d1427939ed795f096dd2c7769213a56c/coverage-7.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:96151262832f3c230ebae23b2d6911ad549e1738465946c97004913ff06112ba", size = 262239, upload-time = "2026-10-22T16:04:09.116Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/c2e59d4a132936c7c207b9b69ab99c0f27b14c3c93266e9922aa3b562b37/coverage-7.17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cd7397de6cc782f00fe5ac1d75e9b306bcfa49c8b6b8dd6ed1f7709393ad40b3", size = 257841, upload-time = "2026-10-22T16:04:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/05/04/50742c0ee8b6c85826ff11737c93ed957fea4c9acf4b4b99804bdac1dd84/coverage-7.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d1fdf82c6fea57b5e9dada509f9ce9638c8334664e2c98c7f6f1ccf3d143ad2", size = 259039, upload-time = "2026-10-22T16:04:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/83ad4bbbfcb6d045a33c14cdca313f6c9275737c67f3b7300222e3239a37/coverage-7.17.0-cp311-cp311-win32.whl", hash = "sha256:38c86e5d74bbfd88a393fa3f91257c1225c63aa6db6d5918de3e49ab04f91c6e", size = 227687, upload-time = "2026-10-22T16:04:13.537Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c8/dcc5a7effa79707eb8a61e699b141cff7c9b46036771274cfb48ea7b765f/coverage-7.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ed3097c6817bc1a7fc8ae0a6131db74aa1150d863cccc308237ff362d3226b2", size = 228290, upload-time = "2026-10-22T16:04:14.899Z" }, + { url = "https://files.pythonhosted.org/packages/d1/58/a8553e0dcd38d8136df63964638d2b588e0a4a916bff1bca996244fda693/coverage-7.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:6052e878b628abcd38fa5f2b64abebc686eed4e2267da42434017a9c6d24f241", size = 228199, upload-time = "2026-10-22T16:04:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/0a/25/989fd3b495c4274ac0cf54cb6664326415e60183a7f8a73dd9bbb0b11e34/coverage-7.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0cfdc938ca3319d873d1f1252e60620a39aee4ebf9dbd7cc58238109ebda9c51", size = 225938, upload-time = "2026-10-22T16:04:18.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/14726426bf3241657facbf0f7df7babd8f7d3fcf8e4609b7929f9dceecbc/coverage-7.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d523da3c2e4bca49417768bcdb1a443c5b359dc44b8b93a3102e118fce912c40", size = 226166, upload-time = "2026-10-22T16:04:19.524Z" }, + { url = "https://files.pythonhosted.org/packages/8e/97/f95ed32cd3cfbe261a3598e694cb770cefc7b2d13ff013418d6f70a44dde/coverage-7.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:adbf3450c0f3f2b2854e282a8758f2b3eddf787eeff24ec9c9bc2cbc38328cc1", size = 258462, upload-time = "2026-10-22T16:04:21.079Z" }, + { url = "https://files.pythonhosted.org/packages/91/ac/eb4c31f741509a9ef48cf6f28385e98e9de0785599282252eee252ff8af7/coverage-7.17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85932227e9dd38e62cd29c17fefdfecfb919eead6af1e3259522d03cf85de983", size = 261789, upload-time = "2026-10-22T16:04:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3e/d9cd006d1a3cc5deab07beb29b86cb4a10dd84d66b330820363fb8d310d6/coverage-7.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f5477aac9c9e4a0a641c55f8f8fddd7f3bd91176c912c50f40ffced96b4034", size = 262322, upload-time = "2026-10-22T16:04:24.94Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3a/4c872c6ae0762b09cdb8e856891bb3f584848cb60b6dda2bacb85fe2f3f0/coverage-7.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b8bb651a4406fb6a983a905309fec1783443c65e1771b41e92740a0f4a1176b", size = 264600, upload-time = "2026-10-22T16:04:26.444Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/1d41a2373fa98b423bcd70adb91d75e38eafb417955426dbb920b10016f3/coverage-7.17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0edeb6594f080c8de1263cccb239cb316ef77aeb959e2e0f38f2953fcc72a49e", size = 258678, upload-time = "2026-10-22T16:04:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/fe04b5e63e76506daa63d90bdfb00a53767fe5df18546b437d6226918d6b/coverage-7.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da94ec0484e84fce5c7bf8d0f54b97767a90d992653a129b35634699d516e0a5", size = 260210, upload-time = "2026-10-22T16:04:29.275Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/5c5a78dc19dd4799be9c6ec022316fb25ababaeacfaf0f52f24c56a57b43/coverage-7.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a25cc5e8c23a590815c6a7aa43624234d25cb23b21b484adb8a0daed59860db5", size = 258375, upload-time = "2026-10-22T16:04:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/4c1d8eaff4d756fefd15d58c4953a78ab656782fd0e32c5a3465dba61aa0/coverage-7.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db645c81cf4d6bcb392ea1894aad65d55d1588a8472c834b25ca575da3029a56", size = 262422, upload-time = "2026-10-22T16:04:32.053Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2a/0b17c3e2e7b6ff1a5d92eb58915c0d1026668928f961d14dac73cf1b6096/coverage-7.17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6a80a11b90c4a03df1502304cf24ff953255b6b5d076ef79066ecf88baaff31f", size = 257875, upload-time = "2026-10-22T16:04:33.824Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/eb0cbde0535342df0d17a667163030a3afb67e5eb9e3ad94af9540041584/coverage-7.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4eaf11d6b0f2147f46cfbf06fe7edcca6f095d0120da00ddc2458995ee869fc", size = 260361, upload-time = "2026-10-22T16:04:35.661Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/1be2a6db7eb655d0a1d899217c3f2469f9121cbbeddac35c1c8173b1482e/coverage-7.17.0-cp312-cp312-win32.whl", hash = "sha256:912a29f338798541d795e2e25903c6fe7a99dee70a40fe4706a9a124649b523e", size = 227747, upload-time = "2026-10-22T16:04:37.102Z" }, + { url = "https://files.pythonhosted.org/packages/11/c5/201c894c57de81875d8b8cc527ba222aa350e8cfb183eb3f6df45d06ad8e/coverage-7.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:88a1a9fb0075b83389dd9b403bb3fce976031b3c2aa0e05e3e57143cf639de69", size = 228325, upload-time = "2026-10-22T16:04:38.989Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/196095339b19758cf83f507fb598bc42820ae124745feb3084662729e17a/coverage-7.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:2c680e838ff9e68d491158625ca2c1ddb17d78d791bba96a31f8b839b4990438", size = 228131, upload-time = "2026-10-22T16:04:40.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/e6/1f01afbc9f4fa8ca5e1fa6fdf537a5c58b5765d1b4167ee284cddb9acc72/coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2", size = 227080, upload-time = "2026-10-22T16:04:42.325Z" }, + { url = "https://files.pythonhosted.org/packages/92/7a/bdb7e6260c4a211389aeac9b89dc31662ebe122153ad0ecfd156897912df/coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5", size = 227152, upload-time = "2026-10-22T16:04:43.737Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/276e7d5b3cfb124871bdb544c27b004a8b4e270e6bf8e7414149c54b5e5b/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398", size = 225397, upload-time = "2026-10-22T16:04:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7d/dac073db12f3c131598a6693253aded581e4d7d622a89864f11bfb0ca2f5/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb", size = 225705, upload-time = "2026-10-22T16:04:46.603Z" }, + { url = "https://files.pythonhosted.org/packages/00/45/877b0a9248e921080ae91d115c13192d17e308f5aee86bcc7f7bc2a54ed1/coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429", size = 225828, upload-time = "2026-10-22T16:04:48.065Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f5/2c48d47abec0e4590b302920334be5ee3854f96bbd22524feabb8d92b220/coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094", size = 226088, upload-time = "2026-10-22T16:04:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/ec0442f3f372c7fd742f49b3022edd392f79e37a0048e1fa333f56d01ec3/coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5", size = 257257, upload-time = "2026-10-22T16:04:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/eb0927ba93809f76eee69fa1e47999825fc7dbbabc3e0e1a2744f2677acb/coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669", size = 260165, upload-time = "2026-10-22T16:04:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/8d4c6055efb32099b15c9cc33d007b8aabebc4832f372a3274de5ba0afc5/coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd", size = 261098, upload-time = "2026-10-22T16:04:53.763Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/93412288688a7b03f152a734c8ef086e395108a0a6a746985d3967e220f2/coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722", size = 263164, upload-time = "2026-10-22T16:04:55.386Z" }, + { url = "https://files.pythonhosted.org/packages/3c/20/7eb8d805c4f1a212245cef20181dfbf39535357985073d49730ee9cf08d4/coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7", size = 257287, upload-time = "2026-10-22T16:04:56.976Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/1df29630b4ed17e135c76390afcc6bd273c3dc5ba2a21a76813a600fe98c/coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398", size = 259256, upload-time = "2026-10-22T16:04:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/a1/79/2365d1008d28aafe468a450a24c6c63363de794b5140223d67685cd0e205/coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e", size = 257178, upload-time = "2026-10-22T16:05:00.103Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9f/f002952d295d772688dffce0d991e3822795b1e3aad158073712a5a5e86e/coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28", size = 261275, upload-time = "2026-10-22T16:05:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/f9e15fbb426be97cd679ee5d359151e046f103bcd76b5092c318c26ed596/coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95", size = 257029, upload-time = "2026-10-22T16:05:02.8Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4a/3c398f38694387334ff4c9f71afc8e15b4fa7db7bc0bea4d5b6032eb0df0/coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9", size = 259109, upload-time = "2026-10-22T16:05:04.241Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a9/908a3cf1a2335aec1c788682a06c2f742a62a4f987033f080de1c5fd1997/coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d", size = 227691, upload-time = "2026-10-22T16:05:05.636Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/8cdd7bfd8a63b3e6b133fc395ecfc7b1cb6f561926e7e2ad050de85b83e7/coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d", size = 228418, upload-time = "2026-10-22T16:05:07.057Z" }, + { url = "https://files.pythonhosted.org/packages/09/cf/0b3fc57f735cd3831ca13d0be5312a5477d5934066683c5d34018cab7541/coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83", size = 228142, upload-time = "2026-10-22T16:05:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" }, + { url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" }, + { url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" }, + { url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" }, + { url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" }, + { url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/f9c24911755f91e6f451ca2e2c1804fbcbe9fecbc3310e341bbbbfa1d85b/coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559", size = 227196, upload-time = "2026-10-22T16:05:58.543Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a5/2b665b40c626e6bb40e5e8e23d91170b48b5f2f57186f46b07f2fac42386/coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b", size = 227315, upload-time = "2026-10-22T16:06:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8c/22b518d61b930b8a4d62905d046c76a03675ef19b466462011dfb16439a3/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c", size = 225490, upload-time = "2026-10-22T16:06:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/08/5b/d3b141455944e030cd4987f055f8c84a44a1a0b28d81ae93389f44051691/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f", size = 225797, upload-time = "2026-10-22T16:06:03.423Z" }, + { url = "https://files.pythonhosted.org/packages/03/24/aab2156983f7022361352640c583d08f0238e4cd7463eb921172001b4cfe/coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109", size = 226025, upload-time = "2026-10-22T16:06:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/db/94697b9ccf4ec94cdad07bf94e2a21b9702247cb212d2d5f18d2b9c3d0de/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4", size = 226193, upload-time = "2026-10-22T16:06:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/a0/fd/db1c4ade4219831d590fff5aa5b7d9ceaf84586e8e60da6fff71b6182251/coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968", size = 258046, upload-time = "2026-10-22T16:06:07.51Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c", size = 261735, upload-time = "2026-10-22T16:06:08.861Z" }, + { url = "https://files.pythonhosted.org/packages/64/ad/c1e4367bbd1e7d562d6bc979c82b542f2040544e95a8cd995b694caf0e1a/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771", size = 262819, upload-time = "2026-10-22T16:06:10.49Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3a/e8e8509b0940403eab15d86f70022790a01fce3e971a161919c544953f7a/coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f", size = 265194, upload-time = "2026-10-22T16:06:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/1f/77/d64bbdbb734b1059ebcde2b5df567f142a9948079e51af9b0abf8c9ec17b/coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28", size = 258410, upload-time = "2026-10-22T16:06:13.234Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/7cf81f08a254532c074eb52cfe6b12bb753d08f2997269c9fac8de359541/coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d", size = 260697, upload-time = "2026-10-22T16:06:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/56/bacea6dee2d5ccb31ab0cfffcee87ca7efa80ff048840209c8013aeaf02e/coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b", size = 258649, upload-time = "2026-10-22T16:06:16.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/97/fd8a6a63740f9ee177c53b59e2f4d0752369bb0fd154086a1e69aa1fb255/coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592", size = 262989, upload-time = "2026-10-22T16:06:17.85Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/7cd3d3520d45db3788456452b65446182c08f2a7c276f40c9dd11966be02/coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549", size = 257955, upload-time = "2026-10-22T16:06:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b9/80343a55e07d854927a915cc21a52e350ae1db9ead33f0248507b960785f/coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089", size = 260512, upload-time = "2026-10-22T16:06:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/e4c816f0cb3efdc6392bb276ff88359729f9dec605bdd3c1c7a44776f759/coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67", size = 227913, upload-time = "2026-10-22T16:06:22.407Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/283120142ac281834808584249b89957de997c28573ddd65ae106b622121/coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e", size = 228532, upload-time = "2026-10-22T16:06:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bc/83dbc6c3bd58042afaefcb0cfd7d7a2e3076989a2f3d9bc6e6edac819784/coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036", size = 228393, upload-time = "2026-10-22T16:06:25.163Z" }, + { url = "https://files.pythonhosted.org/packages/00/ed/9463d1cb1766a2570343a372c2871c7fb4d561e5051f029399f4d57ce947/coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a", size = 226481, upload-time = "2026-10-22T16:06:26.563Z" }, + { url = "https://files.pythonhosted.org/packages/ae/59/6536c7fa87be13236b4184fc395f14d18f622a5b8ef7448a6bfdf5f24ec0/coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6", size = 226876, upload-time = "2026-10-22T16:06:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bc/4ffae6038f78382c9ab6a4969cb5fc01d50b083d119851085807629abe88/coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569", size = 267241, upload-time = "2026-10-22T16:06:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/cae4b708b4217d33ebf66b9c57f8910a257346b3eaf054a78d73605ddc4c/coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca", size = 270079, upload-time = "2026-10-22T16:06:30.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/84/1225ab04e55a6a2e483e903a5eb0f8f081cb5325df833594534aa288d91c/coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36", size = 272775, upload-time = "2026-10-22T16:06:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/ad/567f8dd9246a553aa8753732c41f0e0654e98e9a617d6282e64eae0e6aa8/coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697", size = 274110, upload-time = "2026-10-22T16:06:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/66/5f/e0ee76895df40601e4aedffb6b8e71ea48e276a629fb4488b92cffffca14/coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2", size = 267775, upload-time = "2026-10-22T16:06:35.193Z" }, + { url = "https://files.pythonhosted.org/packages/19/6d/d19c6e45e940e5b9ba697b7be9a8e63cef73d90bdea45b670a59df466717/coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350", size = 270268, upload-time = "2026-10-22T16:06:36.597Z" }, + { url = "https://files.pythonhosted.org/packages/27/a3/b2731bb7aef2db800b330603675c3fa58a6497a05d5b5f8625f3b6285f68/coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a", size = 267298, upload-time = "2026-10-22T16:06:37.973Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/d0c90a81b6b6607933109914dc8b0ef0a5f4cacf9482ca868ae773ee5d0b/coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3", size = 271899, upload-time = "2026-10-22T16:06:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/1e/f038f3a2614e9ef839a40a184713aca40b8ed79e626ab5cdc388771045df/coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03", size = 267380, upload-time = "2026-10-22T16:06:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/8d636e639b2d8b1934128573402a7caf66c8cfbc794fa02061d84f115c34/coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35", size = 268993, upload-time = "2026-10-22T16:06:42.361Z" }, + { url = "https://files.pythonhosted.org/packages/73/7a/219f6b391cb0636242d06c390a8add5af91afa3e92afd0380dbedd34f487/coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802", size = 228216, upload-time = "2026-10-22T16:06:43.714Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3f/514d294319c5eac11494394545de209079f33993e14526c220eab29b2a96/coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc", size = 229146, upload-time = "2026-10-22T16:06:45.262Z" }, + { url = "https://files.pythonhosted.org/packages/f6/82/c0d8bb0a67df838fc7bc6f4060ffc404fd1154fd18e624d83e6abc41aeef/coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c", size = 228716, upload-time = "2026-10-22T16:06:47.097Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffelib" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + +[[package]] +name = "griffelib" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/36/e2/0f5d8ed08766bc6dcd3eebf5a6623d83cbca33a6e4c90aef57156ce745ad/griffelib-2.4.1.tar.gz", hash = "sha256:40c028dd2e623f464423f0e11e3c553158f1cf4f1368239a370534d73e1407ff", size = 245153, upload-time = "2026-10-22T11:20:03.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/02/1156d5f5307f573b901b14373848b84e69ca6cab09169b8cc97d16a181f9/griffelib-2.4.1-py3-none-any.whl", hash = "sha256:76bc562f936ad1ce198d2cf6d0731bd5c92a7d934b69ef828ef22ee0ffdb62ce", size = 172983, upload-time = "2026-10-22T11:19:59.843Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "exceptiongroup" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a8/bd70d7c2966e561228b9fdc075ee77c0ba577dcbbfbf921edf614db14f6a/hypothesis-6.168.5.tar.gz", hash = "sha256:76b9226962fe11d40858253a967eda95bb65811365286317e0118f4ec8f808c7", size = 511115, upload-time = "2026-10-05T23:26:35.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0c/7f04c8d277dfc828ba584b7d9d10dbac5e91fce673fa5328f7bd5bf64609/hypothesis-6.168.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ca43a751410a9c6685f029fd5126cc5507664cafaa76017922aa8ae2e17b6620", size = 791539, upload-time = "2026-10-05T23:24:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/660906d83db74eb86feda715d0f2df14836205b14a183332116676733e6f/hypothesis-6.168.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b98707cbe9f430d100a945bbe17612fd3aa44eac1b0ac5299669fe3b8e4128", size = 787208, upload-time = "2026-10-05T23:25:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/36e19492bc4ff354c2be9c8fa7c6ace0c65f9d2c7116656b741680c6ca55/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dde52a0b696c642e7f988a03026c7c29f90daf21e74507b6f865c3ccc9d536e", size = 1115200, upload-time = "2026-10-05T23:25:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/d4/82/3273fb0a3567c09b767bb8fe2824d65e16ae2abb92cf1f43762df723df94/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f02e4541fe0c17a1320617effc0ab8a8aca2a9af15e3358d4150acf3bbdc00", size = 1145113, upload-time = "2026-10-05T23:25:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/74/59/5c5904555a0bbd4b2898d73ea90c6d03f5be0d8ff0756ac1d519ace6ae66/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf6dd7e537a12763c9afa017f7a6159e5cda608e98670621fa44596a1e8e9288", size = 1139564, upload-time = "2026-10-05T23:25:56.681Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/55654ff9575587a401e304f08ad1d43b7e6318f81c66bd866fdc5ab4665b/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df2c04cd30abf42c52580184216162a75b5508b214a472b86670f6dd50659a3b", size = 1192142, upload-time = "2026-10-05T23:26:06.565Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/4cc9d6e8a950473e07e3ebf00cbb8ee0d76b14d193f94c3de20f1c09e2b1/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278662eb21aaec9eaae71ea4dabd4fe390c2af11ec58a6a0606687cf6d7689b0", size = 1157262, upload-time = "2026-10-05T23:24:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/4b8aa3be788ea81b9a7bc6b673ed89edd72fd0645c6aa691d4c159ff971a/hypothesis-6.168.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6bcedc4ab8ab92dd0f3af0cfe24dce184d225751d7bc870a9cddb9a557de847f", size = 1113779, upload-time = "2026-10-05T23:24:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/f9/98/2eb4c79d1851195e6a083568b065235680ab984e984bbd472f2a7d02ba33/hypothesis-6.168.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b58097cc3b98d8616f635ac73888fc9f859311875f2adc043f1544c40c3c466", size = 1152639, upload-time = "2026-10-05T23:25:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9c/68f7e99b43c6f37c077669a4d3bd88f48c042444ced9e7cff0eaf44bc70a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f8a387d9ee7f804e830b31f2e2e339ab5731665e922cfda4f6f6fbdb05e191b4", size = 1290068, upload-time = "2026-10-05T23:25:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/d4f87164a0d028ab102cea345b601d9dafb3196358df5448caa88ac3c1e2/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:326f6383fdf2e37ac69773589a8238a3bf396ca8ac8efacb0fb9ed42dd08e426", size = 1418551, upload-time = "2026-10-05T23:24:51.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/6b518a25514f0e643f95610c77e279bfbf0e0b3bd423aac0187d6f039b9a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d33fc74e43bbd7c3a8f6f7161a8b93b676924286e97e70e828c6e0dcee5c01f", size = 1372130, upload-time = "2026-10-05T23:25:32.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/32538e14e63193ca894ba584696805d1eb45cfc27e15fccd47acfb87531c/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1994923cf5e5220ae6bf19645302504b27c0289d83e5d8690df71dcae63d8416", size = 1268950, upload-time = "2026-10-05T23:25:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/e50e7e98af9489aa05203c2ab38c95d891dd8d1ed08fad972dcdb6955332/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:501038fd24d3bc95239cfd093a23cf1151f29dd82382a3554dac5dfdab9729ae", size = 1283907, upload-time = "2026-10-05T23:24:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/41c460a7d2148a04b212b2d594d39992fb52e0b844e13bf6784573fc8dea/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2292ddc24fe6d04b7d30fa6a7e2c9e280ad5078fe671d0bf4aa6df6e143b5ac", size = 1323762, upload-time = "2026-10-05T23:24:18.984Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/37a7fc1fe445e3589e0f56ff4573c28de1d6e6a03009cba2f99f04e46ffa/hypothesis-6.168.5-cp310-abi3-win32.whl", hash = "sha256:925d67c69b719d416334aa961c0cdfc4a58a471af1ebd2d7101bd515a70f4e5f", size = 677903, upload-time = "2026-10-05T23:25:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/7b25ca7845a60522ebc5f8054f6bba68d47126fb5d940c784fc528a4be4a/hypothesis-6.168.5-cp310-abi3-win_amd64.whl", hash = "sha256:2311590eccba452de863dfe3466daa86a05c25f072ab31ed8bb4d3313ee68439", size = 684616, upload-time = "2026-10-05T23:25:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/40e7c36b46c8788eddc7a322ad324e6db53c8ab9a8b9a95d6535ee7bdaaf/hypothesis-6.168.5-cp310-abi3-win_arm64.whl", hash = "sha256:222a6d23a2a824b0f9f73761c2fb9cd2aca96cf3e5b441617625bce4f7eb4fd4", size = 683061, upload-time = "2026-10-05T23:25:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/04/0a/3b3414124055ac49c2478cb49add90eb3b727508b2aa54a4fc50de88f98a/hypothesis-6.168.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8dfead3a6b2e2ceb6165505885b81396b0e3fe8a556bd941d88fa43cd8daff2f", size = 792187, upload-time = "2026-10-05T23:25:51.287Z" }, + { url = "https://files.pythonhosted.org/packages/a1/60/90ccc9e18d831480920dc0f1d33a9af142e796d67dbe6a760e93d0122587/hypothesis-6.168.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:658563b8f2782a0577a4d8d195e31f29b18f3f3b61ba58c4dcbd8e6ac502d14d", size = 788228, upload-time = "2026-10-05T23:24:57.84Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/8257699b8456241b8348fe0071c29912aeeaf5d16ef97a45e9c1d3170ca6/hypothesis-6.168.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f40be9b9c6b7b058ff56b0b18a91ff4cfa57a7c7756043eabaa094a0a162c9", size = 1115527, upload-time = "2026-10-05T23:24:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/42/42/31e66ce21aa6ea030ace8874269e5a169b0c69d8a3043042e315bd64c6ad/hypothesis-6.168.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30208c44364b6fe1f70c74b45f3f1f8a173a749d876294a80fe88c9cf16ab6d0", size = 1157678, upload-time = "2026-10-05T23:25:54.904Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2a/b46ea00cb1cb9930b9cf7f844673913bf8bfc34f38c031d39ede6f649c59/hypothesis-6.168.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:09ca5b2f45786feb93ab41c16de602de4a54f42f35985565423417f4ed9d5b6b", size = 1290397, upload-time = "2026-10-05T23:25:34.184Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e8/eb50f72257f8b00f950da99c7ee444aae5f7c6364fce4ffbe82dd550ffdf/hypothesis-6.168.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:257175b2800cb3073f21041d174e67db7613dc64cc79f3f09f93cfecf7cfeb68", size = 1324447, upload-time = "2026-10-05T23:26:32.767Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/cbb53055091323c186752b437024ff6cd95564af4389bfd1b36900aa459d/hypothesis-6.168.5-cp310-cp310-win_amd64.whl", hash = "sha256:3cacf8e84badb92e34336a6b6b95e2135ad248f870382daf56fe471d6c6e794a", size = 684598, upload-time = "2026-10-05T23:24:40.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/f1149d913d685809c016b2a3ae9d727741ae22f52376c6d0ed51eecb5ac8/hypothesis-6.168.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c35e5d4a85d0d6071cc267a6cbb8fd7ae23ca8a0f745ea5a52c0064d7c1c4b8", size = 792108, upload-time = "2026-10-05T23:25:12.323Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/7e5ffb6bbfc033c85746243dc4d1541876082e136ee44c02f843bb77427e/hypothesis-6.168.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:244a8d14c0a8a3be0345ad0b120deafb94517cc1d74a961d14b5b5eb041b4c0c", size = 788035, upload-time = "2026-10-05T23:26:26.557Z" }, + { url = "https://files.pythonhosted.org/packages/38/df/022129d3e16d19a84e7a5a35ebf7baca07d3482fb34f0faaab865b14fe66/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e68e1d43b7c9c7a1aa659dfe1c0ecc2de79391b20db853c1e18ea7e3d2ce31f", size = 1115438, upload-time = "2026-10-05T23:24:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/b3e45b0386d8f643a304105883c5bfce79fd530b2dfe3a70564e1d7aa0bd/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a4d3773f285e75551eeef12df058e6316b666bcc3ec187c5eb52a893fbb015", size = 1157503, upload-time = "2026-10-05T23:25:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4a/aba5a74ddb20c9f41ba5b8f2918c5a12660146cab2120f14122122715060/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc327005f2fbb55db81d132948ee7c6cec0589694bed04b1e45fc8fc317e12bd", size = 1290235, upload-time = "2026-10-05T23:25:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/34/f4/7204aa6117a38085e6f1dbefd5cd98050a58c847f2bdecc917422cdb2b1c/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f21c74ad83fe77abc72e82c54114148fb01396769c234e26c9b9dbc21344a9", size = 1324204, upload-time = "2026-10-05T23:26:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/15a46ced6d999148d1b718c5488c243bd56dfcd687a61404fe371192dfd5/hypothesis-6.168.5-cp311-cp311-win_amd64.whl", hash = "sha256:bd3ff6e53e29b86ec6078f123284e65e1c678fe7b30c2b52512244faf266502c", size = 684414, upload-time = "2026-10-05T23:26:18.231Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/a04a727578cbef9f75c11fa6fbad66d13aaffc354f4f979506219814c7d4/hypothesis-6.168.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ddee1ef4bab47e315b705e42d2f4354e789973d11f9620d2df242aef4cfa42b2", size = 793156, upload-time = "2026-10-05T23:25:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/55de4e2a12fe98ebd5bc8f35e59870c897ab360cbfe5aa63862cdbef56ad/hypothesis-6.168.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ceb49b0dc3a4b6126cd0d3bf2b634af4e91513c8f1e2daee16041414ed8e3d", size = 784788, upload-time = "2026-10-05T23:26:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f4/61/230abc6320540bdf73baf9a1c025fb0aa27cfd5a3791a2e0c95114239a70/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a09caa95d2d7e6546f727f703de606145835d9ca215fb3134a21353c69afaac", size = 1113853, upload-time = "2026-10-05T23:25:30.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4d/3bf0a7806b3fa12ed076f2daeb3db0e6f9738994e879432ffd8dbcffd634/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ac1d516a42a3b1f13b36a1aa6a5f842e43d67e69d4dc664a9645b28de411ef", size = 1156695, upload-time = "2026-10-05T23:24:28.607Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a0/603f918fcf8f74f81ea593b04e3a9a9fcd426bbf389ed52cb340249bdc14/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4819fba78c6cbaa6e2f9fd5a69a413817446943f286763819b5ac52391bff3e", size = 1288792, upload-time = "2026-10-05T23:25:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/711ef5be6e889dcd40d9b03cdd85cd42ae39af75835bced3c374730291a9/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87334b95dfbc101652fa48a427a742b0715b814506d9a10f621c29e476b4a2c1", size = 1323090, upload-time = "2026-10-05T23:25:58.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/0377d7d13ff3e2c16efd141942649edcdb568caec4576f86ac779545dd85/hypothesis-6.168.5-cp312-cp312-win_amd64.whl", hash = "sha256:2fcec23ff4eb526ee85d3510f564b938ca74f6011f1eec1050e4eb55280b0468", size = 681999, upload-time = "2026-10-05T23:25:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b3/1f7f72cd28d02a5ca99c432fbffe4b750a375df2284af9d916943dd3aa4f/hypothesis-6.168.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:714337b25ca9137bc359c570b868269462307e120999412ca1946f997f4b9db5", size = 793075, upload-time = "2026-10-05T23:25:15.905Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ba/5b0874828695c4d49e3858d0967254f783e563cd0e211a6db27d11d48a1f/hypothesis-6.168.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1c3617155fcf5b5259a1f2e4c775d3eec7bfa80b162b2f6f145b08f871ab08", size = 784693, upload-time = "2026-10-05T23:24:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/ca777becba5251b0d778bb9d83d15524c559a07e4b5d4e6211473855bae2/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebee70b7a026210bb47c86c89e5bfb42effd5bd630080e76bc084f29c01c7f7a", size = 1113693, upload-time = "2026-10-05T23:24:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/5a74bf329e405db3edc5639a2595eccf33ad6f5aaa191019e9f824d630f4/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cfb06b31cca005345b8ad63f88986d21fd359a7dc3dba2965dd3515b720e5c9", size = 1156530, upload-time = "2026-10-05T23:24:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/e79cf67f03f212a1394abac21053bd6887aa70f557be1da3f9c9c73e58ae/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a4c244d7ab64963fb575f0ec2d813630e1d14cefc39e7c460d5d778e5af4118", size = 1288713, upload-time = "2026-10-05T23:25:22.763Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/df452159ac8d7b278071a3e81fafc69da833ec4302b8c85f5b6e530aea21/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e59d519f6fb38b3fa4fcde046767b03a24740fe827d261ee7ff9a721c06169b", size = 1323134, upload-time = "2026-10-05T23:26:02.485Z" }, + { url = "https://files.pythonhosted.org/packages/af/fb/f07d8d09fb57eb14555cad64dfbe29bfdcecff3806f1e01268258088e741/hypothesis-6.168.5-cp313-cp313-win_amd64.whl", hash = "sha256:c103f655644afa4ef6bf7efbf86e44b78ee475fd0691da2db86e2cfe72c07234", size = 682019, upload-time = "2026-10-05T23:24:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/de/e9/7c3c2262b8cfa825c4c1764d62aa15e628bae257ccfd2ee4f3ffa4f81eaa/hypothesis-6.168.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c4dc037d8001bc6eccb8636f4a38d16ea6b250d6bf0a89075aaa5e5069f751cc", size = 793253, upload-time = "2026-10-05T23:26:14.331Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/7909ed7d29302491e9b7bc0e7ac3287c20736c05a0cc35bae65024aeec3b/hypothesis-6.168.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c90743321f29b65491d146adfc2ece85869bacb71ce18b47674795e896c81ee3", size = 784837, upload-time = "2026-10-05T23:25:24.728Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/57742c459349052e6a3e0c011855840f8cbbbadca91079d5b591f08b25ae/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09debb7f7f0f229da5f7e2ad515a5be7a8dc607ec204074775f8ab6731a447f0", size = 1114268, upload-time = "2026-10-05T23:24:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/07bd2449f91f9426f705fb689429bab6e26d1365f8ac4ef7d7c1cec9055e/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d227f8ac497eca0bde4e8562d32dd4e82fc9566526020bbd567f76b833b923b0", size = 1156838, upload-time = "2026-10-05T23:24:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/75/7f3dda517e5134f73e2ae41821bf40b3fd3ac6551a9a43ea9287471738a1/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc6ebd35601c72c842e5899c3f760f9ed26c69e786ee40a9a64fb5a4a3058315", size = 1288864, upload-time = "2026-10-05T23:24:42.645Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cd/4b1364140642cf3f1431ca59b5841fc322872dfa7197b2facb97692da234/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:503e103ad49e702bad200157d82778eebbc14d3045e9700a8e8fe5db40912953", size = 1323300, upload-time = "2026-10-05T23:24:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/47d7cffcaf15318a4308516b6b3d2fd0db599f18eacc0f2dc553be2206a7/hypothesis-6.168.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:bc5cc310f9f86ec62f0d0dd7eea5a4788f18ec793b70ee2c7163b916768e1057", size = 624239, upload-time = "2026-10-05T23:24:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/2ca0f68150be175b7cfa7bfb6692260638d86aeb9313478ba82e198186e6/hypothesis-6.168.5-cp314-cp314-win_amd64.whl", hash = "sha256:71ce0599e806ce3a68f9f118edf450bf091e11b134f6bcc5f8dd706b42c91ebc", size = 681884, upload-time = "2026-10-05T23:25:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/4fc2b5970df0c27668ec08abc505f1d01314a69953f89dc0edc6528ff5a0/hypothesis-6.168.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f66b02c9e95e916a2c58f725a92377ec988146ed7b5aeccd5e78ceecac1eae6f", size = 791636, upload-time = "2026-10-05T23:26:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/03cdf5f052dcb441e045be1fd0aa531e85cde1a1cbabab60968625c570a3/hypothesis-6.168.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bab27926e1d1575fb43b70d4aeece05b74a5e477af0509b56cb6fd778070dd93", size = 783280, upload-time = "2026-10-05T23:26:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/615557af244e2f3ce4763029c03a62ed82dcbbd646b72a8c479ef0408b33/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edeb42c3009b5652dc1c44907ec91bfe9284100ad5e57993dfebabb76f2961a1", size = 1112149, upload-time = "2026-10-05T23:24:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/67/a6707fcd51dc5f2531bf88ac072e99f31ab9d8020488b01349a6d2981081/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8977456328147c521a16a089325017b2c728fddc23351693a4fd924cc7fc7001", size = 1155637, upload-time = "2026-10-05T23:24:24.047Z" }, + { url = "https://files.pythonhosted.org/packages/85/d4/ac2e852d2f163afd398854662bcbb0b849a767abbf2f95a75de6f685f821/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:36ecf7ac351f9c0b5489ba800884b607da754e88ef40713fbfcc170d2151e6eb", size = 1287040, upload-time = "2026-10-05T23:25:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/f77b1602704bda6ff3d9d0817120bd7fb94fd792bd25508b36ce4b8bd2a2/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0333aa5129ba3019a83fb81a7f0fc238180e415a9edddd9a15101f8deaaa517e", size = 1322166, upload-time = "2026-10-05T23:25:45.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2e/94138a73e0906b31cb5968d20be58688f582a09e5958f2c75d45a7049545/hypothesis-6.168.5-cp314-cp314t-win_amd64.whl", hash = "sha256:2fcb87341d76ae0183e8219c9a14d55957c50d14973879db5fea3e81da45ba1a", size = 681599, upload-time = "2026-10-05T23:24:37.827Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/92cb8092b680be2b6ec5ffe83b9f1a98dbf414117566f9e3ba4e8b569214/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:453ab7d0a1fadbaa54ae8722d22463cc2046fa8ef25b9b88715d28279bf79fc1", size = 790950, upload-time = "2026-10-05T23:24:45.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/78aea12694e3d1177e2980d44798b6d93e191faf59155b18bf5ae315f6a2/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:bbdbc43d1f9dad595b249b7bbe8ee5102bc94a4fcb0a79ff76d20e41fcfe342a", size = 782939, upload-time = "2026-10-05T23:25:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/5ef9947b2d149f773428e555bdf66688405aa5167510bdbe97c8ec5c6090/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bc36194d7b6083591060836c7872711a6820217b325bf432dd7e10b3d4af5cb", size = 1111380, upload-time = "2026-10-05T23:24:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/7e668e203fb2659c6214dc0c24cc09b7dea8a02c7c8d0ad338f644a054c4/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22425e2b1543a43c157a81472c713ba8f291cbaf054c70ffe128e2cacc294f65", size = 1142644, upload-time = "2026-10-05T23:26:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/b112978676e795658d58c4294bf90cdbb8cb56cb8292c8c4874650468cf9/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eea0bc513d0e38d1d5ddfb581132928871cd02dc54dfe4511a5396727c48e9d0", size = 1135966, upload-time = "2026-10-05T23:24:49.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/27/cd3bf01e8246c4318ec3df15f5eeeee3214f444df0129a6c7f9a62859ee8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb142bc70bbf6645e15c7ca72de3f7c8dae198aa2743a609f4f3e3bb4f9c3a52", size = 1189646, upload-time = "2026-10-05T23:26:04.471Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/4d3e882f31c289e432dfec34dbb9029296038a8c69e8b28cebb0a5fb7ea8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27b758707bd37f5a1759cca6eef83fe1a212c38dc4ca0a203434004c5647d15", size = 1154770, upload-time = "2026-10-05T23:25:10.814Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/96f5455e1b3d0409cbbb1434c98e792bcceefd614a4b11e600072520b487/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:77a111cb50c330fa7098f65852fa17a01ecd781a85be3cf5e5871bdeeeb0ecbc", size = 1109925, upload-time = "2026-10-05T23:25:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/0758985d9d36f0c5ec981a1457ea1c8173f62d46a94531417aec117df4d7/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdd0afc13e86ec76cae3d3659569c1f601f4e9ca52b5cf91c1685979eae64d7b", size = 1148476, upload-time = "2026-10-05T23:24:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/a9b8953e1d8aefcd3c22cf8d10dd8acf93e602b903278e2e51cf8544ccea/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5fefb02035864c3d322e3b0969b296250923fdcfb574ea1ad4374f1a6333f663", size = 1285856, upload-time = "2026-10-05T23:25:38.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/66098444dc832523ddc4f2e05723662834e5f99bba3c759615d059f6420e/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:9db8aa1f5529e1b577ec18b775c2fb4225821712e946f7762b90c966604faf83", size = 1415500, upload-time = "2026-10-05T23:24:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/e971b6fe20ef8d7c2019cbf24b4f6149468efc88c42a744f5bc99e6ca0ed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:59e07d2f62b5ff573b0059959ae9cef9edfb0f5393fdb35ea81fce1ee77b27ac", size = 1368623, upload-time = "2026-10-05T23:26:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b279dfbd2c06cdb3030ba7eea042cb2cf0171d0013563d103d5207dde63b/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8a03ca128bea29d6826fc545f1f6289fb1ea2e83a5bb811321761b2d515ca575", size = 1264457, upload-time = "2026-10-05T23:25:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/16ac9f8ddfada1cd278bd2185234d0d36ebd304926b69ad0497c210c6fed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:5c03f2d3f84f626f3fd07f54573ab40455e1a1996e98a4f4971caf8b7e796afe", size = 1281744, upload-time = "2026-10-05T23:24:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/99a44430b82998fdef0ffd7d353f64ee5f078c2805ff70f8677ee102cb6c/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2bdf8ce9b72a620cd5ec4dd6b1c1837ff6971489a863851d11d9b0f58dd4062a", size = 1321504, upload-time = "2026-10-05T23:26:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/58ef2564d1985a5c1a1dc57906b8363a767094abca180e80a0aca4cb635f/hypothesis-6.168.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:5c3abbef7b17571fd713b0922407d9cd8cbc652254c0f462875f15199fcb29f7", size = 674959, upload-time = "2026-10-05T23:24:36.482Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/153414f55eb0c85bd9d891bd7811d746978c7ad3de81ea79eeb4e62e088b/hypothesis-6.168.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:38172199abab94a04bc017613e055faa796d7175fbc6221aac504d406c960b60", size = 681329, upload-time = "2026-10-05T23:26:08.897Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/117c82f08ab3ba1dcfbf6562ac43b8deb8efa8106646494fadd15122cc1b/hypothesis-6.168.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0600ddc24c32dab5ca8e780630ab6e2561df6d7f594f781d0608b38e04c4da91", size = 679632, upload-time = "2026-10-05T23:24:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/73/25/5c38b739fb778d4de48aab6509b9cf0afd0317bb0459741afdcd0ad44aed/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6786049db92275e0c5cfac7dfcda6d4bbc80bdf84cbc8c9c7171ca17f47b5aac", size = 792028, upload-time = "2026-10-05T23:24:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3f/91071d53240f5f13ab1dda286e3ddb33177537dbf55cede76e7f4a3856db/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ffbde24430dcd73231fd03324a934e0f638f7c0899fc566f3ef8c851534f8030", size = 787324, upload-time = "2026-10-05T23:24:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/10/ef/eb262e50d7741de6c49d27923e2c282d079273b8bcdacde33165ea39488d/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea967baaedfd532f1a521aaedafc66bb9de09795071492b0e7252139df38479f", size = 1115118, upload-time = "2026-10-05T23:24:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/87/67/a655a8666164aa896516f919af272fa3a3a00d2786be880c31bb638e79e2/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f98289a5da876c08b9eeb68d1cfdfbd0fcc110cf364d33c3cc32cf229ffe8", size = 1157894, upload-time = "2026-10-05T23:26:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/71c422a29446c03e9a052f10b8ee527044242e71e3c0139100991f721e16/hypothesis-6.168.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e313a01ce580180dc3bb8fa98ddd0ffb20e51e108d9fa747ba6c1596790dc3fa", size = 685030, upload-time = "2026-10-05T23:24:09.964Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.170.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/02/d55560240dcb9f3e758213f0079b312271a6e832c5f84e767f7af6130898/hypothesis-6.170.12.tar.gz", hash = "sha256:92b353d21a010b50b638c730ce67cb631ca78a369b146bcc28b3488f83136e35", size = 521506, upload-time = "2026-10-26T17:12:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/08/e460fc3c5fcbd8e7ceecd45a9503fdcc8780c3a74b12354bb239f023068e/hypothesis-6.170.12-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8d601516e32bd822c5558b553d186e35cd1451d0a241403a2357aee0d8085278", size = 800088, upload-time = "2026-10-26T17:10:30.787Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c4/60086043aa1688eeaff8236ca131bad1c6bd1b651806204efdf2f910d737/hypothesis-6.170.12-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b9129107c3d13cb778cb4ee438358677c9e20e916a824073fc601649a28f78fb", size = 795251, upload-time = "2026-10-26T17:10:32.15Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/4211426f3d9b3a4315441792ef784109db87107a7670fc40954ec229a3d6/hypothesis-6.170.12-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13a922a8498457d293f20b3682bca09eb27495d8b83d1620301a1331a90b97e1", size = 1124178, upload-time = "2026-10-26T17:10:27.046Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a5/1ee9e9eac46a7bc7f6b0bcd516e64be4bd8ff2c9b59b7a7bb5dd666e6217/hypothesis-6.170.12-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcb5a1fe6c0d0a1167ea6683e1adb4794b3ce3d9425f6f2dd8ee1341e6851398", size = 1154653, upload-time = "2026-10-26T17:11:00.946Z" }, + { url = "https://files.pythonhosted.org/packages/32/1a/fd539478f430cd096290bfa5f7ed714a7034bea82761fc3cb45448847466/hypothesis-6.170.12-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a052212ada966f9d770b402a6f07c2f552e503242d8c186ba10f1de81cc79c6", size = 1148554, upload-time = "2026-10-26T17:10:24.438Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c4/a5c0a87ba22d96cfa1a4f716f3485d2cb247a96fbfa6ebd042f2b87df771/hypothesis-6.170.12-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f59d0530d25b96fbdcabf6d2eb10d740e001dd01ff37729b0bbcaedf67ea298", size = 1201308, upload-time = "2026-10-26T17:10:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/12/81/4012aa8f70ce549ac1503078e196175527584e0c86197f625a7c5a7eb7a9/hypothesis-6.170.12-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8f58c29512520a29c4fe90545f7f299cf7b42f018b9218e1c1ee1a3045df5bd", size = 1167564, upload-time = "2026-10-26T17:10:22.516Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e2/72fe8caf0c5833d2546078cbef2e3f45d9f73e8ce5f38a9ef8fb424ba5da/hypothesis-6.170.12-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:abde1babc40ecfdbd5ec7837fd47ff0799df27190ff2f097d88c4fa802741068", size = 1123683, upload-time = "2026-10-26T17:10:25.77Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/222034c030457a28caf4ca52264088ff8b547f5c2bbaca2c26da1dafdd72/hypothesis-6.170.12-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d63321e5ec4b3c1b34298e2b44fe79bc607d230d4799a72d4a083856843e6b8c", size = 1161998, upload-time = "2026-10-26T17:11:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/3eb747c2334c2f89cb5c7385d490c25602389e7ab19e2bc2b57940c75d11/hypothesis-6.170.12-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:98af0af451ed0c7ec21640490a5454ff57ac67ad5381db489effb2305c3c3165", size = 1299097, upload-time = "2026-10-26T17:11:20.238Z" }, + { url = "https://files.pythonhosted.org/packages/83/2c/c7104b8b3d0451be15a4fd4f04043fb0a73092a5f042b3fbe73e25a8bc14/hypothesis-6.170.12-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0992ada76db76aace71f4514e66730f6c836b507c57f821e36b474f2a10fe7ab", size = 1427748, upload-time = "2026-10-26T17:11:25.643Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/aa3538690ec226b66e8e4a22ce714aa8cce67da6a9496209654d22a3db18/hypothesis-6.170.12-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:64a3b3faa02238e08d5dfe66ef8891571a511d4a92e5f45dbd41c6c277975689", size = 1381824, upload-time = "2026-10-26T17:10:46.797Z" }, + { url = "https://files.pythonhosted.org/packages/61/2d/ac7320c4f0b34fdd065e179f932aae6b0420b1811fb0df95e28511fdd5a0/hypothesis-6.170.12-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:0baceceb4a32310186c1cefacb28874f6298658f9486f7e90ca5f366fb9d3627", size = 1277780, upload-time = "2026-10-26T17:11:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/c1aa4b06db40e8635d2444f43997a7c1d0e0f2dfc31264d590b1fb76d994/hypothesis-6.170.12-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:e6ddbab42945eb706ad0c3c7ae05d8df1f1b63ca62e212959169ab8baf29bc9b", size = 1293539, upload-time = "2026-10-26T17:11:54.406Z" }, + { url = "https://files.pythonhosted.org/packages/15/3e/68e83242d21561aff4a0f1dbc7edc1341ec397163f2a7d5481e26f6465a6/hypothesis-6.170.12-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f240b0f4a5c8d0b0399b854e76351d266d73692cc55c227f23ac2680b82891c2", size = 1333281, upload-time = "2026-10-26T17:12:01.444Z" }, + { url = "https://files.pythonhosted.org/packages/89/ac/0a024b48507e6db4378fc51b8f5ed9a5a0d2fe874a2de91cb44328e19802/hypothesis-6.170.12-cp311-abi3-win32.whl", hash = "sha256:cf96091375fa2e612cece3926b1cfc72763b6bd00c9a2052ee839d4cd7d3ef85", size = 687286, upload-time = "2026-10-26T17:11:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/0d/cdf07a645b2fe1224e70a1080670fc793b296333af3f0caf73409729f294/hypothesis-6.170.12-cp311-abi3-win_amd64.whl", hash = "sha256:41e47d6c82e69138de731e39467d244a42e752bfa3d9462159e4c00d414fe7df", size = 693348, upload-time = "2026-10-26T17:11:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/22/07/d5ff2b49491c287dca5c8c75537b4cdc901fe550970e9771996a7b9d9048/hypothesis-6.170.12-cp311-abi3-win_arm64.whl", hash = "sha256:c564cc9e1e1a5ca833e1f6620371e926f61817ed7d466cde9e190a029ae3eeaf", size = 691653, upload-time = "2026-10-26T17:10:50.59Z" }, + { url = "https://files.pythonhosted.org/packages/2c/de/b1509a931d9dc0f3cf5f2f2d4f25cc4856b8c0017a613170f68125b16fcc/hypothesis-6.170.12-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:92e4df5c3cb67779fdb3be9b3b1661fba9b99798bd8e7b9dd8f7cf6929dff43a", size = 800804, upload-time = "2026-10-26T17:10:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/06/d0/426931e695cf68f18d03959f71d5d3a5048a2d6dba6e7a937911d83474b5/hypothesis-6.170.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d3025b55670373756a30a27fcafeb73d86118009367af18bd573410e6666acb", size = 796479, upload-time = "2026-10-26T17:10:51.807Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5d/aee62331f499b13792a0f44c70994cfda8f8070c1abb5d82c33888d7eba3/hypothesis-6.170.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d51ecc561464fca7633c671cafca7a11f09f56aeb8c6a95c685a14c6558624a", size = 1125102, upload-time = "2026-10-26T17:11:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a3/5577afe54770f65b7fca743f87fd8e5063f7d7b69feb9fd34f8325aae2d7/hypothesis-6.170.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0e470fcc238db3b193b3f04483985b331117ed04a9abce19199032e870f3164", size = 1167862, upload-time = "2026-10-26T17:10:58.234Z" }, + { url = "https://files.pythonhosted.org/packages/65/a3/ae5490a6e15781dcd229074212dcd20fddaba9552039d9d3ba347f1be680/hypothesis-6.170.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27136250ae1356c7de139e7dfb1a1949872fc6c4b73fd58ac70525ef66faf4d4", size = 1299836, upload-time = "2026-10-26T17:11:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/5f/70/5c774c8399fe13050072c777e32d194e43f2ba58dac3746a6973b16b36f5/hypothesis-6.170.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:13c5656eaa6f2aedc597583bc4db7bf3f7c8dc2d293b41be9bd5c0cb979848d9", size = 1333844, upload-time = "2026-10-26T17:10:44.074Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/22f4298ac904c350152c543558f3af187a1bd04fe4ecbe07b953ddaeeccb/hypothesis-6.170.12-cp311-cp311-win_amd64.whl", hash = "sha256:447fe134533d93b045797d5e44cb5ddf3460deaa1494d88b524266c21c7bf0c9", size = 693464, upload-time = "2026-10-26T17:11:51.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/f6/424ecd49ba7339329e937ea3f88860ca7a455797a7184886ccaf73b7269b/hypothesis-6.170.12-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0d010e40387d8c3a5e2c8fde71459ce6f2bb5e8bba8914731780529b79a0b5b0", size = 801881, upload-time = "2026-10-26T17:11:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e2/05/e38e2de5486b03670360d47e1deb419917754fc9daf2aaee3ad185083d5a/hypothesis-6.170.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:94b05dd80631d60f672c7d87d4280be9efa31b3116d0d88e71b7d26951fd3f94", size = 793880, upload-time = "2026-10-26T17:12:03.87Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/4d693faeabc67d9d000db253e092ef72dd5725aaca3fd897a0f6a484acf9/hypothesis-6.170.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e674b6a7a7f2dee9bdb1da9b42c78edd971fea6758640997a9d8bb32cfddeb9", size = 1123538, upload-time = "2026-10-26T17:11:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4f1eb1bb67283240aac366b77e2ebbf864e1fd4d93c3762399f499d200db/hypothesis-6.170.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51ccd8964291b6cba57709b6acf1cad2e9c4347e993198ef8ece5653cdafcbdc", size = 1167229, upload-time = "2026-10-26T17:11:50.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/19/4e40637c37285231e4cbd6291fbce5cf9da325b6c73af53d9004f7030155/hypothesis-6.170.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19fd1565947111aa1bba31689585f5501eca4f660762069d7515e5e1d3a45b3b", size = 1298417, upload-time = "2026-10-26T17:10:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/57/96/97aa8d7115a399e409c24b3f7642477de3521da797e078ccb94d64a128b4/hypothesis-6.170.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5007cb77462b0e1ae444e05684fe799906673942ab18255ad6144ba2f9b74bfb", size = 1332856, upload-time = "2026-10-26T17:10:40.414Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/11c464d8aed7777b99eec35d31c474ea64caaa68e4da342609aa1bad2868/hypothesis-6.170.12-cp312-cp312-win_amd64.whl", hash = "sha256:51c7553a5d5a368347dc2f063ac1bc6ad5b3385b883ca9e7009bbeb52e79eaa6", size = 690967, upload-time = "2026-10-26T17:12:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/11/f0/283fcf6c9ead403928a24cd3d32098bfcaf154d8556411cb2822120cc001/hypothesis-6.170.12-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:492eb0d70d8bda18322a1dee3e503796997bcab7415afea01b111551f7591e9c", size = 801848, upload-time = "2026-10-26T17:11:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/79/c5/9df47092d6e7fa638f40ad268eb87476eec95897db98f3c6d11cc9680a65/hypothesis-6.170.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f67a87338893d5c050372ab3a1e818e6c5bd267f302624b7c444c18657b7da39", size = 793875, upload-time = "2026-10-26T17:10:48.069Z" }, + { url = "https://files.pythonhosted.org/packages/4a/76/79b52b312af5f2d9758293324078eba186a3f082ecba2879d606087e998f/hypothesis-6.170.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cae68b4bcae8c748ac16c91a39b2e13643400aa1b1cccc1682ec7ffdc9c82a6f", size = 1123439, upload-time = "2026-10-26T17:11:40.937Z" }, + { url = "https://files.pythonhosted.org/packages/05/b1/1e41fc27605bf9d7c43a8e873745e2eeafea80985cf517b1fef1e624fd07/hypothesis-6.170.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00275564eab789af6dba53947194eeff0937ef9cd82866cd49b1160f604b6b58", size = 1167165, upload-time = "2026-10-26T17:11:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/05/ee/99cf7e7b034ab806041c9f3ad506ee7cbb83381284684d2406da42812918/hypothesis-6.170.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea6334c3d67021c0778d9db519249d58a6d1e07baebb42144d7f0b88d4db8cf8", size = 1298395, upload-time = "2026-10-26T17:10:45.339Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/94d3ec0b039f6f42265fa64b97145065e1e286dcab7e68c56581ac9852ac/hypothesis-6.170.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b8a29ec47aca3e3e7ad47ffca197368afbeed156f4c03db482213ff084b10a50", size = 1332887, upload-time = "2026-10-26T17:11:13.819Z" }, + { url = "https://files.pythonhosted.org/packages/06/26/e08bc6f179a542e69c36bd02d78a01edf2a2ee561e6f12b00ca038d29c0d/hypothesis-6.170.12-cp313-cp313-win_amd64.whl", hash = "sha256:b3c4fbf33cf0c96381a9f301c7f423d21164222411f56c2bb050a7e44fb6dc51", size = 690938, upload-time = "2026-10-26T17:11:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/de/8a/bebaec693c114e312382f12e6d79efadb4a378044ceba67e0ac3ff962558/hypothesis-6.170.12-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:90a63aebc766fc574276519d2840b7c144768be2f15f409458567f652f206559", size = 802107, upload-time = "2026-10-26T17:11:24.406Z" }, + { url = "https://files.pythonhosted.org/packages/00/14/e988ad36da4df17b8ec9131f29f77fa2a1a385be06b0d7cd2a7c20a39d16/hypothesis-6.170.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1982c1660cc215679f34580690cd440c878208544e4eaf697c2f04698767aab", size = 793991, upload-time = "2026-10-26T17:11:45.133Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/89593bb4c43d09a899878ba20ed00fb0e27896146e0b511d2449a045246a/hypothesis-6.170.12-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5648bad764aab81f38dc8fb9b80942e5e325e19c7d7497312e9f7ef63540d48f", size = 1123961, upload-time = "2026-10-26T17:12:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ed/4a6cec0a929079b3c2103dbc71d5846c74264453cca30b2d0da61ca83e6a/hypothesis-6.170.12-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cb0a1042041ad3b03159c658214eb19d19ec9d0efcf03a84f4ed94de54f1630", size = 1167355, upload-time = "2026-10-26T17:11:27.146Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/e991b1e6cc88a0d2e648864819360c48d03769c31ceec2b2095970a63f7c/hypothesis-6.170.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:45eefe8622916a10bd5ac1639adde0552985896624eadf9d9fa807b5eaa60aaf", size = 1298917, upload-time = "2026-10-26T17:11:08.811Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b4/a8c292a80eabfbf5201c0360a677176880f993e91c890db7582e579efeb1/hypothesis-6.170.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:503a07eb31b806c29d4731c5e9c842df74dfb184f183f82f682c19f658761a7a", size = 1333081, upload-time = "2026-10-26T17:10:56.94Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/434322320807b9585f3eb7e414e0bd92126548de0dbeb0dee0dd5f38b513/hypothesis-6.170.12-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a8ac6f5e5a943fcac211e09e858aaa90033e10e44295977a32fdc3cd4f180245", size = 633703, upload-time = "2026-10-26T17:12:02.785Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/4a2483c029adf6bdb78b237c5cbedf411df97e31fbbb81c8bf4c3ffe6769/hypothesis-6.170.12-cp314-cp314-win_amd64.whl", hash = "sha256:f5d839f0ba43101b479f1559c3b3c2870fa0eb92ad86bca82f247fd3a9d98866", size = 690674, upload-time = "2026-10-26T17:10:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/f7357b87c6e0b6f9d044f8247d8532cf79c68c3692f64800e3833261b9c7/hypothesis-6.170.12-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ee71dadea93346799b6f6825b62c75f67ddaae36ecfd96591fd1fff17f687f7b", size = 800195, upload-time = "2026-10-26T17:11:48.676Z" }, + { url = "https://files.pythonhosted.org/packages/a4/46/1c054440c5f2d562ae973db1d4ac6ece2a2aadf442b3887c0bba4716a57b/hypothesis-6.170.12-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:681dfd2e47d2a473a36b3075bd15126b476a40a4cd5a2e134c71f3fafb651628", size = 792428, upload-time = "2026-10-26T17:10:59.572Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/6ea8efa8fcef75f44a250bd121dfd49c6cbd4f12407d996d472ae6754bf0/hypothesis-6.170.12-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87d50be85d98a05f177941fb21c2619279a0c6a289b139fe510126580c9ffa91", size = 1122389, upload-time = "2026-10-26T17:11:02.296Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/62be30dbd660e288f028da31fc8a8809a1c2230ec589a96a04f6c2303a05/hypothesis-6.170.12-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb59d1a301e5ceb4b402026880c74ba1b65ba1c249dc169324681e6a74fb0d59", size = 1166268, upload-time = "2026-10-26T17:11:28.494Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/1aa266dbf967081bfcbea3ffad90f77f879e1294585bdc94b38ac56b7cbc/hypothesis-6.170.12-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4d015bfcd7d317ab8752b05c4469278cda807be8aab4f2dbdbf6662df0fb6570", size = 1297161, upload-time = "2026-10-26T17:11:12.544Z" }, + { url = "https://files.pythonhosted.org/packages/a8/35/4626673a9a6ba0781e36a064ec03083c397702737db06dfb1f48af42a9a8/hypothesis-6.170.12-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3a3471e28d40532099751e1be61526f8a36329c4f3eb67a132526c795ff717f9", size = 1332087, upload-time = "2026-10-26T17:11:35.055Z" }, + { url = "https://files.pythonhosted.org/packages/6f/71/eb1725a0159c0759ba33e08964c3ce61335e304811e2dd4f0935cfd3dedf/hypothesis-6.170.12-cp314-cp314t-win_amd64.whl", hash = "sha256:322548a87c3bb790854f8688ad2e8a652f696305ee2a182c85a3e0bf4a7b0498", size = 690804, upload-time = "2026-10-26T17:11:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/64/cc/d9f44015a7b402f965ca80dcd4d3d5c179535235617baf1e306d5cea1b47/hypothesis-6.170.12-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:9480e495c9bce6019971b5d70dbb737e3ba56d90c32d22930556c4c56bd6c528", size = 799824, upload-time = "2026-10-26T17:12:06.032Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3e/5deca2e25b89e0ea4c8ede15a6e3ddf1c938671270c14add4a2491966fec/hypothesis-6.170.12-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:393f82b7f76141dac5082edba58287a7ee6cc255f986b7dd9dd5fc4c2bb12afa", size = 792273, upload-time = "2026-10-26T17:11:04.799Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/9ec46e023726a54961af06521ebe6caeccfa9cd155e62feaf8cdc180286e/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d84e49b1db4f3fce90e5e8fba7a4e81961ebe522c5db8b60e3fe5fcd58512813", size = 1121164, upload-time = "2026-10-26T17:10:35.868Z" }, + { url = "https://files.pythonhosted.org/packages/8f/52/f42a0ae67c2d05e39f6170f7487f00650cab495ab604792cad7184ab5ff3/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b9ac42c8e163c1da3938364f8338391f71e7c721b79ae5cb9b310c0bdb646f1", size = 1152061, upload-time = "2026-10-26T17:11:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/4699066529d2c70ffa2e4d9a8371363922a244fc6fcc29c0740075e62971/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d1a12d62292568f4b37afd7ca0e05fc80e11e896e989d5f6ce7dad44efaff6d", size = 1145781, upload-time = "2026-10-26T17:11:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/1588dce65e5343364c0a5e4b0d4924aa415c446e65a6cb0a4e900bc081eb/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f14d172dab9bb05394d5409699c3817f3f759ca0313ee2a8933b7c190169c2df", size = 1199556, upload-time = "2026-10-26T17:11:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d5/11d5810838ffa5ee40c4c1fdb4e00de9fa8ca7037f9e8d52ea5de739d2f4/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb2a0cd5e3808077143290344646e6ed9e46fdf0c3777910f90763884de719e8", size = 1165475, upload-time = "2026-10-26T17:10:28.172Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d6/751bb29605db0457bbdd2d497dbeb92874e348731ce4b7677ee8def67bc9/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:9db4f9d90a41e15938bbefd0b32dc93c589510ad1309f164e9436c11c1ad2ad0", size = 1119771, upload-time = "2026-10-26T17:10:29.561Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/c1eed293fb064ac3a128cf0b827199ba0487302fcd2f7dda73d692b4b757/hypothesis-6.170.12-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:10869687863bb79028025e5a4acf5d30d2da8b6d215f46f02c12d91647f14849", size = 1158253, upload-time = "2026-10-26T17:10:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/746b02f3b91bc11b77f7791a2da115afda4264e3ed8538a778abb2684d94/hypothesis-6.170.12-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:44463725ee0812b8226410294c8db69956ec726f22b42d133f5366fefa939cf9", size = 1295881, upload-time = "2026-10-26T17:10:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f0/75/d39b8caeb0f44e8e4e2cbda67dab372a5dded9cfc91daf8a7436633d1982/hypothesis-6.170.12-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:0d469c9cced28f7d7ed411dc41609b91ac19149947f7a68ac8404d576c433e85", size = 1425052, upload-time = "2026-10-26T17:11:39.715Z" }, + { url = "https://files.pythonhosted.org/packages/81/db/393ca7953415fbb9775824c7f8206e0ca004d664dddb2c5cdeea79de7e0c/hypothesis-6.170.12-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:02857858c8985fdd5652b56d4a5795e66416c80cb3dd5ea1e1d0ee496fc58cdd", size = 1378674, upload-time = "2026-10-26T17:11:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/72/0b/7fe8b2f09c615886e454e418e0296d6b2756abd8866a83fdbcd8f912b9b3/hypothesis-6.170.12-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:b5db8c5e15e3e0e3135d41d9b1c3ae361823d65d0b19adf2bb8cb09b21108607", size = 1274395, upload-time = "2026-10-26T17:11:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/3d/21/fa36acad45a3a9b1da13c3e1eb9d11202dd483e665f5ae5722d42702f32a/hypothesis-6.170.12-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:67ef145399463542418c8c47d8f0ec31458ba07c86289bcd008e5081f5e2213d", size = 1291512, upload-time = "2026-10-26T17:11:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2bb225a5196fcfae0c474f5c405e5772011c6601d6ed0eddffa2a3ae6faa/hypothesis-6.170.12-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d77f02bd604b764398acb493203dc2f45b7634cafa3bdd6b18d502ed3a1bfe0", size = 1331449, upload-time = "2026-10-26T17:11:17.753Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/01d3b1834116952263a01e8da4b1634f83ac0b38904c805f8f732fef553b/hypothesis-6.170.12-cp315-abi3.abi3t-win32.whl", hash = "sha256:2484674ee787451b050dd9bf80105786cb34899c1e5a75195f2c8feaad87c47d", size = 684613, upload-time = "2026-10-26T17:11:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/14/54/fecb01f6b5d7adf58b104fe2d8abfebd5d51a7afda702d20cc55c767a3e8/hypothesis-6.170.12-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:020c2be4b16a09787b2d5b532ad1a75e20a4443c8b220e59a981c45d488fa4d8", size = 690578, upload-time = "2026-10-26T17:11:47.512Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/cf60b7e7901412b4d60e26be9373f9b9b9e4fdf5ffdd739aa3e43fdea4d9/hypothesis-6.170.12-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:a39811c29cac6bb35ef775db6a0e276578350e4768ea9a6669f70dfcc3626f6e", size = 688566, upload-time = "2026-10-26T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/73/b2/907785e3c06c1c408b91222ef40fdc226ba7818e8f0fdd4e3dfa566fc3c1/hypothesis-6.170.12-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:145248c96c3af5262438ed356829f0f54c05ab4d1bc3ade476cfd6b6a40ea772", size = 800641, upload-time = "2026-10-26T17:10:37.057Z" }, + { url = "https://files.pythonhosted.org/packages/ab/76/7b8f74f49798aef9dd117a9a7d04048bf37342fbcc55bcf91b70c1cfbd58/hypothesis-6.170.12-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac592beb4129b83d89c619f97d6f0a388963211d8af17cc85e6c37d135cdfc4a", size = 795556, upload-time = "2026-10-26T17:10:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/3ec388a5653fedd63b4673b69f4a31a9031adf2be80a186a130a81755369/hypothesis-6.170.12-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04a5dfbcba89f2b5b42756404ebcd28f2a5b6bb9744c3e2a5a1b74dc9254d46e", size = 1124627, upload-time = "2026-10-26T17:11:33.795Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d6/026e9fa82471cfc790e10a7d38be3cdf6da4a4a0a71a227cbcd41447f281/hypothesis-6.170.12-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35963711f0f1013d65b3cec03b3f57c7dbdfe74043e0efb17ac22652a05b7904", size = 1168007, upload-time = "2026-10-26T17:10:54.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/d716ce5c7d72959175daa90feb24b3e6023611db399a17e202774d4cd147/hypothesis-6.170.12-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:09986fa92076a03b083c5a52c930fea4b2c396a658c3e68b6d16a10f61105c96", size = 693936, upload-time = "2026-10-26T17:10:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/45/fd/712d04bffaf9614ddaf9aef4ef355560dca122fe3408f668a1a929e42a2c/hypothesis-6.170.12-pp312-pypy312_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8414611abd203b0fe3d11fb7877108c775416d454e29d3b0d72b5f8968549237", size = 800956, upload-time = "2026-10-26T17:11:16.549Z" }, + { url = "https://files.pythonhosted.org/packages/93/7c/568a6a7ce8c6a140def54fc5185eb070de946d16518a57f51bc4338f2b9a/hypothesis-6.170.12-pp312-pypy312_pp73-macosx_11_0_arm64.whl", hash = "sha256:8cd063d3620c4097505f4e697fcaded527a9565333ae3e4ef6a9b727f47b67e4", size = 792848, upload-time = "2026-10-26T17:10:55.658Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/4fafe1a50b0df2d032b6142e13366a7d37ed0cab5d6af749c430fb538f0a/hypothesis-6.170.12-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18ea9df1deec60fad073305438f050365a2ab8f5c7a577b5c8f96e5aaec620ad", size = 1122799, upload-time = "2026-10-26T17:11:19.011Z" }, + { url = "https://files.pythonhosted.org/packages/2f/24/b9b30baa2b6c3703162f8a5f49235d74df568ed941680edde70332e4fe42/hypothesis-6.170.12-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99efd12654a380a6d683b962f481969d08beb55bd2426424cd5178d3f9f12b23", size = 1167006, upload-time = "2026-10-26T17:11:29.968Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/dcfda3cbfcbd0501aefe0ee6e46591671a19df3ba18d65fdfd4229c5e3b6/hypothesis-6.170.12-pp312-pypy312_pp73-win_amd64.whl", hash = "sha256:8259bf11eed5379c8bc69c7fd998baa0e122350ab3e5b3432d5ef2e22e11d8e3", size = 692109, upload-time = "2026-10-26T17:11:32.645Z" }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f6/4eda8458eb00ec03e715c376c6b23b3def69687924c68d36d1c4003d3bc4/importlib_metadata-10.1.0.tar.gz", hash = "sha256:82b04047fa09792c1a8cfeddfe08bec79e4b13c29c8469b0d3949860f61017e8", size = 67761, upload-time = "2026-10-24T00:01:44.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b6/495438ad95e0102aff8f6786b5474202f38cd5d4700d03a72b77977dec10/importlib_metadata-10.1.0-py3-none-any.whl", hash = "sha256:cf45ce66a929c64e994185a809c04840d0501db17e663810d993d0f4829265c7", size = 32221, upload-time = "2026-10-24T00:01:43.507Z" }, +] + +[[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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "importlib-metadata", version = "10.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[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/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, + { 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 = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "markdown" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/32/0b4d0297064244f04af4331b65de16237cc3cce07474171ee14222a28981/markdown-3.11.2.tar.gz", hash = "sha256:87bf5d90f2d2882e2c02fcda182c2800456b985bff38913867c49706481514b0", size = 491445, upload-time = "2026-10-19T19:41:42.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/66/bcab3b677b71ad286b4e607e3427499537c76e13ad2e22584ba7a7d9882c/markdown-3.11.2-py3-none-any.whl", hash = "sha256:f80390747a2a0ff2ba54da074ce4f862f72a6685f608cd2637b646a6d83993f5", size = 114266, upload-time = "2026-10-19T19:41:41.244Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/aa/9a65962e364bf19745f6bec7bde398fb1f5ca53ad2e734258e6198cc32ba/markupsafe-3.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889", size = 11521, upload-time = "2026-10-02T23:04:20.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/36/927999a34b7d1def6957de89153d327fedc4030061940b5a468584e6c5a8/markupsafe-3.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2", size = 12066, upload-time = "2026-10-02T23:04:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/69e7b0db9bd687bfd83451eed5666384d67cfed3be062bfc59a06a15474e/markupsafe-3.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a", size = 22081, upload-time = "2026-10-02T23:04:22.221Z" }, + { url = "https://files.pythonhosted.org/packages/bd/14/f6f5c97903f7d2db76bbfaced31509a47d360a103b3aaf4849f6536591ec/markupsafe-3.0.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc", size = 26079, upload-time = "2026-10-02T23:04:23.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/04/c3cc9b75f94f8b54d7e503c44cebd4b4a115ec1d6f1b996b999807daba99/markupsafe-3.0.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8", size = 22611, upload-time = "2026-10-02T23:04:24.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/26/c4708ed3b0f08e8e6d7cbce3314ac130cb5352d1f62951b6fef7878f2b1e/markupsafe-3.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9", size = 20712, upload-time = "2026-10-02T23:04:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/f3894d6aae3d7a52c9363317f4baf2fcadc052163d6dbc871266e32639ed/markupsafe-3.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a", size = 20742, upload-time = "2026-10-02T23:04:26.403Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/8e248ddbfe286ab6bddb462bdac0851cbb1510d2cbbb815a415fcb0511af/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36", size = 21560, upload-time = "2026-10-02T23:04:27.522Z" }, + { url = "https://files.pythonhosted.org/packages/df/26/2353fef7d4bcf2b18e16bad81979fcecff2915156ca6882447917205b8e2/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be", size = 20973, upload-time = "2026-10-02T23:04:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/67/6f/a9561d98d9a6ee3494b0b970a1c766e58bab128cc84841d56ec009456dbd/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa", size = 22036, upload-time = "2026-10-02T23:04:29.568Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/6217df9192eca95af3ff0cad955c9854a93fa43b288cf7411ae24713eb52/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9", size = 20314, upload-time = "2026-10-02T23:04:30.881Z" }, + { url = "https://files.pythonhosted.org/packages/35/7c/9cd8081dae70e17fdab3558121fa618d459c7950c8abd8fc10fc024486d7/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a", size = 20780, upload-time = "2026-10-02T23:04:31.975Z" }, + { url = "https://files.pythonhosted.org/packages/44/37/c5f2f45f4d8f0c24e4af7b9dc711640c888044cbf5f7e2ba2c0bb74cbb55/markupsafe-3.0.4-cp310-cp310-win32.whl", hash = "sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278", size = 14096, upload-time = "2026-10-02T23:04:33.129Z" }, + { url = "https://files.pythonhosted.org/packages/50/50/394a1c61c9b9972cb4f76875c332af7109bd39bdad09d3a0ec5327a339dc/markupsafe-3.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7", size = 14329, upload-time = "2026-10-02T23:04:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9a/86d03d2f32ba41a57baa124a7bcb76d0a6d39b1622e9bd4d550074e8b61a/markupsafe-3.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf", size = 14196, upload-time = "2026-10-02T23:04:35.201Z" }, + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" } }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "markdown", version = "3.11.2", source = { registry = "https://pypi.org/simple" } }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/e0/1e8ed4e8dabdcba8cba5dbf4b8ce49bb3b51e750177155247abb5345d48a/mkdocs_autorefs-1.4.5.tar.gz", hash = "sha256:856f75b327b7717923ce06267094650689a510a748c48657f5333965950c2788", size = 59736, upload-time = "2026-10-22T15:03:55.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/cf/3dfc07ec910553bfb1e5c9009c1854bd9d2efa7f3dda03f007a77db0e63a/mkdocs_autorefs-1.4.5-py3-none-any.whl", hash = "sha256:68ee484e0800f3ba50cda1bc4a94293adbdfcc010243c322b3f5d66e15d06770", size = 29633, upload-time = "2026-10-22T15:03:54.091Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "platformdirs", version = "4.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" } }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown", version = "3.11.2", source = { registry = "https://pypi.org/simple" } }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/02/3c0df248819286810d8479b3a72d9531db0cc36c042fe1ab3985f137c664/mkdocs_material-9.8.0.tar.gz", hash = "sha256:1ed4c5edaf405a3fb62e55288b8fdff592971729361548b1ffa757bf9849fb81", size = 4094495, upload-time = "2026-10-24T09:50:16.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/16/360f44c7702f27ff4670a647a16c72432291e311371d72b4c34a57e990e0/mkdocs_material-9.8.0-py3-none-any.whl", hash = "sha256:19304a7263c5700428227122b3b2699aa73f432c9de8816876adaec7d7293ce3", size = 9302245, upload-time = "2026-10-24T09:50:12.636Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" } }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs", version = "1.4.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python", version = "2.0.9", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "jinja2" }, + { name = "markdown", version = "3.11.2", source = { registry = "https://pypi.org/simple" } }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs", version = "1.4.5", source = { registry = "https://pypi.org/simple" } }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/cc/e3ed8b16851f5d664dffcf8f55fc60d0d3f736a3edca21d1508105d1255a/mkdocstrings-1.0.8.tar.gz", hash = "sha256:b50fc326230906425c82ee9ec59b80e5db00892b2b593d4d0b841a0d4acf537b", size = 107444, upload-time = "2026-10-19T15:43:02.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/5e/4df7a2290e97c60a3ba410084966e5ab84aa89d15c8c98c195acbaefa8f4/mkdocstrings-1.0.8-py3-none-any.whl", hash = "sha256:4b03ed2b863009f1153f0d04aacb22a85b92afc28a0636d85717c92eecca638c", size = 42209, upload-time = "2026-10-19T15:43:00.65Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python", version = "2.0.10", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "griffelib", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mkdocs-autorefs", version = "1.4.4", source = { registry = "https://pypi.org/simple" } }, + { name = "mkdocstrings", version = "1.0.6", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "griffelib", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "mkdocs-autorefs", version = "1.4.5", source = { registry = "https://pypi.org/simple" } }, + { name = "mkdocstrings", version = "1.0.8", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/43/4783d5606de4f5e4bea10388c8741b0c40d03e7cb9527b94ad35966351f5/mkdocstrings_python-2.0.10.tar.gz", hash = "sha256:5e34ae56b2f20a57807ee479d603ddc5ec7cc6d2252c4c177754627ef2d3b833", size = 207682, upload-time = "2026-10-19T15:58:07.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/1b/fc924a97a237adda47f15c1b0cc271639e6b181ab3f95500fbf060c79a69/mkdocstrings_python-2.0.10-py3-none-any.whl", hash = "sha256:4a3aaeebb25ba5ae57a8f9e75432fe4571984889602f2b2f7d672faa760e9062", size = 105538, upload-time = "2026-10-19T15:58:06.287Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz", hash = "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63", size = 154133, upload-time = "2026-10-14T18:50:30.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", size = 74405, upload-time = "2026-10-14T18:50:29.116Z" }, +] + +[[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 = "tomli", marker = "python_full_version < '3.11'" }, + { 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/5c/0a/78c9ac93933e2ecd51d807021f78c03ffe98640f33951a8be159f5fd55bc/mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2", size = 14408618, upload-time = "2026-10-01T20:39:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4f/5f5f4429fd5a4218ce6268164a66afeed75c7e2ce5522a4d163faaa5fe85/mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23", size = 14552749, upload-time = "2026-10-01T20:40:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/fa1c319cd51db7a6027b46117460d5a361c6bba0c0f0f0670a2dab8ce2a5/mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080", size = 15505846, upload-time = "2026-10-01T20:39:15.475Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/14c02690776fb04e9fa60b81173cb9ae6315e0745fad3813b303737f2f0c/mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f", size = 15823059, upload-time = "2026-10-01T20:39:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/76/d8/d29afa819f8cb765018d6e2773e390a41ea4592f75abe417135969646857/mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb", size = 11312927, upload-time = "2026-10-01T20:39:28.509Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c8/966dec4d80303855d824e6bf67fe1da812d88adfe4d75e24d8545dab4826/mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220", size = 10879252, upload-time = "2026-10-01T20:38:41.545Z" }, + { 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 = "nh3" +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" }, + { url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" }, + { url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, +] + +[[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 = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[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.12.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +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 = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, +] + +[[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 = "pymdown-extensions" +version = "12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/fe/ac50ee24a80a1b344e643f6fb626063cc7041b978cd1dd9a7f9963ae1cd1/pymdown_extensions-12.3.tar.gz", hash = "sha256:a7f2a4e0bc60c6e6babfa7922cfb3b01cd640eb00fb208f3c582e12bae00ea67", size = 870532, upload-time = "2026-10-14T22:40:43.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/d0/758afe732c2da502c051b4d03ff88daa3c0fc1a1e671e6c5a6318177bab4/pymdown_extensions-12.3-py3-none-any.whl", hash = "sha256:345493a8e3497e24382902903a1c43a28e34a52f2b7571a1c9641429b820ca74", size = 277597, upload-time = "2026-10-14T22:40:41.543Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz", hash = "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43", size = 21013, upload-time = "2026-09-16T08:58:03.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl", hash = "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b", size = 10724, upload-time = "2026-09-16T08:58:02.96Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.16.2", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.11'" }, + { name = "coverage", version = "7.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { 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-dateutil" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz", hash = "sha256:7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2", size = 271066, upload-time = "2026-10-21T23:41:45.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, +] + +[[package]] +name = "python-wrap" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "hypothesis", version = "6.168.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "hypothesis", version = "6.170.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs-material", version = "9.7.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "mkdocs-material", version = "9.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mkdocstrings", version = "1.0.6", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version < '3.11'" }, + { name = "mkdocstrings", version = "1.0.8", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version >= '3.11'" }, +] +release = [ + { name = "build" }, + { name = "twine" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.100" }, + { name = "mypy", specifier = ">=1.13" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-cov", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.8" }, +] +docs = [ + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27" }, +] +release = [ + { name = "build", specifier = ">=1.2" }, + { name = "twine", specifier = ">=6.0" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "readme-renderer" +version = "46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz", hash = "sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90", size = 38382, upload-time = "2026-08-28T15:18:32.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl", hash = "sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560", size = 14239, upload-time = "2026-08-28T15:18:31.132Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "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 = "twine" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, +] + +[[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 = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/05/ba1458dee0ac485b2a01071c1b88f5199fe878a485df482e10e1ef2205cc/zipp-4.1.2.tar.gz", hash = "sha256:cf474b90b07ea932637e17c6b9479de2b2882900b0d311bdaf4f5c000a15a97f", size = 27864, upload-time = "2026-10-21T18:57:04.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/44/7bd2007260da11cccca29ba2d4c345f1287aa663db38ee7cefeabf6386a5/zipp-4.1.2-py3-none-any.whl", hash = "sha256:b30cb6cd253122169c47de245ca24a86bac2a5d3802ba15bf7ba6414eb9e144d", size = 10581, upload-time = "2026-10-21T18:57:03.79Z" }, +]