diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..12d1589 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build_and_publish: + uses: tehw0lf/workflows/.github/workflows/build-test-publish.yml@main + permissions: + id-token: write + attestations: write + actions: write + contents: write + packages: write + security-events: write + pull-requests: write + with: + tool: uv + install: sync + lint: run ruff check && uv run ruff format --check && uv run mypy + # Fetches the conformance suite pinned in conformance.lock, then runs + # every test on the oldest and the newest supported Python: the + # orchestrator installs one version, so 3.11 would otherwise go + # untested and a newer-only construct would ship unnoticed. + test: run ./scripts/fetch-conformance.sh && uv run --python 3.11 pytest && uv run --python 3.14 pytest + build_branch: build + build_main: build + artifact_path: dist + # No PyPI yet: publishing comes with the API provider, once Trusted + # Publishing is configured on pypi.org. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c95894f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# CLAUDE.md + +Python port of YaFT (PyPI `yaft`, import `yaft`). The normative rules are +`yaft-conformance/SPEC.md` (fetched to `tests/suite/SPEC.md`); the TypeScript, +Java and Go ports are references for behaviour, not for API shape. + +## Layout + +- `src/yaft/model.py` — `Feature` (frozen dataclass, snake_case fields) +- `src/yaft/evaluate.py` — `Clock`, `evaluate`, `parse_timestamp` (R3–R13, + R27, R28). `datetime` does the calendar checks; only the offset minutes are + checked by hand. The pattern needs `re.ASCII` and `fullmatch`. +- `src/yaft/mapping.py` — response normalisation (R22–R25, R29, R30). A key or + value that is not a string is not set: no coercion, JSON booleans belong in + the boolean shape. +- `src/yaft/providers.py` — `FeatureProvider` protocol, local providers; + `LocalFeatureProvider.load` is the all-or-nothing refresh (R30) +- `src/yaft/toggle.py` — `feature_toggle`, `set_provider`; functions per call, + classes once, empty shell for an off class without fallback (R14–R19) +- `tests/conformance/` — the suite adapter; unknown case values and unknown + case-file format versions must fail, never be skipped +- `tests/test_*.py` — what the suite cannot see (generators, static/class + methods, the shell, Python's parsing traps) + +## Constraints + +- No runtime dependencies. +- `requires-python = ">=3.11"`; CI runs the tests on 3.11 and 3.14. +- Bump the patch version in `pyproject.toml` on every PR and run `uv lock`. +- Mutation-check new rules: `PYTHONDONTWRITEBYTECODE=1`, or a same-length edit + within the same second keeps running the stale `.pyc`. + +## Pre-commit validation + +```bash +uv sync && uv run ruff check && uv run ruff format --check && uv run mypy \ + && ./scripts/fetch-conformance.sh && uv run --python 3.11 pytest \ + && uv run --python 3.14 pytest && uv build +``` diff --git a/README.md b/README.md index d670472..2c6a9a4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,160 @@ -

YaFT

+# YaFT for Python -# yaft-python +
+ YaFT Logo +
-YaFT (Yet another Feature Toggle) for Python: decorators for functions, -methods and classes, evaluated by the same rules as every other YaFT port and -checked against the shared -[conformance suite](https://github.com/tehw0lf/yaft-conformance). +--- -Work in progress. +Feature toggles for functions, methods and classes in Python, following the +same rules as [`@tehw0lf/yaft`](https://github.com/tehw0lf/yaft-ts), +[`de.tehwolf:yaft`](https://github.com/tehw0lf/yaft-java) and +[`yaft-go`](https://github.com/tehw0lf/yaft-go). It passes every case of +[yaft-conformance](https://github.com/tehw0lf/yaft-conformance), is fully +typed, and has no runtime dependencies. Python 3.11 or later. + +--- + +## Installation + +Not on PyPI yet; that comes with the API provider. Until then: + +```bash +pip install git+https://github.com/tehw0lf/yaft-python +``` + +```python +from yaft import feature_toggle, set_provider +``` + +## Providers + +Set one provider at startup: + +```python +from yaft import Feature, LocalFeatureProvider, set_provider + +set_provider(LocalFeatureProvider({"newCheckout": Feature(key="newCheckout", value="true")})) +``` + +| Provider | Data | Time bounds | +|---|---|---| +| `LocalFeatureProvider` | full `Feature` records | yes: `active_at`, `disabled_at` | +| `LocalBooleanProvider` | `{"myToggle": True}` | none, by design | + +Both read a JSON file with `from_file(path)`. `LocalFeatureProvider` takes a +YaFT backend response (`{"toggles": [...]}`, in either field spelling) or +features keyed by name, the format yaft-ts reads: + +```json +{ + "newCheckout": { "value": "true", "activeAt": "2026-10-01T00:00:00Z", "disabledAt": "" } +} +``` + +A missing or unreadable file raises instead of starting with everything off. +`load(response)` replaces the data with a newer backend response, all or +nothing: a body that is not a toggle group raises `ValueError` and the old +data stays. + +Any object with an `is_enabled(key: str) -> bool` method is a provider, for +example one that reads environment variables. + +### Evaluation rules + +A feature is on when all of these hold: + +- `value` is exactly `"true"`. `"True"`, `"1"` and `""` are off. +- `active_at` has been reached, if set. At exactly `active_at` it is on. +- `disabled_at` has not been reached, if set. At exactly `disabled_at` it is off. + +A missing feature is off. Only RFC 3339 with an offset is a valid bound +(`2026-09-18T15:00:00Z`, `2026-09-18T17:00:00+02:00`); an unset or invalid one +is ignored with a warning on the `yaft` logger, never raised. The clock is a +parameter, so tests can pin the time: + +```python +from datetime import UTC, datetime + +provider = LocalFeatureProvider(data, clock=lambda: datetime(2026, 9, 18, 12, tzinfo=UTC)) +``` + +The boolean shape has no time logic, and only the boolean `True` is on. A +value that is not a boolean, such as the string `"true"`, is dropped when the +data is loaded. + +## Toggling code + +### Functions and methods: evaluated on every call + +```python +class Checkout: + def classic_total(self, cart: list[int]) -> int: + return sum(cart) + + @feature_toggle("newPricing", fallback=classic_total) + def total(self, cart: list[int]) -> int: + return sum(cart) - self.discount(cart) +``` + +Off, the fallback runs with the same arguments and the same `self`. Without a +fallback, an off call returns *nothing*: + +| Decorated | Off, without a fallback | +|---|---| +| function or method | `None` | +| `async def` | a coroutine that resolves to `None`, so `await` still works | +| generator | an empty iterator, so a `for` loop still works | +| async generator | an empty async iterator | + +A synchronous fallback on an `async def` is fine; its result is returned from +the coroutine. `staticmethod` and `classmethod` can be decorated too, with +`@feature_toggle` on the outside. + +### Classes: decided once + +```python +@feature_toggle("newCheckout", fallback=ClassicCheckout) +class NewCheckout: ... +``` + +The toggle is read **once**, when the class is decorated; changing it later +does not swap the class. Without a fallback, an off class becomes an *empty +shell*: it takes any constructor arguments, and every method, inherited ones +included, returns nothing of its own kind (see the table above). Properties +read as `None`. `__call__` and the context manager methods are kept; other +dunder methods and data attributes are not. + +### When a toggle is read + +| | Evaluated | +|---|---| +| class | **once**, when decorated | +| function or method | on **every call** | +| `async def`, generator | on every call, when the coroutine or generator starts | + +### Fail fast + +`feature_toggle` raises `ProviderNotSetError` ("FeatureToggleProvider not +set") when it is applied, not on the first call, if no provider is set. A +fallback of the wrong kind, such as a class for a function, raises `TypeError` +at the same point. A misconfiguration shows at startup. + +## Conformance + +`conformance.lock` pins a release of the suite by version and checksum. +`scripts/fetch-conformance.sh` fetches and verifies it into `tests/suite`, and +`pytest` then runs every case next to this package's own tests. + +## Development + +```bash +uv sync +uv run ruff check && uv run ruff format --check && uv run mypy +./scripts/fetch-conformance.sh && uv run --python 3.11 pytest && uv run --python 3.14 pytest +uv build +``` + +## License + +MIT diff --git a/conformance.lock b/conformance.lock new file mode 100644 index 0000000..85a787e --- /dev/null +++ b/conformance.lock @@ -0,0 +1,2 @@ +version=v5.0.0 +sha256=47b6adaac579aa7963f1fe1679676445c09b7f74c6812136ee35eeeeb4a7f3bc diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0084996 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "yaft" +version = "0.1.0" +description = "Yet another Feature Toggle: decorators for functions, methods and classes" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "tehw0lf", email = "tehwolf@protonmail.com" }] +requires-python = ">=3.11" +# No runtime dependencies, on purpose: a feature toggle library should not pull +# a dependency tree into every application that uses it. +dependencies = [] +keywords = ["feature", "toggle", "feature-toggle", "feature-flag", "yaft"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/tehw0lf/yaft-python" +Repository = "https://github.com/tehw0lf/yaft-python" +Issues = "https://github.com/tehw0lf/yaft-python/issues" + +[dependency-groups] +dev = ["mypy", "pytest", "ruff"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "PT"] + +[tool.mypy] +strict = true +python_version = "3.11" +files = ["src", "tests"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" diff --git a/scripts/fetch-conformance.sh b/scripts/fetch-conformance.sh new file mode 100755 index 0000000..2d7073d --- /dev/null +++ b/scripts/fetch-conformance.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Fetches the YaFT conformance suite pinned in conformance.lock. +# +# Copy this into a port, next to a conformance.lock of the form: +# +# version=v1.0.0 +# sha256= +# +# The checksum is not optional. A Git tag can be moved; verifying the asset is +# what stops a port's tests from changing without a diff. +# +# Usage: scripts/fetch-conformance.sh [target-dir] (default: tests/suite) +# +# Set the default to wherever the port's adapter reads the cases from. Ports +# differ -- yaft-ts uses src/test/conformance -- and a default that does not +# match leaves the next test run reporting missing cases. + +set -euo pipefail + +REPO="tehw0lf/yaft-conformance" +LOCK="${LOCK:-conformance.lock}" +TARGET="${1:-tests/suite}" + +if [[ ! -f "$LOCK" ]]; then + echo "error: $LOCK not found; run from the port's root" >&2 + exit 1 +fi + +# || true: without it, a missing line makes grep fail, and set -e would end +# the script before the message below could say why. +version="$(grep -E '^version=' "$LOCK" | cut -d= -f2- || true)" +expected="$(grep -E '^sha256=' "$LOCK" | cut -d= -f2- || true)" + +if [[ -z "$version" || -z "$expected" ]]; then + echo "error: $LOCK needs both version= and sha256=" >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +url="https://github.com/${REPO}/releases/download/${version}/cases.tar.gz" +echo "fetching conformance suite ${version}" +curl --fail --location --silent --show-error --output "$tmp/cases.tar.gz" "$url" + +# sha256sum is GNU coreutils and is not present on macOS; shasum ships with +# both. Preferring sha256sum keeps Linux CI on the faster binary. +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$tmp/cases.tar.gz" | cut -d' ' -f1)" +elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$tmp/cases.tar.gz" | cut -d' ' -f1)" +else + echo "error: neither sha256sum nor shasum found; cannot verify the download" >&2 + exit 1 +fi +if [[ "$actual" != "$expected" ]]; then + echo "error: checksum mismatch for cases.tar.gz" >&2 + echo " expected $expected" >&2 + echo " actual $actual" >&2 + echo "The tag may have been moved. Do not update the lock without reading the diff." >&2 + exit 1 +fi + +rm -rf "$TARGET" +mkdir -p "$TARGET" +tar --extract --gzip --file "$tmp/cases.tar.gz" --directory "$TARGET" + +echo "conformance suite ${version} unpacked into ${TARGET}" diff --git a/src/yaft/__init__.py b/src/yaft/__init__.py new file mode 100644 index 0000000..f3d68fa --- /dev/null +++ b/src/yaft/__init__.py @@ -0,0 +1,46 @@ +"""YaFT - Yet another Feature Toggle. + +Decorators that switch functions, methods and classes on and off, evaluated by +the rules every YaFT port shares (https://github.com/tehw0lf/yaft-conformance):: + + from yaft import LocalFeatureProvider, feature_toggle, set_provider + + set_provider(LocalFeatureProvider.from_file("features.json")) + + class Checkout: + @feature_toggle("new-pricing", fallback=lambda self, cart: sum(cart)) + def total(self, cart: list[int]) -> int: ... +""" + +from importlib.metadata import PackageNotFoundError, version + +from .evaluate import Clock, evaluate, parse_timestamp, system_clock +from .mapping import normalise_booleans, normalise_collection, normalise_feature, normalise_group +from .model import Feature +from .providers import FeatureProvider, LocalBooleanProvider, LocalFeatureProvider +from .toggle import ProviderNotSetError, feature_toggle, get_provider, set_provider + +try: + __version__ = version("yaft") +except PackageNotFoundError: # pragma: no cover - only without an installed distribution + __version__ = "0+unknown" + +__all__ = [ + "Clock", + "Feature", + "FeatureProvider", + "LocalBooleanProvider", + "LocalFeatureProvider", + "ProviderNotSetError", + "__version__", + "evaluate", + "feature_toggle", + "get_provider", + "normalise_booleans", + "normalise_collection", + "normalise_feature", + "normalise_group", + "parse_timestamp", + "set_provider", + "system_clock", +] diff --git a/src/yaft/evaluate.py b/src/yaft/evaluate.py new file mode 100644 index 0000000..439025a --- /dev/null +++ b/src/yaft/evaluate.py @@ -0,0 +1,121 @@ +"""The evaluation rules, in one place (SPEC sections 2 and 3). + +Providers call :func:`evaluate` instead of implementing the rules themselves +(R20), so every provider -- and every port that mirrors this module -- gives +the same answer for the same feature at the same instant. +""" + +import logging +import re +from collections.abc import Callable +from datetime import UTC, datetime, timedelta, timezone + +from .model import Feature + +log = logging.getLogger("yaft") + +Clock = Callable[[], datetime] +"""A source of the current time, as a timezone-aware ``datetime``. + +Everything that evaluates takes one instead of reading the system time, so +tests -- and the conformance suite, which brings its own ``now`` per case -- +can evaluate against a fixed instant (R9). +""" + + +def system_clock() -> datetime: + """The default clock: the system time, in UTC.""" + return datetime.now(UTC) + + +# RFC 3339 with an explicit offset, and nothing else (R10). re.ASCII matters: +# without it \d also matches digits of other scripts, which int() then reads. +_RFC3339 = re.compile( + r"(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?" + r"(?:([Zz])|([+-])(\d{2}):(\d{2}))", + re.ASCII, +) + + +def parse_timestamp(value: object) -> datetime | None: + """Parses an RFC 3339 timestamp with an offset into an aware ``datetime``. + + Returns ``None`` for anything unset, malformed or in another format; + callers treat that as no bound, never as an error (R8). An invalid value + is logged as a warning but never raises, so a bad timestamp in the backend + cannot take an application down. + + ``datetime`` does the calendar checks: it rejects an impossible date or + time instead of rolling it over (R11), and a leap second with it (R12). + Fractional seconds are truncated to milliseconds (R27); offsets up to + ``±23:59`` are applied (R13, R28). + """ + if value is None or value == "": + return None + if not isinstance(value, str): + log.warning("YaFT: ignoring %r, expected an RFC 3339 string", value) + return None + + match = _RFC3339.fullmatch(value) + if match is None: + log.warning( + 'YaFT: ignoring "%s", expected RFC 3339 with an offset (e.g. 2026-09-18T15:00:00Z)', + value, + ) + return None + + year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7)) + fraction, zulu, sign, offset_hours, offset_minutes = match.group(7, 8, 9, 10, 11) + + # timedelta would carry minute 60 into the hour, so it is checked here; + # timezone() itself rejects 24 hours and more. + if not zulu and int(offset_minutes) > 59: + log.warning('YaFT: ignoring "%s", not a valid offset', value) + return None + offset = timedelta(hours=int(offset_hours or 0), minutes=int(offset_minutes or 0)) + + try: + return datetime( + year, + month, + day, + hour, + minute, + second, + # Truncated, not rounded: .9999 is .999, never the next second (R27). + int((fraction or "")[:3].ljust(3, "0")) * 1000, + tzinfo=UTC if zulu else timezone(-offset if sign == "-" else offset), + ) + except ValueError: + log.warning('YaFT: ignoring "%s", not a valid date, time or offset', value) + return None + + +def evaluate(feature: Feature | None, now: datetime) -> bool: + """Decides whether ``feature`` is on at the instant ``now``. + + - A missing feature is off (R3). + - Only the exact string ``"true"`` is on (R4). + - The window is ``[active_at, disabled_at)``: on at exactly ``active_at``, + off at exactly ``disabled_at`` (R5, R6). + - ``active_at`` after ``disabled_at`` is not special-cased; the window is + simply never open (R7). + - An unset or unparseable bound is ignored (R8). + + ``now`` must be timezone-aware; a naive ``datetime`` raises ``ValueError``, + because guessing a timezone would flip the same feature at a different time + on every machine. It is truncated to milliseconds, like the bounds. + """ + if now.utcoffset() is None: + raise ValueError("now must be timezone-aware, e.g. datetime.now(UTC)") + if feature is None or feature.value != "true": + return False + + now = now.replace(microsecond=now.microsecond // 1000 * 1000) + + active_at = parse_timestamp(feature.active_at) + if active_at is not None and now < active_at: + return False + + disabled_at = parse_timestamp(feature.disabled_at) + return not (disabled_at is not None and now >= disabled_at) diff --git a/src/yaft/mapping.py b/src/yaft/mapping.py new file mode 100644 index 0000000..fdd69ec --- /dev/null +++ b/src/yaft/mapping.py @@ -0,0 +1,116 @@ +"""Turns a backend response or a local file into provider data (SPEC section 6). + +This is the single definition of the mapping rules, the way +:func:`yaft.evaluate` is the single definition of the evaluation rules. +""" + +from collections.abc import Mapping +from typing import Any + +from .model import Feature + + +def _field(raw: Mapping[str, Any], lower: str, upper: str) -> Any: + """Reads a field by presence, not by truthiness (R23). + + ``raw.get("value") or raw.get("Value")`` is wrong: a present but empty + value falls through to the other spelling, so a feature stored as ``""`` + reads as whatever the capitalised field holds, and an off feature reports + on. The same trap applies to ``tags: []``. + """ + if lower in raw: + return raw[lower] + if upper in raw: + return raw[upper] + return None + + +def _text(value: Any) -> str: + """A string field, or ``""`` for anything that is not a string. + + ``value`` is a string by definition (R1); a JSON boolean belongs in the + boolean shape, not here. So ``"value": true`` is not coerced to ``"true"`` + -- it is not set, and the feature is off. A key that is not a string is + likewise no key, and the entry is skipped (R25). The backend sends ``null`` + for an unset bound and fixtures ``""``; both are no bound (R24). + """ + return value if isinstance(value, str) else "" + + +def normalise_feature(raw: Mapping[str, Any]) -> Feature: + """Normalises one entry into a :class:`Feature`, in either field spelling (R22a).""" + tags = _field(raw, "tags", "Tags") + return Feature( + key=_text(_field(raw, "key", "Key")), + value=_text(_field(raw, "value", "Value")), + active_at=_text(_field(raw, "activeAt", "ActiveAt")), + disabled_at=_text(_field(raw, "disabledAt", "DisabledAt")), + # Filtered rather than trusted: a mixed array would otherwise hand + # callers a non-string through a field typed as strings. + tags=tuple(tag for tag in tags if isinstance(tag, str)) if isinstance(tags, list) else (), + ) + + +def _collection(body: Mapping[str, Any]) -> list[Any] | None: + for name in ("toggles", "value"): + entries = body.get(name) + if isinstance(entries, list): + return entries + return None + + +def normalise_collection(response: object) -> dict[str, Feature]: + """Normalises a whole response into features keyed by their key. + + Three envelopes are accepted, because the backend uses all three (R22): + ``{"toggles": [...]}`` for a UUID group, ``{"value": [...]}`` for the same + thing under another name, and a flat object for a single toggle. An entry + without a usable key is skipped rather than stored under ``""`` (R25). + """ + if not isinstance(response, Mapping): + return {} + + collection = _collection(response) + entries = collection if collection is not None else [response] + + data: dict[str, Feature] = {} + for entry in entries: + if isinstance(entry, Mapping): + feature = normalise_feature(entry) + if feature.key != "": + data[feature.key] = feature + return data + + +def normalise_group(response: object) -> dict[str, Feature] | None: + """Normalises a response, but only if it is recognisably a toggle group (R30). + + A group is a collection envelope -- an empty one is a valid empty group -- + or a single toggle. Returns ``None`` for anything else: ``None``, a list, + a proxy's error object, or a collection none of whose entries is usable. + :func:`normalise_collection` turns all of those into ``{}``, and a provider + storing that would switch every feature off without an error. + """ + if not isinstance(response, Mapping): + return None + data = normalise_collection(response) + if data: + return data + collection = _collection(response) + return data if collection is not None and len(collection) == 0 else None + + +def normalise_booleans(response: object) -> dict[str, bool]: + """Normalises a boolean-shape payload, ``{"myToggle": true}``. + + Only real booleans are kept (R29). Anything else is dropped, so its key + reads as missing and therefore off. Keeping it and testing its truthiness + would turn ``"false"`` on, since a non-empty string is truthy. + """ + if not isinstance(response, Mapping): + return {} + return { + key: value + for key, value in response.items() + if isinstance(key, str) and isinstance(value, bool) + } diff --git a/src/yaft/model.py b/src/yaft/model.py new file mode 100644 index 0000000..83ee8f3 --- /dev/null +++ b/src/yaft/model.py @@ -0,0 +1,20 @@ +"""The feature record every provider of the feature shape holds (SPEC section 1).""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Feature: + """One feature toggle. + + ``value`` is a string, not a boolean: the backend stores it as one, and only + the exact string ``"true"`` is on (R1, R4). ``active_at`` and + ``disabled_at`` are RFC 3339 timestamps with an offset, or ``""`` for no + bound (R2). + """ + + key: str + value: str + active_at: str = "" + disabled_at: str = "" + tags: tuple[str, ...] = () diff --git a/src/yaft/providers.py b/src/yaft/providers.py new file mode 100644 index 0000000..619b6aa --- /dev/null +++ b/src/yaft/providers.py @@ -0,0 +1,140 @@ +"""Providers: where feature data comes from, and how a key is answered (SPEC section 5).""" + +import json +import os +from collections.abc import Mapping +from types import MappingProxyType +from typing import Any, Protocol, Self, runtime_checkable + +from .evaluate import Clock, evaluate, system_clock +from .mapping import normalise_booleans, normalise_feature, normalise_group +from .model import Feature + + +@runtime_checkable +class FeatureProvider(Protocol): + """Anything that answers whether a feature is on. + + A feature-shape provider holds :class:`Feature` records and delegates to + :func:`yaft.evaluate` (R20); a boolean-shape provider holds plain booleans + and has no time logic (R21). Any object with ``is_enabled`` will do, for + example one backed by environment variables. + """ + + def is_enabled(self, key: str) -> bool: ... + + +class LocalFeatureProvider: + """A feature-shape provider over data held in memory. + + Time bounds are evaluated against ``clock`` on every call. Replacing the + data swaps a reference, so readers on other threads see either the old or + the new data, never a mix. + """ + + def __init__(self, data: Mapping[str, Feature] | None = None, clock: Clock = system_clock): + self._clock = clock + self._data: Mapping[str, Feature] = MappingProxyType(dict(data or {})) + + @classmethod + def from_response(cls, response: object, clock: Clock = system_clock) -> Self: + """Builds a provider from a parsed backend response (R22). + + Raises ``ValueError`` if ``response`` is not a toggle group (R30). + """ + provider = cls(clock=clock) + provider.load(response) + return provider + + @classmethod + def from_file(cls, path: str | os.PathLike[str], clock: Clock = system_clock) -> Self: + """Reads a JSON file: a backend response, or features keyed by name. + + The keyed form is what yaft-ts reads from local files:: + + {"myToggle": {"value": "true", "activeAt": "", "disabledAt": ""}} + + A missing or unreadable file raises, rather than starting with every + feature off. + """ + with open(path, encoding="utf-8") as file: + content = json.load(file) + group = normalise_group(content) + if group is None: + group = _keyed_features(content) + return cls(group, clock) + + @property + def data(self) -> Mapping[str, Feature]: + """The current features by key, read-only.""" + return self._data + + def replace(self, data: Mapping[str, Feature]) -> None: + """Swaps the data, for example after reloading it. The mapping is copied.""" + self._data = MappingProxyType(dict(data)) + + def load(self, response: object) -> None: + """Replaces the data with the group in a parsed backend response. + + The refresh succeeds or fails as a whole (R30). A group -- even an + empty one -- replaces the data completely, so a toggle missing from it + is off. Anything else raises ``ValueError`` and leaves the data as it + was: storing a proxy's error page as "no toggles" would switch every + feature off without an error. + """ + group = normalise_group(response) + if group is None: + raise ValueError(f"not a toggle group: {_preview(response)}") + self.replace(group) + + def is_enabled(self, key: str) -> bool: + """Evaluates the feature stored under ``key``; a missing key is off.""" + return evaluate(self._data.get(key), self._clock()) + + +class LocalBooleanProvider: + """A boolean-shape provider: ``{"myToggle": true}``, no time logic (R21). + + Only the boolean ``True`` is on. Anything that is not a boolean is dropped + when the data is set, so its key reads as missing and therefore off (R29). + """ + + def __init__(self, data: Mapping[str, Any] | None = None): + self._data: Mapping[str, bool] = MappingProxyType(normalise_booleans(data or {})) + + @classmethod + def from_file(cls, path: str | os.PathLike[str]) -> Self: + """Reads a JSON object of booleans. A missing or unreadable file raises.""" + with open(path, encoding="utf-8") as file: + content = json.load(file) + if not isinstance(content, Mapping): + raise ValueError(f"{os.fspath(path)}: expected a JSON object, got {_preview(content)}") + return cls(content) + + @property + def data(self) -> Mapping[str, bool]: + """The current booleans by key, read-only.""" + return self._data + + def replace(self, data: Mapping[str, Any]) -> None: + """Swaps the data; entries that are not booleans are dropped (R29).""" + self._data = MappingProxyType(normalise_booleans(data)) + + def is_enabled(self, key: str) -> bool: + return self._data.get(key) is True + + +def _keyed_features(content: object) -> dict[str, Feature]: + """Reads ``{"name": {feature}}``; each feature is stored under its name.""" + if not isinstance(content, Mapping): + raise ValueError(f"not a toggle group or features keyed by name: {_preview(content)}") + return { + name: normalise_feature({**raw, "key": name}) + for name, raw in content.items() + if isinstance(name, str) and name != "" and isinstance(raw, Mapping) + } + + +def _preview(value: object) -> str: + text = repr(value) + return text if len(text) <= 80 else text[:77] + "..." diff --git a/src/yaft/py.typed b/src/yaft/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/yaft/toggle.py b/src/yaft/toggle.py new file mode 100644 index 0000000..1fe357c --- /dev/null +++ b/src/yaft/toggle.py @@ -0,0 +1,238 @@ +"""The ``feature_toggle`` decorator (SPEC section 4). + +When a toggle is evaluated is observable, so it is fixed: + +- a **class** is decided once, when it is decorated (R14); +- a **function or method** is decided on every call (R15). +""" + +import functools +import inspect +from collections.abc import AsyncIterator, Callable, Generator, Iterator +from typing import Any, TypeVar + +from .providers import FeatureProvider + +_T = TypeVar("_T") + +_provider: FeatureProvider | None = None + + +class ProviderNotSetError(RuntimeError): + """Raised when a toggle is used before :func:`set_provider` (R16).""" + + def __init__(self) -> None: + super().__init__("FeatureToggleProvider not set") + + +def set_provider(provider: FeatureProvider | None) -> None: + """Installs the provider every toggle asks; ``None`` removes it. + + A decorated method asks whichever provider is installed when it is called, + so replacing the provider takes effect on the next call. + """ + global _provider + _provider = provider + + +def get_provider() -> FeatureProvider | None: + """The installed provider, or ``None``.""" + return _provider + + +def _is_enabled(key: str) -> bool: + provider = _provider + if provider is None: + raise ProviderNotSetError + return provider.is_enabled(key) + + +def feature_toggle(key: str, fallback: Any = None) -> Callable[[_T], _T]: + """Switches a function, method or class on and off by the toggle ``key``. + + Off, a function or method runs ``fallback`` with the same arguments and the + same ``self`` (R19), or returns *nothing* without one: ``None``, a + coroutine resolving to ``None`` for an ``async def`` (R18), an empty + iterator for a generator. Off, a class is replaced by ``fallback``, or by + an empty shell whose methods all return *nothing* (R17). + + Raises :class:`ProviderNotSetError` right here, at decoration time, if no + provider is installed (R16), and ``TypeError`` for a fallback of the wrong + kind -- a startup misconfiguration should not wait for the first call. + """ + if _provider is None: + raise ProviderNotSetError + + def decorate(target: _T) -> _T: + if isinstance(target, type): + result: Any = _toggle_class(target, key, fallback) + elif isinstance(target, staticmethod | classmethod): + result = type(target)(_toggle_function(target.__func__, key, fallback)) + elif callable(target): + result = _toggle_function(target, key, fallback) + else: + raise TypeError(f"feature_toggle({key!r}) cannot decorate {target!r}") + return result # type: ignore[no-any-return] + + return decorate + + +def _toggle_class(cls: type, key: str, fallback: Any) -> type: + if fallback is not None and not isinstance(fallback, type): + raise TypeError(f"feature_toggle({key!r}): a class's fallback must be a class") + if _is_enabled(key): + return cls + return fallback if fallback is not None else _empty_shell(cls) + + +def _toggle_function(fn: Callable[..., Any], key: str, fallback: Any) -> Callable[..., Any]: + if fallback is not None and (isinstance(fallback, type) or not callable(fallback)): + raise TypeError(f"feature_toggle({key!r}): a function's fallback must be a function") + + if inspect.iscoroutinefunction(fn): + # An async def wrapper keeps inspect.iscoroutinefunction true, which + # frameworks such as FastAPI check. The toggle is read when the + # coroutine starts, which for `await f()` is the call. + @functools.wraps(fn) + async def toggled_coroutine(*args: Any, **kwargs: Any) -> Any: + if _is_enabled(key): + return await fn(*args, **kwargs) + if fallback is None: + return None + # A synchronous fallback is fine too; awaiting only what is awaitable + # keeps the promise the signature makes either way. + result = fallback(*args, **kwargs) + return await result if inspect.isawaitable(result) else result + + return toggled_coroutine + + if inspect.isgeneratorfunction(fn): + + @functools.wraps(fn) + def toggled_generator(*args: Any, **kwargs: Any) -> Generator[Any, Any, Any]: + if _is_enabled(key): + return (yield from fn(*args, **kwargs)) + result = fallback(*args, **kwargs) if fallback is not None else None + if result is not None: + return (yield from result) + return None + + return toggled_generator + + if inspect.isasyncgenfunction(fn): + + @functools.wraps(fn) + async def toggled_async_generator(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + source = ( + fn(*args, **kwargs) + if _is_enabled(key) + else fallback(*args, **kwargs) + if fallback is not None + else None + ) + if source is not None: + async for item in source: + yield item + + return toggled_async_generator + + @functools.wraps(fn) + def toggled(*args: Any, **kwargs: Any) -> Any: + if _is_enabled(key): + return fn(*args, **kwargs) + return fallback(*args, **kwargs) if fallback is not None else None + + return toggled + + +# Dunder methods are left out of the shell: returning None from __len__ or +# __eq__ breaks the caller. These few are safe to answer with nothing, and +# without them a shell could not stand in for a callable or a context manager. +_SHELL_DUNDERS = frozenset({"__call__", "__enter__", "__exit__", "__aenter__", "__aexit__"}) + + +def _empty_shell(cls: type) -> type: + """A class with the methods of ``cls``, every one returning nothing (R17). + + It takes any constructor arguments, so it can be created wherever ``cls`` + was. Inherited methods are included; data attributes are not. + """ + + def accept_anything(self: object, *args: Any, **kwargs: Any) -> None: + pass + + namespace: dict[str, Any] = { + "__module__": cls.__module__, + "__qualname__": cls.__qualname__, + "__doc__": cls.__doc__, + "__init__": accept_anything, + } + # Base classes first, so a subclass's definition wins. + for klass in reversed(cls.__mro__[:-1]): + for name, attr in vars(klass).items(): + if name.startswith("__") and name.endswith("__") and name not in _SHELL_DUNDERS: + continue + stub = _stub(attr) + if stub is not None: + namespace[name] = stub + else: + namespace.pop(name, None) + return type(cls.__name__, (), namespace) + + +def _stub(attr: Any) -> Any: + if isinstance(attr, staticmethod): + return staticmethod(_nothing_like(attr.__func__)) + if isinstance(attr, classmethod): + return classmethod(_nothing_like(attr.__func__)) + if isinstance(attr, property | functools.cached_property): + # Assigning to it must not fail where the original accepted it. + writable = isinstance(attr, functools.cached_property) or attr.fset is not None + return property(_nothing, _ignore if writable else None) + if inspect.isfunction(attr): + return _nothing_like(attr) + return None + + +def _nothing(*args: Any, **kwargs: Any) -> None: + return None + + +def _ignore(*args: Any, **kwargs: Any) -> None: + pass + + +def _nothing_like(fn: Callable[..., Any]) -> Callable[..., Any]: + """A function of the same kind as ``fn`` that returns nothing.""" + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def nothing_coroutine(*args: Any, **kwargs: Any) -> None: + return None + + return nothing_coroutine + + if inspect.isgeneratorfunction(fn): + + @functools.wraps(fn) + def nothing_generator(*args: Any, **kwargs: Any) -> Iterator[Any]: + yield from () + + return nothing_generator + + if inspect.isasyncgenfunction(fn): + + @functools.wraps(fn) + async def nothing_async_generator(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + return + yield + + return nothing_async_generator + + # A new function each time: functools.wraps mutates the function it is + # given, so wrapping the shared _nothing would rename every stub. + @functools.wraps(fn) + def nothing(*args: Any, **kwargs: Any) -> None: + return None + + return nothing diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/__init__.py b/tests/conformance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/cases.py b/tests/conformance/cases.py new file mode 100644 index 0000000..29a1eab --- /dev/null +++ b/tests/conformance/cases.py @@ -0,0 +1,71 @@ +"""Loads the conformance case files. + +The cases live in ``tests/suite``, fetched by ``scripts/fetch-conformance.sh`` +from the version pinned in ``conformance.lock``. They are not checked in: the +lock file plus its checksum is what makes a suite bump a reviewable one-line +diff, and a committed copy would drift from the tag it claims to be. +""" + +import json +from pathlib import Path +from typing import Any, NoReturn + +from yaft import Feature + +CASES_DIR = Path(__file__).parent.parent / "suite" / "cases" + +# The case-file format versions this adapter implements. A file in any other +# format may carry a field this adapter never reads, which would leave a rule +# silently unenforced, so it is rejected instead. +FORMATS = {"evaluation": 1, "decorator": 1, "mapping": 4} + +Case = dict[str, Any] + + +def load(suite: str) -> list[Case]: + path = CASES_DIR / f"{suite}.json" + if not path.exists(): + raise FileNotFoundError( + f"Conformance cases missing at {path}.\n" + "Run scripts/fetch-conformance.sh before the tests; CI does this first." + ) + file = json.loads(path.read_text(encoding="utf-8")) + if file["suite"] != suite: + raise ValueError(f'{path} declares suite "{file["suite"]}", expected "{suite}"') + if file["version"] != FORMATS[suite]: + raise ValueError( + f"{path} is format version {file['version']}, but this adapter implements " + f"version {FORMATS[suite]}. Extend the adapter to the new format." + ) + cases: list[Case] = file["cases"] + return cases + + +def title(case: Case) -> str: + """Names a case for the test output, so a failure points at a rule.""" + return f"{case['name']} [{', '.join(case['rules'])}]" + + +def unsupported(kind: str, value: object, case: Case) -> NoReturn: + """Rejects a value the adapter does not know how to handle. + + A case whose ``target``, ``toggle`` or ``expected`` this adapter has never + seen is a rule nothing enforces here. Skipping it would leave the suite + looking green while a requirement goes unchecked. + """ + raise AssertionError( + f'Case "{case["name"]}" uses {kind} "{value}", which this adapter does not ' + "implement. The conformance suite has gained a case this port does not cover " + "yet -- extend the adapter rather than skipping the case." + ) + + +def as_record(feature: Feature) -> dict[str, Any]: + """A feature in the suite's spelling, to compare with ``expected``.""" + return { + "key": feature.key, + "value": feature.value, + "activeAt": feature.active_at, + "disabledAt": feature.disabled_at, + "tags": list(feature.tags), + } diff --git a/tests/conformance/test_decorator.py b/tests/conformance/test_decorator.py new file mode 100644 index 0000000..fb95486 --- /dev/null +++ b/tests/conformance/test_decorator.py @@ -0,0 +1,206 @@ +"""Runs the shared decorator cases against this port. + +These are scenarios rather than input/output pairs -- "the fallback receives +the same receiver" cannot be written as JSON. Each case names a scenario and an +outcome in port-neutral terms, and this module maps them onto Python +decorators. An unknown value fails instead of being skipped. +""" + +import asyncio +import inspect +from collections.abc import Iterator +from typing import Any + +import pytest + +from yaft import ProviderNotSetError, feature_toggle, get_provider, set_provider + +from .cases import Case, load, title, unsupported + +CASES = load("decorator") +KEY = "conformanceToggle" +ORIGINAL = "original" +FALLBACK = "fallback" + + +class SwitchableProvider: + """A provider whose answer can change between decoration and call.""" + + def __init__(self, enabled: bool): + self.enabled = enabled + + def is_enabled(self, key: str) -> bool: + return self.enabled + + +@pytest.fixture(autouse=True) +def restore_provider() -> Iterator[None]: + saved = get_provider() + yield + set_provider(saved) + + +def set_up(case: Case) -> SwitchableProvider: + """Installs a provider in the state ``toggle`` starts in. + + ``on-then-off`` is on while decorating and off by the time the class or + method is used, which is what separates "evaluated once" (R14) from + "evaluated per call" (R15). + """ + match case["toggle"]: + case "on" | "on-then-off": + provider = SwitchableProvider(True) + case "off" | "off-then-on": + provider = SwitchableProvider(False) + case other: + unsupported("toggle", other, case) + set_provider(provider) + return provider + + +def flip_if_two_phase(case: Case, provider: SwitchableProvider) -> None: + if case["toggle"] == "on-then-off": + provider.enabled = False + elif case["toggle"] == "off-then-on": + provider.enabled = True + + +def test_loads_the_suite() -> None: + assert CASES + + +@pytest.mark.parametrize("case", CASES, ids=title) +def test_decorator(case: Case) -> None: + if case["toggle"] == "no-provider": + expect_decoration_error(case) + return + match case["target"]: + case "method": + method_case(case) + case "async-method": + async_method_case(case) + case "class": + class_case(case) + case other: + unsupported("target", other, case) + + +def method_case(case: Case) -> None: + provider = set_up(case) + seen: dict[str, Any] = {"ran": ""} + + def fallback_method(this: object, *args: object) -> str: + seen.update(ran=FALLBACK, args=args, receiver=this) + return FALLBACK + + match case["fallback"]: + case "none": + fallback = None + case "method": + fallback = fallback_method + case other: + unsupported("fallback", f"{other} on a method target", case) + + class Subject: + @feature_toggle(KEY, fallback) + def run(self, *args: object) -> str: + seen["ran"] = ORIGINAL + return ORIGINAL + + flip_if_two_phase(case, provider) + instance = Subject() + result: object = instance.run("a", 1) + + match case["expected"]: + case "original": + assert (seen["ran"], result) == (ORIGINAL, ORIGINAL) + case "fallback": + assert (seen["ran"], result) == (FALLBACK, FALLBACK) + case "nothing": + assert (seen["ran"], result) == ("", None) + case other: + unsupported("expected", other, case) + + for assertion in case.get("assertions", []): + match assertion: + case "same-arguments": + assert seen["args"] == ("a", 1) + case "same-receiver": + assert seen["receiver"] is instance + case other: + unsupported("assertion", other, case) + + +def async_method_case(case: Case) -> None: + provider = set_up(case) + if case["fallback"] != "none": + unsupported("fallback", f"{case['fallback']} on an async method", case) + seen = {"ran": ""} + + class Subject: + @feature_toggle(KEY) + async def run(self) -> str: + seen["ran"] = ORIGINAL + return ORIGINAL + + flip_if_two_phase(case, provider) + result = Subject().run() + # An await at the call site must not break: the result is awaitable even + # when the method is off (R18). + assert inspect.iscoroutine(result) + value = asyncio.run(result) + + match case["expected"]: + case "original": + assert (seen["ran"], value) == (ORIGINAL, ORIGINAL) + case "resolved-nothing": + assert (seen["ran"], value) == ("", None) + case other: + unsupported("expected", other, case) + + +def class_case(case: Case) -> None: + provider = set_up(case) + + class FallbackClass: + def which(self) -> str: + return FALLBACK + + match case["fallback"]: + case "none": + fallback: type | None = None + case "class": + fallback = FallbackClass + case other: + unsupported("fallback", f"{other} on a class target", case) + + @feature_toggle(KEY, fallback) + class Subject: + def which(self) -> str: + return ORIGINAL + + # A class is decided when it is decorated, so this flip must have no + # effect -- exactly what the two-phase cases check (R14). + flip_if_two_phase(case, provider) + which = Subject().which() + + match case["expected"]: + case "original": + assert which == ORIGINAL + case "fallback": + assert which == FALLBACK + case "empty-shell": + # The shell still answers every method of the original, each with + # nothing, so calling into a disabled class does not raise. + assert which is None + case other: + unsupported("expected", other, case) + + +def expect_decoration_error(case: Case) -> None: + """With no provider, decorating itself must fail, not the first call (R16).""" + if case["expected"] != "decoration-error": + unsupported("expected", case["expected"], case) + set_provider(None) + with pytest.raises(ProviderNotSetError, match="FeatureToggleProvider not set"): + feature_toggle(KEY) diff --git a/tests/conformance/test_evaluation.py b/tests/conformance/test_evaluation.py new file mode 100644 index 0000000..e65c0ed --- /dev/null +++ b/tests/conformance/test_evaluation.py @@ -0,0 +1,28 @@ +"""Runs the shared evaluation cases against this port. + +Each case brings its own ``now``, which is why the clock is injectable (R9). +""" + +import pytest + +from yaft import evaluate, normalise_feature, parse_timestamp + +from .cases import Case, load, title + +CASES = load("evaluation") + + +def test_loads_the_suite() -> None: + assert CASES + + +@pytest.mark.parametrize("case", CASES, ids=title) +def test_evaluation(case: Case) -> None: + now = parse_timestamp(case["now"]) + # A case whose own now does not parse would evaluate against nothing and + # pass or fail for the wrong reason. + assert now is not None, f"now {case['now']!r} does not parse" + + raw = case["features"].get(case["key"]) + feature = normalise_feature(raw) if raw is not None else None + assert evaluate(feature, now) is case["expected"] diff --git a/tests/conformance/test_mapping.py b/tests/conformance/test_mapping.py new file mode 100644 index 0000000..46e214c --- /dev/null +++ b/tests/conformance/test_mapping.py @@ -0,0 +1,73 @@ +"""Runs the shared mapping cases against this port. + +These pin down how a backend response becomes provider data: which envelopes +exist, how the two field spellings are reconciled, that a present but empty +value is kept, and that a refresh replaces the data as a whole or not at all. +""" + +from collections.abc import Mapping + +import pytest + +from yaft import Feature, LocalBooleanProvider, LocalFeatureProvider, normalise_collection + +from .cases import Case, as_record, load, title, unsupported + +CASES = load("mapping") + + +def records(data: Mapping[str, Feature]) -> dict[str, object]: + return {key: as_record(feature) for key, feature in data.items()} + + +def test_loads_the_suite() -> None: + assert CASES + + +@pytest.mark.parametrize("case", CASES, ids=title) +def test_mapping(case: Case) -> None: + match case["shape"]: + case "feature" if "held" in case: + refresh(case) + case "feature": + assert records(normalise_collection(case["response"])) == case["expected"] + case "boolean": + provider = LocalBooleanProvider(case["response"]) + assert dict(provider.data) == case["expected"] + probes = case.get("isEnabled") + if not probes: + raise AssertionError(f'Case "{case["name"]}" has no isEnabled probes') + for key, enabled in probes.items(): + assert (key, provider.is_enabled(key)) == (key, enabled) + case other: + unsupported("shape", other, case) + + +def refresh(case: Case) -> None: + """A refresh case (R30, R32): ``held`` is loaded, then ``response`` arrives. + + This port has no API provider yet, so the refresh goes through + ``LocalFeatureProvider.load``, which the API provider will use as well. + ``load`` must raise exactly when the case says the response is rejected + (R32), and leave the data the case expects. ``retry`` checks that a + rejected refresh does not block the next one; without a collection hash + there is nothing to record, so here it only checks the data it loads. + """ + rejected = case["rejected"] + if not isinstance(rejected, bool): + unsupported("rejected", rejected, case) + + provider = LocalFeatureProvider.from_response({"toggles": list(case["held"].values())}) + assert records(provider.data) == case["held"] + + if rejected: + with pytest.raises(ValueError, match="not a toggle group"): + provider.load(case["response"]) + else: + provider.load(case["response"]) + assert records(provider.data) == case["expected"] + + retry = case.get("retry") + if retry is not None: + provider.load(retry["response"]) + assert records(provider.data) == retry["expected"] diff --git a/tests/test_evaluate.py b/tests/test_evaluate.py new file mode 100644 index 0000000..d5495bc --- /dev/null +++ b/tests/test_evaluate.py @@ -0,0 +1,68 @@ +"""What the conformance suite cannot see: Python's own traps around time.""" + +import logging +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from yaft import Feature, evaluate, parse_timestamp, system_clock + +ON = Feature(key="f", value="true") + + +def test_parses_to_an_aware_datetime() -> None: + assert parse_timestamp("2026-09-18T14:00:00+02:00") == datetime(2026, 9, 18, 12, tzinfo=UTC) + assert parse_timestamp("2026-09-18T12:00:00.123456Z") == datetime( + 2026, 9, 18, 12, 0, 0, 123_000, tzinfo=UTC + ) + + +@pytest.mark.parametrize( + "value", + [ + # \d without re.ASCII matches digits of other scripts, and int() reads + # them: this would parse as 2026-09-18. + "\u0662\u0660\u0662\u0666-\u0660\u0669-\u0661\u0668T12:00:00Z", # Arabic-Indic digits + # re.match with $ allows a trailing newline; fullmatch does not. + "2026-09-18T12:00:00Z\n", + " 2026-09-18T12:00:00Z", + ], +) +def test_rejects_what_a_loose_pattern_lets_through(value: str) -> None: + assert parse_timestamp(value) is None + + +@pytest.mark.parametrize("value", [None, "", 1_789_732_800, ["2026-09-18T12:00:00Z"]]) +def test_ignores_what_is_not_a_timestamp_string(value: object) -> None: + assert parse_timestamp(value) is None + + +def test_warns_about_an_ignored_bound(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger="yaft"): + assert parse_timestamp("2026-09-18") is None + assert "2026-09-18" in caplog.text + + +def test_now_must_be_timezone_aware() -> None: + # A naive datetime names no instant; guessing would flip at different + # times on different machines. + with pytest.raises(ValueError, match="timezone-aware"): + evaluate(ON, datetime(2026, 9, 18, 12)) + + +def test_now_in_any_timezone_is_the_same_instant() -> None: + feature = Feature(key="f", value="true", active_at="2026-09-18T12:00:00Z") + berlin = timezone(timedelta(hours=2)) + assert evaluate(feature, datetime(2026, 9, 18, 14, tzinfo=berlin)) + assert not evaluate(feature, datetime(2026, 9, 18, 13, 59, 59, 999_999, tzinfo=berlin)) + + +def test_now_is_truncated_to_milliseconds() -> None: + # Like a millisecond clock: .000999 is still .000, so a bound at .001 has + # not been reached. Rounding would make it .001 and switch the feature off. + feature = Feature(key="f", value="true", disabled_at="2026-09-18T12:00:00.001Z") + assert evaluate(feature, datetime(2026, 9, 18, 12, 0, 0, 999, tzinfo=UTC)) + + +def test_system_clock_is_aware() -> None: + assert system_clock().utcoffset() == timedelta(0) diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..8a73d78 --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,41 @@ +"""Mapping details the conformance suite does not pin down.""" + +from yaft import ( + Feature, + normalise_booleans, + normalise_collection, + normalise_feature, + normalise_group, +) + + +def test_a_value_that_is_not_a_string_is_not_set() -> None: + # A JSON boolean belongs in the boolean shape; in the feature shape it is + # not coerced to "true", so the feature is off (R1, R4). + assert normalise_feature({"key": "f", "value": True}).value == "" + assert normalise_feature({"key": "f", "value": 1}).value == "" + + +def test_an_entry_whose_key_is_not_a_string_is_skipped() -> None: + assert normalise_collection({"toggles": [{"key": 7, "value": "true"}]}) == {} + + +def test_tags_that_are_not_strings_are_dropped() -> None: + feature = normalise_feature({"key": "f", "value": "true", "tags": ["ok", 42, None, "fine"]}) + assert feature.tags == ("ok", "fine") + + +def test_a_list_of_toggles_is_not_a_group() -> None: + assert normalise_group([{"key": "f", "value": "true"}]) is None + + +def test_single_toggle_is_a_group() -> None: + assert normalise_group({"key": "f", "value": "true"}) == {"f": Feature(key="f", value="true")} + + +def test_booleans_keep_only_booleans() -> None: + assert normalise_booleans({"on": True, "off": False, "text": "true", "one": 1}) == { + "on": True, + "off": False, + } + assert normalise_booleans(None) == {} diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..bc8a846 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,103 @@ +"""The local providers.""" + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from yaft import Feature, FeatureProvider, LocalBooleanProvider, LocalFeatureProvider + + +def fixed(instant: str) -> datetime: + return datetime.fromisoformat(instant).astimezone(UTC) + + +def write(tmp_path: Path, content: object) -> Path: + path = tmp_path / "features.json" + path.write_text(json.dumps(content), encoding="utf-8") + return path + + +def test_feature_provider_evaluates_against_its_clock() -> None: + now = fixed("2026-09-18T12:00:00+00:00") + provider = LocalFeatureProvider( + {"f": Feature(key="f", value="true", active_at="2026-09-18T13:00:00Z")}, + clock=lambda: now, + ) + assert not provider.is_enabled("f") + now = fixed("2026-09-18T13:00:00+00:00") + assert provider.is_enabled("f") + assert not provider.is_enabled("missing") + + +def test_reads_the_keyed_file_format_of_yaft_ts(tmp_path: Path) -> None: + path = write( + tmp_path, + { + "on": {"key": "on", "value": "true", "activeAt": "", "disabledAt": ""}, + "off": {"value": "false"}, + "stray": "not a feature", + }, + ) + provider = LocalFeatureProvider.from_file(path) + assert set(provider.data) == {"on", "off"} + assert provider.is_enabled("on") + assert not provider.is_enabled("off") + + +def test_reads_a_backend_response_from_a_file(tmp_path: Path) -> None: + path = write(tmp_path, {"toggles": [{"Key": "uuid|a", "Value": "true"}]}) + assert LocalFeatureProvider.from_file(path).is_enabled("uuid|a") + + +def test_a_missing_file_raises(tmp_path: Path) -> None: + # Starting with every feature off would hide the misconfiguration. + with pytest.raises(FileNotFoundError): + LocalFeatureProvider.from_file(tmp_path / "absent.json") + with pytest.raises(FileNotFoundError): + LocalBooleanProvider.from_file(tmp_path / "absent.json") + + +def test_load_keeps_the_data_when_the_body_is_not_a_group() -> None: + provider = LocalFeatureProvider.from_response({"key": "f", "value": "true"}) + with pytest.raises(ValueError, match="not a toggle group"): + provider.load({"error": "Bad Gateway"}) + assert provider.is_enabled("f") + + provider.load({"toggles": []}) + assert not provider.is_enabled("f") + + +def test_data_is_read_only() -> None: + provider = LocalFeatureProvider({"f": Feature(key="f", value="true")}) + with pytest.raises(TypeError): + provider.data["f"] = Feature(key="f", value="false") # type: ignore[index] + + +def test_constructor_copies_the_data() -> None: + data = {"f": Feature(key="f", value="true")} + provider = LocalFeatureProvider(data) + data.clear() + assert provider.is_enabled("f") + + +def test_boolean_provider_drops_non_booleans(tmp_path: Path) -> None: + provider = LocalBooleanProvider.from_file(write(tmp_path, {"on": True, "text": "true"})) + assert provider.data == {"on": True} + assert provider.is_enabled("on") + assert not provider.is_enabled("text") + + provider.replace({"on": False, "one": 1}) + assert provider.data == {"on": False} + assert not provider.is_enabled("on") + + +def test_boolean_file_must_be_an_object(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="expected a JSON object"): + LocalBooleanProvider.from_file(write(tmp_path, [True])) + + +def test_providers_satisfy_the_protocol() -> None: + assert isinstance(LocalFeatureProvider(), FeatureProvider) + assert isinstance(LocalBooleanProvider(), FeatureProvider) diff --git a/tests/test_toggle.py b/tests/test_toggle.py new file mode 100644 index 0000000..3e9010d --- /dev/null +++ b/tests/test_toggle.py @@ -0,0 +1,268 @@ +"""The decorator beyond the shared cases: Python's other kinds of callables.""" + +import asyncio +import inspect +from collections.abc import AsyncIterator, Iterator +from types import TracebackType +from typing import Any + +import pytest + +from yaft import ProviderNotSetError, feature_toggle, get_provider, set_provider + + +class Switch: + def __init__(self, enabled: bool = True): + self.enabled = enabled + + def is_enabled(self, key: str) -> bool: + return self.enabled + + +@pytest.fixture +def switch() -> Iterator[Switch]: + saved = get_provider() + provider = Switch() + set_provider(provider) + yield provider + set_provider(saved) + + +async def collect(source: AsyncIterator[Any]) -> list[Any]: + return [item async for item in source] + + +def test_keeps_name_and_coroutine_function_marker(switch: Switch) -> None: + @feature_toggle("k") + async def fetch() -> str: + """Fetches.""" + return "data" + + # Frameworks such as FastAPI decide whether to await by this check. + assert inspect.iscoroutinefunction(fetch) + assert (fetch.__name__, fetch.__doc__) == ("fetch", "Fetches.") + + +def test_async_method_takes_a_sync_or_async_fallback(switch: Switch) -> None: + async def async_fallback() -> str: + return "async" + + @feature_toggle("k", fallback=lambda: "sync") + async def with_sync() -> str: + return "original" + + @feature_toggle("k", fallback=async_fallback) + async def with_async() -> str: + return "original" + + switch.enabled = False + assert asyncio.run(with_sync()) == "sync" + assert asyncio.run(with_async()) == "async" + + +def test_generator_off_is_an_empty_iterator(switch: Switch) -> None: + @feature_toggle("k") + def numbers() -> Iterator[int]: + yield from (1, 2) + + assert list(numbers()) == [1, 2] + switch.enabled = False + # None here would break the caller's for loop, the way None breaks await. + assert list(numbers()) == [] + + +def test_generator_fallback(switch: Switch) -> None: + @feature_toggle("k", fallback=lambda: iter([9])) + def numbers() -> Iterator[int]: + yield 1 + + switch.enabled = False + assert list(numbers()) == [9] + + +def test_async_generator(switch: Switch) -> None: + async def fallback() -> AsyncIterator[int]: + yield 9 + + @feature_toggle("k") + async def plain() -> AsyncIterator[int]: + yield 1 + + @feature_toggle("k", fallback=fallback) + async def with_fallback() -> AsyncIterator[int]: + yield 1 + + assert asyncio.run(collect(plain())) == [1] + switch.enabled = False + assert asyncio.run(collect(plain())) == [] + assert asyncio.run(collect(with_fallback())) == [9] + + +def test_static_and_class_methods(switch: Switch) -> None: + class Subject: + name = "subject" + + @feature_toggle("k") + @staticmethod + def static() -> str: + return "static" + + @feature_toggle("k", fallback=lambda cls: f"fallback {cls.name}") + @classmethod + def klass(cls) -> str: + return f"class {cls.name}" + + assert (Subject.static(), Subject.klass()) == ("static", "class subject") + switch.enabled = False + # Typed as the original's str; off, the static method returns nothing. + results: tuple[object, object] = (Subject.static(), Subject.klass()) + assert results == (None, "fallback subject") + + +def test_fallback_can_be_a_method_of_the_same_class(switch: Switch) -> None: + class Pricing: + rate = 2 + + def old(self, amount: int) -> int: + return amount + + @feature_toggle("k", fallback=old) + def new(self, amount: int) -> int: + return amount * self.rate + + switch.enabled = False + assert Pricing().new(5) == 5 + + +def test_empty_shell_stands_in_for_the_class(switch: Switch) -> None: + class Base: + def inherited(self) -> str: + return "inherited" + + class Service(Base): + limit = 3 + + def __init__(self, url: str): + self.url = url + + def run(self) -> str: + return "run" + + async def fetch(self) -> str: + return "fetch" + + def items(self) -> Iterator[int]: + yield 1 + + @property + def status(self) -> str: + return "ok" + + @status.setter + def status(self, value: str) -> None: + pass + + @property + def read_only(self) -> str: + return "ro" + + @staticmethod + def helper() -> str: + return "helper" + + def __call__(self) -> str: + return "called" + + def __enter__(self) -> "Service": + return self + + def __exit__( + self, + kind: type[BaseException] | None, + error: BaseException | None, + trace: TracebackType | None, + ) -> None: + pass + + switch.enabled = False + Shell = feature_toggle("k")(Service) + + assert Shell is not Service + assert Shell.__name__ == "Service" + # Created wherever the original was, with the original's arguments. + shell = Shell("https://example.test") + assert shell.run() is None + assert shell.inherited() is None + assert asyncio.run(shell.fetch()) is None + assert list(shell.items()) == [] + assert shell.status is None + shell.status = "ignored" + with pytest.raises(AttributeError): + shell.read_only = "x" + assert Shell.helper() is None + assert shell() is None + with shell as entered: + assert entered is None + # Only behaviour is stubbed; data attributes are not copied. + assert not hasattr(shell, "limit") + + +def test_shell_stubs_keep_their_own_names(switch: Switch) -> None: + # functools.wraps mutates the function it is handed; wrapping one shared + # function would give every stub the name of the last one. + class Service: + def first(self) -> None: ... + + def second(self) -> None: ... + + switch.enabled = False + shell = feature_toggle("k")(Service) + assert (shell.first.__name__, shell.second.__name__) == ("first", "second") + + +def test_class_fallback_must_be_a_class(switch: Switch) -> None: + with pytest.raises(TypeError, match="must be a class"): + + @feature_toggle("k", fallback=lambda: None) + class Subject: + pass + + +def test_function_fallback_must_be_a_function(switch: Switch) -> None: + class NotAFunction: + pass + + with pytest.raises(TypeError, match="must be a function"): + + @feature_toggle("k", fallback=NotAFunction) + def run() -> None: ... + + with pytest.raises(TypeError, match="must be a function"): + + @feature_toggle("k", fallback="old") + def run_by_name() -> None: ... + + +def test_rejects_what_it_cannot_decorate(switch: Switch) -> None: + with pytest.raises(TypeError, match="cannot decorate"): + feature_toggle("k")(42) + + +def test_provider_removed_after_decoration_fails_at_the_call(switch: Switch) -> None: + @feature_toggle("k") + def run() -> str: + return "run" + + set_provider(None) + with pytest.raises(ProviderNotSetError): + run() + + +def test_the_provider_is_asked_on_every_call(switch: Switch) -> None: + @feature_toggle("k") + def run() -> str: + return "run" + + replacement = Switch(enabled=False) + set_provider(replacement) + assert run() is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0b34f57 --- /dev/null +++ b/uv.lock @@ -0,0 +1,381 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[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 = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "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 = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[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 = "yaft" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +]