Skip to content

Commit 43a7f67

Browse files
committed
feat(etl-uvicorn): install invocation-settings handling (0.1.0)
1 parent 6a88783 commit 43a7f67

6 files changed

Lines changed: 177 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
## 0.1.0
2+
3+
* **The wrapper now installs the invocation-settings envelope handling itself.** Every wrapped app
4+
gets the `utic-invocation-settings` ASGI middleware and a `/metadata` route at construction: the
5+
reserved `invocation_settings` / `invocation_context` fields are handled outside the generated
6+
handler schema, a sealed `dag_node_settings` member is decrypted with the configured private
7+
key, and the resolved values are exposed request-scoped through
8+
`current_invocation_settings()` / `current_invocation_context()`. Missing fields preserve the
9+
existing fallback behavior; when `FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS` is enabled,
10+
missing or plaintext settings fail closed. Repeated installation is safe: the middleware
11+
installs once and the last `/metadata` registration wins.
12+
* **New opt-in `invoke_with_sealed_dag_node_settings` advertisement.** Pass
13+
`invoke_with_sealed_dag_node_settings=True` to `wrap_in_fastapi` / `generate_fast_api` (or
14+
`--sealed-dag-node-settings` on the CLI) only for a plugin that consumes per-invoke settings;
15+
it advertises that the application accepts and consumes sealed per-invocation settings.
16+
A plugin that serves a custom `/metadata` payload must register it via `add_metadata_route`
17+
(which replaces the wrapper's route) — a plain `@app.get("/metadata")` added after construction
18+
is shadowed by the wrapper's earlier registration.
19+
* **Sync plugin functions now observe request-scoped context.** `invoke_func` copies the current
20+
context into the executor thread; previously `run_in_executor` dropped contextvars, so a sync
21+
function reading a request-scoped binding (such as `current_invocation_settings()`) would see
22+
it as absent and could take an unintended fallback path.
23+
* **Python floor is now 3.11** (required by `utic-invocation-settings`).
24+
125
## 0.0.45
226

327
* **`/invoke` no longer demands a body from a plugin whose parameters are all optional.** A pydantic

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "unstructured_platform_plugins"
33
description = "Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform"
4-
requires-python = ">=3.10"
4+
requires-python = ">=3.11"
55
classifiers = [
66
"Development Status :: 4 - Beta",
77
"Intended Audience :: Developers",
@@ -10,7 +10,6 @@ classifiers = [
1010
"License :: OSI Approved :: Apache Software License",
1111
"Operating System :: OS Independent",
1212
"Programming Language :: Python :: 3",
13-
"Programming Language :: Python :: 3.10",
1413
"Programming Language :: Python :: 3.11",
1514
"Programming Language :: Python :: 3.12",
1615
"Programming Language :: Python :: 3.13",
@@ -24,6 +23,7 @@ dependencies = [
2423
"fastapi",
2524
"click",
2625
"unstructured-ingest",
26+
"utic-invocation-settings>=0.3.0,<1.0.0",
2727
"opentelemetry-instrumentation-fastapi",
2828
"opentelemetry-exporter-otlp-proto-grpc",
2929
"dataclasses-json"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""The wrapper-installed invocation-settings surface: /metadata and reserved-field binding."""
2+
3+
from typing import Optional
4+
5+
from fastapi.testclient import TestClient
6+
from pydantic import BaseModel
7+
from utic_invocation_settings import current_invocation_settings
8+
9+
from unstructured_platform_plugins.etl_uvicorn.api_generator import wrap_in_fastapi
10+
11+
12+
class _Echo(BaseModel):
13+
content: str
14+
settings: Optional[dict]
15+
16+
17+
def _echo_settings(content: str) -> _Echo:
18+
return _Echo(content=content, settings=current_invocation_settings())
19+
20+
21+
def test_metadata_route_is_registered_with_default_capabilities():
22+
client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin"))
23+
24+
resp = client.get("/metadata")
25+
26+
assert resp.status_code == 200
27+
payload = resp.json()
28+
assert payload["identifier"] == "mock_plugin"
29+
assert payload["capabilities"] == ["invocation_settings", "invocation_context"]
30+
31+
32+
def test_sealed_capability_is_opt_in():
33+
client = TestClient(
34+
wrap_in_fastapi(
35+
func=_echo_settings,
36+
plugin_id="mock_plugin",
37+
invoke_with_sealed_dag_node_settings=True,
38+
)
39+
)
40+
41+
payload = client.get("/metadata").json()
42+
43+
assert "invoke_with_sealed_dag_node_settings" in payload["capabilities"]
44+
45+
46+
def test_reserved_settings_field_binds_without_appearing_in_schema():
47+
app = wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin")
48+
client = TestClient(app)
49+
50+
resp = client.post(
51+
"/invoke",
52+
json={"content": "hello", "invocation_settings": {"model": "m"}},
53+
)
54+
55+
assert resp.status_code == 200
56+
output = resp.json()["output"]
57+
assert output == {"content": "hello", "settings": {"model": "m"}}
58+
# The wrapper does not add the reserved field to the generated handler input model.
59+
openapi = app.openapi()
60+
request_schema = openapi["paths"]["/invoke"]["post"]["requestBody"]["content"][
61+
"application/json"
62+
]["schema"]
63+
schema_name = request_schema["$ref"].rsplit("/", 1)[-1]
64+
properties = openapi["components"]["schemas"][schema_name]["properties"]
65+
assert "invocation_settings" not in properties
66+
67+
68+
def test_sync_function_sees_bound_settings_across_the_executor():
69+
# Sync functions run in an executor thread; the context must be copied there or the
70+
# request-scoped binding would read as absent.
71+
def sync_echo(content: str) -> _Echo:
72+
return _Echo(content=content, settings=current_invocation_settings())
73+
74+
client = TestClient(wrap_in_fastapi(func=sync_echo, plugin_id="mock_plugin"))
75+
76+
resp = client.post(
77+
"/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}}
78+
)
79+
80+
assert resp.json()["output"]["settings"] == {"model": "m"}
81+
82+
83+
async def _async_echo(content: str) -> _Echo:
84+
return _Echo(content=content, settings=current_invocation_settings())
85+
86+
87+
def test_async_function_sees_bound_settings():
88+
client = TestClient(wrap_in_fastapi(func=_async_echo, plugin_id="mock_plugin"))
89+
90+
resp = client.post(
91+
"/invoke", json={"content": "hello", "invocation_settings": {"model": "m"}}
92+
)
93+
94+
assert resp.json()["output"]["settings"] == {"model": "m"}
95+
96+
97+
def test_absent_reserved_fields_bind_none():
98+
client = TestClient(wrap_in_fastapi(func=_echo_settings, plugin_id="mock_plugin"))
99+
100+
resp = client.post("/invoke", json={"content": "hello"})
101+
102+
assert resp.status_code == 200
103+
assert resp.json()["output"] == {"content": "hello", "settings": None}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.45" # pragma: no cover
1+
__version__ = "0.1.0" # pragma: no cover

unstructured_platform_plugins/etl_uvicorn/api_generator.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import contextvars
23
import hashlib
34
import inspect
45
import json
@@ -14,6 +15,7 @@
1415
from typing_extensions import deprecated
1516
from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict
1617
from unstructured_ingest.error import UnstructuredIngestError
18+
from utic_invocation_settings import add_metadata_route, install_invocation_envelope
1719
from uvicorn.config import LOG_LEVELS
1820
from uvicorn.importer import import_from_string
1921

@@ -67,7 +69,13 @@ async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -
6769
if inspect.iscoroutinefunction(func):
6870
return await func(**kwargs)
6971
else:
70-
return await asyncio.get_event_loop().run_in_executor(None, partial(func, **kwargs))
72+
# run_in_executor does not propagate contextvars, so without copying the context a sync
73+
# plugin would observe request-scoped bindings (current_invocation_settings and friends)
74+
# as absent and could take an unintended fallback path.
75+
ctx = contextvars.copy_context()
76+
return await asyncio.get_event_loop().run_in_executor(
77+
None, ctx.run, partial(func, **kwargs)
78+
)
7179

7280

7381
def check_precheck_func(precheck_func: Callable):
@@ -117,9 +125,15 @@ def wrap_in_fastapi(
117125
func: Callable,
118126
plugin_id: str,
119127
precheck_func: Optional[Callable] = None,
128+
invoke_with_sealed_dag_node_settings: bool = False,
120129
) -> FastAPI:
121130
try:
122-
return _wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func)
131+
return _wrap_in_fastapi(
132+
func=func,
133+
plugin_id=plugin_id,
134+
precheck_func=precheck_func,
135+
invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings,
136+
)
123137
except Exception as e:
124138
logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True)
125139
raise EtlApiException(e) from e
@@ -129,6 +143,7 @@ def _wrap_in_fastapi(
129143
func: Callable,
130144
plugin_id: str,
131145
precheck_func: Optional[Callable] = None,
146+
invoke_with_sealed_dag_node_settings: bool = False,
132147
) -> FastAPI:
133148
if precheck_func is not None:
134149
check_precheck_func(precheck_func=precheck_func)
@@ -347,6 +362,19 @@ async def get_id() -> str:
347362
except TypeError as e:
348363
raise TypeError(f"failed to validate function schema: {e}") from e
349364

365+
# The middleware handles the reserved /invoke fields (invocation_settings and
366+
# invocation_context) outside the generated handler schema. It resolves sealed settings with
367+
# the configured private key and exposes both values through request-scoped accessors. The
368+
# sealed-settings capability remains opt-in because it asserts that the wrapped function
369+
# consumes current_invocation_settings(), not merely that the host can resolve it. Repeated
370+
# installation is safe: the middleware installs once and the last /metadata registration wins.
371+
add_metadata_route(
372+
fastapi_app,
373+
identifier=plugin_id,
374+
invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings,
375+
)
376+
install_invocation_envelope(fastapi_app)
377+
350378
FastAPIInstrumentor.instrument_app(
351379
fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider()
352380
)
@@ -361,6 +389,7 @@ def generate_fast_api(
361389
id_method: Optional[str] = None,
362390
precheck_str: Optional[str] = None,
363391
precheck_method: Optional[str] = None,
392+
invoke_with_sealed_dag_node_settings: bool = False,
364393
) -> FastAPI:
365394
instance = import_from_string(app)
366395
func = get_func(instance, method_name)
@@ -379,4 +408,9 @@ def generate_fast_api(
379408
elif precheck_method:
380409
precheck_func = get_func(instance, precheck_method)
381410

382-
return wrap_in_fastapi(func=func, plugin_id=plugin_id, precheck_func=precheck_func)
411+
return wrap_in_fastapi(
412+
func=func,
413+
plugin_id=plugin_id,
414+
precheck_func=precheck_func,
415+
invoke_with_sealed_dag_node_settings=invoke_with_sealed_dag_node_settings,
416+
)

unstructured_platform_plugins/etl_uvicorn/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def api_wrapper(
5656
plugin_id_method: Optional[str] = None,
5757
precheck_app: Optional[str] = None,
5858
precheck_app_method: Optional[str] = None,
59+
sealed_dag_node_settings: bool = False,
5960
**kwargs,
6061
):
6162
# Make sure logging is configured before the call to run() so any setup has the same format
@@ -73,6 +74,7 @@ def api_wrapper(
7374
id_method=plugin_id_method,
7475
precheck_str=precheck_app,
7576
precheck_method=precheck_app_method,
77+
invoke_with_sealed_dag_node_settings=sealed_dag_node_settings,
7678
)
7779
# Explicitly map values that are manipulated in the original
7880
# call to run(), preventing **kwargs reference
@@ -130,6 +132,14 @@ def api_wrapper(
130132
"If precheck-app not provided, assumes method "
131133
"lives on main class passes in.",
132134
),
135+
click.Option(
136+
["--sealed-dag-node-settings"],
137+
is_flag=True,
138+
default=False,
139+
help="Advertise the invoke_with_sealed_dag_node_settings capability on "
140+
"/metadata. Set only for a plugin that consumes per-invoke settings "
141+
"through current_invocation_settings().",
142+
),
133143
]
134144
)
135145
return cmd

0 commit comments

Comments
 (0)