From d1f44645f029c71a0034effea246d757afb40d90 Mon Sep 17 00:00:00 2001 From: tehw0lf Date: Tue, 29 Sep 2026 21:02:09 +0200 Subject: [PATCH 1/3] feat: core, local providers and decorator, conformance suite 4.0.0 green The Python port of YaFT, first stage: evaluation (R3-R13, R27, R28), response mapping (R22-R25, R29, R30), local providers in both shapes and the feature_toggle decorator for functions, methods and classes (R14-R19). The adapter runs every case of yaft-conformance 4.0.0, pinned in conformance.lock; mutation-checked against eight injected bugs. Beyond the suite: async def stays a coroutine function, generators turn into an empty iterator when off, static and class methods can be toggled, and an off class without a fallback becomes an empty shell that accepts any constructor arguments. Timestamps are computed to epoch milliseconds by hand, so the range and truncation match JavaScript. No runtime dependencies; Python 3.11 and later, tested on 3.11 and 3.14 in CI. The API provider and PyPI publishing come next. Claude-Session: https://claude.ai/code/session_01SDYfZZQsaZpLvSwDgHYcr7 --- .github/workflows/build.yml | 33 +++ CLAUDE.md | 40 +++ README.md | 164 +++++++++++- conformance.lock | 2 + pyproject.toml | 52 ++++ scripts/fetch-conformance.sh | 69 +++++ src/yaft/__init__.py | 46 ++++ src/yaft/evaluate.py | 156 +++++++++++ src/yaft/mapping.py | 126 +++++++++ src/yaft/model.py | 20 ++ src/yaft/providers.py | 140 ++++++++++ src/yaft/py.typed | 0 src/yaft/toggle.py | 238 +++++++++++++++++ tests/__init__.py | 0 tests/conformance/__init__.py | 0 tests/conformance/cases.py | 71 +++++ tests/conformance/test_decorator.py | 206 +++++++++++++++ tests/conformance/test_evaluation.py | 31 +++ tests/conformance/test_mapping.py | 73 +++++ tests/test_evaluate.py | 73 +++++ tests/test_mapping.py | 33 +++ tests/test_providers.py | 103 ++++++++ tests/test_toggle.py | 268 +++++++++++++++++++ uv.lock | 381 +++++++++++++++++++++++++++ 24 files changed, 2318 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 CLAUDE.md create mode 100644 conformance.lock create mode 100644 pyproject.toml create mode 100755 scripts/fetch-conformance.sh create mode 100644 src/yaft/__init__.py create mode 100644 src/yaft/evaluate.py create mode 100644 src/yaft/mapping.py create mode 100644 src/yaft/model.py create mode 100644 src/yaft/providers.py create mode 100644 src/yaft/py.typed create mode 100644 src/yaft/toggle.py create mode 100644 tests/__init__.py create mode 100644 tests/conformance/__init__.py create mode 100644 tests/conformance/cases.py create mode 100644 tests/conformance/test_decorator.py create mode 100644 tests/conformance/test_evaluation.py create mode 100644 tests/conformance/test_mapping.py create mode 100644 tests/test_evaluate.py create mode 100644 tests/test_mapping.py create mode 100644 tests/test_providers.py create mode 100644 tests/test_toggle.py create mode 100644 uv.lock 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..6d24ad6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# 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). Timestamps are computed to epoch milliseconds by hand, not via + `datetime`, so the accepted range and truncation match JavaScript exactly. + The pattern needs `re.ASCII` and `fullmatch`. +- `src/yaft/mapping.py` — response normalisation (R22–R25, R29, R30). Non-string + keys and values are written out like JSON (`true` → `"true"`), as every other + port does. +- `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..9c25cf5 --- /dev/null +++ b/conformance.lock @@ -0,0 +1,2 @@ +version=v4.0.0 +sha256=8e9dd45e897d0527b28f29a31d35736e20b4429b7280d21e5755aeaaaf4bf3c7 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..4a4cdcc --- /dev/null +++ b/src/yaft/evaluate.py @@ -0,0 +1,156 @@ +"""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 + +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, +) + +_DAYS_IN_MONTH = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + + +def _days_in_month(year: int, month: int) -> int: + leap = (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 + return 29 if month == 2 and leap else _DAYS_IN_MONTH[month] + + +def _days_from_civil(year: int, month: int, day: int) -> int: + """Days since 1970-01-01 in the proleptic Gregorian calendar. + + Computed rather than taken from ``datetime``, which stops at year 1 and + cannot represent every instant JavaScript's ``Date.parse`` can. + """ + year -= month <= 2 + era = (year if year >= 0 else year - 399) // 400 + year_of_era = year - era * 400 + day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1 + day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year + return era * 146097 + day_of_era - 719468 + + +def parse_timestamp(value: object) -> int | None: + """Parses an RFC 3339 timestamp with an offset into epoch milliseconds. + + 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. + + Fractional seconds are truncated to milliseconds (R27), the calendar is + range-checked before anything is computed (R11), a leap second is ignored + (R12) and the offset is applied, up to ``±23:59`` (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) + + # Checked before anything is computed: the arithmetic below would roll an + # impossible date over instead of rejecting it, turning 2027-02-30 into + # 2027-03-02 (R11). + if not 1 <= month <= 12 or not 1 <= day <= _days_in_month(year, month): + log.warning('YaFT: ignoring "%s", not a valid date', value) + return None + if hour > 23 or minute > 59 or second > 60: + log.warning('YaFT: ignoring "%s", not a valid time', value) + return None + if second == 60: + # RFC 3339 permits a leap second, but not every language can represent + # one, so every port ignores it (R12). + log.warning('YaFT: ignoring "%s", a leap second is not supported', value) + return None + + offset = 0 + if not zulu: + if int(offset_hours) > 23 or int(offset_minutes) > 59: + log.warning('YaFT: ignoring "%s", not a valid offset', value) + return None + offset = (int(offset_hours) * 60 + int(offset_minutes)) * 60_000 + if sign == "-": + offset = -offset + + # Truncated, not rounded: .9999 is .999, never the next second (R27). + millis = int((fraction or "")[:3].ljust(3, "0")) + seconds = ((_days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60 + second + return seconds * 1000 + millis - offset + + +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + + +def _epoch_millis(now: datetime) -> int: + if now.utcoffset() is None: + # A naive datetime has no instant; guessing a timezone would make the + # same feature flip at a different time on every machine. + raise ValueError("now must be timezone-aware, e.g. datetime.now(UTC)") + delta = now - _EPOCH + # Integer arithmetic, truncated towards the past, like a millisecond clock. + return (delta.days * 86_400 + delta.seconds) * 1000 + delta.microseconds // 1000 + + +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``. + """ + if feature is None or feature.value != "true": + return False + + instant = _epoch_millis(now) + + active_at = parse_timestamp(feature.active_at) + if active_at is not None and instant < active_at: + return False + + disabled_at = parse_timestamp(feature.disabled_at) + return not (disabled_at is not None and instant >= disabled_at) diff --git a/src/yaft/mapping.py b/src/yaft/mapping.py new file mode 100644 index 0000000..98b2a2e --- /dev/null +++ b/src/yaft/mapping.py @@ -0,0 +1,126 @@ +"""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. +""" + +import json +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: + """Reads a key or value the way the other ports do. + + ``None`` is empty. Anything else that is not a string is written out as + JSON would: every port turns the JSON boolean ``true`` into ``"true"``, and + Python's ``str(True)`` would be ``"True"``, which is off. + """ + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return json.dumps(value, separators=(",", ":")) + + +def _date(value: Any) -> str: + """The backend sends ``null`` for an unset bound, fixtures ``""`` (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=_date(_field(raw, "activeAt", "ActiveAt")), + disabled_at=_date(_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..282d471 --- /dev/null +++ b/tests/conformance/test_evaluation.py @@ -0,0 +1,31 @@ +"""Runs the shared evaluation cases against this port. + +Each case brings its own ``now``, which is why the clock is injectable (R9). +""" + +from datetime import UTC, datetime, timedelta + +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: + millis = 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 millis is not None, f"now {case['now']!r} does not parse" + now = datetime(1970, 1, 1, tzinfo=UTC) + timedelta(milliseconds=millis) + + 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..85fd2c1 --- /dev/null +++ b/tests/test_evaluate.py @@ -0,0 +1,73 @@ +"""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_epoch_milliseconds() -> None: + assert parse_timestamp("1970-01-01T00:00:00Z") == 0 + assert parse_timestamp("2026-09-18T14:00:00+02:00") == parse_timestamp("2026-09-18T12:00:00Z") + assert parse_timestamp("2026-09-18T12:00:00.123456Z") == 1_789_732_800_123 + + +def test_reaches_before_datetime_can() -> None: + # datetime stops at year 1; JavaScript's Date.parse does not. + assert parse_timestamp("0000-01-01T00:00:00Z") == -62_167_219_200_000 + assert parse_timestamp("1969-12-31T23:59:59.999Z") == -1 + + +@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..bd086ab --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,33 @@ +"""Mapping details the conformance suite does not pin down.""" + +from yaft import Feature, normalise_booleans, normalise_feature, normalise_group + + +def test_non_string_values_read_like_the_other_ports() -> None: + # Go, Java and TypeScript all turn the JSON boolean true into "true". + # Python's str(True) is "True", which would be off in this port alone. + assert normalise_feature({"key": "f", "value": True}).value == "true" + assert normalise_feature({"key": "f", "value": False}).value == "false" + assert normalise_feature({"key": 7, "value": "true"}).key == "7" + assert normalise_feature({"key": 7.0, "value": "true"}).key == "7" + + +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" }, +] From 39cf4b1c86d3dc1efbd07f75732f4630d7643adf Mon Sep 17 00:00:00 2001 From: tehw0lf Date: Tue, 29 Sep 2026 22:02:33 +0200 Subject: [PATCH 2/3] refactor: use datetime and plain strings instead of imitating JavaScript parse_timestamp computed epoch milliseconds with its own calendar code, only to accept the same years as Date.parse. datetime already rejects an impossible date, time and leap second (R11, R12) and takes offsets below 24 hours (R28); only offset minutes above 59 need checking by hand. It now returns an aware datetime. The one difference, year 0000, is not a timestamp anyone sends. A key or value that is not a string was written out like JavaScript's String() to match the other ports. JSON booleans belong in the boolean shape: in the feature shape a non-string is now not set, so the value is off and a keyless entry is skipped (R1, R4, R25). Claude-Session: https://claude.ai/code/session_01SDYfZZQsaZpLvSwDgHYcr7 --- CLAUDE.md | 11 ++- src/yaft/evaluate.py | 105 +++++++++------------------ src/yaft/mapping.py | 26 ++----- tests/conformance/test_evaluation.py | 7 +- tests/test_evaluate.py | 15 ++-- tests/test_mapping.py | 24 ++++-- 6 files changed, 71 insertions(+), 117 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d24ad6..c95894f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,11 @@ Java and Go ports are references for behaviour, not for API shape. - `src/yaft/model.py` — `Feature` (frozen dataclass, snake_case fields) - `src/yaft/evaluate.py` — `Clock`, `evaluate`, `parse_timestamp` (R3–R13, - R27, R28). Timestamps are computed to epoch milliseconds by hand, not via - `datetime`, so the accepted range and truncation match JavaScript exactly. - The pattern needs `re.ASCII` and `fullmatch`. -- `src/yaft/mapping.py` — response normalisation (R22–R25, R29, R30). Non-string - keys and values are written out like JSON (`true` → `"true"`), as every other - port does. + 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, diff --git a/src/yaft/evaluate.py b/src/yaft/evaluate.py index 4a4cdcc..439025a 100644 --- a/src/yaft/evaluate.py +++ b/src/yaft/evaluate.py @@ -8,7 +8,7 @@ import logging import re from collections.abc import Callable -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta, timezone from .model import Feature @@ -36,39 +36,19 @@ def system_clock() -> datetime: re.ASCII, ) -_DAYS_IN_MONTH = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) - -def _days_in_month(year: int, month: int) -> int: - leap = (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 - return 29 if month == 2 and leap else _DAYS_IN_MONTH[month] - - -def _days_from_civil(year: int, month: int, day: int) -> int: - """Days since 1970-01-01 in the proleptic Gregorian calendar. - - Computed rather than taken from ``datetime``, which stops at year 1 and - cannot represent every instant JavaScript's ``Date.parse`` can. - """ - year -= month <= 2 - era = (year if year >= 0 else year - 399) // 400 - year_of_era = year - era * 400 - day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1 - day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year - return era * 146097 + day_of_era - 719468 - - -def parse_timestamp(value: object) -> int | None: - """Parses an RFC 3339 timestamp with an offset into epoch milliseconds. +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. - Fractional seconds are truncated to milliseconds (R27), the calendar is - range-checked before anything is computed (R11), a leap second is ignored - (R12) and the offset is applied, up to ``±23:59`` (R13, R28). + ``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 @@ -87,48 +67,29 @@ def parse_timestamp(value: object) -> int | 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) - # Checked before anything is computed: the arithmetic below would roll an - # impossible date over instead of rejecting it, turning 2027-02-30 into - # 2027-03-02 (R11). - if not 1 <= month <= 12 or not 1 <= day <= _days_in_month(year, month): - log.warning('YaFT: ignoring "%s", not a valid date', value) - return None - if hour > 23 or minute > 59 or second > 60: - log.warning('YaFT: ignoring "%s", not a valid time', value) + # 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 - if second == 60: - # RFC 3339 permits a leap second, but not every language can represent - # one, so every port ignores it (R12). - log.warning('YaFT: ignoring "%s", a leap second is not supported', value) + 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 - offset = 0 - if not zulu: - if int(offset_hours) > 23 or int(offset_minutes) > 59: - log.warning('YaFT: ignoring "%s", not a valid offset', value) - return None - offset = (int(offset_hours) * 60 + int(offset_minutes)) * 60_000 - if sign == "-": - offset = -offset - - # Truncated, not rounded: .9999 is .999, never the next second (R27). - millis = int((fraction or "")[:3].ljust(3, "0")) - seconds = ((_days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60 + second - return seconds * 1000 + millis - offset - - -_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) - - -def _epoch_millis(now: datetime) -> int: - if now.utcoffset() is None: - # A naive datetime has no instant; guessing a timezone would make the - # same feature flip at a different time on every machine. - raise ValueError("now must be timezone-aware, e.g. datetime.now(UTC)") - delta = now - _EPOCH - # Integer arithmetic, truncated towards the past, like a millisecond clock. - return (delta.days * 86_400 + delta.seconds) * 1000 + delta.microseconds // 1000 - def evaluate(feature: Feature | None, now: datetime) -> bool: """Decides whether ``feature`` is on at the instant ``now``. @@ -141,16 +102,20 @@ def evaluate(feature: Feature | None, now: datetime) -> bool: simply never open (R7). - An unset or unparseable bound is ignored (R8). - ``now`` must be timezone-aware; a naive ``datetime`` raises ``ValueError``. + ``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 - instant = _epoch_millis(now) + now = now.replace(microsecond=now.microsecond // 1000 * 1000) active_at = parse_timestamp(feature.active_at) - if active_at is not None and instant < 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 instant >= 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 index 98b2a2e..fdd69ec 100644 --- a/src/yaft/mapping.py +++ b/src/yaft/mapping.py @@ -4,7 +4,6 @@ :func:`yaft.evaluate` is the single definition of the evaluation rules. """ -import json from collections.abc import Mapping from typing import Any @@ -27,23 +26,14 @@ def _field(raw: Mapping[str, Any], lower: str, upper: str) -> Any: def _text(value: Any) -> str: - """Reads a key or value the way the other ports do. + """A string field, or ``""`` for anything that is not a string. - ``None`` is empty. Anything else that is not a string is written out as - JSON would: every port turns the JSON boolean ``true`` into ``"true"``, and - Python's ``str(True)`` would be ``"True"``, which is off. + ``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). """ - if value is None: - return "" - if isinstance(value, str): - return value - if isinstance(value, float) and value.is_integer(): - return str(int(value)) - return json.dumps(value, separators=(",", ":")) - - -def _date(value: Any) -> str: - """The backend sends ``null`` for an unset bound, fixtures ``""`` (R24).""" return value if isinstance(value, str) else "" @@ -53,8 +43,8 @@ def normalise_feature(raw: Mapping[str, Any]) -> Feature: return Feature( key=_text(_field(raw, "key", "Key")), value=_text(_field(raw, "value", "Value")), - active_at=_date(_field(raw, "activeAt", "ActiveAt")), - disabled_at=_date(_field(raw, "disabledAt", "DisabledAt")), + 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 (), diff --git a/tests/conformance/test_evaluation.py b/tests/conformance/test_evaluation.py index 282d471..e65c0ed 100644 --- a/tests/conformance/test_evaluation.py +++ b/tests/conformance/test_evaluation.py @@ -3,8 +3,6 @@ Each case brings its own ``now``, which is why the clock is injectable (R9). """ -from datetime import UTC, datetime, timedelta - import pytest from yaft import evaluate, normalise_feature, parse_timestamp @@ -20,11 +18,10 @@ def test_loads_the_suite() -> None: @pytest.mark.parametrize("case", CASES, ids=title) def test_evaluation(case: Case) -> None: - millis = parse_timestamp(case["now"]) + 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 millis is not None, f"now {case['now']!r} does not parse" - now = datetime(1970, 1, 1, tzinfo=UTC) + timedelta(milliseconds=millis) + 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 diff --git a/tests/test_evaluate.py b/tests/test_evaluate.py index 85fd2c1..d5495bc 100644 --- a/tests/test_evaluate.py +++ b/tests/test_evaluate.py @@ -10,16 +10,11 @@ ON = Feature(key="f", value="true") -def test_parses_to_epoch_milliseconds() -> None: - assert parse_timestamp("1970-01-01T00:00:00Z") == 0 - assert parse_timestamp("2026-09-18T14:00:00+02:00") == parse_timestamp("2026-09-18T12:00:00Z") - assert parse_timestamp("2026-09-18T12:00:00.123456Z") == 1_789_732_800_123 - - -def test_reaches_before_datetime_can() -> None: - # datetime stops at year 1; JavaScript's Date.parse does not. - assert parse_timestamp("0000-01-01T00:00:00Z") == -62_167_219_200_000 - assert parse_timestamp("1969-12-31T23:59:59.999Z") == -1 +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( diff --git a/tests/test_mapping.py b/tests/test_mapping.py index bd086ab..8a73d78 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -1,15 +1,23 @@ """Mapping details the conformance suite does not pin down.""" -from yaft import Feature, normalise_booleans, normalise_feature, normalise_group +from yaft import ( + Feature, + normalise_booleans, + normalise_collection, + normalise_feature, + normalise_group, +) -def test_non_string_values_read_like_the_other_ports() -> None: - # Go, Java and TypeScript all turn the JSON boolean true into "true". - # Python's str(True) is "True", which would be off in this port alone. - assert normalise_feature({"key": "f", "value": True}).value == "true" - assert normalise_feature({"key": "f", "value": False}).value == "false" - assert normalise_feature({"key": 7, "value": "true"}).key == "7" - assert normalise_feature({"key": 7.0, "value": "true"}).key == "7" +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: From bdcbf9bcd00a8e62c78bf5e9f0db186e0c90ecbf Mon Sep 17 00:00:00 2001 From: tehw0lf Date: Tue, 29 Sep 2026 22:48:24 +0200 Subject: [PATCH 3/3] test: pin yaft-conformance 5.0.0 5.0.0 adds R33, which this port already follows: a key or value that is no string is not set. Claude-Session: https://claude.ai/code/session_01SDYfZZQsaZpLvSwDgHYcr7 --- conformance.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conformance.lock b/conformance.lock index 9c25cf5..85a787e 100644 --- a/conformance.lock +++ b/conformance.lock @@ -1,2 +1,2 @@ -version=v4.0.0 -sha256=8e9dd45e897d0527b28f29a31d35736e20b4429b7280d21e5755aeaaaf4bf3c7 +version=v5.0.0 +sha256=47b6adaac579aa7963f1fe1679676445c09b7f74c6812136ee35eeeeb4a7f3bc