diff --git a/pyproject.toml b/pyproject.toml index 72ced27..c635d71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,22 @@ dependencies = [ "xxhash>=3.5.0", # HTTP client for SaaS backend (cachekit.io) "httpx[http2]>=0.28.1", + # anyio is transitive via the mandatory httpx dependency above, so it ships + # to EVERY install, not just dev. Declared here rather than as a + # [tool.uv] constraint because that table is uv-local: it never reaches + # requires-dist, so `pip install cachekit` would ignore it. 4.14.2 fixes + # GHSA-82r6-8w77-94w6 / CVE-2026-63374 (IDNA-2003 hostname encoding lets a + # hijacked connection to an internationalised domain pass TLS certificate + # validation; CVSS 9.3), GHSA-5p39-cfhj-2xmp / CVE-2026-64847 (undrained + # process-pool stderr pipe deadlocks the worker) and GHSA-3w57-8xmc-8v26 / + # CVE-2026-63349 (extra_groups ignored, parent supplementary groups kept). + "anyio>=4.14.2", + # h2 arrives via the http2 extra on that same mandatory httpx dependency, so + # it ships to every install too, and was declared as a [tool.uv] constraint + # with the same no-op effect. 4.4.1 fixes GHSA-6hr6-w5qg-qmwg (duplicate Host + # headers forwarded across an HTTP/2 -> HTTP/1.1 downgrade — a request + # smuggling primitive). + "h2>=4.4.1", ] [project.optional-dependencies] @@ -248,7 +264,11 @@ fuzz = [ "atheris>=2.3.0", ] -# Override vulnerable transitive dependencies +# Override vulnerable DEV-ONLY transitive dependencies. +# This table is uv-local: it constrains resolution of this repo's lockfile and +# never reaches requires-dist, so a floor placed here does NOT protect anyone who +# runs `pip install cachekit`. A transitive that reaches users belongs in +# [project] dependencies instead — see the anyio/h2 entries above. [tool.uv] constraint-dependencies = [ "urllib3>=2.7.0", @@ -259,8 +279,4 @@ constraint-dependencies = [ # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering); 26.2 fixes # PYSEC-2026-3721 (doubly-encoded index URLs install to arbitrary paths). "pip>=26.2", - # h2 is a transitive dep (httpx[http2] -> h2). 4.4.1 fixes - # GHSA-6hr6-w5qg-qmwg (duplicate Host headers forwarded on HTTP/2 -> - # HTTP/1.1 downgrade — request smuggling primitive). - "h2>=4.4.1", ] diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 91cc9ac..0b3e476 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -802,6 +802,64 @@ def _l1_backfill_from_l2(cache_key: str, cached_data: Any, is_stale: bool, fresh return _cached_keys.add(cache_key) + def _record_l2_hit_async(cached_data: Any, get_duration_ms: float) -> None: + """Record the telemetry for an async L2 hit — the uncontended read and + both post-lock double-check hits (LAB-3769) share this so a + thundering-herd hit is never invisible to cache_operations_total / + cache_info() just because it arrived via the lock's double-check. + + size_bytes is computed here rather than read from the handler's + size_bytes tuple slot (LAB-348), so this label never depends on the + handler's tuple shape; the two agree on every in-contract async hit. + + Best-effort, for the same reason _l1_backfill_from_l2 is (LAB-348): + every call site sits inside an `except Exception` that falls through to + a recompute, so a throwing metrics collector would silently turn a hit + already in hand into a full recompute — under exactly the stampede the + lock exists to absorb. Telemetry never costs a served hit. + + The two clauses are deliberately split rather than narrowed to the + collector's own error types. Narrowing does NOT fail fast here: an + unexpected raise would land in the caller's `except Exception`, which + logs at DEBUG under "Double-check cache failed after lock acquisition" + and recomputes — quieter than this, misattributed, and a recompute per + contended hit. So the unexpected case is caught too, and made loud + instead: ERROR with the exception type named, which is the signal a + narrow clause was meant to produce. + """ + try: + # Local stat first, external collector second: this is pure arithmetic + # under a lock and cannot realistically refuse, whereas the collector can + # — and once the hit is served anyway, a collector refusal must not leave + # cache_info() omitting a hit the caller was handed. Losing the counter to + # someone else's registry error is the same invisibility this helper exists + # to remove. + _stats.record_l2_hit(get_duration_ms) + features.set_operation_context("get", duration_ms=get_duration_ms) + features.record_success() + if features.collect_stats: + # Defensive encode for an out-of-contract backend: both async readers + # annotate this slot `bytes`, so the ternary is a guard, not a live path. + envelope = cached_data.encode("utf-8") if isinstance(cached_data, str) else cached_data + features.record_cache_operation( + operation="get", + namespace=namespace or "default", + serializer="rust", + success=True, + duration_ms=get_duration_ms, + size_bytes=len(envelope), + hit=True, + ) + except (ValueError, TypeError) as exc: + # The collector's documented refusals: duplicated timeseries, a label set + # that disagrees with the registered metric, a non-numeric observation. + logger().warning(f"L2 hit telemetry skipped: {redact_error_for_log(exc)}") + except Exception as exc: + # Not a collector refusal — a bug in the telemetry stack. Still must not + # cost the served hit, so surface it at ERROR with its type rather than + # letting the caller demote this hit into a recompute. + logger().error(f"L2 hit telemetry failed unexpectedly ({type(exc).__name__}): {redact_error_for_log(exc)}") + def _l2_swr_try_begin(cache_key: str) -> bool: """Claim a revalidation slot for this key; False = already in flight or at capacity. @@ -1740,23 +1798,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # Record cache hit (always compute for L2 latency stats) get_duration_ms = (time.perf_counter() - start_time) * 1000 - features.set_operation_context("get", duration_ms=get_duration_ms) - features.record_success() - - if features.collect_stats: - # size_bytes: the encoded envelope, matching the bytes _l1_backfill_from_l2 - # stores and the L1 site's len(l1_bytes). A str envelope is UTF-8 encoded - # first, so non-ASCII payloads report byte length, not character count. - _l2_envelope = cached_data.encode("utf-8") if isinstance(cached_data, str) else cached_data - features.record_cache_operation( - operation="get", - namespace=namespace or "default", - serializer="rust", - success=True, - duration_ms=get_duration_ms, - size_bytes=len(_l2_envelope), - hit=True, - ) + _record_l2_hit_async(cached_data, get_duration_ms) # Update L1 cache with the L2 value (serialized bytes) for subsequent # fast access — stale-exclusion + remaining-freshness bound (LAB-557). @@ -1778,9 +1820,6 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # the opted-in flag (LAB-446). Still degrades gracefully. warn_ttl_refresh_unsupported(_backend) - # Record L2 hit with latency for cache_info() - _stats.record_l2_hit(get_duration_ms) - # SWR: stale hit — value already in hand; revalidate in the # background so no request pays the recompute at a TTL boundary. # Gated on _l2_swr_active (not just the read gate above): a @@ -1834,10 +1873,13 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # Routed through the operation handler: corrupt entries evict (#159), # stale hits skip L1, fresh backfill bounded by fresh_for (LAB-557). try: + _dc_start = time.perf_counter() cached_result, _dc_stale, _dc_fresh_for = await _l2_double_check(cache_key) if cached_result is not None: # Another request filled the cache while we waited _found, result, cached_data, _size_bytes = cached_result + _dc_duration_ms = (time.perf_counter() - _dc_start) * 1000 + _record_l2_hit_async(cached_data, _dc_duration_ms) _l1_backfill_from_l2(cache_key, cached_data, _dc_stale, _dc_fresh_for) return result except DecryptionAuthenticationError: @@ -1861,10 +1903,13 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: try: # Routed through the operation handler: corrupt entries evict (#159), # stale hits skip L1, fresh backfill bounded by fresh_for (LAB-557). + _dc_start = time.perf_counter() cached_result, _dc_stale, _dc_fresh_for = await _l2_double_check(cache_key) if cached_result is not None: # Cache was populated while waiting - use it _found, result, cached_data, _size_bytes = cached_result + _dc_duration_ms = (time.perf_counter() - _dc_start) * 1000 + _record_l2_hit_async(cached_data, _dc_duration_ms) _l1_backfill_from_l2(cache_key, cached_data, _dc_stale, _dc_fresh_for) return result except DecryptionAuthenticationError: diff --git a/tests/unit/test_async_l2_double_check_hit_labels.py b/tests/unit/test_async_l2_double_check_hit_labels.py new file mode 100644 index 0000000..6245d2d --- /dev/null +++ b/tests/unit/test_async_l2_double_check_hit_labels.py @@ -0,0 +1,175 @@ +"""Async lock double-check L2 hit-record parity (LAB-3769). + +The uncontended async L2 hit site records get/serializer="rust"/hit=True telemetry +(LAB-3765); the two post-lock ``_l2_double_check`` hit returns did not — so a +thundering-herd hit, filled by another worker while this one waited on the +distributed lock, was invisible to ``cache_operations_total`` and ``cache_info()`` +L2 stats. That is exactly the traffic the lock exists to absorb. + +Reproduces contention by patching the backend's ``get`` to miss once (forcing the +wrapper past the pre-lock check into the lock path) then hit on the next call — +the double-check read standing in for "another request filled the cache while we +waited". Parametrised over the lock outcome so both hit returns are covered: the +lock-acquired branch and the lock-timeout branch, which record via the same +``_record_l2_hit_async`` helper but are reached by different control flow. + +``_LockableByteStore`` is defined locally rather than imported from +tests/unit/test_async_set_record_labels.py (cachekit-py#295): that file does not +exist on `main` yet. Hoist to a shared fixture once #295 merges. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +from cachekit import cache +from cachekit.decorators.orchestrator import FeatureOrchestrator + + +class _LockableByteStore: + """In-memory byte store implementing LockableBackend. + + ``lock_acquired`` selects which double-check hit return is exercised: True + takes the lock-acquired branch, False the lock-timeout branch. + """ + + def __init__(self, *, lock_acquired: bool = True) -> None: + self.store: dict[str, bytes] = {} + self._lock_acquired = lock_acquired + + def get(self, key: str) -> bytes | None: + return self.store.get(key) + + def set(self, key: str, value: bytes, ttl: int | None = None) -> None: + self.store[key] = value + + def delete(self, key: str) -> bool: + return self.store.pop(key, None) is not None + + def exists(self, key: str) -> bool: + return key in self.store + + def health_check(self) -> tuple[bool, dict[str, Any]]: + return True, {} + + @asynccontextmanager + async def acquire_lock(self, key: str, timeout: float, blocking_timeout: float | None = None) -> AsyncIterator[bool]: + yield self._lock_acquired + + +@pytest.fixture +def recorded(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """Capture the wrapper's explicit features.record_cache_operation(...) calls. + + Patched at the orchestrator, not the collector: record_success() also forwards + an operation-context record to the collector, which would shadow the labels + under test (same rationale as test_async_get_record_labels.py). + """ + calls: list[dict[str, Any]] = [] + monkeypatch.setattr(FeatureOrchestrator, "record_cache_operation", lambda self, **kw: calls.append(kw)) + return calls + + +@pytest.mark.unit +@pytest.mark.parametrize("lock_acquired", [True, False], ids=["lock-acquired", "lock-timeout"]) +async def test_async_l2_double_check_hit_records_get(recorded: list[dict[str, Any]], lock_acquired: bool) -> None: + backend = _LockableByteStore(lock_acquired=lock_acquired) + + @cache(backend=backend, ttl=60, namespace="async-dc-labels", l1_enabled=False) + async def compute() -> dict[str, int]: + return {"answer": 42} + + assert await compute() == {"answer": 42} # miss: primes L2 with the real envelope + assert backend.store + expected_size = len(next(iter(backend.store.values()))) + + # Make the pre-lock L2 check miss exactly once so the wrapper falls into the + # lock/double-check path; the primed value is still in the real store, so the + # double-check read inside the lock finds it — the contended-hit scenario. + real_get = backend.get + call_count = 0 + + def patched_get(key: str) -> bytes | None: + nonlocal call_count + call_count += 1 + return None if call_count == 1 else real_get(key) + + backend.get = patched_get # type: ignore[method-assign] + recorded.clear() + + assert await compute() == {"answer": 42} + assert call_count >= 2 # pre-lock miss, then the double-check hit + + gets = [c for c in recorded if c["operation"] == "get"] + assert len(gets) == 1 + assert (gets[0].get("serializer"), gets[0].get("hit")) == ("rust", True) + assert gets[0].get("size_bytes") == expected_size + # The double-check read is timed on its own window, so a duration is always + # recorded — a regression that drops it would still pass the label asserts. + assert isinstance(gets[0].get("duration_ms"), float) + assert gets[0]["duration_ms"] >= 0.0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "raised", + [ValueError("duplicated timeseries"), AttributeError("collector refactored away")], + ids=["collector-refusal", "unexpected-bug"], +) +async def test_throwing_collector_does_not_cost_the_served_hit(monkeypatch: pytest.MonkeyPatch, raised: Exception) -> None: + """A throwing metrics collector must never demote a contended hit into a recompute. + + Both double-check returns sit inside an `except Exception` that falls through to + executing the function again, so an unguarded telemetry call would turn the hit + this lock exists to protect into exactly the recompute it exists to prevent — + once per contender. Parametrised over both handler clauses in + `_record_l2_hit_async`: the collector's own refusal types, and an unexpected + error that is caught too (narrowing there would not fail fast, it would just + hand the raise to the caller's DEBUG-level handler and recompute anyway). + """ + backend = _LockableByteStore() + calls = 0 + + @cache(backend=backend, ttl=60, namespace="async-dc-throw", l1_enabled=False) + async def compute() -> dict[str, int]: + nonlocal calls + calls += 1 + return {"answer": 42} + + assert await compute() == {"answer": 42} + assert calls == 1 + + real_get = backend.get + gets = 0 + + def patched_get(key: str) -> bytes | None: + nonlocal gets + gets += 1 + return None if gets == 1 else real_get(key) + + backend.get = patched_get # type: ignore[method-assign] + + def boom(self: Any, **kw: Any) -> None: + raise raised + + monkeypatch.setattr(FeatureOrchestrator, "record_cache_operation", boom) + + # Counters are keyed by module.qualname and shared across decorator + # applications (see cache_info's docstring), so both parametrised runs share + # one set — assert the delta, not an absolute. + l2_hits_before = compute.cache_info().l2_hits + + # The hit is still served from the double-check read, and the function body + # never runs a second time. + assert await compute() == {"answer": 42} + assert calls == 1 + + # ...and cache_info() still counts it. A refusal from the external collector + # must not cost the local L2 stat, or a served hit goes missing from + # cache_info().l2_hits and the average L2 latency — the same invisibility + # this helper exists to remove, just moved to a different sink. + assert compute.cache_info().l2_hits == l2_hits_before + 1 diff --git a/uv.lock b/uv.lock index 65f75bc..0309cdf 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,6 @@ resolution-markers = [ [manifest] constraints = [ { name = "fonttools", specifier = ">=4.60.2" }, - { name = "h2", specifier = ">=4.4.1" }, { name = "pip", specifier = ">=26.2" }, { name = "urllib3", specifier = ">=2.7.0" }, { name = "werkzeug", specifier = ">=3.1.4" }, @@ -36,17 +35,36 @@ wheels = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -112,7 +130,7 @@ name = "blake3" version = "1.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" } wheels = [ @@ -234,7 +252,10 @@ name = "cachekit" version = "0.18.0" source = { editable = "." } dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "blake3" }, + { name = "h2" }, { name = "httpx", extra = ["http2"] }, { name = "msgpack" }, { name = "prometheus-client" }, @@ -315,7 +336,9 @@ test = [ [package.metadata] requires-dist = [ + { name = "anyio", specifier = ">=4.14.2" }, { name = "blake3", specifier = ">=1.0.5" }, + { name = "h2", specifier = ">=4.4.1" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "msgpack", specifier = ">=1.2.1" }, { name = "numpy", marker = "extra == 'data'", specifier = ">=2.0.2" }, @@ -632,7 +655,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -667,7 +690,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/14/b47b8471303af7deed7080290c14cff27a831fa47b38f45643e6bf889cee/fakeredis-2.32.1.tar.gz", hash = "sha256:dd8246db159f0b66a1ced7800c9d5ef07769e3d2fde44b389a57f2ce2834e444", size = 171582, upload-time = "2025-11-06T01:40:57.836Z" } wheels = [ @@ -818,7 +841,8 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, @@ -1520,7 +1544,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } @@ -1533,7 +1558,8 @@ name = "pydantic-core" version = "2.41.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } wheels = [ @@ -1708,7 +1734,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -1957,15 +1984,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "sortedcontainers" version = "2.4.0" @@ -2122,17 +2140,34 @@ wheels = [ name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +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 = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [