diff --git a/.gitignore b/.gitignore index 16cfb3c..38c915e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,9 @@ Thumbs.db *.log .env .env.local + +# Internal planning hub (specs/plans) — not published +localdocs/ + +# Local review/feature worktrees +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4240c86..0e2a09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [1.1.0] - 2026-06-09 + +### Added +- `atomicmemory.contract.v1`: a wire codec for the v1 provider contract's deliberately mixed-case encoding (`Memory.createdAt`/`updatedAt` and `SearchResult.rankingScore` are camelCase on the wire; `version_id`, `observed_at`, and retrieval-receipt fields are snake_case). Encode/decode helpers cover `Memory`, `Provenance`, `SearchResult`, `SearchResultPage`, `SearchRequest`, and ingest payloads (`IngestInput`, `IngestResult`). Dates follow the contract's ISO-8601 UTC millisecond `Z` form (`_to_iso_z`, equivalent to TS `toISOString()`). Naive datetimes in encode paths are assumed UTC. `encode_ingest_input` fails closed on the Python-ahead `content_class` field (no place in the v1 `additionalProperties: false` schemas; TS contract alignment is a recorded follow-up). Explicit-null `version_id` in `SearchResult` normalizes to absent on re-encode, matching the TS optional declaration. `encode_search_request` uses `by_alias=True` so Python-keyword-safe combinator field names (`and_`/`or_`/`not_`) emit their wire aliases; a recursive `_jsonify` walk converts any `datetime` operands in filter trees to the toISOString form. In-process models and provider mappers are unchanged. +- Vendored the TS SDK's versioned v1 wire contract (JSON Schemas, cross-provider conformance corpus, and CONTRACT.md) under `contract/`, with explicit provenance in `contract/VENDORED.json` and a documented refresh script (`scripts/refresh_contract.py`, never run in CI). A pytest conformance harness proves corpus fixtures decode into the Python models (directly for snake-on-wire types, through the codec for the mixed-case search response) and that SDK emissions validate against the vendored draft-2020-12 schemas, with the TS suite's negative cases mirrored against both schemas and Pydantic. The `capabilities-descriptor` case is schema-only (no Python model in this release — recorded follow-up). +- `atomicmemory.contract` re-exports `v1` as a specialty import surface; deliberately not re-exported from the package root to keep the root namespace focused on the core provider API. +- `AsyncProviderFactory` now accepts factories that return an `Awaitable[AsyncProviderRegistration]`, enabling lazy or async provider construction during `AsyncMemoryService.initialize()`. +- `MemoryService.initialize()` and `AsyncMemoryService.initialize()` raise `ConfigError` when the configured default provider has no registered factory, making a misconfigured default an immediate, explicit error rather than a silent no-op. + +### Changed +- `content_class` is now accepted on **every** ingest mode (`text`, `messages`, and `verbatim`), not just `verbatim`, and is forwarded to core for all modes. Extraction-based ingests (`text`/`messages`) can now satisfy a core running the default `RAW_CONTENT_POLICY=reject`. Still never defaulted — omitting it leaves the field off the wire and a reject-policy core fails closed. +- Both clients' `initialize()` is now concurrency-safe and idempotent: concurrent callers share a single initialization run (the first caller's registry wins), and the completed outcome — success or failure — is captured in loop-independent state for `AsyncMemoryClient`. +- A failed `initialize()` is sticky: retrying re-raises the original error from any caller; resolve the cause and construct a new client rather than retrying on the same instance. +- `AsyncMemoryClient.initialize()` shields each waiter from cancellation so that one waiter's timeout or cancellation never cancels the shared run for other concurrent callers. +- `AsyncMemoryClient.close()` during a pending initialization cancels the shared run; staged providers are torn down by the service's atomic-initialize cleanup, any concurrent `initialize()` waiter receives `CancelledError`, and the client ends in the not-initialized state without recording a sticky error. +- Both `MemoryService` and `AsyncMemoryService` stage provider registrations atomically: factories and provider `initialize()` calls run against a local staging area, and the maps are replaced only after every provider succeeds; on any failure, already-staged providers are torn down best-effort before the original error re-raises. +- `MemoryService.close()` and `AsyncMemoryService.close()` are best-effort: every provider gets a chance to close regardless of earlier failures, maps are cleared in a `finally` block, and the first failure is re-raised after all providers have been given the chance to close. + +### Fixed +- `atomicmemory.__version__` reported `1.0.0` while package metadata said `1.0.1`; all version sources now agree at `1.1.0`, guarded by a regression test that will fail if they drift again. + ## [1.0.1] - 2026-05-14 ### Changed diff --git a/README.md b/README.md index e9717ec..98b3e68 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,13 @@ pip install 'atomicmemory[embeddings]' # + sentence-transformers for local ## Quick start -Prerequisite: start `atomicmemory-core` first. Follow the [Core Quickstart](https://docs.atomicstrata.ai/quickstart) if you do not already have a backend at `http://localhost:3050`. +Prerequisite: start `atomicmemory-core` first. Follow the [Core Quickstart](https://docs.atomicstrata.ai/quickstart) if you do not already have a backend at `http://localhost:17350`. ```python from atomicmemory import AtomicMemoryClient with AtomicMemoryClient({ - "apiUrl": "http://localhost:3050", + "apiUrl": "http://localhost:17350", "apiKey": "server-api-key", "userId": "demo", }) as client: @@ -72,7 +72,7 @@ from atomicmemory import AsyncAtomicMemoryClient async def main() -> None: async with AsyncAtomicMemoryClient({ - "apiUrl": "http://localhost:3050", + "apiUrl": "http://localhost:17350", "apiKey": "server-api-key", "userId": "demo", }) as client: @@ -131,6 +131,50 @@ The `client.storage` namespace mirrors the TypeScript SDK's direct storage API: Every storage request sends `Authorization: Bearer ` and `X-AtomicMemory-User-Id`. The SDK never sends the legacy `?user_id=` URL parameter. +## v1 wire contract + +`atomicmemory.contract.v1` is the wire codec for the v1 provider-contract encoding. The wire form is deliberately mixed-case — `Memory.createdAt`/`updatedAt` and `SearchResult.rankingScore` are camelCase; `version_id`, `observed_at`, and retrieval-receipt fields are snake_case — as pinned by the vendored `contract/CONTRACT.md`. This module is the only place that mapping lives; in-process models and provider mappers are unchanged. + +```python +from atomicmemory.contract import v1 + +# decode a wire search response (e.g. from a cross-SDK provider call) +wire_page = { + "results": [ + { + "memory": { + "id": "mem_1", + "content": "I prefer aisle seats on flights.", + "scope": {"user": "demo"}, + "kind": "fact", + "createdAt": "2026-05-30T12:00:00.000Z", + }, + "score": 0.91, + "rankingScore": 0.87, + } + ], + "retrieval": { + "embedding_model": "text-embedding-x", + "embedding_model_version": "1", + "embedding_dimensions": 1536, + "query_text": "deploy gate", + "candidate_ids": ["mem_1"], + "trace_id": "trace-1", + }, +} + +page = v1.decode_search_result_page(wire_page) +for hit in page.results: + print(hit.memory.content, hit.score) # snake_case in-process models + +# re-encode to the exact v1 wire form (millisecond-precision UTC datetimes) +wire_out = v1.encode_search_result_page(page) +``` + +Two behaviors to know: naive datetimes passed to encode functions are assumed UTC (bare `astimezone()` would shift by the host's UTC offset); `encode_ingest_input` rejects models carrying `content_class` with a clear error because the v1 schemas have `additionalProperties: false` and no such field — this is a Python-ahead field pending TS contract alignment. + +This is NOT the AtomicMemory core HTTP API. That boundary stays in the provider mappers. The import path is `atomicmemory.contract` — deliberately not re-exported from the package root to keep the root namespace focused on the core provider API. + ## Development ```bash diff --git a/atomicmemory/__init__.py b/atomicmemory/__init__.py index da78faa..34e3856 100644 --- a/atomicmemory/__init__.py +++ b/atomicmemory/__init__.py @@ -23,11 +23,25 @@ RateLimitError, ValidationError, ) +from atomicmemory.memory.capability_profiles import ( + CapabilityGap, + CapabilityProfile, + capability_gaps, + satisfies_profile, +) from atomicmemory.memory.filters import FieldFilter, FieldFilterOp, FilterExpr +from atomicmemory.memory.meta_fact_filter import ( + DEFAULT_META_FACT_PATTERNS, + MetaFactFilterConfig, + filter_meta_facts, + is_meta_fact, + resolve_meta_fact_patterns, +) from atomicmemory.memory.types import ( Capabilities, CapabilitiesExtensions, CapabilitiesRequiredScope, + ContentClass, ContextPackage, GraphEdge, GraphNode, @@ -52,6 +66,7 @@ PackageRequest, Profile, Provenance, + RetrievalReceipt, Scope, SearchRequest, SearchResult, @@ -87,6 +102,7 @@ ) __all__ = [ + "DEFAULT_META_FACT_PATTERNS", "ArtifactHead", "ArtifactInUseError", "ArtifactMetadata", @@ -103,7 +119,10 @@ "Capabilities", "CapabilitiesExtensions", "CapabilitiesRequiredScope", + "CapabilityGap", + "CapabilityProfile", "ConfigError", + "ContentClass", "ContextPackage", "DeleteArtifactOptions", "DeleteArtifactPolicy", @@ -133,6 +152,7 @@ "Message", "MessageIngest", "MessageRole", + "MetaFactFilterConfig", "NetworkError", "NotInitializedError", "PackageFormat", @@ -146,6 +166,7 @@ "PutManagedInput", "PutPointerInput", "RateLimitError", + "RetrievalReceipt", "Scope", "SearchRequest", "SearchResult", @@ -163,4 +184,9 @@ "VerificationResult", "VerifyArtifactOptions", "__version__", + "capability_gaps", + "filter_meta_facts", + "is_meta_fact", + "resolve_meta_fact_patterns", + "satisfies_profile", ] diff --git a/atomicmemory/_version.py b/atomicmemory/_version.py index b7354d5..5f306b4 100644 --- a/atomicmemory/_version.py +++ b/atomicmemory/_version.py @@ -1,3 +1,7 @@ -"""Version metadata for the atomicmemory Python SDK.""" +"""Version metadata for the atomicmemory Python SDK. -__version__ = "1.0.0" +Exports: + __version__: The current package version string (PEP 440). +""" + +__version__ = "1.1.0" diff --git a/atomicmemory/client/async_memory_client.py b/atomicmemory/client/async_memory_client.py index 25870a1..10abc23 100644 --- a/atomicmemory/client/async_memory_client.py +++ b/atomicmemory/client/async_memory_client.py @@ -1,5 +1,6 @@ """AsyncMemoryClient — async facade for the V3 memory layer. +Port of `atomicmemory-sdk/src/client/memory-client.ts` (async variant). Mirrors :class:`atomicmemory.client.memory_client.MemoryClient` with ``async def`` for every I/O method and a ``__aenter__`` / ``__aexit__`` context manager. Dict coercion + Pydantic-error wrapping is identical @@ -8,6 +9,8 @@ from __future__ import annotations +import asyncio +import contextlib from dataclasses import dataclass from types import TracebackType from typing import Any @@ -17,11 +20,13 @@ import atomicmemory.providers.hindsight import atomicmemory.providers.mem0 # noqa: F401 from atomicmemory.client.memory_client import ( + MemoryProviderConfigs, _coerce_ingest, _coerce_list_request, _coerce_package, _coerce_ref, _coerce_search, + _pick_first_provider_key, ) from atomicmemory.core.errors import ConfigError, NotInitializedError from atomicmemory.memory.provider import BaseAsyncMemoryProvider @@ -42,8 +47,6 @@ ) from atomicmemory.providers.atomicmemory.async_handle_impl import AsyncAtomicMemoryHandle -MemoryProviderConfigs = dict[str, Any] - @dataclass class AsyncProviderStatus: @@ -62,7 +65,7 @@ class AsyncMemoryClient: Example: >>> async with AsyncMemoryClient( - ... providers={"atomicmemory": {"api_url": "http://localhost:3050"}} + ... providers={"atomicmemory": {"api_url": "http://localhost:17350"}} ... ) as memory: ... await memory.initialize() ... await memory.ingest({"mode": "text", "content": "hi", "scope": {"user": "u1"}}) @@ -88,18 +91,82 @@ def __init__( ) ) self._initialized = False + self._init_error: Exception | None = None + self._init_task: asyncio.Task[None] | None = None async def initialize(self, registry: AsyncProviderRegistry | None = None) -> None: + """Initialize all configured providers. Idempotent and concurrency-safe. + + Concurrent calls on one event loop share a single initialization run + (the first call's ``registry`` wins). The COMPLETED outcome — success + or the original failure — is captured into loop-independent state, so + a failed initialization is sticky from any loop: retrying re-raises + the original error; construct a new client after resolving the cause. + An instance is bound to the event loop of its first ``initialize()`` + while initialization is still PENDING — awaiting a pending run from a + different loop is unsupported. ``close()`` after a SUCCESSFUL + lifecycle returns the client to the uninitialized state. + """ if self._initialized: return - await self._service.initialize(registry if registry is not None else default_async_registry) + if self._init_error is not None: + raise self._init_error + if self._init_task is None: + self._init_task = asyncio.ensure_future(self._run_initialize(registry)) + self._init_task.add_done_callback(_mark_retrieved) + task = self._init_task + try: + # shield: cancelling ONE waiter (e.g. wait_for timeout) must not + # cancel the shared run for everyone — promises aren't cancellable + # in TS, so unshielded awaiting would NOT be lifecycle parity. + await asyncio.shield(task) + finally: + if task.done(): + self._init_task = None + + async def _run_initialize(self, registry: AsyncProviderRegistry | None) -> None: + """Execute the shared initialization run; capture errors into sticky state. + + CancelledError is BaseException and never caught here, so cancellation + never becomes sticky. A cancelled task's ``_init_task`` slot is cleared + by a surviving waiter's ``finally`` once the task is done, or by + ``close()``; either path lets a later call start fresh. + """ + try: + await self._service.initialize(registry if registry is not None else default_async_registry) + except Exception as exc: + self._init_error = exc + raise self._initialized = True async def close(self) -> None: + """Close providers; safe to call multiple times. + + Closing while an initialization is PENDING cancels that run: staged + providers are torn down by the service's atomic-initialize cleanup, + any concurrent initialize() waiter receives CancelledError, and the + client ends not-initialized (no sticky error is recorded for + cancellation). After a SUCCESSFUL lifecycle, close() returns the + client to the uninitialized state. A FAILED initialization remains + sticky — close() does not reset it. + """ + task = self._init_task + if task is not None: + if not task.done(): + task.cancel() + with contextlib.suppress(Exception, asyncio.CancelledError): + await task + # Always clear, even when already done: a run whose waiters were + # all cancelled leaves a stale DONE task behind, and a later + # initialize() awaiting it would resolve instantly WITHOUT + # re-running — silently leaving the client uninitialized. + self._init_task = None if not self._initialized: return - await self._service.close() - self._initialized = False + try: + await self._service.close() + finally: + self._initialized = False async def __aenter__(self) -> AsyncMemoryClient: return self @@ -117,6 +184,7 @@ async def ingest(self, input: IngestInput | dict[str, Any]) -> IngestResult: return await self._service.ingest(_coerce_ingest(input)) async def ingest_direct(self, input: IngestInput | dict[str, Any]) -> IngestResult: + """Identical to :meth:`ingest`; preserved for wrapper-subclass parity with TS.""" self._assert_initialized() return await self._service.ingest(_coerce_ingest(input)) @@ -125,6 +193,7 @@ async def search(self, request: SearchRequest | dict[str, Any]) -> SearchResultP return await self._service.search(_coerce_search(request)) async def search_direct(self, request: SearchRequest | dict[str, Any]) -> SearchResultPage: + """Identical to :meth:`search`; preserved for wrapper-subclass parity with TS.""" self._assert_initialized() return await self._service.search(_coerce_search(request)) @@ -133,6 +202,7 @@ async def package(self, request: PackageRequest | dict[str, Any]) -> ContextPack return await self._service.package(_coerce_package(request)) async def package_direct(self, request: PackageRequest | dict[str, Any]) -> ContextPackage: + """Identical to :meth:`package`; preserved for wrapper-subclass parity with TS.""" self._assert_initialized() return await self._service.package(_coerce_package(request)) @@ -181,6 +251,11 @@ def get_provider(self, name: str | None = None) -> BaseAsyncMemoryProvider: @property def atomicmemory(self) -> AsyncAtomicMemoryHandle | None: + """Typed access to AtomicMemory-specific routes. + + Returns ``None`` when the client is not yet initialized or the + ``atomicmemory`` provider was not configured. + """ if not self._initialized: return None if "atomicmemory" not in self._service.get_configured_providers(): @@ -196,8 +271,13 @@ def _assert_initialized(self) -> None: raise NotInitializedError("AsyncMemoryClient is not initialized. Call await client.initialize() first.") -def _pick_first_provider_key(providers: MemoryProviderConfigs) -> str | None: - for key, value in providers.items(): - if value is not None and key != "default": - return key - return None +def _mark_retrieved(task: asyncio.Task[None]) -> None: + """Retrieve the task's exception so asyncio never logs 'never retrieved'. + + A run whose waiters were all cancelled fails unobserved; without this + callback asyncio would log "Task exception was never retrieved" at GC. + Correctness is unchanged: waiters still see errors through the shield, + and stickiness is recorded by ``_run_initialize`` itself. + """ + if not task.cancelled(): + task.exception() diff --git a/atomicmemory/client/memory_client.py b/atomicmemory/client/memory_client.py index 691f50f..faaec27 100644 --- a/atomicmemory/client/memory_client.py +++ b/atomicmemory/client/memory_client.py @@ -9,6 +9,7 @@ from __future__ import annotations +import threading from dataclasses import dataclass from types import TracebackType from typing import Any @@ -127,7 +128,7 @@ class MemoryClient: """Sync entry point for the V3 memory API. Example: - >>> with MemoryClient(providers={"atomicmemory": {"api_url": "http://localhost:3050"}}) as memory: + >>> with MemoryClient(providers={"atomicmemory": {"api_url": "http://localhost:17350"}}) as memory: ... memory.initialize() ... memory.ingest({"mode": "text", "content": "hi", "scope": {"user": "u1"}}) """ @@ -151,24 +152,54 @@ def __init__( ) ) self._initialized = False + self._init_lock = threading.Lock() + self._init_error: Exception | None = None # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def initialize(self, registry: ProviderRegistry | None = None) -> None: - """Initialize all configured providers. Idempotent.""" - if self._initialized: - return - self._service.initialize(registry if registry is not None else default_registry) - self._initialized = True + """Initialize all configured providers. Idempotent and thread-safe. + + Concurrent and subsequent calls share a single initialization run + (the first call's ``registry`` wins; later arguments are ignored). + A FAILED initialization is sticky: retrying re-raises the original + error — resolve the cause and construct a new client. A successful + lifecycle stays re-openable: ``close()`` returns the client to the + uninitialized state. Factories must not call back into this client + instance; the non-reentrant lock would deadlock. + """ + with self._init_lock: + if self._initialized: + return + if self._init_error is not None: + raise self._init_error + try: + self._service.initialize(registry if registry is not None else default_registry) + except Exception as exc: + # Sticky failures are real initialization errors ONLY — + # KeyboardInterrupt/SystemExit propagate without poisoning the client. + self._init_error = exc + raise + self._initialized = True def close(self) -> None: - """Close every initialized provider; safe to call multiple times.""" - if not self._initialized: - return - self._service.close() - self._initialized = False + """Close every initialized provider; safe to call multiple times. + + A pending sync initialize holds the lock, so close() blocks until + initialization finishes, including any network I/O it performs — + deterministic by construction. A client that never initialized + successfully is unaffected: close() is a no-op and the sticky + initialization error is preserved — construct a new client. + """ + with self._init_lock: + if not self._initialized: + return + try: + self._service.close() + finally: + self._initialized = False def __enter__(self) -> MemoryClient: return self diff --git a/atomicmemory/contract/__init__.py b/atomicmemory/contract/__init__.py new file mode 100644 index 0000000..dd21c52 --- /dev/null +++ b/atomicmemory/contract/__init__.py @@ -0,0 +1,18 @@ +"""atomicmemory.contract — v1 provider-contract wire codec. + +Re-exports the public codec functions from :mod:`atomicmemory.contract.v1`. +Import the submodule directly for IDE discoverability: + + from atomicmemory.contract import v1 + + page = v1.decode_search_result_page(wire_dict) + wire = v1.encode_search_result_page(page) + +This package is a specialty surface; it is deliberately NOT re-exported from +the ``atomicmemory`` package root to keep the root namespace focused on the +core provider API. +""" + +from atomicmemory.contract import v1 + +__all__ = ["v1"] diff --git a/atomicmemory/contract/v1.py b/atomicmemory/contract/v1.py new file mode 100644 index 0000000..e8c6266 --- /dev/null +++ b/atomicmemory/contract/v1.py @@ -0,0 +1,400 @@ +"""v1 provider-contract wire codec. + +Translates between the idiomatic snake_case in-process models and the v1 wire +encoding pinned by ``contract/CONTRACT.md`` + ``contract/v1/*.schema.json``. +The wire casing is deliberately mixed (``Memory.createdAt`` camel; +``version_id``/receipt fields snake); this module is the ONLY place that +mapping lives. In-process models and provider mappers are not v1 surfaces. + +See Also: + contract/CONTRACT.md: The prose encoding spec (source of truth). + contract/v1/provider-contract.schema.json: Machine-readable field maps. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, cast + +from atomicmemory.memory.types import ( + IngestInput, + IngestResult, + Memory, + Provenance, + SearchRequest, + SearchResult, + SearchResultPage, +) + +# No hand-rolled date PARSER: Pydantic v2 accepts trailing-'Z' ISO strings +# natively (regardless of the Python 3.10 stdlib fromisoformat limitation), +# so decode_* just renames keys and lets model validation parse. + +# Allowed wire-key sets, one per decoded type, mirroring the `properties` of +# the vendored schemas' $defs (all five declare additionalProperties: false). +# Code literals rather than schema reads because the wheel doesn't ship +# contract/; the conformance harness pins these against the schemas so they +# can't drift. They make the decoders a STRICT v1 boundary: in-process snake +# names (created_at/ranking_score/source_url) and unknown extras are rejected +# instead of slipping through the rename-if-present + extra="ignore" path. +_MEMORY_WIRE_KEYS = frozenset({"id", "content", "scope", "kind", "createdAt", "updatedAt", "provenance", "metadata"}) +_PROVENANCE_WIRE_KEYS = frozenset({"source", "sourceUrl", "sourceId", "extractor"}) +_SEARCH_RESULT_WIRE_KEYS = frozenset( + {"memory", "score", "similarity", "rankingScore", "relevance", "version_id", "observed_at"} +) +_SEARCH_RESULT_PAGE_WIRE_KEYS = frozenset({"results", "cursor", "retrieval"}) +_INGEST_RESULT_WIRE_KEYS = frozenset({"created", "updated", "unchanged"}) + + +def _require_wire_keys(wire: dict[str, Any], allowed: frozenset[str], type_name: str) -> None: + """Reject non-v1 wire keys (the schemas declare ``additionalProperties: false``). + + Args: + wire: The raw wire dict about to be decoded. + allowed: The type's allowed wire-key set. + type_name: Human-readable type name for the error message. + + Raises: + ValueError: If ``wire`` carries any key outside ``allowed`` — covers + both in-process snake aliases and unknown extras. + """ + unknown = set(wire) - allowed + if unknown: + raise ValueError( + f"{type_name}: non-v1 wire keys {sorted(unknown)} — the v1 contract uses " + f"{sorted(allowed)} (in-process snake_case names are not wire names)" + ) + + +def _to_iso_z(value: datetime) -> str: + """Emit a datetime as TS ``toISOString()`` equivalent. + + Produces UTC, millisecond precision, trailing Z — matching the + CONTRACT.md §1 encoding rule. Pydantic's ``model_dump(mode="json")`` + emits seconds-precision (``...12:00:00Z``), NOT the millisecond form + (``...12:00:00.000Z``), so every datetime field that crosses the v1 + boundary must go through this helper instead of the raw dump. + + Args: + value: The datetime to encode. If naive, UTC is assumed — bare + ``astimezone()`` would interpret it as LOCAL system time and + shift the encoded instant by the host's UTC offset. + + Returns: + An ISO-8601 string like ``"2026-05-30T12:00:00.123Z"``. + """ + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def decode_provenance(wire: dict[str, Any]) -> Provenance: + """Decode a v1 wire Provenance dict into the in-process model. + + Renames camel fields ``sourceUrl``/``sourceId`` to snake equivalents; + ``source`` and ``extractor`` are passthrough. + + Args: + wire: A raw wire-format provenance dict. + + Returns: + The validated in-process Provenance model. + + Raises: + ValueError: If ``wire`` carries non-v1 keys. + """ + _require_wire_keys(wire, _PROVENANCE_WIRE_KEYS, "Provenance") + data = dict(wire) + if "sourceUrl" in data: + data["source_url"] = data.pop("sourceUrl") + if "sourceId" in data: + data["source_id"] = data.pop("sourceId") + return Provenance.model_validate(data) + + +def encode_provenance(provenance: Provenance) -> dict[str, Any]: + """Encode the in-process Provenance model into the v1 wire form. + + Renames snake fields ``source_url``/``source_id`` to camel equivalents. + The schema has ``additionalProperties: false``, so emitting snake names + would be wire-invalid. + + Args: + provenance: The in-process provenance model. + + Returns: + A wire-format dict with ``sourceUrl``/``sourceId`` camel keys. + """ + data = provenance.model_dump(mode="json", exclude_none=True) + if "source_url" in data: + data["sourceUrl"] = data.pop("source_url") + if "source_id" in data: + data["sourceId"] = data.pop("source_id") + return data + + +def decode_memory(wire: dict[str, Any]) -> Memory: + """Decode a v1 wire Memory dict (camel dates) into the in-process model. + + Renames ``createdAt``/``updatedAt`` to snake equivalents; routes the + ``provenance`` sub-object through :func:`decode_provenance`. + + Args: + wire: A raw wire-format memory dict with camel-cased date fields. + + Returns: + The validated in-process Memory model. + + Raises: + ValueError: If ``wire`` carries non-v1 keys. + pydantic.ValidationError: If ``createdAt`` is missing or any field + fails validation. + """ + _require_wire_keys(wire, _MEMORY_WIRE_KEYS, "Memory") + data = dict(wire) + if "createdAt" in data: + data["created_at"] = data.pop("createdAt") + if "updatedAt" in data: + data["updated_at"] = data.pop("updatedAt") + if "provenance" in data and isinstance(data["provenance"], dict): + data["provenance"] = decode_provenance(data["provenance"]).model_dump(exclude_none=True) + return Memory.model_validate(data) + + +def encode_memory(memory: Memory) -> dict[str, Any]: + """Encode the in-process Memory model into the exact v1 wire form. + + Renames ``created_at``/``updated_at`` to camel equivalents (using + ``_to_iso_z`` for millisecond precision) and routes ``provenance`` + through :func:`encode_provenance`. + + Args: + memory: The in-process memory model. + + Returns: + A wire-format dict with ``createdAt``/``updatedAt`` camel keys. + """ + data = memory.model_dump(mode="json", exclude_none=True) + # Replace seconds-precision Pydantic dump with the toISOString millis form. + data.pop("created_at", None) + data.pop("updated_at", None) + data["createdAt"] = _to_iso_z(memory.created_at) + if memory.updated_at is not None: + data["updatedAt"] = _to_iso_z(memory.updated_at) + if memory.provenance is not None: + data["provenance"] = encode_provenance(memory.provenance) + if data.get("metadata") is not None: + # _jsonify must walk the PYTHON-side metadata: the json-mode dump above + # already stringified any datetime values in the wrong (non-millis) form. + data["metadata"] = _jsonify(memory.metadata) + return data + + +def decode_search_result(wire: dict[str, Any]) -> SearchResult: + """Decode a v1 wire SearchResult dict into the in-process model. + + Renames ``rankingScore`` to ``ranking_score``; delegates ``memory`` + to :func:`decode_memory`. + + Args: + wire: A raw wire-format search result dict. + + Returns: + The validated in-process SearchResult model. + + Raises: + ValueError: If ``wire`` carries non-v1 keys. + """ + _require_wire_keys(wire, _SEARCH_RESULT_WIRE_KEYS, "SearchResult") + data = dict(wire) + if "rankingScore" in data: + data["ranking_score"] = data.pop("rankingScore") + if "memory" in data and isinstance(data["memory"], dict): + data["memory"] = decode_memory(data["memory"]).model_dump(exclude_none=True) + return SearchResult.model_validate(data) + + +def encode_search_result(result: SearchResult) -> dict[str, Any]: + """Encode the in-process SearchResult model into the v1 wire form. + + Renames ``ranking_score`` to ``rankingScore``; delegates ``memory`` + to :func:`encode_memory`. + + Args: + result: The in-process search result model. + + Returns: + A wire-format dict with ``rankingScore`` camel key. + """ + # exclude_none drops version_id/observed_at when None — deliberately: + # the TS source (types.ts @ 2a67871) declares both OPTIONAL + # (`versionId?: string | null`, `observedAt?: string`), so the absent key + # is the canonical wire form of the None state; a decoded explicit null + # normalizes to absent on re-encode. + data = result.model_dump(mode="json", exclude_none=True) + if "ranking_score" in data: + data["rankingScore"] = data.pop("ranking_score") + data["memory"] = encode_memory(result.memory) + return data + + +def decode_search_result_page(wire: dict[str, Any]) -> SearchResultPage: + """Decode a v1 wire SearchResultPage into the in-process model. + + Maps ``results`` through :func:`decode_search_result`; ``cursor`` and + ``retrieval`` are passthrough (fully snake_case on the wire). + + Args: + wire: A raw wire-format search result page dict. + + Returns: + The validated in-process SearchResultPage model. + + Raises: + ValueError: If ``wire`` carries non-v1 keys. + """ + _require_wire_keys(wire, _SEARCH_RESULT_PAGE_WIRE_KEYS, "SearchResultPage") + data = dict(wire) + data["results"] = [decode_search_result(r).model_dump(exclude_none=True) for r in data.get("results", [])] + return SearchResultPage.model_validate(data) + + +def encode_search_result_page(page: SearchResultPage) -> dict[str, Any]: + """Encode the in-process SearchResultPage model into the v1 wire form. + + Maps ``results`` through :func:`encode_search_result`; ``cursor`` and + ``retrieval`` are passthrough (fully snake_case on the wire). + + Args: + page: The in-process search result page model. + + Returns: + A wire-format dict with each result's ``rankingScore`` camel key. + """ + data: dict[str, Any] = { + "results": [encode_search_result(r) for r in page.results], + } + if page.cursor is not None: + data["cursor"] = page.cursor + if page.retrieval is not None: + data["retrieval"] = page.retrieval.model_dump(mode="json", exclude_none=True) + return data + + +def _jsonify(value: Any) -> Any: + """Recursively convert datetimes to ``_to_iso_z`` strings. + + Walks dicts and lists, converting any :class:`datetime` encountered to + the v1 wire form. All other JSON-native values are returned unchanged. + This covers ``FieldFilter.value`` datetime operands at any nesting depth + in an ``and``/``or``/``not`` filter tree. + + Args: + value: A value that may be a dict, list, datetime, or JSON primitive. + + Returns: + The value with all datetimes replaced by ISO-8601 Z strings. + """ + if isinstance(value, datetime): + return _to_iso_z(value) + if isinstance(value, dict): + return {k: _jsonify(v) for k, v in value.items()} + if isinstance(value, list): + return [_jsonify(item) for item in value] + return value + + +def encode_search_request(request: SearchRequest) -> dict[str, Any]: + """Encode the in-process SearchRequest into the v1 wire form. + + ``by_alias=True`` is load-bearing: the filter combinator models use + Python-keyword-safe field names with wire aliases (``and_`` → ``and``, + ``or_`` → ``or``, ``not_`` → ``not``); a non-alias dump emits the Python + names which the schema rejects. A recursive ``_jsonify`` walk converts + any ``datetime`` operands in the filter tree to the toISOString millis + form (CONTRACT.md §1). + + Args: + request: The in-process search request model. + + Returns: + A wire-format dict suitable for JSON serialization. + """ + raw = request.model_dump(mode="python", by_alias=True, exclude_none=True) + # _jsonify is typed Any -> Any (it walks arbitrary JSON shapes), but a dict + # input always yields a dict; a cast keeps the guarantee without a + # strippable runtime assert (python -O removes asserts). + return cast("dict[str, Any]", _jsonify(raw)) + + +def decode_search_request(wire: dict[str, Any]) -> SearchRequest: + """Decode a v1 wire SearchRequest dict into the in-process model. + + SearchRequest fields are fully snake_case on the wire; this is a thin + ``model_validate`` passthrough. + + Args: + wire: A raw wire-format search request dict. + + Returns: + The validated in-process SearchRequest model. + """ + return SearchRequest.model_validate(wire) + + +def encode_ingest_input(model: IngestInput) -> dict[str, Any]: + """Encode the in-process IngestInput model into the v1 wire form. + + Routes ``provenance`` through :func:`encode_provenance`. Raises + ``ValueError`` if ``content_class`` is set: the v1 schemas have + ``additionalProperties: false`` with no ``content_class`` field, so + emitting it would be wire-invalid. This field is Python-ahead; the TS + contract catch-up is the recorded follow-up. + + Args: + model: The in-process ingest input model (any mode variant). + + Returns: + A wire-format dict suitable for JSON serialization. + + Raises: + ValueError: If the model carries ``content_class`` (Python-only field + not present in the v1 wire schema). + """ + # Deliberately generic (getattr, not an isinstance check on a single mode): + # content_class lives on IngestBase, so every ingest mode carries it and + # every mode must fail closed here until the v1 contract adds the field. + content_class = getattr(model, "content_class", None) + if content_class is not None: + raise ValueError( + f"content_class={content_class!r} is a Python-ahead field with no place in the v1 wire " + "schema (additionalProperties: false). Strip it before encoding, or wait for the TS " + "contract to add it." + ) + data = model.model_dump(mode="json", exclude_none=True) + if "provenance" in data and isinstance(data["provenance"], dict): + data["provenance"] = encode_provenance(model.provenance) # type: ignore[arg-type] + if data.get("metadata") is not None: + # _jsonify must walk the PYTHON-side metadata: the json-mode dump above + # already stringified any datetime values in the wrong (non-millis) form. + data["metadata"] = _jsonify(model.metadata) + return data + + +def decode_ingest_result(wire: dict[str, Any]) -> IngestResult: + """Decode a v1 wire IngestResult dict into the in-process model. + + IngestResult is fully snake_case on the wire; this is a thin + ``model_validate`` passthrough. + + Args: + wire: A raw wire-format ingest result dict. + + Returns: + The validated in-process IngestResult model. + + Raises: + ValueError: If ``wire`` carries non-v1 keys. + """ + _require_wire_keys(wire, _INGEST_RESULT_WIRE_KEYS, "IngestResult") + return IngestResult.model_validate(wire) diff --git a/atomicmemory/memory/__init__.py b/atomicmemory/memory/__init__.py index 4a9e424..b906421 100644 --- a/atomicmemory/memory/__init__.py +++ b/atomicmemory/memory/__init__.py @@ -5,11 +5,25 @@ `atomicmemory.providers`. """ +from atomicmemory.memory.capability_profiles import ( + CapabilityGap, + CapabilityProfile, + capability_gaps, + satisfies_profile, +) from atomicmemory.memory.filters import FieldFilter, FieldFilterOp, FilterExpr +from atomicmemory.memory.meta_fact_filter import ( + DEFAULT_META_FACT_PATTERNS, + MetaFactFilterConfig, + filter_meta_facts, + is_meta_fact, + resolve_meta_fact_patterns, +) from atomicmemory.memory.types import ( Capabilities, CapabilitiesExtensions, CapabilitiesRequiredScope, + ContentClass, ContextPackage, GraphEdge, GraphNode, @@ -34,6 +48,7 @@ PackageRequest, Profile, Provenance, + RetrievalReceipt, Scope, SearchRequest, SearchResult, @@ -43,9 +58,13 @@ ) __all__ = [ + "DEFAULT_META_FACT_PATTERNS", "Capabilities", "CapabilitiesExtensions", "CapabilitiesRequiredScope", + "CapabilityGap", + "CapabilityProfile", + "ContentClass", "ContextPackage", "FieldFilter", "FieldFilterOp", @@ -69,14 +88,21 @@ "Message", "MessageIngest", "MessageRole", + "MetaFactFilterConfig", "PackageFormat", "PackageRequest", "Profile", "Provenance", + "RetrievalReceipt", "Scope", "SearchRequest", "SearchResult", "SearchResultPage", "TextIngest", "VerbatimIngest", + "capability_gaps", + "filter_meta_facts", + "is_meta_fact", + "resolve_meta_fact_patterns", + "satisfies_profile", ] diff --git a/atomicmemory/memory/capability_profiles.py b/atomicmemory/memory/capability_profiles.py new file mode 100644 index 0000000..d948569 --- /dev/null +++ b/atomicmemory/memory/capability_profiles.py @@ -0,0 +1,79 @@ +"""Capability profiles. + +A capability profile is the minimum :class:`Capabilities` a memory provider +must satisfy for a given consumer's needs (for example, an audited +ingest->search->replay path that requires deterministic verbatim storage and +version pinning). It is a typed, partial requirement set so a caller can gate a +provider at wiring time with an actionable diff instead of an opaque boolean. + +Port of ``atomicmemory-sdk/src/memory/capability-profiles.ts``. The SDK ships +the generic mechanism; each consumer defines its own profile against it. Pure +runtime code -- no I/O, no provider construction. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from atomicmemory.memory.types import Capabilities, IngestMode + + +class CapabilityProfile(BaseModel): + """Minimum capability requirement set a provider must satisfy. + + ``extensions`` lists the boolean flag names on + :class:`CapabilitiesExtensions` that must be ``True`` (e.g. ``"health"``, + ``"versioning"``). ``search`` is not listed -- it is a core method every + provider implements, so it is implied rather than gated. + """ + + model_config = ConfigDict(extra="forbid") + + ingest_modes: list[IngestMode] = Field(default_factory=list) + extensions: list[str] = Field(default_factory=list) + + +class CapabilityGap(BaseModel): + """A single unmet capability requirement, for actionable rejection errors.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["ingest_mode", "extension"] + requirement: str + detail: str + + +def capability_gaps(caps: Capabilities, profile: CapabilityProfile) -> list[CapabilityGap]: + """Return every requirement in ``profile`` that ``caps`` fails to satisfy. + + An empty list means the provider satisfies the profile. Use this to build + actionable errors ("provider X is missing verbatim ingest, missing + versioning extension") instead of an opaque boolean rejection. + """ + gaps: list[CapabilityGap] = [] + for mode in profile.ingest_modes: + if mode not in caps.ingest_modes: + gaps.append( + CapabilityGap( + kind="ingest_mode", + requirement=mode, + detail=f"ingest_modes must include '{mode}'", + ) + ) + for extension in profile.extensions: + if getattr(caps.extensions, extension, False) is not True: + gaps.append( + CapabilityGap( + kind="extension", + requirement=extension, + detail=f"extensions.{extension} must be true", + ) + ) + return gaps + + +def satisfies_profile(caps: Capabilities, profile: CapabilityProfile) -> bool: + """Whether ``caps`` satisfies every requirement in ``profile``.""" + return len(capability_gaps(caps, profile)) == 0 diff --git a/atomicmemory/memory/meta_fact_filter.py b/atomicmemory/memory/meta_fact_filter.py new file mode 100644 index 0000000..438af97 --- /dev/null +++ b/atomicmemory/memory/meta_fact_filter.py @@ -0,0 +1,122 @@ +"""Meta-fact filter. + +Post-retrieval filter that drops "meta-facts" -- extraction artifacts that +describe the conversation itself ("The user asked for the user's name.", "As of +, X is a term mentioned in the conversation.") rather than recording a +durable fact about the user. When such artifacts sit in the recall pool they +can outrank real user facts at thin cosine margins, so removing them lifts +recall quality. + +Port of ``atomicmemory-sdk/src/memory/meta-fact-filter.ts``. The filter is +intentionally pure (deterministic regex application, no I/O), opt-in (off unless +explicitly enabled in provider config), case-insensitive, and additive (apps may +add patterns without losing the defaults). +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Literal, TypeVar + +from atomicmemory.core.logging import get_logger + +T = TypeVar("T") + +logger = get_logger(__name__) + +# Built-in patterns observed in real partner demos. Each is matched +# case-insensitively against the memory content; a match drops the memory. +DEFAULT_META_FACT_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^\s*the user (asked|requested|said|is asking|is me)\b", re.IGNORECASE), + re.compile( + r"^\s*as of [^,]+,\s+.+\s+is a term mentioned in the conversation\.?$", + re.IGNORECASE, + ), + re.compile(r"^\s*a name was mentioned\b", re.IGNORECASE), + re.compile(r"^\s*the conversation involves the user\b", re.IGNORECASE), + re.compile(r"^\s*the user has started a conversation\b", re.IGNORECASE), +) + + +@dataclass(frozen=True) +class MetaFactFilterConfig: + """Configuration for the opt-in meta-fact filter. + + Attributes: + enabled: Master switch. When ``False`` (the default for the provider, + which leaves this config unset) the filter is a no-op. Not inferred + from the environment, to keep behavior deterministic. + patterns: Patterns matched against ``memory.content``. When ``None`` the + built-in :data:`DEFAULT_META_FACT_PATTERNS` are used. + mode: How ``patterns`` interacts with the defaults. ``"replace"`` (the + default) uses only the provided patterns; ``"extend"`` applies the + provided patterns *and* the defaults. + on_drop: Optional callback invoked once per dropped result with + ``(content, pattern_index)``. Exceptions it raises are swallowed so + telemetry can never break recall. + """ + + enabled: bool + patterns: Sequence[re.Pattern[str]] | None = None + mode: Literal["replace", "extend"] = "replace" + on_drop: Callable[[str, int], None] | None = None + + +def resolve_meta_fact_patterns(config: MetaFactFilterConfig) -> tuple[re.Pattern[str], ...]: + """Resolve the effective pattern list for a config. Pure; safe to repeat.""" + if config.patterns is None: + return DEFAULT_META_FACT_PATTERNS + if config.mode == "extend": + return (*config.patterns, *DEFAULT_META_FACT_PATTERNS) + return tuple(config.patterns) + + +def is_meta_fact( + content: object, + patterns: Sequence[re.Pattern[str]] = DEFAULT_META_FACT_PATTERNS, +) -> bool: + """Return ``True`` when ``content`` matches any pattern. + + Defensive against non-string input (returns ``False``) so a malformed + result cannot crash the filter pipeline. + """ + if not isinstance(content, str) or not content: + return False + return any(pattern.search(content) for pattern in patterns) + + +def filter_meta_facts( + items: Sequence[T], + get_content: Callable[[T], object], + config: MetaFactFilterConfig, +) -> list[T]: + """Drop items whose ``get_content(item)`` matches an active meta-fact pattern. + + Generic over ``T`` so callers can filter ``SearchResult`` / ``Memory`` / + raw shapes with the same primitive. Pure and synchronous. + """ + if not config.enabled: + return list(items) + patterns = resolve_meta_fact_patterns(config) + if not patterns: + return list(items) + kept: list[T] = [] + for item in items: + content = get_content(item) + matched_index = -1 + if isinstance(content, str) and content: + for index, pattern in enumerate(patterns): + if pattern.search(content): + matched_index = index + break + if matched_index >= 0: + if config.on_drop is not None and isinstance(content, str): + try: + config.on_drop(content, matched_index) + except Exception: + logger.warning("meta-fact filter on_drop callback raised; ignoring", exc_info=True) + continue + kept.append(item) + return kept diff --git a/atomicmemory/memory/registry.py b/atomicmemory/memory/registry.py index 79dabad..65164e4 100644 --- a/atomicmemory/memory/registry.py +++ b/atomicmemory/memory/registry.py @@ -12,7 +12,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -40,7 +40,7 @@ class AsyncProviderRegistration: SyncProviderFactory = Callable[[Any], ProviderRegistration] -AsyncProviderFactory = Callable[[Any], AsyncProviderRegistration] +AsyncProviderFactory = Callable[[Any], AsyncProviderRegistration | Awaitable[AsyncProviderRegistration]] class ProviderRegistry: diff --git a/atomicmemory/memory/service.py b/atomicmemory/memory/service.py index f7bd675..5f29989 100644 --- a/atomicmemory/memory/service.py +++ b/atomicmemory/memory/service.py @@ -9,6 +9,9 @@ from __future__ import annotations +import asyncio +import contextlib +import inspect from dataclasses import dataclass from typing import Any @@ -19,6 +22,8 @@ BaseMemoryProvider, ) from atomicmemory.memory.registry import ( + AsyncProviderFactory, + AsyncProviderRegistration, AsyncProviderRegistry, ProviderRegistry, default_async_registry, @@ -38,6 +43,25 @@ ) +async def _resolve_async_registration(factory: AsyncProviderFactory, config: Any) -> AsyncProviderRegistration: + """Invoke an async-registry factory, awaiting the result if it is awaitable. + + Args: + factory: An async-registry factory; may return the registration + directly or an awaitable of it (lazy/async construction). + config: The provider-specific config object passed to the factory. + + Returns: + The resolved ``AsyncProviderRegistration``. + """ + registration = factory(config) + # Mypy strict narrows the registration|awaitable union on BOTH branches + # of isawaitable here, so no casts are needed to pin the return type. + if inspect.isawaitable(registration): + return await registration + return registration + + @dataclass class MemoryServiceConfig: """Inputs to construct a service.""" @@ -74,19 +98,58 @@ def __init__(self, config: MemoryServiceConfig) -> None: self._pipelines: dict[str, MemoryProcessingPipeline] = {} def initialize(self, registry: ProviderRegistry | None = None) -> None: + """Initialize all configured providers atomically. + + Registrations are staged locally and committed only after every + factory and provider ``initialize()`` succeeds, so a mid-loop failure + never leaves partially registered providers observable. On failure, + already-staged providers get a best-effort ``close()`` before the + original error re-raises. + """ reg = registry if registry is not None else default_registry - for name, provider_config in self._config.provider_configs.items(): - factory = reg.get(name) - if factory is None: - continue - registration = factory(provider_config) - self._providers[name] = registration.provider - self._pipelines[name] = registration.pipeline - registration.provider.initialize() + staged_providers: dict[str, BaseMemoryProvider] = {} + staged_pipelines: dict[str, MemoryProcessingPipeline] = {} + try: + for name, provider_config in self._config.provider_configs.items(): + factory = reg.get(name) + if factory is None: + continue + registration = factory(provider_config) + staged_providers[name] = registration.provider + staged_pipelines[name] = registration.pipeline + registration.provider.initialize() + if self._default_provider_name not in staged_providers: + raise ConfigError(f"Default provider '{self._default_provider_name}' has no factory in the registry") + except BaseException: + for provider in staged_providers.values(): + with contextlib.suppress(Exception): + provider.close() + raise + # REPLACE (don't update) so a close() → re-initialize with a different + # registry can never resurrect a previously-closed provider. + self._providers = staged_providers + self._pipelines = staged_pipelines def close(self) -> None: - for provider in self._providers.values(): - provider.close() + """Close every provider best-effort, clear state, then re-raise the first failure. + + Only the FIRST close failure is re-raised; failures from later + providers are suppressed after every provider has been given the + chance to close. + """ + first_error: Exception | None = None + try: + for provider in self._providers.values(): + try: + provider.close() + except Exception as exc: + if first_error is None: + first_error = exc + finally: + self._providers = {} + self._pipelines = {} + if first_error is not None: + raise first_error def get_provider(self, name: str | None = None) -> BaseMemoryProvider: provider_name = name or self._default_provider_name @@ -143,19 +206,57 @@ def __init__(self, config: MemoryServiceConfig) -> None: self._pipelines: dict[str, MemoryProcessingPipeline] = {} async def initialize(self, registry: AsyncProviderRegistry | None = None) -> None: + """Initialize all configured providers atomically. + + Factories may return the registration directly or an awaitable of it + (enabling lazy/async provider construction). Registrations are staged + and committed only on full success; on failure, staged providers get a + best-effort ``close()`` before the original error re-raises. + """ reg = registry if registry is not None else default_async_registry - for name, provider_config in self._config.provider_configs.items(): - factory = reg.get(name) - if factory is None: - continue - registration = factory(provider_config) - self._providers[name] = registration.provider - self._pipelines[name] = registration.pipeline - await registration.provider.initialize() + staged_providers: dict[str, BaseAsyncMemoryProvider] = {} + staged_pipelines: dict[str, MemoryProcessingPipeline] = {} + try: + for name, provider_config in self._config.provider_configs.items(): + factory = reg.get(name) + if factory is None: + continue + registration = await _resolve_async_registration(factory, provider_config) + staged_providers[name] = registration.provider + staged_pipelines[name] = registration.pipeline + await registration.provider.initialize() + if self._default_provider_name not in staged_providers: + raise ConfigError(f"Default provider '{self._default_provider_name}' has no factory in the registry") + except BaseException: + await asyncio.gather( + *(provider.close() for provider in staged_providers.values()), + return_exceptions=True, + ) + raise + # REPLACE (don't update) — same stale-provider rationale as the sync service. + self._providers = staged_providers + self._pipelines = staged_pipelines async def close(self) -> None: - for provider in self._providers.values(): - await provider.close() + """Close every provider best-effort, clear state, then re-raise the first failure. + + Only the FIRST close failure is re-raised; failures from later + providers are suppressed after every provider has been given the + chance to close. + """ + first_error: Exception | None = None + try: + for provider in self._providers.values(): + try: + await provider.close() + except Exception as exc: + if first_error is None: + first_error = exc + finally: + self._providers = {} + self._pipelines = {} + if first_error is not None: + raise first_error def get_provider(self, name: str | None = None) -> BaseAsyncMemoryProvider: provider_name = name or self._default_provider_name diff --git a/atomicmemory/memory/types.py b/atomicmemory/memory/types.py index 949571d..ad32f80 100644 --- a/atomicmemory/memory/types.py +++ b/atomicmemory/memory/types.py @@ -25,6 +25,16 @@ MessageRole = Literal["user", "assistant", "system", "tool"] MemoryKind = Literal["fact", "episode", "summary", "procedure", "document"] +ContentClass = Literal["summary", "redacted", "raw"] +"""Sensitivity class for ingested content (any mode), mirroring core's ``content_class``. + +``summary`` (distilled, hosted-safe), ``redacted`` (sensitive spans removed by the +caller), or ``raw`` (verbatim prompt/response/diff/transcript). Honored by core +only on the verbatim path; under the default ``RAW_CONTENT_POLICY=reject`` it +rejects ``raw`` (and unclassified) content. The SDK never infers this — the caller +chooses it — so omitting it (or choosing ``raw``) fails closed rather than +mislabeling raw content as safe. +""" MemoryVersionEvent = Literal["created", "updated", "superseded", "invalidated"] PackageFormat = Literal["flat", "tiered", "structured"] IngestMode = Literal["text", "messages", "verbatim"] @@ -93,6 +103,13 @@ class IngestBase(BaseModel): scope: Scope provenance: Provenance | None = None metadata: dict[str, Any] | None = None + content_class: ContentClass | None = None + """Sensitivity class stamped on the ingested content. Applies to every mode: + a core running the default ``RAW_CONTENT_POLICY=reject`` refuses content that + is ``"raw"`` or carries no ``content_class`` at all (treated as raw), so + extraction (``text``/``messages``) and ``verbatim`` ingests alike must stamp + ``"summary"`` or ``"redacted"`` to be accepted. Omitted against an ``allow`` + core, ingest proceeds unstamped.""" class TextIngest(IngestBase): @@ -180,6 +197,29 @@ class SearchResult(BaseModel): similarity: float | None = None ranking_score: float | None = None relevance: float | None = None + # Per-result retrieval-receipt fields (replay pinning). ``version_id`` is + # None when the memory is unversioned; ``observed_at`` is ISO-8601. + version_id: str | None = None + observed_at: str | None = None + + +class RetrievalReceipt(BaseModel): + """Audit-grade retrieval receipt. + + Pins the embedding model and the ranked candidate set so a search can be + replayed bit-for-bit. Present when the provider emits one (AtomicMemory + always does on search). + """ + + model_config = ConfigDict(extra="ignore") + + embedding_provider: str | None = None + embedding_model: str + embedding_model_version: str + embedding_dimensions: int + query_text: str + candidate_ids: list[str] = Field(default_factory=list) + trace_id: str class SearchResultPage(BaseModel): @@ -189,6 +229,8 @@ class SearchResultPage(BaseModel): results: list[SearchResult] = Field(default_factory=list) cursor: str | None = None + # Audit-grade retrieval receipt for this search, when the provider emits one. + retrieval: RetrievalReceipt | None = None # --------------------------------------------------------------------------- diff --git a/atomicmemory/providers/atomicmemory/async_provider.py b/atomicmemory/providers/atomicmemory/async_provider.py index 026b10c..a26671e 100644 --- a/atomicmemory/providers/atomicmemory/async_provider.py +++ b/atomicmemory/providers/atomicmemory/async_provider.py @@ -17,6 +17,7 @@ import httpx from atomicmemory.core.errors import ProviderError +from atomicmemory.memory.meta_fact_filter import filter_meta_facts from atomicmemory.memory.provider import BaseAsyncMemoryProvider from atomicmemory.memory.types import ( Capabilities, @@ -50,6 +51,7 @@ to_ingest_result, to_memory, to_memory_version, + to_retrieval_receipt, to_search_result, ) from atomicmemory.providers.atomicmemory.path import normalize_api_version @@ -111,6 +113,13 @@ async def do_ingest(self, input: IngestInput) -> IngestResult: raw = await afetch_json(self._require_client(), self._http_options, path, method="POST", json=body) return to_ingest_result(raw) + def _apply_meta_fact_filter(self, results: list[SearchResult]) -> list[SearchResult]: + """Drop meta-facts when the opt-in filter is enabled; otherwise pass through.""" + config = self._config.meta_fact_filter + if config is None or not config.enabled: + return results + return filter_meta_facts(results, lambda result: result.memory.content, config) + async def do_search(self, request: SearchRequest) -> SearchResultPage: body = _build_search_body(request) raw = await afetch_json( @@ -121,7 +130,8 @@ async def do_search(self, request: SearchRequest) -> SearchResultPage: json=body, ) return SearchResultPage( - results=[to_search_result(m, request.scope) for m in raw.get("memories", [])], + results=self._apply_meta_fact_filter([to_search_result(m, request.scope) for m in raw.get("memories", [])]), + retrieval=to_retrieval_receipt(raw["retrieval"]) if raw.get("retrieval") else None, ) async def do_get(self, ref: MemoryRef) -> Memory | None: @@ -192,7 +202,9 @@ async def package(self, request: PackageRequest) -> ContextPackage: method="POST", json=body, ) - results: list[SearchResult] = [to_search_result(m, request.scope) for m in raw.get("memories", [])] + results: list[SearchResult] = self._apply_meta_fact_filter( + [to_search_result(m, request.scope) for m in raw.get("memories", [])] + ) budget_constrained = raw.get("budget_constrained") if not isinstance(budget_constrained, bool): raise ValueError( @@ -217,7 +229,8 @@ async def search_as_of(self, request: SearchRequest, as_of: datetime) -> SearchR json=body, ) return SearchResultPage( - results=[to_search_result(m, request.scope) for m in raw.get("memories", [])], + results=self._apply_meta_fact_filter([to_search_result(m, request.scope) for m in raw.get("memories", [])]), + retrieval=to_retrieval_receipt(raw["retrieval"]) if raw.get("retrieval") else None, ) async def history(self, ref: MemoryRef) -> list[MemoryVersion]: diff --git a/atomicmemory/providers/atomicmemory/config.py b/atomicmemory/providers/atomicmemory/config.py index e5c8041..7d87669 100644 --- a/atomicmemory/providers/atomicmemory/config.py +++ b/atomicmemory/providers/atomicmemory/config.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field +from atomicmemory.memory.meta_fact_filter import MetaFactFilterConfig + ATOMICMEMORY_DEFAULT_TIMEOUT_SECONDS: float = 30.0 """Default request timeout (seconds). Mirrors TS ``ATOMICMEMORY_DEFAULT_TIMEOUT`` (ms).""" @@ -17,10 +19,10 @@ class AtomicMemoryProviderConfig(BaseModel): """Inputs to construct an AtomicMemoryProvider.""" - model_config = ConfigDict(extra="forbid", populate_by_name=True) + model_config = ConfigDict(extra="forbid", populate_by_name=True, arbitrary_types_allowed=True) api_url: str = Field(alias="apiUrl") - """Base URL of the atomicmemory-core instance, e.g. ``http://localhost:3050``.""" + """Base URL of the atomicmemory-core instance, e.g. ``http://localhost:17350``.""" api_key: str | None = Field(default=None, alias="apiKey") """Optional bearer token forwarded as ``Authorization: Bearer ``.""" @@ -36,3 +38,6 @@ class AtomicMemoryProviderConfig(BaseModel): alias="apiVersion", ) """API-version segment prepended to every route path (e.g. ``v1`` → ``/v1/...``).""" + + meta_fact_filter: MetaFactFilterConfig | None = Field(default=None, alias="metaFactFilter") + """Optional opt-in post-retrieval meta-fact filter. Off when unset.""" diff --git a/atomicmemory/providers/atomicmemory/mappers.py b/atomicmemory/providers/atomicmemory/mappers.py index bef5a4a..1b3a353 100644 --- a/atomicmemory/providers/atomicmemory/mappers.py +++ b/atomicmemory/providers/atomicmemory/mappers.py @@ -17,6 +17,7 @@ MemoryVersion, MemoryVersionEvent, Provenance, + RetrievalReceipt, Scope, SearchResult, ) @@ -118,9 +119,16 @@ def to_search_result(raw: dict[str, Any], scope: Scope) -> SearchResult: similarity=similarity, ranking_score=ranking_score, relevance=relevance, + version_id=raw.get("version_id"), + observed_at=raw.get("observed_at"), ) +def to_retrieval_receipt(raw: dict[str, Any]) -> RetrievalReceipt: + """Map the snake_case wire retrieval receipt to the SDK model.""" + return RetrievalReceipt.model_validate(raw) + + def to_ingest_result(raw: dict[str, Any]) -> IngestResult: """Map ``POST /memories/ingest[/quick]`` response to V3 IngestResult.""" return IngestResult( diff --git a/atomicmemory/providers/atomicmemory/provider.py b/atomicmemory/providers/atomicmemory/provider.py index a8d43e6..aeab9b3 100644 --- a/atomicmemory/providers/atomicmemory/provider.py +++ b/atomicmemory/providers/atomicmemory/provider.py @@ -14,6 +14,7 @@ import httpx from atomicmemory.core.errors import ProviderError +from atomicmemory.memory.meta_fact_filter import filter_meta_facts from atomicmemory.memory.provider import BaseMemoryProvider from atomicmemory.memory.types import ( Capabilities, @@ -49,6 +50,7 @@ to_ingest_result, to_memory, to_memory_version, + to_retrieval_receipt, to_search_result, ) from atomicmemory.providers.atomicmemory.path import normalize_api_version @@ -103,6 +105,13 @@ def do_ingest(self, input: IngestInput) -> IngestResult: raw = fetch_json(self._require_client(), self._http_options, path, method="POST", json=body) return to_ingest_result(raw) + def _apply_meta_fact_filter(self, results: list[SearchResult]) -> list[SearchResult]: + """Drop meta-facts when the opt-in filter is enabled; otherwise pass through.""" + config = self._config.meta_fact_filter + if config is None or not config.enabled: + return results + return filter_meta_facts(results, lambda result: result.memory.content, config) + def do_search(self, request: SearchRequest) -> SearchResultPage: body = _build_search_body(request) raw = fetch_json( @@ -113,7 +122,8 @@ def do_search(self, request: SearchRequest) -> SearchResultPage: json=body, ) return SearchResultPage( - results=[to_search_result(m, request.scope) for m in raw.get("memories", [])], + results=self._apply_meta_fact_filter([to_search_result(m, request.scope) for m in raw.get("memories", [])]), + retrieval=to_retrieval_receipt(raw["retrieval"]) if raw.get("retrieval") else None, ) def do_get(self, ref: MemoryRef) -> Memory | None: @@ -185,7 +195,9 @@ def package(self, request: PackageRequest) -> ContextPackage: method="POST", json=body, ) - results: list[SearchResult] = [to_search_result(m, request.scope) for m in raw.get("memories", [])] + results: list[SearchResult] = self._apply_meta_fact_filter( + [to_search_result(m, request.scope) for m in raw.get("memories", [])] + ) budget_constrained = raw.get("budget_constrained") if not isinstance(budget_constrained, bool): raise ValueError( @@ -209,7 +221,8 @@ def search_as_of(self, request: SearchRequest, as_of: datetime) -> SearchResultP json=body, ) return SearchResultPage( - results=[to_search_result(m, request.scope) for m in raw.get("memories", [])], + results=self._apply_meta_fact_filter([to_search_result(m, request.scope) for m in raw.get("memories", [])]), + retrieval=to_retrieval_receipt(raw["retrieval"]) if raw.get("retrieval") else None, ) def history(self, ref: MemoryRef) -> list[MemoryVersion]: @@ -271,6 +284,12 @@ def _build_ingest_body(input: IngestInput) -> dict[str, Any]: body["skip_extraction"] = True if input.metadata: body["metadata"] = input.metadata + # Forward the caller-chosen sensitivity class for every mode (text / messages / + # verbatim): a RAW_CONTENT_POLICY=reject core gates ingest on content_class + # regardless of mode. Never defaulted — omitting it fails closed rather than + # the SDK labeling raw content as safe on the caller's behalf. + if input.content_class is not None: + body["content_class"] = input.content_class return body diff --git a/contract/CONTRACT.md b/contract/CONTRACT.md new file mode 100644 index 0000000..3240e27 --- /dev/null +++ b/contract/CONTRACT.md @@ -0,0 +1,114 @@ +# AtomicMemory Provider Contract — Wire Encoding (v1) + +This document pins the wire encodings of the `MemoryProvider` boundary types +that the in-process TypeScript types (`packages/sdk/src/memory/types.ts`) leave +ambiguous. It is the prose companion to the machine-readable schemas under +[`schema/v1/`](./schema/v1/). Cross-language consumers (non-JS callers, +the future dashboard) MUST follow the rules here; they are not inferrable from +the `.ts` types alone. + +Versioning: the JSON Schemas carry a top-level `"version": 1` and a `$id` +containing `/v1/`. Breaking changes get a new `v2/` directory, not an in-place +edit. + +## 1. Date encoding (`FilterExpr.value`, and Date fields generally) + +`FieldFilter.value` is typed `string | number | boolean | Date | Array<...>` +in-process. On the wire there is no `Date`: + +- A `Date` operand is encoded as an **ISO-8601 / RFC-3339 date-time string** + via `Date.prototype.toISOString()` (e.g. `"2026-05-30T12:00:00.000Z"`, + always UTC, millisecond precision, trailing `Z`). +- The same rule applies to every Date-typed field that crosses the boundary: + `Memory.createdAt`, `Memory.updatedAt`, `TemporalSearch.asOf` + (serialized as `as_of`), `observed_at`. These appear as ISO-8601 strings in + JSON even though the SDK surfaces them as `Date` objects. +- Numeric operands stay numbers; booleans stay booleans; string and + number arrays serialize as JSON arrays. + +Note: `SearchRequest.filter` is part of the contract but the AtomicMemory +provider's `doSearch` does not yet forward `filter` to core. The encoding rule +above is the contract any provider MUST honor once it wires filters; it is not +a claim that AtomicMemory applies server-side filtering today. + +## 2. `list` cursor format + +The `cursor` returned by `list` (and the `ListResultPage.cursor` / extension +`cursor`) is an **opaque, stringified non-negative integer offset**: + +- The provider derives it as `String(previousOffset + pageLength)` and reads it + back with `parseInt(cursor, 10)`. +- A request with no `cursor` starts at offset `0`. +- `cursor` is **absent** (undefined) on the last page — i.e. when fewer than + `limit` rows were returned there is no next page and no `cursor` field. +- Treat the value as opaque: do not parse, increment, or otherwise interpret it + client-side. It is offset-based today; that is an implementation detail behind + the opaque-string contract. + +## 3. `Scope` field mapping + +The backend-agnostic `Scope` maps onto AtomicMemory core's wire fields as +follows (see `scope-mapper.ts` and the provider request builders): + +| `Scope` field | Wire field | Notes | +| --- | --- | --- | +| `scope.user` | `user_id` | Required by AtomicMemory (`requiredScope.default = ['user']`). | +| `scope.thread` | `session_id` | Emitted on ingest, search, and list only. Routes that do not filter by session (get/delete/expand) must not send or echo it. Returned `session_id` must round-trip the requested `thread`. | +| `scope.namespace` | `namespace_scope` | Workspace/namespace partition for search and packaging. | +| `scope.agent` | *(no direct core field)* | AtomicMemory does not project the generic `Scope.agent` onto a wire field on the core search/list/get path. Agent-scoped behavior is expressed through the AtomicMemory-specific `MemoryScope` workspace variant (`agent_id` / `agent_scope`), which is a separate, namespace-extension surface — not the generic `Scope.agent`. A generic `Scope.agent` value does not silently become `agent_id`. | + +## 4. `IngestResult.unchanged` + +`IngestResult.unchanged` is **always an empty array on the wire today**. + +Core's ingest responses report created and updated memory ids but do not emit a +no-op/deduped set, so the provider populates `created` and `updated` from the +backend and sets `unchanged` to `[]`. Consumers must not infer "nothing was a +duplicate" from an empty `unchanged`; the field is reserved for a future +backend capability and currently carries no signal. + +## 5. `score` vs `rankingScore` and cross-provider comparability + +`SearchResult` exposes several scalar scores. Their semantics: + +- **`score`** — the backward-compatible provider score. For AtomicMemory this + is the composite `rankingScore` and is **not normalized** (may fall outside + `[0, 1]`). Other providers preserve their own historical `score` meaning. + Because the definition is provider-specific, `score` is **not comparable + across providers** and must not be used for cross-provider thresholds. +- **`similarity`** — raw semantic/vector similarity when the provider exposes + it. Higher is better. Provider-defined scale. +- **`rankingScore`** — the composite ranking/debug score (RRF-style fusion in + AtomicMemory). Useful for debugging rank order; **not normalized**. +- **`relevance`** — normalized injection relevance clamped to `[0, 1]`. This is + the field to use for threshold checks (`SearchRequest.threshold`) and the + only score with a portable, cross-provider-comparable meaning. + +Rule of thumb: filter and gate on **`relevance`**; surface `score` only for +backward-compatible display; use `similarity` / `rankingScore` for debugging. + +## 6. Retrieval receipt + +The audit-grade retrieval receipt is **snake_case** on the wire, mirroring the +AtomicMemory core search response (`/search` and `/search/fast`). It is the +per-response object plus two per-result fields: + +- Per response (`SearchResultPage.retrieval`): + `embedding_provider`, `embedding_model`, `embedding_model_version`, + `embedding_dimensions`, `query_text`, `candidate_ids` (returned memory ids in + ranked order), `trace_id`. + - `embedding_model_version` is the resolved model id (no supported provider + exposes a separate immutable version string); it is never fabricated. + - `embedding_provider` is present on the core wire shape; the + cross-language required set is the six fields named in the schema's + `required` list (`embedding_model`, `embedding_model_version`, + `embedding_dimensions`, `query_text`, `candidate_ids`, `trace_id`). +- Per result (`SearchResult`): + - `version_id` — the owning claim's `current_version_id`, letting a client + pin the exact retrieved version as a replay fixture. `null` when the memory + has no claim version (e.g. workspace-pool rows). + - `observed_at` — ISO-8601 date-time when the memory was observed/recorded. + +The receipt is always present on search responses; it is not gated on retrieval +tracing. It exists so a retrieval can be logged and replayed bit-for-bit, which +is what makes an audited path deterministic. diff --git a/contract/VENDORED.json b/contract/VENDORED.json new file mode 100644 index 0000000..ea6c515 --- /dev/null +++ b/contract/VENDORED.json @@ -0,0 +1,8 @@ +{ + "source_repo": "atomicmemory-internal", + "source_path": "packages/sdk/schema/v1 + packages/sdk/CONTRACT.md", + "source_sdk_version": "1.1.0", + "source_main_commit": "2a67871", + "schema_last_modified_commit": "6fccaf4", + "vendored_at": "2026-06-09" +} diff --git a/contract/v1/capabilities-descriptor.schema.json b/contract/v1/capabilities-descriptor.schema.json new file mode 100644 index 0000000..6e3b01a --- /dev/null +++ b/contract/v1/capabilities-descriptor.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.atomicmemory.dev/provider-contract/v1/capabilities-descriptor.schema.json", + "title": "CoreCapabilities (v1)", + "version": 1, + "description": "Entry-point schema for the over-the-wire capabilities descriptor served at AtomicMemory core GET /v1/capabilities. Delegates to provider-contract.schema.json#/$defs/CoreCapabilities.", + "$ref": "https://schemas.atomicmemory.dev/provider-contract/v1/provider-contract.schema.json#/$defs/CoreCapabilities" +} diff --git a/contract/v1/conformance/capabilities-descriptor.json b/contract/v1/conformance/capabilities-descriptor.json new file mode 100644 index 0000000..6ce8bdc --- /dev/null +++ b/contract/v1/conformance/capabilities-descriptor.json @@ -0,0 +1,20 @@ +{ + "name": "capabilities-descriptor", + "operation": "capabilities", + "description": "Over-the-wire capabilities descriptor served by core at GET /v1/capabilities. A protocol caller GETs this (no request body) to negotiate the feature surface at startup without the JS SDK. snake_case wire shape.", + "request_schema": null, + "request": null, + "response_schema": "capabilities-descriptor.schema.json", + "expected_response": { + "version": 1, + "ingest_modes": ["text", "messages", "verbatim"], + "search": true, + "retrieval": "semantic", + "deterministic_fast_path": true, + "extensions": { + "health": true, + "versioning": true, + "temporal": true + } + } +} diff --git a/contract/v1/conformance/ingest-text.json b/contract/v1/conformance/ingest-text.json new file mode 100644 index 0000000..e05a0af --- /dev/null +++ b/contract/v1/conformance/ingest-text.json @@ -0,0 +1,18 @@ +{ + "name": "ingest-text", + "operation": "ingest", + "description": "Full-extraction text ingest. The request is an IngestInput with mode=text; the response is an IngestResult naming created/updated/unchanged ids.", + "request_schema": "ingest-input.schema.json", + "request": { + "mode": "text", + "content": "We agreed to block deploys when the PR check is red.", + "scope": { "user": "u1", "namespace": "team-alpha" }, + "provenance": { "source": "app", "extractor": "codex-hook" } + }, + "response_schema": "provider-contract.schema.json#/$defs/IngestResult", + "expected_response": { + "created": ["mem_a1"], + "updated": [], + "unchanged": [] + } +} diff --git a/contract/v1/conformance/ingest-verbatim.json b/contract/v1/conformance/ingest-verbatim.json new file mode 100644 index 0000000..6a9f9e3 --- /dev/null +++ b/contract/v1/conformance/ingest-verbatim.json @@ -0,0 +1,19 @@ +{ + "name": "ingest-verbatim", + "operation": "ingest", + "description": "Verbatim ingest: one input = one memory record, deterministic (no LLM extraction). Capability-gated on Capabilities.ingestModes including 'verbatim'.", + "request_schema": "ingest-input.schema.json", + "request": { + "mode": "verbatim", + "content": "The deploy gate requires a green PR check.", + "kind": "fact", + "scope": { "user": "u1", "namespace": "team-alpha" }, + "metadata": { "externalId": "atom-7", "source": "app" } + }, + "response_schema": "provider-contract.schema.json#/$defs/IngestResult", + "expected_response": { + "created": ["mem_v1"], + "updated": [], + "unchanged": [] + } +} diff --git a/contract/v1/conformance/manifest.json b/contract/v1/conformance/manifest.json new file mode 100644 index 0000000..e54f006 --- /dev/null +++ b/contract/v1/conformance/manifest.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "version": 1, + "title": "AtomicMemory cross-provider conformance corpus (v1)", + "description": "Versioned, language-neutral conformance corpus. Each case names a contract operation, a concrete request payload, and the expected response *shape* (validated against a v1 JSON Schema entry point under ../, not against exact values). A future MemoryProvider implementation runs every case's request and expected response through these schemas to prove it speaks the v1 contract. Schemas referenced by `request_schema` / `response_schema` are filenames relative to the schema/v1/ directory (the parent of this conformance/ dir).", + "cases": [ + { "name": "ingest-text", "file": "ingest-text.json" }, + { "name": "ingest-verbatim", "file": "ingest-verbatim.json" }, + { "name": "search-with-retrieval-receipt", "file": "search-with-retrieval-receipt.json" }, + { "name": "capabilities-descriptor", "file": "capabilities-descriptor.json" } + ] +} diff --git a/contract/v1/conformance/search-with-retrieval-receipt.json b/contract/v1/conformance/search-with-retrieval-receipt.json new file mode 100644 index 0000000..7447932 --- /dev/null +++ b/contract/v1/conformance/search-with-retrieval-receipt.json @@ -0,0 +1,39 @@ +{ + "name": "search-with-retrieval-receipt", + "operation": "search", + "description": "Semantic search returning ranked hits plus the retrieval receipt (snake_case). The receipt lets a caller pin and replay a retrieval bit-for-bit; per-result version_id/observed_at are the per-hit receipt fields.", + "request_schema": "provider-contract.schema.json#/$defs/SearchRequest", + "request": { + "query": "deploy gate", + "scope": { "user": "u1", "namespace": "team-alpha" }, + "limit": 5 + }, + "response_schema": "search-result-page.schema.json", + "expected_response": { + "results": [ + { + "memory": { + "id": "mem_v1", + "content": "The deploy gate requires a green PR check.", + "scope": { "user": "u1", "namespace": "team-alpha" }, + "kind": "fact", + "createdAt": "2026-05-30T12:00:00.000Z" + }, + "score": 0.82, + "similarity": 0.88, + "relevance": 0.74, + "version_id": "ver_9", + "observed_at": "2026-05-30T12:00:00.000Z" + } + ], + "retrieval": { + "embedding_provider": "ollama", + "embedding_model": "mxbai-embed-large", + "embedding_model_version": "mxbai-embed-large", + "embedding_dimensions": 1024, + "query_text": "deploy gate", + "candidate_ids": ["mem_v1"], + "trace_id": "trace_abc" + } + } +} diff --git a/contract/v1/ingest-input.schema.json b/contract/v1/ingest-input.schema.json new file mode 100644 index 0000000..a299abd --- /dev/null +++ b/contract/v1/ingest-input.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.atomicmemory.dev/provider-contract/v1/ingest-input.schema.json", + "title": "IngestInput (v1)", + "version": 1, + "description": "Entry-point schema for a single MemoryProvider.ingest() input. Delegates to the IngestInput union defined in provider-contract.schema.json.", + "$ref": "https://schemas.atomicmemory.dev/provider-contract/v1/provider-contract.schema.json#/$defs/IngestInput" +} diff --git a/contract/v1/provider-contract.schema.json b/contract/v1/provider-contract.schema.json new file mode 100644 index 0000000..03834ea --- /dev/null +++ b/contract/v1/provider-contract.schema.json @@ -0,0 +1,336 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.atomicmemory.dev/provider-contract/v1/provider-contract.schema.json", + "title": "AtomicMemory Provider Contract (v1)", + "version": 1, + "description": "Versioned, language-neutral JSON Schema for the AtomicMemory MemoryProvider wire contract. Mirrors packages/sdk/src/memory/types.ts. Wire encoding conventions are documented in packages/sdk/CONTRACT.md. SDK field names are camelCase; the snake_case fields under `RetrievalReceipt` and the per-result receipt fields (`version_id`, `observed_at`) mirror the AtomicMemory core search-response wire shape.", + "$defs": { + "Scope": { + "title": "Scope", + "description": "Identity and partition context. Providers declare required fields via Capabilities.requiredScope. AtomicMemory maps scope.thread -> session_id and scope.namespace -> namespace_scope on the wire (see CONTRACT.md).", + "type": "object", + "properties": { + "user": { "type": "string" }, + "agent": { "type": "string" }, + "namespace": { "type": "string" }, + "thread": { "type": "string" } + }, + "additionalProperties": false + }, + "Provenance": { + "title": "Provenance", + "type": "object", + "properties": { + "source": { "type": "string" }, + "sourceUrl": { "type": "string" }, + "sourceId": { "type": "string" }, + "extractor": { "type": "string" } + }, + "additionalProperties": false + }, + "MemoryKind": { + "title": "MemoryKind", + "type": "string", + "enum": ["fact", "episode", "summary", "procedure", "document"] + }, + "Message": { + "title": "Message", + "type": "object", + "properties": { + "role": { "type": "string", "enum": ["user", "assistant", "system", "tool"] }, + "content": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["role", "content"], + "additionalProperties": false + }, + "Memory": { + "title": "Memory", + "description": "A single memory unit. `createdAt`/`updatedAt` are ISO-8601 date-time strings on the wire (Date objects in-process).", + "type": "object", + "properties": { + "id": { "type": "string" }, + "content": { "type": "string" }, + "scope": { "$ref": "#/$defs/Scope" }, + "kind": { "$ref": "#/$defs/MemoryKind" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "provenance": { "$ref": "#/$defs/Provenance" }, + "metadata": { "type": "object", "additionalProperties": true } + }, + "required": ["id", "content", "scope", "createdAt"], + "additionalProperties": false + }, + "IngestInput": { + "title": "IngestInput", + "description": "Discriminated union over `mode`: text, messages, or verbatim. Verbatim is capability-gated (Capabilities.ingestModes must include 'verbatim').", + "oneOf": [ + { "$ref": "#/$defs/TextIngest" }, + { "$ref": "#/$defs/MessageIngest" }, + { "$ref": "#/$defs/VerbatimIngest" } + ] + }, + "TextIngest": { + "title": "TextIngest", + "type": "object", + "properties": { + "mode": { "const": "text" }, + "content": { "type": "string" }, + "scope": { "$ref": "#/$defs/Scope" }, + "provenance": { "$ref": "#/$defs/Provenance" }, + "metadata": { "type": "object", "additionalProperties": true } + }, + "required": ["mode", "content", "scope"], + "additionalProperties": false + }, + "MessageIngest": { + "title": "MessageIngest", + "type": "object", + "properties": { + "mode": { "const": "messages" }, + "messages": { "type": "array", "items": { "$ref": "#/$defs/Message" } }, + "scope": { "$ref": "#/$defs/Scope" }, + "provenance": { "$ref": "#/$defs/Provenance" }, + "metadata": { "type": "object", "additionalProperties": true } + }, + "required": ["mode", "messages", "scope"], + "additionalProperties": false + }, + "VerbatimIngest": { + "title": "VerbatimIngest", + "description": "Bypass LLM extraction; one input = one memory record (deterministic).", + "type": "object", + "properties": { + "mode": { "const": "verbatim" }, + "content": { "type": "string" }, + "kind": { "$ref": "#/$defs/MemoryKind" }, + "scope": { "$ref": "#/$defs/Scope" }, + "provenance": { "$ref": "#/$defs/Provenance" }, + "metadata": { "type": "object", "additionalProperties": true } + }, + "required": ["mode", "content", "scope"], + "additionalProperties": false + }, + "IngestResult": { + "title": "IngestResult", + "description": "`unchanged` is always an empty array on the wire today; core does not currently report no-op dedupes (see CONTRACT.md).", + "type": "object", + "properties": { + "created": { "type": "array", "items": { "type": "string" } }, + "updated": { "type": "array", "items": { "type": "string" } }, + "unchanged": { "type": "array", "items": { "type": "string" } } + }, + "required": ["created", "updated", "unchanged"], + "additionalProperties": false + }, + "FieldFilter": { + "title": "FieldFilter", + "description": "`value` Date operands are encoded as ISO-8601 date-time strings on the wire (see CONTRACT.md).", + "type": "object", + "properties": { + "field": { "type": "string" }, + "op": { + "type": "string", + "enum": ["eq", "neq", "gt", "gte", "lt", "lte", "in", "contains", "exists"] + }, + "value": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "array", "items": { "type": ["string", "number"] } } + ] + } + }, + "required": ["field", "op"], + "additionalProperties": false + }, + "FilterExpr": { + "title": "FilterExpr", + "oneOf": [ + { + "type": "object", + "properties": { "and": { "type": "array", "items": { "$ref": "#/$defs/FilterExpr" } } }, + "required": ["and"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { "or": { "type": "array", "items": { "$ref": "#/$defs/FilterExpr" } } }, + "required": ["or"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { "not": { "$ref": "#/$defs/FilterExpr" } }, + "required": ["not"], + "additionalProperties": false + }, + { "$ref": "#/$defs/FieldFilter" } + ] + }, + "SearchRequest": { + "title": "SearchRequest", + "type": "object", + "properties": { + "query": { "type": "string" }, + "scope": { "$ref": "#/$defs/Scope" }, + "limit": { "type": "integer", "minimum": 0 }, + "threshold": { "type": "number" }, + "filter": { "$ref": "#/$defs/FilterExpr" }, + "reranker": { "type": "string" } + }, + "required": ["query", "scope"], + "additionalProperties": false + }, + "RetrievalReceipt": { + "title": "RetrievalReceipt", + "description": "Audit-grade retrieval receipt. snake_case mirrors the AtomicMemory core search-response wire shape. Always present on /search and /search/fast; lets a client pin and replay a retrieval bit-for-bit.", + "type": "object", + "properties": { + "embedding_provider": { "type": "string" }, + "embedding_model": { "type": "string" }, + "embedding_model_version": { "type": "string" }, + "embedding_dimensions": { "type": "integer", "minimum": 1 }, + "query_text": { "type": "string" }, + "candidate_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Returned memory ids in ranked order." + }, + "trace_id": { "type": "string" } + }, + "required": [ + "embedding_model", + "embedding_model_version", + "embedding_dimensions", + "query_text", + "candidate_ids", + "trace_id" + ], + "additionalProperties": false + }, + "SearchResult": { + "title": "SearchResult", + "description": "`score` is the backward-compatible provider score (for AtomicMemory the composite rankingScore, not normalized). Prefer `relevance` (normalized [0,1]) for threshold checks. `version_id`/`observed_at` are the per-result fields of the retrieval receipt.", + "type": "object", + "properties": { + "memory": { "$ref": "#/$defs/Memory" }, + "score": { "type": "number" }, + "similarity": { "type": "number" }, + "rankingScore": { "type": "number" }, + "relevance": { "type": "number", "minimum": 0, "maximum": 1 }, + "version_id": { "type": ["string", "null"] }, + "observed_at": { "type": "string", "format": "date-time" } + }, + "required": ["memory", "score"], + "additionalProperties": false + }, + "SearchResultPage": { + "title": "SearchResultPage", + "type": "object", + "properties": { + "results": { "type": "array", "items": { "$ref": "#/$defs/SearchResult" } }, + "cursor": { "type": "string" }, + "retrieval": { "$ref": "#/$defs/RetrievalReceipt" } + }, + "required": ["results"], + "additionalProperties": false + }, + "Capabilities": { + "title": "Capabilities", + "type": "object", + "properties": { + "ingestModes": { + "type": "array", + "items": { "type": "string", "enum": ["text", "messages", "verbatim"] } + }, + "requiredScope": { + "type": "object", + "properties": { + "default": { "$ref": "#/$defs/ScopeFieldList" } + }, + "required": ["default"], + "additionalProperties": { "$ref": "#/$defs/ScopeFieldList" } + }, + "extensions": { + "type": "object", + "properties": { + "update": { "type": "boolean" }, + "package": { "type": "boolean" }, + "temporal": { "type": "boolean" }, + "graph": { "type": "boolean" }, + "forget": { "type": "boolean" }, + "profile": { "type": "boolean" }, + "reflect": { "type": "boolean" }, + "versioning": { "type": "boolean" }, + "batch": { "type": "boolean" }, + "health": { "type": "boolean" } + }, + "required": [ + "update", "package", "temporal", "graph", "forget", + "profile", "reflect", "versioning", "batch", "health" + ], + "additionalProperties": false + }, + "customExtensions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "version": { "type": "string" }, + "description": { "type": "string" } + }, + "additionalProperties": false + } + }, + "supportedRerankers": { "type": "array", "items": { "type": "string" } }, + "supportedFilterOps": { + "type": "array", + "items": { "$ref": "#/$defs/FieldFilter/properties/op" } + }, + "maxTokenBudget": { "type": "number" } + }, + "required": ["ingestModes", "requiredScope", "extensions"], + "additionalProperties": false + }, + "ScopeFieldList": { + "type": "array", + "items": { "type": "string", "enum": ["user", "agent", "namespace", "thread"] } + }, + "CoreCapabilities": { + "title": "CoreCapabilities", + "description": "Over-the-wire capabilities descriptor served by AtomicMemory core at GET /v1/capabilities. snake_case wire shape that a non-JS protocol caller deserializes to negotiate the feature surface at startup. This is the wire equivalent of the in-process SDK `Capabilities` def above; the two intentionally differ in casing because `Capabilities` is the camelCase SDK provider shape while `CoreCapabilities` is the snake_case HTTP descriptor.", + "type": "object", + "properties": { + "version": { "type": "integer", "minimum": 1 }, + "ingest_modes": { + "type": "array", + "items": { "type": "string", "enum": ["text", "messages", "verbatim"] } + }, + "search": { "type": "boolean" }, + "retrieval": { "type": "string", "enum": ["semantic"] }, + "deterministic_fast_path": { "type": "boolean" }, + "extensions": { + "type": "object", + "properties": { + "health": { "type": "boolean" }, + "versioning": { "type": "boolean" }, + "temporal": { "type": "boolean" } + }, + "required": ["health", "versioning", "temporal"], + "additionalProperties": false + } + }, + "required": [ + "version", + "ingest_modes", + "search", + "retrieval", + "deterministic_fast_path", + "extensions" + ], + "additionalProperties": false + } + } +} diff --git a/contract/v1/search-result-page.schema.json b/contract/v1/search-result-page.schema.json new file mode 100644 index 0000000..94a7730 --- /dev/null +++ b/contract/v1/search-result-page.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.atomicmemory.dev/provider-contract/v1/search-result-page.schema.json", + "title": "SearchResultPage (v1)", + "version": 1, + "description": "Entry-point schema for a MemoryProvider.search() response page, including the retrieval receipt. Delegates to provider-contract.schema.json.", + "$ref": "https://schemas.atomicmemory.dev/provider-contract/v1/provider-contract.schema.json#/$defs/SearchResultPage" +} diff --git a/examples/async_pipeline.py b/examples/async_pipeline.py index 513ba9a..b4d35ce 100644 --- a/examples/async_pipeline.py +++ b/examples/async_pipeline.py @@ -6,7 +6,7 @@ from atomicmemory import AsyncMemoryClient, Scope, SearchRequest, TextIngest -API_URL = "http://localhost:3050" +API_URL = "http://localhost:17350" async def main() -> None: diff --git a/examples/basic_ingest_search.py b/examples/basic_ingest_search.py index af3a29f..9ef4086 100644 --- a/examples/basic_ingest_search.py +++ b/examples/basic_ingest_search.py @@ -2,7 +2,7 @@ Prereq: cd ../atomicmemory-core - npm run dev # starts core on http://localhost:3050 + npm run dev # starts core on http://localhost:17350 Then: uv run python examples/basic_ingest_search.py @@ -12,7 +12,7 @@ from atomicmemory import MemoryClient, MemoryRef, Scope, SearchRequest, TextIngest -API_URL = "http://localhost:3050" +API_URL = "http://localhost:17350" def main() -> None: diff --git a/pyproject.toml b/pyproject.toml index 91b08bb..672f2ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "atomicmemory" -version = "1.0.1" +version = "1.1.0" description = "Python client SDK for AtomicMemory memory and artifact storage." readme = "README.md" requires-python = ">=3.10" @@ -36,6 +36,7 @@ dev = [ "ruff>=0.6", "mypy>=1.10", "vulture>=2.11", + "jsonschema[format]>=4.21", # 4.21+: stable referencing/Registry API + format extras ] [project.urls] @@ -56,6 +57,7 @@ packages = ["atomicmemory"] [tool.hatch.build.targets.sdist] include = [ "atomicmemory", + "contract", "examples", "tests", "CHANGELOG.md", diff --git a/scripts/refresh_contract.py b/scripts/refresh_contract.py new file mode 100644 index 0000000..ad5e9e1 --- /dev/null +++ b/scripts/refresh_contract.py @@ -0,0 +1,66 @@ +"""Refresh the vendored v1 contract artifacts from a local TS-SDK checkout. + +Run MANUALLY when the TS contract revs — never in CI. The committed copy +under contract/ is the source of truth for this repo's tests; this script +exists so refreshes are mechanical and the provenance manifest can't drift +from what was actually copied. + +Usage: + uv run python scripts/refresh_contract.py /path/to/atomicmemory-internal +""" + +from __future__ import annotations + +import datetime as _dt +import json +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONTRACT = REPO_ROOT / "contract" +SOURCE_SUBPATHS = ("packages/sdk/schema/v1", "packages/sdk/CONTRACT.md") + + +def _git(source_repo: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(source_repo), *args], text=True).strip() + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + source = Path(sys.argv[1]).resolve() + schema_dir = source / "packages/sdk/schema/v1" + contract_md = source / "packages/sdk/CONTRACT.md" + if not schema_dir.is_dir() or not contract_md.is_file(): + print(f"refresh_contract: {source} does not look like an atomicmemory-internal checkout") + return 2 + + shutil.rmtree(CONTRACT / "v1", ignore_errors=True) + shutil.copytree(schema_dir, CONTRACT / "v1") + shutil.copyfile(contract_md, CONTRACT / "CONTRACT.md") + + sdk_pkg = json.loads((source / "packages/sdk/package.json").read_text()) + vendored = { + "source_repo": "atomicmemory-internal", + "source_path": " + ".join(SOURCE_SUBPATHS), + "source_sdk_version": sdk_pkg["version"], + "source_main_commit": _git(source, "rev-parse", "--short", "HEAD"), + "schema_last_modified_commit": _git(source, "log", "-1", "--format=%h", "--", *SOURCE_SUBPATHS), + "vendored_at": _dt.date.today().isoformat(), + } + if not vendored["schema_last_modified_commit"]: + print( + "refresh_contract: git log found no commits for the source paths; is this a shallow clone?", + file=sys.stderr, + ) + return 1 + (CONTRACT / "VENDORED.json").write_text(json.dumps(vendored, indent=2) + "\n") + print(f"refresh_contract: vendored {vendored['source_sdk_version']} @ {vendored['source_main_commit']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/_lifecycle_fakes.py b/tests/_lifecycle_fakes.py new file mode 100644 index 0000000..ca89630 --- /dev/null +++ b/tests/_lifecycle_fakes.py @@ -0,0 +1,153 @@ +"""Shared fake providers and registry helpers for lifecycle tests. + +Task 2 creates _RecordingProvider and _registry_ok_then_bad here. +Task 3 adds _AsyncRecordingProvider. +Both tests/memory/test_service_lifecycle.py and +tests/client/test_client_lifecycle.py import from this module. +""" + +from __future__ import annotations + +import asyncio + +from atomicmemory.memory.provider import BaseAsyncMemoryProvider, BaseMemoryProvider +from atomicmemory.memory.registry import ( + AsyncProviderRegistration, + AsyncProviderRegistry, + ProviderRegistration, + ProviderRegistry, +) +from atomicmemory.memory.types import ( + Capabilities, + CapabilitiesExtensions, + CapabilitiesRequiredScope, + IngestResult, + ListResultPage, + Memory, + SearchResultPage, +) + + +class _RecordingProvider(BaseMemoryProvider): + """Minimal provider mirroring test_service.py's _Recorder; records close().""" + + def __init__(self, close_raises: bool = False, init_raises: bool = False) -> None: + self.name = "recording" + self.close_calls = 0 + self._close_raises = close_raises + self._init_raises = init_raises + + def initialize(self) -> None: + if self._init_raises: + raise RuntimeError("init failed") + + def close(self) -> None: + self.close_calls += 1 + if self._close_raises: + raise RuntimeError("close failed") + + def capabilities(self) -> Capabilities: + return Capabilities( + ingest_modes=["text"], + required_scope=CapabilitiesRequiredScope(default=["user"]), + extensions=CapabilitiesExtensions(package=False), + ) + + def do_ingest(self, input: object) -> IngestResult: # type: ignore[override] + return IngestResult(created=[]) + + def do_search(self, request: object) -> SearchResultPage: # type: ignore[override] + return SearchResultPage() + + def do_get(self, ref: object) -> Memory | None: # type: ignore[override] + return None + + def do_delete(self, ref: object) -> None: # type: ignore[override] + return None + + def do_list(self, request: object) -> ListResultPage: # type: ignore[override] + return ListResultPage() + + +def _registry_ok_then_bad(ok_provider: _RecordingProvider) -> ProviderRegistry: + """Build a registry whose 'ok' factory succeeds and 'bad' factory raises.""" + registry = ProviderRegistry() + registry.register("ok", lambda _cfg: ProviderRegistration(provider=ok_provider)) + + def _bad(_cfg: object) -> ProviderRegistration: + raise RuntimeError("boom") + + registry.register("bad", _bad) + return registry + + +class _AsyncRecordingProvider(BaseAsyncMemoryProvider): + """Async counterpart to _RecordingProvider; records close() calls. + + Optional Event knobs for Task 5 cancellation tests: + - ``init_started``: set at entry to ``initialize()`` so waiters can + synchronize on the provider being mid-init. + - ``init_gate``: awaited in ``initialize()`` when provided, blocking + until the caller sets it (or the run is cancelled). + """ + + def __init__( + self, + close_raises: bool = False, + init_raises: bool = False, + init_started: asyncio.Event | None = None, + init_gate: asyncio.Event | None = None, + ) -> None: + self.name = "async-recording" + self.close_calls = 0 + self._close_raises = close_raises + self._init_raises = init_raises + self._init_started = init_started + self._init_gate = init_gate + + async def initialize(self) -> None: + if self._init_started is not None: + self._init_started.set() + if self._init_gate is not None: + await self._init_gate.wait() + if self._init_raises: + raise RuntimeError("init failed") + + async def close(self) -> None: + self.close_calls += 1 + if self._close_raises: + raise RuntimeError("close failed") + + def capabilities(self) -> Capabilities: + return Capabilities( + ingest_modes=["text"], + required_scope=CapabilitiesRequiredScope(default=["user"]), + extensions=CapabilitiesExtensions(package=False), + ) + + async def do_ingest(self, input: object) -> IngestResult: # type: ignore[override] + return IngestResult(created=[]) + + async def do_search(self, request: object) -> SearchResultPage: # type: ignore[override] + return SearchResultPage() + + async def do_get(self, ref: object) -> Memory | None: # type: ignore[override] + return None + + async def do_delete(self, ref: object) -> None: # type: ignore[override] + return None + + async def do_list(self, request: object) -> ListResultPage: # type: ignore[override] + return ListResultPage() + + +def _async_registry_ok_then_bad(ok_provider: _AsyncRecordingProvider) -> AsyncProviderRegistry: + """Build an async registry whose 'ok' factory succeeds and 'bad' factory raises.""" + registry = AsyncProviderRegistry() + registry.register("ok", lambda _cfg: AsyncProviderRegistration(provider=ok_provider)) + + async def _bad(_cfg: object) -> AsyncProviderRegistration: + raise RuntimeError("boom") + + registry.register("bad", _bad) + return registry diff --git a/tests/client/test_client_lifecycle.py b/tests/client/test_client_lifecycle.py new file mode 100644 index 0000000..38a48d7 --- /dev/null +++ b/tests/client/test_client_lifecycle.py @@ -0,0 +1,312 @@ +"""Client-level lifecycle tests: concurrency, sticky failure, status consistency.""" + +from __future__ import annotations + +import asyncio +import threading + +import pytest + +from atomicmemory import AsyncMemoryClient, MemoryClient +from atomicmemory.memory.registry import ( + AsyncProviderRegistration, + AsyncProviderRegistry, + ProviderRegistration, + ProviderRegistry, +) +from tests._lifecycle_fakes import _AsyncRecordingProvider, _RecordingProvider, _registry_ok_then_bad + + +def test_concurrent_initialize_runs_factories_once() -> None: + calls = [] + + def _factory(_cfg): # type: ignore[no-untyped-def] + calls.append(1) + return ProviderRegistration(provider=_RecordingProvider()) + + registry = ProviderRegistry() + registry.register("p", _factory) + client = MemoryClient(providers={"p": {}}) + threads = [threading.Thread(target=client.initialize, args=(registry,)) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(calls) == 1 + + +def test_failed_initialize_is_sticky() -> None: + calls = [] + + def _bad(_cfg): # type: ignore[no-untyped-def] + calls.append(1) + raise RuntimeError("boom") + + registry = ProviderRegistry() + registry.register("p", _bad) + client = MemoryClient(providers={"p": {}}) + with pytest.raises(RuntimeError, match="boom"): + client.initialize(registry) + with pytest.raises(RuntimeError, match="boom"): + client.initialize(registry) + assert len(calls) == 1 + + +def test_provider_status_consistent_after_failed_initialize() -> None: + registry = _registry_ok_then_bad(_RecordingProvider()) + client = MemoryClient(providers={"ok": {}, "bad": {}}) + with pytest.raises(RuntimeError): + client.initialize(registry) + assert all(not s.initialized for s in client.get_provider_status()) + + +def test_sync_first_registry_wins() -> None: + # A second thread races with a BAD registry while the first (good, slow) + # run holds the lock — the bad factory must never be invoked. + bad_calls = [] + good_entered = threading.Event() + release_good = threading.Event() + + def _slow_good(_cfg: object) -> ProviderRegistration: + good_entered.set() + assert release_good.wait(timeout=5) + return ProviderRegistration(provider=_RecordingProvider()) + + def _bad(_cfg: object) -> ProviderRegistration: + bad_calls.append(1) + raise RuntimeError("must not run") + + good, bad = ProviderRegistry(), ProviderRegistry() + good.register("p", _slow_good) + bad.register("p", _bad) + client = MemoryClient(providers={"p": {}}) + t_good = threading.Thread(target=client.initialize, args=(good,)) + t_good.start() + assert good_entered.wait(timeout=5) # good run owns the lock before bad starts + t_bad = threading.Thread(target=client.initialize, args=(bad,)) + t_bad.start() + release_good.set() + t_good.join() + t_bad.join() + assert bad_calls == [] + assert all(s.initialized for s in client.get_provider_status()) + + +def test_close_then_initialize_reopens() -> None: + registry = ProviderRegistry() + registry.register("p", lambda _cfg: ProviderRegistration(provider=_RecordingProvider())) + client = MemoryClient(providers={"p": {}}) + client.initialize(registry) + client.close() + client.initialize(registry) # successful lifecycle stays re-openable + assert all(s.initialized for s in client.get_provider_status()) + + +# --------------------------------------------------------------------------- +# Async client lifecycle tests +# --------------------------------------------------------------------------- + + +async def test_async_concurrent_initialize_runs_factories_once() -> None: + calls: list[int] = [] + + async def _factory(_cfg: object) -> AsyncProviderRegistration: + calls.append(1) + await asyncio.sleep(0.02) + return AsyncProviderRegistration(provider=_AsyncRecordingProvider()) + + registry = AsyncProviderRegistry() + registry.register("p", _factory) + client = AsyncMemoryClient(providers={"p": {}}) + await asyncio.gather(*(client.initialize(registry) for _ in range(8))) + assert len(calls) == 1 + + +async def test_async_failed_initialize_is_sticky() -> None: + calls: list[int] = [] + + def _bad(_cfg: object) -> AsyncProviderRegistration: + calls.append(1) + raise RuntimeError("boom") + + registry = AsyncProviderRegistry() + registry.register("p", _bad) + client = AsyncMemoryClient(providers={"p": {}}) + with pytest.raises(RuntimeError, match="boom"): + await client.initialize(registry) + with pytest.raises(RuntimeError, match="boom"): + await client.initialize(registry) + assert len(calls) == 1 + + +async def test_cancelled_waiter_does_not_poison_shared_initialize() -> None: + # One waiter is cancelled mid-run; the shared run continues, a second + # waiter completes, factory ran once, client is initialized. Event + # barriers make the ordering deterministic (no sleep-based timing). + calls: list[int] = [] + entered, gate = asyncio.Event(), asyncio.Event() + + async def _gated(_cfg: object) -> AsyncProviderRegistration: + calls.append(1) + entered.set() + await gate.wait() + return AsyncProviderRegistration(provider=_AsyncRecordingProvider()) + + registry = AsyncProviderRegistry() + registry.register("p", _gated) + client = AsyncMemoryClient(providers={"p": {}}) + waiter = asyncio.ensure_future(client.initialize(registry)) + await entered.wait() # the shared run is definitely in-flight + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + gate.set() + await client.initialize(registry) # shares the surviving run + assert len(calls) == 1 + assert all(s.initialized for s in client.get_provider_status()) + + +async def test_async_first_registry_wins() -> None: + # Second caller passes a BAD registry while the first (good) run is + # provably pending (Event barrier) — the bad factory must never be + # invoked; init succeeds with the first registry's provider. + bad_calls: list[int] = [] + entered, gate = asyncio.Event(), asyncio.Event() + + async def _gated_good(_cfg: object) -> AsyncProviderRegistration: + entered.set() + await gate.wait() + return AsyncProviderRegistration(provider=_AsyncRecordingProvider()) + + def _bad(_cfg: object) -> AsyncProviderRegistration: + bad_calls.append(1) + raise RuntimeError("must not run") + + good, bad = AsyncProviderRegistry(), AsyncProviderRegistry() + good.register("p", _gated_good) + bad.register("p", _bad) + client = AsyncMemoryClient(providers={"p": {}}) + first = asyncio.ensure_future(client.initialize(good)) + await entered.wait() # good run owns _init_task before bad arrives + second = asyncio.ensure_future(client.initialize(bad)) + gate.set() + await asyncio.gather(first, second) + assert bad_calls == [] + + +async def test_close_during_pending_initialize_cancels_and_ends_uninitialized() -> None: + # Cancellation lands in the FACTORY phase (Event barrier, deterministic). + calls: list[int] = [] + entered = asyncio.Event() + + async def _gated(_cfg: object) -> AsyncProviderRegistration: + calls.append(1) + entered.set() + await asyncio.Event().wait() # blocks until the run is cancelled + return AsyncProviderRegistration(provider=_AsyncRecordingProvider()) + + registry = AsyncProviderRegistry() + registry.register("p", _gated) + client = AsyncMemoryClient(providers={"p": {}}) + waiter = asyncio.ensure_future(client.initialize(registry)) + await entered.wait() # the init run is provably in-flight + await client.close() + with pytest.raises(asyncio.CancelledError): + await waiter + assert all(not s.initialized for s in client.get_provider_status()) + fresh = AsyncProviderRegistry() + fresh.register("p", lambda _cfg: AsyncProviderRegistration(provider=_AsyncRecordingProvider())) + await client.initialize(fresh) # cancellation is not sticky — fresh run allowed + assert len(calls) == 1 + + +async def test_close_during_provider_initialize_tears_down_staged_provider() -> None: + # Cancellation lands in the PROVIDER-INITIALIZE phase: the provider is + # already STAGED, so this exercises the service's staged-init teardown + # (gathered close) that the factory-phase test above cannot reach. + init_started = asyncio.Event() + blocked = _AsyncRecordingProvider(init_started=init_started, init_gate=asyncio.Event()) + registry = AsyncProviderRegistry() + registry.register("p", lambda _cfg: AsyncProviderRegistration(provider=blocked)) + client = AsyncMemoryClient(providers={"p": {}}) + waiter = asyncio.ensure_future(client.initialize(registry)) + await init_started.wait() # provider.initialize() is blocked → provider IS staged + await client.close() + with pytest.raises(asyncio.CancelledError): + await waiter + assert blocked.close_calls == 1 # staged-init teardown closed it + assert all(not s.initialized for s in client.get_provider_status()) + + +async def test_reinitialize_after_orphaned_success_then_close() -> None: + # All waiters cancelled; the run SUCCEEDS unobserved; close(); re-initialize + # must run a FRESH initialization (the stale done task must not satisfy it). + entered, gate = asyncio.Event(), asyncio.Event() + calls: list[int] = [] + + async def _gated(_cfg: object) -> AsyncProviderRegistration: + calls.append(1) + entered.set() + await gate.wait() + return AsyncProviderRegistration(provider=_AsyncRecordingProvider()) + + registry = AsyncProviderRegistry() + registry.register("p", _gated) + client = AsyncMemoryClient(providers={"p": {}}) + waiter = asyncio.ensure_future(client.initialize(registry)) + await entered.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + gate.set() + await asyncio.sleep(0) # let the orphaned run complete successfully + assert all(s.initialized for s in client.get_provider_status()) + await client.close() + await client.initialize(registry) # must be a FRESH run, not the stale task + assert len(calls) == 2 + assert all(s.initialized for s in client.get_provider_status()) + + +async def test_orphaned_failed_run_stays_sticky_without_waiters() -> None: + # All waiters cancelled, then the unobserved run FAILS: the outcome must + # still be recorded sticky (and the task's exception retrieved so asyncio + # never logs "Task exception was never retrieved"). + entered, gate = asyncio.Event(), asyncio.Event() + calls: list[int] = [] + + async def _failing(_cfg: object) -> AsyncProviderRegistration: + calls.append(1) + entered.set() + await gate.wait() + raise RuntimeError("boom") + + registry = AsyncProviderRegistry() + registry.register("p", _failing) + client = AsyncMemoryClient(providers={"p": {}}) + waiter = asyncio.ensure_future(client.initialize(registry)) + await entered.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + gate.set() + await asyncio.sleep(0) # let the orphaned run fail unobserved + with pytest.raises(RuntimeError, match="boom"): + await client.initialize(registry) # sticky error, no fresh run + assert len(calls) == 1 + + +def test_async_failed_outcome_is_observable_from_another_loop() -> None: + # Loop-independence of the COMPLETED outcome (spec §3.2): fail in loop 1, + # observe the same sticky error from loop 2. (A still-PENDING init is + # loop-bound and unsupported cross-loop — documented, not tested.) + registry = AsyncProviderRegistry() + + def _bad(_cfg: object) -> AsyncProviderRegistration: + raise RuntimeError("boom") + + registry.register("p", _bad) + client = AsyncMemoryClient(providers={"p": {}}) + with pytest.raises(RuntimeError, match="boom"): + asyncio.run(client.initialize(registry)) + with pytest.raises(RuntimeError, match="boom"): + asyncio.run(client.initialize(registry)) diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/_schema_registry.py b/tests/contract/_schema_registry.py new file mode 100644 index 0000000..9b7247a --- /dev/null +++ b/tests/contract/_schema_registry.py @@ -0,0 +1,66 @@ +"""Load the vendored v1 schemas into a jsonschema (draft 2020-12) registry. + +Provides :func:`validator_for`, which builds a ``Draft202012Validator`` for a +corpus ref (``"file.schema.json"`` or ``"file.schema.json#/$defs/Name"``). +The validator resolves via the full registry so that the 26 intra-document +``#/$defs/...`` refs inside the schemas remain valid; extracting a fragment as +a standalone schema would break every one of them. + +See Also: + tests/contract/test_conformance.py: The conformance harness that uses this. + contract/v1/*.schema.json: The vendored schema files loaded here. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker +from referencing import Registry, Resource + +V1 = Path(__file__).resolve().parents[2] / "contract" / "v1" + +# format= keywords (date-time on createdAt/observed_at/...) are ADVISORY in +# jsonschema unless a FormatChecker is supplied — without one, "not-a-date" +# validates (probe-verified). The dev dependency is jsonschema[format] so the +# rfc3339 checker is actually installed. +_FORMAT_CHECKER = FormatChecker() + + +def _load_registry() -> Registry: + """Build a jsonschema Registry from all vendored *.schema.json files. + + Returns: + A Registry with all four v1 schemas keyed by their ``$id`` URIs. + """ + registry: Registry = Registry() + for schema_path in sorted(V1.glob("*.schema.json")): + schema = json.loads(schema_path.read_text()) + registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) + return registry + + +_REGISTRY = _load_registry() + + +def validator_for(ref: str) -> Draft202012Validator: + """Build a validator for a corpus ref like 'file.schema.json#/$defs/Name'. + + Resolution goes through the registry via a ``$ref`` wrapper so the parent + document context is preserved — the schemas contain 26 intra-document + ``#/$defs/...`` references, and extracting a fragment as a standalone + schema would break every one of them. + + Args: + ref: A schema reference in the form ``"file.schema.json"`` or + ``"file.schema.json#/$defs/SomeName"`` relative to the v1 dir. + + Returns: + A ``Draft202012Validator`` with the full registry and format checking. + """ + file_part, _, fragment = ref.partition("#") + schema: dict[str, Any] = json.loads((V1 / file_part).read_text()) + uri = schema["$id"] + (("#" + fragment) if fragment else "") + return Draft202012Validator({"$ref": uri}, registry=_REGISTRY, format_checker=_FORMAT_CHECKER) diff --git a/tests/contract/test_codec.py b/tests/contract/test_codec.py new file mode 100644 index 0000000..14e9b17 --- /dev/null +++ b/tests/contract/test_codec.py @@ -0,0 +1,207 @@ +"""Unit tests for the atomicmemory.contract.v1 wire codec. + +Each test exercises a specific codec boundary: camel↔snake field renames, +``_to_iso_z`` millisecond precision, provenance nesting in Memory and ingest, +``rankingScore`` mapping, page round-trip, datetime-filter normalization, and +the ``content_class`` rejection guard. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from atomicmemory.contract import v1 +from atomicmemory.memory.filters import FilterExpr +from atomicmemory.memory.types import ( + Memory, + Provenance, + Scope, + SearchRequest, + SearchResultPage, + VerbatimIngest, +) + +_WIRE_MEMORY = { + "id": "mem_1", + "content": "hello", + "scope": {"user": "u1"}, + "kind": "fact", + "createdAt": "2026-05-30T12:00:00.000Z", +} + + +def test_decode_memory_maps_camel_dates() -> None: + memory = v1.decode_memory(_WIRE_MEMORY) + assert memory.created_at == datetime(2026, 5, 30, 12, 0, tzinfo=timezone.utc) + assert memory.updated_at is None + + +def test_encode_memory_round_trips_to_exact_wire_form() -> None: + memory = v1.decode_memory(_WIRE_MEMORY) + assert v1.encode_memory(memory) == _WIRE_MEMORY + + +def test_encode_memory_emits_toISOString_format() -> None: + memory = Memory( + id="m", + content="c", + scope=Scope(user="u"), + created_at=datetime(2026, 5, 30, 12, 0, 0, 123456, tzinfo=timezone.utc), + ) + assert v1.encode_memory(memory)["createdAt"] == "2026-05-30T12:00:00.123Z" + + +def test_encode_memory_treats_naive_datetime_as_utc() -> None: + # Regression: astimezone() on a NAIVE datetime assumes LOCAL system time, + # so without an explicit UTC default a naive noon shifts by the host's + # UTC offset (e.g. emits 19:00Z on a UTC-7 machine). + memory = Memory(id="m", content="c", scope=Scope(user="u"), created_at=datetime(2026, 5, 30, 12, 0, 0)) + assert v1.encode_memory(memory)["createdAt"] == "2026-05-30T12:00:00.000Z" + + +def test_decode_memory_rejects_missing_created_at() -> None: + bad = {k: v for k, v in _WIRE_MEMORY.items() if k != "createdAt"} + with pytest.raises(PydanticValidationError): + v1.decode_memory(bad) + + +def test_search_result_page_round_trip() -> None: + page_wire = { + "results": [ + { + "memory": _WIRE_MEMORY, + "score": 0.9, + "rankingScore": 0.42, + "version_id": "v1", + "observed_at": "2026-05-30T12:00:00.000Z", + } + ], + "retrieval": { + "embedding_model": "m", + "embedding_model_version": "1", + "embedding_dimensions": 8, + "query_text": "q", + "candidate_ids": ["mem_1"], + "trace_id": "t", + }, + } + page = v1.decode_search_result_page(page_wire) + assert isinstance(page, SearchResultPage) + assert page.results[0].memory.created_at.tzinfo is not None + assert page.results[0].ranking_score == 0.42 # rankingScore camel↔snake pinned + assert v1.encode_search_result_page(page) == page_wire + + +def test_provenance_camel_fields_round_trip_in_memory_and_ingest() -> None: + wire_prov = {"source": "app", "sourceUrl": "https://x.test/d", "sourceId": "doc-1"} + memory = v1.decode_memory({**_WIRE_MEMORY, "provenance": wire_prov}) + assert memory.provenance is not None and memory.provenance.source_url == "https://x.test/d" + assert v1.encode_memory(memory)["provenance"] == wire_prov + + +def test_encode_search_request_normalizes_datetimes_in_nested_filters() -> None: + # NESTED (an `and` wrapper, not just a leaf): proves both the recursive + # datetime walk AND the by_alias dump (a non-alias dump emits "and_", + # which the schema rejects). + stamp = datetime(2026, 5, 30, 12, 0, 0, 123456, tzinfo=timezone.utc) + request = SearchRequest( + query="q", + scope=Scope(user="u"), + filter=FilterExpr.model_validate( + { + "and": [ + {"field": "createdAt", "op": "gte", "value": stamp}, + {"field": "kind", "op": "eq", "value": "fact"}, + ] + } + ), + ) + encoded = v1.encode_search_request(request) + assert "and" in encoded["filter"] and "and_" not in encoded["filter"] + assert encoded["filter"]["and"][0]["value"] == "2026-05-30T12:00:00.123Z" + + +def test_ingest_provenance_camel_fields_round_trip() -> None: + # Provenance nests in every ingest mode — the corpus never carries + # sourceUrl/sourceId, so this synthetic case is the only coverage. + wire_provenance = {"source": "app", "sourceUrl": "https://x.test/d", "sourceId": "doc-1"} + text_ingest = VerbatimIngest( + content="hello", + scope=Scope(user="u1"), + provenance=Provenance(source="app", source_url="https://x.test/d", source_id="doc-1"), + ) + emitted = v1.encode_ingest_input(text_ingest) + assert emitted["provenance"] == wire_provenance + + +def test_metadata_datetimes_encode_in_wire_date_form() -> None: + # metadata is schema-open (additionalProperties: true) but CONTRACT.md §1's + # date rule still applies to any datetime that crosses the boundary — and + # the json-mode dump would emit the wrong (seconds-precision) form. + stamp = datetime(2026, 5, 30, 12, 0, 0, tzinfo=timezone.utc) + memory = Memory( + id="m", + content="c", + scope=Scope(user="u"), + created_at=stamp, + metadata={"event_at": stamp}, + ) + assert v1.encode_memory(memory)["metadata"]["event_at"] == "2026-05-30T12:00:00.000Z" + ingest = VerbatimIngest(content="c", scope=Scope(user="u"), metadata={"event_at": stamp}) + assert v1.encode_ingest_input(ingest)["metadata"]["event_at"] == "2026-05-30T12:00:00.000Z" + + +def test_decoded_null_version_id_normalizes_to_absent_on_encode() -> None: + # TS pins `versionId?: string | null` (OPTIONAL nullable; types.ts @ + # 2a67871): absent and null are both valid wire forms of the None state, + # and the codec re-encodes the optional field in its canonical absent form. + result = v1.decode_search_result({"memory": _WIRE_MEMORY, "score": 0.5, "version_id": None}) + assert result.version_id is None + assert "version_id" not in v1.encode_search_result(result) + + +def test_encode_ingest_input_rejects_python_only_content_class() -> None: + model = VerbatimIngest(content="x", scope=Scope(user="u"), content_class="summary") + with pytest.raises(ValueError, match="content_class"): + v1.encode_ingest_input(model) + + +def test_decode_memory_rejects_in_process_snake_date_key() -> None: + # The codec is a STRICT v1 boundary: in-process snake names are not wire + # names, and the rename-if-present pattern must not let them through. + bad = {k: v for k, v in _WIRE_MEMORY.items() if k != "createdAt"} + bad["created_at"] = "2026-05-30T12:00:00.000Z" + with pytest.raises(ValueError, match="non-v1 wire keys"): + v1.decode_memory(bad) + + +def test_decode_search_result_rejects_in_process_snake_score_key() -> None: + with pytest.raises(ValueError, match="non-v1 wire keys"): + v1.decode_search_result({"memory": _WIRE_MEMORY, "score": 0.5, "ranking_score": 0.42}) + + +def test_decode_provenance_rejects_in_process_snake_url_key() -> None: + with pytest.raises(ValueError, match="non-v1 wire keys"): + v1.decode_provenance({"source": "app", "source_url": "https://x.test/d"}) + + +def test_decode_search_result_page_rejects_unknown_extra_key() -> None: + # The schemas declare additionalProperties: false; extra="ignore" models + # would silently drop unknown keys without the wire-key guard. + with pytest.raises(ValueError, match="non-v1 wire keys"): + v1.decode_search_result_page({"results": [], "extra": 1}) + + +def test_decode_search_request_is_passthrough() -> None: + # SearchRequest is fully snake_case on the wire; decode is a thin + # model_validate passthrough — pinned so an accidental alias rename + # in the codec can't go undetected (the only previously-untested + # public codec function). + wire = {"query": "deploy gate", "scope": {"user": "u1"}, "limit": 5} + model = v1.decode_search_request(wire) + assert model.query == "deploy gate" + assert model.limit == 5 + assert model.scope.user == "u1" diff --git a/tests/contract/test_conformance.py b/tests/contract/test_conformance.py new file mode 100644 index 0000000..d8d3eb0 --- /dev/null +++ b/tests/contract/test_conformance.py @@ -0,0 +1,142 @@ +"""Two-direction conformance harness over the vendored v1 corpus. + +Direction 1 (wire → SDK): every corpus request/response decodes into the +Python models — directly for the snake-on-wire types, through the +``atomicmemory.contract.v1`` codec for the mixed-case search response. +Direction 2 (SDK → wire): the decoded models re-encode to payloads that +validate against the vendored JSON Schemas. +Plus the TS suite's negative cases, mirrored against schema AND Pydantic. + +The capabilities-descriptor case is schema-only: its expected_response is the +wire-level capability descriptor, which the Python SDK has no model for yet — +a recorded program follow-up (the Python ``Capabilities`` model is the +in-process surface; the over-the-wire ``capabilities-descriptor.schema.json`` +shape has no Python counterpart in this PR). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import TypeAdapter +from pydantic import ValidationError as PydanticValidationError + +from atomicmemory.contract import v1 as codec +from atomicmemory.memory.types import ( + IngestInput, + IngestResult, + RetrievalReceipt, + SearchRequest, +) +from tests.contract._schema_registry import validator_for + +V1_DIR = Path(__file__).resolve().parents[2] / "contract" / "v1" +_CASES = { + c["name"]: json.loads((V1_DIR / "conformance" / c["file"]).read_text()) + for c in json.loads((V1_DIR / "conformance" / "manifest.json").read_text())["cases"] +} +_INGEST: TypeAdapter[IngestInput] = TypeAdapter(IngestInput) + + +@pytest.mark.parametrize("name", sorted(_CASES)) +def test_corpus_payloads_validate_against_schemas(name: str) -> None: + """Every corpus request and response validates against its declared schema.""" + case = _CASES[name] + assert "response_schema" in case and "expected_response" in case, ( + f"corpus case {name!r} is missing response_schema or expected_response" + ) + if case.get("request_schema") and case.get("request") is not None: + validator_for(case["request_schema"]).validate(case["request"]) + validator_for(case["response_schema"]).validate(case["expected_response"]) + + +def test_ingest_cases_round_trip_through_models() -> None: + """Ingest corpus cases decode through the TypeAdapter and re-encode schema-valid.""" + # Derived from the corpus, not hard-coded names: a refresh that ADDS an + # ingest case must not be silently under-tested, and a rename must fail + # actionably. The count pin mirrors the manifest-level len==4 philosophy. + ingest_cases = [c for c in _CASES.values() if c["operation"] == "ingest"] + assert len(ingest_cases) == 2, ( + f"expected 2 ingest corpus cases, got {[c['name'] for c in ingest_cases]} — " + "a corpus refresh changed the ingest set; extend this round-trip accordingly" + ) + for case in ingest_cases: + model = _INGEST.validate_python(case["request"]) + encoded = codec.encode_ingest_input(model) + validator_for(case["request_schema"]).validate(encoded) + result = codec.decode_ingest_result(case["expected_response"]) + assert isinstance(result, IngestResult) + + +def test_search_request_round_trips() -> None: + """Search corpus request decodes through SearchRequest and re-encodes schema-valid.""" + # Through the codec, not raw model_dump: the corpus request happens to be + # filter-free, but encode_search_request is the v1 surface (it normalizes + # datetime filter operands to toISOString form) — exercise the real path. + case = _CASES["search-with-retrieval-receipt"] + model = SearchRequest.model_validate(case["request"]) + encoded = codec.encode_search_request(model) + validator_for(case["request_schema"]).validate(encoded) + + +def test_format_keyword_is_enforced_not_advisory() -> None: + """A malformed date-time string is rejected by the FormatChecker-backed validator.""" + # Without a FormatChecker, jsonschema treats format: date-time as advisory + # and "not-a-date" would validate — pin that our validators enforce it. + case = _CASES["search-with-retrieval-receipt"] + page = json.loads(json.dumps(case["expected_response"])) + page["results"][0]["memory"]["createdAt"] = "not-a-date" + assert not validator_for(case["response_schema"]).is_valid(page) + + +def test_search_response_round_trips_through_the_codec() -> None: + """The decisive parity test: search response decodes via codec and re-encodes byte-equal.""" + # The mixed-case wire page decodes into the in-process models via the codec, + # and re-encodes to a schema-valid, byte-equal wire form. + case = _CASES["search-with-retrieval-receipt"] + page = codec.decode_search_result_page(case["expected_response"]) + encoded = codec.encode_search_result_page(page) + validator_for(case["response_schema"]).validate(encoded) + assert encoded == case["expected_response"] + + +def test_unknown_ingest_mode_rejected_by_both_validators() -> None: + """An unknown mode value is rejected by both the schema and the Pydantic discriminator.""" + bad = dict(_CASES["ingest-text"]["request"], mode="binary") + assert not validator_for(_CASES["ingest-text"]["request_schema"]).is_valid(bad) + with pytest.raises(PydanticValidationError): + _INGEST.validate_python(bad) + + +def test_codec_wire_key_sets_match_the_vendored_schemas() -> None: + """The codec's hard-coded wire-key sets stay in lockstep with the schemas.""" + # The key sets are code literals (the wheel doesn't ship contract/), so a + # schema refresh that adds/renames a property must fail here, forcing a + # conscious codec update. All five $defs declare additionalProperties: + # false, which is what licenses the strict decode guard. + contract = json.loads((V1_DIR / "provider-contract.schema.json").read_text()) + defs = contract["$defs"] + expectations = { + "Memory": codec._MEMORY_WIRE_KEYS, + "Provenance": codec._PROVENANCE_WIRE_KEYS, + "SearchResult": codec._SEARCH_RESULT_WIRE_KEYS, + "SearchResultPage": codec._SEARCH_RESULT_PAGE_WIRE_KEYS, + "IngestResult": codec._INGEST_RESULT_WIRE_KEYS, + } + for def_name, wire_keys in expectations.items(): + assert defs[def_name].get("additionalProperties") is False, ( + f"{def_name} no longer declares additionalProperties: false — " + "re-evaluate whether the strict decode guard is still licensed" + ) + assert set(defs[def_name]["properties"]) == set(wire_keys), def_name + + +def test_receipt_missing_trace_id_rejected_by_both_validators() -> None: + """A retrieval receipt missing trace_id is rejected by both the schema and Pydantic.""" + page = json.loads(json.dumps(_CASES["search-with-retrieval-receipt"]["expected_response"])) + del page["retrieval"]["trace_id"] + assert not validator_for(_CASES["search-with-retrieval-receipt"]["response_schema"]).is_valid(page) + with pytest.raises(PydanticValidationError): + RetrievalReceipt.model_validate(page["retrieval"]) diff --git a/tests/contract/test_vendored_tree.py b/tests/contract/test_vendored_tree.py new file mode 100644 index 0000000..1141258 --- /dev/null +++ b/tests/contract/test_vendored_tree.py @@ -0,0 +1,101 @@ +"""Integrity of the vendored v1 contract artifacts. + +The corpus is vendored (committed, immutable) so CI is deterministic; the +refresh script re-copies from a local atomicmemory-internal checkout and is +never run in CI. These tests pin the tree's internal consistency, not its +content (content correctness is the conformance harness's job). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +CONTRACT = Path(__file__).resolve().parents[2] / "contract" +V1 = CONTRACT / "v1" + +REQUIRED_VENDORED_FIELDS = { + "source_repo", + "source_path", + "source_sdk_version", + "source_main_commit", + "schema_last_modified_commit", + "vendored_at", +} + + +def test_vendored_manifest_pins_the_exact_vendored_source() -> None: + # EXACT pins, deliberately — the opposite call from the PR1 version test. + # A package version changes on every release (a pin = pure friction), but a + # vendored immutable artifact set has exactly ONE correct provenance; a + # refresh is a conscious act that updates these constants in the same + # commit as the new artifacts. Drift between the pins and the committed + # corpus would mean the tree was edited outside the refresh script. + vendored = json.loads((CONTRACT / "VENDORED.json").read_text()) + assert set(vendored) >= REQUIRED_VENDORED_FIELDS + assert vendored["source_repo"] == "atomicmemory-internal" + assert vendored["source_sdk_version"] == "1.1.0" + assert vendored["source_main_commit"] == "2a67871" + assert vendored["schema_last_modified_commit"] == "6fccaf4" + + +def test_corpus_manifest_cases_and_schemas_all_exist() -> None: + manifest = json.loads((V1 / "conformance" / "manifest.json").read_text()) + assert manifest["version"] == 1 + # Deliberate content pin (unlike the version assertions): a refresh that + # adds/removes corpus cases MUST fail here, because new cases need + # conscious model/codec mappings in the harness — silent pass-through + # would un-cover the new case. + assert len(manifest["cases"]) == 4 + for case in manifest["cases"]: + case_path = V1 / "conformance" / case["file"] + assert case_path.is_file(), f"missing corpus case {case['file']}" + body = json.loads(case_path.read_text()) + for key in ("name", "operation", "response_schema", "expected_response"): + assert key in body, f"{case['file']} missing {key}" + for ref in filter(None, (body.get("request_schema"), body["response_schema"])): + schema_file = ref.split("#", 1)[0] + assert (V1 / schema_file).is_file(), f"{case['file']} references missing schema {schema_file}" + + +def test_contract_doc_present() -> None: + assert (CONTRACT / "CONTRACT.md").read_text().strip() + + +def test_pyproject_declares_contract_in_the_sdist() -> None: + # Deterministic primary assertion (no build, no network): the include list + # itself. The tarball test below is the thorough end-to-end check. + pyproject = (CONTRACT.parent / "pyproject.toml").read_text() + include_block = re.search( + r"\[tool\.hatch\.build\.targets\.sdist\].*?^include\s*=\s*\[([^\]]*)\]", + pyproject, + re.DOTALL | re.MULTILINE, + ) + assert include_block and '"contract"' in include_block.group(1) + + +def test_sdist_ships_the_contract_artifacts(tmp_path: Path) -> None: + # The sdist ships tests/, and tests read contract/ — an sdist without the + # fixtures would fail pytest-from-source. Build into a temp dir and inspect. + # NOTE: `uv build` resolves the hatchling build backend (network on a cold + # cache; cached thereafter — CI already fetches deps via uv, so no new class + # of dependency). + import subprocess + import tarfile + + result = subprocess.run( + ["uv", "build", "--sdist", "--out-dir", str(tmp_path)], + cwd=str(CONTRACT.parent), + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise AssertionError(f"uv build failed:\n{result.stderr.decode(errors='replace')}") + tarballs = list(tmp_path.glob("*.tar.gz")) + assert len(tarballs) == 1, f"expected exactly one sdist, got: {tarballs}" + sdist = tarballs[0] + with tarfile.open(sdist) as tar: + names = tar.getnames() + assert any(n.endswith("contract/v1/conformance/manifest.json") for n in names) + assert any(n.endswith("contract/VENDORED.json") for n in names) diff --git a/tests/memory/test_capability_profiles.py b/tests/memory/test_capability_profiles.py new file mode 100644 index 0000000..b7529d5 --- /dev/null +++ b/tests/memory/test_capability_profiles.py @@ -0,0 +1,60 @@ +"""Tests for the generic capability-profile API (parity with the TS SDK).""" + +from __future__ import annotations + +from atomicmemory.memory.capability_profiles import ( + CapabilityProfile, + capability_gaps, + satisfies_profile, +) +from atomicmemory.memory.types import ( + Capabilities, + CapabilitiesExtensions, + CapabilitiesRequiredScope, +) + +# Sample profile: an audited ingest->search->replay path needs deterministic +# verbatim storage plus liveness (health) and version pinning (versioning). +_PROFILE = CapabilityProfile(ingest_modes=["text", "verbatim"], extensions=["health", "versioning"]) + + +def _eligible_caps() -> Capabilities: + return Capabilities( + ingest_modes=["text", "messages", "verbatim"], + required_scope=CapabilitiesRequiredScope(default=["user"]), + extensions=CapabilitiesExtensions(versioning=True, health=True), + ) + + +def test_satisfies_profile_accepts_eligible_capabilities() -> None: + caps = _eligible_caps() + assert satisfies_profile(caps, _PROFILE) is True + assert capability_gaps(caps, _PROFILE) == [] + + +def test_rejects_missing_extension_and_names_the_gap() -> None: + caps = _eligible_caps() + caps.extensions.versioning = False + assert satisfies_profile(caps, _PROFILE) is False + gaps = capability_gaps(caps, _PROFILE) + assert len(gaps) == 1 + assert gaps[0].kind == "extension" + assert gaps[0].requirement == "versioning" + + +def test_rejects_missing_ingest_mode_and_names_the_gap() -> None: + caps = _eligible_caps() + caps.ingest_modes = ["text", "messages"] + gaps = capability_gaps(caps, _PROFILE) + assert len(gaps) == 1 + assert gaps[0].kind == "ingest_mode" + assert gaps[0].requirement == "verbatim" + + +def test_reports_every_gap_when_multiple_unmet() -> None: + caps = _eligible_caps() + caps.ingest_modes = ["messages"] + caps.extensions.health = False + caps.extensions.versioning = False + requirements = sorted(gap.requirement for gap in capability_gaps(caps, _PROFILE)) + assert requirements == ["health", "text", "verbatim", "versioning"] diff --git a/tests/memory/test_meta_fact_filter.py b/tests/memory/test_meta_fact_filter.py new file mode 100644 index 0000000..2178dea --- /dev/null +++ b/tests/memory/test_meta_fact_filter.py @@ -0,0 +1,80 @@ +"""Tests for the opt-in meta-fact filter (parity with the TS SDK).""" + +from __future__ import annotations + +import re + +from atomicmemory.memory.meta_fact_filter import ( + DEFAULT_META_FACT_PATTERNS, + MetaFactFilterConfig, + filter_meta_facts, + is_meta_fact, + resolve_meta_fact_patterns, +) + +_META = "The user asked for the user's name." +_REAL = "Dana prefers concise technical answers." + + +def _content(item: dict[str, str]) -> str: + return item["content"] + + +def test_is_meta_fact_matches_defaults_and_rejects_real_facts() -> None: + assert is_meta_fact(_META) is True + assert is_meta_fact("A name was mentioned in the chat.") is True + assert is_meta_fact(_REAL) is False + assert is_meta_fact("") is False + assert is_meta_fact(None) is False # type: ignore[arg-type] + + +def test_disabled_filter_passes_everything_through() -> None: + items = [{"content": _META}, {"content": _REAL}] + out = filter_meta_facts(items, _content, MetaFactFilterConfig(enabled=False)) + assert out == items + + +def test_enabled_filter_drops_meta_facts_keeps_real() -> None: + items = [{"content": _META}, {"content": _REAL}] + out = filter_meta_facts(items, _content, MetaFactFilterConfig(enabled=True)) + assert out == [{"content": _REAL}] + + +def test_custom_patterns_replace_defaults() -> None: + cfg = MetaFactFilterConfig(enabled=True, patterns=[re.compile(r"^drop me", re.IGNORECASE)]) + items = [{"content": "DROP ME now"}, {"content": _META}] + out = filter_meta_facts(items, _content, cfg) + # Only the custom pattern applies; the default meta-fact survives. + assert out == [{"content": _META}] + + +def test_extend_mode_unions_custom_and_defaults() -> None: + cfg = MetaFactFilterConfig(enabled=True, patterns=[re.compile(r"^drop me", re.IGNORECASE)], mode="extend") + items = [{"content": "DROP ME"}, {"content": _META}, {"content": _REAL}] + out = filter_meta_facts(items, _content, cfg) + assert out == [{"content": _REAL}] + + +def test_on_drop_callback_invoked_per_drop() -> None: + dropped: list[tuple[str, int]] = [] + cfg = MetaFactFilterConfig(enabled=True, on_drop=lambda c, i: dropped.append((c, i))) + filter_meta_facts([{"content": _META}], _content, cfg) + assert dropped == [(_META, 0)] + + +def test_on_drop_exception_is_swallowed() -> None: + def boom(_c: str, _i: int) -> None: + raise RuntimeError("telemetry down") + + cfg = MetaFactFilterConfig(enabled=True, on_drop=boom) + # Must not raise; recall continues even if telemetry fails. + out = filter_meta_facts([{"content": _META}, {"content": _REAL}], _content, cfg) + assert out == [{"content": _REAL}] + + +def test_resolve_patterns_replace_and_extend() -> None: + custom = [re.compile(r"^x")] + assert resolve_meta_fact_patterns(MetaFactFilterConfig(enabled=True)) == DEFAULT_META_FACT_PATTERNS + assert resolve_meta_fact_patterns(MetaFactFilterConfig(enabled=True, patterns=custom)) == tuple(custom) + extended = resolve_meta_fact_patterns(MetaFactFilterConfig(enabled=True, patterns=custom, mode="extend")) + assert extended == (*custom, *DEFAULT_META_FACT_PATTERNS) diff --git a/tests/memory/test_service_lifecycle.py b/tests/memory/test_service_lifecycle.py new file mode 100644 index 0000000..1130cbd --- /dev/null +++ b/tests/memory/test_service_lifecycle.py @@ -0,0 +1,188 @@ +"""Lifecycle tests for MemoryService and AsyncMemoryService initialize semantics. + +Verifies atomic staged registration, best-effort teardown on failure, +ConfigError when the default provider has no factory, and correct +state reset across close → re-initialize cycles — for both the sync +and async service surfaces. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from atomicmemory.core.errors import ConfigError +from atomicmemory.memory.registry import ( + AsyncProviderRegistration, + AsyncProviderRegistry, + ProviderRegistration, + ProviderRegistry, +) +from atomicmemory.memory.service import AsyncMemoryService, MemoryService, MemoryServiceConfig +from tests._lifecycle_fakes import ( + _async_registry_ok_then_bad, + _AsyncRecordingProvider, + _RecordingProvider, + _registry_ok_then_bad, +) + + +def test_failed_initialize_leaves_no_partial_state() -> None: + ok = _RecordingProvider() + svc = MemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "bad": {}})) + with pytest.raises(RuntimeError, match="boom"): + svc.initialize(_registry_ok_then_bad(ok)) + assert svc.get_available_providers() == [] + + +def test_failed_initialize_tears_down_staged_providers() -> None: + ok = _RecordingProvider() + svc = MemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "bad": {}})) + with pytest.raises(RuntimeError): + svc.initialize(_registry_ok_then_bad(ok)) + assert ok.close_calls == 1 + + +def test_failure_inside_provider_initialize_tears_down_prior_providers() -> None: + ok = _RecordingProvider() + bad_init = _RecordingProvider(init_raises=True) + registry = ProviderRegistry() + registry.register("ok", lambda _cfg: ProviderRegistration(provider=ok)) + registry.register("bad", lambda _cfg: ProviderRegistration(provider=bad_init)) + svc = MemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "bad": {}})) + with pytest.raises(RuntimeError, match="init failed"): + svc.initialize(registry) + assert svc.get_available_providers() == [] + assert ok.close_calls == 1 + assert bad_init.close_calls == 1 # bad was STAGED before its initialize raised + + +def test_teardown_failure_does_not_mask_original_error() -> None: + ok = _RecordingProvider(close_raises=True) + svc = MemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "bad": {}})) + with pytest.raises(RuntimeError, match="boom"): + svc.initialize(_registry_ok_then_bad(ok)) + + +def test_initialize_fails_when_default_provider_has_no_factory() -> None: + registry = ProviderRegistry() + registry.register("other", lambda _cfg: ProviderRegistration(provider=_RecordingProvider())) + svc = MemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "other": {}})) + with pytest.raises(ConfigError, match="ok"): + svc.initialize(registry) + assert svc.get_available_providers() == [] + + +def test_best_effort_close_runs_all_providers_and_reraises() -> None: + p1, p2 = _RecordingProvider(close_raises=True), _RecordingProvider() + svc = MemoryService(MemoryServiceConfig(default_provider="p1", provider_configs={"p1": {}, "p2": {}})) + reg = ProviderRegistry() + reg.register("p1", lambda _cfg: ProviderRegistration(provider=p1)) + reg.register("p2", lambda _cfg: ProviderRegistration(provider=p2)) + svc.initialize(reg) + with pytest.raises(RuntimeError, match="close failed"): + svc.close() + assert p2.close_calls == 1 + assert svc.get_available_providers() == [] + + +def test_reinitialize_after_close_drops_old_providers() -> None: + first, second = _RecordingProvider(), _RecordingProvider() + reg_a, reg_b = ProviderRegistry(), ProviderRegistry() + reg_a.register("ok", lambda _cfg: ProviderRegistration(provider=first)) + reg_a.register("other", lambda _cfg: ProviderRegistration(provider=_RecordingProvider())) + reg_b.register("other", lambda _cfg: ProviderRegistration(provider=second)) + svc = MemoryService(MemoryServiceConfig(default_provider="other", provider_configs={"ok": {}, "other": {}})) + svc.initialize(reg_a) + svc.close() + svc.initialize(reg_b) + assert svc.get_available_providers() == ["other"] + + +# --------------------------------------------------------------------------- +# Async service lifecycle tests +# --------------------------------------------------------------------------- + + +async def test_async_factory_returning_awaitable_is_awaited() -> None: + ok = _AsyncRecordingProvider() + registry = AsyncProviderRegistry() + + async def _factory(_cfg: object) -> AsyncProviderRegistration: + return AsyncProviderRegistration(provider=ok) + + registry.register("ok", _factory) + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}})) + await svc.initialize(registry) + assert svc.get_provider("ok") is ok + + +async def test_async_sync_factories_still_work() -> None: + ok = _AsyncRecordingProvider() + registry = AsyncProviderRegistry() + registry.register("ok", lambda _cfg: AsyncProviderRegistration(provider=ok)) + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}})) + await svc.initialize(registry) + assert svc.get_provider("ok") is ok + + +async def test_async_failed_initialize_is_atomic_with_teardown() -> None: + # Exercise init_started to keep vulture from flagging it as unused before Task 5. + init_started = asyncio.Event() + ok = _AsyncRecordingProvider(init_started=init_started) + registry = _async_registry_ok_then_bad(ok) + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "bad": {}})) + with pytest.raises(RuntimeError, match="boom"): + await svc.initialize(registry) + assert init_started.is_set() # init_started was set during ok's initialize + assert svc.get_available_providers() == [] + assert ok.close_calls == 1 + + +async def test_async_failure_inside_provider_initialize_tears_down_prior_providers() -> None: + ok = _AsyncRecordingProvider() + bad_init = _AsyncRecordingProvider(init_raises=True) + registry = AsyncProviderRegistry() + registry.register("ok", lambda _cfg: AsyncProviderRegistration(provider=ok)) + registry.register("bad", lambda _cfg: AsyncProviderRegistration(provider=bad_init)) + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "bad": {}})) + with pytest.raises(RuntimeError, match="init failed"): + await svc.initialize(registry) + assert svc.get_available_providers() == [] + assert ok.close_calls == 1 + assert bad_init.close_calls == 1 # bad was STAGED before its initialize raised + + +async def test_async_initialize_fails_when_default_provider_has_no_factory() -> None: + registry = AsyncProviderRegistry() + registry.register("other", lambda _cfg: AsyncProviderRegistration(provider=_AsyncRecordingProvider())) + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="ok", provider_configs={"ok": {}, "other": {}})) + with pytest.raises(ConfigError, match="ok"): + await svc.initialize(registry) + assert svc.get_available_providers() == [] + + +async def test_async_best_effort_close_runs_all_providers_and_reraises() -> None: + p1, p2 = _AsyncRecordingProvider(close_raises=True), _AsyncRecordingProvider() + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="p1", provider_configs={"p1": {}, "p2": {}})) + reg = AsyncProviderRegistry() + reg.register("p1", lambda _cfg: AsyncProviderRegistration(provider=p1)) + reg.register("p2", lambda _cfg: AsyncProviderRegistration(provider=p2)) + await svc.initialize(reg) + with pytest.raises(RuntimeError, match="close failed"): + await svc.close() + assert p2.close_calls == 1 + assert svc.get_available_providers() == [] + + +async def test_async_reinitialize_after_close_drops_old_providers() -> None: + reg_a, reg_b = AsyncProviderRegistry(), AsyncProviderRegistry() + reg_a.register("ok", lambda _cfg: AsyncProviderRegistration(provider=_AsyncRecordingProvider())) + reg_a.register("other", lambda _cfg: AsyncProviderRegistration(provider=_AsyncRecordingProvider())) + reg_b.register("other", lambda _cfg: AsyncProviderRegistration(provider=_AsyncRecordingProvider())) + svc = AsyncMemoryService(MemoryServiceConfig(default_provider="other", provider_configs={"ok": {}, "other": {}})) + await svc.initialize(reg_a) + await svc.close() + await svc.initialize(reg_b) + assert svc.get_available_providers() == ["other"] diff --git a/tests/providers/atomicmemory/test_ingest_content_class.py b/tests/providers/atomicmemory/test_ingest_content_class.py new file mode 100644 index 0000000..01adc30 --- /dev/null +++ b/tests/providers/atomicmemory/test_ingest_content_class.py @@ -0,0 +1,50 @@ +"""Unit tests for content_class forwarding on ingest (every mode). + +A core running the default ``RAW_CONTENT_POLICY=reject`` refuses content that is +``"raw"`` or carries no ``content_class`` at all — regardless of ingest mode +(``text``/``messages`` extraction or ``verbatim``). The SDK exposes +``content_class`` on every ingest input and forwards the caller's choice verbatim; +it never infers one, so omitting it leaves the field off the wire and a +reject-policy core fails the ingest closed rather than the SDK mislabeling raw +content as safe. +""" + +from __future__ import annotations + +from atomicmemory.memory.types import MessageIngest, Scope, TextIngest, VerbatimIngest +from atomicmemory.providers.atomicmemory.provider import _build_ingest_body + +_SCOPE = Scope(user="u") +_MSGS = [{"role": "user", "content": "I prefer dark mode"}] + + +def test_forwards_stamped_content_class() -> None: + body = _build_ingest_body(VerbatimIngest(scope=_SCOPE, content="distilled summary", content_class="summary")) + assert body["content_class"] == "summary" + assert body["skip_extraction"] is True + + +def test_forwards_explicit_raw_choice_unchanged() -> None: + body = _build_ingest_body(VerbatimIngest(scope=_SCOPE, content="transcript", content_class="raw")) + assert body["content_class"] == "raw" + + +def test_omits_content_class_when_unstamped() -> None: + body = _build_ingest_body(VerbatimIngest(scope=_SCOPE, content="unclassified")) + assert "content_class" not in body + + +def test_forwards_content_class_on_messages_extraction() -> None: + body = _build_ingest_body(MessageIngest(scope=_SCOPE, messages=_MSGS, content_class="summary")) + assert body["content_class"] == "summary" + assert "skip_extraction" not in body # extraction mode, not verbatim + + +def test_forwards_content_class_on_text_extraction() -> None: + body = _build_ingest_body(TextIngest(scope=_SCOPE, content="a note", content_class="redacted")) + assert body["content_class"] == "redacted" + + +def test_omits_content_class_on_messages_when_unstamped() -> None: + body = _build_ingest_body(MessageIngest(scope=_SCOPE, messages=_MSGS)) + assert "content_class" not in body diff --git a/tests/providers/atomicmemory/test_integration.py b/tests/providers/atomicmemory/test_integration.py new file mode 100644 index 0000000..9bbf014 --- /dev/null +++ b/tests/providers/atomicmemory/test_integration.py @@ -0,0 +1,89 @@ +"""Opt-in live-core integration tests for the AtomicMemory provider. + +Runs only when ``ATOMICMEMORY_TEST_API_URL`` points at a running +atomicmemory-core (set ``ATOMICMEMORY_TEST_API_KEY`` if it requires auth); +skipped otherwise, so the default unit suite stays hermetic:: + + ATOMICMEMORY_TEST_API_URL=http://localhost:17350 \ + ATOMICMEMORY_TEST_API_KEY=local-dev-key \ + uv run --extra dev pytest tests/providers/atomicmemory/test_integration.py + +Verifies the SDK <-> core wire contract end to end against a real backend: the +audit-grade retrieval receipt and per-result version/observed fields the SDK now +surfaces are actually emitted by core and mapped through, and verbatim ingest +keyed by ``externalId`` is idempotent. Requires a core built with the +capabilities/receipt/external-id support (atomicmemory-internal PR #18). +""" + +from __future__ import annotations + +import os +from collections.abc import Generator + +import pytest + +from atomicmemory.memory.types import Scope, SearchRequest, SearchResultPage, VerbatimIngest +from atomicmemory.providers.atomicmemory.config import AtomicMemoryProviderConfig +from atomicmemory.providers.atomicmemory.provider import AtomicMemoryProvider + +_API_URL = os.environ.get("ATOMICMEMORY_TEST_API_URL") + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif(not _API_URL, reason="Set ATOMICMEMORY_TEST_API_URL to run live-core integration tests"), +] + +_SCOPE = Scope(user="sdk-itest-user") +_EXTERNAL_ID = "sdk-itest-receipt-py" +_CONTENT = "Integration probe: Northstar Atlas deploys on-prem and prioritizes low query latency." +_QUERY = "on-prem low latency Atlas" + + +@pytest.fixture(scope="module") +def provider() -> Generator[AtomicMemoryProvider, None, None]: + instance = AtomicMemoryProvider( + AtomicMemoryProviderConfig( + api_url=_API_URL, # type: ignore[arg-type] + api_key=os.environ.get("ATOMICMEMORY_TEST_API_KEY"), + ) + ) + instance.initialize() + instance.ingest( + VerbatimIngest(scope=_SCOPE, content=_CONTENT, content_class="summary", metadata={"externalId": _EXTERNAL_ID}) + ) + yield instance + instance.close() + + +def _match_count(page: SearchResultPage) -> int: + return sum(1 for result in page.results if result.memory.content == _CONTENT) + + +def test_search_surfaces_retrieval_receipt(provider: AtomicMemoryProvider) -> None: + page = provider.search(SearchRequest(query=_QUERY, scope=_SCOPE, limit=5)) + + assert page.results + assert page.retrieval is not None + assert page.retrieval.embedding_model + assert page.retrieval.embedding_model_version + assert isinstance(page.retrieval.candidate_ids, list) + assert page.retrieval.trace_id + + +def test_search_hits_carry_per_result_receipt_fields(provider: AtomicMemoryProvider) -> None: + page = provider.search(SearchRequest(query=_QUERY, scope=_SCOPE, limit=5)) + hit = page.results[0] + + assert hit.observed_at # present on a live search hit + # version_id is present on the model (str for a versioned row, None otherwise). + assert hasattr(hit, "version_id") + + +def test_verbatim_ingest_keyed_by_external_id_is_idempotent(provider: AtomicMemoryProvider) -> None: + before = provider.search(SearchRequest(query=_QUERY, scope=_SCOPE, limit=20)) + provider.ingest( + VerbatimIngest(scope=_SCOPE, content=_CONTENT, content_class="summary", metadata={"externalId": _EXTERNAL_ID}) + ) + after = provider.search(SearchRequest(query=_QUERY, scope=_SCOPE, limit=20)) + + assert _match_count(after) == _match_count(before) == 1 diff --git a/tests/providers/atomicmemory/test_mappers.py b/tests/providers/atomicmemory/test_mappers.py index 7a127a2..490f1bd 100644 --- a/tests/providers/atomicmemory/test_mappers.py +++ b/tests/providers/atomicmemory/test_mappers.py @@ -11,6 +11,7 @@ to_ingest_result, to_memory, to_memory_version, + to_retrieval_receipt, to_search_result, ) @@ -136,3 +137,44 @@ def test_to_memory_version_normalizes_unknown_event() -> None: def test_to_memory_version_requires_created_at() -> None: with pytest.raises(ValueError, match="created_at"): to_memory_version({"id": "v1", "content": "hi"}) + + +def test_to_search_result_surfaces_version_id_and_observed_at() -> None: + result = to_search_result( + { + "id": "m1", + "content": "hi", + "score": 0.5, + "version_id": "v7", + "observed_at": "2026-05-20T10:00:00.000Z", + }, + _SCOPE, + ) + + assert result.version_id == "v7" + assert result.observed_at == "2026-05-20T10:00:00.000Z" + + +def test_to_search_result_omits_receipt_fields_when_absent() -> None: + result = to_search_result({"id": "m2", "content": "hi", "score": 0.1}, _SCOPE) + + assert result.version_id is None + assert result.observed_at is None + + +def test_to_retrieval_receipt_maps_wire_shape() -> None: + receipt = to_retrieval_receipt( + { + "embedding_provider": "voyage", + "embedding_model": "voyage-3", + "embedding_model_version": "1", + "embedding_dimensions": 1024, + "query_text": "q", + "candidate_ids": ["m1", "m2"], + "trace_id": "t1", + } + ) + + assert receipt.embedding_model == "voyage-3" + assert receipt.candidate_ids == ["m1", "m2"] + assert receipt.trace_id == "t1" diff --git a/tests/test_package_imports.py b/tests/test_package_imports.py index 5d68d01..4f0373c 100644 --- a/tests/test_package_imports.py +++ b/tests/test_package_imports.py @@ -6,7 +6,7 @@ def test_top_level_imports() -> None: import atomicmemory - assert atomicmemory.__version__ == "1.0.0" + assert isinstance(atomicmemory.__version__, str) assert atomicmemory.Scope is not None assert atomicmemory.Memory is not None assert atomicmemory.AtomicMemoryError is not None diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py new file mode 100644 index 0000000..ce9fc8f --- /dev/null +++ b/tests/test_version_consistency.py @@ -0,0 +1,26 @@ +"""Regression guard: every exposed version source must agree. + +pyproject.toml's `version` is what pip/uv report; `atomicmemory.__version__` +(re-exported from `_version.py`) is what runtime consumers read. They drifted +once (1.0.1 vs 1.0.0) — this test makes drift impossible to ship. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import atomicmemory + +_PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml" + + +def test_version_sources_match() -> None: + # Matches the first bare `version = "..."` line (column 0, MULTILINE). + # pyproject convention puts [project].version at the top of this file and + # no other section here declares a bare `version` key. We read the file + # directly (not importlib.metadata) so the check is correct even in a + # stale/unsynced environment. + match = re.search(r'^version = "([^"]+)"$', _PYPROJECT.read_text(), re.MULTILINE) + assert match is not None, "pyproject.toml must declare a version" + assert match.group(1) == atomicmemory.__version__ diff --git a/uv.lock b/uv.lock index 47601c9..47f1a29 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797 }, +] + [[package]] name = "ast-serialize" version = "0.3.0" @@ -79,7 +92,7 @@ wheels = [ [[package]] name = "atomicmemory" -version = "1.0.1" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -90,6 +103,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "jsonschema", extra = ["format"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -105,6 +119,7 @@ embeddings = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, + { name = "jsonschema", extras = ["format"], marker = "extra == 'dev'", specifier = ">=4.21" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "numpy", specifier = ">=1.26" }, { name = "pydantic", specifier = ">=2.7" }, @@ -118,6 +133,15 @@ requires-dist = [ ] provides-extras = ["embeddings", "dev"] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -251,6 +275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812 }, ] +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 }, +] + [[package]] name = "fsspec" version = "2026.4.0" @@ -367,6 +400,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321 }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -388,6 +433,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 }, ] +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659 }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[package.optional-dependencies] +format = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3987" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + [[package]] name = "librt" version = "0.10.0" @@ -1189,6 +1283,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1253,6 +1359,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + [[package]] name = "regex" version = "2026.4.4" @@ -1386,6 +1507,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557 }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, +] + +[[package]] +name = "rfc3987" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377 }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1399,6 +1541,272 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490 }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751 }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696 }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136 }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699 }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022 }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522 }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579 }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305 }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503 }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322 }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792 }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901 }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823 }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157 }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676 }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938 }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932 }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830 }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033 }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828 }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683 }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583 }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496 }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669 }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011 }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406 }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024 }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069 }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292 }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128 }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542 }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004 }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063 }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099 }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177 }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015 }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736 }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981 }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782 }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609 }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460 }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031 }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121 }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026 }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865 }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012 }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111 }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225 }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487 }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798 }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053 }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390 }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097 }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361 }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040 }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775 }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329 }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539 }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674 }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268 }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280 }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233 }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009 }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113 }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838 }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436 }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734 }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045 }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967 }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787 }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179 }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173 }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162 }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093 }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829 }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786 }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920 }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059 }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030 }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975 }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178 }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481 }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519 }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446 }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287 }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033 }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891 }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646 }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830 }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830 }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613 }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183 }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578 }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573 }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861 }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633 }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074 }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635 }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756 }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831 }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127 }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034 }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823 }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144 }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959 }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558 }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789 }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405 }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701 }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806 }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985 }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219 }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148 }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196 }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981 }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961 }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965 }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526 }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190 }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921 }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766 }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343 }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502 }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916 }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855 }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422 }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576 }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640 }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322 }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066 }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586 }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415 }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427 }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615 }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786 }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583 }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941 }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349 }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922 }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003 }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245 }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015 }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016 }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775 }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270 }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285 }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581 }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041 }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946 }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526 }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165 }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778 }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839 }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866 }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441 }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151 }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195 }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850 }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899 }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618 }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003 }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778 }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359 }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820 }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243 }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541 }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326 }, +] + [[package]] name = "ruff" version = "0.15.12" @@ -1725,6 +2133,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -1972,6 +2389,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140 }, +] + [[package]] name = "vulture" version = "2.16" @@ -1983,3 +2418,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70c wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993 }, ] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905 }, +]