unstructured-platform-plugins 0.0.46__tar.gz → 0.1.1.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/PKG-INFO +3 -3
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/pyproject.toml +2 -2
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/__init__.py +13 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/__version__.py +1 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/api_generator.py +129 -23
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/main.py +10 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/generated/__init__.py +1 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/generated/error_audience_v1.py +28 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/generated/invocation_context_v1.py +89 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/invocation_context.py +125 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/invocation_scope.py +63 -0
- unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/invocation_settings.py +536 -0
- unstructured_platform_plugins-0.0.46/unstructured_platform_plugins/__version__.py +0 -1
- unstructured_platform_plugins-0.0.46/unstructured_platform_plugins/etl_uvicorn/__init__.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/.gitignore +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/LICENSE.md +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/README.md +0 -0
- {unstructured_platform_plugins-0.0.46/unstructured_platform_plugins → unstructured_platform_plugins-0.1.1.dev1/unstructured_platform_plugins/etl_uvicorn}/__init__.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/otel.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/etl_uvicorn/utils.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/__init__.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/filedata_meta.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/json_schema.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/model.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/usage.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/schema/utils.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/type_hints.py +0 -0
- {unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/unstructured_platform_plugins/validate_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unstructured_platform_plugins
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.1.1.dev1
|
|
4
4
|
Summary: Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform
|
|
5
5
|
License-File: LICENSE.md
|
|
6
6
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -10,12 +10,11 @@ Classifier: Intended Audience :: Science/Research
|
|
|
10
10
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
16
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.11
|
|
19
18
|
Requires-Dist: click
|
|
20
19
|
Requires-Dist: dataclasses-json
|
|
21
20
|
Requires-Dist: fastapi
|
|
@@ -23,6 +22,7 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc
|
|
|
23
22
|
Requires-Dist: opentelemetry-instrumentation-fastapi
|
|
24
23
|
Requires-Dist: requests
|
|
25
24
|
Requires-Dist: unstructured-ingest
|
|
25
|
+
Requires-Dist: utic-invocation-settings<1.0.0,>=0.5.0
|
|
26
26
|
Requires-Dist: uvicorn
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
{unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/pyproject.toml
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unstructured_platform_plugins"
|
|
3
3
|
description = "Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform"
|
|
4
|
-
requires-python = ">=3.
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
5
|
classifiers = [
|
|
6
6
|
"Development Status :: 4 - Beta",
|
|
7
7
|
"Intended Audience :: Developers",
|
|
@@ -10,7 +10,6 @@ classifiers = [
|
|
|
10
10
|
"License :: OSI Approved :: Apache Software License",
|
|
11
11
|
"Operating System :: OS Independent",
|
|
12
12
|
"Programming Language :: Python :: 3",
|
|
13
|
-
"Programming Language :: Python :: 3.10",
|
|
14
13
|
"Programming Language :: Python :: 3.11",
|
|
15
14
|
"Programming Language :: Python :: 3.12",
|
|
16
15
|
"Programming Language :: Python :: 3.13",
|
|
@@ -24,6 +23,7 @@ dependencies = [
|
|
|
24
23
|
"fastapi",
|
|
25
24
|
"click",
|
|
26
25
|
"unstructured-ingest",
|
|
26
|
+
"utic-invocation-settings>=0.5.0,<1.0.0",
|
|
27
27
|
"opentelemetry-instrumentation-fastapi",
|
|
28
28
|
"opentelemetry-exporter-otlp-proto-grpc",
|
|
29
29
|
"dataclasses-json"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from unstructured_platform_plugins.invocation_scope import (
|
|
2
|
+
InvocationIdentity,
|
|
3
|
+
current_invocation_identity,
|
|
4
|
+
invocation_scratch_dir,
|
|
5
|
+
)
|
|
6
|
+
from unstructured_platform_plugins.invocation_settings import current_invocation_context
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"InvocationIdentity",
|
|
10
|
+
"current_invocation_context",
|
|
11
|
+
"current_invocation_identity",
|
|
12
|
+
"invocation_scratch_dir",
|
|
13
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1.dev1" # pragma: no cover
|
|
@@ -3,6 +3,7 @@ import hashlib
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
+
import re
|
|
6
7
|
from typing import Any, Callable, Optional, Union, get_origin
|
|
7
8
|
|
|
8
9
|
from fastapi import FastAPI, HTTPException, status
|
|
@@ -12,7 +13,7 @@ from pydantic import BaseModel, Field, create_model
|
|
|
12
13
|
from starlette.responses import RedirectResponse
|
|
13
14
|
from typing_extensions import deprecated
|
|
14
15
|
from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict
|
|
15
|
-
from unstructured_ingest.error import UnstructuredIngestError
|
|
16
|
+
from unstructured_ingest.error import RateLimitError, UnstructuredIngestError, UserError
|
|
16
17
|
from uvicorn.config import LOG_LEVELS
|
|
17
18
|
from uvicorn.importer import import_from_string
|
|
18
19
|
|
|
@@ -25,6 +26,14 @@ from unstructured_platform_plugins.etl_uvicorn.utils import (
|
|
|
25
26
|
get_schema_dict,
|
|
26
27
|
map_inputs,
|
|
27
28
|
)
|
|
29
|
+
from unstructured_platform_plugins.generated.error_audience_v1 import ErrorAudience
|
|
30
|
+
from unstructured_platform_plugins.invocation_settings import (
|
|
31
|
+
add_metadata_route,
|
|
32
|
+
current_invocation_context,
|
|
33
|
+
current_invocation_settings,
|
|
34
|
+
install_invocation_envelope,
|
|
35
|
+
invocation_envelope,
|
|
36
|
+
)
|
|
28
37
|
from unstructured_platform_plugins.schema import FileDataMeta, NewRecord, UsageData
|
|
29
38
|
from unstructured_platform_plugins.schema.json_schema import (
|
|
30
39
|
schema_to_base_model,
|
|
@@ -45,6 +54,16 @@ class MessageChannels(BaseModel):
|
|
|
45
54
|
warnings: list[str] = Field(default_factory=list)
|
|
46
55
|
|
|
47
56
|
|
|
57
|
+
class PluginErrorMetadata(BaseModel):
|
|
58
|
+
"""Canonical metadata nested at ``plugin_error`` on a failed legacy response."""
|
|
59
|
+
|
|
60
|
+
error_type: str
|
|
61
|
+
error_reason: str
|
|
62
|
+
dependency: Optional[str] = None
|
|
63
|
+
audience: Optional[ErrorAudience] = None
|
|
64
|
+
retryable: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
48
67
|
def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
|
|
49
68
|
msg = None
|
|
50
69
|
if logger.level == LOG_LEVELS.get("debug", logging.NOTSET):
|
|
@@ -93,6 +112,57 @@ def failure_category_of(error: BaseException) -> Optional[str]:
|
|
|
93
112
|
return category if isinstance(category, str) else None
|
|
94
113
|
|
|
95
114
|
|
|
115
|
+
def _as_error_reason(category: Optional[str]) -> Optional[str]:
|
|
116
|
+
"""Render a failure_category as an ``error.reason`` token, or None when it cannot be.
|
|
117
|
+
|
|
118
|
+
``failure_category`` and ``error.reason`` are two vocabularies, not one: the preflight
|
|
119
|
+
categories are SCREAMING_SNAKE (``AUTH_PERMISSION_DENIED``) while ``error.reason`` is
|
|
120
|
+
specified lower_snake_case. ``failure_category`` still rides its own top-level response
|
|
121
|
+
field verbatim, so nothing is lost by normalizing the copy that lands here.
|
|
122
|
+
|
|
123
|
+
The category is plugin-supplied free text rather than a closed enum, so lowercasing
|
|
124
|
+
alone would not produce a snake_case token; punctuation and whitespace collapse to
|
|
125
|
+
single underscores and a category with no usable characters yields None.
|
|
126
|
+
"""
|
|
127
|
+
if category is None:
|
|
128
|
+
return None
|
|
129
|
+
# -- str.lower, not category.lower: the isinstance check upstream admits str SUBCLASSES,
|
|
130
|
+
# -- and this renders an error response, where raising is the one thing it must not do --
|
|
131
|
+
reason = re.sub(r"[^a-z0-9]+", "_", str.lower(category)).strip("_")
|
|
132
|
+
return reason or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def plugin_error_of(error: BaseException) -> Optional[PluginErrorMetadata]:
|
|
136
|
+
"""Map the legacy UserError family onto the canonical plugin-error envelope.
|
|
137
|
+
|
|
138
|
+
The ratified ErrorAudience vocabulary owns the wire spelling. A non-user failure remains
|
|
139
|
+
unclassified: orchestrators must not infer actionability from its HTTP status.
|
|
140
|
+
|
|
141
|
+
Terminal is the safe default, so a transient condition has to declare itself: a provider
|
|
142
|
+
rate limit is the one member of this family that clears on its own, and it mirrors the
|
|
143
|
+
utic_plugin_base ``RateLimitError`` declaration (platform-libs #889) field for field.
|
|
144
|
+
Retryability is orthogonal to audience -- a throttled request is still the caller's
|
|
145
|
+
quota, so the audience stays ``user``.
|
|
146
|
+
|
|
147
|
+
The class picks the DEFAULT reason; an explicit ``failure_category`` still wins, on this
|
|
148
|
+
branch as on every other. A plugin that declares a category has said something more
|
|
149
|
+
specific than the class did (a rate limit raised as ``PROVIDER_RATE_LIMITED``), and
|
|
150
|
+
dropping it here would discard the more precise of the two.
|
|
151
|
+
"""
|
|
152
|
+
if not isinstance(error, UserError):
|
|
153
|
+
return None
|
|
154
|
+
if isinstance(error, RateLimitError):
|
|
155
|
+
error_type, default_reason, retryable = "dependency", "rate_limited", True
|
|
156
|
+
else:
|
|
157
|
+
error_type, default_reason, retryable = "configuration", "invalid_input", False
|
|
158
|
+
return PluginErrorMetadata(
|
|
159
|
+
error_type=error_type,
|
|
160
|
+
error_reason=_as_error_reason(failure_category_of(error)) or default_reason,
|
|
161
|
+
audience=ErrorAudience.USER,
|
|
162
|
+
retryable=retryable,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
96
166
|
def status_code_of(error: BaseException) -> int:
|
|
97
167
|
"""Return the error's status_code only when it is an int in the HTTP range, else 500."""
|
|
98
168
|
status_code = _error_attr(error, "status_code")
|
|
@@ -169,9 +239,15 @@ def wrap_in_fastapi(
|
|
|
169
239
|
func: Callable,
|
|
170
240
|
plugin_id: str,
|
|
171
241
|
precheck_func: Optional[Callable] = None,
|
|
242
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
172
243
|
) -> FastAPI:
|
|
173
244
|
try:
|
|
174
|
-
return _wrap_in_fastapi(
|
|
245
|
+
return _wrap_in_fastapi(
|
|
246
|
+
func=func,
|
|
247
|
+
plugin_id=plugin_id,
|
|
248
|
+
precheck_func=precheck_func,
|
|
249
|
+
invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
|
|
250
|
+
)
|
|
175
251
|
except Exception as e:
|
|
176
252
|
logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True)
|
|
177
253
|
raise EtlApiException(e) from e
|
|
@@ -181,6 +257,7 @@ def _wrap_in_fastapi(
|
|
|
181
257
|
func: Callable,
|
|
182
258
|
plugin_id: str,
|
|
183
259
|
precheck_func: Optional[Callable] = None,
|
|
260
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
184
261
|
) -> FastAPI:
|
|
185
262
|
if precheck_func is not None:
|
|
186
263
|
check_precheck_func(precheck_func=precheck_func)
|
|
@@ -191,6 +268,8 @@ def _wrap_in_fastapi(
|
|
|
191
268
|
logger.warning("usage data not an expected parameter, omitting")
|
|
192
269
|
|
|
193
270
|
fastapi_app = FastAPI()
|
|
271
|
+
# Installation contributes a public router dependency, so it must happen before /invoke.
|
|
272
|
+
install_invocation_envelope(fastapi_app)
|
|
194
273
|
|
|
195
274
|
response_type = get_output_sig(func)
|
|
196
275
|
filedata_meta_model = update_filedata_model(response_type)
|
|
@@ -202,6 +281,7 @@ def _wrap_in_fastapi(
|
|
|
202
281
|
filedata_meta: Optional[filedata_meta_model] = None
|
|
203
282
|
status_code_text: Optional[str] = None
|
|
204
283
|
failure_category: Optional[str] = None
|
|
284
|
+
plugin_error: Optional[PluginErrorMetadata] = None
|
|
205
285
|
output: Optional[response_type] = None
|
|
206
286
|
message_channels: MessageChannels = Field(default_factory=MessageChannels)
|
|
207
287
|
|
|
@@ -226,10 +306,34 @@ def _wrap_in_fastapi(
|
|
|
226
306
|
request_dict["filedata_meta"] = filedata_meta
|
|
227
307
|
try:
|
|
228
308
|
if inspect.isasyncgenfunction(func):
|
|
309
|
+
bound_settings = current_invocation_settings()
|
|
310
|
+
bound_context = current_invocation_context()
|
|
311
|
+
|
|
229
312
|
# Stream response if function is an async generator
|
|
230
313
|
async def _stream_response():
|
|
231
|
-
|
|
232
|
-
|
|
314
|
+
# FastAPI 0.117 closes yield dependencies before iterating a
|
|
315
|
+
# StreamingResponse. Re-enter the captured binding inside the generator so the
|
|
316
|
+
# plugin sees the right request regardless of dependency-cleanup timing.
|
|
317
|
+
with invocation_envelope(bound_settings, bound_context):
|
|
318
|
+
try:
|
|
319
|
+
async for output in func(**(request_dict or {})):
|
|
320
|
+
yield (
|
|
321
|
+
InvokeResponse(
|
|
322
|
+
usage=usage,
|
|
323
|
+
message_channels=message_channels,
|
|
324
|
+
filedata_meta=filedata_meta_model.model_validate(
|
|
325
|
+
filedata_meta.model_dump()
|
|
326
|
+
),
|
|
327
|
+
status_code=status.HTTP_200_OK,
|
|
328
|
+
output=output,
|
|
329
|
+
file_data=request_dict.get("file_data", None),
|
|
330
|
+
).model_dump_json()
|
|
331
|
+
+ "\n"
|
|
332
|
+
)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.error(
|
|
335
|
+
f"Failure streaming response: {_safe_str(e)}", exc_info=True
|
|
336
|
+
)
|
|
233
337
|
yield (
|
|
234
338
|
InvokeResponse(
|
|
235
339
|
usage=usage,
|
|
@@ -237,27 +341,13 @@ def _wrap_in_fastapi(
|
|
|
237
341
|
filedata_meta=filedata_meta_model.model_validate(
|
|
238
342
|
filedata_meta.model_dump()
|
|
239
343
|
),
|
|
240
|
-
status_code=
|
|
241
|
-
|
|
242
|
-
|
|
344
|
+
status_code=status_code_of(e),
|
|
345
|
+
status_code_text=f"[{type(e).__name__}] {_safe_str(e)}",
|
|
346
|
+
failure_category=failure_category_of(e),
|
|
347
|
+
plugin_error=plugin_error_of(e),
|
|
243
348
|
).model_dump_json()
|
|
244
349
|
+ "\n"
|
|
245
350
|
)
|
|
246
|
-
except Exception as e:
|
|
247
|
-
logger.error(f"Failure streaming response: {_safe_str(e)}", exc_info=True)
|
|
248
|
-
yield (
|
|
249
|
-
InvokeResponse(
|
|
250
|
-
usage=usage,
|
|
251
|
-
message_channels=message_channels,
|
|
252
|
-
filedata_meta=filedata_meta_model.model_validate(
|
|
253
|
-
filedata_meta.model_dump()
|
|
254
|
-
),
|
|
255
|
-
status_code=status_code_of(e),
|
|
256
|
-
status_code_text=f"[{type(e).__name__}] {_safe_str(e)}",
|
|
257
|
-
failure_category=failure_category_of(e),
|
|
258
|
-
).model_dump_json()
|
|
259
|
-
+ "\n"
|
|
260
|
-
)
|
|
261
351
|
|
|
262
352
|
return StreamingResponse(_stream_response(), media_type="application/x-ndjson")
|
|
263
353
|
else:
|
|
@@ -299,6 +389,7 @@ def _wrap_in_fastapi(
|
|
|
299
389
|
status_code=status_code_of(exc),
|
|
300
390
|
status_code_text=_safe_str(exc),
|
|
301
391
|
failure_category=failure_category_of(exc),
|
|
392
|
+
plugin_error=plugin_error_of(exc),
|
|
302
393
|
file_data=request_dict.get("file_data", None),
|
|
303
394
|
)
|
|
304
395
|
except Exception as invoke_error:
|
|
@@ -376,6 +467,7 @@ def _wrap_in_fastapi(
|
|
|
376
467
|
status_code: int
|
|
377
468
|
status_code_text: Optional[str] = None
|
|
378
469
|
failure_category: Optional[str] = None
|
|
470
|
+
plugin_error: Optional[PluginErrorMetadata] = None
|
|
379
471
|
|
|
380
472
|
@fastapi_app.get("/schema")
|
|
381
473
|
async def get_schema() -> SchemaOutputResponse:
|
|
@@ -391,6 +483,7 @@ def _wrap_in_fastapi(
|
|
|
391
483
|
status_code=fn_response.status_code,
|
|
392
484
|
status_code_text=fn_response.status_code_text,
|
|
393
485
|
failure_category=fn_response.failure_category,
|
|
486
|
+
plugin_error=fn_response.plugin_error,
|
|
394
487
|
usage=fn_response.usage,
|
|
395
488
|
)
|
|
396
489
|
else:
|
|
@@ -406,6 +499,13 @@ def _wrap_in_fastapi(
|
|
|
406
499
|
except TypeError as e:
|
|
407
500
|
raise TypeError(f"failed to validate function schema: {e}") from e
|
|
408
501
|
|
|
502
|
+
# Registered last so add_metadata_route replaces any /metadata the plugin registered itself.
|
|
503
|
+
add_metadata_route(
|
|
504
|
+
fastapi_app,
|
|
505
|
+
identifier=plugin_id,
|
|
506
|
+
invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
|
|
507
|
+
)
|
|
508
|
+
|
|
409
509
|
FastAPIInstrumentor.instrument_app(
|
|
410
510
|
fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider()
|
|
411
511
|
)
|
|
@@ -420,6 +520,7 @@ def generate_fast_api(
|
|
|
420
520
|
id_method: Optional[str] = None,
|
|
421
521
|
precheck_str: Optional[str] = None,
|
|
422
522
|
precheck_method: Optional[str] = None,
|
|
523
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
423
524
|
) -> FastAPI:
|
|
424
525
|
instance = import_from_string(app)
|
|
425
526
|
func = get_func(instance, method_name)
|
|
@@ -438,4 +539,9 @@ def generate_fast_api(
|
|
|
438
539
|
elif precheck_method:
|
|
439
540
|
precheck_func = get_func(instance, precheck_method)
|
|
440
541
|
|
|
441
|
-
return wrap_in_fastapi(
|
|
542
|
+
return wrap_in_fastapi(
|
|
543
|
+
func=func,
|
|
544
|
+
plugin_id=plugin_id,
|
|
545
|
+
precheck_func=precheck_func,
|
|
546
|
+
invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
|
|
547
|
+
)
|
|
@@ -56,6 +56,7 @@ def get_command() -> click.Command:
|
|
|
56
56
|
plugin_id_method: Optional[str] = None,
|
|
57
57
|
precheck_app: Optional[str] = None,
|
|
58
58
|
precheck_app_method: Optional[str] = None,
|
|
59
|
+
sealed_dag_node_settings_v2: bool = False,
|
|
59
60
|
**kwargs,
|
|
60
61
|
):
|
|
61
62
|
# Make sure logging is configured before the call to run() so any setup has the same format
|
|
@@ -73,6 +74,7 @@ def get_command() -> click.Command:
|
|
|
73
74
|
id_method=plugin_id_method,
|
|
74
75
|
precheck_str=precheck_app,
|
|
75
76
|
precheck_method=precheck_app_method,
|
|
77
|
+
invoke_with_sealed_dag_node_settings_v2=sealed_dag_node_settings_v2,
|
|
76
78
|
)
|
|
77
79
|
# Explicitly map values that are manipulated in the original
|
|
78
80
|
# call to run(), preventing **kwargs reference
|
|
@@ -130,6 +132,14 @@ def get_command() -> click.Command:
|
|
|
130
132
|
"If precheck-app not provided, assumes method "
|
|
131
133
|
"lives on main class passes in.",
|
|
132
134
|
),
|
|
135
|
+
click.Option(
|
|
136
|
+
["--sealed-dag-node-settings-v2"],
|
|
137
|
+
is_flag=True,
|
|
138
|
+
default=False,
|
|
139
|
+
help="Advertise the invoke_with_sealed_dag_node_settings_v2 capability on "
|
|
140
|
+
"/metadata. Set only for a plugin that consumes per-invoke settings "
|
|
141
|
+
"through current_invocation_settings().",
|
|
142
|
+
),
|
|
133
143
|
]
|
|
134
144
|
)
|
|
135
145
|
return cmd
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Schema-generated local wire-contract bindings."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by scripts/generate_invocation_contracts.py; do not edit by hand.
|
|
2
|
+
# ruff: noqa: E501
|
|
3
|
+
# Source: https://schemas.u10d.dev/errors/audience/v1.json
|
|
4
|
+
"""Error Audience — generated from the ratified JSON Schema.
|
|
5
|
+
https://schemas.u10d.dev/errors/audience/v1
|
|
6
|
+
|
|
7
|
+
Non-null actionability audience carried by plugin_error.audience; distinct from technical fault-
|
|
8
|
+
source classifications such as utic_invocation_settings.Blame
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ErrorAudienceValue(str, Enum):
|
|
17
|
+
"""Who can act to resolve an error."""
|
|
18
|
+
USER = "user"
|
|
19
|
+
PLATFORM = "platform"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Non-null actionability audience carried by plugin_error.audience; distinct from technical
|
|
23
|
+
# fault-source classifications such as utic_invocation_settings.Blame
|
|
24
|
+
ErrorAudience = ErrorAudienceValue
|
|
25
|
+
|
|
26
|
+
# Generation provenance used by drift tests and reviewers.
|
|
27
|
+
SCHEMA_ID = 'https://schemas.u10d.dev/errors/audience/v1.json'
|
|
28
|
+
SCHEMA_SHA256 = '36d017a1b132d0c06ded5a60376c425e8d5a5e681f134cfdc4dcb1fa4c9498d8'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Generated by scripts/generate_invocation_contracts.py; do not edit by hand.
|
|
2
|
+
# ruff: noqa: E501
|
|
3
|
+
# Source: https://schemas.u10d.dev/invocation-context/v1.json
|
|
4
|
+
"""Invocation Context — generated from the ratified JSON Schema.
|
|
5
|
+
https://schemas.u10d.dev/invocation-context/v1
|
|
6
|
+
|
|
7
|
+
Versioned request-scoped identity and correlation context carried in the reserved
|
|
8
|
+
invocation_context field of a plugin /invoke body
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvocationContext(BaseModel):
|
|
19
|
+
"""
|
|
20
|
+
Versioned request-scoped identity and correlation context carried in the reserved
|
|
21
|
+
invocation_context field of a plugin /invoke body
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="allow")
|
|
25
|
+
|
|
26
|
+
schema_version: Literal["1"] = Field(
|
|
27
|
+
default="1",
|
|
28
|
+
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.",
|
|
29
|
+
)
|
|
30
|
+
invocation_id: str | None = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Correlation identifier for this individual invocation.",
|
|
33
|
+
)
|
|
34
|
+
job_id: str | None = Field(default=None, description="Job that owns the invocation.")
|
|
35
|
+
workflow_id: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Workflow that owns the job, when known.",
|
|
38
|
+
)
|
|
39
|
+
attribution_id: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="Platform attribution identifier associated with the invocation.",
|
|
42
|
+
)
|
|
43
|
+
tenant_id: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="Tenant identity to which the invocation is attributed.",
|
|
46
|
+
)
|
|
47
|
+
org_id: str | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="Organization identity to which the invocation is attributed.",
|
|
50
|
+
)
|
|
51
|
+
dag_node_id: str | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
description="Identifier of the DAG node being invoked.",
|
|
54
|
+
)
|
|
55
|
+
dag_node_type: str | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="Plugin type of the DAG node being invoked.",
|
|
58
|
+
)
|
|
59
|
+
dag_node_subtype: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="Plugin subtype of the DAG node being invoked.",
|
|
62
|
+
)
|
|
63
|
+
record_id: str | None = Field(
|
|
64
|
+
default=None,
|
|
65
|
+
description="Record being processed by a single-record invocation.",
|
|
66
|
+
)
|
|
67
|
+
attempt: int | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Attempt number associated with the claimed work.",
|
|
70
|
+
)
|
|
71
|
+
job_created_timestamp: str | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="Job creation timestamp forwarded for lifecycle timing. It is context metadata, not a telemetry dimension.",
|
|
74
|
+
)
|
|
75
|
+
record_ids: list[str] | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Record identifiers in a batch invocation. When invocation_ids is also present, the two arrays are index-aligned.",
|
|
78
|
+
)
|
|
79
|
+
invocation_ids: list[str | None] | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Per-record invocation identifiers for a batch invocation. Entry i identifies record_ids[i]; null means that record carried no invocation identifier.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Generation provenance used by drift tests and reviewers.
|
|
85
|
+
SCHEMA_ID = 'https://schemas.u10d.dev/invocation-context/v1.json'
|
|
86
|
+
SCHEMA_SHA256 = 'ca64be22a104acc697b7aff0fb47633ad75fa30cd88a0c92259af732fa1023c0'
|
|
87
|
+
RESERVED_CONTEXT_KEY = 'invocation_context'
|
|
88
|
+
SUPPORTED_CONTEXT_VERSIONS = frozenset(['1'])
|
|
89
|
+
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')
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The ``invocation_context`` companion to the settings envelope.
|
|
2
|
+
|
|
3
|
+
Where ``invocation_settings`` carries *what* a plugin should be configured with, the context
|
|
4
|
+
carries *who* the invocation is for: the identity facets a shared-tenancy pod can no longer read
|
|
5
|
+
from its process environment. It travels in a second reserved, out-of-schema field of the
|
|
6
|
+
``/invoke`` body, extracted by the same route dependency that resolves the settings field.
|
|
7
|
+
|
|
8
|
+
The context is `/invoke` protocol identity, not settings security: it touches no crypto and no
|
|
9
|
+
secrets, and it evolves with the plugin protocol this package defines. The errors it raises come
|
|
10
|
+
from the shared ``InvocationSettingsError`` taxonomy so hosts classify context failures with the
|
|
11
|
+
same ``reason``/``blame`` machinery as settings failures.
|
|
12
|
+
|
|
13
|
+
The model below is the **consumer** view of that contract, deliberately lenient: unknown keys are
|
|
14
|
+
preserved for additive forward compatibility, and every identity field is optional so a
|
|
15
|
+
partially-populated context degrades to "less telemetry" rather than a failed invoke. The one thing
|
|
16
|
+
it is strict about is ``schema_version`` — that field makes incompatible producer and consumer
|
|
17
|
+
contracts detectable. The payload carries the version, so evolving the contract does not require
|
|
18
|
+
adding endpoints.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Mapping
|
|
24
|
+
|
|
25
|
+
import pydantic
|
|
26
|
+
from utic_invocation_settings import Blame, InvocationSettingsError, MalformedEnvelopeError
|
|
27
|
+
|
|
28
|
+
from unstructured_platform_plugins.generated.invocation_context_v1 import (
|
|
29
|
+
DIMENSION_FIELDS,
|
|
30
|
+
RESERVED_CONTEXT_KEY,
|
|
31
|
+
SUPPORTED_CONTEXT_VERSIONS,
|
|
32
|
+
)
|
|
33
|
+
from unstructured_platform_plugins.generated.invocation_context_v1 import (
|
|
34
|
+
InvocationContext as _GeneratedInvocationContext,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value.
|
|
38
|
+
_ABSENT = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnsupportedContextVersionError(InvocationSettingsError):
|
|
42
|
+
"""The ``invocation_context`` declares a ``schema_version`` this package does not understand.
|
|
43
|
+
|
|
44
|
+
This indicates deployment skew between platform components, not a fault in the request.
|
|
45
|
+
``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced by the platform's own claim
|
|
46
|
+
pipeline, and a 422 would make an upstream blame classifier pin a version-skew failure on the
|
|
47
|
+
customer. Failing the request prevents an unreadable context from silently removing telemetry
|
|
48
|
+
dimensions.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
reason = "unsupported_context_version"
|
|
52
|
+
blame = Blame.CONTENT
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvocationContext(_GeneratedInvocationContext):
|
|
56
|
+
"""Request-scoped identity delivered alongside one claimed unit of work.
|
|
57
|
+
|
|
58
|
+
The field shape, version literal, extra-field policy, reserved key, and dimensions are generated
|
|
59
|
+
from the ratified schema. This adapter retains the cross-field invariant JSON Schema cannot
|
|
60
|
+
express.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@pydantic.model_validator(mode="after")
|
|
64
|
+
def _batch_lists_stay_index_aligned(self) -> "InvocationContext":
|
|
65
|
+
if (
|
|
66
|
+
self.record_ids is not None
|
|
67
|
+
and self.invocation_ids is not None
|
|
68
|
+
and len(self.record_ids) != len(self.invocation_ids)
|
|
69
|
+
):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"record_ids and invocation_ids must be the same length: "
|
|
72
|
+
"entry i of invocation_ids describes record i"
|
|
73
|
+
)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None:
|
|
77
|
+
"""Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``.
|
|
78
|
+
|
|
79
|
+
Returns ``None`` only when the producer omitted the reserved key. A present-but-invalid value
|
|
80
|
+
fails closed rather than degrading to "no context", because a context that silently vanishes
|
|
81
|
+
takes a pod's tenant attribution with it.
|
|
82
|
+
|
|
83
|
+
A recognizable context carrying an unknown ``schema_version`` raises
|
|
84
|
+
:class:`UnsupportedContextVersionError` so an incompatible contract cannot be mistaken for
|
|
85
|
+
absent context.
|
|
86
|
+
"""
|
|
87
|
+
raw = payload.get(RESERVED_CONTEXT_KEY, _ABSENT)
|
|
88
|
+
if raw is _ABSENT:
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(raw, InvocationContext):
|
|
91
|
+
return raw
|
|
92
|
+
try:
|
|
93
|
+
return InvocationContext.model_validate(raw)
|
|
94
|
+
except pydantic.ValidationError as exc:
|
|
95
|
+
errors = exc.errors()
|
|
96
|
+
# Only a well-typed version string this package does not know reads as deployment skew; a
|
|
97
|
+
# schema_version of the wrong type is the caller's malformed context like any other field.
|
|
98
|
+
reported = _reported_version(raw)
|
|
99
|
+
if isinstance(reported, str) and any(
|
|
100
|
+
error["loc"] == ("schema_version",) for error in errors
|
|
101
|
+
):
|
|
102
|
+
raise UnsupportedContextVersionError(
|
|
103
|
+
f"unsupported invocation_context schema_version: "
|
|
104
|
+
f"{_reported_version(raw)!r}; expected one of {sorted(SUPPORTED_CONTEXT_VERSIONS)}"
|
|
105
|
+
) from None
|
|
106
|
+
# `from None` so the pydantic error tree does not cross the domain-error boundary; a count
|
|
107
|
+
# plus the first message is enough signal. Mirrors the envelope extraction.
|
|
108
|
+
raise MalformedEnvelopeError(
|
|
109
|
+
f"invalid invocation_context: {exc.error_count()} validation error(s), "
|
|
110
|
+
f"first: {errors[0]['msg']}"
|
|
111
|
+
) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reported_version(raw: Any) -> Any:
|
|
115
|
+
"""The offending ``schema_version``, for the error message only. Never trusted."""
|
|
116
|
+
return raw.get("schema_version") if isinstance(raw, Mapping) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def dimensions(context: InvocationContext | None) -> dict[str, Any]:
|
|
120
|
+
"""The context's populated identity facets, ready to bind as telemetry dimensions."""
|
|
121
|
+
if context is None:
|
|
122
|
+
return {}
|
|
123
|
+
return {
|
|
124
|
+
field: value for field in DIMENSION_FIELDS if (value := getattr(context, field)) is not None
|
|
125
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Identity and filesystem scope for the current invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, fields
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from unstructured_platform_plugins.invocation_settings import current_invocation_context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class InvocationIdentity:
|
|
17
|
+
job_id: str | None
|
|
18
|
+
tenant_id: str | None
|
|
19
|
+
org_id: str | None
|
|
20
|
+
workflow_id: str | None
|
|
21
|
+
attribution_id: str | None
|
|
22
|
+
dag_node_id: str | None
|
|
23
|
+
dag_node_type: str | None
|
|
24
|
+
dag_node_subtype: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def current_invocation_identity() -> InvocationIdentity:
|
|
28
|
+
"""Read identity from the bound context, or the environment when no context is bound."""
|
|
29
|
+
context = current_invocation_context()
|
|
30
|
+
values = {}
|
|
31
|
+
for field in fields(InvocationIdentity):
|
|
32
|
+
values[field.name] = (
|
|
33
|
+
getattr(context, field.name, None)
|
|
34
|
+
if context is not None
|
|
35
|
+
else os.getenv(field.name.upper())
|
|
36
|
+
)
|
|
37
|
+
return InvocationIdentity(**values)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _segment(value: str | None) -> str:
|
|
41
|
+
if not value:
|
|
42
|
+
return "unknown"
|
|
43
|
+
safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", value)
|
|
44
|
+
if safe in {".", ".."} or safe != value or len(safe) > 96:
|
|
45
|
+
digest = hashlib.sha256(value.encode()).hexdigest()[:16]
|
|
46
|
+
safe = f"{safe[:79].strip('.') or 'unknown'}-{digest}"
|
|
47
|
+
return safe
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def invocation_scratch_dir(root: str | os.PathLike[str], *, create: bool = True) -> Path:
|
|
51
|
+
"""Return an invocation directory, or the unchanged root when no context is bound."""
|
|
52
|
+
path = Path(root)
|
|
53
|
+
context = current_invocation_context()
|
|
54
|
+
if context is not None:
|
|
55
|
+
identity = current_invocation_identity()
|
|
56
|
+
invocation_id = context.invocation_id
|
|
57
|
+
if not invocation_id and context.record_ids is None and context.invocation_ids is None:
|
|
58
|
+
invocation_id = context.record_id
|
|
59
|
+
path = path / _segment(identity.job_id) / _segment(identity.dag_node_id)
|
|
60
|
+
path = path / _segment(invocation_id or uuid4().hex)
|
|
61
|
+
if create:
|
|
62
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
return path
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Transport for the reserved `/invoke` fields: request dependency, `/metadata`, optional body cap.
|
|
2
|
+
|
|
3
|
+
The *settings contract* — including the only accepted sealed `/invoke` shape (the v2 settings
|
|
4
|
+
document), field-level resolution, and what an absent field is allowed to mean — lives in
|
|
5
|
+
`utic_invocation_settings`, next to the crypto it governs; every decision about a settings payload
|
|
6
|
+
is delegated there. The *identity contract* —
|
|
7
|
+
the `invocation_context` model — is
|
|
8
|
+
`/invoke` protocol rather than settings security and lives in this package's
|
|
9
|
+
`invocation_context` module. This module is the delivery mechanism for both: getting the payloads
|
|
10
|
+
off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP
|
|
11
|
+
statuses.
|
|
12
|
+
|
|
13
|
+
The reserved fields are a first-class HTTP contract independent of the generated input schema.
|
|
14
|
+
They never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler
|
|
15
|
+
model built purely from the wrapped function, and a plugin reads the fields through
|
|
16
|
+
`current_invocation_settings()` / `current_invocation_context()` instead.
|
|
17
|
+
|
|
18
|
+
Well-formed extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke`
|
|
19
|
+
body is buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on
|
|
20
|
+
the `Request`, and the dependency reads that cached parse. Generated routes can reject malformed
|
|
21
|
+
JSON while validating their typed body before dependencies run, so the installer normalizes that
|
|
22
|
+
specific validation failure to the same transport error. Hosts that have established a safe
|
|
23
|
+
request ceiling may opt into `InvokeBodyLimitMiddleware`, which counts bytes as they stream through
|
|
24
|
+
without buffering. Wrapped plugins do not acquire a fleet-wide limit merely by upgrading this
|
|
25
|
+
package.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import hashlib
|
|
32
|
+
import inspect
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from collections import OrderedDict
|
|
38
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
39
|
+
from contextlib import contextmanager
|
|
40
|
+
from contextvars import ContextVar
|
|
41
|
+
from typing import Any, Optional, TypeVar
|
|
42
|
+
|
|
43
|
+
from fastapi import Depends, FastAPI, Request
|
|
44
|
+
from fastapi.exception_handlers import request_validation_exception_handler
|
|
45
|
+
from fastapi.exceptions import RequestValidationError
|
|
46
|
+
from opentelemetry import trace
|
|
47
|
+
from starlette.requests import ClientDisconnect
|
|
48
|
+
from starlette.responses import JSONResponse
|
|
49
|
+
from starlette.routing import get_route_path
|
|
50
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
51
|
+
from utic_invocation_settings import (
|
|
52
|
+
INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY,
|
|
53
|
+
RESERVED_ENVELOPE_KEY,
|
|
54
|
+
Blame,
|
|
55
|
+
InvocationSettingsError,
|
|
56
|
+
MalformedEnvelopeError,
|
|
57
|
+
resolve_invocation_settings,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
from unstructured_platform_plugins.invocation_context import (
|
|
61
|
+
RESERVED_CONTEXT_KEY,
|
|
62
|
+
InvocationContext,
|
|
63
|
+
dimensions,
|
|
64
|
+
extract_context,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def http_status_for(error: BaseException) -> int:
|
|
71
|
+
"""The HTTP status this transport answers for a failed resolution, from ``blame``.
|
|
72
|
+
|
|
73
|
+
One rule, the one the library README states normatively: ``Blame.CALLER`` -> 422, everything
|
|
74
|
+
else -> 500. The line it draws is whether a different request would work. Sealing drift, an
|
|
75
|
+
envelope for another recipient and a broken local mount are all 5xx, which keeps a controller's
|
|
76
|
+
blame classification off the customer, whose request was fine. Anything that is not a
|
|
77
|
+
classified error is a 500: an unclassified failure is not the caller's.
|
|
78
|
+
"""
|
|
79
|
+
return 422 if getattr(error, "blame", None) is Blame.CALLER else 500
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
T = TypeVar("T")
|
|
83
|
+
|
|
84
|
+
_METADATA_PATH = "/metadata"
|
|
85
|
+
_INVOKE_PATH = "/invoke"
|
|
86
|
+
|
|
87
|
+
# A convenient opt-in ceiling for hosts that have verified it against their request distribution.
|
|
88
|
+
# It is deliberately not the install default: batch invokes carry arrays of file_data payloads,
|
|
89
|
+
# and a wrapper upgrade must not reject previously valid fleet traffic without an explicit choice.
|
|
90
|
+
MAX_INVOKE_BODY_BYTES = 64 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
_INVOCATION: ContextVar[tuple[Optional[dict], Optional[InvocationContext]]] = ContextVar(
|
|
93
|
+
"invocation", default=(None, None)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def current_invocation_settings() -> Optional[dict]:
|
|
98
|
+
"""Reserved `invocation_settings` field bound for the current request, if any.
|
|
99
|
+
|
|
100
|
+
`None` means the field was genuinely absent — the only case in which a plugin may fall back to
|
|
101
|
+
its boot-time settings. A field that arrived and could not be opened never reaches a handler:
|
|
102
|
+
the binding dependency fails the request first.
|
|
103
|
+
"""
|
|
104
|
+
return _INVOCATION.get()[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def current_invocation_context() -> Optional[InvocationContext]:
|
|
108
|
+
"""Reserved `invocation_context` field bound for the current request, if any."""
|
|
109
|
+
return _INVOCATION.get()[1]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def invocation_envelope(
|
|
114
|
+
invocation_settings: Optional[dict], invocation_context: Optional[InvocationContext]
|
|
115
|
+
) -> Iterator[None]:
|
|
116
|
+
"""Bind the reserved /invoke fields for the current context."""
|
|
117
|
+
token = _INVOCATION.set((invocation_settings, invocation_context))
|
|
118
|
+
try:
|
|
119
|
+
yield
|
|
120
|
+
finally:
|
|
121
|
+
_INVOCATION.reset(token)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def add_metadata_route(
|
|
125
|
+
app: FastAPI,
|
|
126
|
+
identifier: Optional[str] = None,
|
|
127
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Register GET /metadata advertising the reserved /invoke fields this plugin accepts.
|
|
130
|
+
|
|
131
|
+
`/metadata` is the plugin API spec's own discovery surface (`PluginMetadataOutput`): capability
|
|
132
|
+
flags are strings in its `capabilities` list, which is where the controller looks before
|
|
133
|
+
forwarding the reserved fields — no controller-private probe route.
|
|
134
|
+
|
|
135
|
+
`invocation_settings` and `invocation_context` are transport capabilities: installing the
|
|
136
|
+
dependency makes the host receive, resolve, and bind those fields. The sealed-settings
|
|
137
|
+
capability is stronger: it tells the controller that the plugin handler consumes the resolved
|
|
138
|
+
field-level v2 settings in place of boot-time state. The controller may therefore send the
|
|
139
|
+
versioned v2 document, so this remains an explicit opt-in.
|
|
140
|
+
|
|
141
|
+
Last call wins: the payload lives on `app.state` and every call overwrites it, while the route
|
|
142
|
+
is registered once. A host wrapper may register at app construction and a plugin can still
|
|
143
|
+
re-register with its own identifier afterwards, with no route-order dependence.
|
|
144
|
+
"""
|
|
145
|
+
capabilities = [RESERVED_ENVELOPE_KEY, RESERVED_CONTEXT_KEY]
|
|
146
|
+
if invoke_with_sealed_dag_node_settings_v2:
|
|
147
|
+
capabilities.append(INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY)
|
|
148
|
+
app.state.plugin_metadata_payload = {
|
|
149
|
+
"api_version": "3",
|
|
150
|
+
"identifier": identifier,
|
|
151
|
+
"capabilities": capabilities,
|
|
152
|
+
}
|
|
153
|
+
if getattr(app.state, "plugin_metadata_route_installed", False):
|
|
154
|
+
return
|
|
155
|
+
app.state.plugin_metadata_route_installed = True
|
|
156
|
+
|
|
157
|
+
# A /metadata route registered by the application itself would win by route order and pin its
|
|
158
|
+
# own stale payload; drop it so the last add_metadata_route call is the one that answers.
|
|
159
|
+
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != _METADATA_PATH]
|
|
160
|
+
|
|
161
|
+
@app.get(_METADATA_PATH)
|
|
162
|
+
async def plugin_metadata() -> dict:
|
|
163
|
+
return app.state.plugin_metadata_payload
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class UnusableInvocationEnvelope(Exception):
|
|
167
|
+
"""A reserved /invoke field arrived but cannot be used.
|
|
168
|
+
|
|
169
|
+
Raised by `bind_invocation_envelope` and answered by the handler
|
|
170
|
+
`install_invocation_envelope` registers, so the response shape — `detail` plus the library's
|
|
171
|
+
stable `reason` code as top-level siblings — stays what orchestrators parse, independent of
|
|
172
|
+
FastAPI's own error envelope.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, status_code: int, payload: dict):
|
|
176
|
+
super().__init__(payload.get("detail"))
|
|
177
|
+
self.status_code = status_code
|
|
178
|
+
self.payload = payload
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _unusable_envelope_response(
|
|
182
|
+
_request: Request, exc: UnusableInvocationEnvelope
|
|
183
|
+
) -> JSONResponse:
|
|
184
|
+
return JSONResponse(status_code=exc.status_code, content=exc.payload)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_malformed_json_validation(exc: RequestValidationError) -> bool:
|
|
188
|
+
"""Whether FastAPI rejected the request body before route dependencies could run."""
|
|
189
|
+
# A top-level JSONDecodeError stores the raw document and reports its integer parser offset as
|
|
190
|
+
# exactly ("body", offset). Pydantic's Json fields also emit `json_invalid`, but by then the
|
|
191
|
+
# outer request has been decoded into a mapping/list and the location continues through the
|
|
192
|
+
# model field. Those are ordinary schema-validation errors and must retain FastAPI's detail.
|
|
193
|
+
if not isinstance(exc.body, (str, bytes, bytearray)):
|
|
194
|
+
return False
|
|
195
|
+
return any(
|
|
196
|
+
error.get("type") == "json_invalid"
|
|
197
|
+
and len(location := tuple(error.get("loc", ()))) == 2
|
|
198
|
+
and location[0] == "body"
|
|
199
|
+
and isinstance(location[1], int)
|
|
200
|
+
for error in exc.errors()
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]:
|
|
205
|
+
"""Resolve the reserved /invoke fields and bind them for the duration of the request.
|
|
206
|
+
|
|
207
|
+
Runs as a route dependency, after the framework has read the body: `request.json()` is
|
|
208
|
+
Starlette-cached, so the parse is shared with the framework's own body handling.
|
|
209
|
+
A reserved field that is present but unusable fails the request rather than being
|
|
210
|
+
treated as absent, because absence is the signal to fall back to the boot-time settings file:
|
|
211
|
+
degrading a malformed field to absence would quietly answer a request configured for one
|
|
212
|
+
tenant with whatever the pod happened to boot with. Under
|
|
213
|
+
`FF_INVOCATION_SETTINGS` there is no settings file to fall back to,
|
|
214
|
+
so absent or plaintext settings fail too — as does any /invoke whose body is not a JSON
|
|
215
|
+
object, since such a body cannot carry the envelope a native pod requires.
|
|
216
|
+
"""
|
|
217
|
+
# ASGI `path` includes any deployment root_path; get_route_path strips it, which is how the
|
|
218
|
+
# router itself matches, so binding fires exactly when the /invoke route does.
|
|
219
|
+
if request.method != "POST" or get_route_path(request.scope) != _INVOKE_PATH:
|
|
220
|
+
yield
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
parsed = await request.json()
|
|
225
|
+
except ValueError:
|
|
226
|
+
# request.json() reads through Starlette's cached body, so this does not consume or buffer
|
|
227
|
+
# the stream a second time. A truly empty body is absence and remains subject to the
|
|
228
|
+
# native-pod policy below. Any bytes that fail JSON parsing are a malformed request, not
|
|
229
|
+
# absence: collapsing them would turn a caller-fixable syntax error into a native pod's
|
|
230
|
+
# SealedDagNodeSettingsRequiredError (RECIPIENT -> 500).
|
|
231
|
+
if await request.body():
|
|
232
|
+
raise UnusableInvocationEnvelope(
|
|
233
|
+
422,
|
|
234
|
+
{
|
|
235
|
+
"detail": "Invalid JSON body",
|
|
236
|
+
"reason": MalformedEnvelopeError.reason,
|
|
237
|
+
},
|
|
238
|
+
) from None
|
|
239
|
+
parsed = None
|
|
240
|
+
|
|
241
|
+
raw_settings: Optional[Any] = None
|
|
242
|
+
if isinstance(parsed, dict):
|
|
243
|
+
raw_settings = parsed.get(RESERVED_ENVELOPE_KEY)
|
|
244
|
+
if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict):
|
|
245
|
+
raise UnusableInvocationEnvelope(
|
|
246
|
+
422,
|
|
247
|
+
{
|
|
248
|
+
"detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}",
|
|
249
|
+
"reason": MalformedEnvelopeError.reason,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
# Off the event loop: resolution may perform blocking cryptography for independently
|
|
254
|
+
# sealed fields, and this dependency fronts every invoke on the pod.
|
|
255
|
+
invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
# Class name only — never envelope contents, and never the exception's own message,
|
|
258
|
+
# which can embed request-controlled values.
|
|
259
|
+
logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__)
|
|
260
|
+
payload = {"detail": f"Unusable invocation settings: {type(exc).__name__}"}
|
|
261
|
+
reason = getattr(exc, "reason", None)
|
|
262
|
+
if isinstance(reason, str):
|
|
263
|
+
payload["reason"] = reason
|
|
264
|
+
raise UnusableInvocationEnvelope(http_status_for(exc), payload) from exc
|
|
265
|
+
|
|
266
|
+
invocation_context: Optional[InvocationContext] = None
|
|
267
|
+
if isinstance(parsed, dict):
|
|
268
|
+
try:
|
|
269
|
+
invocation_context = extract_context(parsed)
|
|
270
|
+
except InvocationSettingsError as exc:
|
|
271
|
+
# A context this plugin cannot read fails loudly here rather than running with
|
|
272
|
+
# silently absent identity. Status comes from the blame taxonomy: a malformed
|
|
273
|
+
# field is the caller's 422, but an unreadable schema_version is deployment skew
|
|
274
|
+
# between platform components and must not read as a caller fault. The log line is
|
|
275
|
+
# truncated because the message can embed request-controlled values.
|
|
276
|
+
logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc)
|
|
277
|
+
status = http_status_for(exc)
|
|
278
|
+
detail = (
|
|
279
|
+
f"Invalid field: {RESERVED_CONTEXT_KEY}"
|
|
280
|
+
if status == 422
|
|
281
|
+
else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}"
|
|
282
|
+
)
|
|
283
|
+
raise UnusableInvocationEnvelope(
|
|
284
|
+
status, {"detail": detail, "reason": exc.reason}
|
|
285
|
+
) from exc
|
|
286
|
+
|
|
287
|
+
# Keep plugin-side request spans aligned with the controller's wide-event dimensions. The
|
|
288
|
+
# ratified DIMENSION_FIELDS list deliberately excludes batch correlation and unknown additive
|
|
289
|
+
# fields, so only the shared invocation identity is promoted to indexed telemetry.
|
|
290
|
+
invocation_dimensions = dimensions(invocation_context)
|
|
291
|
+
if invocation_dimensions:
|
|
292
|
+
span = trace.get_current_span()
|
|
293
|
+
for key, value in invocation_dimensions.items():
|
|
294
|
+
span.set_attribute(key, value)
|
|
295
|
+
|
|
296
|
+
with invocation_envelope(invocation_settings, invocation_context):
|
|
297
|
+
yield
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _send_json(send: Send, status_code: int, payload: dict) -> None:
|
|
301
|
+
body = json.dumps(payload).encode()
|
|
302
|
+
await send(
|
|
303
|
+
{
|
|
304
|
+
"type": "http.response.start",
|
|
305
|
+
"status": status_code,
|
|
306
|
+
"headers": [
|
|
307
|
+
(b"content-type", b"application/json"),
|
|
308
|
+
(b"content-length", str(len(body)).encode()),
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
await send({"type": "http.response.body", "body": body})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class InvokeBodyLimitMiddleware:
|
|
316
|
+
"""Reject a POST /invoke body over ``max_body_bytes`` with 413.
|
|
317
|
+
|
|
318
|
+
Counts bytes as the framework consumes them; nothing is buffered here. When the count crosses
|
|
319
|
+
the cap the downstream read is answered with ``http.disconnect``, which aborts the framework's
|
|
320
|
+
body read before another byte is held, and the 413 is sent once the application has unwound.
|
|
321
|
+
This has to sit below the framework because neither Starlette nor uvicorn bounds request-body
|
|
322
|
+
size.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(self, app: ASGIApp, max_body_bytes: int = MAX_INVOKE_BODY_BYTES):
|
|
326
|
+
self.app = app
|
|
327
|
+
self.max_body_bytes = max_body_bytes
|
|
328
|
+
|
|
329
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
330
|
+
if (
|
|
331
|
+
scope["type"] != "http"
|
|
332
|
+
or scope.get("method") != "POST"
|
|
333
|
+
or get_route_path(scope) != _INVOKE_PATH
|
|
334
|
+
):
|
|
335
|
+
await self.app(scope, receive, send)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
seen = 0
|
|
339
|
+
exceeded = False
|
|
340
|
+
response_started = False
|
|
341
|
+
|
|
342
|
+
async def counting_receive() -> dict:
|
|
343
|
+
nonlocal seen, exceeded
|
|
344
|
+
message = await receive()
|
|
345
|
+
if message["type"] == "http.request":
|
|
346
|
+
seen += len(message.get("body", b""))
|
|
347
|
+
if seen > self.max_body_bytes:
|
|
348
|
+
exceeded = True
|
|
349
|
+
return {"type": "http.disconnect"}
|
|
350
|
+
return message
|
|
351
|
+
|
|
352
|
+
async def guarded_send(message: dict) -> None:
|
|
353
|
+
nonlocal response_started
|
|
354
|
+
if exceeded and not response_started:
|
|
355
|
+
# A response computed after the body was cut is answering a truncated request;
|
|
356
|
+
# drop it so the 413 below is what the caller sees. A response that started
|
|
357
|
+
# before the cap tripped keeps streaming — its start is already on the wire.
|
|
358
|
+
return
|
|
359
|
+
if message["type"] == "http.response.start":
|
|
360
|
+
response_started = True
|
|
361
|
+
await send(message)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
await self.app(scope, counting_receive, guarded_send)
|
|
365
|
+
except ClientDisconnect:
|
|
366
|
+
if not exceeded:
|
|
367
|
+
raise
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
# The cut body stream can surface downstream as something other than
|
|
370
|
+
# ClientDisconnect; once the cap is the cause, the 413 below is the answer. Retain a
|
|
371
|
+
# type-only diagnostic for a coincident downstream bug without rendering its message,
|
|
372
|
+
# which may contain request data or credentials.
|
|
373
|
+
if not exceeded:
|
|
374
|
+
raise
|
|
375
|
+
logger.debug(
|
|
376
|
+
"downstream raised after /invoke body limit was exceeded: %s",
|
|
377
|
+
type(exc).__name__,
|
|
378
|
+
)
|
|
379
|
+
if exceeded and not response_started:
|
|
380
|
+
await _send_json(send, 413, {"detail": "Request body too large"})
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def install_invocation_envelope(app: FastAPI, max_body_bytes: int | None = None) -> None:
|
|
384
|
+
"""Install reserved-field binding before registering a FastAPI app's routes.
|
|
385
|
+
|
|
386
|
+
Adds `bind_invocation_envelope` through the router's public dependency list and registers the
|
|
387
|
+
failure response shape. FastAPI parses a generated route's typed body before dependencies run,
|
|
388
|
+
so malformed JSON validation is normalized to that same response shape at the app boundary;
|
|
389
|
+
every other validation error retains the handler the app already had. When ``max_body_bytes``
|
|
390
|
+
is supplied, also installs the body-size cap beneath the framework. The cap is opt-in because
|
|
391
|
+
a wrapper upgrade must not impose an unvalidated fleet-wide request limit. The dependency is a
|
|
392
|
+
path/method-aware no-op outside POST /invoke, so routes registered after this call can all
|
|
393
|
+
inherit it without private dependency-graph mutation. Calling after routes have already been
|
|
394
|
+
registered raises rather than silently leaving those routes uncovered.
|
|
395
|
+
|
|
396
|
+
Idempotent per app because both host-wrapper and plugin setup may call this function, while a
|
|
397
|
+
double installation would resolve settings twice per request. A repeated call asking for a
|
|
398
|
+
different ``max_body_bytes`` raises because the installed configuration cannot be changed and
|
|
399
|
+
silently keeping the first value would misrepresent the limit actually enforced.
|
|
400
|
+
"""
|
|
401
|
+
if getattr(app.state, "invocation_envelope_installed", False):
|
|
402
|
+
installed_max = app.state.invocation_envelope_max_body_bytes
|
|
403
|
+
if max_body_bytes != installed_max:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
"install_invocation_envelope already installed with "
|
|
406
|
+
f"max_body_bytes={installed_max}; cannot reinstall with {max_body_bytes}"
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
if any(
|
|
410
|
+
getattr(route, "path", None) == _INVOKE_PATH
|
|
411
|
+
and "POST" in (getattr(route, "methods", None) or set())
|
|
412
|
+
for route in app.router.routes
|
|
413
|
+
):
|
|
414
|
+
raise RuntimeError(
|
|
415
|
+
"install_invocation_envelope must be called before the POST /invoke route is registered"
|
|
416
|
+
)
|
|
417
|
+
app.state.invocation_envelope_installed = True
|
|
418
|
+
app.state.invocation_envelope_max_body_bytes = max_body_bytes
|
|
419
|
+
app.router.dependencies.append(Depends(bind_invocation_envelope))
|
|
420
|
+
if max_body_bytes is not None:
|
|
421
|
+
app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes)
|
|
422
|
+
app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response)
|
|
423
|
+
|
|
424
|
+
previous_validation_handler = app.exception_handlers.get(
|
|
425
|
+
RequestValidationError, request_validation_exception_handler
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
async def invocation_request_validation_response(request: Request, exc: RequestValidationError):
|
|
429
|
+
if (
|
|
430
|
+
request.method == "POST"
|
|
431
|
+
and get_route_path(request.scope) == _INVOKE_PATH
|
|
432
|
+
and _is_malformed_json_validation(exc)
|
|
433
|
+
):
|
|
434
|
+
return JSONResponse(
|
|
435
|
+
status_code=422,
|
|
436
|
+
content={
|
|
437
|
+
"detail": "Invalid JSON body",
|
|
438
|
+
"reason": MalformedEnvelopeError.reason,
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
response = previous_validation_handler(request, exc)
|
|
442
|
+
return await response if inspect.isawaitable(response) else response
|
|
443
|
+
|
|
444
|
+
app.add_exception_handler(RequestValidationError, invocation_request_validation_response)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str:
|
|
448
|
+
"""Digest of an ordinary resolved settings mapping, safe for secret-bearing values."""
|
|
449
|
+
return hashlib.sha256(json.dumps(invocation_settings, sort_keys=True).encode()).hexdigest()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class SettingsScopedCache:
|
|
453
|
+
"""Bind expensive derived state (clients, models, handlers) to the settings that built it.
|
|
454
|
+
|
|
455
|
+
A plugin consuming ``current_invocation_settings()`` derives a handler from each distinct
|
|
456
|
+
resolved mapping. Construction typically performs network work (model resolution, prechecks),
|
|
457
|
+
so results are memoized by ``settings_cache_key``. Raw field envelopes never reach this cache:
|
|
458
|
+
the public settings library resolves and caches them at the field boundary before this
|
|
459
|
+
transport binds the result. Both bounds matter under shared tenancy: size caps how many
|
|
460
|
+
distinct mappings stay live, and age evicts state after credential rotation. Eviction driven
|
|
461
|
+
only by the count of distinct mappings can take arbitrarily long on a quiet pod.
|
|
462
|
+
|
|
463
|
+
Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice;
|
|
464
|
+
the extra build is wasted work, never wrong state.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
def __init__(
|
|
468
|
+
self,
|
|
469
|
+
*,
|
|
470
|
+
ttl_seconds: float = 15 * 60,
|
|
471
|
+
maxsize: int = 32,
|
|
472
|
+
clock: Callable[[], float] = time.monotonic,
|
|
473
|
+
) -> None:
|
|
474
|
+
if ttl_seconds <= 0:
|
|
475
|
+
raise ValueError("ttl_seconds must be positive")
|
|
476
|
+
if maxsize < 1:
|
|
477
|
+
raise ValueError("maxsize must be at least 1")
|
|
478
|
+
self._ttl_seconds = float(ttl_seconds)
|
|
479
|
+
self._maxsize = maxsize
|
|
480
|
+
self._clock = clock
|
|
481
|
+
self._lock = threading.Lock()
|
|
482
|
+
self._entries: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
|
483
|
+
|
|
484
|
+
def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[], T]) -> T:
|
|
485
|
+
"""Return the cached value for these settings, building it on a miss."""
|
|
486
|
+
key = settings_cache_key(invocation_settings)
|
|
487
|
+
now = self._clock()
|
|
488
|
+
with self._lock:
|
|
489
|
+
entry = self._entries.get(key)
|
|
490
|
+
if entry is not None:
|
|
491
|
+
expires_at, value = entry
|
|
492
|
+
if now < expires_at:
|
|
493
|
+
self._entries.move_to_end(key)
|
|
494
|
+
return value
|
|
495
|
+
del self._entries[key]
|
|
496
|
+
value = build()
|
|
497
|
+
with self._lock:
|
|
498
|
+
# Every insert also sweeps entries whose TTL has lapsed, so a tenant that stops
|
|
499
|
+
# sending requests does not keep its credential-bearing handler live while the pod
|
|
500
|
+
# stays busy for others; per-key expiry alone only fires on that tenant's next hit.
|
|
501
|
+
# Re-read the clock: build() may take long enough for more entries to lapse.
|
|
502
|
+
now = self._clock()
|
|
503
|
+
for stale_key, (expires_at, _) in list(self._entries.items()):
|
|
504
|
+
if now >= expires_at:
|
|
505
|
+
del self._entries[stale_key]
|
|
506
|
+
self._entries[key] = (now + self._ttl_seconds, value)
|
|
507
|
+
self._entries.move_to_end(key)
|
|
508
|
+
while len(self._entries) > self._maxsize:
|
|
509
|
+
self._entries.popitem(last=False)
|
|
510
|
+
return value
|
|
511
|
+
|
|
512
|
+
def handler_for(
|
|
513
|
+
self,
|
|
514
|
+
resolved_settings: Optional[Mapping[str, Any]],
|
|
515
|
+
*,
|
|
516
|
+
boot: Callable[[], Optional[T]],
|
|
517
|
+
build: Callable[[], T],
|
|
518
|
+
) -> T:
|
|
519
|
+
"""The handler for a request: cached per distinct resolved mapping, or the boot fallback.
|
|
520
|
+
|
|
521
|
+
Absent settings select the single handler configured by the boot-time settings file.
|
|
522
|
+
``boot`` returning ``None`` means the pod has no boot configuration and sealed per-invoke
|
|
523
|
+
settings are required, so the request fails rather than running an unconfigured handler.
|
|
524
|
+
"""
|
|
525
|
+
if resolved_settings is None:
|
|
526
|
+
handler = boot()
|
|
527
|
+
if handler is None:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
"no boot-time handler on this pod: sealed per-invoke settings are required"
|
|
530
|
+
)
|
|
531
|
+
return handler
|
|
532
|
+
return self.get_or_build(resolved_settings, build)
|
|
533
|
+
|
|
534
|
+
def clear(self) -> None:
|
|
535
|
+
with self._lock:
|
|
536
|
+
self._entries.clear()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.46" # pragma: no cover
|
|
File without changes
|
{unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/.gitignore
RENAMED
|
File without changes
|
{unstructured_platform_plugins-0.0.46 → unstructured_platform_plugins-0.1.1.dev1}/LICENSE.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|