Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
461be64
feat(etl-uvicorn): install invocation-settings handling (0.1.0)
CyMule Jul 31, 2026
8049666
refactor(etl-uvicorn): own the /invoke transport, not the contract
CyMule Aug 1, 2026
14d63b9
ci: move workflows to the Python 3.11 floor
CyMule Aug 3, 2026
b32f096
feat(etl-uvicorn): settings-scoped cache for per-invoke derived state
CyMule Aug 4, 2026
472491c
fix(etl-uvicorn): map context errors through the blame taxonomy
CyMule Aug 4, 2026
3df816d
feat(etl-uvicorn): declare blame in failure responses instead of enco…
CyMule Aug 4, 2026
855881e
feat(etl-uvicorn): own the invocation-context model and the blame sta…
CyMule Aug 5, 2026
8dc9cae
refactor(etl-uvicorn): bind /invoke envelope without body replay (#75)
CyMule Aug 10, 2026
3a809fe
fix(etl-uvicorn): declare user blame on the streaming error path
CyMule Aug 13, 2026
d7cea08
fix(etl-uvicorn): match /invoke by the route path under a rooted depl…
CyMule Aug 13, 2026
dcabc9b
fix(etl-uvicorn): reject a repeat envelope install with a different b…
CyMule Aug 13, 2026
1e7408a
chore(etl-uvicorn): describe context extraction as a route dependency
CyMule Aug 13, 2026
f8b5104
build(etl-uvicorn): pin unpublished invocation-settings source
CyMule Aug 25, 2026
a930c88
refactor(etl-uvicorn): simplify invocation failure plumbing
CyMule Aug 25, 2026
1a7d177
refactor(etl-uvicorn): delegate field-atomic settings resolution
CyMule Aug 26, 2026
292b3ed
refactor(etl-uvicorn): simplify settings resolver integration
CyMule Aug 26, 2026
42fdb29
fix(etl-uvicorn): target invocation settings 0.4.0
Aug 26, 2026
f88e2b6
docs(invocation-settings): make contract guidance timeless
Aug 27, 2026
cc11cfe
build(deps): advance invocation settings pin
Aug 27, 2026
3f06700
feat(invocation-settings): require field-set transport
Aug 27, 2026
72371c4
feat(etl-uvicorn): resolve boot-or-scoped handlers on the settings cache
Aug 27, 2026
e4b4d65
build(deps): advance invocation settings pin
Aug 27, 2026
a05ec45
build(deps): advance invocation settings pin
Aug 27, 2026
02a6153
fix(etl-uvicorn): tighten context validation, cache expiry, and prech…
Aug 27, 2026
3c8495d
feat(etl-uvicorn): align invocation transport with v2 settings
Aug 31, 2026
4706959
fix(cache): re-read clock before insert-time sweep; fix stale CLI fla…
Aug 31, 2026
e80eb09
fix(etl-uvicorn): address invocation transport review
Sep 1, 2026
ee156c6
fix(etl-uvicorn): normalize malformed invoke JSON
Sep 1, 2026
8ae8fb4
fix(etl-uvicorn): preserve nested JSON validation
Sep 1, 2026
cc1fa56
feat(contracts): generate ratified invocation bindings
Sep 1, 2026
069b3a7
chore(deps): consume released invocation settings
Sep 1, 2026
18beb28
feat(observability): attach invocation context to request spans
Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
- published

env:
PYTHON_VERSION: "3.10"
PYTHON_VERSION: "3.11"

jobs:
release:
Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,60 @@
## 0.1.0

* **This package now owns the `/invoke` transport for the reserved fields.**
`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
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
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.
* **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
is exposed through `current_invocation_settings()` / `current_invocation_context()`.
An absent field preserves the existing fallback behaviour; under
`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
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
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;
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
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.** 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
carry the error's class name and never its message, which can embed request-controlled values.
* **Python floor is now 3.11** (required by `utic-invocation-settings`).

## 0.0.46

* **Carry preflight failure categories through standard `/precheck` responses.**
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floor bump for every plugin that consumes this wrapper. Can you confirm no in-tree plugin is still on 3.10? If any is, this pins them out of the settings work entirely rather than just deferring their cutover.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: every in-tree plugin requires Python 3.12+, so the 3.11 floor excludes none.

classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
Expand All @@ -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",
Expand All @@ -24,6 +23,7 @@ dependencies = [
"fastapi",
"click",
"unstructured-ingest",
"utic-invocation-settings>=0.5.0,<1.0.0",
"opentelemetry-instrumentation-fastapi",
"opentelemetry-exporter-otlp-proto-grpc",
"dataclasses-json"
Expand Down
112 changes: 112 additions & 0 deletions scripts/generate_invocation_contracts.py
Original file line number Diff line number Diff line change
@@ -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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the generator runs without the external GitHub CLI, _load_json raises an unhandled FileNotFoundError before producing bindings. Use a declared HTTP client or document and validate gh as a prerequisite with an actionable error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/generate_invocation_contracts.py, line 36:

<comment>When the generator runs without the external GitHub CLI, `_load_json` raises an unhandled `FileNotFoundError` before producing bindings. Use a declared HTTP client or document and validate `gh` as a prerequisite with an actionable error.</comment>

<file context>
@@ -0,0 +1,112 @@
+
+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,
</file context>

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())
88 changes: 88 additions & 0 deletions test/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +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
plugin_error: Optional[PluginErrorMetadata] = None
output: Optional[Any] = None
file_data: Optional[Union[FileData, BatchFileData]] = None

Expand Down Expand Up @@ -222,6 +232,67 @@ 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_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"))

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.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_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

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.plugin_error is None


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,
)

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])).plugin_error is None
error_response = InvokeResponse.model_validate(json.loads(lines[1]))
assert error_response.status_code >= 400
assert error_response.plugin_error is not None
assert error_response.plugin_error.audience is ErrorAudience.USER


@pytest.mark.parametrize(
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
)
Expand Down Expand Up @@ -608,9 +679,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["plugin_error"] is None
assert "credential rejected" in body["status_code_text"]


def test_precheck_declares_user_audience_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["plugin_error"]["audience"] == "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)
Expand Down
Loading
Loading