diff --git a/api/api.py b/api/api.py index b9a3dd7e..e2a377bb 100644 --- a/api/api.py +++ b/api/api.py @@ -47,6 +47,8 @@ from lib.context_utils import store_context_async, extract_otel_trace_context from lib.logging_utils import init_logger from lib.queue import VconQueue +from lib.vcon_redis import VconRedis +from lib.vcon_egress_compat import to_configured_legacy import redis_mgr from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request as StarletteRequest @@ -485,6 +487,12 @@ async def sync_vcon_from_storage(vcon_uuid: UUID) -> Optional[dict]: for storage_name in Configuration.get_storages(): vcon = Storage(storage_name=storage_name).get(str(vcon_uuid)) if vcon: + # Storage may hold a legacy / egress-converted format (see the + # egress_format_version storage option). Canonicalize to the current + # spec before caching or returning so Redis and API clients never see + # legacy field names or JSON-string bodies — mirrors the + # VconRedis.get_vcon storage-fallback path. + VconRedis._enforce_spec_on_write(vcon) # Store the vCon back in Redis with expiration await cache_vcon_in_redis(f"vcon:{str(vcon_uuid)}", vcon) # Add to sorted set for timestamp-based retrieval @@ -610,11 +618,13 @@ async def get_vcon(vcon_uuid: UUID) -> JSONResponse: HTTPException: If vCon is not found (404) """ vcon = await ensure_vcon_in_redis(vcon_uuid) - + if not vcon: raise HTTPException(status_code=404, detail="vCon not found") - - return JSONResponse(content=vcon) + + # Redis/cache stays canonical; emit the configured legacy format (if any) + # only on the egress response. + return JSONResponse(content=to_configured_legacy(vcon)) @api_router.get( @@ -649,7 +659,9 @@ async def get_vcons( if not vcon: # Only sync from storage if not found in Redis (avoids redundant Redis check) vcon = await sync_vcon_from_storage(vcon_uuid) - results.append(vcon) + # Redis/cache stays canonical; emit the configured legacy format (if + # any) only on the egress response. + results.append(to_configured_legacy(vcon) if vcon else vcon) return JSONResponse(content=results, status_code=200) diff --git a/common/lib/vcon_egress_compat.py b/common/lib/vcon_egress_compat.py new file mode 100644 index 00000000..1cfda669 --- /dev/null +++ b/common/lib/vcon_egress_compat.py @@ -0,0 +1,156 @@ +"""Spec→legacy vCon conversion for egress compatibility (CON-581). + +This is the inverse of :func:`lib.vcon_compat.normalize_legacy_fields`. + +The conserver normalizes every vCon *up* to the current spec (``vcon: "0.4.0"``) +on read and write. Downstream consumers built against an older schema (e.g. +``0.0.1``) break on the canonical shape — notably the ``type`` → ``purpose`` +attachment rename and the write-path serialization of dict/list analysis and +attachment bodies into JSON strings (``encoding: "json"``). This module converts +an *outgoing* payload back to a legacy version — reversing the field renames and +re-inflating those JSON-string bodies to native objects with ``encoding: "none"`` +— so those consumers keep working while a migration is planned. + +It never mutates the canonical in-pipeline copy: callers pass ``vcon.to_dict()`` +and receive a new, deep-copied, downgraded dict. Enable it per egress point via +the ``egress_format_version`` option on the webhook link and the +postgres / s3 / elasticsearch storage modules. When the option is unset, callers +skip this module entirely and behaviour is unchanged. + +The rename tables below mirror ``lib.vcon_compat`` (in the opposite direction). +``test_vcon_egress_compat`` round-trips ``normalize_legacy_fields(to_legacy(x))`` +to guard against the two drifting apart. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any, Dict + +# Spec name → legacy name. Inverse of lib.vcon_compat._TOP_LEVEL_RENAMES. +_TOP_LEVEL_SPEC_TO_LEGACY = { + "amended": "appended", + "critical": "must_support", +} + +# Spec name → legacy name for dialog / analysis / attachment entries. +# Inverse of the renames applied by lib.vcon_compat._normalize_entry. +_ENTRY_SPEC_TO_LEGACY = { + "schema": "schema_version", + "mediatype": "mimetype", + "critical": "must_support", +} + +# Legacy versions this module knows how to emit. +SUPPORTED_VERSIONS = {"0.0.1"} + + +def _rename(d: Dict[str, Any], old: str, new: str) -> None: + """Move ``d[old]`` to ``d[new]`` unless ``d[new]`` is already set. + + Mirrors ``vcon_compat._rename``: if both are present the destination + (legacy) field wins and the source is dropped. + """ + if old not in d: + return + if new in d: + d.pop(old, None) + return + d[new] = d.pop(old) + + +def _entry_to_legacy(entry: Dict[str, Any]) -> None: + if not isinstance(entry, dict): + return + for spec, legacy in _ENTRY_SPEC_TO_LEGACY.items(): + _rename(entry, spec, legacy) + + +def _body_to_legacy(entry: Dict[str, Any]) -> None: + """Inverse of ``VconRedis._stringify_json_body``. + + The spec write-path serializes dict/list ``body`` values to a JSON string + and sets ``encoding: "json"``. The legacy 0.0.1 shape carries the native + object/array with ``encoding: "none"``, so parse it back. Applied to + analysis and attachment entries only — dialog bodies are not stringified on + write. Left untouched if the body isn't valid JSON. + """ + if not isinstance(entry, dict): + return + if entry.get("encoding") == "json" and isinstance(entry.get("body"), str): + try: + entry["body"] = json.loads(entry["body"]) + except (ValueError, TypeError): + return + entry["encoding"] = "none" + + +def _attachment_to_legacy(att: Dict[str, Any]) -> None: + if not isinstance(att, dict): + return + _entry_to_legacy(att) + # Spec uses ``purpose``; the legacy field was ``type``. Mirror the forward + # normalizer's caveat: only migrate when ``type`` is absent, since the + # ``lawful_basis`` extension legitimately uses ``type`` as its value. + if "type" not in att and "purpose" in att: + att["type"] = att.pop("purpose") + + +def to_legacy(vcon_dict: Dict[str, Any], target_version: str) -> Dict[str, Any]: + """Return a deep-copied vCon dict converted to ``target_version``. + + :param vcon_dict: a spec-current (0.4.0) vCon dict, e.g. ``vcon.to_dict()``. + :param target_version: legacy version to emit; must be in + :data:`SUPPORTED_VERSIONS`. + :raises ValueError: if ``target_version`` is not supported. + + The input is never mutated. + """ + if target_version not in SUPPORTED_VERSIONS: + raise ValueError( + f"Unsupported egress_format_version {target_version!r}; " + f"supported: {sorted(SUPPORTED_VERSIONS)}" + ) + if not isinstance(vcon_dict, dict): + return vcon_dict + + out = copy.deepcopy(vcon_dict) + + for spec, legacy in _TOP_LEVEL_SPEC_TO_LEGACY.items(): + _rename(out, spec, legacy) + + for entry in out.get("dialog", []) or []: + _entry_to_legacy(entry) + for entry in out.get("analysis", []) or []: + _entry_to_legacy(entry) + _body_to_legacy(entry) + for att in out.get("attachments", []) or []: + _attachment_to_legacy(att) + _body_to_legacy(att) + + # Legacy 0.0.1 always carries these top-level keys (matching the shape in + # Strolid's store today); the 0.4.0 library drops empty group/redacted. + out.setdefault("group", []) + out.setdefault("redacted", {}) + out.setdefault("appended", None) + + out["vcon"] = target_version + return out + + +def to_configured_legacy(vcon_dict: Dict[str, Any]) -> Dict[str, Any]: + """Apply :func:`to_legacy` if the deployment configured a legacy egress + version, otherwise return ``vcon_dict`` unchanged. + + Reads the single ``EGRESS_FORMAT_VERSION`` setting (deployment-wide). This + is the one place every egress point — the webhook link, the storage + backends, and the API read endpoints — consults, so the behavior is + configured once rather than per module. The setting is read lazily on each + call so tests (and runtime config reloads) take effect. + """ + from settings import EGRESS_FORMAT_VERSION + + if EGRESS_FORMAT_VERSION: + return to_legacy(vcon_dict, EGRESS_FORMAT_VERSION) + return vcon_dict diff --git a/common/lib/vcon_redis.py b/common/lib/vcon_redis.py index 5dbc6fa3..906d2bf5 100644 --- a/common/lib/vcon_redis.py +++ b/common/lib/vcon_redis.py @@ -69,9 +69,13 @@ def _enforce_spec_on_write(cls, vcon_dict: dict) -> dict: # Rename legacy field names before the rest of the enforcement # so subsequent loops operate on spec-named entries. normalize_legacy_fields(vcon_dict) - # draft-ietf-vcon-vcon-core-02 §4.1.1 — syntax param. - if not vcon_dict.get("vcon"): - vcon_dict["vcon"] = "0.4.0" + # draft-ietf-vcon-vcon-core-02 §4.1.1 — syntax param. The renames above + # bring field names up to the current spec, so stamp the matching + # version unconditionally. A missing value, or a stale legacy value + # (e.g. "0.0.1" from a legacy producer or an egress-converted storage + # payload loaded back on a Redis miss), would otherwise misdescribe the + # now-canonical data. + vcon_dict["vcon"] = "0.4.0" # speckit: ``group`` is reserved and must not be emitted empty. if vcon_dict.get("group") == []: vcon_dict.pop("group", None) diff --git a/common/settings.py b/common/settings.py index a8ffcebf..5bb8df5e 100644 --- a/common/settings.py +++ b/common/settings.py @@ -65,6 +65,14 @@ # Enable parallel storage writes using ThreadPoolExecutor (default True) CONSERVER_PARALLEL_STORAGE = os.getenv("CONSERVER_PARALLEL_STORAGE", "true").lower() in ("true", "1", "yes") +# Egress format compatibility (deployment-wide). +# When set to a legacy vCon version string (e.g. "0.0.1"), every egress point +# emits that older format instead of the current spec: the webhook link, the +# storage backends, and the API read endpoints. Leave unset to emit the current +# spec everywhere. The canonical in-pipeline representation (Redis cache, link +# processing) is always kept on the current spec regardless of this setting. +EGRESS_FORMAT_VERSION = os.getenv("EGRESS_FORMAT_VERSION") or None + # Per-worker in-flight vCon concurrency (default 1 = strict serial, current behaviour). # When > 1, each worker process dispatches up to N vCons to a ThreadPoolExecutor, # back-pressuring before BLPOP so at most N chains run in parallel per worker. diff --git a/common/storage/elasticsearch/__init__.py b/common/storage/elasticsearch/__init__.py index be1f8089..ad34db85 100644 --- a/common/storage/elasticsearch/__init__.py +++ b/common/storage/elasticsearch/__init__.py @@ -1,5 +1,6 @@ from lib.logging_utils import init_logger from lib.vcon_redis import VconRedis +from lib.vcon_egress_compat import to_configured_legacy import logging import elasticsearch import json @@ -65,6 +66,12 @@ def save( vcon = vcon_redis.get_vcon(vcon_uuid) vcon_dict = vcon.to_dict() + # If EGRESS_FORMAT_VERSION is set, downgrade the indexed payload to that + # legacy format for downstream consumers built against an older schema. + # The canonical vCon in Redis is untouched. This also restores the + # legacy attachment ``type`` key the index names below are built from. + vcon_dict = to_configured_legacy(vcon_dict) + if not vcon_dict["dialog"]: return @@ -99,9 +106,11 @@ def save( # Index the attachments, separated by 'type' - id=f"{vcon_uuid}_{attachment_index}" for ind, attachment in enumerate(vcon_dict["attachments"]): - attachment_type = attachment.get( - "type" - ).lower() # TODO this might be "purpose" in some of the attachments!! + # Spec 0.4.0 renamed attachment ``type`` -> ``purpose``; accept + # either so the index name resolves regardless of payload version. + attachment_type = ( + attachment.get("type") or attachment.get("purpose") or "unknown" + ).lower() encoding = attachment.get("encoding", "none") if encoding == "json" and isinstance(attachment["body"], str): # Only parse if it's a string attachment["body"] = json.loads(attachment["body"]) diff --git a/common/storage/postgres/__init__.py b/common/storage/postgres/__init__.py index 4ac16704..519f192a 100644 --- a/common/storage/postgres/__init__.py +++ b/common/storage/postgres/__init__.py @@ -14,6 +14,7 @@ from typing import Optional, Dict, Any, Type from lib.logging_utils import init_logger from lib.vcon_redis import VconRedis +from lib.vcon_egress_compat import to_configured_legacy from playhouse.postgres_ext import PostgresqlExtDatabase, BinaryJSONField from peewee import ( Model, @@ -117,14 +118,20 @@ def save( try: vcon_redis = VconRedis() vcon = vcon_redis.get_vcon(vcon_uuid) - + + # If EGRESS_FORMAT_VERSION is set, downgrade the persisted payload to + # that legacy format for downstream consumers built against an older + # schema. The canonical vCon in Redis is untouched; the stored copy + # normalizes back up on read. + vcon_json = to_configured_legacy(vcon.to_dict()) + # Connect to Postgres db = get_db_connection(opts) table_name = opts.get("table_name", "vcons") - + # Create dynamic model for this database and table VconsModel = create_vcons_model(db, table_name) - + # Ensure table exists db.create_tables([VconsModel], safe=True) @@ -132,11 +139,11 @@ def save( vcon_data = { "id": vcon.uuid, "uuid": vcon.uuid, - "vcon": vcon.vcon, + "vcon": vcon_json.get("vcon", vcon.vcon), "created_at": vcon.created_at, "updated_at": datetime.now(), "subject": vcon.subject, - "vcon_json": vcon.to_dict(), + "vcon_json": vcon_json, } # Insert or update the vCon diff --git a/common/storage/s3/__init__.py b/common/storage/s3/__init__.py index 0852dac7..6f276151 100644 --- a/common/storage/s3/__init__.py +++ b/common/storage/s3/__init__.py @@ -3,6 +3,7 @@ from typing import Optional from lib.logging_utils import init_logger from lib.vcon_redis import VconRedis +from lib.vcon_egress_compat import to_configured_legacy import boto3 logger = init_logger(__name__) @@ -85,10 +86,16 @@ def save( vcon = vcon_redis.get_vcon(vcon_uuid) s3 = _create_s3_client(opts) + # If EGRESS_FORMAT_VERSION is set, downgrade the stored payload to that + # legacy format for downstream consumers built against an older schema. + # The canonical vCon in Redis is untouched. + vcon_dict = to_configured_legacy(vcon.to_dict()) + body = json.dumps(vcon_dict) + date_path = _date_prefix(vcon.created_at) destination_directory = _build_s3_key(vcon_uuid, date_path, opts.get("s3_path")) s3.put_object( - Bucket=opts["aws_bucket"], Key=destination_directory, Body=vcon.dumps() + Bucket=opts["aws_bucket"], Key=destination_directory, Body=body ) lookup_key = _build_lookup_key(vcon_uuid, opts.get("s3_path")) diff --git a/common/tests/schemas/vcon-0.0.1.schema.json b/common/tests/schemas/vcon-0.0.1.schema.json new file mode 100644 index 00000000..8bb3d365 --- /dev/null +++ b/common/tests/schemas/vcon-0.0.1.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://vcon.dev/schemas/vcon-0.0.1.schema.json", + "title": "Legacy vCon 0.0.1 (egress-compat target)", + "description": "Derived from Strolid's production conserver store (no canonical 0.0.1 schema exists). Permissive about extension/custom keys, but strict about the spec deltas that must be downgraded: attachments use `type` (not `purpose`), dialog uses `mimetype` (not `mediatype`), top level uses `appended`/`must_support` (not `amended`/`critical`), and `vcon` is `0.0.1`. Validation fails if any 0.4.0 spec key leaks through.", + "type": "object", + "required": [ + "vcon", + "uuid", + "created_at", + "redacted", + "appended", + "group", + "parties", + "dialog", + "analysis", + "attachments" + ], + "not": { "anyOf": [ + { "required": ["amended"] }, + { "required": ["critical"] } + ]}, + "properties": { + "vcon": { "const": "0.0.1" }, + "uuid": { "type": "string" }, + "created_at": { "type": "string" }, + "subject": { "type": ["string", "null"] }, + "redacted": { "type": "object" }, + "appended": { "type": ["object", "null"] }, + "group": { "type": "array" }, + "meta": { "type": "object" }, + "parties": { + "type": "array", + "items": { "type": "object" } + }, + "dialog": { + "type": "array", + "items": { + "type": "object", + "not": { "required": ["mediatype"] } + } + }, + "analysis": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "not": { "anyOf": [ + { "required": ["purpose"] }, + { "required": ["schema"] } + ]} + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "not": { "anyOf": [ + { "required": ["purpose"] }, + { "required": ["mediatype"] } + ]} + } + } + } +} diff --git a/common/tests/test_api_branches.py b/common/tests/test_api_branches.py index 560434ec..7beee995 100644 --- a/common/tests/test_api_branches.py +++ b/common/tests/test_api_branches.py @@ -73,6 +73,77 @@ async def test_sync_vcon_from_storage_restores_to_redis_and_indexes(redis_async) mock_add_to_set.assert_awaited_once() +@pytest.mark.asyncio +async def test_sync_vcon_from_storage_canonicalizes_legacy_payload(redis_async): + """A storage backend may hold a legacy / egress-converted 0.0.1 payload + (see egress_format_version). The fallback must canonicalize it before + caching to Redis or returning to the client, so downstream links/clients + never see legacy field names or native (non-stringified) bodies.""" + vcon_uuid = uuid4() + legacy = { + "uuid": str(vcon_uuid), + "created_at": "2024-01-01T12:00:00", + "vcon": "0.0.1", + "attachments": [{"type": "tags", "body": ["a", "b"], "encoding": "none"}], + "analysis": [{"type": "summary", "body": {"k": "v"}, "encoding": "none"}], + } + storage = Mock(get=Mock(return_value=legacy)) + + with patch.object(api.Configuration, "get_storages", return_value=["s"]), patch.object( + api, "Storage", return_value=storage + ), patch.object(api, "add_vcon_to_set", AsyncMock()): + result = await api.sync_vcon_from_storage(vcon_uuid) + + # Returned payload is canonical. + att = result["attachments"][0] + assert att.get("purpose") == "tags" and "type" not in att + assert isinstance(att["body"], str) and att["encoding"] == "json" + an = result["analysis"][0] + assert isinstance(an["body"], str) and an["encoding"] == "json" + + # The copy cached back into Redis is canonical too (not the raw legacy dict). + cached = redis_async.json.return_value.set.await_args.args[2] + assert cached["attachments"][0].get("purpose") == "tags" + assert "type" not in cached["attachments"][0] + + +@pytest.mark.asyncio +async def test_get_vcon_endpoint_emits_legacy_when_setting_on(redis_async): + """GET /vcon honors the deployment-wide EGRESS_FORMAT_VERSION: the response + is downgraded to the legacy format, while the cached/internal copy stays + canonical.""" + vcon_uuid = uuid4() + canonical = { + "uuid": str(vcon_uuid), + "vcon": "0.4.0", + "created_at": "2024-01-01T12:00:00", + "attachments": [{"purpose": "tags", "body": "[\"a\"]", "encoding": "json"}], + } + with patch.object(api, "ensure_vcon_in_redis", AsyncMock(return_value=canonical)), patch( + "settings.EGRESS_FORMAT_VERSION", "0.0.1" + ): + resp = await api.get_vcon(vcon_uuid) + + body = json.loads(resp.body) + assert body["vcon"] == "0.0.1" + assert body["attachments"][0].get("type") == "tags" + assert "purpose" not in body["attachments"][0] + # Body re-inflated to native form for the legacy consumer. + assert body["attachments"][0]["body"] == ["a"] + + +@pytest.mark.asyncio +async def test_get_vcon_endpoint_canonical_when_setting_off(redis_async): + vcon_uuid = uuid4() + canonical = {"uuid": str(vcon_uuid), "vcon": "0.4.0", "created_at": "2024-01-01T12:00:00"} + with patch.object(api, "ensure_vcon_in_redis", AsyncMock(return_value=canonical)), patch( + "settings.EGRESS_FORMAT_VERSION", None + ): + resp = await api.get_vcon(vcon_uuid) + + assert json.loads(resp.body)["vcon"] == "0.4.0" + + @pytest.mark.asyncio async def test_sync_vcon_from_storage_returns_none_when_not_found(redis_async): with patch.object(api.Configuration, "get_storages", return_value=["a"]), patch.object( diff --git a/common/tests/test_vcon_egress_compat.py b/common/tests/test_vcon_egress_compat.py new file mode 100644 index 00000000..dcebd742 --- /dev/null +++ b/common/tests/test_vcon_egress_compat.py @@ -0,0 +1,224 @@ +"""Tests for lib.vcon_egress_compat.to_legacy (CON-581). + +Covers each field delta, the lawful_basis caveat, non-mutation of the input, +unsupported-version rejection, a round-trip against the forward normalizer +(drift guard), and validation against the derived 0.0.1 schema. +""" + +import copy +import json +import os +from unittest.mock import patch + +import pytest + +from lib.vcon_compat import normalize_legacy_fields +from lib.vcon_egress_compat import SUPPORTED_VERSIONS, to_configured_legacy, to_legacy + +SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "schemas", "vcon-0.0.1.schema.json") + + +def _canonical_vcon(): + """A spec-current (0.4.0) vCon mirroring the production shape.""" + return { + "vcon": "0.4.0", + "uuid": "0190a0e0-0000-8000-8000-000000000000", + "created_at": "2026-06-05T00:00:00+00:00", + "subject": None, + "amended": {"uuid": "previous"}, + "critical": ["lawful_basis"], + "parties": [{"tel": "+15551234567", "name": "Alice", "role": "customer"}], + "dialog": [ + { + "type": "recording", + "start": "2026-06-05T00:00:00+00:00", + "parties": [0], + "mediatype": "audio/wav", + "url": "https://example.com/a.wav", + } + ], + "analysis": [ + { + "type": "summary", + "dialog": 0, + "vendor": "openai", + "body": "a summary", + "encoding": "none", + "schema": "v1", + } + ], + "attachments": [ + {"purpose": "tags", "body": ["category:1"], "encoding": "none"}, + # lawful_basis legitimately carries a `type` value — must survive. + {"type": "lawful_basis", "body": "consent", "encoding": "none"}, + ], + "meta": {"tenant_id": 42}, + } + + +def test_version_is_stamped(): + out = to_legacy(_canonical_vcon(), "0.0.1") + assert out["vcon"] == "0.0.1" + + +def test_top_level_renames(): + out = to_legacy(_canonical_vcon(), "0.0.1") + assert "amended" not in out and out["appended"] == {"uuid": "previous"} + assert "critical" not in out and out["must_support"] == ["lawful_basis"] + + +def test_attachment_purpose_to_type(): + out = to_legacy(_canonical_vcon(), "0.0.1") + tags = out["attachments"][0] + assert tags.get("type") == "tags" + assert "purpose" not in tags + + +def test_attachment_lawful_basis_type_preserved(): + out = to_legacy(_canonical_vcon(), "0.0.1") + lb = out["attachments"][1] + assert lb["type"] == "lawful_basis" + assert "purpose" not in lb + + +def test_dialog_mediatype_to_mimetype(): + out = to_legacy(_canonical_vcon(), "0.0.1") + d = out["dialog"][0] + assert d.get("mimetype") == "audio/wav" + assert "mediatype" not in d + + +def test_analysis_schema_to_schema_version(): + out = to_legacy(_canonical_vcon(), "0.0.1") + a = out["analysis"][0] + assert a.get("schema_version") == "v1" + assert "schema" not in a + + +def test_json_string_body_inflated_to_native(): + """Reverse VconRedis._stringify_json_body: encoding 'json' string body + becomes a native object/array with encoding 'none'.""" + canonical = _canonical_vcon() + canonical["attachments"] = [ + {"purpose": "tags", "body": '["source:crexendo", "direction:out"]', "encoding": "json"}, + {"purpose": "tenant", "body": '{"id": 385}', "encoding": "json"}, + ] + canonical["analysis"] = [ + {"type": "transcript", "dialog": 0, "vendor": "x", "body": '{"transcript": "hi"}', "encoding": "json"}, + ] + out = to_legacy(canonical, "0.0.1") + assert out["attachments"][0]["body"] == ["source:crexendo", "direction:out"] + assert out["attachments"][0]["encoding"] == "none" + assert out["attachments"][1]["body"] == {"id": 385} + assert out["analysis"][0]["body"] == {"transcript": "hi"} + assert out["analysis"][0]["encoding"] == "none" + + +def test_plain_string_body_untouched(): + """A non-JSON body (encoding != 'json') is left exactly as-is.""" + canonical = _canonical_vcon() + canonical["analysis"] = [ + {"type": "summary", "dialog": 0, "vendor": "openai", "body": "a sentence", "encoding": "none"}, + ] + out = to_legacy(canonical, "0.0.1") + assert out["analysis"][0]["body"] == "a sentence" + assert out["analysis"][0]["encoding"] == "none" + + +def test_invalid_json_body_left_as_is(): + """encoding 'json' but unparseable body is not mangled.""" + canonical = _canonical_vcon() + canonical["attachments"] = [{"purpose": "x", "body": "{not json", "encoding": "json"}] + out = to_legacy(canonical, "0.0.1") + assert out["attachments"][0]["body"] == "{not json" + assert out["attachments"][0]["encoding"] == "json" + + +def test_dialog_body_not_inflated(): + """Dialog bodies are not stringified on write, so they must not be touched.""" + canonical = _canonical_vcon() + canonical["dialog"] = [ + {"type": "text", "start": "t", "body": '{"parts": []}', "encoding": "json"}, + ] + out = to_legacy(canonical, "0.0.1") + # Unchanged: still the JSON string with encoding json. + assert out["dialog"][0]["body"] == '{"parts": []}' + assert out["dialog"][0]["encoding"] == "json" + + +def test_legacy_top_level_keys_present_when_dropped(): + canonical = _canonical_vcon() + # 0.4.0 lib drops empty group/redacted entirely. + canonical.pop("group", None) + canonical.pop("redacted", None) + out = to_legacy(canonical, "0.0.1") + assert out["group"] == [] + assert out["redacted"] == {} + assert "appended" in out + + +def test_input_is_not_mutated(): + canonical = _canonical_vcon() + before = copy.deepcopy(canonical) + to_legacy(canonical, "0.0.1") + assert canonical == before + + +def test_unsupported_version_raises(): + with pytest.raises(ValueError): + to_legacy(_canonical_vcon(), "9.9.9") + + +def test_supported_versions_contains_001(): + assert "0.0.1" in SUPPORTED_VERSIONS + + +def test_round_trip_restores_spec_form(): + """Downgrading then re-normalizing must restore the spec field names. + + This guards against to_legacy drifting from vcon_compat.normalize_legacy_fields. + """ + legacy = to_legacy(_canonical_vcon(), "0.0.1") + renorm = normalize_legacy_fields(copy.deepcopy(legacy)) + assert renorm["amended"] == {"uuid": "previous"} + assert renorm["critical"] == ["lawful_basis"] + assert renorm["attachments"][0]["purpose"] == "tags" + assert renorm["dialog"][0]["mediatype"] == "audio/wav" + assert renorm["analysis"][0]["schema"] == "v1" + + +# --- schema validation ----------------------------------------------------- + +def _schema(): + with open(SCHEMA_PATH) as fh: + return json.load(fh) + + +def test_downgraded_payload_validates_against_legacy_schema(): + jsonschema = pytest.importorskip("jsonschema") + out = to_legacy(_canonical_vcon(), "0.0.1") + jsonschema.validate(instance=out, schema=_schema()) + + +def test_canonical_payload_fails_legacy_schema(): + jsonschema = pytest.importorskip("jsonschema") + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance=_canonical_vcon(), schema=_schema()) + + +# --- to_configured_legacy (reads the EGRESS_FORMAT_VERSION setting) -------- + +def test_to_configured_legacy_applies_when_set(): + with patch("settings.EGRESS_FORMAT_VERSION", "0.0.1"): + out = to_configured_legacy(_canonical_vcon()) + assert out["vcon"] == "0.0.1" + assert out["attachments"][0].get("type") == "tags" + + +def test_to_configured_legacy_noop_when_unset(): + original = _canonical_vcon() + with patch("settings.EGRESS_FORMAT_VERSION", None): + out = to_configured_legacy(original) + # Unset → returned unchanged (same object, current spec). + assert out is original + assert out["vcon"] == "0.4.0" diff --git a/common/tests/test_vcon_redis_spec_enforce.py b/common/tests/test_vcon_redis_spec_enforce.py index d3b90b05..74630d90 100644 --- a/common/tests/test_vcon_redis_spec_enforce.py +++ b/common/tests/test_vcon_redis_spec_enforce.py @@ -15,6 +15,14 @@ def test_keeps_existing_syntax_param(): assert d["vcon"] == "0.4.0" +def test_upgrades_legacy_syntax_param(): + # A stale legacy version is stamped to the current spec, since the field + # names are normalized up to that spec on the same pass. + d = {"uuid": "u", "vcon": "0.0.1"} + VconRedis._enforce_spec_on_write(d) + assert d["vcon"] == "0.4.0" + + def test_strips_empty_group(): d = {"uuid": "u", "group": []} VconRedis._enforce_spec_on_write(d) diff --git a/conserver/links/webhook/__init__.py b/conserver/links/webhook/__init__.py index 27117346..627e7e3f 100644 --- a/conserver/links/webhook/__init__.py +++ b/conserver/links/webhook/__init__.py @@ -1,6 +1,7 @@ from lib.vcon_redis import VconRedis from lib.logging_utils import init_logger from lib.metrics import increment_counter +from lib.vcon_egress_compat import to_configured_legacy import requests logger = init_logger(__name__) @@ -13,6 +14,8 @@ # "x-conserver-api-token": "your-api-token", # }, # } +# To emit a legacy vCon format instead of the current spec, set the +# deployment-wide EGRESS_FORMAT_VERSION environment variable (see settings). default_options = { "webhook-urls": [], @@ -33,6 +36,11 @@ def run( # The webhook needs a stringified JSON version. json_dict = vCon.to_dict() + # If EGRESS_FORMAT_VERSION is set, downgrade the egress payload to that + # legacy format for downstream consumers built against an older schema. The + # canonical vCon in Redis is untouched. + json_dict = to_configured_legacy(json_dict) + # Build headers from configuration headers = opts.get("headers", {}) diff --git a/docs/configuration/egress-compatibility.md b/docs/configuration/egress-compatibility.md new file mode 100644 index 00000000..c6039a9d --- /dev/null +++ b/docs/configuration/egress-compatibility.md @@ -0,0 +1,78 @@ +# Egress Format Compatibility + +The conserver normalizes every vCon to the current spec version (`vcon: "0.4.0"`) +internally. Some deployments have downstream consumers (analytics pipelines, +indexes, BI tooling) that were built against an older vCon schema and cannot yet +be migrated. The **egress format compatibility** setting lets a deployment emit a +legacy vCon format to its external consumers, without changing the canonical +representation used inside the pipeline. + +## How it works + +A single converter (`lib.vcon_egress_compat.to_legacy`) downgrades an outgoing +payload to a target legacy version. It is the inverse of the read/write +normalization the conserver applies to bring legacy producers up to the current +spec, so a downgraded payload that is later re-ingested normalizes back up +cleanly. + +The behavior is controlled by one deployment-wide setting, +`EGRESS_FORMAT_VERSION`. Every **egress point** consults it: + +- the `webhook` link +- the `postgres`, `s3`, and `elasticsearch` storage backends +- the API read endpoints (`GET /vcon/{uuid}`, `GET /vcons`) + +The **canonical in-pipeline representation is never affected**: the Redis cache +and everything links read during processing always stay on the current spec. +Only the data that leaves the system is converted. + +## Enabling it + +Set the `EGRESS_FORMAT_VERSION` environment variable to a supported legacy +version string. Leave it unset for the default behavior (current spec emitted +everywhere, byte-identical to a deployment without this feature). + +```bash +EGRESS_FORMAT_VERSION=0.0.1 +``` + +An unsupported version string raises an error at egress time rather than +emitting a wrong payload. + +## Supported version mappings + +| Target | Status | +| --- | --- | +| `0.0.1` | Supported | + +### Field deltas applied for `0.0.1` + +| Current spec (0.4.0) | Legacy (0.0.1) | Scope | +| --- | --- | --- | +| `vcon: "0.4.0"` | `vcon: "0.0.1"` | top level | +| `amended` | `appended` | top level | +| `critical` | `must_support` | top level + dialog / analysis entries | +| `purpose` | `type` | attachments (only when `type` is absent) | +| `mediatype` | `mimetype` | dialog + attachments | +| `schema` | `schema_version` | dialog + analysis | +| `body` as JSON string + `encoding: "json"` | native object/array + `encoding: "none"` | analysis + attachments | +| empty `group` / `redacted` omitted | re-added as `group: []`, `redacted: {}`, `appended: null` | top level | + +The JSON-body conversion re-inflates analysis and attachment bodies that the +spec write-path serializes to strings. Dialog bodies are not serialized on +write and are left unchanged. + +## Known gaps and notes + +- **Deployment-wide:** every egress point emits the same configured version. +- **API consumers:** while the setting is on, the API read endpoints return the + legacy format to all callers. Use it only when every API consumer expects the + legacy schema. +- **Attachment `type` vs. `purpose`:** for attachments that already carry a + `type` value (e.g. the `lawful_basis` extension, which uses `type` as its + value), the value is preserved rather than overwritten. +- **Custom/extension fields** (e.g. application-specific top-level keys or + `vendor_schema`) are passed through unchanged in both directions. +- **Coverage:** the supported delta set reflects the differences between the + current spec and `0.0.1`. New downstream requirements should be validated + against the target schema and added here before relying on them. diff --git a/mkdocs.yml b/mkdocs.yml index cb8cd208..c662d8ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ nav: - Environment Variables: configuration/environment-variables.md - YAML Configuration: configuration/yaml-configuration.md - Chains and Pipelines: configuration/chains-and-pipelines.md + - Egress Format Compatibility: configuration/egress-compatibility.md - Authentication: configuration/authentication.md - Workers: configuration/workers.md - Operations: diff --git a/pyproject.toml b/pyproject.toml index 556b4e83..5c0f9d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,9 @@ dev = [ "anyio>=4.8.0", "httpx>=0.27.0", "pytest-cov>=7.1.0", + # Used by test_vcon_egress_compat to validate downgraded payloads against + # the derived legacy (0.0.1) schema. + "jsonschema>=4.21.0", ] diff --git a/tests/storage/test_egress_compat_integration.py b/tests/storage/test_egress_compat_integration.py new file mode 100644 index 00000000..1218a682 --- /dev/null +++ b/tests/storage/test_egress_compat_integration.py @@ -0,0 +1,156 @@ +"""Integration tests for the egress_format_version option (CON-581). + +Exercises the real save()/run() code paths of the egress points that consume a +vCon document — the s3 and elasticsearch storage modules and the webhook link — +with the option active, asserting the emitted payload is the legacy 0.0.1 shape +while the canonical copy handed in is untouched. + +Follows test_s3's sys.modules pattern: lib.vcon_redis / lib.logging_utils / +lib.metrics are mocked before importing the modules under test (so importing +them doesn't open a real Redis connection), then restored. +""" + +import importlib +import json +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +CONSERVER_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "conserver") + +_ORIG = {name: sys.modules.get(name) for name in ("lib.logging_utils", "lib.vcon_redis", "lib.metrics")} + +sys.modules["lib.logging_utils"] = MagicMock(init_logger=MagicMock(return_value=MagicMock())) +sys.modules["lib.vcon_redis"] = MagicMock() +sys.modules["lib.metrics"] = MagicMock(increment_counter=MagicMock()) + +from storage import s3 as s3_storage # noqa: E402 +from storage import elasticsearch as es_storage # noqa: E402 + +if CONSERVER_DIR not in sys.path: + sys.path.insert(0, CONSERVER_DIR) +webhook_link = importlib.import_module("links.webhook") # noqa: E402 + +for _name, _mod in _ORIG.items(): + if _mod is not None: + sys.modules[_name] = _mod + else: + sys.modules.pop(_name, None) + + +def _canonical_dict(): + """A spec-current (0.4.0) vCon as vcon.to_dict() would return it.""" + return { + "vcon": "0.4.0", + "uuid": "test-uuid", + "created_at": "2026-06-05T00:00:00+00:00", + "subject": None, + "parties": [{"tel": "+15551234567", "role": "customer"}], + "dialog": [{"type": "recording", "start": "2026-06-05T00:00:00+00:00", "mediatype": "audio/wav"}], + "analysis": [{"type": "summary", "dialog": 0, "vendor": "openai", "body": "s", "encoding": "none", "schema": "v1"}], + "attachments": [{"purpose": "tags", "body": ["category:1"], "encoding": "none"}], + "meta": {"tenant_id": 42}, + } + + +def _mock_vcon(): + canonical = _canonical_dict() + mock = MagicMock() + mock.to_dict.return_value = canonical + mock.dumps.return_value = json.dumps(canonical) + mock.created_at = canonical["created_at"] + mock.uuid = canonical["uuid"] + mock.vcon = canonical["vcon"] + mock.subject = canonical["subject"] + return mock + + +# Behavior is driven by the deployment-wide EGRESS_FORMAT_VERSION setting, read +# lazily by lib.vcon_egress_compat.to_configured_legacy. Patch it on the +# settings module so each egress point sees it at call time. +def _legacy(version="0.0.1"): + return patch("settings.EGRESS_FORMAT_VERSION", version) + + +# --- s3 storage ------------------------------------------------------------ + +def test_s3_save_emits_legacy_when_setting_on(): + opts = {"aws_access_key_id": "k", "aws_secret_access_key": "s", "aws_bucket": "b"} + with _legacy(), patch("storage.s3.VconRedis") as redis_cls, patch("storage.s3.boto3.client") as boto: + redis_cls.return_value.get_vcon.return_value = _mock_vcon() + mock_s3 = MagicMock() + boto.return_value = mock_s3 + + s3_storage.save("test-uuid", opts) + + body_call = next(c for c in mock_s3.put_object.call_args_list if c.kwargs["Key"].endswith(".vcon")) + stored = json.loads(body_call.kwargs["Body"]) + assert stored["vcon"] == "0.0.1" + assert stored["attachments"][0]["type"] == "tags" + assert "purpose" not in stored["attachments"][0] + assert stored["dialog"][0]["mimetype"] == "audio/wav" + + +def test_s3_save_unchanged_when_setting_off(): + opts = {"aws_access_key_id": "k", "aws_secret_access_key": "s", "aws_bucket": "b"} + with _legacy(None), patch("storage.s3.VconRedis") as redis_cls, patch("storage.s3.boto3.client") as boto: + redis_cls.return_value.get_vcon.return_value = _mock_vcon() + mock_s3 = MagicMock() + boto.return_value = mock_s3 + + s3_storage.save("test-uuid", opts) + + body_call = next(c for c in mock_s3.put_object.call_args_list if c.kwargs["Key"].endswith(".vcon")) + assert json.loads(body_call.kwargs["Body"])["vcon"] == "0.4.0" + + +# --- elasticsearch storage ------------------------------------------------- + +def test_elasticsearch_indexes_attachments_by_legacy_type_when_setting_on(): + opts = {"url": "http://es:9200", "username": "u", "password": "p"} + # Patch the module's bound `elasticsearch` reference wholesale rather than a + # dotted attribute path — robust to import-name shadowing under full-suite + # collection order. + with _legacy(), patch.object(es_storage, "VconRedis") as redis_cls, \ + patch.object(es_storage, "elasticsearch") as es_lib: + redis_cls.return_value.get_vcon.return_value = _mock_vcon() + es = es_lib.Elasticsearch.return_value + + es_storage.save("test-uuid", opts) + + attach_indexes = [c.kwargs["index"] for c in es.index.call_args_list if "attachments" in c.kwargs["index"]] + assert "vcon_attachments_tags" in attach_indexes + + +# --- webhook link ---------------------------------------------------------- + +def test_webhook_posts_legacy_payload_when_setting_on(): + opts = {"webhook-urls": ["https://downstream.example/ingest"]} + with _legacy(), patch.object(webhook_link, "VconRedis") as redis_cls, \ + patch.object(webhook_link, "requests") as req: + redis_cls.return_value.get_vcon.return_value = _mock_vcon() + req.post.return_value = MagicMock(status_code=200, text="ok") + + result = webhook_link.run("test-uuid", "wh", opts) + + assert result == "test-uuid" + posted = req.post.call_args.kwargs["json"] + assert posted["vcon"] == "0.0.1" + assert posted["attachments"][0]["type"] == "tags" + assert "purpose" not in posted["attachments"][0] + + +def test_webhook_posts_canonical_when_setting_off(): + opts = {"webhook-urls": ["https://downstream.example/ingest"]} + with _legacy(None), patch.object(webhook_link, "VconRedis") as redis_cls, \ + patch.object(webhook_link, "requests") as req: + redis_cls.return_value.get_vcon.return_value = _mock_vcon() + req.post.return_value = MagicMock(status_code=200, text="ok") + + webhook_link.run("test-uuid", "wh", opts) + + posted = req.post.call_args.kwargs["json"] + assert posted["vcon"] == "0.4.0" + assert posted["attachments"][0]["purpose"] == "tags" diff --git a/tests/storage/test_s3.py b/tests/storage/test_s3.py index 9d554eea..2e7f3243 100644 --- a/tests/storage/test_s3.py +++ b/tests/storage/test_s3.py @@ -296,6 +296,7 @@ def mock_vcon(self): """Create a mock vCon object.""" mock = MagicMock() mock.dumps.return_value = '{"uuid": "test-uuid", "vcon": "1.0.0"}' + mock.to_dict.return_value = {"uuid": "test-uuid", "vcon": "1.0.0"} mock.created_at = "2025-12-10T15:30:00Z" return mock diff --git a/uv.lock b/uv.lock index c343e112..99881eb5 100644 --- a/uv.lock +++ b/uv.lock @@ -960,6 +960,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/16/4697abf6c1d92e8297e07c3fba6d400b5a9c71780a24072480d9076451d7/jq-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:54f896e878c89cef4c05aff53f822de62a08e91d08bad7cbf4f7e91b7a06a460", size = 409686, upload-time = "2026-01-16T16:37:09.921Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[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, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "logger" version = "1.4" @@ -1913,6 +1940,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2e/409703d645363352a20c944f5d119bdae3eb3034051a53724a7c5fee12b8/redis-4.6.0-py3-none-any.whl", hash = "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c", size = 241149, upload-time = "2023-06-25T13:13:54.563Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { 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, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2026.2.28" @@ -1997,6 +2038,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { 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, upload-time = "2026-05-28T11:59:12.531Z" }, + { 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, upload-time = "2026-05-28T11:59:13.827Z" }, + { 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, upload-time = "2026-05-28T11:59:15.271Z" }, + { 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, upload-time = "2026-05-28T11:59:16.665Z" }, + { 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, upload-time = "2026-05-28T11:59:17.991Z" }, + { 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, upload-time = "2026-05-28T11:59:19.434Z" }, + { 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, upload-time = "2026-05-28T11:59:21Z" }, + { 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, upload-time = "2026-05-28T11:59:22.454Z" }, + { 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, upload-time = "2026-05-28T11:59:23.845Z" }, + { 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, upload-time = "2026-05-28T11:59:25.184Z" }, + { 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, upload-time = "2026-05-28T11:59:26.749Z" }, + { 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, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { 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, upload-time = "2026-05-28T11:59:33.655Z" }, + { 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, upload-time = "2026-05-28T11:59:35Z" }, + { 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, upload-time = "2026-05-28T11:59:36.43Z" }, + { 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, upload-time = "2026-05-28T11:59:37.995Z" }, + { 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, upload-time = "2026-05-28T11:59:39.453Z" }, + { 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, upload-time = "2026-05-28T11:59:40.896Z" }, + { 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, upload-time = "2026-05-28T11:59:42.626Z" }, + { 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, upload-time = "2026-05-28T11:59:44.124Z" }, + { 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, upload-time = "2026-05-28T11:59:47.177Z" }, + { 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, upload-time = "2026-05-28T11:59:48.603Z" }, + { 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, upload-time = "2026-05-28T11:59:50.314Z" }, + { 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, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { 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, upload-time = "2026-05-28T11:59:57.448Z" }, + { 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, upload-time = "2026-05-28T11:59:58.881Z" }, + { 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, upload-time = "2026-05-28T12:00:00.516Z" }, + { 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, upload-time = "2026-05-28T12:00:02.394Z" }, + { 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, upload-time = "2026-05-28T12:00:04.189Z" }, + { 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, upload-time = "2026-05-28T12:00:05.797Z" }, + { 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, upload-time = "2026-05-28T12:00:07.433Z" }, + { 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, upload-time = "2026-05-28T12:00:09.227Z" }, + { 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, upload-time = "2026-05-28T12:00:10.779Z" }, + { 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, upload-time = "2026-05-28T12:00:12.443Z" }, + { 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, upload-time = "2026-05-28T12:00:14.09Z" }, + { 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, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, +] + [[package]] name = "s3transfer" version = "0.16.0" @@ -2364,6 +2457,7 @@ dev = [ { name = "black" }, { name = "faker" }, { name = "httpx" }, + { name = "jsonschema" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -2440,6 +2534,7 @@ dev = [ { name = "black", specifier = ">=24.2.0" }, { name = "faker", specifier = ">=33.3.1" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "jsonschema", specifier = ">=4.21.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=7.1.0" },