From 461be6454f4bbe2a8867be22f3264c6704e34a23 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:28:00 -0400 Subject: [PATCH 01/32] feat(etl-uvicorn): install invocation-settings handling (0.1.0) --- CHANGELOG.md | 24 ++++ pyproject.toml | 4 +- test/api/test_invocation_settings.py | 103 ++++++++++++++++++ unstructured_platform_plugins/__version__.py | 2 +- .../etl_uvicorn/api_generator.py | 32 +++++- .../etl_uvicorn/main.py | 10 ++ 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 test/api/test_invocation_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a36cf50..0c0996e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## 0.1.0 + +* **The wrapper now installs the invocation-settings envelope handling itself.** Every wrapped app + gets the `utic-invocation-settings` ASGI middleware and a `/metadata` route at construction: the + reserved `invocation_settings` / `invocation_context` fields are handled outside the generated + handler schema, a sealed `dag_node_settings` member is decrypted with the configured private + key, and the resolved values are exposed request-scoped through + `current_invocation_settings()` / `current_invocation_context()`. Missing fields preserve the + existing fallback behavior; when `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` is enabled, + missing or plaintext settings fail closed. Repeated installation is safe: the middleware + installs once and the last `/metadata` registration wins. +* **New opt-in `invoke_with_sealed_dag_node_settings` advertisement.** Pass + `invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or + `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; + it advertises that the application accepts and consumes sealed per-invocation settings. + A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` + (which replaces the wrapper's route) — a plain `@app.get("/metadata")` added after construction + is shadowed by the wrapper's earlier registration. +* **Sync plugin functions now observe request-scoped context.** `invoke_func` copies the current + context into the executor thread; previously `run_in_executor` dropped contextvars, so a sync + function reading a request-scoped binding (such as `current_invocation_settings()`) would see + it as absent and could take an unintended fallback path. +* **Python floor is now 3.11** (required by `utic-invocation-settings`). + ## 0.0.46 * **Carry preflight failure categories through standard `/precheck` responses.** diff --git a/pyproject.toml b/pyproject.toml index 4bb84aa..471440e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "unstructured_platform_plugins" description = "Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform" -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -10,7 +10,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -24,6 +23,7 @@ dependencies = [ "fastapi", "click", "unstructured-ingest", + "utic-invocation-settings>=0.3.0,<1.0.0", "opentelemetry-instrumentation-fastapi", "opentelemetry-exporter-otlp-proto-grpc", "dataclasses-json" diff --git a/test/api/test_invocation_settings.py b/test/api/test_invocation_settings.py new file mode 100644 index 0000000..856e17e --- /dev/null +++ b/test/api/test_invocation_settings.py @@ -0,0 +1,103 @@ +"""The wrapper-installed invocation-settings surface: /metadata and reserved-field binding.""" + +from typing import Optional + +from fastapi.testclient import TestClient +from pydantic import BaseModel +from utic_invocation_settings import current_invocation_settings + +from unstructured_platform_plugins.etl_uvicorn.api_generator import wrap_in_fastapi + + +class _Echo(BaseModel): + content: str + settings: Optional[dict] + + +def _echo_settings(content: str) -> _Echo: + return _Echo(content=content, settings=current_invocation_settings()) + + +def test_metadata_route_is_registered_with_default_capabilities(): + client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) + + resp = client.get("/metadata") + + assert resp.status_code == 200 + payload = resp.json() + assert payload["identifier"] == "mock_plugin" + assert payload["capabilities"] == ["invocation_settings", "invocation_context"] + + +def test_sealed_capability_is_opt_in(): + client = TestClient( + wrap_in_fastapi( + func=_echo_settings, + plugin_id="mock_plugin", + invoke_with_sealed_dag_node_settings=True, + ) + ) + + payload = client.get("/metadata").json() + + assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"] + + +def test_reserved_settings_field_binds_without_appearing_in_schema(): + app = wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin") + client = TestClient(app) + + resp = client.post( + "/invoke", + json={"content": "hello", "invocation_settings": {"model": "m"}}, + ) + + assert resp.status_code == 200 + output = resp.json()["output"] + assert output == {"content": "hello", "settings": {"model": "m"}} + # The wrapper does not add the reserved field to the generated handler input model. + openapi = app.openapi() + request_schema = openapi["paths"]["/invoke"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + schema_name = request_schema["$ref"].rsplit("/", 1)[-1] + properties = openapi["components"]["schemas"][schema_name]["properties"] + assert "invocation_settings" not in properties + + +def test_sync_function_sees_bound_settings_across_the_executor(): + # Sync functions run in an executor thread; the context must be copied there or the + # request-scoped binding would read as absent. + def sync_echo(content: str) -> _Echo: + return _Echo(content=content, settings=current_invocation_settings()) + + client = TestClient(wrap_in_fastapi(func=sync_echo, plugin_id="mock_plugin")) + + resp = client.post( + "/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}} + ) + + assert resp.json()["output"]["settings"] == {"model": "m"} + + +async def _async_echo(content: str) -> _Echo: + return _Echo(content=content, settings=current_invocation_settings()) + + +def test_async_function_sees_bound_settings(): + client = TestClient(wrap_in_fastapi(func=_async_echo, plugin_id="mock_plugin")) + + resp = client.post( + "/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}} + ) + + assert resp.json()["output"]["settings"] == {"model": "m"} + + +def test_absent_reserved_fields_bind_none(): + client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) + + resp = client.post("/invoke", json={"content": "hello"}) + + assert resp.status_code == 200 + assert resp.json()["output"] == {"content": "hello", "settings": None} diff --git a/unstructured_platform_plugins/__version__.py b/unstructured_platform_plugins/__version__.py index 26262e4..2a08aec 100644 --- a/unstructured_platform_plugins/__version__.py +++ b/unstructured_platform_plugins/__version__.py @@ -1 +1 @@ -__version__ = "0.0.46" # pragma: no cover +__version__ = "0.1.0" # pragma: no cover diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 5534260..82107d3 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -1,4 +1,5 @@ import asyncio +import contextvars import hashlib import inspect import json @@ -13,6 +14,7 @@ from typing_extensions import deprecated from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict from unstructured_ingest.error import UnstructuredIngestError +from utic_invocation_settings import add_metadata_route, install_invocation_envelope from uvicorn.config import LOG_LEVELS from uvicorn.importer import import_from_string @@ -169,9 +171,15 @@ def wrap_in_fastapi( func: Callable, plugin_id: str, precheck_func: Optional[Callable] = None, + invoke_with_sealed_dag_node_settings: bool = False, ) -> FastAPI: try: - return _wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func) + return _wrap_in_fastapi( + func=func, + plugin_id=plugin_id, + precheck_func=precheck_func, + invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, + ) except Exception as e: logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True) raise EtlApiException(e) from e @@ -181,6 +189,7 @@ def _wrap_in_fastapi( func: Callable, plugin_id: str, precheck_func: Optional[Callable] = None, + invoke_with_sealed_dag_node_settings: bool = False, ) -> FastAPI: if precheck_func is not None: check_precheck_func(precheck_func=precheck_func) @@ -406,6 +415,19 @@ async def get_id() -> str: except TypeError as e: raise TypeError(f"failed to validate function schema: {e}") from e + # The middleware handles the reserved /invoke fields (invocation_settings and + # invocation_context) outside the generated handler schema. It resolves sealed settings with + # the configured private key and exposes both values through request-scoped accessors. The + # sealed-settings capability remains opt-in because it asserts that the wrapped function + # consumes current_invocation_settings(), not merely that the host can resolve it. Repeated + # installation is safe: the middleware installs once and the last /metadata registration wins. + add_metadata_route( + fastapi_app, + identifier=plugin_id, + invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, + ) + install_invocation_envelope(fastapi_app) + FastAPIInstrumentor.instrument_app( fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider() ) @@ -420,6 +442,7 @@ def generate_fast_api( id_method: Optional[str] = None, precheck_str: Optional[str] = None, precheck_method: Optional[str] = None, + invoke_with_sealed_dag_node_settings: bool = False, ) -> FastAPI: instance = import_from_string(app) func = get_func(instance, method_name) @@ -438,4 +461,9 @@ def generate_fast_api( elif precheck_method: precheck_func = get_func(instance, precheck_method) - return wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func) + return wrap_in_fastapi( + func=func, + plugin_id=plugin_id, + precheck_func=precheck_func, + invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, + ) diff --git a/unstructured_platform_plugins/etl_uvicorn/main.py b/unstructured_platform_plugins/etl_uvicorn/main.py index 600e0c1..62b067f 100644 --- a/unstructured_platform_plugins/etl_uvicorn/main.py +++ b/unstructured_platform_plugins/etl_uvicorn/main.py @@ -56,6 +56,7 @@ def api_wrapper( plugin_id_method: Optional[str] = None, precheck_app: Optional[str] = None, precheck_app_method: Optional[str] = None, + sealed_dag_node_settings: bool = False, **kwargs, ): # Make sure logging is configured before the call to run() so any setup has the same format @@ -73,6 +74,7 @@ def api_wrapper( id_method=plugin_id_method, precheck_str=precheck_app, precheck_method=precheck_app_method, + invoke_with_sealed_dag_node_settings=sealed_dag_node_settings, ) # Explicitly map values that are manipulated in the original # call to run(), preventing **kwargs reference @@ -130,6 +132,14 @@ def api_wrapper( "If precheck-app not provided, assumes method " "lives on main class passes in.", ), + click.Option( + ["--sealed-dag-node-settings"], + is_flag=True, + default=False, + help="Advertise the invoke_with_sealed_dag_node_settings capability on " + "/metadata. Set only for a plugin that consumes per-invoke settings " + "through current_invocation_settings().", + ), ] ) return cmd From 8049666c193ea37016af49876005e39851aa2864 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:49:51 -0400 Subject: [PATCH 02/32] refactor(etl-uvicorn): own the /invoke transport, not the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the ASGI middleware, the /metadata capability route and the request-scoped binding out of utic-invocation-settings and into this package, as unstructured_platform_plugins.invocation_settings. The split follows what the two halves actually are. The contract — which keys carry settings, how a sealed envelope is told from plaintext, what an absent field is allowed to mean — stays in the library: the absence rule is the tenant-confusion vector, and it belongs next to the crypto it governs and the threat model that describes it. Buffering a request body and registering a route do not. Two things this buys immediately: - No more duck-typing. Living in the library forced the middleware to reach into `app.router.routes` through getattr chains to avoid importing Starlette. Here fastapi is already a dependency, so route eviction and the ASGI signature are typed against the real thing. - The library goes back to cryptography + pydantic with no framework test dependencies at all; its packaging suite asserts that against the built wheel. Requires utic-invocation-settings >=0.4.0 for resolve_invocation_settings, http_status_for and the contract constants. Tests: 109 passed (84 + 25 ported transport tests), ruff clean. --- CHANGELOG.md | 32 +- pyproject.toml | 2 +- test/api/test_invocation_middleware.py | 452 ++++++++++++++++++ test/api/test_invocation_settings.py | 2 +- .../etl_uvicorn/api_generator.py | 5 +- .../invocation_settings.py | 243 ++++++++++ 6 files changed, 724 insertions(+), 12 deletions(-) create mode 100644 test/api/test_invocation_middleware.py create mode 100644 unstructured_platform_plugins/invocation_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0996e..9da11b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,21 @@ ## 0.1.0 -* **The wrapper now installs the invocation-settings envelope handling itself.** Every wrapped app - gets the `utic-invocation-settings` ASGI middleware and a `/metadata` route at construction: the - reserved `invocation_settings` / `invocation_context` fields are handled outside the generated - handler schema, a sealed `dag_node_settings` member is decrypted with the configured private - key, and the resolved values are exposed request-scoped through - `current_invocation_settings()` / `current_invocation_context()`. Missing fields preserve the - existing fallback behavior; when `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` is enabled, - missing or plaintext settings fail closed. Repeated installation is safe: the middleware - installs once and the last `/metadata` registration wins. +* **This package now owns the `/invoke` transport for the reserved fields.** + `unstructured_platform_plugins.invocation_settings` holds the ASGI middleware, the `/metadata` + capability route, and the request-scoped binding. It sits on `utic-invocation-settings >=0.4.0`, + which owns the *contract* — which keys carry settings, how a sealed envelope is told from + plaintext, and what an absent field is allowed to mean. That split is deliberate: the absence + rule is a security decision and belongs next to the crypto it governs, while body buffering and + route registration belong here, where a web framework is already a dependency. Nothing about the + wire format is decided in this repository. +* **Every wrapped app installs it at construction.** The reserved `invocation_settings` / + `invocation_context` fields are handled outside the generated handler schema, a sealed + `dag_node_settings` member is opened with this pod's mounted workload key, and the resolved + values are exposed through `current_invocation_settings()` / `current_invocation_context()`. + An absent field preserves the existing fallback behaviour; under + `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` missing or plaintext settings fail closed. + Repeated installation is safe: the middleware installs once and the last `/metadata` + registration wins. * **New opt-in `invoke_with_sealed_dag_node_settings` advertisement.** Pass `invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; @@ -16,6 +23,13 @@ A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` (which replaces the wrapper's route) — a plain `@app.get("/metadata")` added after construction is shadowed by the wrapper's earlier registration. +* **Resolution runs off the event loop.** A cold resolve is an RSA unwrap of a couple of + milliseconds and this middleware fronts every invoke on the pod, so it is dispatched with + `asyncio.to_thread` rather than blocking the loop. +* **Failures map through the library's blame taxonomy**, not a flat 500: only a caller-fixable + fault answers 422. Sealing drift, an envelope addressed to another recipient and a broken local + mount are all 5xx, which keeps the controller's blame classification off the customer. Responses + carry the error's class name and never its message, which can embed request-controlled values. * **Sync plugin functions now observe request-scoped context.** `invoke_func` copies the current context into the executor thread; previously `run_in_executor` dropped contextvars, so a sync function reading a request-scoped binding (such as `current_invocation_settings()`) would see diff --git a/pyproject.toml b/pyproject.toml index 471440e..4950598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "fastapi", "click", "unstructured-ingest", - "utic-invocation-settings>=0.3.0,<1.0.0", + "utic-invocation-settings>=0.4.0,<1.0.0", "opentelemetry-instrumentation-fastapi", "opentelemetry-exporter-otlp-proto-grpc", "dataclasses-json" diff --git a/test/api/test_invocation_middleware.py b/test/api/test_invocation_middleware.py new file mode 100644 index 0000000..2b8b125 --- /dev/null +++ b/test/api/test_invocation_middleware.py @@ -0,0 +1,452 @@ +"""The transport for the reserved /invoke fields: middleware, /metadata, request-scoped binding. + +The *contract* these exercise — which shapes carry settings, what absence means — is owned and +tested in `utic_invocation_settings`. What is tested here is delivery: that a raw body is read, +resolved, bound, and replayed intact, and that a payload which cannot be used fails the request +instead of reaching a handler as absence. +""" + +from __future__ import annotations + +import asyncio +import json +from base64 import b64decode, b64encode + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI +from fastapi.testclient import TestClient +from utic_invocation_settings import ( + DAG_NODE_SETTINGS_KEY, + REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, + default_resolver, + reset_workload_identity_cache, +) +from utic_invocation_settings.crypto import seal_settings + +from unstructured_platform_plugins.invocation_settings import ( + InvocationEnvelopeMiddleware, + add_metadata_route, + current_invocation_context, + current_invocation_settings, +) + +SENTINEL_SECRET = "sealed-settings-sentinel-secret" + + +@pytest.fixture(scope="session") +def private_key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=3072) + + +@pytest.fixture(autouse=True) +def isolated_identity(monkeypatch): + """The identity memo and the resolver caches both outlive a test; an inherited env var or a + stale entry would make these order-dependent.""" + for var in ("WORKLOAD_IDENTITY_DIR", "INVOCATION_SETTINGS_KEY_DIR", + REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR): + monkeypatch.delenv(var, raising=False) + reset_workload_identity_cache() + default_resolver().clear_caches() + yield + reset_workload_identity_cache() + default_resolver().clear_caches() + + +@pytest.fixture +def key_dir(tmp_path, private_key, monkeypatch, isolated_identity): + (tmp_path / "tls.key").write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path)) + reset_workload_identity_cache() + return tmp_path + + +def sealed_payload(private_key: rsa.RSAPrivateKey, settings: dict) -> dict: + return seal_settings(settings, private_key.public_key()).model_dump( + mode="json", exclude_none=True + ) + + +def tampered(sealed: dict) -> dict: + ciphertext = bytearray(b64decode(sealed["content_encryption"]["ciphertext"])) + ciphertext[0] ^= 0x01 + sealed["content_encryption"]["ciphertext"] = b64encode(bytes(ciphertext)).decode() + return sealed + + +class TestMetadataRoute: + def test_advertises_settings_and_context_capabilities(self): + app = FastAPI() + add_metadata_route(app, identifier="plugin.test") + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload == { + "api_version": "3", + "identifier": "plugin.test", + "capabilities": ["invocation_settings", "invocation_context"], + } + + def test_sealed_dag_node_settings_flag_advertises_capability(self): + app = FastAPI() + add_metadata_route(app, invoke_with_sealed_dag_node_settings=True) + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload["capabilities"] == [ + "invocation_settings", + "invocation_context", + "invoke_with_sealed_dag_node_settings", + ] + + def test_last_call_wins(self): + # A host wrapper registers /metadata with defaults at construction; the plugin's later call + # with the sealed capability must replace it, not be shadowed by route order. + app = FastAPI() + add_metadata_route(app, identifier="plugin.test") + add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"] + + def test_replaces_a_directly_registered_metadata_route(self): + # A route the app registered itself would otherwise win by route order and pin its stale + # payload. + app = FastAPI() + + @app.get("/metadata") + async def stale_metadata() -> dict: + return {"api_version": "3", "identifier": "stale", "capabilities": []} + + add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload["identifier"] == "plugin.test" + assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"] + + +def _invoke_scope(path: str = "/invoke", method: str = "POST") -> dict: + return {"type": "http", "method": method, "path": path} + + +def _receive_for(body: bytes): + chunks = [ + {"type": "http.request", "body": body[: len(body) // 2], "more_body": True}, + {"type": "http.request", "body": body[len(body) // 2 :], "more_body": False}, + ] + + async def receive(): + return chunks.pop(0) + + return receive + + +async def _ok(send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"{}"}) + + +class _DownstreamApp: + """Records the replayed body and the envelope bound while handling.""" + + def __init__(self): + self.body = None + self.called = False + self.seen_settings = "unset" + self.seen_context = "unset" + + async def __call__(self, scope, receive, send): + body = b"" + while True: + message = await receive() + body += message.get("body", b"") + if not message.get("more_body"): + break + self.body = body + self.called = True + self.seen_settings = current_invocation_settings() + self.seen_context = current_invocation_context() + await _ok(send) + + +def _run_middleware(body: bytes, scope: dict | None = None) -> tuple[_DownstreamApp, list]: + downstream = _DownstreamApp() + middleware = InvocationEnvelopeMiddleware(downstream) + sent = [] + + async def send(message): + sent.append(message) + + asyncio.run(middleware(scope or _invoke_scope(), _receive_for(body), send)) + return downstream, sent + + +class TestInvocationEnvelopeMiddleware: + def test_binds_reserved_fields_and_replays_body(self): + body = json.dumps( + { + "element_dicts": "/in.json", + "invocation_settings": {"model": "m"}, + "invocation_context": {"schema_version": "1", "job_id": "job-1"}, + } + ).encode() + + downstream, sent = _run_middleware(body) + + assert downstream.body == body + assert downstream.seen_settings == {"model": "m"} + assert downstream.seen_context.job_id == "job-1" + assert sent[0]["status"] == 200 + + def test_absent_fields_bind_none(self): + downstream, _ = _run_middleware(json.dumps({"element_dicts": "/in.json"}).encode()) + + assert downstream.seen_settings is None + assert downstream.seen_context is None + + def test_non_dict_reserved_field_is_rejected(self): + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": "not-a-dict"}).encode() + ) + + assert downstream.body is None + assert sent[0]["status"] == 422 + assert b"invocation_settings" in sent[1]["body"] + + def test_context_with_an_unreadable_schema_version_is_rejected(self): + # Absence means "older caller, use the boot settings"; a context this plugin cannot read + # must not be downgraded to that. + downstream, sent = _run_middleware( + json.dumps({"invocation_context": {"schema_version": "99"}}).encode() + ) + + assert downstream.body is None + assert sent[0]["status"] == 422 + assert b"invocation_context" in sent[1]["body"] + + def test_malformed_context_is_rejected(self): + downstream, sent = _run_middleware( + json.dumps({"invocation_context": "not-an-object"}).encode() + ) + + assert downstream.body is None + assert sent[0]["status"] == 422 + + def test_non_invoke_requests_pass_through_untouched(self): + body = json.dumps({"invocation_settings": "not-a-dict"}).encode() + + downstream, sent = _run_middleware(body, scope=_invoke_scope(path="/schema", method="GET")) + + assert downstream.body == body + assert sent[0]["status"] == 200 + + def test_envelope_is_reset_after_request(self): + body = json.dumps({"invocation_settings": {"model": "m"}}).encode() + + async def scenario(): + middleware = InvocationEnvelopeMiddleware(_DownstreamApp()) + + async def send(_message): + pass + + await middleware(_invoke_scope(), _receive_for(body), send) + return current_invocation_settings(), current_invocation_context() + + assert asyncio.run(scenario()) == (None, None) + + def test_oversized_body_is_rejected(self): + body = json.dumps({"invocation_settings": {"pad": "x" * 64}}).encode() + + downstream = _DownstreamApp() + middleware = InvocationEnvelopeMiddleware(downstream, max_body_bytes=16) + sent = [] + + async def send(message): + sent.append(message) + + asyncio.run(middleware(_invoke_scope(), _receive_for(body), send)) + + assert downstream.body is None + assert sent[0]["status"] == 413 + + def test_drained_replay_proxies_disconnect(self): + body = json.dumps({"invocation_settings": {"model": "m"}}).encode() + + class _DisconnectWatcher: + def __init__(self): + self.saw_disconnect = False + + async def __call__(self, scope, receive, send): + while True: + message = await receive() + if message["type"] == "http.disconnect": + self.saw_disconnect = True + return + if not message.get("more_body"): + break + message = await receive() + self.saw_disconnect = message["type"] == "http.disconnect" + await _ok(send) + + chunks = [ + {"type": "http.request", "body": body, "more_body": False}, + {"type": "http.disconnect"}, + ] + + async def receive(): + return chunks.pop(0) + + watcher = _DisconnectWatcher() + middleware = InvocationEnvelopeMiddleware(watcher) + sent = [] + + async def send(message): + sent.append(message) + + asyncio.run(middleware(_invoke_scope(), receive, send)) + + assert watcher.saw_disconnect + + +class TestMiddlewareResolution: + """Sealed payloads through the middleware. The HTTP class comes from the library's blame + taxonomy, so only a caller-fixable fault is a 422.""" + + def test_sealed_envelope_binds_plaintext_settings(self, key_dir, private_key): + settings = {"api_key": SENTINEL_SECRET, "max_characters": 700} + sealed = sealed_payload(private_key, settings) + + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": sealed}).encode() + ) + + assert sent[0]["status"] == 200 + assert downstream.seen_settings == settings + + def test_composite_dag_node_settings_member_is_opened(self, key_dir, private_key): + settings = {"api_key": SENTINEL_SECRET, "max_characters": 700} + composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} + + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": composite}).encode() + ) + + assert sent[0]["status"] == 200 + assert downstream.seen_settings == settings + + def test_plain_dict_settings_pass_through(self): + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": {"model": "m"}}).encode() + ) + + assert sent[0]["status"] == 200 + assert downstream.seen_settings == {"model": "m"} + + def test_non_envelope_member_fails_as_platform_error(self, key_dir): + composite = {DAG_NODE_SETTINGS_KEY: {"model": "m"}} + + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": composite}).encode() + ) + + assert sent[0]["status"] == 500 + assert "MalformedDagNodeSettingsError" in json.loads(sent[1]["body"])["detail"] + assert not downstream.called + + def test_undecryptable_envelope_fails_without_leaking_the_secret( + self, key_dir, private_key, caplog + ): + sealed = tampered(sealed_payload(private_key, {"api_key": SENTINEL_SECRET})) + + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": sealed}).encode() + ) + + assert sent[0]["status"] == 500 + detail = json.loads(sent[1]["body"])["detail"] + assert "DecryptionError" in detail + assert SENTINEL_SECRET not in caplog.text + assert SENTINEL_SECRET not in detail + assert not downstream.called + + def test_unmounted_identity_fails_as_platform_error(self, tmp_path, monkeypatch, private_key): + monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path / "missing")) + sealed = sealed_payload(private_key, {"api_key": SENTINEL_SECRET}) + + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": sealed}).encode() + ) + + assert sent[0]["status"] == 500 + assert "IdentityNotMountedError" in json.loads(sent[1]["body"])["detail"] + assert not downstream.called + + +class TestRequireSealedDagNodeSettings: + """A native pod's operator sets FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS in the same + decision that drops the init-secrets sidecar: without it, an invoke that arrived with no + envelope would fall back to a settings file that was never written.""" + + @pytest.fixture(autouse=True) + def _require_sealed(self, monkeypatch): + monkeypatch.setenv(REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, "true") + + def test_missing_settings_fail_as_platform_error(self): + downstream, sent = _run_middleware(json.dumps({"element_dicts": "/tmp/x.json"}).encode()) + + assert sent[0]["status"] == 500 + assert "SealedDagNodeSettingsRequiredError" in json.loads(sent[1]["body"])["detail"] + assert not downstream.called + + def test_plaintext_settings_fail_as_platform_error(self): + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": {"model": "m"}}).encode() + ) + + assert sent[0]["status"] == 500 + assert not downstream.called + + def test_sealed_settings_still_bind(self, key_dir, private_key): + settings = {"api_key": SENTINEL_SECRET} + composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} + + downstream, sent = _run_middleware( + json.dumps({"invocation_settings": composite}).encode() + ) + + assert sent[0]["status"] == 200 + assert downstream.seen_settings == settings + + def test_bodyless_invoke_fails_as_platform_error(self): + # A bodyless invoke cannot carry the envelope; on a pod with no boot settings file it must + # not dispatch a handler with no settings source at all. + downstream, sent = _run_middleware(b"") + + assert sent[0]["status"] == 500 + assert not downstream.called + + def test_non_object_json_body_fails_as_platform_error(self): + downstream, sent = _run_middleware(json.dumps([{"element": 1}]).encode()) + + assert sent[0]["status"] == 500 + assert not downstream.called + + +def test_non_object_bodies_pass_through_when_sealed_settings_not_required(): + downstream, sent = _run_middleware(b"") + + assert sent[0]["status"] == 200 + assert downstream.seen_settings is None diff --git a/test/api/test_invocation_settings.py b/test/api/test_invocation_settings.py index 856e17e..5523b23 100644 --- a/test/api/test_invocation_settings.py +++ b/test/api/test_invocation_settings.py @@ -4,9 +4,9 @@ from fastapi.testclient import TestClient from pydantic import BaseModel -from utic_invocation_settings import current_invocation_settings from unstructured_platform_plugins.etl_uvicorn.api_generator import wrap_in_fastapi +from unstructured_platform_plugins.invocation_settings import current_invocation_settings class _Echo(BaseModel): diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 82107d3..50ed70e 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -14,7 +14,6 @@ from typing_extensions import deprecated from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict from unstructured_ingest.error import UnstructuredIngestError -from utic_invocation_settings import add_metadata_route, install_invocation_envelope from uvicorn.config import LOG_LEVELS from uvicorn.importer import import_from_string @@ -27,6 +26,10 @@ get_schema_dict, map_inputs, ) +from unstructured_platform_plugins.invocation_settings import ( + add_metadata_route, + install_invocation_envelope, +) from unstructured_platform_plugins.schema import FileDataMeta, NewRecord, UsageData from unstructured_platform_plugins.schema.json_schema import ( schema_to_base_model, diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py new file mode 100644 index 0000000..73d0338 --- /dev/null +++ b/unstructured_platform_plugins/invocation_settings.py @@ -0,0 +1,243 @@ +"""Transport for the reserved `/invoke` fields: ASGI middleware, `/metadata`, request binding. + +The *contract* — which keys carry settings, how a sealed envelope is told from plaintext, and what +an absent field is allowed to mean — lives in `utic_invocation_settings.invoke`, next to the crypto +it governs. This module is the other half: getting the payload off the wire and the result to the +handler. It owns no policy; every decision about a payload it delegates. + +The reserved fields are a first-class HTTP contract independent of the generated input schema. They +never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler model +built purely from the wrapped function, and a plugin reads the fields through +`current_invocation_settings()` / `current_invocation_context()` instead. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, Optional + +from fastapi import FastAPI +from starlette.types import ASGIApp, Receive, Scope, Send +from utic_invocation_settings import ( + INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY, + RESERVED_CONTEXT_KEY, + RESERVED_ENVELOPE_KEY, + InvocationContext, + InvocationSettingsError, + extract_context, + http_status_for, + resolve_invocation_settings, +) + +logger = logging.getLogger(__name__) + +_METADATA_PATH = "/metadata" +_INVOKE_PATH = "/invoke" + +# Bounds middleware body buffering; generous because batch invokes carry an array of file_data +# payloads. The framework buffers the same body afterward, so this cap is the only guard against +# unbounded-memory requests. +MAX_INVOKE_BODY_BYTES = 64 * 1024 * 1024 + +_INVOCATION: ContextVar[tuple[Optional[dict], Optional[InvocationContext]]] = ContextVar( + "invocation", default=(None, None) +) + + +def current_invocation_settings() -> Optional[dict]: + """Reserved `invocation_settings` field bound for the current request, if any. + + `None` means the field was genuinely absent — the only case in which a plugin may fall back to + its boot-time settings. A field that arrived and could not be opened never reaches a handler: + the middleware fails the request first. + """ + return _INVOCATION.get()[0] + + +def current_invocation_context() -> Optional[InvocationContext]: + """Reserved `invocation_context` field bound for the current request, if any.""" + return _INVOCATION.get()[1] + + +@contextmanager +def invocation_envelope( + invocation_settings: Optional[dict], invocation_context: Optional[InvocationContext] +) -> Iterator[None]: + """Bind the reserved /invoke fields for the current context.""" + token = _INVOCATION.set((invocation_settings, invocation_context)) + try: + yield + finally: + _INVOCATION.reset(token) + + +def add_metadata_route( + app: FastAPI, + identifier: Optional[str] = None, + invoke_with_sealed_dag_node_settings: bool = False, +) -> None: + """Register GET /metadata advertising the reserved /invoke fields this plugin accepts. + + `/metadata` is the plugin API spec's own discovery surface (`PluginMetadataOutput`): capability + flags are strings in its `capabilities` list, which is where the controller looks before + forwarding the reserved fields — no controller-private probe route. + `invoke_with_sealed_dag_node_settings` additionally advertises that the plugin can be invoked + with a sealed `dag_node_settings` member and open it itself. + + Last call wins: the payload lives on `app.state` and every call overwrites it, while the route + is registered once. A host wrapper may register with default capabilities at app construction + and a plugin can still declare the sealed capability afterwards, with no route-order dependence. + """ + capabilities = [RESERVED_ENVELOPE_KEY, RESERVED_CONTEXT_KEY] + if invoke_with_sealed_dag_node_settings: + capabilities.append(INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY) + app.state.plugin_metadata_payload = { + "api_version": "3", + "identifier": identifier, + "capabilities": capabilities, + } + if getattr(app.state, "plugin_metadata_route_installed", False): + return + app.state.plugin_metadata_route_installed = True + + # A /metadata route registered by the application itself would win by route order and pin its + # own stale payload; drop it so the last add_metadata_route call is the one that answers. + app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != _METADATA_PATH] + + @app.get(_METADATA_PATH) + async def plugin_metadata() -> dict: + return app.state.plugin_metadata_payload + + +async def _send_json(send: Send, status_code: int, payload: dict) -> None: + body = json.dumps(payload).encode() + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) + await send({"type": "http.response.body", "body": body}) + + +class InvocationEnvelopeMiddleware: + """Extract the reserved envelope fields from the raw POST /invoke body. + + Pure ASGI rather than `BaseHTTPMiddleware`: the body has to be read before the framework parses + it and then replayed intact, which is exactly what the raw protocol allows and what a + request/response middleware would fight. + + A reserved field that is present but unusable fails the request rather than being treated as + absent, because absence is the signal to fall back to the boot-time settings file: degrading a + malformed field to absence would quietly answer a request configured for one tenant with + whatever the pod happened to boot with. Under `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` + there is no settings file to fall back to, so absent or plaintext settings fail too — as does + any /invoke whose body is not a JSON object, since such a body cannot carry the envelope a + native pod requires. A body over `max_body_bytes` is rejected with 413 before it can exhaust + memory. + """ + + def __init__(self, app: ASGIApp, max_body_bytes: int = MAX_INVOKE_BODY_BYTES): + self.app = app + self.max_body_bytes = max_body_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if ( + scope["type"] != "http" + or scope.get("method") != "POST" + or scope.get("path") != _INVOKE_PATH + ): + await self.app(scope, receive, send) + return + + messages = [] + buffered_bytes = 0 + while True: + message = await receive() + messages.append(message) + buffered_bytes += len(message.get("body", b"")) + if buffered_bytes > self.max_body_bytes: + await _send_json(send, 413, {"detail": "Request body too large"}) + return + if message["type"] != "http.request" or not message.get("more_body"): + break + body = b"".join(m.get("body", b"") for m in messages if m["type"] == "http.request") + + try: + parsed = json.loads(body) if body else None + except ValueError: + # Malformed JSON: forward unchanged so the framework returns its own error. + parsed = None + + invocation_context: Optional[InvocationContext] = None + raw_settings: Optional[dict[str, Any]] = None + if isinstance(parsed, dict): + raw_settings = parsed.get(RESERVED_ENVELOPE_KEY) + if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict): + await _send_json(send, 422, {"detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}"}) + return + # Resolved even when the field — or the whole JSON object — is absent: + # resolve_invocation_settings owns the FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS + # policy, under which a bodyless or non-object invoke cannot carry the envelope a native + # pod requires and is a failure, not a fallback signal. + try: + # Off the event loop: a cold resolve is an RSA unwrap of a couple of milliseconds, and + # this middleware sits in front of every invoke on the pod. + invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings) + except Exception as exc: + # Class name only — never envelope contents, and never the exception's own message, + # which can embed request-controlled values. + logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__) + await _send_json( + send, + http_status_for(exc), + {"detail": f"Unusable invocation settings: {type(exc).__name__}"}, + ) + return + if isinstance(parsed, dict): + try: + invocation_context = extract_context(parsed) + except InvocationSettingsError as exc: + # Includes an unknown schema_version: a producer this plugin cannot read fails + # loudly here rather than running with silently absent identity. The message is + # truncated because it can embed request-controlled values. + logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc) + await _send_json(send, 422, {"detail": f"Invalid field: {RESERVED_CONTEXT_KEY}"}) + return + + # The joined body and its parsed tree can be tens of MB and are not needed past this point; + # the framework re-buffers and re-parses the replayed messages downstream, so holding these + # through the handler would double peak memory. + del body, parsed, raw_settings + + async def replay() -> dict: + if messages: + return messages.pop(0) + # Buffer drained: proxy the original channel so downstream still observes + # http.disconnect. + return await receive() + + with invocation_envelope(invocation_settings, invocation_context): + await self.app(scope, replay, send) + + +def install_invocation_envelope(app: FastAPI) -> None: + """Install out-of-schema envelope extraction on a FastAPI app. + + Idempotent per app: a host wrapper may install at app construction while a plugin that predates + the wrapper's support still calls this itself, and a double install would buffer and replay the + request body twice. + """ + if getattr(app.state, "invocation_envelope_installed", False): + return + app.state.invocation_envelope_installed = True + app.add_middleware(InvocationEnvelopeMiddleware) From 14d63b98408422293911612a07a2363b26fd92d0 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:25:25 -0400 Subject: [PATCH 03/32] ci: move workflows to the Python 3.11 floor --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f70a33..c5b4fd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.11", "3.12", "3.13" ] steps: - uses: actions/checkout@v3 @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.11", "3.12", "3.13" ] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a6ad8e..f8fb085 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - published env: - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" jobs: release: From b32f09642a37c8fbb923dba77f87fd64f75fdad1 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:03:36 -0400 Subject: [PATCH 04/32] feat(etl-uvicorn): settings-scoped cache for per-invoke derived state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin consuming current_invocation_settings() builds its handler per distinct settings payload instead of once at boot, and construction typically does network work (model resolution, prechecks). This gives that pattern one home next to the accessor that creates the need: settings_cache_key digests the canonical settings JSON so secret-bearing payloads are never raw keys, and SettingsScopedCache memoizes derived state bounded by both size and age — age matters because state built from since-rotated credentials must not outlive them on a quiet pod. Stdlib-only, so the package's dependency set is unchanged. --- test/api/test_settings_scoped_cache.py | 94 +++++++++++++++++++ .../invocation_settings.py | 71 +++++++++++++- 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 test/api/test_settings_scoped_cache.py diff --git a/test/api/test_settings_scoped_cache.py b/test/api/test_settings_scoped_cache.py new file mode 100644 index 0000000..4820ebf --- /dev/null +++ b/test/api/test_settings_scoped_cache.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock + +import pytest + +from unstructured_platform_plugins.invocation_settings import ( + SettingsScopedCache, + settings_cache_key, +) + + +class TestSettingsCacheKey: + def test_key_is_insensitive_to_key_order(self): + assert settings_cache_key({"a": 1, "b": 2}) == settings_cache_key({"b": 2, "a": 1}) + + def test_key_is_sensitive_to_values(self): + assert settings_cache_key({"a": 1}) != settings_cache_key({"a": 2}) + + def test_secret_values_do_not_appear_in_the_key(self): + secret = "sk-super-secret-credential" + key = settings_cache_key({"api_key": secret}) + assert secret not in key + + +class TestSettingsScopedCache: + def test_second_lookup_with_same_settings_does_not_rebuild(self): + cache = SettingsScopedCache() + build = MagicMock(return_value="handler") + + first = cache.get_or_build({"model": "a"}, build) + second = cache.get_or_build({"model": "a"}, build) + + assert first == second == "handler" + build.assert_called_once() + + def test_distinct_settings_build_distinct_values(self): + cache = SettingsScopedCache() + + first = cache.get_or_build({"model": "a"}, lambda: object()) + second = cache.get_or_build({"model": "b"}, lambda: object()) + + assert first is not second + + def test_entry_expires_after_ttl(self): + now = [0.0] + cache = SettingsScopedCache(ttl_seconds=10, clock=lambda: now[0]) + build = MagicMock(return_value="handler") + + cache.get_or_build({"model": "a"}, build) + now[0] = 11.0 + cache.get_or_build({"model": "a"}, build) + + assert build.call_count == 2 + + def test_entry_survives_within_ttl(self): + now = [0.0] + cache = SettingsScopedCache(ttl_seconds=10, clock=lambda: now[0]) + build = MagicMock(return_value="handler") + + cache.get_or_build({"model": "a"}, build) + now[0] = 9.0 + cache.get_or_build({"model": "a"}, build) + + build.assert_called_once() + + def test_size_bound_evicts_least_recently_used(self): + cache = SettingsScopedCache(maxsize=2) + builds = {name: MagicMock(return_value=name) for name in ("a", "b", "c")} + + cache.get_or_build({"model": "a"}, builds["a"]) + cache.get_or_build({"model": "b"}, builds["b"]) + # Refresh "a" so "b" is the eviction candidate when "c" lands. + cache.get_or_build({"model": "a"}, builds["a"]) + cache.get_or_build({"model": "c"}, builds["c"]) + + cache.get_or_build({"model": "a"}, builds["a"]) + cache.get_or_build({"model": "b"}, builds["b"]) + + builds["a"].assert_called_once() + assert builds["b"].call_count == 2 + + def test_clear_forces_rebuild(self): + cache = SettingsScopedCache() + build = MagicMock(return_value="handler") + + cache.get_or_build({"model": "a"}, build) + cache.clear() + cache.get_or_build({"model": "a"}, build) + + assert build.call_count == 2 + + @pytest.mark.parametrize("kwargs", [{"ttl_seconds": 0}, {"ttl_seconds": -1}, {"maxsize": 0}]) + def test_degenerate_bounds_are_rejected(self, kwargs): + with pytest.raises(ValueError): + SettingsScopedCache(**kwargs) diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 73d0338..ded7c13 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -14,12 +14,16 @@ from __future__ import annotations import asyncio +import hashlib import json import logging -from collections.abc import Iterator +import threading +import time +from collections import OrderedDict +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from contextvars import ContextVar -from typing import Any, Optional +from typing import Any, Optional, TypeVar from fastapi import FastAPI from starlette.types import ASGIApp, Receive, Scope, Send @@ -36,6 +40,8 @@ logger = logging.getLogger(__name__) +T = TypeVar("T") + _METADATA_PATH = "/metadata" _INVOKE_PATH = "/invoke" @@ -241,3 +247,64 @@ def install_invocation_envelope(app: FastAPI) -> None: return app.state.invocation_envelope_installed = True app.add_middleware(InvocationEnvelopeMiddleware) + + +def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str: + """Digest of the canonical settings JSON, safe as a cache key for secret-bearing payloads.""" + return hashlib.sha256(json.dumps(invocation_settings, sort_keys=True).encode()).hexdigest() + + +class SettingsScopedCache: + """Bind expensive derived state (clients, models, handlers) to the settings that built it. + + A plugin consuming ``current_invocation_settings()`` builds its handler per distinct settings + payload instead of once at boot, and construction typically does network work (model + resolution, prechecks), so results are memoized keyed by ``settings_cache_key``. Both bounds + matter under shared tenancy: size caps how many distinct payloads stay live, and age evicts + state built from credentials that may since have been rotated — eviction driven only by the + count of distinct payloads can take arbitrarily long on a quiet pod. + + Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice; + the extra build is wasted work, never wrong state. + """ + + def __init__( + self, + *, + ttl_seconds: float = 15 * 60, + maxsize: int = 32, + clock: Callable[[], float] = time.monotonic, + ) -> None: + if ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive") + if maxsize < 1: + raise ValueError("maxsize must be at least 1") + self._ttl_seconds = float(ttl_seconds) + self._maxsize = maxsize + self._clock = clock + self._lock = threading.Lock() + self._entries: OrderedDict[str, tuple[float, Any]] = OrderedDict() + + def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[], T]) -> T: + """Return the cached value for these settings, building it on a miss.""" + key = settings_cache_key(invocation_settings) + now = self._clock() + with self._lock: + entry = self._entries.get(key) + if entry is not None: + expires_at, value = entry + if now < expires_at: + self._entries.move_to_end(key) + return value + del self._entries[key] + value = build() + with self._lock: + self._entries[key] = (now + self._ttl_seconds, value) + self._entries.move_to_end(key) + while len(self._entries) > self._maxsize: + self._entries.popitem(last=False) + return value + + def clear(self) -> None: + with self._lock: + self._entries.clear() From 472491c993b12b6e241d9c84f8b6b7e3e8c35907 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:40:01 -0400 Subject: [PATCH 05/32] fix(etl-uvicorn): map context errors through the blame taxonomy An invocation_context with an unreadable schema_version is deployment skew between platform components; answering 422 let an upstream blame classifier pin it on the caller. Context failures now take their status from http_status_for like settings failures already did: malformed fields stay the caller's 422, version skew answers 500 with the class name only. Also documents the two capability tiers on /metadata: the unconditional strings are transport-level facts the middleware makes true for every wrapped app; invoke_with_sealed_dag_node_settings is the consumption claim and stays a per-plugin opt-in. --- test/api/test_invocation_middleware.py | 8 +++--- .../invocation_settings.py | 27 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/test/api/test_invocation_middleware.py b/test/api/test_invocation_middleware.py index 2b8b125..83b9ebf 100644 --- a/test/api/test_invocation_middleware.py +++ b/test/api/test_invocation_middleware.py @@ -226,16 +226,18 @@ def test_non_dict_reserved_field_is_rejected(self): assert sent[0]["status"] == 422 assert b"invocation_settings" in sent[1]["body"] - def test_context_with_an_unreadable_schema_version_is_rejected(self): + def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self): # Absence means "older caller, use the boot settings"; a context this plugin cannot read - # must not be downgraded to that. + # must not be downgraded to that. And it is deployment skew, not a caller fault — a 422 + # would let an upstream blame classifier pin version skew on the customer. downstream, sent = _run_middleware( json.dumps({"invocation_context": {"schema_version": "99"}}).encode() ) assert downstream.body is None - assert sent[0]["status"] == 422 + assert sent[0]["status"] == 500 assert b"invocation_context" in sent[1]["body"] + assert b"UnsupportedContextVersionError" in sent[1]["body"] def test_malformed_context_is_rejected(self): downstream, sent = _run_middleware( diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index ded7c13..73288ea 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -92,8 +92,15 @@ def add_metadata_route( `/metadata` is the plugin API spec's own discovery surface (`PluginMetadataOutput`): capability flags are strings in its `capabilities` list, which is where the controller looks before forwarding the reserved fields — no controller-private probe route. - `invoke_with_sealed_dag_node_settings` additionally advertises that the plugin can be invoked - with a sealed `dag_node_settings` member and open it itself. + + The two tiers make different claims. `invocation_settings` / `invocation_context` are + *transport-level* facts, advertised unconditionally because the installed middleware makes + them true for every wrapped app: the reserved fields will be received, resolved, and bound — + or the request failed. They say nothing about whether the handler reads the binding. + `invoke_with_sealed_dag_node_settings` is the *consumption* claim — this plugin opens sealed + `dag_node_settings` itself and its handler acts on the result — and stays a per-plugin opt-in + set in the same change that makes it true, because it is the flag that invites the controller + to seal settings to this pod in place of any other settings source. Last call wins: the payload lives on `app.state` and every call overwrites it, while the route is registered once. A host wrapper may register with default capabilities at app construction @@ -213,11 +220,19 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: try: invocation_context = extract_context(parsed) except InvocationSettingsError as exc: - # Includes an unknown schema_version: a producer this plugin cannot read fails - # loudly here rather than running with silently absent identity. The message is - # truncated because it can embed request-controlled values. + # A context this plugin cannot read fails loudly here rather than running with + # silently absent identity. Status comes from the blame taxonomy: a malformed + # field is the caller's 422, but an unreadable schema_version is deployment skew + # between platform components and must not read as a caller fault. The log line is + # truncated because the message can embed request-controlled values. logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc) - await _send_json(send, 422, {"detail": f"Invalid field: {RESERVED_CONTEXT_KEY}"}) + status = http_status_for(exc) + detail = ( + f"Invalid field: {RESERVED_CONTEXT_KEY}" + if status == 422 + else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}" + ) + await _send_json(send, status, {"detail": detail}) return # The joined body and its parsed tree can be tens of MB and are not needed past this point; From 3df816d788496d02297908429816833f5d898db8 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:00:58 -0400 Subject: [PATCH 06/32] feat(etl-uvicorn): declare blame in failure responses instead of encoding it in status codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status codes carry transport semantics for the immediate caller and cannot also carry business blame: a 422 for a malformed reserved field (composed by the platform) and a 422 for a customer's unreadable file are different faults wearing the same number. Failure responses now say whose fault it is explicitly: - the invoke envelope gains an optional `blame`, set to "user" only when the plugin raised the UserError family — a fault in something the customer owns. Absent means not-the-customer's: an orchestrator must never infer customer fault from the status class alone. - middleware error bodies carry the invocation-settings taxonomy `reason` code alongside `detail`, so an orchestrator can recognize a platform-composed payload failure whatever status answered the hop. --- test/api/test_api.py | 33 +++++++++++++++++++ test/api/test_invocation_middleware.py | 10 ++++-- test/assets/exception_status_code.py | 12 +++++++ .../etl_uvicorn/api_generator.py | 8 ++++- .../invocation_settings.py | 22 +++++++++---- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index 61a6c94..18323e3 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -26,6 +26,7 @@ class InvokeResponse(BaseModel): status_code: int filedata_meta: FileDataMeta status_code_text: Optional[str] = None + blame: Optional[str] = None output: Optional[Any] = None file_data: Optional[Union[FileData, BatchFileData]] = None @@ -222,6 +223,38 @@ def test_http_exception_handling(file_data): assert invoke_response.status_code_text == "Not found" +@pytest.mark.parametrize( + "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] +) +def test_user_error_declares_user_blame(file_data): + """Only the UserError family may claim the failure is the customer's to fix.""" + from test.assets.exception_status_code import function_raises_user_error as test_fn + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + resp = client.post("/invoke", json={"file_data": file_data.model_dump()}) + invoke_response = InvokeResponse.model_validate(resp.json()) + + assert invoke_response.status_code >= 400 + assert invoke_response.blame == "user" + + +@pytest.mark.parametrize( + "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] +) +def test_non_user_failures_declare_no_blame(file_data): + """Anything undeclared is not the customer's: an orchestrator must not infer customer fault + from the status code, which also carries transport semantics.""" + from test.assets.exception_status_code import function_raises_provider_error as test_fn + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + resp = client.post("/invoke", json={"file_data": file_data.model_dump()}) + invoke_response = InvokeResponse.model_validate(resp.json()) + + assert invoke_response.blame is None + + @pytest.mark.parametrize( "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] ) diff --git a/test/api/test_invocation_middleware.py b/test/api/test_invocation_middleware.py index 83b9ebf..9906d8c 100644 --- a/test/api/test_invocation_middleware.py +++ b/test/api/test_invocation_middleware.py @@ -224,7 +224,9 @@ def test_non_dict_reserved_field_is_rejected(self): assert downstream.body is None assert sent[0]["status"] == 422 - assert b"invocation_settings" in sent[1]["body"] + body = json.loads(sent[1]["body"]) + assert "invocation_settings" in body["detail"] + assert body["reason"] == "malformed_envelope" def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self): # Absence means "older caller, use the boot settings"; a context this plugin cannot read @@ -236,8 +238,10 @@ def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self) assert downstream.body is None assert sent[0]["status"] == 500 - assert b"invocation_context" in sent[1]["body"] - assert b"UnsupportedContextVersionError" in sent[1]["body"] + body = json.loads(sent[1]["body"]) + assert "invocation_context" in body["detail"] + assert "UnsupportedContextVersionError" in body["detail"] + assert body["reason"] == "unsupported_context_version" def test_malformed_context_is_rejected(self): downstream, sent = _run_middleware( diff --git a/test/assets/exception_status_code.py b/test/assets/exception_status_code.py index 6e35997..f20cf93 100644 --- a/test/assets/exception_status_code.py +++ b/test/assets/exception_status_code.py @@ -137,3 +137,15 @@ async def async_gen_function_raises_unstructured_ingest_error_with_none_status_c error = UnstructuredIngestError("Async gen test UnstructuredIngestError with None status_code") error.status_code = None raise error + + +def function_raises_user_error() -> None: + from unstructured_ingest.error import UserError + + raise UserError("Customer-owned resource rejected the request") + + +def function_raises_provider_error() -> None: + from unstructured_ingest.error import ProviderError + + raise ProviderError("Upstream provider failed") diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 50ed70e..2ca8b5b 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -13,7 +13,7 @@ from starlette.responses import RedirectResponse from typing_extensions import deprecated from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict -from unstructured_ingest.error import UnstructuredIngestError +from unstructured_ingest.error import UnstructuredIngestError, UserError from uvicorn.config import LOG_LEVELS from uvicorn.importer import import_from_string @@ -214,6 +214,11 @@ class InvokeResponse(BaseModel): filedata_meta: Optional[filedata_meta_model] = None status_code_text: Optional[str] = None failure_category: Optional[str] = None + # Who must act on a failure: "user" only when the plugin raised the UserError family — + # a fault in something the customer owns (their file, their credentials, their provider). + # Absent means not-the-customer's: an orchestrator must never infer customer fault from + # the status code alone, which also carries transport semantics. + blame: Optional[str] = None output: Optional[response_type] = None message_channels: MessageChannels = Field(default_factory=MessageChannels) @@ -311,6 +316,7 @@ async def _stream_response(): status_code=status_code_of(exc), status_code_text=_safe_str(exc), failure_category=failure_category_of(exc), + blame="user" if isinstance(exc, UserError) else None, file_data=request_dict.get("file_data", None), ) except Exception as invoke_error: diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 73288ea..5adb52b 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -33,6 +33,7 @@ RESERVED_ENVELOPE_KEY, InvocationContext, InvocationSettingsError, + MalformedEnvelopeError, extract_context, http_status_for, resolve_invocation_settings, @@ -196,7 +197,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if isinstance(parsed, dict): raw_settings = parsed.get(RESERVED_ENVELOPE_KEY) if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict): - await _send_json(send, 422, {"detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}"}) + await _send_json( + send, + 422, + { + "detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}", + "reason": MalformedEnvelopeError.reason, + }, + ) return # Resolved even when the field — or the whole JSON object — is absent: # resolve_invocation_settings owns the FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS @@ -210,11 +218,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # Class name only — never envelope contents, and never the exception's own message, # which can embed request-controlled values. logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__) - await _send_json( - send, - http_status_for(exc), - {"detail": f"Unusable invocation settings: {type(exc).__name__}"}, - ) + body = {"detail": f"Unusable invocation settings: {type(exc).__name__}"} + reason = getattr(exc, "reason", None) + if isinstance(reason, str): + body["reason"] = reason + await _send_json(send, http_status_for(exc), body) return if isinstance(parsed, dict): try: @@ -232,7 +240,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if status == 422 else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}" ) - await _send_json(send, status, {"detail": detail}) + await _send_json(send, status, {"detail": detail, "reason": exc.reason}) return # The joined body and its parsed tree can be tens of MB and are not needed past this point; From 855881e5014b48520c0ea8083f8cdde1e9fc80ba Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:34:49 -0400 Subject: [PATCH 07/32] feat(etl-uvicorn): own the invocation-context model and the blame status spelling unstructured_platform_plugins.invocation_context holds the /invoke identity contract: InvocationContext, extract_context, dimensions, the reserved context key, the dimension fields, the supported versions, and UnsupportedContextVersionError. The context is protocol identity - no crypto, no secrets - so it ships with the plugin protocol; its errors subclass the shared InvocationSettingsError taxonomy so hosts classify context failures with the same reason/blame machinery as settings failures. http_status_for - the HTTP spelling of the library's normative blame -> status rule - lives with the middleware that emits the responses. --- CHANGELOG.md | 21 ++- test/api/test_invocation_context.py | 153 +++++++++++++++++ .../invocation_context.py | 158 ++++++++++++++++++ .../invocation_settings.py | 35 +++- 4 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 test/api/test_invocation_context.py create mode 100644 unstructured_platform_plugins/invocation_context.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da11b1..f3867a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,21 @@ * **This package now owns the `/invoke` transport for the reserved fields.** `unstructured_platform_plugins.invocation_settings` holds the ASGI middleware, the `/metadata` - capability route, and the request-scoped binding. It sits on `utic-invocation-settings >=0.4.0`, - which owns the *contract* — which keys carry settings, how a sealed envelope is told from - plaintext, and what an absent field is allowed to mean. That split is deliberate: the absence - rule is a security decision and belongs next to the crypto it governs, while body buffering and - route registration belong here, where a web framework is already a dependency. Nothing about the - wire format is decided in this repository. + capability route, the request-scoped binding, and `http_status_for` — the HTTP spelling of the + library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, + which owns the *settings contract* — which key carries settings, how a sealed envelope is told + from plaintext, and what an absent field is allowed to mean. That split is deliberate: the + absence rule is a security decision and belongs next to the crypto it governs, while body + buffering and route registration belong here, where a web framework is already a dependency. + Nothing about the sealed-settings wire format is decided in this repository. +* **This package now owns the `invocation_context` identity model.** + `unstructured_platform_plugins.invocation_context` holds `InvocationContext`, + `extract_context`, `dimensions`, `RESERVED_CONTEXT_KEY`, `DIMENSION_FIELDS`, + `SUPPORTED_CONTEXT_VERSIONS` and `UnsupportedContextVersionError`. The context is `/invoke` + protocol identity — no crypto, no secrets — so it lives with the plugin protocol. Its errors + subclass the shared `InvocationSettingsError` taxonomy, so hosts classify context failures with + the same `reason`/`blame` machinery as settings failures. This module is the public home for + the surface `utic-invocation-settings 0.2.x` carried and its `0.3.0` removed. * **Every wrapped app installs it at construction.** The reserved `invocation_settings` / `invocation_context` fields are handled outside the generated handler schema, a sealed `dag_node_settings` member is opened with this pod's mounted workload key, and the resolved diff --git a/test/api/test_invocation_context.py b/test/api/test_invocation_context.py new file mode 100644 index 0000000..f07a922 --- /dev/null +++ b/test/api/test_invocation_context.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import pytest +from utic_invocation_settings import ( + Blame, + DecryptionError, + IdentityNotMountedError, + KeyNotFoundError, + MalformedDagNodeSettingsError, + MalformedEnvelopeError, + SealedDagNodeSettingsRequiredError, +) + +from unstructured_platform_plugins.invocation_context import ( + RESERVED_CONTEXT_KEY, + InvocationContext, + UnsupportedContextVersionError, + dimensions, + extract_context, +) +from unstructured_platform_plugins.invocation_settings import http_status_for + +VALID = { + "schema_version": "1", + "invocation_id": "inv-1", + "job_id": "job-1", + "tenant_id": "tenant-1", + "dag_node_id": "node-1", + "dag_node_type": "chunker", + "record_id": "rec-1", + "attempt": 2, +} + + +def test_extracts_identity_fields_from_body(): + context = extract_context({"file_data": {"path": "x"}, RESERVED_CONTEXT_KEY: VALID}) + assert context is not None + assert context.tenant_id == "tenant-1" + assert context.attempt == 2 + + +def test_absent_key_returns_none(): + assert extract_context({"file_data": {"path": "x"}}) is None + + +def test_accepts_already_parsed(): + context = InvocationContext(**VALID) + assert extract_context({RESERVED_CONTEXT_KEY: context}) is context + + +def test_present_but_null_fails_closed(): + # Same rule as the envelope: a context that silently vanishes takes tenant attribution with it. + with pytest.raises(MalformedEnvelopeError): + extract_context({RESERVED_CONTEXT_KEY: None}) + + +def test_present_but_not_an_object_fails_closed(): + with pytest.raises(MalformedEnvelopeError): + extract_context({RESERVED_CONTEXT_KEY: "tenant-1"}) + + +def test_unknown_schema_version_is_rejected_by_its_own_error(): + with pytest.raises(UnsupportedContextVersionError) as exc: + extract_context({RESERVED_CONTEXT_KEY: {**VALID, "schema_version": "2"}}) + assert "'2'" in str(exc.value) + + +def test_partial_context_is_accepted(): + # A producer that populates only some identity facets degrades to less telemetry, not a + # failed invoke. + context = extract_context({RESERVED_CONTEXT_KEY: {"schema_version": "1", "job_id": "job-1"}}) + assert context is not None + assert context.job_id == "job-1" + assert context.tenant_id is None + + +def test_unknown_fields_survive_for_forward_compatibility(): + context = extract_context({RESERVED_CONTEXT_KEY: {**VALID, "future_field": "keep me"}}) + assert context is not None + assert context.model_extra["future_field"] == "keep me" + + +def test_batch_fields_are_index_aligned(): + # The controller emits one invocation id per record, using None where a record carried no + # context, so entry i always describes record i. + context = extract_context( + { + RESERVED_CONTEXT_KEY: { + **VALID, + "record_ids": ["rec-1", "rec-2", "rec-3"], + "invocation_ids": ["inv-1", None, "inv-3"], + } + } + ) + assert context is not None + assert len(context.record_ids) == len(context.invocation_ids) + assert dict(zip(context.record_ids, context.invocation_ids))["rec-2"] is None + + +class TestDimensions: + def test_returns_populated_identity_facets(self): + context = InvocationContext(**VALID) + + assert dimensions(context) == { + "invocation_id": "inv-1", + "job_id": "job-1", + "tenant_id": "tenant-1", + "dag_node_id": "node-1", + "dag_node_type": "chunker", + "record_id": "rec-1", + "attempt": 2, + } + + def test_excludes_batch_fields(self): + # These describe the work, not who it belongs to, and would blow up dimension cardinality. + context = InvocationContext.model_validate( + {**VALID, "record_ids": ["a"], "invocation_ids": ["b"]} + ) + + assert not {"record_ids", "invocation_ids"} & set(dimensions(context)) + + def test_unknown_producer_fields_are_not_promoted_to_dimensions(self): + context = InvocationContext.model_validate({**VALID, "future_field": "value"}) + + assert "future_field" not in dimensions(context) + + def test_absent_context_yields_no_dimensions(self): + assert dimensions(None) == {} + + +class TestHttpStatusFor: + """The transport's spelling of the library's normative `blame` -> status rule.""" + + def test_caller_blame_is_the_only_422(self): + assert http_status_for(MalformedEnvelopeError("x")) == 422 + assert MalformedEnvelopeError.blame is Blame.CALLER + + @pytest.mark.parametrize( + "error", + [ + DecryptionError("x"), + KeyNotFoundError("x"), + IdentityNotMountedError("x"), + SealedDagNodeSettingsRequiredError("x"), + MalformedDagNodeSettingsError("x"), + UnsupportedContextVersionError("x"), + ], + ) + def test_everything_else_is_5xx(self, error): + assert http_status_for(error) == 500 + + def test_an_unclassified_exception_is_not_blamed_on_the_caller(self): + assert http_status_for(RuntimeError("boom")) == 500 diff --git a/unstructured_platform_plugins/invocation_context.py b/unstructured_platform_plugins/invocation_context.py new file mode 100644 index 0000000..4cb898f --- /dev/null +++ b/unstructured_platform_plugins/invocation_context.py @@ -0,0 +1,158 @@ +"""The ``invocation_context`` companion to the settings envelope. + +Where ``invocation_settings`` carries *what* a plugin should be configured with, the context +carries *who* the invocation is for: the identity facets a shared-tenancy pod can no longer read +from its process environment. It travels in a second reserved, out-of-schema field of the +``/invoke`` body, extracted by the same middleware that resolves the settings field. + +The context is `/invoke` protocol identity, not settings security: it touches no crypto and no +secrets, and it evolves with the plugin protocol this package defines. The errors it raises come +from the shared ``InvocationSettingsError`` taxonomy so hosts classify context failures with the +same ``reason``/``blame`` machinery as settings failures. + +The model below is the **consumer** view of that contract, deliberately lenient: unknown keys are +preserved so a newer producer does not break an older plugin, and every identity field is optional +so a partially-populated context degrades to "less telemetry" rather than a failed invoke. The one +thing it is strict about is ``schema_version`` — that field exists to make an incompatible producer +detectable, which it can only do if somebody actually reads it. The payload is what carries the +version, not the route, so evolving the contract does not mean adding endpoints. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +import pydantic +from utic_invocation_settings import Blame, InvocationSettingsError, MalformedEnvelopeError + +# Reserved key carrying the invocation context in the invoke request body. +RESERVED_CONTEXT_KEY = "invocation_context" + +# Context payload versions this package understands. Additive keys do not bump this; a change that +# would make an old consumer misread an existing key does. +SUPPORTED_CONTEXT_VERSIONS = frozenset({"1"}) + +# The identity facets that become telemetry dimensions. Shared rather than per-service policy: +# every hop on one invocation's path has to pick the same fields, or the same request is attributed +# differently depending on which component emitted the event. Excludes the batch fields, which +# describe the work rather than who it belongs to. +DIMENSION_FIELDS = ( + "invocation_id", + "tenant_id", + "org_id", + "job_id", + "workflow_id", + "attribution_id", + "dag_node_id", + "dag_node_type", + "dag_node_subtype", + "record_id", + "attempt", +) + +# Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value. +_ABSENT = object() + + +class UnsupportedContextVersionError(InvocationSettingsError): + """The ``invocation_context`` declares a ``schema_version`` this package does not understand. + + A producer upgrade this consumer cannot follow — deployment skew between platform components, + not a fault in the request. ``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced + by the platform's own claim pipeline, and a 422 would make an upstream blame classifier pin a + version-skew failure on the customer. Loud at the first request rather than silently absent + telemetry dimensions later. + """ + + reason = "unsupported_context_version" + blame = Blame.CONTENT + + +class InvocationContext(pydantic.BaseModel): + """Request-scoped identity delivered alongside one claimed unit of work. + + ``extra="allow"`` keeps forward compatibility: fields added by a newer producer survive round + trips and stay reachable via ``model_extra`` instead of being silently dropped. + """ + + model_config = pydantic.ConfigDict(extra="allow") + + schema_version: str = "1" + + invocation_id: str | None = None + job_id: str | None = None + workflow_id: str | None = None + attribution_id: str | None = None + tenant_id: str | None = None + org_id: str | None = None + dag_node_id: str | None = None + dag_node_type: str | None = None + dag_node_subtype: str | None = None + record_id: str | None = None + attempt: int | None = None + job_created_timestamp: str | None = None + + # Added by the controller on the way to the plugin, not by the work API. The batch pair is + # index-aligned: entry i of `invocation_ids` is the invocation id of record i, or None where + # that record carried no context. There is deliberately no `work_dir` field: scratch space is + # the plugin's implementation detail (tempfile / uuid-named paths), not invoke-contract surface. + record_ids: list[str] | None = None + invocation_ids: list[str | None] | None = None + + @pydantic.field_validator("schema_version") + @classmethod + def _known_version(cls, value: str) -> str: + if value not in SUPPORTED_CONTEXT_VERSIONS: + raise ValueError( + f"unsupported invocation_context schema_version {value!r}; " + f"this package understands {sorted(SUPPORTED_CONTEXT_VERSIONS)}" + ) + return value + + +def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None: + """Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``. + + Returns ``None`` only when the reserved key is **absent** — the transitional signal that the + caller is an older controller. A present-but-invalid value fails closed rather than degrading + to "no context", because a context that silently vanishes takes a pod's tenant attribution with + it. + + A recognizable context carrying an unknown ``schema_version`` raises + :class:`UnsupportedContextVersionError` so a producer upgrade is loud at the first request + instead of showing up later as absent telemetry dimensions. + """ + raw = payload.get(RESERVED_CONTEXT_KEY, _ABSENT) + if raw is _ABSENT: + return None + if isinstance(raw, InvocationContext): + return raw + try: + return InvocationContext.model_validate(raw) + except pydantic.ValidationError as exc: + errors = exc.errors() + if any(error["loc"] == ("schema_version",) for error in errors): + raise UnsupportedContextVersionError( + f"unsupported invocation_context schema_version: " + f"{_reported_version(raw)!r}; expected one of {sorted(SUPPORTED_CONTEXT_VERSIONS)}" + ) from None + # `from None` so the pydantic error tree does not cross the domain-error boundary; a count + # plus the first message is enough signal. Mirrors the envelope extraction. + raise MalformedEnvelopeError( + f"invalid invocation_context: {exc.error_count()} validation error(s), " + f"first: {errors[0]['msg']}" + ) from None + + +def _reported_version(raw: Any) -> Any: + """The offending ``schema_version``, for the error message only. Never trusted.""" + return raw.get("schema_version") if isinstance(raw, Mapping) else None + + +def dimensions(context: InvocationContext | None) -> dict[str, Any]: + """The context's populated identity facets, ready to bind as telemetry dimensions.""" + if context is None: + return {} + return { + field: value for field in DIMENSION_FIELDS if (value := getattr(context, field)) is not None + } diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 5adb52b..7b73d43 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -1,9 +1,13 @@ """Transport for the reserved `/invoke` fields: ASGI middleware, `/metadata`, request binding. -The *contract* — which keys carry settings, how a sealed envelope is told from plaintext, and what -an absent field is allowed to mean — lives in `utic_invocation_settings.invoke`, next to the crypto -it governs. This module is the other half: getting the payload off the wire and the result to the -handler. It owns no policy; every decision about a payload it delegates. +The *settings contract* — which key carries settings, how a sealed envelope is told from +plaintext, and what an absent field is allowed to mean — lives in +`utic_invocation_settings.invoke`, next to the crypto it governs; every decision about a settings +payload is delegated there. The *identity contract* — the `invocation_context` model — is +`/invoke` protocol rather than settings security and lives in this package's +`invocation_context` module. This module is the delivery mechanism for both: getting the payloads +off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP +statuses. The reserved fields are a first-class HTTP contract independent of the generated input schema. They never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler model @@ -29,18 +33,33 @@ from starlette.types import ASGIApp, Receive, Scope, Send from utic_invocation_settings import ( INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY, - RESERVED_CONTEXT_KEY, RESERVED_ENVELOPE_KEY, - InvocationContext, + Blame, InvocationSettingsError, MalformedEnvelopeError, - extract_context, - http_status_for, resolve_invocation_settings, ) +from unstructured_platform_plugins.invocation_context import ( + RESERVED_CONTEXT_KEY, + InvocationContext, + extract_context, +) + logger = logging.getLogger(__name__) + +def http_status_for(error: BaseException) -> int: + """The HTTP status this transport answers for a failed resolution, from ``blame``. + + One rule, the one the library README states normatively: ``Blame.CALLER`` -> 422, everything + else -> 500. The line it draws is whether a different request would work. Sealing drift, an + envelope for another recipient and a broken local mount are all 5xx, which keeps a controller's + blame classification off the customer, whose request was fine. Anything that is not a + classified error is a 500: an unclassified failure is not the caller's. + """ + return 422 if getattr(error, "blame", None) is Blame.CALLER else 500 + T = TypeVar("T") _METADATA_PATH = "/metadata" From 8dc9cae43fcc04aa1ab18e33fdd641b4725a48d9 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:10:29 -0400 Subject: [PATCH 08/32] refactor(etl-uvicorn): bind /invoke envelope without body replay (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace raw `/invoke` body buffering and replay with a FastAPI dependency that reads Starlette's cached JSON parse. - Keep `InvokeBodyLimitMiddleware` below FastAPI as a streaming byte counter, so oversized bodies are rejected without a second buffer. - Install binding through the router's public dependency list before `POST /invoke` is registered; remove private `route.dependant` mutation. - Re-enter the captured invocation binding inside async-generator response iteration, so streaming plugins see settings on the repository's locked FastAPI 0.117.1 as well as newer FastAPI releases. - Preserve the explicit `invoke_with_sealed_dag_node_settings` capability opt-in and its wrapper, generator, and CLI arguments. - Align transport tests with the shared contract: sealed settings are accepted only at `invocation_settings.dag_node_settings`; a bare envelope fails closed. ASGI middleware has to consume the raw receive channel before FastAPI can parse it, which forced the old implementation to buffer, parse, and replay the request. Route-level extraction shares the framework's body and JSON caches instead. The initial dependency version still had three correctness problems: FastAPI 0.117.1 closed yield dependencies before `StreamingResponse` iteration, installation mutated FastAPI's private dependency graph after route registration, and unconditional sealed-capability advertisement conflated transport support with handler consumption. This revision fixes all three without raising the FastAPI floor. - `install_invocation_envelope(app)` must run before `POST /invoke` is registered. It may run after unrelated routes such as `/metadata`, which preserves the hand-written plugin integration order. - The dependency is a path/method-aware no-op outside `POST /invoke`, including mixed-method routes and rooted deployments. - Malformed JSON on a declared FastAPI body model continues to use FastAPI's own validation response. - `pytest -q` on FastAPI 0.117.1 / Starlette 0.48.0 with the current utic-invocation-settings 0.4.0 branch — 153 passed. - Focused transport tests on FastAPI 0.141.1 / Starlette 1.6.0 — 38 passed. - `ruff check .` — clean. - `ruff format --check` on changed Python files — clean. - `git diff --check` — clean. --- CHANGELOG.md | 33 +- test/api/test_invocation_envelope.py | 505 ++++++++++++++++++ test/api/test_invocation_middleware.py | 458 ---------------- test/api/test_invocation_settings.py | 24 +- .../etl_uvicorn/api_generator.py | 70 ++- .../invocation_settings.py | 310 ++++++----- 6 files changed, 775 insertions(+), 625 deletions(-) create mode 100644 test/api/test_invocation_envelope.py delete mode 100644 test/api/test_invocation_middleware.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3867a6..013b060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,14 @@ ## 0.1.0 * **This package now owns the `/invoke` transport for the reserved fields.** - `unstructured_platform_plugins.invocation_settings` holds the ASGI middleware, the `/metadata` - capability route, the request-scoped binding, and `http_status_for` — the HTTP spelling of the + `unstructured_platform_plugins.invocation_settings` holds the `/invoke` binding dependency and + body cap, the `/metadata` capability route, the request-scoped accessors, and `http_status_for` + — the HTTP spelling of the library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, which owns the *settings contract* — which key carries settings, how a sealed envelope is told from plaintext, and what an absent field is allowed to mean. That split is deliberate: the - absence rule is a security decision and belongs next to the crypto it governs, while body - buffering and route registration belong here, where a web framework is already a dependency. + absence rule is a security decision and belongs next to the crypto it governs, while request + handling and route registration belong here, where a web framework is already a dependency. Nothing about the sealed-settings wire format is decided in this repository. * **This package now owns the `invocation_context` identity model.** `unstructured_platform_plugins.invocation_context` holds `InvocationContext`, @@ -23,17 +24,27 @@ values are exposed through `current_invocation_settings()` / `current_invocation_context()`. An absent field preserves the existing fallback behaviour; under `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` missing or plaintext settings fail closed. - Repeated installation is safe: the middleware installs once and the last `/metadata` + Repeated installation is safe: the dependency installs once and the last `/metadata` registration wins. -* **New opt-in `invoke_with_sealed_dag_node_settings` advertisement.** Pass +* **Extraction is a route dependency.** `bind_invocation_envelope` reads the body the framework + buffered and parsed (`request.json()` is Starlette-cached), so the `/invoke` body is held and + decoded exactly once per request. `install_invocation_envelope` contributes that path-aware + dependency through the router's public dependency list before `/invoke` is registered; no + private FastAPI dependency graph is mutated. It also registers the failure response shape and + installs `InvokeBodyLimitMiddleware`, a streaming byte counter that answers 413 over the cap + without buffering. Async-generator plugins explicitly re-enter the captured request binding + inside response iteration, so streaming stays correct independently of FastAPI's yield-dependency + cleanup timing. +* **Sealed settings consumption remains opt-in.** Pass `invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; - it advertises that the application accepts and consumes sealed per-invocation settings. - A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` - (which replaces the wrapper's route) — a plain `@app.get("/metadata")` added after construction - is shadowed by the wrapper's earlier registration. + it advertises that the application accepts and acts on sealed `dag_node_settings`. Transport + support alone continues to advertise only `invocation_settings` and `invocation_context`. A + plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` (which + replaces the wrapper's route) — a plain `@app.get("/metadata")` added after construction is + shadowed by the wrapper's earlier registration. * **Resolution runs off the event loop.** A cold resolve is an RSA unwrap of a couple of - milliseconds and this middleware fronts every invoke on the pod, so it is dispatched with + milliseconds and this dependency fronts every invoke on the pod, so it is dispatched with `asyncio.to_thread` rather than blocking the loop. * **Failures map through the library's blame taxonomy**, not a flat 500: only a caller-fixable fault answers 422. Sealing drift, an envelope addressed to another recipient and a broken local diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py new file mode 100644 index 0000000..5c243a5 --- /dev/null +++ b/test/api/test_invocation_envelope.py @@ -0,0 +1,505 @@ +"""The transport for the reserved /invoke fields: dependency binding, /metadata, the body cap. + +The *contract* these exercise — which shapes carry settings, what absence means — is owned and +tested in `utic_invocation_settings`. What is tested here is delivery: that the framework-parsed +body is resolved and bound for the handler, and that a payload which cannot be used fails the +request instead of reaching a handler as absence. +""" + +from __future__ import annotations + +import asyncio +import json +from base64 import b64decode, b64encode +from typing import Optional + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from utic_invocation_settings import ( + DAG_NODE_SETTINGS_KEY, + REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, + default_resolver, + reset_workload_identity_cache, +) +from utic_invocation_settings.crypto import seal_settings + +from unstructured_platform_plugins.invocation_settings import ( + InvokeBodyLimitMiddleware, + add_metadata_route, + current_invocation_context, + current_invocation_settings, + install_invocation_envelope, +) + +SENTINEL_SECRET = "sealed-settings-sentinel-secret" + + +@pytest.fixture(scope="session") +def private_key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=3072) + + +@pytest.fixture(autouse=True) +def isolated_identity(monkeypatch): + """The identity memo and the resolver caches both outlive a test; an inherited env var or a + stale entry would make these order-dependent.""" + for var in ( + "WORKLOAD_IDENTITY_DIR", + "INVOCATION_SETTINGS_KEY_DIR", + REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, + ): + monkeypatch.delenv(var, raising=False) + reset_workload_identity_cache() + default_resolver().clear_caches() + yield + reset_workload_identity_cache() + default_resolver().clear_caches() + + +@pytest.fixture +def key_dir(tmp_path, private_key, monkeypatch, isolated_identity): + (tmp_path / "tls.key").write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path)) + reset_workload_identity_cache() + return tmp_path + + +def sealed_payload(private_key: rsa.RSAPrivateKey, settings: dict) -> dict: + return seal_settings(settings, private_key.public_key()).model_dump( + mode="json", exclude_none=True + ) + + +def tampered(sealed: dict) -> dict: + ciphertext = bytearray(b64decode(sealed["content_encryption"]["ciphertext"])) + ciphertext[0] ^= 0x01 + sealed["content_encryption"]["ciphertext"] = b64encode(bytes(ciphertext)).decode() + return sealed + + +BASE_CAPABILITIES = [ + "invocation_settings", + "invocation_context", +] +ALL_CAPABILITIES = [ + *BASE_CAPABILITIES, + "invoke_with_sealed_dag_node_settings", +] + + +class TestMetadataRoute: + def test_advertises_transport_capabilities_by_default(self): + app = FastAPI() + add_metadata_route(app, identifier="plugin.test") + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload == { + "api_version": "3", + "identifier": "plugin.test", + "capabilities": BASE_CAPABILITIES, + } + + def test_sealed_consumption_capability_is_opt_in(self): + app = FastAPI() + add_metadata_route(app, invoke_with_sealed_dag_node_settings=True) + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload["capabilities"] == ALL_CAPABILITIES + + def test_last_call_wins(self): + # A host wrapper registers /metadata at construction; the plugin's later call with its own + # identifier must replace it, not be shadowed by route order. + app = FastAPI() + add_metadata_route(app, identifier="wrapper.default") + add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload["identifier"] == "plugin.test" + assert payload["capabilities"] == ALL_CAPABILITIES + + def test_replaces_a_directly_registered_metadata_route(self): + # A route the app registered itself would otherwise win by route order and pin its stale + # payload. + app = FastAPI() + + @app.get("/metadata") + async def stale_metadata() -> dict: + return {"api_version": "3", "identifier": "stale", "capabilities": []} + + add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) + + with TestClient(app) as client: + payload = client.get("/metadata").json() + + assert payload["identifier"] == "plugin.test" + assert payload["capabilities"] == ALL_CAPABILITIES + + +class _Recorder: + """What the /invoke handler observed: the bound envelope, and whether it ran at all.""" + + def __init__(self): + self.called = False + self.seen_settings = "unset" + self.seen_context = "unset" + + +def _envelope_app(recorder: _Recorder, max_body_bytes: Optional[int] = None) -> FastAPI: + """A hand-rolled host app: the /invoke route reads the raw request, like a plugin that owns + its own route, so any body shape reaches the handler unless the dependency rejects it.""" + app = FastAPI() + if max_body_bytes is None: + install_invocation_envelope(app) + else: + install_invocation_envelope(app, max_body_bytes=max_body_bytes) + + @app.post("/invoke") + async def invoke(request: Request) -> dict: + recorder.called = True + recorder.seen_settings = current_invocation_settings() + recorder.seen_context = current_invocation_context() + return {} + + @app.get("/schema") + async def schema() -> dict: + recorder.called = True + return {} + + return app + + +def _post_invoke(payload, recorder: Optional[_Recorder] = None, **app_kwargs): + recorder = recorder if recorder is not None else _Recorder() + app = _envelope_app(recorder, **app_kwargs) + with TestClient(app, raise_server_exceptions=False) as client: + if isinstance(payload, bytes): + response = client.post("/invoke", content=payload) + else: + response = client.post("/invoke", json=payload) + return recorder, response + + +class TestInvocationEnvelopeBinding: + def test_binds_reserved_fields(self): + recorder, response = _post_invoke( + { + "element_dicts": "/in.json", + "invocation_settings": {"model": "m"}, + "invocation_context": {"schema_version": "1", "job_id": "job-1"}, + } + ) + + assert response.status_code == 200 + assert recorder.seen_settings == {"model": "m"} + assert recorder.seen_context.job_id == "job-1" + + def test_absent_fields_bind_none(self): + recorder, response = _post_invoke({"element_dicts": "/in.json"}) + + assert response.status_code == 200 + assert recorder.seen_settings is None + assert recorder.seen_context is None + + def test_non_dict_reserved_field_is_rejected(self): + recorder, response = _post_invoke({"invocation_settings": "not-a-dict"}) + + assert not recorder.called + assert response.status_code == 422 + body = response.json() + assert "invocation_settings" in body["detail"] + assert body["reason"] == "malformed_envelope" + + def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self): + # Absence means "older caller, use the boot settings"; a context this plugin cannot read + # must not be downgraded to that. And it is deployment skew, not a caller fault — a 422 + # would let an upstream blame classifier pin version skew on the customer. + recorder, response = _post_invoke({"invocation_context": {"schema_version": "99"}}) + + assert not recorder.called + assert response.status_code == 500 + body = response.json() + assert "invocation_context" in body["detail"] + assert "UnsupportedContextVersionError" in body["detail"] + assert body["reason"] == "unsupported_context_version" + + def test_malformed_context_is_rejected(self): + recorder, response = _post_invoke({"invocation_context": "not-an-object"}) + + assert not recorder.called + assert response.status_code == 422 + + def test_non_invoke_routes_are_untouched(self): + recorder = _Recorder() + app = _envelope_app(recorder) + + with TestClient(app) as client: + response = client.get("/schema") + + assert response.status_code == 200 + assert recorder.called + + def test_envelope_does_not_leak_between_requests(self): + recorder = _Recorder() + app = _envelope_app(recorder) + + with TestClient(app) as client: + client.post("/invoke", json={"invocation_settings": {"model": "m"}}) + client.post("/invoke", json={"element_dicts": "/in.json"}) + + assert recorder.seen_settings is None + assert recorder.seen_context is None + + def test_install_after_an_invoke_route_fails_loudly(self): + app = FastAPI() + + @app.post("/invoke") + async def invoke() -> dict: + return {} + + with pytest.raises(RuntimeError, match="POST /invoke"): + install_invocation_envelope(app) + + def test_install_after_metadata_but_before_invoke_is_supported(self): + recorder = _Recorder() + app = FastAPI() + add_metadata_route(app, invoke_with_sealed_dag_node_settings=True) + install_invocation_envelope(app) + + @app.post("/invoke") + async def invoke() -> dict: + recorder.seen_settings = current_invocation_settings() + return {} + + with TestClient(app) as client: + response = client.post("/invoke", json={"invocation_settings": {"model": "m"}}) + + assert response.status_code == 200 + assert recorder.seen_settings == {"model": "m"} + + def test_mixed_method_route_does_not_bind_or_parse_get(self): + recorder = _Recorder() + app = FastAPI() + install_invocation_envelope(app) + + @app.api_route("/invoke", methods=["GET", "POST"]) + async def invoke() -> dict: + recorder.seen_settings = current_invocation_settings() + return {} + + with TestClient(app) as client: + response = client.get("/invoke") + + assert response.status_code == 200 + assert recorder.seen_settings is None + + def test_root_path_does_not_prevent_invoke_binding(self): + recorder = _Recorder() + app = _envelope_app(recorder) + + with TestClient(app, root_path="/plugins/chunker") as client: + response = client.post("/invoke", json={"invocation_settings": {"model": "rooted"}}) + + assert response.status_code == 200 + assert recorder.seen_settings == {"model": "rooted"} + + def test_oversized_body_is_rejected(self): + recorder, response = _post_invoke( + {"invocation_settings": {"pad": "x" * 64}}, max_body_bytes=16 + ) + + assert not recorder.called + assert response.status_code == 413 + + +class TestInvokeBodyLimitMiddleware: + """ASGI-level behavior of the streaming byte counter: no buffering, disconnect pass-through.""" + + @staticmethod + def _run(middleware, scope, chunks) -> tuple[list, list]: + received = [] + sent = [] + + async def receive(): + return chunks.pop(0) + + async def send(message): + sent.append(message) + + async def downstream(scope, receive, send): + while True: + message = await receive() + received.append(message) + if message["type"] != "http.request" or not message.get("more_body"): + break + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"{}"}) + + middleware = middleware(downstream) + asyncio.run(middleware(scope, receive, send)) + return received, sent + + def test_chunked_body_over_the_cap_answers_413_and_cuts_downstream(self): + chunks = [ + {"type": "http.request", "body": b"x" * 10, "more_body": True}, + {"type": "http.request", "body": b"x" * 10, "more_body": False}, + ] + received, sent = self._run( + lambda app: InvokeBodyLimitMiddleware(app, max_body_bytes=16), + {"type": "http", "method": "POST", "path": "/invoke"}, + chunks, + ) + + assert received[-1] == {"type": "http.disconnect"} + assert sent[0]["status"] == 413 + + def test_client_disconnect_passes_through_uncounted(self): + chunks = [{"type": "http.disconnect"}] + received, sent = self._run( + lambda app: InvokeBodyLimitMiddleware(app, max_body_bytes=16), + {"type": "http", "method": "POST", "path": "/invoke"}, + chunks, + ) + + assert received == [{"type": "http.disconnect"}] + + def test_non_invoke_requests_are_not_capped(self): + chunks = [{"type": "http.request", "body": b"x" * 100, "more_body": False}] + received, sent = self._run( + lambda app: InvokeBodyLimitMiddleware(app, max_body_bytes=16), + {"type": "http", "method": "GET", "path": "/schema"}, + chunks, + ) + + assert sent[0]["status"] == 200 + + +class TestEnvelopeResolution: + """Sealed payloads through the binding dependency. The HTTP class comes from the library's + blame taxonomy, so only a caller-fixable fault is a 422.""" + + def test_bare_sealed_envelope_is_rejected(self, key_dir, private_key): + sealed = sealed_payload(private_key, {"api_key": SENTINEL_SECRET}) + + recorder, response = _post_invoke({"invocation_settings": sealed}) + + assert response.status_code == 500 + assert "MalformedDagNodeSettingsError" in response.json()["detail"] + assert not recorder.called + + def test_composite_dag_node_settings_member_is_opened(self, key_dir, private_key): + settings = {"api_key": SENTINEL_SECRET, "max_characters": 700} + composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} + + recorder, response = _post_invoke({"invocation_settings": composite}) + + assert response.status_code == 200 + assert recorder.seen_settings == settings + + def test_plain_dict_settings_pass_through(self): + recorder, response = _post_invoke({"invocation_settings": {"model": "m"}}) + + assert response.status_code == 200 + assert recorder.seen_settings == {"model": "m"} + + def test_non_envelope_member_fails_as_platform_error(self, key_dir): + composite = {DAG_NODE_SETTINGS_KEY: {"model": "m"}} + + recorder, response = _post_invoke({"invocation_settings": composite}) + + assert response.status_code == 500 + assert "MalformedDagNodeSettingsError" in response.json()["detail"] + assert not recorder.called + + def test_undecryptable_envelope_fails_without_leaking_the_secret( + self, key_dir, private_key, caplog + ): + sealed = tampered(sealed_payload(private_key, {"api_key": SENTINEL_SECRET})) + composite = {DAG_NODE_SETTINGS_KEY: sealed} + + recorder, response = _post_invoke({"invocation_settings": composite}) + + assert response.status_code == 500 + detail = response.json()["detail"] + assert "DecryptionError" in detail + assert SENTINEL_SECRET not in caplog.text + assert SENTINEL_SECRET not in detail + assert not recorder.called + + def test_unmounted_identity_fails_as_platform_error(self, tmp_path, monkeypatch, private_key): + monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path / "missing")) + sealed = sealed_payload(private_key, {"api_key": SENTINEL_SECRET}) + composite = {DAG_NODE_SETTINGS_KEY: sealed} + + recorder, response = _post_invoke({"invocation_settings": composite}) + + assert response.status_code == 500 + assert "IdentityNotMountedError" in response.json()["detail"] + assert not recorder.called + + +class TestRequireSealedDagNodeSettings: + """A native pod's operator sets FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS in the same + decision that drops the init-secrets sidecar: without it, an invoke that arrived with no + envelope would fall back to a settings file that was never written.""" + + @pytest.fixture(autouse=True) + def _require_sealed(self, monkeypatch): + monkeypatch.setenv(REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, "true") + + def test_missing_settings_fail_as_platform_error(self): + recorder, response = _post_invoke({"element_dicts": "/tmp/x.json"}) + + assert response.status_code == 500 + assert "SealedDagNodeSettingsRequiredError" in response.json()["detail"] + assert not recorder.called + + def test_plaintext_settings_fail_as_platform_error(self): + recorder, response = _post_invoke({"invocation_settings": {"model": "m"}}) + + assert response.status_code == 500 + assert not recorder.called + + def test_sealed_settings_still_bind(self, key_dir, private_key): + settings = {"api_key": SENTINEL_SECRET} + composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} + + recorder, response = _post_invoke({"invocation_settings": composite}) + + assert response.status_code == 200 + assert recorder.seen_settings == settings + + def test_bodyless_invoke_fails_as_platform_error(self): + # A bodyless invoke cannot carry the envelope; on a pod with no boot settings file it must + # not dispatch a handler with no settings source at all. + recorder, response = _post_invoke(b"") + + assert response.status_code == 500 + assert not recorder.called + + def test_non_object_json_body_fails_as_platform_error(self): + recorder, response = _post_invoke(json.dumps([{"element": 1}]).encode()) + + assert response.status_code == 500 + assert not recorder.called + + +def test_non_object_bodies_pass_through_when_sealed_settings_not_required(): + recorder, response = _post_invoke(b"") + + assert response.status_code == 200 + assert recorder.seen_settings is None diff --git a/test/api/test_invocation_middleware.py b/test/api/test_invocation_middleware.py deleted file mode 100644 index 9906d8c..0000000 --- a/test/api/test_invocation_middleware.py +++ /dev/null @@ -1,458 +0,0 @@ -"""The transport for the reserved /invoke fields: middleware, /metadata, request-scoped binding. - -The *contract* these exercise — which shapes carry settings, what absence means — is owned and -tested in `utic_invocation_settings`. What is tested here is delivery: that a raw body is read, -resolved, bound, and replayed intact, and that a payload which cannot be used fails the request -instead of reaching a handler as absence. -""" - -from __future__ import annotations - -import asyncio -import json -from base64 import b64decode, b64encode - -import pytest -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from fastapi import FastAPI -from fastapi.testclient import TestClient -from utic_invocation_settings import ( - DAG_NODE_SETTINGS_KEY, - REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, - default_resolver, - reset_workload_identity_cache, -) -from utic_invocation_settings.crypto import seal_settings - -from unstructured_platform_plugins.invocation_settings import ( - InvocationEnvelopeMiddleware, - add_metadata_route, - current_invocation_context, - current_invocation_settings, -) - -SENTINEL_SECRET = "sealed-settings-sentinel-secret" - - -@pytest.fixture(scope="session") -def private_key() -> rsa.RSAPrivateKey: - return rsa.generate_private_key(public_exponent=65537, key_size=3072) - - -@pytest.fixture(autouse=True) -def isolated_identity(monkeypatch): - """The identity memo and the resolver caches both outlive a test; an inherited env var or a - stale entry would make these order-dependent.""" - for var in ("WORKLOAD_IDENTITY_DIR", "INVOCATION_SETTINGS_KEY_DIR", - REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR): - monkeypatch.delenv(var, raising=False) - reset_workload_identity_cache() - default_resolver().clear_caches() - yield - reset_workload_identity_cache() - default_resolver().clear_caches() - - -@pytest.fixture -def key_dir(tmp_path, private_key, monkeypatch, isolated_identity): - (tmp_path / "tls.key").write_bytes( - private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path)) - reset_workload_identity_cache() - return tmp_path - - -def sealed_payload(private_key: rsa.RSAPrivateKey, settings: dict) -> dict: - return seal_settings(settings, private_key.public_key()).model_dump( - mode="json", exclude_none=True - ) - - -def tampered(sealed: dict) -> dict: - ciphertext = bytearray(b64decode(sealed["content_encryption"]["ciphertext"])) - ciphertext[0] ^= 0x01 - sealed["content_encryption"]["ciphertext"] = b64encode(bytes(ciphertext)).decode() - return sealed - - -class TestMetadataRoute: - def test_advertises_settings_and_context_capabilities(self): - app = FastAPI() - add_metadata_route(app, identifier="plugin.test") - - with TestClient(app) as client: - payload = client.get("/metadata").json() - - assert payload == { - "api_version": "3", - "identifier": "plugin.test", - "capabilities": ["invocation_settings", "invocation_context"], - } - - def test_sealed_dag_node_settings_flag_advertises_capability(self): - app = FastAPI() - add_metadata_route(app, invoke_with_sealed_dag_node_settings=True) - - with TestClient(app) as client: - payload = client.get("/metadata").json() - - assert payload["capabilities"] == [ - "invocation_settings", - "invocation_context", - "invoke_with_sealed_dag_node_settings", - ] - - def test_last_call_wins(self): - # A host wrapper registers /metadata with defaults at construction; the plugin's later call - # with the sealed capability must replace it, not be shadowed by route order. - app = FastAPI() - add_metadata_route(app, identifier="plugin.test") - add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) - - with TestClient(app) as client: - payload = client.get("/metadata").json() - - assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"] - - def test_replaces_a_directly_registered_metadata_route(self): - # A route the app registered itself would otherwise win by route order and pin its stale - # payload. - app = FastAPI() - - @app.get("/metadata") - async def stale_metadata() -> dict: - return {"api_version": "3", "identifier": "stale", "capabilities": []} - - add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) - - with TestClient(app) as client: - payload = client.get("/metadata").json() - - assert payload["identifier"] == "plugin.test" - assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"] - - -def _invoke_scope(path: str = "/invoke", method: str = "POST") -> dict: - return {"type": "http", "method": method, "path": path} - - -def _receive_for(body: bytes): - chunks = [ - {"type": "http.request", "body": body[: len(body) // 2], "more_body": True}, - {"type": "http.request", "body": body[len(body) // 2 :], "more_body": False}, - ] - - async def receive(): - return chunks.pop(0) - - return receive - - -async def _ok(send): - await send({"type": "http.response.start", "status": 200, "headers": []}) - await send({"type": "http.response.body", "body": b"{}"}) - - -class _DownstreamApp: - """Records the replayed body and the envelope bound while handling.""" - - def __init__(self): - self.body = None - self.called = False - self.seen_settings = "unset" - self.seen_context = "unset" - - async def __call__(self, scope, receive, send): - body = b"" - while True: - message = await receive() - body += message.get("body", b"") - if not message.get("more_body"): - break - self.body = body - self.called = True - self.seen_settings = current_invocation_settings() - self.seen_context = current_invocation_context() - await _ok(send) - - -def _run_middleware(body: bytes, scope: dict | None = None) -> tuple[_DownstreamApp, list]: - downstream = _DownstreamApp() - middleware = InvocationEnvelopeMiddleware(downstream) - sent = [] - - async def send(message): - sent.append(message) - - asyncio.run(middleware(scope or _invoke_scope(), _receive_for(body), send)) - return downstream, sent - - -class TestInvocationEnvelopeMiddleware: - def test_binds_reserved_fields_and_replays_body(self): - body = json.dumps( - { - "element_dicts": "/in.json", - "invocation_settings": {"model": "m"}, - "invocation_context": {"schema_version": "1", "job_id": "job-1"}, - } - ).encode() - - downstream, sent = _run_middleware(body) - - assert downstream.body == body - assert downstream.seen_settings == {"model": "m"} - assert downstream.seen_context.job_id == "job-1" - assert sent[0]["status"] == 200 - - def test_absent_fields_bind_none(self): - downstream, _ = _run_middleware(json.dumps({"element_dicts": "/in.json"}).encode()) - - assert downstream.seen_settings is None - assert downstream.seen_context is None - - def test_non_dict_reserved_field_is_rejected(self): - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": "not-a-dict"}).encode() - ) - - assert downstream.body is None - assert sent[0]["status"] == 422 - body = json.loads(sent[1]["body"]) - assert "invocation_settings" in body["detail"] - assert body["reason"] == "malformed_envelope" - - def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self): - # Absence means "older caller, use the boot settings"; a context this plugin cannot read - # must not be downgraded to that. And it is deployment skew, not a caller fault — a 422 - # would let an upstream blame classifier pin version skew on the customer. - downstream, sent = _run_middleware( - json.dumps({"invocation_context": {"schema_version": "99"}}).encode() - ) - - assert downstream.body is None - assert sent[0]["status"] == 500 - body = json.loads(sent[1]["body"]) - assert "invocation_context" in body["detail"] - assert "UnsupportedContextVersionError" in body["detail"] - assert body["reason"] == "unsupported_context_version" - - def test_malformed_context_is_rejected(self): - downstream, sent = _run_middleware( - json.dumps({"invocation_context": "not-an-object"}).encode() - ) - - assert downstream.body is None - assert sent[0]["status"] == 422 - - def test_non_invoke_requests_pass_through_untouched(self): - body = json.dumps({"invocation_settings": "not-a-dict"}).encode() - - downstream, sent = _run_middleware(body, scope=_invoke_scope(path="/schema", method="GET")) - - assert downstream.body == body - assert sent[0]["status"] == 200 - - def test_envelope_is_reset_after_request(self): - body = json.dumps({"invocation_settings": {"model": "m"}}).encode() - - async def scenario(): - middleware = InvocationEnvelopeMiddleware(_DownstreamApp()) - - async def send(_message): - pass - - await middleware(_invoke_scope(), _receive_for(body), send) - return current_invocation_settings(), current_invocation_context() - - assert asyncio.run(scenario()) == (None, None) - - def test_oversized_body_is_rejected(self): - body = json.dumps({"invocation_settings": {"pad": "x" * 64}}).encode() - - downstream = _DownstreamApp() - middleware = InvocationEnvelopeMiddleware(downstream, max_body_bytes=16) - sent = [] - - async def send(message): - sent.append(message) - - asyncio.run(middleware(_invoke_scope(), _receive_for(body), send)) - - assert downstream.body is None - assert sent[0]["status"] == 413 - - def test_drained_replay_proxies_disconnect(self): - body = json.dumps({"invocation_settings": {"model": "m"}}).encode() - - class _DisconnectWatcher: - def __init__(self): - self.saw_disconnect = False - - async def __call__(self, scope, receive, send): - while True: - message = await receive() - if message["type"] == "http.disconnect": - self.saw_disconnect = True - return - if not message.get("more_body"): - break - message = await receive() - self.saw_disconnect = message["type"] == "http.disconnect" - await _ok(send) - - chunks = [ - {"type": "http.request", "body": body, "more_body": False}, - {"type": "http.disconnect"}, - ] - - async def receive(): - return chunks.pop(0) - - watcher = _DisconnectWatcher() - middleware = InvocationEnvelopeMiddleware(watcher) - sent = [] - - async def send(message): - sent.append(message) - - asyncio.run(middleware(_invoke_scope(), receive, send)) - - assert watcher.saw_disconnect - - -class TestMiddlewareResolution: - """Sealed payloads through the middleware. The HTTP class comes from the library's blame - taxonomy, so only a caller-fixable fault is a 422.""" - - def test_sealed_envelope_binds_plaintext_settings(self, key_dir, private_key): - settings = {"api_key": SENTINEL_SECRET, "max_characters": 700} - sealed = sealed_payload(private_key, settings) - - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": sealed}).encode() - ) - - assert sent[0]["status"] == 200 - assert downstream.seen_settings == settings - - def test_composite_dag_node_settings_member_is_opened(self, key_dir, private_key): - settings = {"api_key": SENTINEL_SECRET, "max_characters": 700} - composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} - - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": composite}).encode() - ) - - assert sent[0]["status"] == 200 - assert downstream.seen_settings == settings - - def test_plain_dict_settings_pass_through(self): - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": {"model": "m"}}).encode() - ) - - assert sent[0]["status"] == 200 - assert downstream.seen_settings == {"model": "m"} - - def test_non_envelope_member_fails_as_platform_error(self, key_dir): - composite = {DAG_NODE_SETTINGS_KEY: {"model": "m"}} - - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": composite}).encode() - ) - - assert sent[0]["status"] == 500 - assert "MalformedDagNodeSettingsError" in json.loads(sent[1]["body"])["detail"] - assert not downstream.called - - def test_undecryptable_envelope_fails_without_leaking_the_secret( - self, key_dir, private_key, caplog - ): - sealed = tampered(sealed_payload(private_key, {"api_key": SENTINEL_SECRET})) - - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": sealed}).encode() - ) - - assert sent[0]["status"] == 500 - detail = json.loads(sent[1]["body"])["detail"] - assert "DecryptionError" in detail - assert SENTINEL_SECRET not in caplog.text - assert SENTINEL_SECRET not in detail - assert not downstream.called - - def test_unmounted_identity_fails_as_platform_error(self, tmp_path, monkeypatch, private_key): - monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path / "missing")) - sealed = sealed_payload(private_key, {"api_key": SENTINEL_SECRET}) - - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": sealed}).encode() - ) - - assert sent[0]["status"] == 500 - assert "IdentityNotMountedError" in json.loads(sent[1]["body"])["detail"] - assert not downstream.called - - -class TestRequireSealedDagNodeSettings: - """A native pod's operator sets FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS in the same - decision that drops the init-secrets sidecar: without it, an invoke that arrived with no - envelope would fall back to a settings file that was never written.""" - - @pytest.fixture(autouse=True) - def _require_sealed(self, monkeypatch): - monkeypatch.setenv(REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, "true") - - def test_missing_settings_fail_as_platform_error(self): - downstream, sent = _run_middleware(json.dumps({"element_dicts": "/tmp/x.json"}).encode()) - - assert sent[0]["status"] == 500 - assert "SealedDagNodeSettingsRequiredError" in json.loads(sent[1]["body"])["detail"] - assert not downstream.called - - def test_plaintext_settings_fail_as_platform_error(self): - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": {"model": "m"}}).encode() - ) - - assert sent[0]["status"] == 500 - assert not downstream.called - - def test_sealed_settings_still_bind(self, key_dir, private_key): - settings = {"api_key": SENTINEL_SECRET} - composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} - - downstream, sent = _run_middleware( - json.dumps({"invocation_settings": composite}).encode() - ) - - assert sent[0]["status"] == 200 - assert downstream.seen_settings == settings - - def test_bodyless_invoke_fails_as_platform_error(self): - # A bodyless invoke cannot carry the envelope; on a pod with no boot settings file it must - # not dispatch a handler with no settings source at all. - downstream, sent = _run_middleware(b"") - - assert sent[0]["status"] == 500 - assert not downstream.called - - def test_non_object_json_body_fails_as_platform_error(self): - downstream, sent = _run_middleware(json.dumps([{"element": 1}]).encode()) - - assert sent[0]["status"] == 500 - assert not downstream.called - - -def test_non_object_bodies_pass_through_when_sealed_settings_not_required(): - downstream, sent = _run_middleware(b"") - - assert sent[0]["status"] == 200 - assert downstream.seen_settings is None diff --git a/test/api/test_invocation_settings.py b/test/api/test_invocation_settings.py index 5523b23..2ec8410 100644 --- a/test/api/test_invocation_settings.py +++ b/test/api/test_invocation_settings.py @@ -1,5 +1,6 @@ """The wrapper-installed invocation-settings surface: /metadata and reserved-field binding.""" +import json from typing import Optional from fastapi.testclient import TestClient @@ -18,7 +19,7 @@ def _echo_settings(content: str) -> _Echo: return _Echo(content=content, settings=current_invocation_settings()) -def test_metadata_route_is_registered_with_default_capabilities(): +def test_metadata_route_is_registered_with_transport_capabilities(): client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) resp = client.get("/metadata") @@ -73,9 +74,7 @@ def sync_echo(content: str) -> _Echo: client = TestClient(wrap_in_fastapi(func=sync_echo, plugin_id="mock_plugin")) - resp = client.post( - "/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}} - ) + resp = client.post("/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}}) assert resp.json()["output"]["settings"] == {"model": "m"} @@ -87,13 +86,24 @@ async def _async_echo(content: str) -> _Echo: def test_async_function_sees_bound_settings(): client = TestClient(wrap_in_fastapi(func=_async_echo, plugin_id="mock_plugin")) - resp = client.post( - "/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}} - ) + resp = client.post("/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}}) assert resp.json()["output"]["settings"] == {"model": "m"} +async def _stream_echo(content: str) -> _Echo: + yield _Echo(content=content, settings=current_invocation_settings()) + + +def test_async_generator_sees_bound_settings_during_stream_iteration(): + client = TestClient(wrap_in_fastapi(func=_stream_echo, plugin_id="mock_plugin")) + + resp = client.post("/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}}) + + line = json.loads(resp.text.strip()) + assert line["output"]["settings"] == {"model": "m"} + + def test_absent_reserved_fields_bind_none(): client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 2ca8b5b..f75961d 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -28,7 +28,10 @@ ) from unstructured_platform_plugins.invocation_settings import ( add_metadata_route, + current_invocation_context, + current_invocation_settings, install_invocation_envelope, + invocation_envelope, ) from unstructured_platform_plugins.schema import FileDataMeta, NewRecord, UsageData from unstructured_platform_plugins.schema.json_schema import ( @@ -203,6 +206,9 @@ def _wrap_in_fastapi( logger.warning("usage data not an expected parameter, omitting") fastapi_app = FastAPI() + # Installation contributes a public router dependency, so it must happen before /invoke is + # registered. The dependency itself is a no-op for every other route. + install_invocation_envelope(fastapi_app) response_type = get_output_sig(func) filedata_meta_model = update_filedata_model(response_type) @@ -241,12 +247,35 @@ async def wrap_fn(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Re request_dict["message_channels"] = message_channels if "filedata_meta" in params: request_dict["filedata_meta"] = filedata_meta + bound_settings = current_invocation_settings() + bound_context = current_invocation_context() try: if inspect.isasyncgenfunction(func): # Stream response if function is an async generator async def _stream_response(): - try: - async for output in func(**(request_dict or {})): + # FastAPI 0.117 closes yield dependencies before iterating a + # StreamingResponse. Re-enter the captured binding inside the generator so the + # plugin sees the right request regardless of dependency-cleanup timing. + with invocation_envelope(bound_settings, bound_context): + try: + async for output in func(**(request_dict or {})): + yield ( + InvokeResponse( + usage=usage, + message_channels=message_channels, + filedata_meta=filedata_meta_model.model_validate( + filedata_meta.model_dump() + ), + status_code=status.HTTP_200_OK, + output=output, + file_data=request_dict.get("file_data", None), + ).model_dump_json() + + "\n" + ) + except Exception as e: + logger.error( + f"Failure streaming response: {_safe_str(e)}", exc_info=True + ) yield ( InvokeResponse( usage=usage, @@ -254,31 +283,19 @@ async def _stream_response(): filedata_meta=filedata_meta_model.model_validate( filedata_meta.model_dump() ), - status_code=status.HTTP_200_OK, - output=output, - file_data=request_dict.get("file_data", None), + status_code=status_code_of(e), + status_code_text=f"[{type(e).__name__}] {_safe_str(e)}", + failure_category=failure_category_of(e), ).model_dump_json() + "\n" ) - except Exception as e: - logger.error(f"Failure streaming response: {_safe_str(e)}", exc_info=True) - yield ( - InvokeResponse( - usage=usage, - message_channels=message_channels, - filedata_meta=filedata_meta_model.model_validate( - filedata_meta.model_dump() - ), - status_code=status_code_of(e), - status_code_text=f"[{type(e).__name__}] {_safe_str(e)}", - failure_category=failure_category_of(e), - ).model_dump_json() - + "\n" - ) return StreamingResponse(_stream_response(), media_type="application/x-ndjson") else: - output = await invoke_func(func=func, kwargs=request_dict) + # Keep execution-scoped binding explicit for the same reason as the streaming + # branch; nested binding is harmless while the route dependency is still active. + with invocation_envelope(bound_settings, bound_context): + output = await invoke_func(func=func, kwargs=request_dict) return InvokeResponse( usage=usage, message_channels=message_channels, @@ -424,18 +441,17 @@ async def get_id() -> str: except TypeError as e: raise TypeError(f"failed to validate function schema: {e}") from e - # The middleware handles the reserved /invoke fields (invocation_settings and + # The route dependency handles the reserved /invoke fields (invocation_settings and # invocation_context) outside the generated handler schema. It resolves sealed settings with - # the configured private key and exposes both values through request-scoped accessors. The - # sealed-settings capability remains opt-in because it asserts that the wrapped function - # consumes current_invocation_settings(), not merely that the host can resolve it. Repeated - # installation is safe: the middleware installs once and the last /metadata registration wins. + # the configured private key and exposes both values through request-scoped accessors. + # The sealed-settings capability remains opt-in because it asserts that the wrapped function + # consumes current_invocation_settings(), not merely that the host can resolve it. The binding + # dependency was installed before route registration; the last /metadata registration wins. add_metadata_route( fastapi_app, identifier=plugin_id, invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, ) - install_invocation_envelope(fastapi_app) FastAPIInstrumentor.instrument_app( fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider() diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 7b73d43..c0e95e5 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -1,4 +1,4 @@ -"""Transport for the reserved `/invoke` fields: ASGI middleware, `/metadata`, request binding. +"""Transport for the reserved `/invoke` fields: request dependency, `/metadata`, body cap. The *settings contract* — which key carries settings, how a sealed envelope is told from plaintext, and what an absent field is allowed to mean — lives in @@ -9,10 +9,17 @@ off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP statuses. -The reserved fields are a first-class HTTP contract independent of the generated input schema. They -never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler model -built purely from the wrapped function, and a plugin reads the fields through +The reserved fields are a first-class HTTP contract independent of the generated input schema. +They never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler +model built purely from the wrapped function, and a plugin reads the fields through `current_invocation_settings()` / `current_invocation_context()` instead. + +Extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke` body is +buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on the +`Request`, and the dependency reads that cached parse. The request-size cap is the one concern +that must sit below the framework — neither Starlette nor uvicorn bounds request-body size — and +`InvokeBodyLimitMiddleware` enforces it by counting bytes as they stream through, without +buffering. """ from __future__ import annotations @@ -24,12 +31,14 @@ import threading import time from collections import OrderedDict -from collections.abc import Callable, Iterator, Mapping +from collections.abc import AsyncIterator, Callable, Iterator, Mapping from contextlib import contextmanager from contextvars import ContextVar from typing import Any, Optional, TypeVar -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Request +from starlette.requests import ClientDisconnect +from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send from utic_invocation_settings import ( INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY, @@ -60,13 +69,14 @@ def http_status_for(error: BaseException) -> int: """ return 422 if getattr(error, "blame", None) is Blame.CALLER else 500 + T = TypeVar("T") _METADATA_PATH = "/metadata" _INVOKE_PATH = "/invoke" -# Bounds middleware body buffering; generous because batch invokes carry an array of file_data -# payloads. The framework buffers the same body afterward, so this cap is the only guard against +# Bounds the /invoke request body; generous because batch invokes carry an array of file_data +# payloads. Nothing below the framework buffers, so this cap is the only guard against # unbounded-memory requests. MAX_INVOKE_BODY_BYTES = 64 * 1024 * 1024 @@ -80,7 +90,7 @@ def current_invocation_settings() -> Optional[dict]: `None` means the field was genuinely absent — the only case in which a plugin may fall back to its boot-time settings. A field that arrived and could not be opened never reaches a handler: - the middleware fails the request first. + the binding dependency fails the request first. """ return _INVOCATION.get()[0] @@ -113,18 +123,14 @@ def add_metadata_route( flags are strings in its `capabilities` list, which is where the controller looks before forwarding the reserved fields — no controller-private probe route. - The two tiers make different claims. `invocation_settings` / `invocation_context` are - *transport-level* facts, advertised unconditionally because the installed middleware makes - them true for every wrapped app: the reserved fields will be received, resolved, and bound — - or the request failed. They say nothing about whether the handler reads the binding. - `invoke_with_sealed_dag_node_settings` is the *consumption* claim — this plugin opens sealed - `dag_node_settings` itself and its handler acts on the result — and stays a per-plugin opt-in - set in the same change that makes it true, because it is the flag that invites the controller - to seal settings to this pod in place of any other settings source. + `invocation_settings` and `invocation_context` are transport capabilities: installing the + dependency makes the host receive, resolve, and bind those fields. The sealed-settings + capability is stronger: it tells the controller that the plugin handler consumes the resolved + `dag_node_settings` in place of boot-time state, so it remains an explicit opt-in. Last call wins: the payload lives on `app.state` and every call overwrites it, while the route - is registered once. A host wrapper may register with default capabilities at app construction - and a plugin can still declare the sealed capability afterwards, with no route-order dependence. + is registered once. A host wrapper may register at app construction and a plugin can still + re-register with its own identifier afterwards, with no route-order dependence. """ capabilities = [RESERVED_ENVELOPE_KEY, RESERVED_CONTEXT_KEY] if invoke_with_sealed_dag_node_settings: @@ -147,6 +153,104 @@ async def plugin_metadata() -> dict: return app.state.plugin_metadata_payload +class UnusableInvocationEnvelope(Exception): + """A reserved /invoke field arrived but cannot be used. + + Raised by `bind_invocation_envelope` and answered by the handler + `install_invocation_envelope` registers, so the response shape — `detail` plus the library's + stable `reason` code as top-level siblings — stays what orchestrators parse, independent of + FastAPI's own error envelope. + """ + + def __init__(self, status_code: int, payload: dict): + super().__init__(payload.get("detail")) + self.status_code = status_code + self.payload = payload + + +async def _unusable_envelope_response( + _request: Request, exc: UnusableInvocationEnvelope +) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content=exc.payload) + + +async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: + """Resolve the reserved /invoke fields and bind them for the duration of the request. + + Runs as a route dependency, after the framework has read the body: `request.json()` is + Starlette-cached, so the parse is shared with the framework's own body handling. + A reserved field that is present but unusable fails the request rather than being + treated as absent, because absence is the signal to fall back to the boot-time settings file: + degrading a malformed field to absence would quietly answer a request configured for one + tenant with whatever the pod happened to boot with. Under + `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` there is no settings file to fall back to, + so absent or plaintext settings fail too — as does any /invoke whose body is not a JSON + object, since such a body cannot carry the envelope a native pod requires. + """ + # ASGI `path` is mount-relative and excludes a deployment root_path; request.url.path may + # include that prefix and would silently skip binding behind a rooted proxy deployment. + if request.method != "POST" or request.scope["path"] != _INVOKE_PATH: + yield + return + + try: + parsed = await request.json() + except ValueError: + # Empty or malformed body: the framework's own validation answers for the body itself; + # for the reserved fields it is absence, which resolve_invocation_settings still judges + # (absence is a failure on a native pod). + parsed = None + + raw_settings: Optional[Any] = None + if isinstance(parsed, dict): + raw_settings = parsed.get(RESERVED_ENVELOPE_KEY) + if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict): + raise UnusableInvocationEnvelope( + 422, + { + "detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}", + "reason": MalformedEnvelopeError.reason, + }, + ) + try: + # Off the event loop: a cold resolve is an RSA unwrap of a couple of milliseconds, and + # this dependency fronts every invoke on the pod. + invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings) + except Exception as exc: + # Class name only — never envelope contents, and never the exception's own message, + # which can embed request-controlled values. + logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__) + payload = {"detail": f"Unusable invocation settings: {type(exc).__name__}"} + reason = getattr(exc, "reason", None) + if isinstance(reason, str): + payload["reason"] = reason + raise UnusableInvocationEnvelope(http_status_for(exc), payload) from exc + + invocation_context: Optional[InvocationContext] = None + if isinstance(parsed, dict): + try: + invocation_context = extract_context(parsed) + except InvocationSettingsError as exc: + # A context this plugin cannot read fails loudly here rather than running with + # silently absent identity. Status comes from the blame taxonomy: a malformed + # field is the caller's 422, but an unreadable schema_version is deployment skew + # between platform components and must not read as a caller fault. The log line is + # truncated because the message can embed request-controlled values. + logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc) + status = http_status_for(exc) + detail = ( + f"Invalid field: {RESERVED_CONTEXT_KEY}" + if status == 422 + else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}" + ) + raise UnusableInvocationEnvelope( + status, {"detail": detail, "reason": exc.reason} + ) from exc + + with invocation_envelope(invocation_settings, invocation_context): + yield + + async def _send_json(send: Send, status_code: int, payload: dict) -> None: body = json.dumps(payload).encode() await send( @@ -162,21 +266,14 @@ async def _send_json(send: Send, status_code: int, payload: dict) -> None: await send({"type": "http.response.body", "body": body}) -class InvocationEnvelopeMiddleware: - """Extract the reserved envelope fields from the raw POST /invoke body. +class InvokeBodyLimitMiddleware: + """Reject a POST /invoke body over ``max_body_bytes`` with 413. - Pure ASGI rather than `BaseHTTPMiddleware`: the body has to be read before the framework parses - it and then replayed intact, which is exactly what the raw protocol allows and what a - request/response middleware would fight. - - A reserved field that is present but unusable fails the request rather than being treated as - absent, because absence is the signal to fall back to the boot-time settings file: degrading a - malformed field to absence would quietly answer a request configured for one tenant with - whatever the pod happened to boot with. Under `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` - there is no settings file to fall back to, so absent or plaintext settings fail too — as does - any /invoke whose body is not a JSON object, since such a body cannot carry the envelope a - native pod requires. A body over `max_body_bytes` is rejected with 413 before it can exhaust - memory. + Counts bytes as the framework consumes them; nothing is buffered here. When the count crosses + the cap the downstream read is answered with ``http.disconnect``, which aborts the framework's + body read before another byte is held, and the 413 is sent once the application has unwound. + This has to sit below the framework because neither Starlette nor uvicorn bounds request-body + size. """ def __init__(self, app: ASGIApp, max_body_bytes: int = MAX_INVOKE_BODY_BYTES): @@ -192,103 +289,72 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - messages = [] - buffered_bytes = 0 - while True: + seen = 0 + exceeded = False + response_started = False + + async def counting_receive() -> dict: + nonlocal seen, exceeded message = await receive() - messages.append(message) - buffered_bytes += len(message.get("body", b"")) - if buffered_bytes > self.max_body_bytes: - await _send_json(send, 413, {"detail": "Request body too large"}) + if message["type"] == "http.request": + seen += len(message.get("body", b"")) + if seen > self.max_body_bytes: + exceeded = True + return {"type": "http.disconnect"} + return message + + async def guarded_send(message: dict) -> None: + nonlocal response_started + if exceeded and not response_started: + # A response computed after the body was cut is answering a truncated request; + # drop it so the 413 below is what the caller sees. A response that started + # before the cap tripped keeps streaming — its start is already on the wire. return - if message["type"] != "http.request" or not message.get("more_body"): - break - body = b"".join(m.get("body", b"") for m in messages if m["type"] == "http.request") + if message["type"] == "http.response.start": + response_started = True + await send(message) try: - parsed = json.loads(body) if body else None - except ValueError: - # Malformed JSON: forward unchanged so the framework returns its own error. - parsed = None - - invocation_context: Optional[InvocationContext] = None - raw_settings: Optional[dict[str, Any]] = None - if isinstance(parsed, dict): - raw_settings = parsed.get(RESERVED_ENVELOPE_KEY) - if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict): - await _send_json( - send, - 422, - { - "detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}", - "reason": MalformedEnvelopeError.reason, - }, - ) - return - # Resolved even when the field — or the whole JSON object — is absent: - # resolve_invocation_settings owns the FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS - # policy, under which a bodyless or non-object invoke cannot carry the envelope a native - # pod requires and is a failure, not a fallback signal. - try: - # Off the event loop: a cold resolve is an RSA unwrap of a couple of milliseconds, and - # this middleware sits in front of every invoke on the pod. - invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings) - except Exception as exc: - # Class name only — never envelope contents, and never the exception's own message, - # which can embed request-controlled values. - logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__) - body = {"detail": f"Unusable invocation settings: {type(exc).__name__}"} - reason = getattr(exc, "reason", None) - if isinstance(reason, str): - body["reason"] = reason - await _send_json(send, http_status_for(exc), body) - return - if isinstance(parsed, dict): - try: - invocation_context = extract_context(parsed) - except InvocationSettingsError as exc: - # A context this plugin cannot read fails loudly here rather than running with - # silently absent identity. Status comes from the blame taxonomy: a malformed - # field is the caller's 422, but an unreadable schema_version is deployment skew - # between platform components and must not read as a caller fault. The log line is - # truncated because the message can embed request-controlled values. - logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc) - status = http_status_for(exc) - detail = ( - f"Invalid field: {RESERVED_CONTEXT_KEY}" - if status == 422 - else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}" - ) - await _send_json(send, status, {"detail": detail, "reason": exc.reason}) - return - - # The joined body and its parsed tree can be tens of MB and are not needed past this point; - # the framework re-buffers and re-parses the replayed messages downstream, so holding these - # through the handler would double peak memory. - del body, parsed, raw_settings - - async def replay() -> dict: - if messages: - return messages.pop(0) - # Buffer drained: proxy the original channel so downstream still observes - # http.disconnect. - return await receive() - - with invocation_envelope(invocation_settings, invocation_context): - await self.app(scope, replay, send) - - -def install_invocation_envelope(app: FastAPI) -> None: - """Install out-of-schema envelope extraction on a FastAPI app. - - Idempotent per app: a host wrapper may install at app construction while a plugin that predates - the wrapper's support still calls this itself, and a double install would buffer and replay the - request body twice. + await self.app(scope, counting_receive, guarded_send) + except ClientDisconnect: + if not exceeded: + raise + except Exception: + # The cut body stream can surface downstream as something other than + # ClientDisconnect; once the cap is the cause, the 413 below is the answer. + if not exceeded: + raise + if exceeded and not response_started: + await _send_json(send, 413, {"detail": "Request body too large"}) + + +def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_BODY_BYTES) -> None: + """Install reserved-field binding before registering a FastAPI app's routes. + + Adds `bind_invocation_envelope` through the router's public dependency list, installs the + body-size cap beneath the framework, and registers the failure response shape. The dependency + is a path/method-aware no-op outside POST /invoke, so routes registered after this call can all + inherit it without private dependency-graph mutation. Calling after routes have already been + registered raises rather than silently leaving those routes uncovered. + + Idempotent per app: a host wrapper may install at app construction while a plugin that + predates the wrapper's support still calls this itself, and a double install would resolve + settings twice per request. """ if getattr(app.state, "invocation_envelope_installed", False): return + if any( + getattr(route, "path", None) == _INVOKE_PATH + and "POST" in (getattr(route, "methods", None) or set()) + for route in app.router.routes + ): + raise RuntimeError( + "install_invocation_envelope must be called before the POST /invoke route is registered" + ) app.state.invocation_envelope_installed = True - app.add_middleware(InvocationEnvelopeMiddleware) + app.router.dependencies.append(Depends(bind_invocation_envelope)) + app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes) + app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response) def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str: From 3a809fe635fe237401a63e9537cdb56a63df26e8 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:58:13 -0400 Subject: [PATCH 09/32] fix(etl-uvicorn): declare user blame on the streaming error path The non-streaming failure envelope derives blame from the UserError family; the async-generator error envelope omitted it, so a customer fault raised mid-stream read as unattributed. --- test/api/test_api.py | 24 +++++++++++++++++++ test/assets/exception_status_code.py | 13 ++++++++++ .../etl_uvicorn/api_generator.py | 1 + 3 files changed, 38 insertions(+) diff --git a/test/api/test_api.py b/test/api/test_api.py index 18323e3..4395577 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -255,6 +255,30 @@ def test_non_user_failures_declare_no_blame(file_data): assert invoke_response.blame is None +def test_streaming_user_error_declares_user_blame(): + """The streaming error envelope carries the same blame derivation as the non-streaming path.""" + from test.assets.exception_status_code import ( + async_gen_function_raises_user_error_mid_stream as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + resp = client.post("/invoke", json={"file_data": mock_file_data[0].model_dump()}) + + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/x-ndjson" + + import json + + lines = resp.content.decode().strip().split("\n") + assert len(lines) == 2 # One yielded item, then the error envelope + + assert InvokeResponse.model_validate(json.loads(lines[0])).blame is None + error_response = InvokeResponse.model_validate(json.loads(lines[1])) + assert error_response.status_code >= 400 + assert error_response.blame == "user" + + @pytest.mark.parametrize( "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] ) diff --git a/test/assets/exception_status_code.py b/test/assets/exception_status_code.py index f20cf93..034f3af 100644 --- a/test/assets/exception_status_code.py +++ b/test/assets/exception_status_code.py @@ -1,6 +1,7 @@ """Test assets for testing exception handling with various status_code scenarios.""" from fastapi import HTTPException +from typing_extensions import TypedDict from unstructured_ingest.error import UnstructuredIngestError @@ -139,6 +140,18 @@ async def async_gen_function_raises_unstructured_ingest_error_with_none_status_c raise error +class PartialStreamResponse(TypedDict): + partial: str + + +async def async_gen_function_raises_user_error_mid_stream() -> PartialStreamResponse: + """Async generator that yields once, then raises UserError.""" + from unstructured_ingest.error import UserError + + yield PartialStreamResponse(partial="output") + raise UserError("Customer-owned resource rejected the request") + + def function_raises_user_error() -> None: from unstructured_ingest.error import UserError diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index f75961d..120a2ff 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -286,6 +286,7 @@ async def _stream_response(): status_code=status_code_of(e), status_code_text=f"[{type(e).__name__}] {_safe_str(e)}", failure_category=failure_category_of(e), + blame="user" if isinstance(e, UserError) else None, ).model_dump_json() + "\n" ) From d7cea08699368338ff21622d7b05b1391d9f1461 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:58:38 -0400 Subject: [PATCH 10/32] fix(etl-uvicorn): match /invoke by the route path under a rooted deployment Per the ASGI spec, scope["path"] includes any deployment root_path; the router matches on get_route_path, which strips it. Comparing the raw path to /invoke made both envelope binding and the body cap silently skip on a spec-compliant rooted server, so settings read as absent and the boot-file fallback took over. TestClient(root_path=...) does not prefix path, so the regression tests build the spec-compliant scope themselves. --- test/api/test_invocation_envelope.py | 55 +++++++++++++++++++ .../invocation_settings.py | 9 +-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index 5c243a5..0dd3231 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -183,6 +183,21 @@ async def schema() -> dict: return app +class _SpecCompliantRootShim: + """A rooted deployment as the ASGI spec describes it: ``path`` carries the mount prefix and + ``root_path`` names it. ``TestClient(root_path=...)`` sets only ``root_path`` without + prefixing ``path``, so it cannot produce this shape.""" + + def __init__(self, app, root: str): + self.app = app + self.root = root + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + scope = {**scope, "path": self.root + scope["path"], "root_path": self.root} + await self.app(scope, receive, send) + + def _post_invoke(payload, recorder: Optional[_Recorder] = None, **app_kwargs): recorder = recorder if recorder is not None else _Recorder() app = _envelope_app(recorder, **app_kwargs) @@ -317,6 +332,16 @@ def test_root_path_does_not_prevent_invoke_binding(self): assert response.status_code == 200 assert recorder.seen_settings == {"model": "rooted"} + def test_spec_compliant_root_path_scope_still_binds(self): + recorder = _Recorder() + app = _envelope_app(recorder) + + with TestClient(_SpecCompliantRootShim(app, "/plugins/chunker")) as client: + response = client.post("/invoke", json={"invocation_settings": {"model": "rooted"}}) + + assert response.status_code == 200 + assert recorder.seen_settings == {"model": "rooted"} + def test_oversized_body_is_rejected(self): recorder, response = _post_invoke( {"invocation_settings": {"pad": "x" * 64}}, max_body_bytes=16 @@ -325,6 +350,18 @@ def test_oversized_body_is_rejected(self): assert not recorder.called assert response.status_code == 413 + def test_spec_compliant_root_path_scope_still_caps_body(self): + recorder = _Recorder() + app = _envelope_app(recorder, max_body_bytes=16) + + with TestClient( + _SpecCompliantRootShim(app, "/plugins/chunker"), raise_server_exceptions=False + ) as client: + response = client.post("/invoke", json={"invocation_settings": {"pad": "x" * 64}}) + + assert not recorder.called + assert response.status_code == 413 + class TestInvokeBodyLimitMiddleware: """ASGI-level behavior of the streaming byte counter: no buffering, disconnect pass-through.""" @@ -367,6 +404,24 @@ def test_chunked_body_over_the_cap_answers_413_and_cuts_downstream(self): assert received[-1] == {"type": "http.disconnect"} assert sent[0]["status"] == 413 + def test_rooted_scope_over_the_cap_answers_413(self): + # Per the ASGI spec, `path` includes the deployment root_path; the cap must key on the + # mount-relative path, the same one the router matches. + chunks = [{"type": "http.request", "body": b"x" * 20, "more_body": False}] + received, sent = self._run( + lambda app: InvokeBodyLimitMiddleware(app, max_body_bytes=16), + { + "type": "http", + "method": "POST", + "path": "/plugins/chunker/invoke", + "root_path": "/plugins/chunker", + }, + chunks, + ) + + assert received[-1] == {"type": "http.disconnect"} + assert sent[0]["status"] == 413 + def test_client_disconnect_passes_through_uncounted(self): chunks = [{"type": "http.disconnect"}] received, sent = self._run( diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index c0e95e5..cd5a412 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -39,6 +39,7 @@ from fastapi import Depends, FastAPI, Request from starlette.requests import ClientDisconnect from starlette.responses import JSONResponse +from starlette.routing import get_route_path from starlette.types import ASGIApp, Receive, Scope, Send from utic_invocation_settings import ( INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY, @@ -187,9 +188,9 @@ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: so absent or plaintext settings fail too — as does any /invoke whose body is not a JSON object, since such a body cannot carry the envelope a native pod requires. """ - # ASGI `path` is mount-relative and excludes a deployment root_path; request.url.path may - # include that prefix and would silently skip binding behind a rooted proxy deployment. - if request.method != "POST" or request.scope["path"] != _INVOKE_PATH: + # ASGI `path` includes any deployment root_path; get_route_path strips it, which is how the + # router itself matches, so binding fires exactly when the /invoke route does. + if request.method != "POST" or get_route_path(request.scope) != _INVOKE_PATH: yield return @@ -284,7 +285,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if ( scope["type"] != "http" or scope.get("method") != "POST" - or scope.get("path") != _INVOKE_PATH + or get_route_path(scope) != _INVOKE_PATH ): await self.app(scope, receive, send) return From dcabc9b2092a0d8075cea997b4e8c82dc490707f Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:59:22 -0400 Subject: [PATCH 11/32] fix(etl-uvicorn): reject a repeat envelope install with a different body cap The idempotence guard returned before reading max_body_bytes, so a second install asking for a different cap was silently ignored and the caller was left believing its limit was enforced. A same-value repeat stays a no-op. --- test/api/test_invocation_envelope.py | 18 ++++++++++++++++++ .../invocation_settings.py | 11 ++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index 0dd3231..a2c880a 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -362,6 +362,24 @@ def test_spec_compliant_root_path_scope_still_caps_body(self): assert not recorder.called assert response.status_code == 413 + def test_repeat_install_with_the_same_cap_is_a_noop(self): + recorder = _Recorder() + app = _envelope_app(recorder, max_body_bytes=1024) + install_invocation_envelope(app, max_body_bytes=1024) + + with TestClient(app) as client: + response = client.post("/invoke", json={"element_dicts": "/in.json"}) + + assert response.status_code == 200 + assert recorder.called + + def test_repeat_install_with_a_different_cap_fails_loudly(self): + app = FastAPI() + install_invocation_envelope(app, max_body_bytes=16) + + with pytest.raises(ValueError, match="max_body_bytes"): + install_invocation_envelope(app, max_body_bytes=32) + class TestInvokeBodyLimitMiddleware: """ASGI-level behavior of the streaming byte counter: no buffering, disconnect pass-through.""" diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index cd5a412..680415f 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -340,9 +340,17 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_B Idempotent per app: a host wrapper may install at app construction while a plugin that predates the wrapper's support still calls this itself, and a double install would resolve - settings twice per request. + settings twice per request. A repeated call asking for a different ``max_body_bytes`` raises, + because the cap already installed cannot be changed and silently keeping the first value would + misrepresent the limit actually enforced. """ if getattr(app.state, "invocation_envelope_installed", False): + installed_max = app.state.invocation_envelope_max_body_bytes + if max_body_bytes != installed_max: + raise ValueError( + "install_invocation_envelope already installed with " + f"max_body_bytes={installed_max}; cannot reinstall with {max_body_bytes}" + ) return if any( getattr(route, "path", None) == _INVOKE_PATH @@ -353,6 +361,7 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_B "install_invocation_envelope must be called before the POST /invoke route is registered" ) app.state.invocation_envelope_installed = True + app.state.invocation_envelope_max_body_bytes = max_body_bytes app.router.dependencies.append(Depends(bind_invocation_envelope)) app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes) app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response) From 1e7408a15f441e6fe195eac0cbe1cb7ab0072f6f Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:59:22 -0400 Subject: [PATCH 12/32] chore(etl-uvicorn): describe context extraction as a route dependency The envelope has been extracted by a route dependency, not middleware, since the body-replay design was removed. --- unstructured_platform_plugins/invocation_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unstructured_platform_plugins/invocation_context.py b/unstructured_platform_plugins/invocation_context.py index 4cb898f..2c456a6 100644 --- a/unstructured_platform_plugins/invocation_context.py +++ b/unstructured_platform_plugins/invocation_context.py @@ -3,7 +3,7 @@ Where ``invocation_settings`` carries *what* a plugin should be configured with, the context carries *who* the invocation is for: the identity facets a shared-tenancy pod can no longer read from its process environment. It travels in a second reserved, out-of-schema field of the -``/invoke`` body, extracted by the same middleware that resolves the settings field. +``/invoke`` body, extracted by the same route dependency that resolves the settings field. The context is `/invoke` protocol identity, not settings security: it touches no crypto and no secrets, and it evolves with the plugin protocol this package defines. The errors it raises come From f8b5104c1f4321a87dfd5453b27871b027ae8aba Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:13:00 -0400 Subject: [PATCH 13/32] build(etl-uvicorn): pin unpublished invocation-settings source --- pyproject.toml | 4 + .../etl_uvicorn/api_generator.py | 1 - uv.lock | 312 +++++++++--------- 3 files changed, 156 insertions(+), 161 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4950598..310a1f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,3 +86,7 @@ fail_under = 15 [tool.hatch.build.targets.sdist] packages = ["/unstructured_platform_plugins"] + +[tool.uv.sources] +# Temporary dev pin until utic-invocation-settings 0.4.0 is published. +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "feat/invocation-settings-asgi" } diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 120a2ff..8fc6926 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -1,5 +1,4 @@ import asyncio -import contextvars import hashlib import inspect import json diff --git a/uv.lock b/uv.lock index 18c13ae..cf4c661 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.10" +revision = 3 +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13'", "python_full_version < '3.13'", @@ -20,7 +20,6 @@ name = "anyio" version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -34,9 +33,6 @@ wheels = [ name = "asgiref" version = "3.9.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/7f/bf/0f3ecda32f1cb3bf1dca480aca08a7a8a3bdc4bed2343a103f30731565c9/asgiref-3.9.2.tar.gz", hash = "sha256:a0249afacb66688ef258ffe503528360443e2b9a8d8c4581b6ebefa58c841ef1", size = 36894, upload-time = "2025-09-23T15:00:55.136Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" }, @@ -57,10 +53,8 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, { name = "packaging" }, { name = "pyproject-hooks" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } wheels = [ @@ -85,14 +79,8 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, @@ -101,6 +89,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, @@ -108,6 +101,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, @@ -115,18 +113,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -135,17 +146,6 @@ version = "3.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, @@ -220,18 +220,6 @@ version = "7.10.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, @@ -324,10 +312,10 @@ version = "46.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/62/e3664e6ffd7743e1694b244dde70b43a394f6f7fbcacf7014a8ff5197c73/cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7", size = 749198, upload-time = "2025-09-17T00:10:35.797Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/8c/44ee01267ec01e26e43ebfdae3f120ec2312aa72fa4c0507ebe41a26739f/cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475", size = 7285044, upload-time = "2025-09-17T00:08:36.807Z" }, { url = "https://files.pythonhosted.org/packages/22/59/9ae689a25047e0601adfcb159ec4f83c0b4149fdb5c3030cc94cd218141d/cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080", size = 4308182, upload-time = "2025-09-17T00:08:39.388Z" }, { url = "https://files.pythonhosted.org/packages/c4/ee/ca6cc9df7118f2fcd142c76b1da0f14340d77518c05b1ebfbbabca6b9e7d/cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", size = 4572393, upload-time = "2025-09-17T00:08:41.663Z" }, { url = "https://files.pythonhosted.org/packages/7f/a3/0f5296f63815d8e985922b05c31f77ce44787b3127a67c0b7f70f115c45f/cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", size = 4308400, upload-time = "2025-09-17T00:08:43.559Z" }, @@ -339,6 +327,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/6b/09c30543bb93401f6f88fce556b3bdbb21e55ae14912c04b7bf355f5f96c/cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", size = 4603669, upload-time = "2025-09-17T00:08:57.16Z" }, { url = "https://files.pythonhosted.org/packages/23/9a/38cb01cb09ce0adceda9fc627c9cf98eb890fc8d50cacbe79b011df20f8a/cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", size = 4435828, upload-time = "2025-09-17T00:08:59.606Z" }, { url = "https://files.pythonhosted.org/packages/0f/53/435b5c36a78d06ae0bef96d666209b0ecd8f8181bfe4dda46536705df59e/cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", size = 4709553, upload-time = "2025-09-17T00:09:01.832Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c4/0da6e55595d9b9cd3b6eb5dc22f3a07ded7f116a3ea72629cab595abb804/cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", size = 3058327, upload-time = "2025-09-17T00:09:03.726Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/cd29a35e0d6e78a0ee61793564c8cff0929c38391cb0de27627bdc7525aa/cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", size = 3523893, upload-time = "2025-09-17T00:09:06.272Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dd/eea390f3e78432bc3d2f53952375f8b37cb4d37783e626faa6a51e751719/cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", size = 2932145, upload-time = "2025-09-17T00:09:08.568Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fb/c73588561afcd5e24b089952bd210b14676c0c5bf1213376350ae111945c/cryptography-46.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee", size = 7193928, upload-time = "2025-09-17T00:09:10.595Z" }, { url = "https://files.pythonhosted.org/packages/26/34/0ff0bb2d2c79f25a2a63109f3b76b9108a906dd2a2eb5c1d460b9938adbb/cryptography-46.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd", size = 4293515, upload-time = "2025-09-17T00:09:12.861Z" }, { url = "https://files.pythonhosted.org/packages/df/b7/d4f848aee24ecd1be01db6c42c4a270069a4f02a105d9c57e143daf6cf0f/cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a", size = 4545619, upload-time = "2025-09-17T00:09:15.397Z" }, { url = "https://files.pythonhosted.org/packages/44/a5/42fedefc754fd1901e2d95a69815ea4ec8a9eed31f4c4361fcab80288661/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a", size = 4299160, upload-time = "2025-09-17T00:09:17.155Z" }, @@ -350,6 +342,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/3b/d8fb17ffeb3a83157a1cc0aa5c60691d062aceecba09c2e5e77ebfc1870c/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657", size = 4576958, upload-time = "2025-09-17T00:09:29.924Z" }, { url = "https://files.pythonhosted.org/packages/d9/46/86bc3a05c10c8aa88c8ae7e953a8b4e407c57823ed201dbcba55c4d655f4/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0", size = 4422507, upload-time = "2025-09-17T00:09:32.222Z" }, { url = "https://files.pythonhosted.org/packages/a8/4e/387e5a21dfd2b4198e74968a541cfd6128f66f8ec94ed971776e15091ac3/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0", size = 4683964, upload-time = "2025-09-17T00:09:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/25/a3/f9f5907b166adb8f26762071474b38bbfcf89858a5282f032899075a38a1/cryptography-46.0.1-cp314-cp314t-win32.whl", hash = "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277", size = 3029705, upload-time = "2025-09-17T00:09:36.381Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/4d3a4f1850db2e71c2b1628d14b70b5e4c1684a1bd462f7fffb93c041c38/cryptography-46.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05", size = 3502175, upload-time = "2025-09-17T00:09:38.261Z" }, + { url = "https://files.pythonhosted.org/packages/52/c7/9f10ad91435ef7d0d99a0b93c4360bea3df18050ff5b9038c489c31ac2f5/cryptography-46.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784", size = 2912354, upload-time = "2025-09-17T00:09:40.078Z" }, + { url = "https://files.pythonhosted.org/packages/98/e5/fbd632385542a3311915976f88e0dfcf09e62a3fc0aff86fb6762162a24d/cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", size = 7255677, upload-time = "2025-09-17T00:09:42.407Z" }, { url = "https://files.pythonhosted.org/packages/56/3e/13ce6eab9ad6eba1b15a7bd476f005a4c1b3f299f4c2f32b22408b0edccf/cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", size = 4301110, upload-time = "2025-09-17T00:09:45.614Z" }, { url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" }, { url = "https://files.pythonhosted.org/packages/17/db/d64ae4c6f4e98c3dac5bf35dd4d103f4c7c345703e43560113e5e8e31b2b/cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", size = 4302126, upload-time = "2025-09-17T00:09:49.335Z" }, @@ -361,10 +357,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/0f/f66125ecf88e4cb5b8017ff43f3a87ede2d064cb54a1c5893f9da9d65093/cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", size = 4591247, upload-time = "2025-09-17T00:10:02.874Z" }, { url = "https://files.pythonhosted.org/packages/f6/22/9f3134ae436b63b463cfdf0ff506a0570da6873adb4bf8c19b8a5b4bac64/cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", size = 4428534, upload-time = "2025-09-17T00:10:04.994Z" }, { url = "https://files.pythonhosted.org/packages/89/39/e6042bcb2638650b0005c752c38ea830cbfbcbb1830e4d64d530000aa8dc/cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", size = 4699541, upload-time = "2025-09-17T00:10:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/46/753d457492d15458c7b5a653fc9a84a1c9c7a83af6ebdc94c3fc373ca6e8/cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1", size = 3043779, upload-time = "2025-09-17T00:10:08.951Z" }, + { url = "https://files.pythonhosted.org/packages/2f/50/b6f3b540c2f6ee712feeb5fa780bb11fad76634e71334718568e7695cb55/cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3", size = 3517226, upload-time = "2025-09-17T00:10:10.769Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" }, + { url = "https://files.pythonhosted.org/packages/27/27/077e09fd92075dd1338ea0ffaf5cfee641535545925768350ad90d8c36ca/cryptography-46.0.1-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b9c79af2c3058430d911ff1a5b2b96bbfe8da47d5ed961639ce4681886614e70", size = 3722319, upload-time = "2025-09-17T00:10:20.273Z" }, { url = "https://files.pythonhosted.org/packages/db/32/6fc7250280920418651640d76cee34d91c1e0601d73acd44364570cf041f/cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0ca4be2af48c24df689a150d9cd37404f689e2968e247b6b8ff09bff5bcd786f", size = 4249030, upload-time = "2025-09-17T00:10:22.396Z" }, { url = "https://files.pythonhosted.org/packages/32/33/8d5398b2da15a15110b2478480ab512609f95b45ead3a105c9a9c76f9980/cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:13e67c4d3fb8b6bc4ef778a7ccdd8df4cd15b4bcc18f4239c8440891a11245cc", size = 4528009, upload-time = "2025-09-17T00:10:24.418Z" }, { url = "https://files.pythonhosted.org/packages/fd/1c/4012edad2a8977ab386c36b6e21f5065974d37afa3eade83a9968cba4855/cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:15b5fd9358803b0d1cc42505a18d8bca81dabb35b5cfbfea1505092e13a9d96d", size = 4248902, upload-time = "2025-09-17T00:10:26.255Z" }, { url = "https://files.pythonhosted.org/packages/58/a3/257cd5ae677302de8fa066fca9de37128f6729d1e63c04dd6a15555dd450/cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e34da95e29daf8a71cb2841fd55df0511539a6cdf33e6f77c1e95e44006b9b46", size = 4527150, upload-time = "2025-09-17T00:10:28.28Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cd/fe6b65e1117ec7631f6be8951d3db076bac3e1b096e3e12710ed071ffc3c/cryptography-46.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34f04b7311174469ab3ac2647469743720f8b6c8b046f238e5cb27905695eb2a", size = 3448210, upload-time = "2025-09-17T00:10:30.145Z" }, ] [[package]] @@ -389,18 +390,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - [[package]] name = "fastapi" version = "0.117.1" @@ -436,16 +425,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/f7/8963848164c7604efb3a3e6ee457fdb3a469653e19002bd24742473254f8/grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2", size = 12731327, upload-time = "2025-09-26T09:03:36.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/57/89fd829fb00a6d0bee3fbcb2c8a7aa0252d908949b6ab58bfae99d39d77e/grpcio-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:1712b5890b22547dd29f3215c5788d8fc759ce6dd0b85a6ba6e2731f2d04c088", size = 5705534, upload-time = "2025-09-26T09:00:52.225Z" }, - { url = "https://files.pythonhosted.org/packages/76/dd/2f8536e092551cf804e96bcda79ecfbc51560b214a0f5b7ebc253f0d4664/grpcio-1.75.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d04e101bba4b55cea9954e4aa71c24153ba6182481b487ff376da28d4ba46cf", size = 11484103, upload-time = "2025-09-26T09:00:59.457Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3d/affe2fb897804c98d56361138e73786af8f4dd876b9d9851cfe6342b53c8/grpcio-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:683cfc70be0c1383449097cba637317e4737a357cfc185d887fd984206380403", size = 6289953, upload-time = "2025-09-26T09:01:03.699Z" }, - { url = "https://files.pythonhosted.org/packages/87/aa/0f40b7f47a0ff10d7e482bc3af22dac767c7ff27205915f08962d5ca87a2/grpcio-1.75.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:491444c081a54dcd5e6ada57314321ae526377f498d4aa09d975c3241c5b9e1c", size = 6949785, upload-time = "2025-09-26T09:01:07.504Z" }, - { url = "https://files.pythonhosted.org/packages/a5/45/b04407e44050781821c84f26df71b3f7bc469923f92f9f8bc27f1406dbcc/grpcio-1.75.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce08d4e112d0d38487c2b631ec8723deac9bc404e9c7b1011426af50a79999e4", size = 6465708, upload-time = "2025-09-26T09:01:11.028Z" }, - { url = "https://files.pythonhosted.org/packages/09/3e/4ae3ec0a4d20dcaafbb6e597defcde06399ccdc5b342f607323f3b47f0a3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a2acda37fc926ccc4547977ac3e56b1df48fe200de968e8c8421f6e3093df6c", size = 7100912, upload-time = "2025-09-26T09:01:14.393Z" }, - { url = "https://files.pythonhosted.org/packages/34/3f/a9085dab5c313bb0cb853f222d095e2477b9b8490a03634cdd8d19daa5c3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:745c5fe6bf05df6a04bf2d11552c7d867a2690759e7ab6b05c318a772739bd75", size = 8042497, upload-time = "2025-09-26T09:01:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/c3/87/ea54eba931ab9ed3f999ba95f5d8d01a20221b664725bab2fe93e3dee848/grpcio-1.75.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:259526a7159d39e2db40d566fe3e8f8e034d0fb2db5bf9c00e09aace655a4c2b", size = 7493284, upload-time = "2025-09-26T09:01:20.896Z" }, - { url = "https://files.pythonhosted.org/packages/b7/5e/287f1bf1a998f4ac46ef45d518de3b5da08b4e86c7cb5e1108cee30b0282/grpcio-1.75.1-cp310-cp310-win32.whl", hash = "sha256:f4b29b9aabe33fed5df0a85e5f13b09ff25e2c05bd5946d25270a8bd5682dac9", size = 3950809, upload-time = "2025-09-26T09:01:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/3cbfc06a4ec160dc77403b29ecb5cf76ae329eb63204fea6a7c715f1dfdb/grpcio-1.75.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf2e760978dcce7ff7d465cbc7e276c3157eedc4c27aa6de7b594c7a295d3d61", size = 4644704, upload-time = "2025-09-26T09:01:25.763Z" }, { url = "https://files.pythonhosted.org/packages/0c/3c/35ca9747473a306bfad0cee04504953f7098527cd112a4ab55c55af9e7bd/grpcio-1.75.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:573855ca2e58e35032aff30bfbd1ee103fbcf4472e4b28d4010757700918e326", size = 5709761, upload-time = "2025-09-26T09:01:28.528Z" }, { url = "https://files.pythonhosted.org/packages/c9/2c/ecbcb4241e4edbe85ac2663f885726fea0e947767401288b50d8fdcb9200/grpcio-1.75.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:6a4996a2c8accc37976dc142d5991adf60733e223e5c9a2219e157dc6a8fd3a2", size = 11496691, upload-time = "2025-09-26T09:01:31.214Z" }, { url = "https://files.pythonhosted.org/packages/81/40/bc07aee2911f0d426fa53fe636216100c31a8ea65a400894f280274cb023/grpcio-1.75.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b1ea1bbe77ecbc1be00af2769f4ae4a88ce93be57a4f3eebd91087898ed749f9", size = 6296084, upload-time = "2025-09-26T09:01:34.596Z" }, @@ -895,7 +874,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.9" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -903,96 +882,111 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -1019,12 +1013,10 @@ version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -1294,14 +1286,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] @@ -1332,6 +1324,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, + { name = "utic-invocation-settings" }, { name = "uvicorn" }, ] @@ -1359,6 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=feat%2Finvocation-settings-asgi" }, { name = "uvicorn" }, ] @@ -1384,6 +1378,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "utic-invocation-settings" +version = "0.4.0" +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=feat%2Finvocation-settings-asgi#323c46616b15f14a285b9768a9c29c2347c5701e" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, +] + [[package]] name = "uvicorn" version = "0.37.0" @@ -1391,7 +1394,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } wheels = [ @@ -1413,16 +1415,6 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, From a930c88f081f531f46ba0661ff81611cc71f0802 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:29:54 -0400 Subject: [PATCH 14/32] refactor(etl-uvicorn): simplify invocation failure plumbing --- CHANGELOG.md | 4 -- .../etl_uvicorn/api_generator.py | 38 +++++++++---------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 013b060..cd9c656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,10 +50,6 @@ fault answers 422. Sealing drift, an envelope addressed to another recipient and a broken local mount are all 5xx, which keeps the controller's blame classification off the customer. Responses carry the error's class name and never its message, which can embed request-controlled values. -* **Sync plugin functions now observe request-scoped context.** `invoke_func` copies the current - context into the executor thread; previously `run_in_executor` dropped contextvars, so a sync - function reading a request-scoped binding (such as `current_invocation_settings()`) would see - it as absent and could take an unintended fallback path. * **Python floor is now 3.11** (required by `utic-invocation-settings`). ## 0.0.46 diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 8fc6926..09ff2b5 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -100,6 +100,15 @@ def failure_category_of(error: BaseException) -> Optional[str]: return category if isinstance(category, str) else None +def blame_of(error: BaseException) -> Optional[str]: + """Return ``user`` only for failures in something the customer owns. + + An absent value means not-the-customer's: orchestrators must not infer customer fault from an + HTTP status code, which also carries transport semantics. + """ + return "user" if isinstance(error, UserError) else None + + def status_code_of(error: BaseException) -> int: """Return the error's status_code only when it is an int in the HTTP range, else 500.""" status_code = _error_attr(error, "status_code") @@ -205,8 +214,7 @@ def _wrap_in_fastapi( logger.warning("usage data not an expected parameter, omitting") fastapi_app = FastAPI() - # Installation contributes a public router dependency, so it must happen before /invoke is - # registered. The dependency itself is a no-op for every other route. + # Installation contributes a public router dependency, so it must happen before /invoke. install_invocation_envelope(fastapi_app) response_type = get_output_sig(func) @@ -219,10 +227,7 @@ class InvokeResponse(BaseModel): filedata_meta: Optional[filedata_meta_model] = None status_code_text: Optional[str] = None failure_category: Optional[str] = None - # Who must act on a failure: "user" only when the plugin raised the UserError family — - # a fault in something the customer owns (their file, their credentials, their provider). - # Absent means not-the-customer's: an orchestrator must never infer customer fault from - # the status code alone, which also carries transport semantics. + # Absent means not-the-customer's; see blame_of(). blame: Optional[str] = None output: Optional[response_type] = None message_channels: MessageChannels = Field(default_factory=MessageChannels) @@ -246,10 +251,11 @@ async def wrap_fn(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Re request_dict["message_channels"] = message_channels if "filedata_meta" in params: request_dict["filedata_meta"] = filedata_meta - bound_settings = current_invocation_settings() - bound_context = current_invocation_context() try: if inspect.isasyncgenfunction(func): + bound_settings = current_invocation_settings() + bound_context = current_invocation_context() + # Stream response if function is an async generator async def _stream_response(): # FastAPI 0.117 closes yield dependencies before iterating a @@ -285,17 +291,14 @@ async def _stream_response(): status_code=status_code_of(e), status_code_text=f"[{type(e).__name__}] {_safe_str(e)}", failure_category=failure_category_of(e), - blame="user" if isinstance(e, UserError) else None, + blame=blame_of(e), ).model_dump_json() + "\n" ) return StreamingResponse(_stream_response(), media_type="application/x-ndjson") else: - # Keep execution-scoped binding explicit for the same reason as the streaming - # branch; nested binding is harmless while the route dependency is still active. - with invocation_envelope(bound_settings, bound_context): - output = await invoke_func(func=func, kwargs=request_dict) + output = await invoke_func(func=func, kwargs=request_dict) return InvokeResponse( usage=usage, message_channels=message_channels, @@ -333,7 +336,7 @@ async def _stream_response(): status_code=status_code_of(exc), status_code_text=_safe_str(exc), failure_category=failure_category_of(exc), - blame="user" if isinstance(exc, UserError) else None, + blame=blame_of(exc), file_data=request_dict.get("file_data", None), ) except Exception as invoke_error: @@ -441,12 +444,7 @@ async def get_id() -> str: except TypeError as e: raise TypeError(f"failed to validate function schema: {e}") from e - # The route dependency handles the reserved /invoke fields (invocation_settings and - # invocation_context) outside the generated handler schema. It resolves sealed settings with - # the configured private key and exposes both values through request-scoped accessors. - # The sealed-settings capability remains opt-in because it asserts that the wrapped function - # consumes current_invocation_settings(), not merely that the host can resolve it. The binding - # dependency was installed before route registration; the last /metadata registration wins. + # Registered last so add_metadata_route replaces any /metadata the plugin registered itself. add_metadata_route( fastapi_app, identifier=plugin_id, From 1a7d1772893250c3ec7496bc7b48a14cb6980c57 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:06:07 -0400 Subject: [PATCH 15/32] refactor(etl-uvicorn): delegate field-atomic settings resolution --- CHANGELOG.md | 23 +- pyproject.toml | 6 +- test/api/test_invocation_envelope.py | 204 +++++------------- test/api/test_settings_scoped_cache.py | 2 +- .../invocation_settings.py | 43 ++-- uv.lock | 6 +- 6 files changed, 105 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9c656..202a5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ `unstructured_platform_plugins.invocation_settings` holds the `/invoke` binding dependency and body cap, the `/metadata` capability route, the request-scoped accessors, and `http_status_for` — the HTTP spelling of the - library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, - which owns the *settings contract* — which key carries settings, how a sealed envelope is told - from plaintext, and what an absent field is allowed to mean. That split is deliberate: the + library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.5.0`, which + owns the *settings contract* — including the field-atomic wire shape, independent sealed-field + resolution, and what an absent field is allowed to mean. That split is deliberate: the absence rule is a security decision and belongs next to the crypto it governs, while request handling and route registration belong here, where a web framework is already a dependency. Nothing about the sealed-settings wire format is decided in this repository. @@ -19,9 +19,9 @@ the same `reason`/`blame` machinery as settings failures. This module is the public home for the surface `utic-invocation-settings 0.2.x` carried and its `0.3.0` removed. * **Every wrapped app installs it at construction.** The reserved `invocation_settings` / - `invocation_context` fields are handled outside the generated handler schema, a sealed - `dag_node_settings` member is opened with this pod's mounted workload key, and the resolved - values are exposed through `current_invocation_settings()` / `current_invocation_context()`. + `invocation_context` fields are handled outside the generated handler schema, the opaque + settings payload is delegated to `utic-invocation-settings`, and only the final resolved mapping + is exposed through `current_invocation_settings()` / `current_invocation_context()`. An absent field preserves the existing fallback behaviour; under `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` missing or plaintext settings fail closed. Repeated installation is safe: the dependency installs once and the last `/metadata` @@ -38,14 +38,15 @@ * **Sealed settings consumption remains opt-in.** Pass `invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; - it advertises that the application accepts and acts on sealed `dag_node_settings`. Transport - support alone continues to advertise only `invocation_settings` and `invocation_context`. A + it advertises that the application accepts and acts on independently sealed settings fields. + Transport support alone continues to advertise only `invocation_settings` and + `invocation_context`. A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` (which replaces the wrapper's route) — a plain `@app.get("/metadata")` added after construction is shadowed by the wrapper's earlier registration. -* **Resolution runs off the event loop.** A cold resolve is an RSA unwrap of a couple of - milliseconds and this dependency fronts every invoke on the pod, so it is dispatched with - `asyncio.to_thread` rather than blocking the loop. +* **Resolution runs off the event loop.** Resolution may perform blocking cryptography for + independently sealed fields and this dependency fronts every invoke on the pod, so it is + dispatched with `asyncio.to_thread` rather than blocking the loop. * **Failures map through the library's blame taxonomy**, not a flat 500: only a caller-fixable fault answers 422. Sealing drift, an envelope addressed to another recipient and a broken local mount are all 5xx, which keeps the controller's blame classification off the customer. Responses diff --git a/pyproject.toml b/pyproject.toml index 310a1f8..3ca55a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "fastapi", "click", "unstructured-ingest", - "utic-invocation-settings>=0.4.0,<1.0.0", + "utic-invocation-settings>=0.5.0,<1.0.0", "opentelemetry-instrumentation-fastapi", "opentelemetry-exporter-otlp-proto-grpc", "dataclasses-json" @@ -88,5 +88,5 @@ fail_under = 15 packages = ["/unstructured_platform_plugins"] [tool.uv.sources] -# Temporary dev pin until utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "feat/invocation-settings-asgi" } +# Temporary dev pin until field-atomic utic-invocation-settings 0.5.0 is published. +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "7629dca8f8983f5af6bd76f7bd1442119f79c803" } diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index a2c880a..b30b555 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -9,23 +9,15 @@ from __future__ import annotations import asyncio -import json -from base64 import b64decode, b64encode +from threading import get_ident from typing import Optional import pytest -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import FastAPI, Request from fastapi.testclient import TestClient -from utic_invocation_settings import ( - DAG_NODE_SETTINGS_KEY, - REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, - default_resolver, - reset_workload_identity_cache, -) -from utic_invocation_settings.crypto import seal_settings +from utic_invocation_settings import Blame +import unstructured_platform_plugins.invocation_settings as invocation_settings_transport from unstructured_platform_plugins.invocation_settings import ( InvokeBodyLimitMiddleware, add_metadata_route, @@ -34,58 +26,6 @@ install_invocation_envelope, ) -SENTINEL_SECRET = "sealed-settings-sentinel-secret" - - -@pytest.fixture(scope="session") -def private_key() -> rsa.RSAPrivateKey: - return rsa.generate_private_key(public_exponent=65537, key_size=3072) - - -@pytest.fixture(autouse=True) -def isolated_identity(monkeypatch): - """The identity memo and the resolver caches both outlive a test; an inherited env var or a - stale entry would make these order-dependent.""" - for var in ( - "WORKLOAD_IDENTITY_DIR", - "INVOCATION_SETTINGS_KEY_DIR", - REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, - ): - monkeypatch.delenv(var, raising=False) - reset_workload_identity_cache() - default_resolver().clear_caches() - yield - reset_workload_identity_cache() - default_resolver().clear_caches() - - -@pytest.fixture -def key_dir(tmp_path, private_key, monkeypatch, isolated_identity): - (tmp_path / "tls.key").write_bytes( - private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path)) - reset_workload_identity_cache() - return tmp_path - - -def sealed_payload(private_key: rsa.RSAPrivateKey, settings: dict) -> dict: - return seal_settings(settings, private_key.public_key()).model_dump( - mode="json", exclude_none=True - ) - - -def tampered(sealed: dict) -> dict: - ciphertext = bytearray(b64decode(sealed["content_encryption"]["ciphertext"])) - ciphertext[0] ^= 0x01 - sealed["content_encryption"]["ciphertext"] = b64encode(bytes(ciphertext)).decode() - return sealed - - BASE_CAPABILITIES = [ "invocation_settings", "invocation_context", @@ -155,6 +95,7 @@ class _Recorder: def __init__(self): self.called = False + self.handler_thread = None self.seen_settings = "unset" self.seen_context = "unset" @@ -171,6 +112,7 @@ def _envelope_app(recorder: _Recorder, max_body_bytes: Optional[int] = None) -> @app.post("/invoke") async def invoke(request: Request) -> dict: recorder.called = True + recorder.handler_thread = get_ident() recorder.seen_settings = current_invocation_settings() recorder.seen_context = current_invocation_context() return {} @@ -461,113 +403,85 @@ def test_non_invoke_requests_are_not_capped(self): assert sent[0]["status"] == 200 -class TestEnvelopeResolution: - """Sealed payloads through the binding dependency. The HTTP class comes from the library's - blame taxonomy, so only a caller-fixable fault is a 422.""" +class _ResolutionFailure(Exception): + reason = "test_resolution_failure" - def test_bare_sealed_envelope_is_rejected(self, key_dir, private_key): - sealed = sealed_payload(private_key, {"api_key": SENTINEL_SECRET}) + def __init__(self, blame: Blame, secret: str = ""): + super().__init__(secret) + self.blame = blame - recorder, response = _post_invoke({"invocation_settings": sealed}) - assert response.status_code == 500 - assert "MalformedDagNodeSettingsError" in response.json()["detail"] - assert not recorder.called +class TestSettingsResolutionBoundary: + """The library owns settings shape and crypto; this package owns delivery and HTTP mapping.""" - def test_composite_dag_node_settings_member_is_opened(self, key_dir, private_key): - settings = {"api_key": SENTINEL_SECRET, "max_characters": 700} - composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} + def test_opaque_payload_is_delegated_and_final_mapping_is_bound(self, monkeypatch): + opaque_payload = {"contract-owned": {"content": "opaque-to-transport"}} + resolved = {"api_key": "resolved-secret", "max_characters": 700} + seen = [] - recorder, response = _post_invoke({"invocation_settings": composite}) + def resolve(payload): + seen.append(payload) + return resolved - assert response.status_code == 200 - assert recorder.seen_settings == settings + monkeypatch.setattr( + invocation_settings_transport._invocation_settings_contract, + "resolve_invocation_settings", + resolve, + ) - def test_plain_dict_settings_pass_through(self): - recorder, response = _post_invoke({"invocation_settings": {"model": "m"}}) + recorder, response = _post_invoke({"invocation_settings": opaque_payload}) assert response.status_code == 200 - assert recorder.seen_settings == {"model": "m"} - - def test_non_envelope_member_fails_as_platform_error(self, key_dir): - composite = {DAG_NODE_SETTINGS_KEY: {"model": "m"}} - - recorder, response = _post_invoke({"invocation_settings": composite}) - - assert response.status_code == 500 - assert "MalformedDagNodeSettingsError" in response.json()["detail"] - assert not recorder.called - - def test_undecryptable_envelope_fails_without_leaking_the_secret( - self, key_dir, private_key, caplog - ): - sealed = tampered(sealed_payload(private_key, {"api_key": SENTINEL_SECRET})) - composite = {DAG_NODE_SETTINGS_KEY: sealed} - - recorder, response = _post_invoke({"invocation_settings": composite}) - - assert response.status_code == 500 - detail = response.json()["detail"] - assert "DecryptionError" in detail - assert SENTINEL_SECRET not in caplog.text - assert SENTINEL_SECRET not in detail - assert not recorder.called - - def test_unmounted_identity_fails_as_platform_error(self, tmp_path, monkeypatch, private_key): - monkeypatch.setenv("WORKLOAD_IDENTITY_DIR", str(tmp_path / "missing")) - sealed = sealed_payload(private_key, {"api_key": SENTINEL_SECRET}) - composite = {DAG_NODE_SETTINGS_KEY: sealed} + assert seen == [opaque_payload] + assert recorder.seen_settings == resolved - recorder, response = _post_invoke({"invocation_settings": composite}) + def test_resolution_runs_off_the_event_loop(self, monkeypatch): + resolver_threads = [] - assert response.status_code == 500 - assert "IdentityNotMountedError" in response.json()["detail"] - assert not recorder.called + def resolve(_payload): + resolver_threads.append(get_ident()) + return {"model": "m"} + monkeypatch.setattr(invocation_settings_transport, "_resolve_invocation_settings", resolve) -class TestRequireSealedDagNodeSettings: - """A native pod's operator sets FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS in the same - decision that drops the init-secrets sidecar: without it, an invoke that arrived with no - envelope would fall back to a settings file that was never written.""" + recorder, response = _post_invoke({"invocation_settings": {"opaque": "payload"}}) - @pytest.fixture(autouse=True) - def _require_sealed(self, monkeypatch): - monkeypatch.setenv(REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_ENV_VAR, "true") + assert response.status_code == 200 + assert recorder.called + assert len(resolver_threads) == 1 + assert resolver_threads[0] != recorder.handler_thread - def test_missing_settings_fail_as_platform_error(self): - recorder, response = _post_invoke({"element_dicts": "/tmp/x.json"}) + @pytest.mark.parametrize( + ("blame", "expected_status"), + [(Blame.CALLER, 422), (Blame.RECIPIENT, 500)], + ) + def test_resolution_failure_uses_blame_for_http_status( + self, monkeypatch, blame, expected_status + ): + def fail(_payload): + raise _ResolutionFailure(blame) - assert response.status_code == 500 - assert "SealedDagNodeSettingsRequiredError" in response.json()["detail"] - assert not recorder.called + monkeypatch.setattr(invocation_settings_transport, "_resolve_invocation_settings", fail) - def test_plaintext_settings_fail_as_platform_error(self): - recorder, response = _post_invoke({"invocation_settings": {"model": "m"}}) + recorder, response = _post_invoke({"invocation_settings": {"opaque": "payload"}}) - assert response.status_code == 500 + assert response.status_code == expected_status + assert response.json()["reason"] == _ResolutionFailure.reason assert not recorder.called - def test_sealed_settings_still_bind(self, key_dir, private_key): - settings = {"api_key": SENTINEL_SECRET} - composite = {DAG_NODE_SETTINGS_KEY: sealed_payload(private_key, settings)} + def test_resolution_failure_never_exposes_exception_message(self, monkeypatch, caplog): + secret = "sealed-settings-sentinel-secret" - recorder, response = _post_invoke({"invocation_settings": composite}) + def fail(_payload): + raise _ResolutionFailure(Blame.RECIPIENT, secret) - assert response.status_code == 200 - assert recorder.seen_settings == settings - - def test_bodyless_invoke_fails_as_platform_error(self): - # A bodyless invoke cannot carry the envelope; on a pod with no boot settings file it must - # not dispatch a handler with no settings source at all. - recorder, response = _post_invoke(b"") - - assert response.status_code == 500 - assert not recorder.called + monkeypatch.setattr(invocation_settings_transport, "_resolve_invocation_settings", fail) - def test_non_object_json_body_fails_as_platform_error(self): - recorder, response = _post_invoke(json.dumps([{"element": 1}]).encode()) + recorder, response = _post_invoke({"invocation_settings": {"opaque": "payload"}}) assert response.status_code == 500 + assert secret not in caplog.text + assert secret not in response.json()["detail"] assert not recorder.called diff --git a/test/api/test_settings_scoped_cache.py b/test/api/test_settings_scoped_cache.py index 4820ebf..092c7a0 100644 --- a/test/api/test_settings_scoped_cache.py +++ b/test/api/test_settings_scoped_cache.py @@ -32,7 +32,7 @@ def test_second_lookup_with_same_settings_does_not_rebuild(self): assert first == second == "handler" build.assert_called_once() - def test_distinct_settings_build_distinct_values(self): + def test_change_in_resolved_field_builds_a_distinct_value(self): cache = SettingsScopedCache() first = cache.get_or_build({"model": "a"}, lambda: object()) diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 680415f..6d06ed6 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -1,9 +1,9 @@ """Transport for the reserved `/invoke` fields: request dependency, `/metadata`, body cap. -The *settings contract* — which key carries settings, how a sealed envelope is told from -plaintext, and what an absent field is allowed to mean — lives in -`utic_invocation_settings.invoke`, next to the crypto it governs; every decision about a settings -payload is delegated there. The *identity contract* — the `invocation_context` model — is +The *settings contract* — including the field-atomic wire shape, sealed-field resolution, and what +an absent field is allowed to mean — lives in `utic_invocation_settings`, next to the crypto it +governs; every decision about a settings payload is delegated there. The *identity contract* — +the `invocation_context` model — is `/invoke` protocol rather than settings security and lives in this package's `invocation_context` module. This module is the delivery mechanism for both: getting the payloads off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP @@ -36,6 +36,7 @@ from contextvars import ContextVar from typing import Any, Optional, TypeVar +import utic_invocation_settings as _invocation_settings_contract from fastapi import Depends, FastAPI, Request from starlette.requests import ClientDisconnect from starlette.responses import JSONResponse @@ -47,7 +48,6 @@ Blame, InvocationSettingsError, MalformedEnvelopeError, - resolve_invocation_settings, ) from unstructured_platform_plugins.invocation_context import ( @@ -101,6 +101,15 @@ def current_invocation_context() -> Optional[InvocationContext]: return _INVOCATION.get()[1] +def _resolve_invocation_settings(payload: Any) -> Optional[dict[str, Any]]: + """Resolve a contract-owned payload to the ordinary mapping bound to a plugin. + + This deliberately thin call is the only integration point with the settings wire contract. + The transport does not inspect ``dag_node_settings`` or any field envelope within it. + """ + return _invocation_settings_contract.resolve_invocation_settings(payload) + + @contextmanager def invocation_envelope( invocation_settings: Optional[dict], invocation_context: Optional[InvocationContext] @@ -127,7 +136,7 @@ def add_metadata_route( `invocation_settings` and `invocation_context` are transport capabilities: installing the dependency makes the host receive, resolve, and bind those fields. The sealed-settings capability is stronger: it tells the controller that the plugin handler consumes the resolved - `dag_node_settings` in place of boot-time state, so it remains an explicit opt-in. + field-atomic settings in place of boot-time state, so it remains an explicit opt-in. Last call wins: the payload lives on `app.state` and every call overwrites it, while the route is registered once. A host wrapper may register at app construction and a plugin can still @@ -214,9 +223,9 @@ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: }, ) try: - # Off the event loop: a cold resolve is an RSA unwrap of a couple of milliseconds, and - # this dependency fronts every invoke on the pod. - invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings) + # Off the event loop: resolution may perform blocking cryptography for independently + # sealed fields, and this dependency fronts every invoke on the pod. + invocation_settings = await asyncio.to_thread(_resolve_invocation_settings, raw_settings) except Exception as exc: # Class name only — never envelope contents, and never the exception's own message, # which can embed request-controlled values. @@ -368,19 +377,21 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_B def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str: - """Digest of the canonical settings JSON, safe as a cache key for secret-bearing payloads.""" + """Digest of an ordinary resolved settings mapping, safe for secret-bearing values.""" return hashlib.sha256(json.dumps(invocation_settings, sort_keys=True).encode()).hexdigest() class SettingsScopedCache: """Bind expensive derived state (clients, models, handlers) to the settings that built it. - A plugin consuming ``current_invocation_settings()`` builds its handler per distinct settings - payload instead of once at boot, and construction typically does network work (model - resolution, prechecks), so results are memoized keyed by ``settings_cache_key``. Both bounds - matter under shared tenancy: size caps how many distinct payloads stay live, and age evicts - state built from credentials that may since have been rotated — eviction driven only by the - count of distinct payloads can take arbitrarily long on a quiet pod. + A plugin consuming ``current_invocation_settings()`` builds its handler per distinct resolved + mapping instead of once at boot, and construction typically does network work (model + resolution, prechecks), so results are memoized keyed by ``settings_cache_key``. Raw field + envelopes never reach this cache: the public settings library resolves and caches them at the + field boundary before this transport binds the result. Both bounds matter under shared + tenancy: size caps how many distinct mappings stay live, and age evicts state built from + credentials that may since have been rotated — eviction driven only by the count of distinct + mappings can take arbitrarily long on a quiet pod. Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice; the extra build is wasted work, never wrong state. diff --git a/uv.lock b/uv.lock index cf4c661..c22a927 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=feat%2Finvocation-settings-asgi" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=7629dca8f8983f5af6bd76f7bd1442119f79c803" }, { name = "uvicorn" }, ] @@ -1380,8 +1380,8 @@ wheels = [ [[package]] name = "utic-invocation-settings" -version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=feat%2Finvocation-settings-asgi#323c46616b15f14a285b9768a9c29c2347c5701e" } +version = "0.5.0" +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=7629dca8f8983f5af6bd76f7bd1442119f79c803#7629dca8f8983f5af6bd76f7bd1442119f79c803" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From 292b3ede19ac7c3b7f73f052096647611e689e09 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:52:45 -0400 Subject: [PATCH 16/32] refactor(etl-uvicorn): simplify settings resolver integration --- test/api/test_invocation_envelope.py | 10 ++++------ .../invocation_settings.py | 13 ++----------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index b30b555..dd24a05 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -424,9 +424,7 @@ def resolve(payload): return resolved monkeypatch.setattr( - invocation_settings_transport._invocation_settings_contract, - "resolve_invocation_settings", - resolve, + invocation_settings_transport, "resolve_invocation_settings", resolve ) recorder, response = _post_invoke({"invocation_settings": opaque_payload}) @@ -442,7 +440,7 @@ def resolve(_payload): resolver_threads.append(get_ident()) return {"model": "m"} - monkeypatch.setattr(invocation_settings_transport, "_resolve_invocation_settings", resolve) + monkeypatch.setattr(invocation_settings_transport, "resolve_invocation_settings", resolve) recorder, response = _post_invoke({"invocation_settings": {"opaque": "payload"}}) @@ -461,7 +459,7 @@ def test_resolution_failure_uses_blame_for_http_status( def fail(_payload): raise _ResolutionFailure(blame) - monkeypatch.setattr(invocation_settings_transport, "_resolve_invocation_settings", fail) + monkeypatch.setattr(invocation_settings_transport, "resolve_invocation_settings", fail) recorder, response = _post_invoke({"invocation_settings": {"opaque": "payload"}}) @@ -475,7 +473,7 @@ def test_resolution_failure_never_exposes_exception_message(self, monkeypatch, c def fail(_payload): raise _ResolutionFailure(Blame.RECIPIENT, secret) - monkeypatch.setattr(invocation_settings_transport, "_resolve_invocation_settings", fail) + monkeypatch.setattr(invocation_settings_transport, "resolve_invocation_settings", fail) recorder, response = _post_invoke({"invocation_settings": {"opaque": "payload"}}) diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 6d06ed6..c460655 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -36,7 +36,6 @@ from contextvars import ContextVar from typing import Any, Optional, TypeVar -import utic_invocation_settings as _invocation_settings_contract from fastapi import Depends, FastAPI, Request from starlette.requests import ClientDisconnect from starlette.responses import JSONResponse @@ -48,6 +47,7 @@ Blame, InvocationSettingsError, MalformedEnvelopeError, + resolve_invocation_settings, ) from unstructured_platform_plugins.invocation_context import ( @@ -101,15 +101,6 @@ def current_invocation_context() -> Optional[InvocationContext]: return _INVOCATION.get()[1] -def _resolve_invocation_settings(payload: Any) -> Optional[dict[str, Any]]: - """Resolve a contract-owned payload to the ordinary mapping bound to a plugin. - - This deliberately thin call is the only integration point with the settings wire contract. - The transport does not inspect ``dag_node_settings`` or any field envelope within it. - """ - return _invocation_settings_contract.resolve_invocation_settings(payload) - - @contextmanager def invocation_envelope( invocation_settings: Optional[dict], invocation_context: Optional[InvocationContext] @@ -225,7 +216,7 @@ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: try: # Off the event loop: resolution may perform blocking cryptography for independently # sealed fields, and this dependency fronts every invoke on the pod. - invocation_settings = await asyncio.to_thread(_resolve_invocation_settings, raw_settings) + invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings) except Exception as exc: # Class name only — never envelope contents, and never the exception's own message, # which can embed request-controlled values. From 42fdb2964c2bee09b341d8ca1d8c8e90c4b74b44 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 26 Aug 2026 16:10:15 -0400 Subject: [PATCH 17/32] fix(etl-uvicorn): target invocation settings 0.4.0 --- CHANGELOG.md | 2 +- pyproject.toml | 6 +++--- uv.lock | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 202a5f7..b6a9cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ `unstructured_platform_plugins.invocation_settings` holds the `/invoke` binding dependency and body cap, the `/metadata` capability route, the request-scoped accessors, and `http_status_for` — the HTTP spelling of the - library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.5.0`, which + library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, which owns the *settings contract* — including the field-atomic wire shape, independent sealed-field resolution, and what an absent field is allowed to mean. That split is deliberate: the absence rule is a security decision and belongs next to the crypto it governs, while request diff --git a/pyproject.toml b/pyproject.toml index 3ca55a4..15f82d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "fastapi", "click", "unstructured-ingest", - "utic-invocation-settings>=0.5.0,<1.0.0", + "utic-invocation-settings>=0.4.0,<1.0.0", "opentelemetry-instrumentation-fastapi", "opentelemetry-exporter-otlp-proto-grpc", "dataclasses-json" @@ -88,5 +88,5 @@ fail_under = 15 packages = ["/unstructured_platform_plugins"] [tool.uv.sources] -# Temporary dev pin until field-atomic utic-invocation-settings 0.5.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "7629dca8f8983f5af6bd76f7bd1442119f79c803" } +# Temporary source pin until field-atomic utic-invocation-settings 0.4.0 is published. +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "1eb2fb77c6e54947600720260e59d446126b35da" } diff --git a/uv.lock b/uv.lock index c22a927..9b01d25 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=7629dca8f8983f5af6bd76f7bd1442119f79c803" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=1eb2fb77c6e54947600720260e59d446126b35da" }, { name = "uvicorn" }, ] @@ -1380,8 +1380,8 @@ wheels = [ [[package]] name = "utic-invocation-settings" -version = "0.5.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=7629dca8f8983f5af6bd76f7bd1442119f79c803#7629dca8f8983f5af6bd76f7bd1442119f79c803" } +version = "0.4.0" +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=1eb2fb77c6e54947600720260e59d446126b35da#1eb2fb77c6e54947600720260e59d446126b35da" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From f88e2b683a685c9925afce18eb5ec096b3f1d9bd Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 26 Aug 2026 20:12:40 -0400 Subject: [PATCH 18/32] docs(invocation-settings): make contract guidance timeless --- test/api/test_invocation_envelope.py | 6 +-- .../invocation_context.py | 39 +++++++++---------- .../invocation_settings.py | 24 ++++++------ 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index dd24a05..1f7e825 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -182,9 +182,9 @@ def test_non_dict_reserved_field_is_rejected(self): assert body["reason"] == "malformed_envelope" def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self): - # Absence means "older caller, use the boot settings"; a context this plugin cannot read - # must not be downgraded to that. And it is deployment skew, not a caller fault — a 422 - # would let an upstream blame classifier pin version skew on the customer. + # An absent context permits boot-state behavior; an unreadable context must not be + # downgraded to absence. It is deployment skew, not a caller fault — a 422 would let an + # upstream blame classifier pin version skew on the customer. recorder, response = _post_invoke({"invocation_context": {"schema_version": "99"}}) assert not recorder.called diff --git a/unstructured_platform_plugins/invocation_context.py b/unstructured_platform_plugins/invocation_context.py index 2c456a6..0d6ed6d 100644 --- a/unstructured_platform_plugins/invocation_context.py +++ b/unstructured_platform_plugins/invocation_context.py @@ -11,11 +11,11 @@ same ``reason``/``blame`` machinery as settings failures. The model below is the **consumer** view of that contract, deliberately lenient: unknown keys are -preserved so a newer producer does not break an older plugin, and every identity field is optional -so a partially-populated context degrades to "less telemetry" rather than a failed invoke. The one -thing it is strict about is ``schema_version`` — that field exists to make an incompatible producer -detectable, which it can only do if somebody actually reads it. The payload is what carries the -version, not the route, so evolving the contract does not mean adding endpoints. +preserved for additive forward compatibility, and every identity field is optional so a +partially-populated context degrades to "less telemetry" rather than a failed invoke. The one thing +it is strict about is ``schema_version`` — that field makes incompatible producer and consumer +contracts detectable. The payload carries the version, so evolving the contract does not require +adding endpoints. """ from __future__ import annotations @@ -28,8 +28,8 @@ # Reserved key carrying the invocation context in the invoke request body. RESERVED_CONTEXT_KEY = "invocation_context" -# Context payload versions this package understands. Additive keys do not bump this; a change that -# would make an old consumer misread an existing key does. +# Context payload versions this package understands. Additive keys do not bump this; changing an +# existing key's meaning incompatibly does. SUPPORTED_CONTEXT_VERSIONS = frozenset({"1"}) # The identity facets that become telemetry dimensions. Shared rather than per-service policy: @@ -57,11 +57,11 @@ class UnsupportedContextVersionError(InvocationSettingsError): """The ``invocation_context`` declares a ``schema_version`` this package does not understand. - A producer upgrade this consumer cannot follow — deployment skew between platform components, - not a fault in the request. ``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced - by the platform's own claim pipeline, and a 422 would make an upstream blame classifier pin a - version-skew failure on the customer. Loud at the first request rather than silently absent - telemetry dimensions later. + This indicates deployment skew between platform components, not a fault in the request. + ``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced by the platform's own claim + pipeline, and a 422 would make an upstream blame classifier pin a version-skew failure on the + customer. Failing the request prevents an unreadable context from silently removing telemetry + dimensions. """ reason = "unsupported_context_version" @@ -71,8 +71,8 @@ class UnsupportedContextVersionError(InvocationSettingsError): class InvocationContext(pydantic.BaseModel): """Request-scoped identity delivered alongside one claimed unit of work. - ``extra="allow"`` keeps forward compatibility: fields added by a newer producer survive round - trips and stay reachable via ``model_extra`` instead of being silently dropped. + ``extra="allow"`` keeps additive fields available through ``model_extra`` instead of silently + dropping them. """ model_config = pydantic.ConfigDict(extra="allow") @@ -113,14 +113,13 @@ def _known_version(cls, value: str) -> str: def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None: """Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``. - Returns ``None`` only when the reserved key is **absent** — the transitional signal that the - caller is an older controller. A present-but-invalid value fails closed rather than degrading - to "no context", because a context that silently vanishes takes a pod's tenant attribution with - it. + Returns ``None`` only when the producer omitted the reserved key. A present-but-invalid value + fails closed rather than degrading to "no context", because a context that silently vanishes + takes a pod's tenant attribution with it. A recognizable context carrying an unknown ``schema_version`` raises - :class:`UnsupportedContextVersionError` so a producer upgrade is loud at the first request - instead of showing up later as absent telemetry dimensions. + :class:`UnsupportedContextVersionError` so an incompatible contract cannot be mistaken for + absent context. """ raw = payload.get(RESERVED_CONTEXT_KEY, _ABSENT) if raw is _ABSENT: diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index c460655..ee54bf3 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -338,11 +338,10 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_B inherit it without private dependency-graph mutation. Calling after routes have already been registered raises rather than silently leaving those routes uncovered. - Idempotent per app: a host wrapper may install at app construction while a plugin that - predates the wrapper's support still calls this itself, and a double install would resolve - settings twice per request. A repeated call asking for a different ``max_body_bytes`` raises, - because the cap already installed cannot be changed and silently keeping the first value would - misrepresent the limit actually enforced. + Idempotent per app because both host-wrapper and plugin setup may call this function, while a + double installation would resolve settings twice per request. A repeated call asking for a + different ``max_body_bytes`` raises because the installed cap cannot be changed and silently + keeping the first value would misrepresent the limit actually enforced. """ if getattr(app.state, "invocation_envelope_installed", False): installed_max = app.state.invocation_envelope_max_body_bytes @@ -375,14 +374,13 @@ def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str: class SettingsScopedCache: """Bind expensive derived state (clients, models, handlers) to the settings that built it. - A plugin consuming ``current_invocation_settings()`` builds its handler per distinct resolved - mapping instead of once at boot, and construction typically does network work (model - resolution, prechecks), so results are memoized keyed by ``settings_cache_key``. Raw field - envelopes never reach this cache: the public settings library resolves and caches them at the - field boundary before this transport binds the result. Both bounds matter under shared - tenancy: size caps how many distinct mappings stay live, and age evicts state built from - credentials that may since have been rotated — eviction driven only by the count of distinct - mappings can take arbitrarily long on a quiet pod. + A plugin consuming ``current_invocation_settings()`` derives a handler from each distinct + resolved mapping. Construction typically performs network work (model resolution, prechecks), + so results are memoized by ``settings_cache_key``. Raw field envelopes never reach this cache: + the public settings library resolves and caches them at the field boundary before this + transport binds the result. Both bounds matter under shared tenancy: size caps how many + distinct mappings stay live, and age evicts state after credential rotation. Eviction driven + only by the count of distinct mappings can take arbitrarily long on a quiet pod. Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice; the extra build is wasted work, never wrong state. From cc11cfe795eedff32e46e763d7dd88b1615db025 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 26 Aug 2026 20:14:04 -0400 Subject: [PATCH 19/32] build(deps): advance invocation settings pin --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 15f82d6..54da170 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,4 +89,4 @@ fail_under = 15 [tool.uv.sources] # Temporary source pin until field-atomic utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "1eb2fb77c6e54947600720260e59d446126b35da" } +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "733a15dd89a1696d3ac646e694f322ea06bc1eaf" } diff --git a/uv.lock b/uv.lock index 9b01d25..d5e2da8 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=1eb2fb77c6e54947600720260e59d446126b35da" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=733a15dd89a1696d3ac646e694f322ea06bc1eaf" }, { name = "uvicorn" }, ] @@ -1381,7 +1381,7 @@ wheels = [ [[package]] name = "utic-invocation-settings" version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=1eb2fb77c6e54947600720260e59d446126b35da#1eb2fb77c6e54947600720260e59d446126b35da" } +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=733a15dd89a1696d3ac646e694f322ea06bc1eaf#733a15dd89a1696d3ac646e694f322ea06bc1eaf" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From 3f067008bbb6101c4dc7610cf605f1dde7e5ed27 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 26 Aug 2026 21:14:33 -0400 Subject: [PATCH 20/32] feat(invocation-settings): require field-set transport --- CHANGELOG.md | 7 ++--- pyproject.toml | 2 +- test/api/test_invocation_envelope.py | 26 ++++++++++++++++--- .../invocation_settings.py | 10 ++++--- uv.lock | 4 +-- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a9cc8..198d12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ body cap, the `/metadata` capability route, the request-scoped accessors, and `http_status_for` — the HTTP spelling of the library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, which - owns the *settings contract* — including the field-atomic wire shape, independent sealed-field - resolution, and what an absent field is allowed to mean. That split is deliberate: the + owns the *settings contract* — including the field-set carrier as the only accepted sealed + `/invoke` shape, independent sealed-field resolution, and what an absent field is allowed to + mean. That split is deliberate: the absence rule is a security decision and belongs next to the crypto it governs, while request handling and route registration belong here, where a web framework is already a dependency. Nothing about the sealed-settings wire format is decided in this repository. @@ -38,7 +39,7 @@ * **Sealed settings consumption remains opt-in.** Pass `invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; - it advertises that the application accepts and acts on independently sealed settings fields. + it advertises that the application accepts and acts on the versioned field-set carrier. Transport support alone continues to advertise only `invocation_settings` and `invocation_context`. A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` (which diff --git a/pyproject.toml b/pyproject.toml index 54da170..f46cec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,4 +89,4 @@ fail_under = 15 [tool.uv.sources] # Temporary source pin until field-atomic utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "733a15dd89a1696d3ac646e694f322ea06bc1eaf" } +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "814595faf94b424267b569c92211c093917988c4" } diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index 1f7e825..e271647 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -15,7 +15,7 @@ import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient -from utic_invocation_settings import Blame +from utic_invocation_settings import FIELD_SET_FIELDS_KEY, FIELD_SET_FORMAT, Blame import unstructured_platform_plugins.invocation_settings as invocation_settings_transport from unstructured_platform_plugins.invocation_settings import ( @@ -414,8 +414,15 @@ def __init__(self, blame: Blame, secret: str = ""): class TestSettingsResolutionBoundary: """The library owns settings shape and crypto; this package owns delivery and HTTP mapping.""" - def test_opaque_payload_is_delegated_and_final_mapping_is_bound(self, monkeypatch): - opaque_payload = {"contract-owned": {"content": "opaque-to-transport"}} + def test_field_set_payload_is_delegated_and_final_mapping_is_bound(self, monkeypatch): + opaque_payload = { + "dag_node_settings": { + "format": FIELD_SET_FORMAT, + FIELD_SET_FIELDS_KEY: { + "api_key": {"format": "u10d.invocation-settings.v1", "opaque": "member"} + }, + } + } resolved = {"api_key": "resolved-secret", "max_characters": 700} seen = [] @@ -433,6 +440,19 @@ def resolve(payload): assert seen == [opaque_payload] assert recorder.seen_settings == resolved + def test_empty_field_set_is_resolved_and_bound(self): + field_set_payload = { + "dag_node_settings": { + "format": FIELD_SET_FORMAT, + FIELD_SET_FIELDS_KEY: {}, + } + } + + recorder, response = _post_invoke({"invocation_settings": field_set_payload}) + + assert response.status_code == 200 + assert recorder.seen_settings == {} + def test_resolution_runs_off_the_event_loop(self, monkeypatch): resolver_threads = [] diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index ee54bf3..8db9fbd 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -1,8 +1,9 @@ """Transport for the reserved `/invoke` fields: request dependency, `/metadata`, body cap. -The *settings contract* — including the field-atomic wire shape, sealed-field resolution, and what -an absent field is allowed to mean — lives in `utic_invocation_settings`, next to the crypto it -governs; every decision about a settings payload is delegated there. The *identity contract* — +The *settings contract* — including the only accepted sealed `/invoke` shape (the field-set +carrier), sealed-field resolution, and what an absent field is allowed to mean — lives in +`utic_invocation_settings`, next to the crypto it governs; every decision about a settings payload +is delegated there. The *identity contract* — the `invocation_context` model — is `/invoke` protocol rather than settings security and lives in this package's `invocation_context` module. This module is the delivery mechanism for both: getting the payloads @@ -127,7 +128,8 @@ def add_metadata_route( `invocation_settings` and `invocation_context` are transport capabilities: installing the dependency makes the host receive, resolve, and bind those fields. The sealed-settings capability is stronger: it tells the controller that the plugin handler consumes the resolved - field-atomic settings in place of boot-time state, so it remains an explicit opt-in. + field-atomic settings in place of boot-time state. The controller may therefore send the + versioned field-set carrier, so this remains an explicit opt-in. Last call wins: the payload lives on `app.state` and every call overwrites it, while the route is registered once. A host wrapper may register at app construction and a plugin can still diff --git a/uv.lock b/uv.lock index d5e2da8..c967fe8 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=733a15dd89a1696d3ac646e694f322ea06bc1eaf" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=814595faf94b424267b569c92211c093917988c4" }, { name = "uvicorn" }, ] @@ -1381,7 +1381,7 @@ wheels = [ [[package]] name = "utic-invocation-settings" version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=733a15dd89a1696d3ac646e694f322ea06bc1eaf#733a15dd89a1696d3ac646e694f322ea06bc1eaf" } +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=814595faf94b424267b569c92211c093917988c4#814595faf94b424267b569c92211c093917988c4" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From 72371c4910cd08bfcc2b35197c809fdc144f1420 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Thu, 27 Aug 2026 10:58:14 -0400 Subject: [PATCH 21/32] feat(etl-uvicorn): resolve boot-or-scoped handlers on the settings cache --- test/api/test_settings_scoped_cache.py | 29 +++++++++++++++++++ .../invocation_settings.py | 22 ++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/test/api/test_settings_scoped_cache.py b/test/api/test_settings_scoped_cache.py index 092c7a0..cd367e6 100644 --- a/test/api/test_settings_scoped_cache.py +++ b/test/api/test_settings_scoped_cache.py @@ -92,3 +92,32 @@ def test_clear_forces_rebuild(self): def test_degenerate_bounds_are_rejected(self, kwargs): with pytest.raises(ValueError): SettingsScopedCache(**kwargs) + + +class TestHandlerFor: + def test_absent_settings_return_the_boot_handler(self): + cache = SettingsScopedCache() + build = MagicMock() + + handler = cache.handler_for(None, boot=lambda: "boot-handler", build=build) + + assert handler == "boot-handler" + build.assert_not_called() + + def test_absent_settings_without_a_boot_handler_fail(self): + cache = SettingsScopedCache() + + with pytest.raises(ValueError): + cache.handler_for(None, boot=lambda: None, build=MagicMock()) + + def test_resolved_settings_build_and_cache_per_distinct_mapping(self): + cache = SettingsScopedCache() + boot = MagicMock() + build = MagicMock(return_value="handler") + + first = cache.handler_for({"model": "a"}, boot=boot, build=build) + second = cache.handler_for({"model": "a"}, boot=boot, build=build) + + assert first == second == "handler" + build.assert_called_once() + boot.assert_not_called() diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 8db9fbd..3e6fd51 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -425,6 +425,28 @@ def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[ self._entries.popitem(last=False) return value + def handler_for( + self, + resolved_settings: Optional[Mapping[str, Any]], + *, + boot: Callable[[], Optional[T]], + build: Callable[[], T], + ) -> T: + """The handler for a request: cached per distinct resolved mapping, or the boot fallback. + + Absent settings select the single handler configured by the boot-time settings file. + ``boot`` returning ``None`` means the pod has no boot configuration and sealed per-invoke + settings are required, so the request fails rather than running an unconfigured handler. + """ + if resolved_settings is None: + handler = boot() + if handler is None: + raise ValueError( + "no boot-time handler on this pod: sealed per-invoke settings are required" + ) + return handler + return self.get_or_build(resolved_settings, build) + def clear(self) -> None: with self._lock: self._entries.clear() From e4b4d65ceccbeb4961ad73837b3be1f30da18728 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Thu, 27 Aug 2026 11:45:46 -0400 Subject: [PATCH 22/32] build(deps): advance invocation settings pin --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f46cec5..6728182 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,4 +89,4 @@ fail_under = 15 [tool.uv.sources] # Temporary source pin until field-atomic utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "814595faf94b424267b569c92211c093917988c4" } +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "6240432d41b2366e308537524475b44e7aa79181" } diff --git a/uv.lock b/uv.lock index c967fe8..e5535d0 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=814595faf94b424267b569c92211c093917988c4" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=6240432d41b2366e308537524475b44e7aa79181" }, { name = "uvicorn" }, ] @@ -1381,7 +1381,7 @@ wheels = [ [[package]] name = "utic-invocation-settings" version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=814595faf94b424267b569c92211c093917988c4#814595faf94b424267b569c92211c093917988c4" } +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=6240432d41b2366e308537524475b44e7aa79181#6240432d41b2366e308537524475b44e7aa79181" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From a05ec4527cbb94f3ac27c5b1cc77b2591996e488 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Thu, 27 Aug 2026 12:00:02 -0400 Subject: [PATCH 23/32] build(deps): advance invocation settings pin --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6728182..95daae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,4 +89,4 @@ fail_under = 15 [tool.uv.sources] # Temporary source pin until field-atomic utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "6240432d41b2366e308537524475b44e7aa79181" } +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "05d7bcc2b7720694904113b827c6e70fe565fc22" } diff --git a/uv.lock b/uv.lock index e5535d0..39b80e5 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=6240432d41b2366e308537524475b44e7aa79181" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=05d7bcc2b7720694904113b827c6e70fe565fc22" }, { name = "uvicorn" }, ] @@ -1381,7 +1381,7 @@ wheels = [ [[package]] name = "utic-invocation-settings" version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=6240432d41b2366e308537524475b44e7aa79181#6240432d41b2366e308537524475b44e7aa79181" } +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=05d7bcc2b7720694904113b827c6e70fe565fc22#05d7bcc2b7720694904113b827c6e70fe565fc22" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From 02a6153cab52b40ade0f2bf9246b1430dabf1c2e Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Thu, 27 Aug 2026 13:25:20 -0400 Subject: [PATCH 24/32] fix(etl-uvicorn): tighten context validation, cache expiry, and precheck blame --- test/api/test_api.py | 17 ++++++++++++++ test/api/test_invocation_context.py | 23 +++++++++++++++++++ test/api/test_settings_scoped_cache.py | 11 +++++++++ .../etl_uvicorn/api_generator.py | 3 +++ .../invocation_context.py | 20 +++++++++++++++- .../invocation_settings.py | 6 +++++ 6 files changed, 79 insertions(+), 1 deletion(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index 4395577..ffdfa6e 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -665,9 +665,26 @@ def test_precheck_reports_failure_category_from_raised_error(): body = resp.json() assert body["status_code"] == 403 assert body["failure_category"] == "AUTH_PERMISSION_DENIED" + # A plain exception is not the customer's to fix. + assert body["blame"] is None assert "credential rejected" in body["status_code_text"] +def test_precheck_declares_user_blame_like_invoke_does(): + from unstructured_ingest.error import UserError + + def user_fault_precheck() -> None: + raise UserError("bad credentials") + + client = TestClient( + wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=user_fault_precheck) + ) + + body = client.get("/precheck").json() + + assert body["blame"] == "user" + + def test_precheck_success_has_no_failure_category(): client = TestClient( wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_passing_precheck) diff --git a/test/api/test_invocation_context.py b/test/api/test_invocation_context.py index f07a922..d5d6fd2 100644 --- a/test/api/test_invocation_context.py +++ b/test/api/test_invocation_context.py @@ -65,6 +65,29 @@ def test_unknown_schema_version_is_rejected_by_its_own_error(): assert "'2'" in str(exc.value) +@pytest.mark.parametrize("version", [None, 2, ["1"]]) +def test_a_mistyped_schema_version_is_malformed_not_version_skew(version): + # Only a well-typed version string this consumer does not know reads as deployment skew; + # a wrong-typed field is the caller's malformed context like any other field. + with pytest.raises(MalformedEnvelopeError): + extract_context({RESERVED_CONTEXT_KEY: {**VALID, "schema_version": version}}) + + +def test_misaligned_batch_lists_are_rejected(): + # invocation_ids is read positionally against record_ids, so a length mismatch would let + # every id past the gap name the wrong record. + with pytest.raises(MalformedEnvelopeError): + extract_context( + { + RESERVED_CONTEXT_KEY: { + **VALID, + "record_ids": ["r1", "r2"], + "invocation_ids": ["inv-1"], + } + } + ) + + def test_partial_context_is_accepted(): # A producer that populates only some identity facets degrades to less telemetry, not a # failed invoke. diff --git a/test/api/test_settings_scoped_cache.py b/test/api/test_settings_scoped_cache.py index cd367e6..8b97d5c 100644 --- a/test/api/test_settings_scoped_cache.py +++ b/test/api/test_settings_scoped_cache.py @@ -121,3 +121,14 @@ def test_resolved_settings_build_and_cache_per_distinct_mapping(self): assert first == second == "handler" build.assert_called_once() boot.assert_not_called() + + +class TestExpirySweep: + def test_an_idle_tenants_expired_handler_is_dropped_by_anothers_insert(self): + clock = MagicMock(side_effect=[0.0, 100.0]) + cache = SettingsScopedCache(ttl_seconds=50, clock=clock) + + cache.get_or_build({"tenant": "a"}, lambda: "handler-a") + cache.get_or_build({"tenant": "b"}, lambda: "handler-b") + + assert len(cache._entries) == 1 diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 09ff2b5..3effdc6 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -414,6 +414,8 @@ class InvokePrecheckResponse(BaseModel): status_code: int status_code_text: Optional[str] = None failure_category: Optional[str] = None + # Absent means not-the-customer's; see blame_of(). + blame: Optional[str] = None @fastapi_app.get("/schema") async def get_schema() -> SchemaOutputResponse: @@ -429,6 +431,7 @@ async def run_precheck() -> InvokePrecheckResponse: status_code=fn_response.status_code, status_code_text=fn_response.status_code_text, failure_category=fn_response.failure_category, + blame=fn_response.blame, usage=fn_response.usage, ) else: diff --git a/unstructured_platform_plugins/invocation_context.py b/unstructured_platform_plugins/invocation_context.py index 0d6ed6d..7534b35 100644 --- a/unstructured_platform_plugins/invocation_context.py +++ b/unstructured_platform_plugins/invocation_context.py @@ -99,6 +99,19 @@ class InvocationContext(pydantic.BaseModel): record_ids: list[str] | None = None invocation_ids: list[str | None] | None = None + @pydantic.model_validator(mode="after") + def _batch_lists_stay_index_aligned(self) -> "InvocationContext": + if ( + self.record_ids is not None + and self.invocation_ids is not None + and len(self.record_ids) != len(self.invocation_ids) + ): + raise ValueError( + "record_ids and invocation_ids must be the same length: " + "entry i of invocation_ids describes record i" + ) + return self + @pydantic.field_validator("schema_version") @classmethod def _known_version(cls, value: str) -> str: @@ -130,7 +143,12 @@ def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None: return InvocationContext.model_validate(raw) except pydantic.ValidationError as exc: errors = exc.errors() - if any(error["loc"] == ("schema_version",) for error in errors): + # Only a well-typed version string this package does not know reads as deployment skew; a + # schema_version of the wrong type is the caller's malformed context like any other field. + reported = _reported_version(raw) + if isinstance(reported, str) and any( + error["loc"] == ("schema_version",) for error in errors + ): raise UnsupportedContextVersionError( f"unsupported invocation_context schema_version: " f"{_reported_version(raw)!r}; expected one of {sorted(SUPPORTED_CONTEXT_VERSIONS)}" diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 3e6fd51..dff8872 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -419,6 +419,12 @@ def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[ del self._entries[key] value = build() with self._lock: + # Every insert also sweeps entries whose TTL has lapsed, so a tenant that stops + # sending requests does not keep its credential-bearing handler live while the pod + # stays busy for others; per-key expiry alone only fires on that tenant's next hit. + for stale_key, (expires_at, _) in list(self._entries.items()): + if now >= expires_at: + del self._entries[stale_key] self._entries[key] = (now + self._ttl_seconds, value) self._entries.move_to_end(key) while len(self._entries) > self._maxsize: From 3c8495d6aa2e88897536c4cd493034c230869825 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Mon, 31 Aug 2026 14:16:41 -0400 Subject: [PATCH 25/32] feat(etl-uvicorn): align invocation transport with v2 settings --- CHANGELOG.md | 6 +-- pyproject.toml | 4 +- test/api/test_invocation_envelope.py | 41 ++++++++++++------- test/api/test_invocation_settings.py | 4 +- .../etl_uvicorn/api_generator.py | 12 +++--- .../etl_uvicorn/main.py | 8 ++-- .../invocation_settings.py | 16 ++++---- uv.lock | 4 +- 8 files changed, 53 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 198d12f..7eefaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ body cap, the `/metadata` capability route, the request-scoped accessors, and `http_status_for` — the HTTP spelling of the library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, which - owns the *settings contract* — including the field-set carrier as the only accepted sealed + owns the *settings contract* — including the v2 document as the only accepted sealed `/invoke` shape, independent sealed-field resolution, and what an absent field is allowed to mean. That split is deliberate: the absence rule is a security decision and belongs next to the crypto it governs, while request @@ -37,9 +37,9 @@ inside response iteration, so streaming stays correct independently of FastAPI's yield-dependency cleanup timing. * **Sealed settings consumption remains opt-in.** Pass - `invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or + `invoke_with_sealed_dag_node_settings_v2=True` to `wrap_in_fastapi` / `generate_fast_api` (or `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; - it advertises that the application accepts and acts on the versioned field-set carrier. + it advertises that the application accepts and acts on the versioned v2 document. Transport support alone continues to advertise only `invocation_settings` and `invocation_context`. A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route` (which diff --git a/pyproject.toml b/pyproject.toml index 95daae1..8496e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,5 +88,5 @@ fail_under = 15 packages = ["/unstructured_platform_plugins"] [tool.uv.sources] -# Temporary source pin until field-atomic utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "05d7bcc2b7720694904113b827c6e70fe565fc22" } +# Temporary source pin until v2-capable utic-invocation-settings 0.4.0 is published. +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "98f5b0eff7e18130788062119436b4eb780faa87" } diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index e271647..04529b4 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -15,7 +15,7 @@ import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient -from utic_invocation_settings import FIELD_SET_FIELDS_KEY, FIELD_SET_FORMAT, Blame +from utic_invocation_settings import FIELDS_DOCUMENT_FORMAT, Blame import unstructured_platform_plugins.invocation_settings as invocation_settings_transport from unstructured_platform_plugins.invocation_settings import ( @@ -32,7 +32,7 @@ ] ALL_CAPABILITIES = [ *BASE_CAPABILITIES, - "invoke_with_sealed_dag_node_settings", + "invoke_with_sealed_dag_node_settings_v2", ] @@ -52,7 +52,7 @@ def test_advertises_transport_capabilities_by_default(self): def test_sealed_consumption_capability_is_opt_in(self): app = FastAPI() - add_metadata_route(app, invoke_with_sealed_dag_node_settings=True) + add_metadata_route(app, invoke_with_sealed_dag_node_settings_v2=True) with TestClient(app) as client: payload = client.get("/metadata").json() @@ -64,7 +64,11 @@ def test_last_call_wins(self): # identifier must replace it, not be shadowed by route order. app = FastAPI() add_metadata_route(app, identifier="wrapper.default") - add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) + add_metadata_route( + app, + identifier="plugin.test", + invoke_with_sealed_dag_node_settings_v2=True, + ) with TestClient(app) as client: payload = client.get("/metadata").json() @@ -81,7 +85,11 @@ def test_replaces_a_directly_registered_metadata_route(self): async def stale_metadata() -> dict: return {"api_version": "3", "identifier": "stale", "capabilities": []} - add_metadata_route(app, identifier="plugin.test", invoke_with_sealed_dag_node_settings=True) + add_metadata_route( + app, + identifier="plugin.test", + invoke_with_sealed_dag_node_settings_v2=True, + ) with TestClient(app) as client: payload = client.get("/metadata").json() @@ -234,7 +242,7 @@ async def invoke() -> dict: def test_install_after_metadata_but_before_invoke_is_supported(self): recorder = _Recorder() app = FastAPI() - add_metadata_route(app, invoke_with_sealed_dag_node_settings=True) + add_metadata_route(app, invoke_with_sealed_dag_node_settings_v2=True) install_invocation_envelope(app) @app.post("/invoke") @@ -414,12 +422,15 @@ def __init__(self, blame: Blame, secret: str = ""): class TestSettingsResolutionBoundary: """The library owns settings shape and crypto; this package owns delivery and HTTP mapping.""" - def test_field_set_payload_is_delegated_and_final_mapping_is_bound(self, monkeypatch): + def test_v2_document_is_delegated_and_final_mapping_is_bound(self, monkeypatch): opaque_payload = { "dag_node_settings": { - "format": FIELD_SET_FORMAT, - FIELD_SET_FIELDS_KEY: { - "api_key": {"format": "u10d.invocation-settings.v1", "opaque": "member"} + "format": FIELDS_DOCUMENT_FORMAT, + "settings": { + "api_key": { + "format": "u10d.invocation-settings.field.v1", + "opaque": "member", + } }, } } @@ -440,15 +451,15 @@ def resolve(payload): assert seen == [opaque_payload] assert recorder.seen_settings == resolved - def test_empty_field_set_is_resolved_and_bound(self): - field_set_payload = { + def test_empty_v2_document_is_resolved_and_bound(self): + document_payload = { "dag_node_settings": { - "format": FIELD_SET_FORMAT, - FIELD_SET_FIELDS_KEY: {}, + "format": FIELDS_DOCUMENT_FORMAT, + "settings": {}, } } - recorder, response = _post_invoke({"invocation_settings": field_set_payload}) + recorder, response = _post_invoke({"invocation_settings": document_payload}) assert response.status_code == 200 assert recorder.seen_settings == {} diff --git a/test/api/test_invocation_settings.py b/test/api/test_invocation_settings.py index 2ec8410..ef48ecb 100644 --- a/test/api/test_invocation_settings.py +++ b/test/api/test_invocation_settings.py @@ -35,13 +35,13 @@ def test_sealed_capability_is_opt_in(): wrap_in_fastapi( func=_echo_settings, plugin_id="mock_plugin", - invoke_with_sealed_dag_node_settings=True, + invoke_with_sealed_dag_node_settings_v2=True, ) ) payload = client.get("/metadata").json() - assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"] + assert "invoke_with_sealed_dag_node_settings_v2" in payload["capabilities"] def test_reserved_settings_field_binds_without_appearing_in_schema(): diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 3effdc6..8f06ef0 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -185,14 +185,14 @@ def wrap_in_fastapi( func: Callable, plugin_id: str, precheck_func: Optional[Callable] = None, - invoke_with_sealed_dag_node_settings: bool = False, + invoke_with_sealed_dag_node_settings_v2: bool = False, ) -> FastAPI: try: return _wrap_in_fastapi( func=func, plugin_id=plugin_id, precheck_func=precheck_func, - invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, + invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2, ) except Exception as e: logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True) @@ -203,7 +203,7 @@ def _wrap_in_fastapi( func: Callable, plugin_id: str, precheck_func: Optional[Callable] = None, - invoke_with_sealed_dag_node_settings: bool = False, + invoke_with_sealed_dag_node_settings_v2: bool = False, ) -> FastAPI: if precheck_func is not None: check_precheck_func(precheck_func=precheck_func) @@ -451,7 +451,7 @@ async def get_id() -> str: add_metadata_route( fastapi_app, identifier=plugin_id, - invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, + invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2, ) FastAPIInstrumentor.instrument_app( @@ -468,7 +468,7 @@ def generate_fast_api( id_method: Optional[str] = None, precheck_str: Optional[str] = None, precheck_method: Optional[str] = None, - invoke_with_sealed_dag_node_settings: bool = False, + invoke_with_sealed_dag_node_settings_v2: bool = False, ) -> FastAPI: instance = import_from_string(app) func = get_func(instance, method_name) @@ -491,5 +491,5 @@ def generate_fast_api( func=func, plugin_id=plugin_id, precheck_func=precheck_func, - invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings, + invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2, ) diff --git a/unstructured_platform_plugins/etl_uvicorn/main.py b/unstructured_platform_plugins/etl_uvicorn/main.py index 62b067f..72f7ff2 100644 --- a/unstructured_platform_plugins/etl_uvicorn/main.py +++ b/unstructured_platform_plugins/etl_uvicorn/main.py @@ -56,7 +56,7 @@ def api_wrapper( plugin_id_method: Optional[str] = None, precheck_app: Optional[str] = None, precheck_app_method: Optional[str] = None, - sealed_dag_node_settings: bool = False, + sealed_dag_node_settings_v2: bool = False, **kwargs, ): # Make sure logging is configured before the call to run() so any setup has the same format @@ -74,7 +74,7 @@ def api_wrapper( id_method=plugin_id_method, precheck_str=precheck_app, precheck_method=precheck_app_method, - invoke_with_sealed_dag_node_settings=sealed_dag_node_settings, + invoke_with_sealed_dag_node_settings_v2=sealed_dag_node_settings_v2, ) # Explicitly map values that are manipulated in the original # call to run(), preventing **kwargs reference @@ -133,10 +133,10 @@ def api_wrapper( "lives on main class passes in.", ), click.Option( - ["--sealed-dag-node-settings"], + ["--sealed-dag-node-settings-v2"], is_flag=True, default=False, - help="Advertise the invoke_with_sealed_dag_node_settings capability on " + help="Advertise the invoke_with_sealed_dag_node_settings_v2 capability on " "/metadata. Set only for a plugin that consumes per-invoke settings " "through current_invocation_settings().", ), diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index dff8872..ec48fab 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -1,7 +1,7 @@ """Transport for the reserved `/invoke` fields: request dependency, `/metadata`, body cap. -The *settings contract* — including the only accepted sealed `/invoke` shape (the field-set -carrier), sealed-field resolution, and what an absent field is allowed to mean — lives in +The *settings contract* — including the only accepted sealed `/invoke` shape (the v2 settings +document), field-level resolution, and what an absent field is allowed to mean — lives in `utic_invocation_settings`, next to the crypto it governs; every decision about a settings payload is delegated there. The *identity contract* — the `invocation_context` model — is @@ -43,7 +43,7 @@ from starlette.routing import get_route_path from starlette.types import ASGIApp, Receive, Scope, Send from utic_invocation_settings import ( - INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY, + INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY, RESERVED_ENVELOPE_KEY, Blame, InvocationSettingsError, @@ -117,7 +117,7 @@ def invocation_envelope( def add_metadata_route( app: FastAPI, identifier: Optional[str] = None, - invoke_with_sealed_dag_node_settings: bool = False, + invoke_with_sealed_dag_node_settings_v2: bool = False, ) -> None: """Register GET /metadata advertising the reserved /invoke fields this plugin accepts. @@ -128,16 +128,16 @@ def add_metadata_route( `invocation_settings` and `invocation_context` are transport capabilities: installing the dependency makes the host receive, resolve, and bind those fields. The sealed-settings capability is stronger: it tells the controller that the plugin handler consumes the resolved - field-atomic settings in place of boot-time state. The controller may therefore send the - versioned field-set carrier, so this remains an explicit opt-in. + field-level v2 settings in place of boot-time state. The controller may therefore send the + versioned v2 document, so this remains an explicit opt-in. Last call wins: the payload lives on `app.state` and every call overwrites it, while the route is registered once. A host wrapper may register at app construction and a plugin can still re-register with its own identifier afterwards, with no route-order dependence. """ capabilities = [RESERVED_ENVELOPE_KEY, RESERVED_CONTEXT_KEY] - if invoke_with_sealed_dag_node_settings: - capabilities.append(INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY) + if invoke_with_sealed_dag_node_settings_v2: + capabilities.append(INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY) app.state.plugin_metadata_payload = { "api_version": "3", "identifier": identifier, diff --git a/uv.lock b/uv.lock index 39b80e5..ce741c0 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=05d7bcc2b7720694904113b827c6e70fe565fc22" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98f5b0eff7e18130788062119436b4eb780faa87" }, { name = "uvicorn" }, ] @@ -1381,7 +1381,7 @@ wheels = [ [[package]] name = "utic-invocation-settings" version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=05d7bcc2b7720694904113b827c6e70fe565fc22#05d7bcc2b7720694904113b827c6e70fe565fc22" } +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98f5b0eff7e18130788062119436b4eb780faa87#98f5b0eff7e18130788062119436b4eb780faa87" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From 4706959eea8a5cd12840bdb4583626a0da28e90a Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Mon, 31 Aug 2026 15:27:46 -0400 Subject: [PATCH 26/32] fix(cache): re-read clock before insert-time sweep; fix stale CLI flag in changelog --- CHANGELOG.md | 2 +- test/api/test_settings_scoped_cache.py | 13 ++++++++++++- .../invocation_settings.py | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eefaac..e30fe70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ cleanup timing. * **Sealed settings consumption remains opt-in.** Pass `invoke_with_sealed_dag_node_settings_v2=True` to `wrap_in_fastapi` / `generate_fast_api` (or - `--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings; + `--sealed-dag-node-settings-v2` on the CLI) only for a plugin that consumes per-invoke settings; it advertises that the application accepts and acts on the versioned v2 document. Transport support alone continues to advertise only `invocation_settings` and `invocation_context`. A diff --git a/test/api/test_settings_scoped_cache.py b/test/api/test_settings_scoped_cache.py index 8b97d5c..4b725bb 100644 --- a/test/api/test_settings_scoped_cache.py +++ b/test/api/test_settings_scoped_cache.py @@ -125,7 +125,18 @@ def test_resolved_settings_build_and_cache_per_distinct_mapping(self): class TestExpirySweep: def test_an_idle_tenants_expired_handler_is_dropped_by_anothers_insert(self): - clock = MagicMock(side_effect=[0.0, 100.0]) + clock = MagicMock(side_effect=[0.0, 0.0, 100.0, 100.0]) + cache = SettingsScopedCache(ttl_seconds=50, clock=clock) + + cache.get_or_build({"tenant": "a"}, lambda: "handler-a") + cache.get_or_build({"tenant": "b"}, lambda: "handler-b") + + assert len(cache._entries) == 1 + + def test_an_entry_expiring_while_build_runs_is_swept_by_that_insert(self): + # Lookup for tenant b happens at t=10 (a still live), but its build finishes at t=100 + # (a lapsed); the sweep must judge staleness at insert time, not lookup time. + clock = MagicMock(side_effect=[0.0, 0.0, 10.0, 100.0]) cache = SettingsScopedCache(ttl_seconds=50, clock=clock) cache.get_or_build({"tenant": "a"}, lambda: "handler-a") diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index ec48fab..575706e 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -422,6 +422,8 @@ def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[ # Every insert also sweeps entries whose TTL has lapsed, so a tenant that stops # sending requests does not keep its credential-bearing handler live while the pod # stays busy for others; per-key expiry alone only fires on that tenant's next hit. + # Re-read the clock: build() may take long enough for more entries to lapse. + now = self._clock() for stale_key, (expires_at, _) in list(self._entries.items()): if now >= expires_at: del self._entries[stale_key] From e80eb093d8fa8ef9c61e5d38c1200b4cde628ad1 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Mon, 31 Aug 2026 20:05:15 -0400 Subject: [PATCH 27/32] fix(etl-uvicorn): address invocation transport review --- CHANGELOG.md | 13 ++-- pyproject.toml | 6 +- test/api/test_invocation_envelope.py | 62 ++++++++++++++++-- .../invocation_settings.py | 64 ++++++++++++------- uv.lock | 6 +- 5 files changed, 109 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e30fe70..8326b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ `unstructured_platform_plugins.invocation_settings` holds the `/invoke` binding dependency and body cap, the `/metadata` capability route, the request-scoped accessors, and `http_status_for` — the HTTP spelling of the - library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`, which + library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.5.0`, which owns the *settings contract* — including the v2 document as the only accepted sealed `/invoke` shape, independent sealed-field resolution, and what an absent field is allowed to mean. That split is deliberate: the @@ -24,7 +24,7 @@ settings payload is delegated to `utic-invocation-settings`, and only the final resolved mapping is exposed through `current_invocation_settings()` / `current_invocation_context()`. An absent field preserves the existing fallback behaviour; under - `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` missing or plaintext settings fail closed. + `FF_INVOCATION_SETTINGS` missing or plaintext settings fail closed. Repeated installation is safe: the dependency installs once and the last `/metadata` registration wins. * **Extraction is a route dependency.** `bind_invocation_envelope` reads the body the framework @@ -32,10 +32,11 @@ decoded exactly once per request. `install_invocation_envelope` contributes that path-aware dependency through the router's public dependency list before `/invoke` is registered; no private FastAPI dependency graph is mutated. It also registers the failure response shape and - installs `InvokeBodyLimitMiddleware`, a streaming byte counter that answers 413 over the cap - without buffering. Async-generator plugins explicitly re-enter the captured request binding - inside response iteration, so streaming stays correct independently of FastAPI's yield-dependency - cleanup timing. + can optionally install `InvokeBodyLimitMiddleware`, a streaming byte counter that answers 413 + over a host-selected cap without buffering. The cap is disabled by default so a wrapper upgrade + cannot impose an unvalidated fleet-wide request limit. Async-generator plugins explicitly + re-enter the captured request binding inside response iteration, so streaming stays correct + independently of FastAPI's yield-dependency cleanup timing. * **Sealed settings consumption remains opt-in.** Pass `invoke_with_sealed_dag_node_settings_v2=True` to `wrap_in_fastapi` / `generate_fast_api` (or `--sealed-dag-node-settings-v2` on the CLI) only for a plugin that consumes per-invoke settings; diff --git a/pyproject.toml b/pyproject.toml index 8496e1e..ce7d92a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "fastapi", "click", "unstructured-ingest", - "utic-invocation-settings>=0.4.0,<1.0.0", + "utic-invocation-settings>=0.5.0,<1.0.0", "opentelemetry-instrumentation-fastapi", "opentelemetry-exporter-otlp-proto-grpc", "dataclasses-json" @@ -88,5 +88,5 @@ fail_under = 15 packages = ["/unstructured_platform_plugins"] [tool.uv.sources] -# Temporary source pin until v2-capable utic-invocation-settings 0.4.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "98f5b0eff7e18130788062119436b4eb780faa87" } +# Temporary source pin until v2-capable utic-invocation-settings 0.5.0 is published. +utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b" } diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index 04529b4..127b3dd 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -1,4 +1,4 @@ -"""The transport for the reserved /invoke fields: dependency binding, /metadata, the body cap. +"""The transport for the reserved /invoke fields: binding, /metadata, the optional body cap. The *contract* these exercise — which shapes carry settings, what absence means — is owned and tested in `utic_invocation_settings`. What is tested here is delivery: that the framework-parsed @@ -180,6 +180,27 @@ def test_absent_fields_bind_none(self): assert recorder.seen_settings is None assert recorder.seen_context is None + def test_malformed_nonempty_json_is_caller_error_when_settings_are_required(self, monkeypatch): + monkeypatch.setenv("FF_INVOCATION_SETTINGS", "true") + + recorder, response = _post_invoke(b"{") + + assert not recorder.called + assert response.status_code == 422 + assert response.json() == { + "detail": "Invalid JSON body", + "reason": "malformed_envelope", + } + + def test_empty_body_remains_absence_when_settings_are_required(self, monkeypatch): + monkeypatch.setenv("FF_INVOCATION_SETTINGS", "true") + + recorder, response = _post_invoke(b"") + + assert not recorder.called + assert response.status_code == 500 + assert response.json()["reason"] == "sealed_dag_node_settings_required" + def test_non_dict_reserved_field_is_rejected(self): recorder, response = _post_invoke({"invocation_settings": "not-a-dict"}) @@ -300,6 +321,14 @@ def test_oversized_body_is_rejected(self): assert not recorder.called assert response.status_code == 413 + def test_body_limit_is_disabled_by_default(self): + app = _envelope_app(_Recorder()) + + assert app.state.invocation_envelope_max_body_bytes is None + assert all( + middleware.cls is not InvokeBodyLimitMiddleware for middleware in app.user_middleware + ) + def test_spec_compliant_root_path_scope_still_caps_body(self): recorder = _Recorder() app = _envelope_app(recorder, max_body_bytes=16) @@ -335,7 +364,7 @@ class TestInvokeBodyLimitMiddleware: """ASGI-level behavior of the streaming byte counter: no buffering, disconnect pass-through.""" @staticmethod - def _run(middleware, scope, chunks) -> tuple[list, list]: + def _run(middleware, scope, chunks, downstream_app=None) -> tuple[list, list]: received = [] sent = [] @@ -345,7 +374,7 @@ async def receive(): async def send(message): sent.append(message) - async def downstream(scope, receive, send): + async def default_downstream(scope, receive, send): while True: message = await receive() received.append(message) @@ -354,7 +383,7 @@ async def downstream(scope, receive, send): await send({"type": "http.response.start", "status": 200, "headers": []}) await send({"type": "http.response.body", "body": b"{}"}) - middleware = middleware(downstream) + middleware = middleware(downstream_app or default_downstream) asyncio.run(middleware(scope, receive, send)) return received, sent @@ -410,6 +439,27 @@ def test_non_invoke_requests_are_not_capped(self): assert sent[0]["status"] == 200 + def test_coincident_downstream_failure_logs_only_its_type(self, caplog): + class CoincidentDownstreamFailure(Exception): + pass + + async def downstream(_scope, receive, _send): + await receive() + raise CoincidentDownstreamFailure("request-secret-sentinel") + + chunks = [{"type": "http.request", "body": b"x" * 20, "more_body": False}] + with caplog.at_level("DEBUG", logger=invocation_settings_transport.__name__): + _, sent = self._run( + lambda app: InvokeBodyLimitMiddleware(app, max_body_bytes=16), + {"type": "http", "method": "POST", "path": "/invoke"}, + chunks, + downstream_app=downstream, + ) + + assert sent[0]["status"] == 413 + assert "CoincidentDownstreamFailure" in caplog.text + assert "request-secret-sentinel" not in caplog.text + class _ResolutionFailure(Exception): reason = "test_resolution_failure" @@ -441,9 +491,7 @@ def resolve(payload): seen.append(payload) return resolved - monkeypatch.setattr( - invocation_settings_transport, "resolve_invocation_settings", resolve - ) + monkeypatch.setattr(invocation_settings_transport, "resolve_invocation_settings", resolve) recorder, response = _post_invoke({"invocation_settings": opaque_payload}) diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 575706e..d47554b 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -1,4 +1,4 @@ -"""Transport for the reserved `/invoke` fields: request dependency, `/metadata`, body cap. +"""Transport for the reserved `/invoke` fields: request dependency, `/metadata`, optional body cap. The *settings contract* — including the only accepted sealed `/invoke` shape (the v2 settings document), field-level resolution, and what an absent field is allowed to mean — lives in @@ -17,10 +17,9 @@ Extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke` body is buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on the -`Request`, and the dependency reads that cached parse. The request-size cap is the one concern -that must sit below the framework — neither Starlette nor uvicorn bounds request-body size — and -`InvokeBodyLimitMiddleware` enforces it by counting bytes as they stream through, without -buffering. +`Request`, and the dependency reads that cached parse. Hosts that have established a safe request +ceiling may opt into `InvokeBodyLimitMiddleware`, which counts bytes as they stream through without +buffering. Wrapped plugins do not acquire a fleet-wide limit merely by upgrading this package. """ from __future__ import annotations @@ -77,9 +76,9 @@ def http_status_for(error: BaseException) -> int: _METADATA_PATH = "/metadata" _INVOKE_PATH = "/invoke" -# Bounds the /invoke request body; generous because batch invokes carry an array of file_data -# payloads. Nothing below the framework buffers, so this cap is the only guard against -# unbounded-memory requests. +# A convenient opt-in ceiling for hosts that have verified it against their request distribution. +# It is deliberately not the install default: batch invokes carry arrays of file_data payloads, +# and a wrapper upgrade must not reject previously valid fleet traffic without an explicit choice. MAX_INVOKE_BODY_BYTES = 64 * 1024 * 1024 _INVOCATION: ContextVar[tuple[Optional[dict], Optional[InvocationContext]]] = ContextVar( @@ -186,7 +185,7 @@ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: treated as absent, because absence is the signal to fall back to the boot-time settings file: degrading a malformed field to absence would quietly answer a request configured for one tenant with whatever the pod happened to boot with. Under - `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` there is no settings file to fall back to, + `FF_INVOCATION_SETTINGS` there is no settings file to fall back to, so absent or plaintext settings fail too — as does any /invoke whose body is not a JSON object, since such a body cannot carry the envelope a native pod requires. """ @@ -199,9 +198,19 @@ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: try: parsed = await request.json() except ValueError: - # Empty or malformed body: the framework's own validation answers for the body itself; - # for the reserved fields it is absence, which resolve_invocation_settings still judges - # (absence is a failure on a native pod). + # request.json() reads through Starlette's cached body, so this does not consume or buffer + # the stream a second time. A truly empty body is absence and remains subject to the + # native-pod policy below. Any bytes that fail JSON parsing are a malformed request, not + # absence: collapsing them would turn a caller-fixable syntax error into a native pod's + # SealedDagNodeSettingsRequiredError (RECIPIENT -> 500). + if await request.body(): + raise UnusableInvocationEnvelope( + 422, + { + "detail": "Invalid JSON body", + "reason": MalformedEnvelopeError.reason, + }, + ) from None parsed = None raw_settings: Optional[Any] = None @@ -322,28 +331,36 @@ async def guarded_send(message: dict) -> None: except ClientDisconnect: if not exceeded: raise - except Exception: + except Exception as exc: # The cut body stream can surface downstream as something other than - # ClientDisconnect; once the cap is the cause, the 413 below is the answer. + # ClientDisconnect; once the cap is the cause, the 413 below is the answer. Retain a + # type-only diagnostic for a coincident downstream bug without rendering its message, + # which may contain request data or credentials. if not exceeded: raise + logger.debug( + "downstream raised after /invoke body limit was exceeded: %s", + type(exc).__name__, + ) if exceeded and not response_started: await _send_json(send, 413, {"detail": "Request body too large"}) -def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_BODY_BYTES) -> None: +def install_invocation_envelope(app: FastAPI, max_body_bytes: int | None = None) -> None: """Install reserved-field binding before registering a FastAPI app's routes. - Adds `bind_invocation_envelope` through the router's public dependency list, installs the - body-size cap beneath the framework, and registers the failure response shape. The dependency - is a path/method-aware no-op outside POST /invoke, so routes registered after this call can all - inherit it without private dependency-graph mutation. Calling after routes have already been - registered raises rather than silently leaving those routes uncovered. + Adds `bind_invocation_envelope` through the router's public dependency list and registers the + failure response shape. When ``max_body_bytes`` is supplied, also installs the body-size cap + beneath the framework. The cap is opt-in because a wrapper upgrade must not impose an + unvalidated fleet-wide request limit. The dependency is a path/method-aware no-op outside POST + /invoke, so routes registered after this call can all inherit it without private + dependency-graph mutation. Calling after routes have already been registered raises rather + than silently leaving those routes uncovered. Idempotent per app because both host-wrapper and plugin setup may call this function, while a double installation would resolve settings twice per request. A repeated call asking for a - different ``max_body_bytes`` raises because the installed cap cannot be changed and silently - keeping the first value would misrepresent the limit actually enforced. + different ``max_body_bytes`` raises because the installed configuration cannot be changed and + silently keeping the first value would misrepresent the limit actually enforced. """ if getattr(app.state, "invocation_envelope_installed", False): installed_max = app.state.invocation_envelope_max_body_bytes @@ -364,7 +381,8 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int = MAX_INVOKE_B app.state.invocation_envelope_installed = True app.state.invocation_envelope_max_body_bytes = max_body_bytes app.router.dependencies.append(Depends(bind_invocation_envelope)) - app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes) + if max_body_bytes is not None: + app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes) app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response) diff --git a/uv.lock b/uv.lock index ce741c0..5fad0c9 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98f5b0eff7e18130788062119436b4eb780faa87" }, + { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b" }, { name = "uvicorn" }, ] @@ -1380,8 +1380,8 @@ wheels = [ [[package]] name = "utic-invocation-settings" -version = "0.4.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98f5b0eff7e18130788062119436b4eb780faa87#98f5b0eff7e18130788062119436b4eb780faa87" } +version = "0.5.0" +source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b#98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, From ee156c6594b830946638e6ccc3a801dbdfda6961 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Mon, 31 Aug 2026 20:16:30 -0400 Subject: [PATCH 28/32] fix(etl-uvicorn): normalize malformed invoke JSON --- test/api/test_invocation_settings.py | 28 +++++++++ .../invocation_settings.py | 60 +++++++++++++++---- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/test/api/test_invocation_settings.py b/test/api/test_invocation_settings.py index ef48ecb..8425ff2 100644 --- a/test/api/test_invocation_settings.py +++ b/test/api/test_invocation_settings.py @@ -66,6 +66,34 @@ def test_reserved_settings_field_binds_without_appearing_in_schema(): assert "invocation_settings" not in properties +def test_generated_invoke_route_classifies_malformed_json_as_caller_error(): + client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) + + resp = client.post( + "/invoke", + content=b"{", + headers={"content-type": "application/json"}, + ) + + assert resp.status_code == 422 + assert resp.json() == { + "detail": "Invalid JSON body", + "reason": "malformed_envelope", + } + + +def test_generated_invoke_route_preserves_other_request_validation_errors(): + client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) + + resp = client.post("/invoke", json={}) + + assert resp.status_code == 422 + payload = resp.json() + assert "reason" not in payload + assert isinstance(payload["detail"], list) + assert any(error["loc"] == ["body", "content"] for error in payload["detail"]) + + def test_sync_function_sees_bound_settings_across_the_executor(): # Sync functions run in an executor thread; the context must be copied there or the # request-scoped binding would read as absent. diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index d47554b..1ef3552 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -15,17 +15,21 @@ model built purely from the wrapped function, and a plugin reads the fields through `current_invocation_settings()` / `current_invocation_context()` instead. -Extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke` body is -buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on the -`Request`, and the dependency reads that cached parse. Hosts that have established a safe request -ceiling may opt into `InvokeBodyLimitMiddleware`, which counts bytes as they stream through without -buffering. Wrapped plugins do not acquire a fleet-wide limit merely by upgrading this package. +Well-formed extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke` +body is buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on +the `Request`, and the dependency reads that cached parse. Generated routes can reject malformed +JSON while validating their typed body before dependencies run, so the installer normalizes that +specific validation failure to the same transport error. Hosts that have established a safe +request ceiling may opt into `InvokeBodyLimitMiddleware`, which counts bytes as they stream through +without buffering. Wrapped plugins do not acquire a fleet-wide limit merely by upgrading this +package. """ from __future__ import annotations import asyncio import hashlib +import inspect import json import logging import threading @@ -37,6 +41,8 @@ from typing import Any, Optional, TypeVar from fastapi import Depends, FastAPI, Request +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError from starlette.requests import ClientDisconnect from starlette.responses import JSONResponse from starlette.routing import get_route_path @@ -176,6 +182,14 @@ async def _unusable_envelope_response( return JSONResponse(status_code=exc.status_code, content=exc.payload) +def _is_malformed_json_validation(exc: RequestValidationError) -> bool: + """Whether FastAPI rejected the request body before route dependencies could run.""" + return any( + error.get("type") == "json_invalid" and tuple(error.get("loc", ()))[:1] == ("body",) + for error in exc.errors() + ) + + async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: """Resolve the reserved /invoke fields and bind them for the duration of the request. @@ -350,12 +364,14 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int | None = None) """Install reserved-field binding before registering a FastAPI app's routes. Adds `bind_invocation_envelope` through the router's public dependency list and registers the - failure response shape. When ``max_body_bytes`` is supplied, also installs the body-size cap - beneath the framework. The cap is opt-in because a wrapper upgrade must not impose an - unvalidated fleet-wide request limit. The dependency is a path/method-aware no-op outside POST - /invoke, so routes registered after this call can all inherit it without private - dependency-graph mutation. Calling after routes have already been registered raises rather - than silently leaving those routes uncovered. + failure response shape. FastAPI parses a generated route's typed body before dependencies run, + so malformed JSON validation is normalized to that same response shape at the app boundary; + every other validation error retains the handler the app already had. When ``max_body_bytes`` + is supplied, also installs the body-size cap beneath the framework. The cap is opt-in because + a wrapper upgrade must not impose an unvalidated fleet-wide request limit. The dependency is a + path/method-aware no-op outside POST /invoke, so routes registered after this call can all + inherit it without private dependency-graph mutation. Calling after routes have already been + registered raises rather than silently leaving those routes uncovered. Idempotent per app because both host-wrapper and plugin setup may call this function, while a double installation would resolve settings twice per request. A repeated call asking for a @@ -385,6 +401,28 @@ def install_invocation_envelope(app: FastAPI, max_body_bytes: int | None = None) app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes) app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response) + previous_validation_handler = app.exception_handlers.get( + RequestValidationError, request_validation_exception_handler + ) + + async def invocation_request_validation_response(request: Request, exc: RequestValidationError): + if ( + request.method == "POST" + and get_route_path(request.scope) == _INVOKE_PATH + and _is_malformed_json_validation(exc) + ): + return JSONResponse( + status_code=422, + content={ + "detail": "Invalid JSON body", + "reason": MalformedEnvelopeError.reason, + }, + ) + response = previous_validation_handler(request, exc) + return await response if inspect.isawaitable(response) else response + + app.add_exception_handler(RequestValidationError, invocation_request_validation_response) + def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str: """Digest of an ordinary resolved settings mapping, safe for secret-bearing values.""" From 8ae8fb4a1322625b8f3943a4a5640a83af7f1bd4 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Mon, 31 Aug 2026 20:21:58 -0400 Subject: [PATCH 29/32] fix(etl-uvicorn): preserve nested JSON validation --- test/api/test_invocation_settings.py | 35 +++++++++++++++++-- .../invocation_settings.py | 11 +++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/test/api/test_invocation_settings.py b/test/api/test_invocation_settings.py index 8425ff2..37c242d 100644 --- a/test/api/test_invocation_settings.py +++ b/test/api/test_invocation_settings.py @@ -3,11 +3,15 @@ import json from typing import Optional +from fastapi import FastAPI from fastapi.testclient import TestClient -from pydantic import BaseModel +from pydantic import BaseModel, Json from unstructured_platform_plugins.etl_uvicorn.api_generator import wrap_in_fastapi -from unstructured_platform_plugins.invocation_settings import current_invocation_settings +from unstructured_platform_plugins.invocation_settings import ( + current_invocation_settings, + install_invocation_envelope, +) class _Echo(BaseModel): @@ -19,6 +23,10 @@ def _echo_settings(content: str) -> _Echo: return _Echo(content=content, settings=current_invocation_settings()) +class _NestedJsonRequest(BaseModel): + payload: Json[dict[str, object]] + + def test_metadata_route_is_registered_with_transport_capabilities(): client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")) @@ -94,6 +102,29 @@ def test_generated_invoke_route_preserves_other_request_validation_errors(): assert any(error["loc"] == ["body", "content"] for error in payload["detail"]) +def test_generated_invoke_route_preserves_malformed_nested_json_field_error(): + app = FastAPI() + install_invocation_envelope(app) + + @app.post("/invoke") + async def invoke(_request: _NestedJsonRequest): + return None + + client = TestClient(app) + + # The outer request document is valid; only the string inside its Pydantic Json field is not. + resp = client.post("/invoke", json={"payload": "{"}) + + assert resp.status_code == 422 + payload = resp.json() + assert "reason" not in payload + assert isinstance(payload["detail"], list) + assert any( + error["type"] == "json_invalid" and error["loc"] == ["body", "payload"] + for error in payload["detail"] + ) + + def test_sync_function_sees_bound_settings_across_the_executor(): # Sync functions run in an executor thread; the context must be copied there or the # request-scoped binding would read as absent. diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 1ef3552..5177d3d 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -184,8 +184,17 @@ async def _unusable_envelope_response( def _is_malformed_json_validation(exc: RequestValidationError) -> bool: """Whether FastAPI rejected the request body before route dependencies could run.""" + # A top-level JSONDecodeError stores the raw document and reports its integer parser offset as + # exactly ("body", offset). Pydantic's Json fields also emit `json_invalid`, but by then the + # outer request has been decoded into a mapping/list and the location continues through the + # model field. Those are ordinary schema-validation errors and must retain FastAPI's detail. + if not isinstance(exc.body, (str, bytes, bytearray)): + return False return any( - error.get("type") == "json_invalid" and tuple(error.get("loc", ()))[:1] == ("body",) + error.get("type") == "json_invalid" + and len(location := tuple(error.get("loc", ()))) == 2 + and location[0] == "body" + and isinstance(location[1], int) for error in exc.errors() ) From cc1fa567582e41ff97de366e7cde676360a87ca2 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Mon, 31 Aug 2026 22:11:33 -0400 Subject: [PATCH 30/32] feat(contracts): generate ratified invocation bindings --- CHANGELOG.md | 16 +-- scripts/generate_invocation_contracts.py | 112 ++++++++++++++++++ test/api/test_api.py | 40 +++++-- test/api/test_invocation_context.py | 23 ++++ .../etl_uvicorn/api_generator.py | 40 +++++-- .../generated/__init__.py | 1 + .../generated/error_audience_v1.py | 28 +++++ .../generated/invocation_context_v1.py | 89 ++++++++++++++ .../invocation_context.py | 72 ++--------- 9 files changed, 327 insertions(+), 94 deletions(-) create mode 100644 scripts/generate_invocation_contracts.py create mode 100644 unstructured_platform_plugins/generated/__init__.py create mode 100644 unstructured_platform_plugins/generated/error_audience_v1.py create mode 100644 unstructured_platform_plugins/generated/invocation_context_v1.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8326b15..5ad54f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,14 @@ absence rule is a security decision and belongs next to the crypto it governs, while request handling and route registration belong here, where a web framework is already a dependency. Nothing about the sealed-settings wire format is decided in this repository. -* **This package now owns the `invocation_context` identity model.** - `unstructured_platform_plugins.invocation_context` holds `InvocationContext`, - `extract_context`, `dimensions`, `RESERVED_CONTEXT_KEY`, `DIMENSION_FIELDS`, - `SUPPORTED_CONTEXT_VERSIONS` and `UnsupportedContextVersionError`. The context is `/invoke` - protocol identity — no crypto, no secrets — so it lives with the plugin protocol. Its errors - subclass the shared `InvocationSettingsError` taxonomy, so hosts classify context failures with - the same `reason`/`blame` machinery as settings failures. This module is the public home for - the surface `utic-invocation-settings 0.2.x` carried and its `0.3.0` removed. +* **The wrapper consumes the ratified `invocation_context` contract locally.** + The field model, reserved key, supported versions, and dimension allow-list are generated from + `https://schemas.u10d.dev/invocation-context/v1.json`. The handwritten + `unstructured_platform_plugins.invocation_context` adapter retains transport error mapping and + the equal-length batch invariant that JSON Schema cannot express. The ratified + `https://schemas.u10d.dev/errors/audience/v1.json` vocabulary also replaces the redundant + top-level `blame` response field: a legacy `UserError` now carries a complete `plugin_error` + metadata object with `audience=user`. * **Every wrapped app installs it at construction.** The reserved `invocation_settings` / `invocation_context` fields are handled outside the generated handler schema, the opaque settings payload is delegated to `utic-invocation-settings`, and only the final resolved mapping diff --git a/scripts/generate_invocation_contracts.py b/scripts/generate_invocation_contracts.py new file mode 100644 index 0000000..f502a6f --- /dev/null +++ b/scripts/generate_invocation_contracts.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Generate local Python bindings from the ratified invocation schema IDs.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT_DIR = ROOT / "unstructured_platform_plugins" / "generated" +API_ROOT = "repos/Unstructured-IO/schemas-experimental/contents" + +CONTRACTS = { + "invocation_context_v1.py": { + "schema": f"{API_ROOT}/schemas/ratified-types/invocation-context/v1.json?ref=main", + "typeviz": f"{API_ROOT}/web/public/typeviz/invocation-context-v1.json?ref=main", + "schema_id": "https://schemas.u10d.dev/invocation-context/v1.json", + "root_type": "InvocationContext", + }, + "error_audience_v1.py": { + "schema": f"{API_ROOT}/schemas/ratified-types/errors/audience/v1.json?ref=main", + "typeviz": f"{API_ROOT}/web/public/typeviz/errors-audience-v1.json?ref=main", + "schema_id": "https://schemas.u10d.dev/errors/audience/v1.json", + "root_type": "ErrorAudience", + }, +} + + +def _load_json(api_path: str) -> dict[str, Any]: + completed = subprocess.run( + ["gh", "api", "-H", "Accept: application/vnd.github.raw+json", api_path], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def _python_binding(sidecar: dict[str, Any]) -> str: + for binding in sidecar["bindings"]: + if binding["key"] == "python": + return binding["code"].rstrip() + "\n" + raise ValueError("typeviz sidecar has no Python binding") + + +def _render(spec: dict[str, str]) -> str: + schema = _load_json(spec["schema"]) + sidecar = _load_json(spec["typeviz"]) + if schema["$id"] != spec["schema_id"]: + raise ValueError(f"unexpected schema id: {schema['$id']}") + if sidecar["root_type_name"] != spec["root_type"]: + raise ValueError(f"unexpected root type: {sidecar['root_type_name']}") + + canonical = json.dumps(schema, sort_keys=True, separators=(",", ":")).encode() + schema_hash = hashlib.sha256(canonical).hexdigest() + generated = _python_binding(sidecar) + if spec["root_type"] == "InvocationContext": + generated = generated.replace( + "from typing import Any, Literal", "from typing import Literal" + ) + else: + generated = generated.replace("\nfrom pydantic import BaseModel, Field\n", "") + provenance = ( + "\n# Generation provenance used by drift tests and reviewers.\n" + f"SCHEMA_ID = {schema['$id']!r}\n" + f"SCHEMA_SHA256 = {schema_hash!r}\n" + ) + if spec["root_type"] == "InvocationContext": + policy = schema["x-unstructured-version-policy"] + provenance += ( + f"RESERVED_CONTEXT_KEY = {schema['x-unstructured-reserved-key']!r}\n" + f"SUPPORTED_CONTEXT_VERSIONS = frozenset({policy['supported']!r})\n" + f"DIMENSION_FIELDS = {tuple(schema['x-unstructured-dimension-fields'])!r}\n" + ) + return ( + "# Generated by scripts/generate_invocation_contracts.py; do not edit by hand.\n" + "# ruff: noqa: E501\n" + f"# Source: {spec['schema_id']}\n" + + generated + + provenance + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + stale: list[str] = [] + for filename, spec in CONTRACTS.items(): + output = OUTPUT_DIR / filename + rendered = _render(spec) + if args.check: + if not output.exists() or output.read_text() != rendered: + stale.append(str(output.relative_to(ROOT))) + else: + output.write_text(rendered) + + if stale: + print("stale generated invocation contracts:", *stale, sep="\n ", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/api/test_api.py b/test/api/test_api.py index ffdfa6e..0961c05 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -18,15 +18,24 @@ UsageData, wrap_in_fastapi, ) +from unstructured_platform_plugins.generated.error_audience_v1 import ErrorAudience from unstructured_platform_plugins.schema.filedata_meta import FileDataMeta +class PluginErrorMetadata(BaseModel): + error_type: str + error_reason: str + dependency: Optional[str] = None + audience: Optional[ErrorAudience] = None + retryable: bool = False + + class InvokeResponse(BaseModel): usage: list[UsageData] status_code: int filedata_meta: FileDataMeta status_code_text: Optional[str] = None - blame: Optional[str] = None + plugin_error: Optional[PluginErrorMetadata] = None output: Optional[Any] = None file_data: Optional[Union[FileData, BatchFileData]] = None @@ -226,8 +235,8 @@ def test_http_exception_handling(file_data): @pytest.mark.parametrize( "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] ) -def test_user_error_declares_user_blame(file_data): - """Only the UserError family may claim the failure is the customer's to fix.""" +def test_user_error_declares_canonical_user_audience(file_data): + """Only the UserError family declares a user-actionable plugin error.""" from test.assets.exception_status_code import function_raises_user_error as test_fn client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) @@ -236,13 +245,17 @@ def test_user_error_declares_user_blame(file_data): invoke_response = InvokeResponse.model_validate(resp.json()) assert invoke_response.status_code >= 400 - assert invoke_response.blame == "user" + assert invoke_response.plugin_error is not None + assert invoke_response.plugin_error.audience is ErrorAudience.USER + assert invoke_response.plugin_error.error_type == "configuration" + assert invoke_response.plugin_error.error_reason == "invalid_input" + assert invoke_response.plugin_error.retryable is False @pytest.mark.parametrize( "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] ) -def test_non_user_failures_declare_no_blame(file_data): +def test_non_user_failures_declare_no_plugin_error(file_data): """Anything undeclared is not the customer's: an orchestrator must not infer customer fault from the status code, which also carries transport semantics.""" from test.assets.exception_status_code import function_raises_provider_error as test_fn @@ -252,11 +265,11 @@ def test_non_user_failures_declare_no_blame(file_data): resp = client.post("/invoke", json={"file_data": file_data.model_dump()}) invoke_response = InvokeResponse.model_validate(resp.json()) - assert invoke_response.blame is None + assert invoke_response.plugin_error is None -def test_streaming_user_error_declares_user_blame(): - """The streaming error envelope carries the same blame derivation as the non-streaming path.""" +def test_streaming_user_error_declares_user_audience(): + """The streaming error envelope carries the same audience as the non-streaming path.""" from test.assets.exception_status_code import ( async_gen_function_raises_user_error_mid_stream as test_fn, ) @@ -273,10 +286,11 @@ def test_streaming_user_error_declares_user_blame(): lines = resp.content.decode().strip().split("\n") assert len(lines) == 2 # One yielded item, then the error envelope - assert InvokeResponse.model_validate(json.loads(lines[0])).blame is None + assert InvokeResponse.model_validate(json.loads(lines[0])).plugin_error is None error_response = InvokeResponse.model_validate(json.loads(lines[1])) assert error_response.status_code >= 400 - assert error_response.blame == "user" + assert error_response.plugin_error is not None + assert error_response.plugin_error.audience is ErrorAudience.USER @pytest.mark.parametrize( @@ -666,11 +680,11 @@ def test_precheck_reports_failure_category_from_raised_error(): assert body["status_code"] == 403 assert body["failure_category"] == "AUTH_PERMISSION_DENIED" # A plain exception is not the customer's to fix. - assert body["blame"] is None + assert body["plugin_error"] is None assert "credential rejected" in body["status_code_text"] -def test_precheck_declares_user_blame_like_invoke_does(): +def test_precheck_declares_user_audience_like_invoke_does(): from unstructured_ingest.error import UserError def user_fault_precheck() -> None: @@ -682,7 +696,7 @@ def user_fault_precheck() -> None: body = client.get("/precheck").json() - assert body["blame"] == "user" + assert body["plugin_error"]["audience"] == "user" def test_precheck_success_has_no_failure_category(): diff --git a/test/api/test_invocation_context.py b/test/api/test_invocation_context.py index d5d6fd2..90bdec6 100644 --- a/test/api/test_invocation_context.py +++ b/test/api/test_invocation_context.py @@ -11,6 +11,7 @@ SealedDagNodeSettingsRequiredError, ) +from unstructured_platform_plugins.generated import error_audience_v1, invocation_context_v1 from unstructured_platform_plugins.invocation_context import ( RESERVED_CONTEXT_KEY, InvocationContext, @@ -32,6 +33,28 @@ } +def test_local_bindings_name_the_ratified_schema_ids(): + assert ( + invocation_context_v1.SCHEMA_ID + == "https://schemas.u10d.dev/invocation-context/v1.json" + ) + assert error_audience_v1.SCHEMA_ID == "https://schemas.u10d.dev/errors/audience/v1.json" + assert invocation_context_v1.RESERVED_CONTEXT_KEY == RESERVED_CONTEXT_KEY + assert invocation_context_v1.DIMENSION_FIELDS == ( + "invocation_id", + "tenant_id", + "org_id", + "job_id", + "workflow_id", + "attribution_id", + "dag_node_id", + "dag_node_type", + "dag_node_subtype", + "record_id", + "attempt", + ) + + def test_extracts_identity_fields_from_body(): context = extract_context({"file_data": {"path": "x"}, RESERVED_CONTEXT_KEY: VALID}) assert context is not None diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 8f06ef0..767ae3f 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -25,6 +25,7 @@ get_schema_dict, map_inputs, ) +from unstructured_platform_plugins.generated.error_audience_v1 import ErrorAudience from unstructured_platform_plugins.invocation_settings import ( add_metadata_route, current_invocation_context, @@ -52,6 +53,16 @@ class MessageChannels(BaseModel): warnings: list[str] = Field(default_factory=list) +class PluginErrorMetadata(BaseModel): + """Canonical metadata nested at ``plugin_error`` on a failed legacy response.""" + + error_type: str + error_reason: str + dependency: Optional[str] = None + audience: Optional[ErrorAudience] = None + retryable: bool = False + + def log_func_and_body(func: Callable, body: Optional[str] = None) -> None: msg = None if logger.level == LOG_LEVELS.get("debug", logging.NOTSET): @@ -100,13 +111,20 @@ def failure_category_of(error: BaseException) -> Optional[str]: return category if isinstance(category, str) else None -def blame_of(error: BaseException) -> Optional[str]: - """Return ``user`` only for failures in something the customer owns. +def plugin_error_of(error: BaseException) -> Optional[PluginErrorMetadata]: + """Map the legacy UserError family onto the canonical plugin-error envelope. - An absent value means not-the-customer's: orchestrators must not infer customer fault from an - HTTP status code, which also carries transport semantics. + The ratified ErrorAudience vocabulary owns the wire spelling. A non-user failure remains + unclassified: orchestrators must not infer actionability from its HTTP status. """ - return "user" if isinstance(error, UserError) else None + if not isinstance(error, UserError): + return None + return PluginErrorMetadata( + error_type="configuration", + error_reason=failure_category_of(error) or "invalid_input", + audience=ErrorAudience.USER, + retryable=False, + ) def status_code_of(error: BaseException) -> int: @@ -227,8 +245,7 @@ class InvokeResponse(BaseModel): filedata_meta: Optional[filedata_meta_model] = None status_code_text: Optional[str] = None failure_category: Optional[str] = None - # Absent means not-the-customer's; see blame_of(). - blame: Optional[str] = None + plugin_error: Optional[PluginErrorMetadata] = None output: Optional[response_type] = None message_channels: MessageChannels = Field(default_factory=MessageChannels) @@ -291,7 +308,7 @@ async def _stream_response(): status_code=status_code_of(e), status_code_text=f"[{type(e).__name__}] {_safe_str(e)}", failure_category=failure_category_of(e), - blame=blame_of(e), + plugin_error=plugin_error_of(e), ).model_dump_json() + "\n" ) @@ -336,7 +353,7 @@ async def _stream_response(): status_code=status_code_of(exc), status_code_text=_safe_str(exc), failure_category=failure_category_of(exc), - blame=blame_of(exc), + plugin_error=plugin_error_of(exc), file_data=request_dict.get("file_data", None), ) except Exception as invoke_error: @@ -414,8 +431,7 @@ class InvokePrecheckResponse(BaseModel): status_code: int status_code_text: Optional[str] = None failure_category: Optional[str] = None - # Absent means not-the-customer's; see blame_of(). - blame: Optional[str] = None + plugin_error: Optional[PluginErrorMetadata] = None @fastapi_app.get("/schema") async def get_schema() -> SchemaOutputResponse: @@ -431,7 +447,7 @@ async def run_precheck() -> InvokePrecheckResponse: status_code=fn_response.status_code, status_code_text=fn_response.status_code_text, failure_category=fn_response.failure_category, - blame=fn_response.blame, + plugin_error=fn_response.plugin_error, usage=fn_response.usage, ) else: diff --git a/unstructured_platform_plugins/generated/__init__.py b/unstructured_platform_plugins/generated/__init__.py new file mode 100644 index 0000000..91143b5 --- /dev/null +++ b/unstructured_platform_plugins/generated/__init__.py @@ -0,0 +1 @@ +"""Schema-generated local wire-contract bindings.""" diff --git a/unstructured_platform_plugins/generated/error_audience_v1.py b/unstructured_platform_plugins/generated/error_audience_v1.py new file mode 100644 index 0000000..dc52918 --- /dev/null +++ b/unstructured_platform_plugins/generated/error_audience_v1.py @@ -0,0 +1,28 @@ +# Generated by scripts/generate_invocation_contracts.py; do not edit by hand. +# ruff: noqa: E501 +# Source: https://schemas.u10d.dev/errors/audience/v1.json +"""Error Audience — generated from the ratified JSON Schema. +https://schemas.u10d.dev/errors/audience/v1 + +Non-null actionability audience carried by plugin_error.audience; distinct from technical fault- +source classifications such as utic_invocation_settings.Blame +""" + +from __future__ import annotations + +from enum import Enum + + +class ErrorAudienceValue(str, Enum): + """Who can act to resolve an error.""" + USER = "user" + PLATFORM = "platform" + + +# Non-null actionability audience carried by plugin_error.audience; distinct from technical +# fault-source classifications such as utic_invocation_settings.Blame +ErrorAudience = ErrorAudienceValue + +# Generation provenance used by drift tests and reviewers. +SCHEMA_ID = 'https://schemas.u10d.dev/errors/audience/v1.json' +SCHEMA_SHA256 = '36d017a1b132d0c06ded5a60376c425e8d5a5e681f134cfdc4dcb1fa4c9498d8' diff --git a/unstructured_platform_plugins/generated/invocation_context_v1.py b/unstructured_platform_plugins/generated/invocation_context_v1.py new file mode 100644 index 0000000..e7e70c5 --- /dev/null +++ b/unstructured_platform_plugins/generated/invocation_context_v1.py @@ -0,0 +1,89 @@ +# Generated by scripts/generate_invocation_contracts.py; do not edit by hand. +# ruff: noqa: E501 +# Source: https://schemas.u10d.dev/invocation-context/v1.json +"""Invocation Context — generated from the ratified JSON Schema. +https://schemas.u10d.dev/invocation-context/v1 + +Versioned request-scoped identity and correlation context carried in the reserved +invocation_context field of a plugin /invoke body +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class InvocationContext(BaseModel): + """ + Versioned request-scoped identity and correlation context carried in the reserved + invocation_context field of a plugin /invoke body + """ + + model_config = ConfigDict(extra="allow") + + schema_version: Literal["1"] = Field( + default="1", + description="Payload contract version. A missing value defaults to v1; consumers reject an unknown value rather than silently dropping attribution. Additive fields do not change this value.", + ) + invocation_id: str | None = Field( + default=None, + description="Correlation identifier for this individual invocation.", + ) + job_id: str | None = Field(default=None, description="Job that owns the invocation.") + workflow_id: str | None = Field( + default=None, + description="Workflow that owns the job, when known.", + ) + attribution_id: str | None = Field( + default=None, + description="Platform attribution identifier associated with the invocation.", + ) + tenant_id: str | None = Field( + default=None, + description="Tenant identity to which the invocation is attributed.", + ) + org_id: str | None = Field( + default=None, + description="Organization identity to which the invocation is attributed.", + ) + dag_node_id: str | None = Field( + default=None, + description="Identifier of the DAG node being invoked.", + ) + dag_node_type: str | None = Field( + default=None, + description="Plugin type of the DAG node being invoked.", + ) + dag_node_subtype: str | None = Field( + default=None, + description="Plugin subtype of the DAG node being invoked.", + ) + record_id: str | None = Field( + default=None, + description="Record being processed by a single-record invocation.", + ) + attempt: int | None = Field( + default=None, + description="Attempt number associated with the claimed work.", + ) + job_created_timestamp: str | None = Field( + default=None, + description="Job creation timestamp forwarded for lifecycle timing. It is context metadata, not a telemetry dimension.", + ) + record_ids: list[str] | None = Field( + default=None, + description="Record identifiers in a batch invocation. When invocation_ids is also present, the two arrays are index-aligned.", + ) + invocation_ids: list[str | None] | None = Field( + default=None, + description="Per-record invocation identifiers for a batch invocation. Entry i identifies record_ids[i]; null means that record carried no invocation identifier.", + ) + +# Generation provenance used by drift tests and reviewers. +SCHEMA_ID = 'https://schemas.u10d.dev/invocation-context/v1.json' +SCHEMA_SHA256 = 'ca64be22a104acc697b7aff0fb47633ad75fa30cd88a0c92259af732fa1023c0' +RESERVED_CONTEXT_KEY = 'invocation_context' +SUPPORTED_CONTEXT_VERSIONS = frozenset(['1']) +DIMENSION_FIELDS = ('invocation_id', 'tenant_id', 'org_id', 'job_id', 'workflow_id', 'attribution_id', 'dag_node_id', 'dag_node_type', 'dag_node_subtype', 'record_id', 'attempt') diff --git a/unstructured_platform_plugins/invocation_context.py b/unstructured_platform_plugins/invocation_context.py index 7534b35..54f406f 100644 --- a/unstructured_platform_plugins/invocation_context.py +++ b/unstructured_platform_plugins/invocation_context.py @@ -25,29 +25,13 @@ import pydantic from utic_invocation_settings import Blame, InvocationSettingsError, MalformedEnvelopeError -# Reserved key carrying the invocation context in the invoke request body. -RESERVED_CONTEXT_KEY = "invocation_context" - -# Context payload versions this package understands. Additive keys do not bump this; changing an -# existing key's meaning incompatibly does. -SUPPORTED_CONTEXT_VERSIONS = frozenset({"1"}) - -# The identity facets that become telemetry dimensions. Shared rather than per-service policy: -# every hop on one invocation's path has to pick the same fields, or the same request is attributed -# differently depending on which component emitted the event. Excludes the batch fields, which -# describe the work rather than who it belongs to. -DIMENSION_FIELDS = ( - "invocation_id", - "tenant_id", - "org_id", - "job_id", - "workflow_id", - "attribution_id", - "dag_node_id", - "dag_node_type", - "dag_node_subtype", - "record_id", - "attempt", +from unstructured_platform_plugins.generated.invocation_context_v1 import ( + DIMENSION_FIELDS, + RESERVED_CONTEXT_KEY, + SUPPORTED_CONTEXT_VERSIONS, +) +from unstructured_platform_plugins.generated.invocation_context_v1 import ( + InvocationContext as _GeneratedInvocationContext, ) # Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value. @@ -68,37 +52,14 @@ class UnsupportedContextVersionError(InvocationSettingsError): blame = Blame.CONTENT -class InvocationContext(pydantic.BaseModel): +class InvocationContext(_GeneratedInvocationContext): """Request-scoped identity delivered alongside one claimed unit of work. - ``extra="allow"`` keeps additive fields available through ``model_extra`` instead of silently - dropping them. + The field shape, version literal, extra-field policy, reserved key, and dimensions are generated + from the ratified schema. This adapter retains the cross-field invariant JSON Schema cannot + express. """ - model_config = pydantic.ConfigDict(extra="allow") - - schema_version: str = "1" - - invocation_id: str | None = None - job_id: str | None = None - workflow_id: str | None = None - attribution_id: str | None = None - tenant_id: str | None = None - org_id: str | None = None - dag_node_id: str | None = None - dag_node_type: str | None = None - dag_node_subtype: str | None = None - record_id: str | None = None - attempt: int | None = None - job_created_timestamp: str | None = None - - # Added by the controller on the way to the plugin, not by the work API. The batch pair is - # index-aligned: entry i of `invocation_ids` is the invocation id of record i, or None where - # that record carried no context. There is deliberately no `work_dir` field: scratch space is - # the plugin's implementation detail (tempfile / uuid-named paths), not invoke-contract surface. - record_ids: list[str] | None = None - invocation_ids: list[str | None] | None = None - @pydantic.model_validator(mode="after") def _batch_lists_stay_index_aligned(self) -> "InvocationContext": if ( @@ -112,17 +73,6 @@ def _batch_lists_stay_index_aligned(self) -> "InvocationContext": ) return self - @pydantic.field_validator("schema_version") - @classmethod - def _known_version(cls, value: str) -> str: - if value not in SUPPORTED_CONTEXT_VERSIONS: - raise ValueError( - f"unsupported invocation_context schema_version {value!r}; " - f"this package understands {sorted(SUPPORTED_CONTEXT_VERSIONS)}" - ) - return value - - def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None: """Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``. From 069b3a7b5609fcfdde4d27624201aadc03d248ba Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Tue, 1 Sep 2026 12:35:14 -0400 Subject: [PATCH 31/32] chore(deps): consume released invocation settings --- pyproject.toml | 4 ---- uv.lock | 7 +++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce7d92a..9da9f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,3 @@ fail_under = 15 [tool.hatch.build.targets.sdist] packages = ["/unstructured_platform_plugins"] - -[tool.uv.sources] -# Temporary source pin until v2-capable utic-invocation-settings 0.5.0 is published. -utic-invocation-settings = { git = "https://github.com/Unstructured-IO/utic-public-libs", subdirectory = "libs/utic-invocation-settings", rev = "98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b" } diff --git a/uv.lock b/uv.lock index 5fad0c9..d76420e 100644 --- a/uv.lock +++ b/uv.lock @@ -1352,7 +1352,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi" }, { name = "requests" }, { name = "unstructured-ingest" }, - { name = "utic-invocation-settings", git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b" }, + { name = "utic-invocation-settings", specifier = ">=0.5.0,<1.0.0" }, { name = "uvicorn" }, ] @@ -1381,11 +1381,14 @@ wheels = [ [[package]] name = "utic-invocation-settings" version = "0.5.0" -source = { git = "https://github.com/Unstructured-IO/utic-public-libs?subdirectory=libs%2Futic-invocation-settings&rev=98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b#98819b0bf9ef4a62fbbdeeed382c2dfea4937a2b" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pydantic" }, ] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/df/8443bd23f6c0cbf0f179a45dd0e318b6aabd42e231ac6cf727a5f0206915/utic_invocation_settings-0.5.0-py3-none-any.whl", hash = "sha256:75788c7f1cea5b033bdb1a40e19f35c6a3326ce9b016d12039353a78765ecb7e", size = 78072, upload-time = "2026-09-01T16:31:34.114Z" }, +] [[package]] name = "uvicorn" From 18beb28dab4bf7b93e65db05c031dc62b0f08af4 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Tue, 1 Sep 2026 20:23:21 -0400 Subject: [PATCH 32/32] feat(observability): attach invocation context to request spans --- test/api/test_invocation_envelope.py | 34 +++++++++++++++++++ .../invocation_settings.py | 11 ++++++ 2 files changed, 45 insertions(+) diff --git a/test/api/test_invocation_envelope.py b/test/api/test_invocation_envelope.py index 127b3dd..0565aa5 100644 --- a/test/api/test_invocation_envelope.py +++ b/test/api/test_invocation_envelope.py @@ -173,6 +173,40 @@ def test_binds_reserved_fields(self): assert recorder.seen_settings == {"model": "m"} assert recorder.seen_context.job_id == "job-1" + def test_context_dimensions_are_attached_to_the_current_span(self, monkeypatch): + attributes = {} + + class _Span: + def set_attribute(self, key, value): + attributes[key] = value + + monkeypatch.setattr( + invocation_settings_transport.trace, + "get_current_span", + lambda: _Span(), + ) + + _, response = _post_invoke( + { + "invocation_context": { + "schema_version": "1", + "tenant_id": "tenant-1", + "job_id": "job-1", + "attempt": 2, + "record_ids": ["record-1"], + "invocation_ids": ["invocation-1"], + "future_field": "preserved-but-not-a-dimension", + } + } + ) + + assert response.status_code == 200 + assert attributes == { + "tenant_id": "tenant-1", + "job_id": "job-1", + "attempt": 2, + } + def test_absent_fields_bind_none(self): recorder, response = _post_invoke({"element_dicts": "/in.json"}) diff --git a/unstructured_platform_plugins/invocation_settings.py b/unstructured_platform_plugins/invocation_settings.py index 5177d3d..8606e8f 100644 --- a/unstructured_platform_plugins/invocation_settings.py +++ b/unstructured_platform_plugins/invocation_settings.py @@ -43,6 +43,7 @@ from fastapi import Depends, FastAPI, Request from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exceptions import RequestValidationError +from opentelemetry import trace from starlette.requests import ClientDisconnect from starlette.responses import JSONResponse from starlette.routing import get_route_path @@ -59,6 +60,7 @@ from unstructured_platform_plugins.invocation_context import ( RESERVED_CONTEXT_KEY, InvocationContext, + dimensions, extract_context, ) @@ -282,6 +284,15 @@ async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]: status, {"detail": detail, "reason": exc.reason} ) from exc + # Keep plugin-side request spans aligned with the controller's wide-event dimensions. The + # ratified DIMENSION_FIELDS list deliberately excludes batch correlation and unknown additive + # fields, so only the shared invocation identity is promoted to indexed telemetry. + invocation_dimensions = dimensions(invocation_context) + if invocation_dimensions: + span = trace.get_current_span() + for key, value in invocation_dimensions.items(): + span.set_attribute(key, value) + with invocation_envelope(invocation_settings, invocation_context): yield