From 5d40e215cdcc111cd9cd5d38d52956d412236c57 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Wed, 17 Jun 2026 14:57:22 +0530 Subject: [PATCH 1/3] feat(sdk): add opt-in server-side response caching --- .changeset/server-side-caching.md | 6 + pnpm-lock.yaml | 10 ++ sdks/python/README.md | 23 +++ sdks/python/examples/async_server.py | 1 + sdks/python/examples/server.py | 1 + sdks/python/pyproject.toml | 2 +- sdks/python/src/flagship/provider.py | 61 ++++++- sdks/python/tests/test_provider_cache.py | 114 ++++++++++++ sdks/python/uv.lock | 11 ++ sdks/typescript/API.md | 38 +++- sdks/typescript/README.md | 16 ++ sdks/typescript/examples/cloudflare-worker.ts | 1 + sdks/typescript/examples/server.ts | 1 + sdks/typescript/package.json | 3 + sdks/typescript/src/index.ts | 1 + sdks/typescript/src/server-provider.ts | 81 ++++++++- sdks/typescript/src/server.ts | 1 + sdks/typescript/src/types.ts | 28 ++- .../tests/server-provider-cache.test.ts | 165 ++++++++++++++++++ 19 files changed, 550 insertions(+), 14 deletions(-) create mode 100644 .changeset/server-side-caching.md create mode 100644 sdks/python/tests/test_provider_cache.py create mode 100644 sdks/typescript/tests/server-provider-cache.test.ts diff --git a/.changeset/server-side-caching.md b/.changeset/server-side-caching.md new file mode 100644 index 0000000..0a51a07 --- /dev/null +++ b/.changeset/server-side-caching.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/flagship": minor +"@cloudflare/flagship-python": minor +--- + +Add opt-in server-side response caching to the server providers. Set `cacheTtl` (TypeScript) or `cache_ttl` (Python) to enable a TTL + LRU cache keyed by flag key, type, and evaluation context. Cache hits resolve with reason `CACHED`; disabled flags and errors are never cached. Caching is off by default. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba623a..6fbcea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,10 @@ importers: sdks/python: {} sdks/typescript: + dependencies: + lru-cache: + specifier: ^11.5.1 + version: 11.5.1 devDependencies: '@cloudflare/workers-types': specifier: ^4.20260617.1 @@ -1355,6 +1359,10 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2940,6 +2948,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + lru-cache@11.5.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/sdks/python/README.md b/sdks/python/README.md index ac2616c..d49c093 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -93,6 +93,10 @@ FlagshipServerProvider( retries=1, # retry attempts on transient errors, capped at 10 (default: 1) retry_delay=1.0, # seconds between retries, capped at 30.0 (default: 1.0) logging=False, # set True to enable SDK debug output (default: False) + + # Response caching — opt-in, off by default (see "Caching") + # cache_ttl=30.0, # seconds; enables caching when set + # cache_max_size=1000, # max cached entries, LRU-evicted (default: 1000) ) ``` @@ -108,6 +112,25 @@ FlagshipServerProvider( | `retries` | `int` | `1` | Retry attempts on transient errors; capped at `10` | | `retry_delay` | `float` | `1.0` | Delay between retries in seconds; capped at `30.0` | | `logging` | `bool` | `False` | Enable SDK-level debug output via the `flagship` logger | +| `cache_ttl` | `float` | — | Cache TTL in seconds; enables caching when set | +| `cache_max_size` | `int` | `1000` | Maximum cached entries; least-recently-used is evicted | + +## Caching + +The provider can cache evaluations to avoid a network round-trip for repeated flag/context pairs. Caching is **off by default** and enabled by setting `cache_ttl` (seconds): + +```python +FlagshipServerProvider( + app_id="your-app-id", + account_id="your-account-id", + cache_ttl=30.0, # cached values may be up to 30s stale + cache_max_size=1000, # LRU eviction beyond this many entries +) +``` + +Each entry is keyed by flag key, type, and the **full evaluation context**, so distinct contexts never share a value. Cache hits resolve with `reason == Reason.CACHED`. Disabled flags and errors are never cached. Because freshness is TTL-based, a flag change in Flagship takes effect after the entry expires. + +The cache is shared by the sync and async APIs and guarded by a lock for thread-safe sync use. ## Evaluation context diff --git a/sdks/python/examples/async_server.py b/sdks/python/examples/async_server.py index 3f71521..e499562 100644 --- a/sdks/python/examples/async_server.py +++ b/sdks/python/examples/async_server.py @@ -25,6 +25,7 @@ async def main() -> None: auth_token="your-token", timeout=5.0, retries=1, + cache_ttl=30.0, # cache evaluations per context for 30s (off by default) ) ) diff --git a/sdks/python/examples/server.py b/sdks/python/examples/server.py index 4817db8..82d5ef9 100644 --- a/sdks/python/examples/server.py +++ b/sdks/python/examples/server.py @@ -24,6 +24,7 @@ def main() -> None: timeout=5.0, retries=1, logging=True, + cache_ttl=30.0, # cache evaluations per context for 30s (off by default) ) ) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 39a56ee..d0fc5ae 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE"] requires-python = ">=3.10" -dependencies = ["httpx>=0.28.1", "openfeature-sdk>=0.9.0"] +dependencies = ["cachetools>=5.0.0", "httpx>=0.28.1", "openfeature-sdk>=0.9.0"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", diff --git a/sdks/python/src/flagship/provider.py b/sdks/python/src/flagship/provider.py index a7d4f89..ab4e899 100644 --- a/sdks/python/src/flagship/provider.py +++ b/sdks/python/src/flagship/provider.py @@ -1,7 +1,10 @@ import logging -from collections.abc import Callable, Mapping, Sequence +import threading +from collections.abc import Callable, Hashable, Mapping, Sequence +from dataclasses import replace from typing import Any +from cachetools import TTLCache from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( GeneralError, @@ -17,6 +20,7 @@ from openfeature.provider import AbstractProvider, Metadata from .client import FLAGSHIP_DEFAULT_BASE_URL, FlagshipClient +from .context import context_to_query_params __all__ = ["FlagshipServerProvider"] @@ -47,6 +51,10 @@ class FlagshipServerProvider(AbstractProvider): Set ``logging=True`` to enable SDK-level debug output. When ``False`` (the default) the SDK produces no log output of its own. + + Set ``cache_ttl`` (seconds) to enable an opt-in TTL + LRU response cache, + keyed by flag key, type, and evaluation context. Caching is disabled by + default; cached values may be up to ``cache_ttl`` stale. """ def __init__( @@ -62,6 +70,8 @@ def __init__( retries: int = 1, retry_delay: float = 1.0, logging: bool = False, + cache_ttl: float | None = None, + cache_max_size: int = 1000, ) -> None: self._client = FlagshipClient( app_id=app_id, @@ -75,6 +85,10 @@ def __init__( retry_delay=retry_delay, ) self._logging = logging + self._cache: TTLCache[Hashable, FlagResolutionDetails[Any]] | None = ( + TTLCache(maxsize=cache_max_size, ttl=cache_ttl) if cache_ttl is not None and cache_ttl > 0 else None + ) + self._cache_lock = threading.Lock() def get_metadata(self) -> Metadata: return Metadata(name="Flagship Server Provider") @@ -83,9 +97,11 @@ def get_provider_hooks(self) -> list[Hook]: return [] def shutdown(self) -> None: + self._clear_cache() self._client.close() async def shutdown_async(self) -> None: + self._clear_cache() await self._client.aclose() def resolve_boolean_details( @@ -176,6 +192,11 @@ def _resolve( evaluation_context: EvaluationContext | None, ) -> FlagResolutionDetails[Any]: self._log_debug("[Flagship] Evaluating flag %r", flag_key) + key = self._cache_key(flag_type, flag_key, evaluation_context) + if key is not None: + cached = self._cache_get(key) + if cached is not None: + return cached result = self._client.evaluate(flag_key, evaluation_context) details = _build_details(flag_type, default_value, result) self._log_debug( @@ -185,6 +206,8 @@ def _resolve( details.reason, details.variant, ) + if key is not None and result.reason != "DISABLED": + self._cache_store(key, details) return details async def _resolve_async( @@ -195,6 +218,11 @@ async def _resolve_async( evaluation_context: EvaluationContext | None, ) -> FlagResolutionDetails[Any]: self._log_debug("[Flagship] Evaluating flag %r", flag_key) + key = self._cache_key(flag_type, flag_key, evaluation_context) + if key is not None: + cached = self._cache_get(key) + if cached is not None: + return cached result = await self._client.evaluate_async(flag_key, evaluation_context) details = _build_details(flag_type, default_value, result) self._log_debug( @@ -204,8 +232,39 @@ async def _resolve_async( details.reason, details.variant, ) + if key is not None and result.reason != "DISABLED": + self._cache_store(key, details) return details + def _cache_key( + self, + flag_type: FlagType, + flag_key: str, + evaluation_context: EvaluationContext | None, + ) -> Hashable | None: + if self._cache is None: + return None + params = context_to_query_params(evaluation_context) + return (flag_key, flag_type, frozenset(params.items())) + + def _cache_get(self, key: Hashable) -> FlagResolutionDetails[Any] | None: + assert self._cache is not None + with self._cache_lock: + cached = self._cache.get(key) + if cached is None: + return None + return replace(cached, reason=Reason.CACHED) + + def _cache_store(self, key: Hashable, details: FlagResolutionDetails[Any]) -> None: + assert self._cache is not None + with self._cache_lock: + self._cache[key] = details + + def _clear_cache(self) -> None: + if self._cache is not None: + with self._cache_lock: + self._cache.clear() + def _log_debug(self, msg: str, *args: Any) -> None: if self._logging: _logger.debug(msg, *args) diff --git a/sdks/python/tests/test_provider_cache.py b/sdks/python/tests/test_provider_cache.py new file mode 100644 index 0000000..80f5243 --- /dev/null +++ b/sdks/python/tests/test_provider_cache.py @@ -0,0 +1,114 @@ +import time + +import httpx +import pytest +import respx +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import FlagNotFoundError +from openfeature.flag_evaluation import Reason + +from flagship import FlagshipServerProvider + +ENDPOINT_REGEX = r".*/evaluate.*" + + +def _resp(value: object, *, reason: str = "TARGETING_MATCH", variant: str = "on") -> httpx.Response: + return httpx.Response(200, json={"flagKey": "k", "value": value, "variant": variant, "reason": reason}) + + +def _provider(**kwargs: object) -> FlagshipServerProvider: + return FlagshipServerProvider(app_id="app-123", account_id="acct-456", **kwargs) # type: ignore[arg-type] + + +def _ctx(user: str) -> EvaluationContext: + return EvaluationContext(targeting_key=user) + + +@respx.mock +def test_caching_disabled_by_default() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider() + provider.resolve_boolean_details("k", False, _ctx("u1")) + provider.resolve_boolean_details("k", False, _ctx("u1")) + assert route.call_count == 2 + + +@respx.mock +def test_cache_hit_serves_without_request_and_marks_cached() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider(cache_ttl=60) + first = provider.resolve_boolean_details("k", False, _ctx("u1")) + second = provider.resolve_boolean_details("k", False, _ctx("u1")) + assert route.call_count == 1 + assert first.reason == Reason.TARGETING_MATCH + assert second.value is True + assert second.variant == "on" + assert second.reason == Reason.CACHED + + +@respx.mock +def test_separate_entry_per_context() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider(cache_ttl=60) + provider.resolve_boolean_details("k", False, _ctx("u1")) + provider.resolve_boolean_details("k", False, _ctx("u2")) + assert route.call_count == 2 + + +@respx.mock +def test_cache_expires_after_ttl() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider(cache_ttl=0.05) + provider.resolve_boolean_details("k", False, _ctx("u1")) + time.sleep(0.1) + provider.resolve_boolean_details("k", False, _ctx("u1")) + assert route.call_count == 2 + + +@respx.mock +def test_disabled_results_are_not_cached() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True, reason="DISABLED", variant="")) + provider = _provider(cache_ttl=60) + provider.resolve_boolean_details("k", False, _ctx("u1")) + provider.resolve_boolean_details("k", False, _ctx("u1")) + assert route.call_count == 2 + + +@respx.mock +def test_errors_are_not_cached() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=httpx.Response(404)) + provider = _provider(cache_ttl=60) + for _ in range(2): + with pytest.raises(FlagNotFoundError): + provider.resolve_boolean_details("k", False, _ctx("u1")) + assert route.call_count == 2 + + +@respx.mock +def test_evicts_least_recently_used_beyond_max_size() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider(cache_ttl=60, cache_max_size=1) + provider.resolve_boolean_details("k", False, _ctx("u1")) + provider.resolve_boolean_details("k", False, _ctx("u2")) + provider.resolve_boolean_details("k", False, _ctx("u1")) + assert route.call_count == 3 + + +@respx.mock +def test_shutdown_clears_cache() -> None: + respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider(cache_ttl=60) + provider.resolve_boolean_details("k", False, _ctx("u1")) + assert provider._cache is not None and len(provider._cache) == 1 # type: ignore[attr-defined] + provider.shutdown() + assert provider._cache is not None and len(provider._cache) == 0 # type: ignore[attr-defined] + + +@respx.mock +async def test_async_cache_hit() -> None: + route = respx.get(url__regex=ENDPOINT_REGEX).mock(return_value=_resp(True)) + provider = _provider(cache_ttl=60) + await provider.resolve_boolean_details_async("k", False, _ctx("u1")) + second = await provider.resolve_boolean_details_async("k", False, _ctx("u1")) + assert route.call_count == 1 + assert second.reason == Reason.CACHED diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 816af72..3d4e74b 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -25,6 +25,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -39,6 +48,7 @@ name = "cloudflare-flagship" version = "0.3.1" source = { editable = "." } dependencies = [ + { name = "cachetools" }, { name = "httpx" }, { name = "openfeature-sdk" }, ] @@ -55,6 +65,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=5.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "openfeature-sdk", specifier = ">=0.9.0" }, ] diff --git a/sdks/typescript/API.md b/sdks/typescript/API.md index a4a3403..5325029 100644 --- a/sdks/typescript/API.md +++ b/sdks/typescript/API.md @@ -120,6 +120,10 @@ new FlagshipServerProvider({ // Logging — controls logs emitted by the Flagship SDK itself (default: false) logging: false, + + // Caching — opt-in, off by default. See "Caching" below. + cacheTtl: 30000, // ms; enables the cache when > 0 + cacheMaxSize: 1000, // max cached entries (default: 1000) }); ``` @@ -151,11 +155,42 @@ new FlagshipServerProvider({ timeout: 5000, // request timeout in ms (default: 5000) retries: 1, // retry attempts on transient errors (default: 1, max: 10) retryDelay: 1000, // delay between retries in ms (default: 1000, max: 30000) + + // Caching — opt-in, off by default. See "Caching" below. + cacheTtl: 30000, // ms; enables the cache when > 0 + cacheMaxSize: 1000, // max cached entries (default: 1000) }); ``` 404 and 400 responses are never retried. Only transient server errors (5xx) and network failures trigger the retry logic. +### Caching + +The server provider can cache evaluations in both HTTP and binding modes. Caching is **off by default** and enabled by setting `cacheTtl` to a positive number of milliseconds. + +```typescript +new FlagshipServerProvider({ + appId: 'your-app-id', + accountId: 'your-account-id', + cacheTtl: 30000, + cacheMaxSize: 1000, +}); +``` + +| Option | Type | Default | Description | +| -------------- | -------- | ------- | ----------------------------------------------------------------- | +| `cacheTtl` | `number` | — | Time-to-live per entry in ms. Enables caching when `> 0`. | +| `cacheMaxSize` | `number` | `1000` | Maximum cached entries; the least-recently-used entry is evicted. | + +| Situation | `reason` | Cached? | +| --------------------------------------------- | ---------------------------------- | ----------------- | +| Successful evaluation served from API/binding | original (`TARGETING_MATCH`, etc.) | yes | +| Same flag + context within the TTL | `CACHED` | served from cache | +| Disabled flag | `DISABLED` | no | +| Any error (not found, timeout, etc.) | `ERROR` | no | + +Each entry is keyed by flag key, expected type, and the **full evaluation context**, so distinct contexts never share a cached value. Because freshness is TTL-based, a flag change in Flagship takes effect once the entry expires (up to `cacheTtl` later). The cache is per-provider-instance and is cleared on `onClose()`. + ### Cloudflare Workers example (binding) ```typescript @@ -420,7 +455,7 @@ Each sub-path re-exports core utilities alongside its provider-specific classes. - `FlagshipErrorCode` — enum: `NETWORK_ERROR`, `TIMEOUT_ERROR`, `PARSE_ERROR`, `INVALID_CONTEXT` - `isBindingOptions()` — type guard for binding options - `FLAGSHIP_DEFAULT_BASE_URL` — default base URL constant -- Types: `FlagshipProviderOptions`, `FlagshipClientProviderOptions`, `FlagshipEvaluationResponse`, `CachedFlag`, `FlagshipBinding`, `FlagshipBindingEvaluationDetails`, `FlagshipBindingProviderOptions`, `FlagshipServerProviderOptions` +- Types: `FlagshipProviderOptions`, `FlagshipClientProviderOptions`, `FlagshipEvaluationResponse`, `CachedFlag`, `FlagshipBinding`, `FlagshipBindingEvaluationDetails`, `FlagshipBindingProviderOptions`, `FlagshipServerProviderOptions`, `FlagshipCacheOptions` **`@cloudflare/flagship/server`** (core value exports + server-relevant types, plus): @@ -438,6 +473,7 @@ Each sub-path re-exports core utilities alongside its provider-specific classes. ``` @cloudflare/flagship/server FlagshipServerProvider — OpenFeature Provider interface (server) + ├─ TTL + LRU cache (opt-in) — keyed by flag key, type, and context ├─ Binding mode (Workers) — delegates to env.FLAGS via RPC │ EvaluationContext → Record └─ HTTP mode (Node.js, etc.) — delegates to FlagshipClient diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 66c7627..87a1753 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -96,6 +96,21 @@ export default { }; ``` +## Caching + +The server provider can cache evaluations to avoid a network round-trip (HTTP mode) or binding call (binding mode) for repeated flag/context pairs. Caching is **off by default** and enabled by setting `cacheTtl`: + +```typescript +new FlagshipServerProvider({ + appId: 'your-app-id', + accountId: 'your-account-id', + cacheTtl: 30_000, // ms — enables caching; values may be up to this stale + cacheMaxSize: 1000, // max cached entries, LRU-evicted (default: 1000) +}); +``` + +Each entry is keyed by flag key, type, and the **full evaluation context**, so distinct contexts never share a value. Cache hits resolve with `reason: 'CACHED'`. Disabled flags and errors are never cached. Because freshness is TTL-based, a flag change in Flagship takes effect after the entry expires. + ## Quick start — browser ```typescript @@ -128,6 +143,7 @@ const darkMode = client.getBooleanValue('dark-mode', false); | All flag types | Boolean, string, number, and object (JSON) | | Authentication | `authToken` option adds `Authorization: Bearer` to every request (HTTP only) | | Logging | `logging` option surfaces fetch errors and cache misses (off by default) | +| Response caching | Opt-in per-context TTL + LRU cache via `cacheTtl` (off by default) | | Retries + timeouts | Configurable retry logic with `AbortController`-based timeouts (HTTP only) | | Hooks | Built-in `LoggingHook` and `TelemetryHook` | | Tree-shakeable | Server and client bundles are fully isolated | diff --git a/sdks/typescript/examples/cloudflare-worker.ts b/sdks/typescript/examples/cloudflare-worker.ts index f7fda5e..6a65184 100644 --- a/sdks/typescript/examples/cloudflare-worker.ts +++ b/sdks/typescript/examples/cloudflare-worker.ts @@ -19,6 +19,7 @@ async function initializeOpenFeature() { new FlagshipServerProvider({ appId: FLAGSHIP_APP_ID, accountId: FLAGSHIP_ACCOUNT_ID, + cacheTtl: 30_000, // reuse evaluations per context across requests in a warm isolate }), ); isInitialized = true; diff --git a/sdks/typescript/examples/server.ts b/sdks/typescript/examples/server.ts index 89ce85b..38fb8cb 100644 --- a/sdks/typescript/examples/server.ts +++ b/sdks/typescript/examples/server.ts @@ -19,6 +19,7 @@ async function main() { accountId: FLAGSHIP_ACCOUNT_ID, timeout: 5000, retries: 1, + cacheTtl: 30_000, // cache evaluations per context for 30s (off by default) }), ); diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 17c30b3..6f52d25 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -97,5 +97,8 @@ "tsx": "^4.22.4", "typescript": "^5.9.3", "vitest": "^4.1.9" + }, + "dependencies": { + "lru-cache": "^11.5.1" } } diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 34fe012..ba731b2 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -22,6 +22,7 @@ export type { FlagshipBindingEvaluationDetails, FlagshipBindingProviderOptions, FlagshipServerProviderOptions, + FlagshipCacheOptions, } from './types.js'; export { FlagshipError, FlagshipErrorCode, FLAGSHIP_DEFAULT_BASE_URL, isBindingOptions } from './types.js'; diff --git a/sdks/typescript/src/server-provider.ts b/sdks/typescript/src/server-provider.ts index 99256e8..38f0294 100644 --- a/sdks/typescript/src/server-provider.ts +++ b/sdks/typescript/src/server-provider.ts @@ -1,5 +1,6 @@ import type { Provider, ResolutionDetails, EvaluationContext, JsonValue, ProviderMetadata, Logger } from '@openfeature/server-sdk'; import { ErrorCode, OpenFeatureEventEmitter } from '@openfeature/server-sdk'; +import { LRUCache } from 'lru-cache'; import { FlagshipClient } from './client.js'; import { FlagshipError, @@ -10,6 +11,9 @@ import { type FlagshipServerProviderOptions, } from './types.js'; +const DEFAULT_CACHE_MAX_SIZE = 1000; +type ExpectedType = 'boolean' | 'string' | 'number' | 'object'; + // Shared no-op used to build a silent logger when logging is false. const _noop = (): void => {}; @@ -80,11 +84,14 @@ export class FlagshipServerProvider implements Provider { private readonly binding: FlagshipBinding | undefined; private readonly logging: boolean; + /** TTL + LRU response cache; `undefined` when caching is disabled. */ + private readonly cache: LRUCache> | undefined; + private readonly resolve: ( flagKey: string, defaultValue: T, context: EvaluationContext, - expectedType: 'boolean' | 'string' | 'number' | 'object', + expectedType: ExpectedType, logger: Logger, ) => Promise>; @@ -92,6 +99,10 @@ export class FlagshipServerProvider implements Provider { this.metadata = { name: 'Flagship Server Provider' }; this.logging = options.logging ?? false; + if (options.cacheTtl !== undefined && options.cacheTtl > 0) { + this.cache = new LRUCache({ max: options.cacheMaxSize ?? DEFAULT_CACHE_MAX_SIZE, ttl: options.cacheTtl }); + } + if (isBindingOptions(options)) { // Validate that no HTTP-specific fields are present alongside `binding`. const conflicts = HTTP_ONLY_FIELDS.filter((key) => key in options); @@ -121,13 +132,17 @@ export class FlagshipServerProvider implements Provider { return { debug: _noop, info: _noop, warn: _noop, error: _noop }; } + async onClose(): Promise { + this.cache?.clear(); + } + async resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, context: EvaluationContext, logger: Logger, ): Promise> { - return this.resolve(flagKey, defaultValue, context, 'boolean', logger); + return this.resolveCached(flagKey, defaultValue, context, 'boolean', logger); } async resolveStringEvaluation( @@ -136,7 +151,7 @@ export class FlagshipServerProvider implements Provider { context: EvaluationContext, logger: Logger, ): Promise> { - return this.resolve(flagKey, defaultValue, context, 'string', logger); + return this.resolveCached(flagKey, defaultValue, context, 'string', logger); } async resolveNumberEvaluation( @@ -145,7 +160,7 @@ export class FlagshipServerProvider implements Provider { context: EvaluationContext, logger: Logger, ): Promise> { - return this.resolve(flagKey, defaultValue, context, 'number', logger); + return this.resolveCached(flagKey, defaultValue, context, 'number', logger); } async resolveObjectEvaluation( @@ -154,7 +169,35 @@ export class FlagshipServerProvider implements Provider { context: EvaluationContext, logger: Logger, ): Promise> { - return this.resolve(flagKey, defaultValue, context, 'object', logger); + return this.resolveCached(flagKey, defaultValue, context, 'object', logger); + } + + // --------------------------------------------------------------------------- + // Caching + // --------------------------------------------------------------------------- + + private async resolveCached( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + expectedType: ExpectedType, + logger: Logger, + ): Promise> { + if (!this.cache) { + return this.resolve(flagKey, defaultValue, context, expectedType, logger); + } + + const key = buildCacheKey(flagKey, expectedType, context); + const cached = this.cache.get(key) as ResolutionDetails | undefined; + if (cached) { + return { ...cached, reason: 'CACHED' }; + } + + const result = await this.resolve(flagKey, defaultValue, context, expectedType, logger); + if (isCacheable(result)) { + this.cache.set(key, result as ResolutionDetails); + } + return result; } // --------------------------------------------------------------------------- @@ -165,7 +208,7 @@ export class FlagshipServerProvider implements Provider { flagKey: string, defaultValue: T, context: EvaluationContext, - expectedType: 'boolean' | 'string' | 'number' | 'object', + expectedType: ExpectedType, logger: Logger, ): Promise> { const log = this.logger(logger); @@ -236,7 +279,7 @@ export class FlagshipServerProvider implements Provider { flagKey: string, defaultValue: T, context: EvaluationContext, - expectedType: 'boolean' | 'string' | 'number' | 'object', + expectedType: ExpectedType, logger: Logger, ): Promise> { const log = this.logger(logger); @@ -290,7 +333,7 @@ export class FlagshipServerProvider implements Provider { private async evaluateBinding( flagKey: string, defaultValue: T, - expectedType: 'boolean' | 'string' | 'number' | 'object', + expectedType: ExpectedType, context: Record, ): Promise> { const binding = this.binding!; @@ -325,13 +368,33 @@ export class FlagshipServerProvider implements Provider { * `null` maps to `'object'` (typeof null === 'object'), treating it as a * JSON null which belongs to the object/structure category. */ -function getValueType(value: unknown): 'boolean' | 'string' | 'number' | 'object' { +function getValueType(value: unknown): ExpectedType { if (typeof value === 'boolean') return 'boolean'; if (typeof value === 'string') return 'string'; if (typeof value === 'number') return 'number'; return 'object'; } +/** A resolution is cacheable only when it succeeded and isn't a disabled flag. */ +function isCacheable(details: ResolutionDetails): boolean { + return details.errorCode === undefined && details.reason !== 'DISABLED'; +} + +/** Stable cache key over flag key, expected type, and the evaluation context. */ +function buildCacheKey(flagKey: string, expectedType: ExpectedType, context: EvaluationContext): string { + const entries = Object.entries(context) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]): [string, string] => [key, serializeContextValue(value)]) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return JSON.stringify([flagKey, expectedType, entries]); +} + +function serializeContextValue(value: unknown): string { + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + /** * Converts an OpenFeature `EvaluationContext` to the flat primitive map that * the Flagship binding expects. diff --git a/sdks/typescript/src/server.ts b/sdks/typescript/src/server.ts index c68bb81..12ba6b0 100644 --- a/sdks/typescript/src/server.ts +++ b/sdks/typescript/src/server.ts @@ -50,6 +50,7 @@ export type { FlagshipBindingEvaluationDetails, FlagshipBindingProviderOptions, FlagshipServerProviderOptions, + FlagshipCacheOptions, } from './index.js'; // Export server provider diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index ddc046c..e2796dc 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -202,14 +202,38 @@ export interface FlagshipBindingProviderOptions { logging?: boolean; } +/** + * Opt-in server-side response cache options. Caching is disabled unless + * `cacheTtl` is set to a positive value. A cache entry is keyed by flag key, + * expected type, and the full evaluation context, so distinct contexts never + * share a cached value. Eviction is TTL-based with an LRU bound on size. + */ +export interface FlagshipCacheOptions { + /** + * Time-to-live for cached evaluations, in milliseconds. Enables caching when + * greater than `0`. Cached values may be up to this stale before a fresh + * evaluation is performed. + * @default undefined (caching disabled) + */ + cacheTtl?: number; + + /** + * Maximum number of cached entries. The least-recently-used entry is evicted + * once the limit is exceeded. Only used when `cacheTtl` is set. + * @default 1000 + */ + cacheMaxSize?: number; +} + /** * Options accepted by `FlagshipServerProvider`. * * Provide **either** HTTP configuration (`appId`/`endpoint` + credentials) **or** * a wrangler `binding` — never both. The provider detects which mode to use - * based on the presence of the `binding` field. + * based on the presence of the `binding` field. Caching options apply to both + * modes. */ -export type FlagshipServerProviderOptions = FlagshipProviderOptions | FlagshipBindingProviderOptions; +export type FlagshipServerProviderOptions = (FlagshipProviderOptions | FlagshipBindingProviderOptions) & FlagshipCacheOptions; /** * Type guard: returns `true` when the options use binding mode. diff --git a/sdks/typescript/tests/server-provider-cache.test.ts b/sdks/typescript/tests/server-provider-cache.test.ts new file mode 100644 index 0000000..6cac94d --- /dev/null +++ b/sdks/typescript/tests/server-provider-cache.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Logger } from '@openfeature/server-sdk'; +import { FlagshipServerProvider } from '../src/server-provider.js'; +import type { FlagshipBinding, FlagshipBindingEvaluationDetails } from '../src/types.js'; + +global.fetch = vi.fn(); + +const noopLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function mockResponse(value: unknown, reason = 'TARGETING_MATCH', variant = 'on'): void { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ flagKey: 'flag', value, variant, reason }), + }); +} + +function createMockBinding(): FlagshipBinding { + return { + get: vi.fn((_flagKey: string, defaultValue?: unknown) => Promise.resolve(defaultValue)), + getBooleanValue: vi.fn((_flagKey: string, defaultValue: boolean) => Promise.resolve(defaultValue)), + getStringValue: vi.fn((_flagKey: string, defaultValue: string) => Promise.resolve(defaultValue)), + getNumberValue: vi.fn((_flagKey: string, defaultValue: number) => Promise.resolve(defaultValue)), + getObjectValue: vi.fn((_flagKey: string, defaultValue: T) => Promise.resolve(defaultValue)), + getBooleanDetails: vi.fn( + (flagKey: string): Promise> => + Promise.resolve({ flagKey, value: true, variant: 'on', reason: 'TARGETING_MATCH' }), + ), + getStringDetails: vi.fn( + (flagKey: string, defaultValue: string): Promise> => + Promise.resolve({ flagKey, value: defaultValue, reason: 'DEFAULT' }), + ), + getNumberDetails: vi.fn( + (flagKey: string, defaultValue: number): Promise> => + Promise.resolve({ flagKey, value: defaultValue, reason: 'DEFAULT' }), + ), + getObjectDetails: vi.fn( + (flagKey: string, defaultValue: T): Promise> => + Promise.resolve({ flagKey, value: defaultValue, reason: 'DEFAULT' }), + ), + }; +} + +describe('FlagshipServerProvider caching', () => { + beforeEach(() => { + (global.fetch as any).mockReset(); + }); + + it('does not cache when cacheTtl is unset (default)', async () => { + mockResponse(true); + mockResponse(true); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate' }); + + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('serves a cache hit without a second fetch and marks it CACHED', async () => { + mockResponse(true, 'TARGETING_MATCH', 'on'); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate', cacheTtl: 60_000 }); + + const first = await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + const second = await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(first.reason).toBe('TARGETING_MATCH'); + expect(second.value).toBe(true); + expect(second.variant).toBe('on'); + expect(second.reason).toBe('CACHED'); + }); + + it('keeps a separate entry per evaluation context', async () => { + mockResponse(true); + mockResponse(false); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate', cacheTtl: 60_000 }); + + const a = await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + const b = await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u2' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(a.value).toBe(true); + expect(b.value).toBe(false); + }); + + it('re-fetches after the TTL expires', async () => { + mockResponse(true); + mockResponse(true); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate', cacheTtl: 20 }); + + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + await new Promise((resolve) => setTimeout(resolve, 40)); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('evicts least-recently-used entries beyond cacheMaxSize', async () => { + mockResponse(true); // u1 + mockResponse(true); // u2 + mockResponse(true); // u1 again (evicted) + const provider = new FlagshipServerProvider({ + endpoint: 'https://api.example.com/evaluate', + cacheTtl: 60_000, + cacheMaxSize: 1, + }); + + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u2' }, noopLogger); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + it('does not cache DISABLED results', async () => { + mockResponse(true, 'DISABLED'); + mockResponse(true, 'DISABLED'); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate', cacheTtl: 60_000 }); + + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('does not cache error results', async () => { + (global.fetch as any).mockResolvedValue(new Response(null, { status: 404 })); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate', cacheTtl: 60_000 }); + + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('clears the cache on close', async () => { + mockResponse(true); + mockResponse(true); + const provider = new FlagshipServerProvider({ endpoint: 'https://api.example.com/evaluate', cacheTtl: 60_000 }); + + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + await provider.onClose(); + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('caches binding-mode evaluations', async () => { + const binding = createMockBinding(); + const provider = new FlagshipServerProvider({ binding, cacheTtl: 60_000 }); + + const first = await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + const second = await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'u1' }, noopLogger); + + expect(binding.getBooleanDetails).toHaveBeenCalledTimes(1); + expect(first.reason).toBe('TARGETING_MATCH'); + expect(second.value).toBe(true); + expect(second.reason).toBe('CACHED'); + }); +}); From 1bf793ef58f78a13f7b54d8f68cb8f216a386eed Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Mon, 22 Jun 2026 13:43:01 +0530 Subject: [PATCH 2/3] fix(ts): sort nested object keys when building cache keys --- .github/workflows/bonk.yml | 2 +- .github/workflows/pull-request.yml | 16 ++++++++-------- .github/workflows/release.yml | 2 +- package.json | 4 +++- sdks/typescript/src/server-provider.ts | 9 +++++++-- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/workflows/bonk.yml b/.github/workflows/bonk.yml index 4f38c94..9413033 100644 --- a/.github/workflows/bonk.yml +++ b/.github/workflows/bonk.yml @@ -31,5 +31,5 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }} with: model: 'cloudflare-ai-gateway/anthropic/claude-opus-4-6' - opencode_version: "1.17.7" + opencode_version: '1.17.7' mentions: '/bonk,@ask-bonk' diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2d86447..571fb1d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 @@ -57,12 +57,12 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 - run: pnpm install --frozen-lockfile - - run: pnpm dlx sherif + - run: pnpm run sherif - run: pnpm run lint typescript-typecheck: @@ -73,7 +73,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 @@ -88,7 +88,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 @@ -103,7 +103,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 @@ -119,7 +119,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: node-version: 24 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dda2f90..4cf1e2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: "11" + version: '11' - uses: actions/setup-node@v6 with: diff --git a/package.json b/package.json index 2f1c8ef..fd3bc5d 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "build": "pnpm -r run build", "changeset:validate": "tsx .github/changeset-version.ts validate", "format": "oxfmt --write .", + "format:write": "oxfmt --write .", "format:check": "oxfmt --check .", + "sherif": "sherif -r root-package-manager-field", "lint": "oxlint .", "typecheck": "tsc --noEmit && pnpm -r run typecheck", - "check": "sherif && oxfmt --check . && oxlint . && pnpm run typecheck", + "check": "sherif -r root-package-manager-field && oxfmt --check . && oxlint . && pnpm run typecheck", "test": "pnpm -r run test", "prepare": "husky" }, diff --git a/sdks/typescript/src/server-provider.ts b/sdks/typescript/src/server-provider.ts index 38f0294..8d366a7 100644 --- a/sdks/typescript/src/server-provider.ts +++ b/sdks/typescript/src/server-provider.ts @@ -391,8 +391,13 @@ function buildCacheKey(flagKey: string, expectedType: ExpectedType, context: Eva function serializeContextValue(value: unknown): string { if (value instanceof Date) return value.toISOString(); - if (typeof value === 'object') return JSON.stringify(value); - return String(value); + if (typeof value !== 'object') return String(value); + // Sort object keys at every depth so semantically equal objects share a cache key. + return JSON.stringify(value, (_key, val) => + val !== null && typeof val === 'object' && !Array.isArray(val) + ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : val, + ); } /** From d071b9cbcdf3010c893fca8857b2bd8315d070ad Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Mon, 22 Jun 2026 14:10:09 +0530 Subject: [PATCH 3/3] chore: upgrde deps --- sdks/python/uv.lock | 88 ++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 3d4e74b..17f605a 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -36,11 +36,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] @@ -194,7 +194,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.0" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -205,9 +205,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +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/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, + { 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]] @@ -250,27 +250,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.17" +version = "0.15.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, - { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, - { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, - { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, - { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] [[package]] @@ -329,27 +329,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.49" +version = "0.0.51" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ce/352fcdba5c72ea20e5d2e46e28809cdb617575b71209d971eff2ace8e6c4/ty-0.0.51.tar.gz", hash = "sha256:b90172d46365bb9d51a7011cbb5c60cc4f514f42c86635df6c092b717f85e1ac", size = 5953151, upload-time = "2026-06-19T01:48:58.015Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" }, - { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" }, - { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" }, - { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" }, - { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" }, - { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" }, - { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" }, - { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" }, - { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" }, - { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8f/8fe7cab79a45320b2cdcd602f16d44c8108d2f418ff7ec316c6212f1f0cc/ty-0.0.51-py3-none-linux_armv6l.whl", hash = "sha256:947986bd82d324b3a5c58ce03f1dad160cdf36443d3e8f64b3484b861ba9bc64", size = 11884805, upload-time = "2026-06-19T01:48:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/56fdc39a3f44c0564fd157e1e59e1f9c3fc5ba57ae4472ded85c67c63d74/ty-0.0.51-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb", size = 11633593, upload-time = "2026-06-19T01:48:22.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/57/136e83f24fc04f5afdcabff42f40fa27eae5ac3f0e3f12627d072a55f679/ty-0.0.51-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2faed19a8f1505370de071c008df52a994fc03a204f3267c3a33a32ca26f854f", size = 11063076, upload-time = "2026-06-19T01:48:25.223Z" }, + { url = "https://files.pythonhosted.org/packages/32/f8/5d32f0df5692446440ab781b9b119aa3e0c0dbfa78c583fe9be8417d54fa/ty-0.0.51-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c", size = 11579542, upload-time = "2026-06-19T01:48:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/4f54ef338e9623886809ecd508931b0cd5b3aba1e591586a2f6aeaa8bd11/ty-0.0.51-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f", size = 11676189, upload-time = "2026-06-19T01:48:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/31729066f9b9d3596941edaf267894eefc0b30df4518f003dba5f7276258/ty-0.0.51-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd92913bc90d1705ef9391ff8c6822b61e2e827fa295eb30bf0dfabcf815645", size = 12188154, upload-time = "2026-06-19T01:48:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/2f/38/d4301aa12d2283c7130908baf1417a37dfe3e10f5669cb4ce2853c2540b4/ty-0.0.51-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:429a997394dac73870d71b87cc90efc54da3efaf319e72ca18aeef35a78aef90", size = 12780597, upload-time = "2026-06-19T01:48:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/4b2e67e53f126d39abe201bd2299e467e27463a284e965ad195cbc217fa0/ty-0.0.51-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62d94f06e8c317e89b6884f2bde443040e596b88c7c79bd944c84c105b06257a", size = 12491115, upload-time = "2026-06-19T01:48:36.169Z" }, + { url = "https://files.pythonhosted.org/packages/74/50/aabfe55c132ebe72b4d639cbf772d931e11b0990d29c1f691922b6ccabc1/ty-0.0.51-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff", size = 12230135, upload-time = "2026-06-19T01:48:38.445Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/9aa428052dbed91c50919cd080426a313cf20ce14c6bfe2b71345e548671/ty-0.0.51-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c1bd1355aee86af01e4e21b0bc16fc460fb05905761f0d8b8d70841de0feade8", size = 12468123, upload-time = "2026-06-19T01:48:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5a/f6ce69f2575259386c950c40e02578d0902760cb61f95045e9971182c24e/ty-0.0.51-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:79d1877e93460f936bc10ed1a31525702b7ce51075763ccba993be17f0b9e905", size = 11541672, upload-time = "2026-06-19T01:48:42.635Z" }, + { url = "https://files.pythonhosted.org/packages/35/3a/2af48924a683e959e95e5cc4dc88e5a8595206a0812b869032b95196f2b0/ty-0.0.51-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c", size = 11694015, upload-time = "2026-06-19T01:48:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/12/899875d8a60b198c8121cb92ce18e18cc072d23ca2130fcdaa176383ef72/ty-0.0.51-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bc7459348a253247bbfb2669a021e614281b86bbea24c36112b8a6e1a2499a16", size = 11832856, upload-time = "2026-06-19T01:48:47.028Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a2/88f681d826d97cc96ef9f6cadd4935f775758944cee07340aa46113bce28/ty-0.0.51-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49a21237f6fd1de56beaff0a3e85fe022a09a3401e67e3abec41ce838a5d4d2e", size = 12333449, upload-time = "2026-06-19T01:48:49.091Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/535a4163b4452c6978c31fedfd7b5803cf3a2253e9455cde350f86638d6a/ty-0.0.51-py3-none-win32.whl", hash = "sha256:61b4b6a003c3ebe53a63a1125c9b6542aa01bc1b6c9a235d01ee328d000d61a9", size = 11177338, upload-time = "2026-06-19T01:48:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4d/2334fbb74291a20129fa7aaa8f789619ec9b6883b27f997b8baa27e4674f/ty-0.0.51-py3-none-win_amd64.whl", hash = "sha256:608d417cd1eaf79bcbd713d9830d5e3db9d57ec225c3af3e4ac9a9ff66b45d70", size = 12325675, upload-time = "2026-06-19T01:48:53.774Z" }, + { url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" }, ] [[package]]