unstructured-platform-plugins 0.0.0__py3-none-any.whl
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.
- test/__init__.py +0 -0
- test/assets/__init__.py +0 -0
- test/assets/async_typed_dict_response.py +10 -0
- test/assets/dataclass_response.py +37 -0
- test/assets/empty_input_and_response.py +8 -0
- test/assets/hash_function.py +2 -0
- test/assets/improper_function.py +2 -0
- test/assets/pydantic_response_class_method.py +21 -0
- test/assets/simple_hash_class.py +6 -0
- test/assets/simple_hash_lambda.py +1 -0
- test/assets/simple_hash_value.py +1 -0
- test/assets/typed_dict_response.py +17 -0
- test/test_schema.py +660 -0
- test/test_utils.py +140 -0
- unstructured_platform_plugins/__init__.py +0 -0
- unstructured_platform_plugins/__version__.py +1 -0
- unstructured_platform_plugins/etl_uvicorn/__init__.py +0 -0
- unstructured_platform_plugins/etl_uvicorn/api_generator.py +173 -0
- unstructured_platform_plugins/etl_uvicorn/main.py +121 -0
- unstructured_platform_plugins/etl_uvicorn/utils.py +115 -0
- unstructured_platform_plugins/schema/__init__.py +0 -0
- unstructured_platform_plugins/schema/json_schema.py +344 -0
- unstructured_platform_plugins/schema/model.py +101 -0
- unstructured_platform_plugins/schema/usage.py +7 -0
- unstructured_platform_plugins/schema/utils.py +31 -0
- unstructured_platform_plugins/type_hints.py +106 -0
- unstructured_platform_plugins/validate_api.py +110 -0
- unstructured_platform_plugins-0.0.0.dist-info/LICENSE +201 -0
- unstructured_platform_plugins-0.0.0.dist-info/LICENSE.md +201 -0
- unstructured_platform_plugins-0.0.0.dist-info/METADATA +106 -0
- unstructured_platform_plugins-0.0.0.dist-info/RECORD +34 -0
- unstructured_platform_plugins-0.0.0.dist-info/WHEEL +5 -0
- unstructured_platform_plugins-0.0.0.dist-info/entry_points.txt +3 -0
- unstructured_platform_plugins-0.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from dataclasses import MISSING, fields, is_dataclass
|
|
3
|
+
from enum import Enum, EnumMeta
|
|
4
|
+
from inspect import Parameter
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import GenericAlias, NoneType, UnionType
|
|
7
|
+
from typing import Any, Optional, Type, Union, _UnionGenericAlias
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, create_model
|
|
10
|
+
from pydantic.fields import FieldInfo, PydanticUndefined
|
|
11
|
+
from unstructured.ingest.v2.interfaces import FileData
|
|
12
|
+
|
|
13
|
+
from unstructured_platform_plugins.schema.utils import TypedParameter
|
|
14
|
+
from unstructured_platform_plugins.type_hints import get_type_hints
|
|
15
|
+
|
|
16
|
+
# https://json-schema.org/understanding-json-schema/reference/type
|
|
17
|
+
types_map: dict[Type, str] = {
|
|
18
|
+
str: "string",
|
|
19
|
+
bool: "boolean",
|
|
20
|
+
int: "integer",
|
|
21
|
+
float: "number",
|
|
22
|
+
dict: "object",
|
|
23
|
+
list: "array",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
typed_map_reverse: dict[str, Type] = {v: k for k, v in types_map.items()}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_generic_alias(val: Any) -> bool:
|
|
30
|
+
return hasattr(val, "__origin__") and hasattr(val, "__args__")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_typed_dict(val: Any) -> bool:
|
|
34
|
+
return (
|
|
35
|
+
inspect.isclass(val) and dict in inspect.getmro(val) and inspect.getmro(val)[0] is not dict
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def type_to_json_schema(t: Type, args: Optional[tuple[Any, ...]] = None) -> dict:
|
|
40
|
+
resp = {"type": types_map[t]}
|
|
41
|
+
if t is list and args:
|
|
42
|
+
list_type = args[0]
|
|
43
|
+
resp["items"] = to_json_schema(list_type)
|
|
44
|
+
if t is dict and args:
|
|
45
|
+
key = args[0]
|
|
46
|
+
value = args[1]
|
|
47
|
+
resp["items"] = {
|
|
48
|
+
"key": to_json_schema(key),
|
|
49
|
+
"value": to_json_schema(value),
|
|
50
|
+
}
|
|
51
|
+
return resp
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def path_to_json_schema(path: Path) -> dict:
|
|
55
|
+
return {"type": "string", "is_path": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def enum_to_json_schema(e: EnumMeta) -> dict:
|
|
59
|
+
values = [i.value for i in e]
|
|
60
|
+
value_types = [type(value) for value in values]
|
|
61
|
+
unique_value_types = list(set(value_types))
|
|
62
|
+
if len(unique_value_types) > 1:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"enum must have consistent types, found mixes: {}".format(
|
|
65
|
+
", ".join([e.__name__ for e in unique_value_types])
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
value_types = unique_value_types[0]
|
|
69
|
+
type_string = types_map[value_types]
|
|
70
|
+
return {"type": type_string, "enum": values}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generic_alias_to_json_schema(t: GenericAlias) -> dict:
|
|
74
|
+
origin = t.__origin__
|
|
75
|
+
if origin is Union:
|
|
76
|
+
types = t.__args__
|
|
77
|
+
if len(types) == 1:
|
|
78
|
+
return to_json_schema(types[0])
|
|
79
|
+
else:
|
|
80
|
+
return {"anyOf": [to_json_schema(t) for t in types]}
|
|
81
|
+
return type_to_json_schema(t=origin, args=t.__args__)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def union_type_to_json_schema(t: UnionType) -> dict:
|
|
85
|
+
types = t.__args__
|
|
86
|
+
if len(types) == 1:
|
|
87
|
+
return to_json_schema(types[0])
|
|
88
|
+
else:
|
|
89
|
+
return {"anyOf": [to_json_schema(t) for t in types]}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def dataclass_to_json_schema(class_or_instance: Any) -> dict:
|
|
93
|
+
resp = {"type": "object"}
|
|
94
|
+
is_filedata_instance = isinstance(class_or_instance, FileData)
|
|
95
|
+
is_filedata_class = class_or_instance is FileData
|
|
96
|
+
if is_filedata_instance or is_filedata_class:
|
|
97
|
+
resp["is_file_data"] = True
|
|
98
|
+
fs = fields(class_or_instance)
|
|
99
|
+
if not fs:
|
|
100
|
+
return resp
|
|
101
|
+
properties = {}
|
|
102
|
+
required = []
|
|
103
|
+
type_hints = get_type_hints(class_or_instance)
|
|
104
|
+
for f in fs:
|
|
105
|
+
t = type_hints[f.name]
|
|
106
|
+
f_resp = to_json_schema(t)
|
|
107
|
+
if f.default is not MISSING:
|
|
108
|
+
f_resp["default"] = f.default.value if isinstance(f.default, Enum) else f.default
|
|
109
|
+
else:
|
|
110
|
+
required.append(f.name)
|
|
111
|
+
properties[f.name] = f_resp
|
|
112
|
+
resp["properties"] = properties
|
|
113
|
+
resp["required"] = required
|
|
114
|
+
return resp
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pydantic_base_model_to_json_schema(model: Type[BaseModel]) -> dict:
|
|
118
|
+
resp = {"type": "object"}
|
|
119
|
+
fs: dict[str, FieldInfo] = model.model_fields
|
|
120
|
+
if not fs:
|
|
121
|
+
return resp
|
|
122
|
+
properties = {}
|
|
123
|
+
required = []
|
|
124
|
+
for name, f in fs.items():
|
|
125
|
+
t = f.annotation
|
|
126
|
+
f_resp = to_json_schema(t)
|
|
127
|
+
if f.default != PydanticUndefined:
|
|
128
|
+
f_resp["default"] = f.default
|
|
129
|
+
else:
|
|
130
|
+
required.append(name)
|
|
131
|
+
properties[name] = f_resp
|
|
132
|
+
resp["properties"] = properties
|
|
133
|
+
resp["required"] = required
|
|
134
|
+
return resp
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def typed_dict_to_json_schem(typed_dict_class) -> dict:
|
|
138
|
+
resp = {"type": "object"}
|
|
139
|
+
fs = typed_dict_class.__annotations__
|
|
140
|
+
if not fs:
|
|
141
|
+
return resp
|
|
142
|
+
properties = {}
|
|
143
|
+
required = []
|
|
144
|
+
type_hints = get_type_hints(typed_dict_class)
|
|
145
|
+
for name in fs:
|
|
146
|
+
t = type_hints[name]
|
|
147
|
+
f_resp = to_json_schema(t)
|
|
148
|
+
properties[name] = f_resp
|
|
149
|
+
required.append(name)
|
|
150
|
+
resp["properties"] = properties
|
|
151
|
+
resp["required"] = required
|
|
152
|
+
return resp
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parameter_to_json_schema(parameter: Parameter) -> dict:
|
|
156
|
+
annotation = parameter.annotation
|
|
157
|
+
resp = to_json_schema(annotation)
|
|
158
|
+
if parameter.default != Parameter.empty:
|
|
159
|
+
resp["default"] = parameter.default
|
|
160
|
+
return resp
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def typed_parameter_to_json_schema(parameter: TypedParameter) -> dict:
|
|
164
|
+
param_type = parameter.param_type
|
|
165
|
+
resp = to_json_schema(param_type)
|
|
166
|
+
if parameter.default != Parameter.empty:
|
|
167
|
+
resp["default"] = parameter.default
|
|
168
|
+
return resp
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def to_json_schema(val: Any) -> dict:
|
|
172
|
+
if val in [None, NoneType]:
|
|
173
|
+
return {"type": "null"}
|
|
174
|
+
if val is Any:
|
|
175
|
+
return {}
|
|
176
|
+
if isinstance(val, TypedParameter):
|
|
177
|
+
return typed_parameter_to_json_schema(parameter=val)
|
|
178
|
+
if isinstance(val, Parameter):
|
|
179
|
+
return parameter_to_json_schema(parameter=val)
|
|
180
|
+
if isinstance(val, UnionType):
|
|
181
|
+
return union_type_to_json_schema(t=val)
|
|
182
|
+
if isinstance(val, EnumMeta):
|
|
183
|
+
return enum_to_json_schema(e=val)
|
|
184
|
+
if is_generic_alias(val=val):
|
|
185
|
+
return generic_alias_to_json_schema(t=val)
|
|
186
|
+
if val is Type:
|
|
187
|
+
return type_to_json_schema(t=val)
|
|
188
|
+
if is_dataclass(val):
|
|
189
|
+
return dataclass_to_json_schema(val)
|
|
190
|
+
if val is Path:
|
|
191
|
+
return path_to_json_schema(val)
|
|
192
|
+
if val in types_map:
|
|
193
|
+
return type_to_json_schema(t=val)
|
|
194
|
+
if inspect.isclass(val) and issubclass(val, BaseModel):
|
|
195
|
+
return pydantic_base_model_to_json_schema(model=val)
|
|
196
|
+
if is_typed_dict(val):
|
|
197
|
+
return typed_dict_to_json_schem(val)
|
|
198
|
+
raise ValueError(f"Unsupported type: ({type(val).__name__}) {val}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run_input_checks(parameters: list[Parameter]):
|
|
202
|
+
# Any variable positional or keyword args are not allowed (i.e. *args, **kwargs)
|
|
203
|
+
var_positional = [str(p) for p in parameters if p.kind == Parameter.VAR_POSITIONAL]
|
|
204
|
+
if var_positional:
|
|
205
|
+
raise TypeError(
|
|
206
|
+
"function has variable positional arguments, which are not allowed: {}".format(
|
|
207
|
+
", ".join(var_positional)
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
var_keywords = [str(p) for p in parameters if p.kind == Parameter.VAR_KEYWORD]
|
|
211
|
+
if var_keywords:
|
|
212
|
+
raise TypeError(
|
|
213
|
+
"function has variable keyword arguments, which are not allowed: {}".format(
|
|
214
|
+
", ".join(var_keywords)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parameters_to_json_schema(parameters: list[Parameter]) -> dict:
|
|
220
|
+
if not parameters:
|
|
221
|
+
return {"type": "null"}
|
|
222
|
+
run_input_checks(parameters=parameters)
|
|
223
|
+
resp = {"type": "object"}
|
|
224
|
+
properties = {}
|
|
225
|
+
required_fields = []
|
|
226
|
+
for p in parameters:
|
|
227
|
+
schema = to_json_schema(val=p)
|
|
228
|
+
properties[p.name] = schema
|
|
229
|
+
if p.default == Parameter.empty:
|
|
230
|
+
required_fields.append(p.name)
|
|
231
|
+
if required_fields:
|
|
232
|
+
resp["required"] = required_fields
|
|
233
|
+
if properties:
|
|
234
|
+
resp["properties"] = properties
|
|
235
|
+
return resp
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def run_output_checks(return_annotation: Any):
|
|
239
|
+
if is_generic_alias(val=return_annotation): # noqa: SIM102
|
|
240
|
+
if return_annotation.__origin__ in [Union, list]:
|
|
241
|
+
for arg in return_annotation.__args__:
|
|
242
|
+
run_output_checks(arg)
|
|
243
|
+
return
|
|
244
|
+
if is_typed_dict(return_annotation):
|
|
245
|
+
return
|
|
246
|
+
if is_dataclass(return_annotation):
|
|
247
|
+
return
|
|
248
|
+
try:
|
|
249
|
+
if inspect.isclass(return_annotation) and issubclass(return_annotation, BaseModel):
|
|
250
|
+
return
|
|
251
|
+
except TypeError:
|
|
252
|
+
pass
|
|
253
|
+
if return_annotation is None:
|
|
254
|
+
return
|
|
255
|
+
if return_annotation is NoneType:
|
|
256
|
+
return
|
|
257
|
+
raise ValueError(f"Unsupported response type: {return_annotation}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def response_to_json_schema(return_annotation: Any) -> dict:
|
|
261
|
+
run_output_checks(return_annotation=return_annotation)
|
|
262
|
+
return to_json_schema(val=return_annotation)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def schema_to_base_model_type(json_type_name, name: str, type_info: dict) -> Type:
|
|
266
|
+
t = typed_map_reverse[json_type_name]
|
|
267
|
+
if t is dict and type_info.get("is_file_data", False):
|
|
268
|
+
return FileData
|
|
269
|
+
if t is str and type_info.get("is_path", False):
|
|
270
|
+
return Path
|
|
271
|
+
if t is dict and "properties" in type_info:
|
|
272
|
+
t = schema_to_base_model(
|
|
273
|
+
schema=type_info,
|
|
274
|
+
name=name,
|
|
275
|
+
)
|
|
276
|
+
if t is dict and "items" in type_info and isinstance(type_info["items"], dict):
|
|
277
|
+
items = type_info["items"]
|
|
278
|
+
if "key" in items and "value" in items:
|
|
279
|
+
key_info = items["key"]
|
|
280
|
+
key_type_name = key_info["type"]
|
|
281
|
+
key_subtype = schema_to_base_model_type(
|
|
282
|
+
json_type_name=key_type_name, name=f"{name}_key", type_info=key_info
|
|
283
|
+
)
|
|
284
|
+
value_info = items["value"]
|
|
285
|
+
if not value_info:
|
|
286
|
+
value_subtype = Any
|
|
287
|
+
else:
|
|
288
|
+
value_type_name = items["value"]["type"]
|
|
289
|
+
value_subtype = schema_to_base_model_type(
|
|
290
|
+
json_type_name=value_type_name, name=f"{name}_value", type_info=value_info
|
|
291
|
+
)
|
|
292
|
+
t = dict[key_subtype, value_subtype]
|
|
293
|
+
if t is list and "items" in type_info and isinstance(type_info["items"], dict):
|
|
294
|
+
items = type_info["items"]
|
|
295
|
+
item_type_name = items["type"]
|
|
296
|
+
subtype = schema_to_base_model_type(
|
|
297
|
+
json_type_name=item_type_name, name=f"{name}_type", type_info=items
|
|
298
|
+
)
|
|
299
|
+
t = list[subtype]
|
|
300
|
+
if "enum" in type_info and isinstance(type_info["enum"], list):
|
|
301
|
+
enum_content = type_info["enum"]
|
|
302
|
+
t = Enum(f"{name}_enum", {v: v for v in enum_content})
|
|
303
|
+
return t
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def schema_to_base_model(schema: dict, name: str = "reconstructed_model") -> Type[BaseModel]:
|
|
307
|
+
inputs = {}
|
|
308
|
+
properties = schema["properties"]
|
|
309
|
+
|
|
310
|
+
for k, v in properties.items():
|
|
311
|
+
optional = False
|
|
312
|
+
if "anyOf" in v:
|
|
313
|
+
any_of_entries = v["anyOf"]
|
|
314
|
+
if "null" in [entry["type"] for entry in any_of_entries]:
|
|
315
|
+
optional = True
|
|
316
|
+
any_of_entries = [entry for entry in any_of_entries if entry["type"] != "null"]
|
|
317
|
+
if len(any_of_entries) > 1:
|
|
318
|
+
type_info = [
|
|
319
|
+
schema_to_base_model_type(
|
|
320
|
+
type_info["type"], name=f"{k}_{index}", type_info=type_info
|
|
321
|
+
)
|
|
322
|
+
for index, type_info in enumerate(any_of_entries)
|
|
323
|
+
]
|
|
324
|
+
# To support python3.10, unpacking not supported
|
|
325
|
+
# t = Union[*type_info]
|
|
326
|
+
t = _UnionGenericAlias(Union, tuple(type_info))
|
|
327
|
+
else:
|
|
328
|
+
entry_info = any_of_entries[0]
|
|
329
|
+
json_type_name = entry_info["type"]
|
|
330
|
+
t = schema_to_base_model_type(
|
|
331
|
+
json_type_name=json_type_name, name=k, type_info=entry_info
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
json_type_name = v["type"]
|
|
335
|
+
t = schema_to_base_model_type(json_type_name=json_type_name, name=k, type_info=v)
|
|
336
|
+
if optional:
|
|
337
|
+
t = Optional[t]
|
|
338
|
+
resp = [t]
|
|
339
|
+
if "default" in v:
|
|
340
|
+
resp.append(v["default"])
|
|
341
|
+
else:
|
|
342
|
+
resp.append(Ellipsis)
|
|
343
|
+
inputs[k] = tuple(resp)
|
|
344
|
+
return create_model(name, **inputs)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from types import UnionType
|
|
3
|
+
from typing import Any, Literal, Optional, Union, get_args
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
|
|
8
|
+
AnnotatedType = type(Annotated[str, str])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StringEntrySchema(BaseModel):
|
|
12
|
+
type: Literal["string"]
|
|
13
|
+
default: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BooleanEntrySchema(BaseModel):
|
|
17
|
+
type: Literal["boolean"]
|
|
18
|
+
default: Optional[bool] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IntegerEntrySchema(BaseModel):
|
|
22
|
+
type: Literal["integer"]
|
|
23
|
+
default: Optional[int] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NumberEntrySchema(BaseModel):
|
|
27
|
+
type: Literal["number"]
|
|
28
|
+
default: Optional[float] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArrayEntrySchema(BaseModel):
|
|
32
|
+
type: Literal["array"]
|
|
33
|
+
items: Optional["AnyEntry"] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ObjectEntrySchema(BaseModel):
|
|
37
|
+
type: Literal["object"]
|
|
38
|
+
properties: Optional[dict[str, "AnyEntry"]] = None
|
|
39
|
+
required: Optional[list[str]] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NullEntrySchema(BaseModel):
|
|
43
|
+
type: Literal["null"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnyOfEntrySchema(BaseModel):
|
|
47
|
+
anyOf: list["AnyEntry"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
TypedAnyEntry = Union[
|
|
51
|
+
StringEntrySchema,
|
|
52
|
+
BooleanEntrySchema,
|
|
53
|
+
IntegerEntrySchema,
|
|
54
|
+
ArrayEntrySchema,
|
|
55
|
+
ObjectEntrySchema,
|
|
56
|
+
NumberEntrySchema,
|
|
57
|
+
NullEntrySchema,
|
|
58
|
+
]
|
|
59
|
+
TypedAnyEntryTyping = Annotated[TypedAnyEntry, Field(discriminator="type")]
|
|
60
|
+
AnyEntry = Union[TypedAnyEntryTyping, AnyOfEntrySchema]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_valid_input_dict(schema: dict) -> bool:
|
|
64
|
+
# Support both null and object cases
|
|
65
|
+
try:
|
|
66
|
+
NullEntrySchema.model_validate(schema, strict=True)
|
|
67
|
+
return True
|
|
68
|
+
except ValidationError:
|
|
69
|
+
pass
|
|
70
|
+
try:
|
|
71
|
+
ObjectEntrySchema.model_validate(schema, strict=True)
|
|
72
|
+
return True
|
|
73
|
+
except ValidationError:
|
|
74
|
+
pass
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decompose_union(tp: Any) -> list[BaseModel]:
|
|
79
|
+
args = get_args(tp)
|
|
80
|
+
decomposed_types = []
|
|
81
|
+
for arg in args:
|
|
82
|
+
if inspect.isclass(arg) and issubclass(arg, BaseModel):
|
|
83
|
+
decomposed_types.append(arg)
|
|
84
|
+
elif isinstance(arg, UnionType):
|
|
85
|
+
decomposed_types.extend(decompose_union(tp=arg))
|
|
86
|
+
elif isinstance(arg, AnnotatedType):
|
|
87
|
+
annotated_args = get_args(arg)
|
|
88
|
+
type_arg = annotated_args[0]
|
|
89
|
+
decomposed_types.extend(decompose_union(tp=type_arg))
|
|
90
|
+
return decomposed_types
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_valid_response_dict(schema: dict) -> bool:
|
|
94
|
+
decomposed_models = decompose_union(tp=AnyEntry)
|
|
95
|
+
for tp in decomposed_models:
|
|
96
|
+
try:
|
|
97
|
+
tp.model_validate(schema, strict=True)
|
|
98
|
+
return True
|
|
99
|
+
except ValidationError:
|
|
100
|
+
pass
|
|
101
|
+
return False
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from inspect import Parameter, _empty
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from unstructured_platform_plugins.type_hints import get_type_hints
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TypedParameter(Parameter):
|
|
9
|
+
def __init__(self, *args, param_type=_empty, **kwargs):
|
|
10
|
+
super().__init__(*args, **kwargs)
|
|
11
|
+
self.param_type = param_type
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_parameter(cls, param: Parameter) -> "TypedParameter":
|
|
15
|
+
return cls(
|
|
16
|
+
name=param.name, default=param.default, annotation=param.annotation, kind=param.kind
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_typed_parameters(fn: Callable) -> list[TypedParameter]:
|
|
21
|
+
type_hints = get_type_hints(fn)
|
|
22
|
+
parameters = list(inspect.signature(fn).parameters.values())
|
|
23
|
+
typed_params = []
|
|
24
|
+
for p in parameters:
|
|
25
|
+
typed_param = TypedParameter.from_parameter(param=p)
|
|
26
|
+
if isinstance(typed_param.annotation, str):
|
|
27
|
+
typed_param.param_type = type_hints[typed_param.name]
|
|
28
|
+
else:
|
|
29
|
+
typed_param.param_type = typed_param.annotation
|
|
30
|
+
typed_params.append(typed_param)
|
|
31
|
+
return typed_params
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import ForwardRef, _allowed_types, _eval_type, _strip_annotations, types
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
python3.10 behavior breaks python3.12 behavior, injecting Optional[] into the type
|
|
6
|
+
if the field has a default value set. Below is the 3.12 behavior, pulling the same logic in when
|
|
7
|
+
3.10 is used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
|
|
12
|
+
"""Return type hints for an object.
|
|
13
|
+
|
|
14
|
+
This is often the same as obj.__annotations__, but it handles
|
|
15
|
+
forward references encoded as string literals and recursively replaces all
|
|
16
|
+
'Annotated[T, ...]' with 'T' (unless 'include_extras=True').
|
|
17
|
+
|
|
18
|
+
The argument may be a module, class, method, or function. The annotations
|
|
19
|
+
are returned as a dictionary. For classes, annotations include also
|
|
20
|
+
inherited members.
|
|
21
|
+
|
|
22
|
+
TypeError is raised if the argument is not of a type that can contain
|
|
23
|
+
annotations, and an empty dictionary is returned if no annotations are
|
|
24
|
+
present.
|
|
25
|
+
|
|
26
|
+
BEWARE -- the behavior of globalns and localns is counterintuitive
|
|
27
|
+
(unless you are familiar with how eval() and exec() work). The
|
|
28
|
+
search order is locals first, then globals.
|
|
29
|
+
|
|
30
|
+
- If no dict arguments are passed, an attempt is made to use the
|
|
31
|
+
globals from obj (or the respective module's globals for classes),
|
|
32
|
+
and these are also used as the locals. If the object does not appear
|
|
33
|
+
to have globals, an empty dictionary is used. For classes, the search
|
|
34
|
+
order is globals first then locals.
|
|
35
|
+
|
|
36
|
+
- If one dict argument is passed, it is used for both globals and
|
|
37
|
+
locals.
|
|
38
|
+
|
|
39
|
+
- If two dict arguments are passed, they specify globals and
|
|
40
|
+
locals, respectively.
|
|
41
|
+
"""
|
|
42
|
+
if getattr(obj, "__no_type_check__", None):
|
|
43
|
+
return {}
|
|
44
|
+
# Classes require a special treatment.
|
|
45
|
+
if isinstance(obj, type):
|
|
46
|
+
hints = {}
|
|
47
|
+
for base in reversed(obj.__mro__):
|
|
48
|
+
if globalns is None:
|
|
49
|
+
base_globals = getattr(sys.modules.get(base.__module__, None), "__dict__", {})
|
|
50
|
+
else:
|
|
51
|
+
base_globals = globalns
|
|
52
|
+
ann = base.__dict__.get("__annotations__", {})
|
|
53
|
+
if isinstance(ann, types.GetSetDescriptorType):
|
|
54
|
+
ann = {}
|
|
55
|
+
base_locals = dict(vars(base)) if localns is None else localns
|
|
56
|
+
if localns is None and globalns is None:
|
|
57
|
+
# This is surprising, but required. Before Python 3.10,
|
|
58
|
+
# get_type_hints only evaluated the globalns of
|
|
59
|
+
# a class. To maintain backwards compatibility, we reverse
|
|
60
|
+
# the globalns and localns order so that eval() looks into
|
|
61
|
+
# *base_globals* first rather than *base_locals*.
|
|
62
|
+
# This only affects ForwardRefs.
|
|
63
|
+
base_globals, base_locals = base_locals, base_globals
|
|
64
|
+
for name, value in ann.items():
|
|
65
|
+
if value is None:
|
|
66
|
+
value = type(None)
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
value = ForwardRef(value, is_argument=False, is_class=True)
|
|
69
|
+
value = _eval_type(value, base_globals, base_locals)
|
|
70
|
+
hints[name] = value
|
|
71
|
+
return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()}
|
|
72
|
+
|
|
73
|
+
if globalns is None:
|
|
74
|
+
if isinstance(obj, types.ModuleType):
|
|
75
|
+
globalns = obj.__dict__
|
|
76
|
+
else:
|
|
77
|
+
nsobj = obj
|
|
78
|
+
# Find globalns for the unwrapped object.
|
|
79
|
+
while hasattr(nsobj, "__wrapped__"):
|
|
80
|
+
nsobj = nsobj.__wrapped__
|
|
81
|
+
globalns = getattr(nsobj, "__globals__", {})
|
|
82
|
+
if localns is None:
|
|
83
|
+
localns = globalns
|
|
84
|
+
elif localns is None:
|
|
85
|
+
localns = globalns
|
|
86
|
+
hints = getattr(obj, "__annotations__", None)
|
|
87
|
+
if hints is None:
|
|
88
|
+
# Return empty annotations for something that _could_ have them.
|
|
89
|
+
if isinstance(obj, _allowed_types):
|
|
90
|
+
return {}
|
|
91
|
+
else:
|
|
92
|
+
raise TypeError(f"{obj!r} is not a module, class, method, " "or function.")
|
|
93
|
+
hints = dict(hints)
|
|
94
|
+
for name, value in hints.items():
|
|
95
|
+
if value is None:
|
|
96
|
+
value = type(None)
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
# class-level forward refs were handled above, this must be either
|
|
99
|
+
# a module-level annotation or a function argument annotation
|
|
100
|
+
value = ForwardRef(
|
|
101
|
+
value,
|
|
102
|
+
is_argument=not isinstance(obj, types.ModuleType),
|
|
103
|
+
is_class=False,
|
|
104
|
+
)
|
|
105
|
+
hints[name] = _eval_type(value, globalns, localns)
|
|
106
|
+
return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from urllib.parse import urljoin
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pydantic import HttpUrl
|
|
5
|
+
from requests import Session
|
|
6
|
+
|
|
7
|
+
from unstructured_platform_plugins.schema.model import is_valid_input_dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiSession(Session):
|
|
11
|
+
def __init__(self, base_url: HttpUrl = None):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.base_url = str(base_url)
|
|
14
|
+
|
|
15
|
+
def request(self, method, url, *args, **kwargs):
|
|
16
|
+
joined_url = urljoin(self.base_url, url)
|
|
17
|
+
return super().request(method, joined_url, *args, **kwargs)
|
|
18
|
+
|
|
19
|
+
def get_url(self, url: str) -> str:
|
|
20
|
+
return urljoin(self.base_url, url)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ValidationError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_endpoint_exists(api_session: ApiSession, endpoint: str):
|
|
28
|
+
try:
|
|
29
|
+
api_session.head(endpoint)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
raise ValidationError(
|
|
32
|
+
f"failed to validate that url exists: {api_session.get_url(url=endpoint)}"
|
|
33
|
+
) from e
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_schema_response(api_session: ApiSession):
|
|
37
|
+
resp = api_session.get("/schema")
|
|
38
|
+
try:
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise ValidationError(
|
|
42
|
+
"failed to validate response from schema "
|
|
43
|
+
"endpoint {}: {}".format(api_session.get_url(url="/schema"), e)
|
|
44
|
+
) from e
|
|
45
|
+
contents = resp.json()
|
|
46
|
+
if not is_valid_input_dict(contents):
|
|
47
|
+
raise ValidationError("schema response don't conform to expected format")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_id_response(api_session: ApiSession):
|
|
51
|
+
resp = api_session.get("/id")
|
|
52
|
+
try:
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
contents = resp.text
|
|
55
|
+
assert contents.strip() != ""
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise ValidationError(
|
|
58
|
+
"failed to validate response from id endpoint "
|
|
59
|
+
"{}: {}".format(api_session.get_url(url="/id"), e)
|
|
60
|
+
) from e
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_report(api_session: ApiSession) -> dict[str, str]:
|
|
64
|
+
report = {}
|
|
65
|
+
try:
|
|
66
|
+
check_endpoint_exists(api_session=api_session, endpoint="/invoke")
|
|
67
|
+
except ValidationError as e:
|
|
68
|
+
report["/invoke endpoint existence"] = str(e)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
check_endpoint_exists(api_session=api_session, endpoint="/schema")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
check_schema_response(api_session=api_session)
|
|
75
|
+
except ValidationError as e:
|
|
76
|
+
report["/schema endpoint response"] = str(e)
|
|
77
|
+
|
|
78
|
+
except ValidationError as e:
|
|
79
|
+
report["/schema endpoint existence"] = str(e)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
check_endpoint_exists(api_session=api_session, endpoint="/id")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
check_id_response(api_session=api_session)
|
|
86
|
+
except ValidationError as e:
|
|
87
|
+
report["/id endpoint response"] = str(e)
|
|
88
|
+
except ValidationError as e:
|
|
89
|
+
report["/id endpoint existence"] = str(e)
|
|
90
|
+
|
|
91
|
+
return report
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.command()
|
|
95
|
+
@click.option("--api-url", type=HttpUrl, required=True, help="API URL to run validation against")
|
|
96
|
+
def validate_api(api_url: HttpUrl) -> None:
|
|
97
|
+
api_session = ApiSession(base_url=api_url)
|
|
98
|
+
report = create_report(api_session=api_session)
|
|
99
|
+
if report:
|
|
100
|
+
print("Api validation failed:")
|
|
101
|
+
for k, v in report.items():
|
|
102
|
+
print(f"{k}: {v}")
|
|
103
|
+
|
|
104
|
+
exit(1)
|
|
105
|
+
print("Api validation successful")
|
|
106
|
+
exit(0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
validate_api()
|