unstructured-platform-plugins 0.0.45__tar.gz → 0.0.46__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.
Files changed (21) hide show
  1. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/PKG-INFO +2 -2
  2. unstructured_platform_plugins-0.0.46/unstructured_platform_plugins/__version__.py +1 -0
  3. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/etl_uvicorn/api_generator.py +89 -30
  4. unstructured_platform_plugins-0.0.45/unstructured_platform_plugins/__version__.py +0 -1
  5. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/.gitignore +0 -0
  6. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/LICENSE.md +0 -0
  7. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/README.md +0 -0
  8. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/pyproject.toml +0 -0
  9. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/__init__.py +0 -0
  10. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/etl_uvicorn/__init__.py +0 -0
  11. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/etl_uvicorn/main.py +0 -0
  12. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/etl_uvicorn/otel.py +0 -0
  13. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/etl_uvicorn/utils.py +0 -0
  14. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/schema/__init__.py +0 -0
  15. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/schema/filedata_meta.py +0 -0
  16. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/schema/json_schema.py +0 -0
  17. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/schema/model.py +0 -0
  18. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/schema/usage.py +0 -0
  19. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/schema/utils.py +0 -0
  20. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/type_hints.py +0 -0
  21. {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.0.46}/unstructured_platform_plugins/validate_api.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: unstructured_platform_plugins
3
- Version: 0.0.45
3
+ Version: 0.0.46
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
@@ -0,0 +1 @@
1
+ __version__ = "0.0.46" # pragma: no cover
@@ -3,8 +3,7 @@ import hashlib
3
3
  import inspect
4
4
  import json
5
5
  import logging
6
- from functools import partial
7
- from typing import Any, Callable, Optional, Union
6
+ from typing import Any, Callable, Optional, Union, get_origin
8
7
 
9
8
  from fastapi import FastAPI, HTTPException, status
10
9
  from fastapi.responses import StreamingResponse
@@ -62,21 +61,74 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
62
61
  logger.log(level=logger.level, msg=msg)
63
62
 
64
63
 
64
+ def _error_attr(error: BaseException, name: str) -> Any:
65
+ """Read an attribute off a raised error, treating a raising property as absent.
66
+
67
+ Runs while an exception handler is building the sanitized response; an
68
+ attribute access that itself raises must not replace that response with a
69
+ raw 500.
70
+ """
71
+ try:
72
+ return getattr(error, name, None)
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def _safe_str(value: object) -> str:
78
+ """str() on a plugin-supplied error can itself raise; never let that escape the handler.
79
+
80
+ An escape replaces the sanitized envelope with a raw HTTP 500, which the
81
+ controller's preflight reads as fail-open — a plugin-reported failure would
82
+ silently become a proceed.
83
+ """
84
+ try:
85
+ return str(value)
86
+ except Exception:
87
+ return "<unrenderable error>"
88
+
89
+
90
+ def failure_category_of(error: BaseException) -> Optional[str]:
91
+ """Return the error's failure_category only when it is a plain string."""
92
+ category = _error_attr(error, "failure_category")
93
+ return category if isinstance(category, str) else None
94
+
95
+
96
+ def status_code_of(error: BaseException) -> int:
97
+ """Return the error's status_code only when it is an int in the HTTP range, else 500."""
98
+ status_code = _error_attr(error, "status_code")
99
+ if (
100
+ isinstance(status_code, int)
101
+ and not isinstance(status_code, bool)
102
+ and 100 <= status_code <= 599
103
+ ):
104
+ return status_code
105
+ return status.HTTP_500_INTERNAL_SERVER_ERROR
106
+
107
+
65
108
  async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Any:
66
109
  kwargs = kwargs or {}
67
110
  if inspect.iscoroutinefunction(func):
68
111
  return await func(**kwargs)
69
- else:
70
- return await asyncio.get_event_loop().run_in_executor(None, partial(func, **kwargs))
112
+ # to_thread copies contextvars into the worker thread, so OpenTelemetry
113
+ # context (and any wide-event adoption inside func) survives the hop;
114
+ # run_in_executor does not.
115
+ return await asyncio.to_thread(func, **kwargs)
71
116
 
72
117
 
73
118
  def check_precheck_func(precheck_func: Callable):
74
- sig = inspect.signature(precheck_func)
75
- inputs = sig.parameters.values()
119
+ try:
120
+ # eval_str resolves postponed/string annotations ('list', 'None')
121
+ sig = inspect.signature(precheck_func, eval_str=True)
122
+ except (NameError, TypeError):
123
+ sig = inspect.signature(precheck_func)
124
+ inputs = list(sig.parameters.values())
76
125
  outputs = sig.return_annotation
77
126
  if len(inputs) == 1:
78
127
  i = inputs[0]
79
- if i.name != "usage" or i.annotation is list:
128
+ annotation_is_list = (
129
+ i.annotation is sig.empty or i.annotation is list or get_origin(i.annotation) is list
130
+ )
131
+ if i.name != "usage" or not annotation_is_list:
80
132
  raise ValueError("the only input available for precheck is usage which must be a list")
81
133
  if outputs not in [None, sig.empty]:
82
134
  raise ValueError(f"no output should exist for precheck function, found: {outputs}")
@@ -135,6 +187,9 @@ def _wrap_in_fastapi(
135
187
 
136
188
  logger.debug(f"set static id response to: {plugin_id}")
137
189
 
190
+ if "usage" not in inspect.signature(func).parameters:
191
+ logger.warning("usage data not an expected parameter, omitting")
192
+
138
193
  fastapi_app = FastAPI()
139
194
 
140
195
  response_type = get_output_sig(func)
@@ -146,6 +201,7 @@ def _wrap_in_fastapi(
146
201
  file_data: Optional[FileDataType] = None
147
202
  filedata_meta: Optional[filedata_meta_model] = None
148
203
  status_code_text: Optional[str] = None
204
+ failure_category: Optional[str] = None
149
205
  output: Optional[response_type] = None
150
206
  message_channels: MessageChannels = Field(default_factory=MessageChannels)
151
207
 
@@ -161,13 +217,12 @@ def _wrap_in_fastapi(
161
217
  filedata_meta = FileDataMeta()
162
218
  message_channels = MessageChannels()
163
219
  request_dict = kwargs if kwargs else {}
164
- if "usage" in inspect.signature(func).parameters:
220
+ params = inspect.signature(func).parameters
221
+ if "usage" in params:
165
222
  request_dict["usage"] = usage
166
- else:
167
- logger.warning("usage data not an expected parameter, omitting")
168
- if "message_channels" in inspect.signature(func).parameters:
223
+ if "message_channels" in params:
169
224
  request_dict["message_channels"] = message_channels
170
- if "filedata_meta" in inspect.signature(func).parameters:
225
+ if "filedata_meta" in params:
171
226
  request_dict["filedata_meta"] = filedata_meta
172
227
  try:
173
228
  if inspect.isasyncgenfunction(func):
@@ -189,7 +244,7 @@ def _wrap_in_fastapi(
189
244
  + "\n"
190
245
  )
191
246
  except Exception as e:
192
- logger.error(f"Failure streaming response: {e}", exc_info=True)
247
+ logger.error(f"Failure streaming response: {_safe_str(e)}", exc_info=True)
193
248
  yield (
194
249
  InvokeResponse(
195
250
  usage=usage,
@@ -197,9 +252,9 @@ def _wrap_in_fastapi(
197
252
  filedata_meta=filedata_meta_model.model_validate(
198
253
  filedata_meta.model_dump()
199
254
  ),
200
- status_code=getattr(e, "status_code", None)
201
- or status.HTTP_500_INTERNAL_SERVER_ERROR,
202
- status_code_text=f"[{e.__class__.__name__}] {e}",
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),
203
258
  ).model_dump_json()
204
259
  + "\n"
205
260
  )
@@ -217,40 +272,44 @@ def _wrap_in_fastapi(
217
272
  )
218
273
  except HTTPException as exc:
219
274
  logger.error(
220
- f"HTTPException: {exc.detail} (status_code={exc.status_code})", exc_info=True
275
+ f"HTTPException: {_safe_str(exc.detail)} (status_code={exc.status_code})",
276
+ exc_info=True,
221
277
  )
222
278
  return InvokeResponse(
223
279
  usage=usage,
224
280
  message_channels=message_channels,
225
281
  filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
226
282
  status_code=exc.status_code,
227
- status_code_text=json.dumps(exc.detail)
228
- if isinstance(exc.detail, dict)
229
- else exc.detail,
283
+ status_code_text=exc.detail
284
+ if isinstance(exc.detail, str)
285
+ else json.dumps(exc.detail, default=_safe_str),
286
+ failure_category=failure_category_of(exc),
230
287
  file_data=request_dict.get("file_data", None),
231
288
  )
232
289
  except UnstructuredIngestError as exc:
233
290
  logger.error(
234
- f"UnstructuredIngestError: {str(exc)} (status_code={exc.status_code})",
291
+ f"UnstructuredIngestError: {_safe_str(exc)} "
292
+ f"(status_code={_error_attr(exc, 'status_code')})",
235
293
  exc_info=True,
236
294
  )
237
295
  return InvokeResponse(
238
296
  usage=usage,
239
297
  message_channels=message_channels,
240
298
  filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
241
- status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR,
242
- status_code_text=str(exc),
299
+ status_code=status_code_of(exc),
300
+ status_code_text=_safe_str(exc),
301
+ failure_category=failure_category_of(exc),
243
302
  file_data=request_dict.get("file_data", None),
244
303
  )
245
304
  except Exception as invoke_error:
246
- logger.error(f"failed to invoke plugin: {invoke_error}", exc_info=True)
305
+ logger.error(f"failed to invoke plugin: {_safe_str(invoke_error)}", exc_info=True)
247
306
  return InvokeResponse(
248
307
  usage=usage,
249
308
  message_channels=message_channels,
250
309
  filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
251
- status_code=getattr(invoke_error, "status_code", None)
252
- or status.HTTP_500_INTERNAL_SERVER_ERROR,
253
- status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}",
310
+ status_code=status_code_of(invoke_error),
311
+ status_code_text=f"[{type(invoke_error).__name__}] {_safe_str(invoke_error)}",
312
+ failure_category=failure_category_of(invoke_error),
254
313
  file_data=request_dict.get("file_data", None),
255
314
  )
256
315
 
@@ -287,9 +346,7 @@ def _wrap_in_fastapi(
287
346
 
288
347
  @fastapi_app.post("/invoke", response_model=InvokeResponse)
289
348
  async def run_job(request: Optional[input_schema_model] = None) -> ResponseType:
290
- return await run_job_with_body(
291
- request if request is not None else input_schema_model()
292
- )
349
+ return await run_job_with_body(request if request is not None else input_schema_model())
293
350
 
294
351
  elif input_schema_model.model_fields:
295
352
 
@@ -318,6 +375,7 @@ def _wrap_in_fastapi(
318
375
  usage: list[UsageData]
319
376
  status_code: int
320
377
  status_code_text: Optional[str] = None
378
+ failure_category: Optional[str] = None
321
379
 
322
380
  @fastapi_app.get("/schema")
323
381
  async def get_schema() -> SchemaOutputResponse:
@@ -332,6 +390,7 @@ def _wrap_in_fastapi(
332
390
  return InvokePrecheckResponse(
333
391
  status_code=fn_response.status_code,
334
392
  status_code_text=fn_response.status_code_text,
393
+ failure_category=fn_response.failure_category,
335
394
  usage=fn_response.usage,
336
395
  )
337
396
  else:
@@ -1 +0,0 @@
1
- __version__ = "0.0.45" # pragma: no cover