richapi 0.3.0__tar.gz → 0.3.2__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.
- {richapi-0.3.0 → richapi-0.3.2}/PKG-INFO +1 -1
- {richapi-0.3.0 → richapi-0.3.2}/docs/conventions.md +26 -0
- {richapi-0.3.0 → richapi-0.3.2}/pyproject.toml +2 -1
- richapi-0.3.2/richapi/__version__.py +1 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/compiler.py +247 -124
- {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/openapi.py +3 -2
- richapi-0.3.2/tests/app/__init__.py +0 -0
- richapi-0.3.2/tests/app/foo1.py +10 -0
- richapi-0.3.2/tests/test_builder_pattern.py +68 -0
- richapi-0.3.2/tests/test_class.py +72 -0
- richapi-0.3.2/tests/test_compiler.py +12 -0
- richapi-0.3.2/tests/test_duplicated_function.py +0 -0
- richapi-0.3.2/tests/test_import_from_other_module.py +20 -0
- richapi-0.3.2/tests/test_instance_attr_call.py +82 -0
- richapi-0.3.0/richapi/__version__.py +0 -1
- {richapi-0.3.0 → richapi-0.3.2}/.envrc +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/.github/workflows/deploy-docs-on-demand.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/.github/workflows/main.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/.github/workflows/on-release-main.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/.gitignore +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/README.md +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/deploy_docs.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/docs/advance.md +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/docs/images/custom-exception.png +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/docs/images/with-richapi.png +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/docs/images/without-richapi.png +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/docs/implementation.md +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/docs/index.md +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/makefile +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/mkdocs.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/__init__.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/cli.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/__init__.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/handler.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/protocol.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/richapi/exceptions.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/tests/__init__.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/tests/test_custom_exc.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/tests/test_exc_raising_cases.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/tests/test_exception_edge_cases.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/tests/test_fastapi_auth_functions.py +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/tox.ini +0 -0
- {richapi-0.3.0 → richapi-0.3.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: richapi
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Find HTTPExceptions and turn them into documented responses!
|
|
5
5
|
Project-URL: repository, https://github.com/ManiMozaffar/richapi
|
|
6
6
|
Project-URL: Homepage, https://github.com/ManiMozaffar/richapi
|
|
@@ -89,3 +89,29 @@ Keep it simple, keep the exception type next to raise keyword; without any dynam
|
|
|
89
89
|
!!! info "Expected Behavior"
|
|
90
90
|
|
|
91
91
|
Library **MIGHT skip** adding the exception to the OpenAPI schema depending on how complicated the exception raising is.
|
|
92
|
+
|
|
93
|
+
## Always Provide Type Annotations For Class/Instance Attributes And Function Arguments
|
|
94
|
+
|
|
95
|
+
To enable accurate type resolution during static analysis, always provide type annotations for your variables, function parameters, and return types. Without type annotations, it's nearly impossible for the library to infer types correctly, which may lead to false positives or negatives. That stands for class attributes and function arguments.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
|
|
99
|
+
```Python hl_lines="6 12"
|
|
100
|
+
class Foo:
|
|
101
|
+
def foo(self) -> None:
|
|
102
|
+
raise HTTPException(status_code=400, detail="This is not allowed")
|
|
103
|
+
|
|
104
|
+
class Baz:
|
|
105
|
+
foo: Foo # this is very important to have type annotations for class/instance attributes
|
|
106
|
+
|
|
107
|
+
def __init__(self, foo: Foo):
|
|
108
|
+
self.foo = foo
|
|
109
|
+
|
|
110
|
+
def foo(self) -> None:
|
|
111
|
+
# if you don't type annotate, RichAPI doesn't have any idea about the type of self.fo
|
|
112
|
+
self.foo.foo()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
!!! info "Expected Behavior"
|
|
116
|
+
|
|
117
|
+
Library **WILL skip** adding exceptions to the OpenAPI schema if it cannot resolve types due to missing annotations.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "richapi"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "Find HTTPExceptions and turn them into documented responses!"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -19,6 +19,7 @@ build-backend = "hatchling.build"
|
|
|
19
19
|
|
|
20
20
|
[tool.pytest.ini_options]
|
|
21
21
|
asyncio_default_fixture_loop_scope = "session"
|
|
22
|
+
addopts = "--log-level=DEBUG -v"
|
|
22
23
|
|
|
23
24
|
[tool.pyright]
|
|
24
25
|
typeCheckingMode = "standard"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
|
@@ -4,9 +4,11 @@ import inspect
|
|
|
4
4
|
import logging
|
|
5
5
|
import sysconfig
|
|
6
6
|
import typing
|
|
7
|
-
from functools import lru_cache
|
|
8
7
|
from importlib.util import find_spec
|
|
9
|
-
from
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Annotated, Callable, Generic, List, NewType, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import typing_extensions
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
12
14
|
|
|
@@ -58,6 +60,22 @@ def is_in_module(module: str, target_modules: list[str]) -> bool:
|
|
|
58
60
|
return False
|
|
59
61
|
|
|
60
62
|
|
|
63
|
+
def _get_tree(obj: Callable):
|
|
64
|
+
to_search = obj
|
|
65
|
+
if is_absolutely_callable_object(obj):
|
|
66
|
+
to_search: Union[type, Callable] = (
|
|
67
|
+
obj_type if (obj_type := type(obj)) is not type else obj
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
tree = ast.parse(inspect.getsource(to_search))
|
|
72
|
+
return tree
|
|
73
|
+
except Exception as error:
|
|
74
|
+
obj_name = obj.__name__ if hasattr(obj, "__name__") else obj.__str__()
|
|
75
|
+
logger.debug(f"Failed to parse source code for {obj_name}", exc_info=error)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
61
79
|
def find_explicit_exceptions(
|
|
62
80
|
func: Callable,
|
|
63
81
|
target_modules: list[str],
|
|
@@ -80,7 +98,15 @@ def find_explicit_exceptions(
|
|
|
80
98
|
|
|
81
99
|
should_search_module_pred = should_search_module_pred_func
|
|
82
100
|
|
|
83
|
-
|
|
101
|
+
module = inspect.getmodule(func)
|
|
102
|
+
if module is None or _should_be_visited(module, should_search_module_pred) is False:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
tree = _get_tree(func)
|
|
106
|
+
if tree is None:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
return _find_explicit_expection_recursively(func, should_search_module_pred, tree)
|
|
84
110
|
|
|
85
111
|
|
|
86
112
|
def get_func_name(func: Callable) -> str:
|
|
@@ -88,32 +114,57 @@ def get_func_name(func: Callable) -> str:
|
|
|
88
114
|
|
|
89
115
|
|
|
90
116
|
def _find_all_class_exceptions(
|
|
91
|
-
cls: type,
|
|
117
|
+
cls: Union[type, Callable],
|
|
92
118
|
to_filter_predicate: Callable[[str], bool],
|
|
93
|
-
tree: Optional[ast.AST] = None,
|
|
94
119
|
):
|
|
120
|
+
cls_module = inspect.getmodule(cls)
|
|
121
|
+
if cls_module and is_stdlib(cls_module.__name__):
|
|
122
|
+
return []
|
|
123
|
+
if cls_module and to_filter_predicate(cls_module.__name__) is False:
|
|
124
|
+
return []
|
|
125
|
+
|
|
95
126
|
try:
|
|
96
|
-
|
|
127
|
+
cls_tree = ast.parse(inspect.getsource(cls))
|
|
97
128
|
except Exception as error:
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
cls_name = cls.__name__ if hasattr(cls, "__name__") else cls.__str__()
|
|
130
|
+
logger.info(
|
|
131
|
+
f"Failed to parse source code for {cls_name}.__init__", exc_info=error
|
|
100
132
|
)
|
|
101
133
|
return []
|
|
102
134
|
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
init_tree = _find_in_module(
|
|
136
|
+
cls_tree,
|
|
137
|
+
lambda n: (
|
|
138
|
+
isinstance(n, ast.AsyncFunctionDef) or isinstance(n, ast.FunctionDef)
|
|
139
|
+
)
|
|
140
|
+
and n.name == "__init__",
|
|
105
141
|
)
|
|
106
|
-
if hasattr(cls, "__call__"):
|
|
107
|
-
# Not sure if __call__ was called or init, so check for both
|
|
108
142
|
|
|
143
|
+
result: list[tuple[Optional[type[Exception]], ast.Raise]] = []
|
|
144
|
+
if init_tree is not None:
|
|
109
145
|
result.extend(
|
|
110
146
|
_find_explicit_expection_recursively(
|
|
111
|
-
cls.
|
|
112
|
-
|
|
113
|
-
|
|
147
|
+
cls.__init__, to_filter_predicate, init_tree
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if hasattr(cls, "__call__"):
|
|
152
|
+
# Not sure if __call__ was called or init, so check for both
|
|
153
|
+
call_tree = _find_in_module(
|
|
154
|
+
cls_tree,
|
|
155
|
+
lambda n: (
|
|
156
|
+
isinstance(n, ast.AsyncFunctionDef) or isinstance(n, ast.FunctionDef)
|
|
114
157
|
)
|
|
158
|
+
and n.name == "__call__",
|
|
115
159
|
)
|
|
116
160
|
|
|
161
|
+
if call_tree is not None:
|
|
162
|
+
result.extend(
|
|
163
|
+
_find_explicit_expection_recursively(
|
|
164
|
+
getattr(cls, "__call__"), to_filter_predicate, call_tree
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
117
168
|
return result
|
|
118
169
|
|
|
119
170
|
|
|
@@ -128,47 +179,20 @@ def is_absolutely_callable_object(obj: Callable) -> bool:
|
|
|
128
179
|
def _find_explicit_expection_recursively(
|
|
129
180
|
func_obj: Callable,
|
|
130
181
|
to_filter_predicate: Callable[[str], bool],
|
|
131
|
-
tree:
|
|
182
|
+
tree: ast.AST,
|
|
132
183
|
) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
|
|
133
184
|
if func_obj in ExceptionFinder.visited:
|
|
134
|
-
return []
|
|
185
|
+
return ExceptionFinder.visited[func_obj]
|
|
135
186
|
|
|
136
187
|
module = inspect.getmodule(func_obj)
|
|
137
|
-
if module
|
|
138
|
-
return []
|
|
139
|
-
|
|
140
|
-
if module and to_filter_predicate(module.__name__) is False:
|
|
188
|
+
if module is None or _should_be_visited(module, to_filter_predicate) is False:
|
|
141
189
|
return []
|
|
142
190
|
|
|
143
191
|
if is_absolutely_callable_object(func_obj):
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return []
|
|
149
|
-
|
|
150
|
-
return _find_all_class_exceptions(type(func_obj), to_filter_predicate, tree)
|
|
151
|
-
|
|
152
|
-
ExceptionFinder.visited.add(func_obj)
|
|
153
|
-
|
|
154
|
-
if tree is None:
|
|
155
|
-
try:
|
|
156
|
-
source = inspect.getsource(func_obj)
|
|
157
|
-
except (OSError, TypeError) as error:
|
|
158
|
-
logger.exception(
|
|
159
|
-
f"Failed to get source code for {get_func_name(func_obj)}.",
|
|
160
|
-
exc_info=error,
|
|
161
|
-
)
|
|
162
|
-
return []
|
|
163
|
-
|
|
164
|
-
try:
|
|
165
|
-
tree = ast.parse(source)
|
|
166
|
-
except IndentationError as error:
|
|
167
|
-
logger.exception(
|
|
168
|
-
f"Failed to get source code for {get_func_name(func_obj)}.",
|
|
169
|
-
exc_info=error,
|
|
170
|
-
)
|
|
171
|
-
return []
|
|
192
|
+
class_type: Union[type, Callable] = (
|
|
193
|
+
obj_type if (obj_type := type(func_obj)) is not type else func_obj
|
|
194
|
+
)
|
|
195
|
+
return _find_all_class_exceptions(class_type, to_filter_predicate)
|
|
172
196
|
|
|
173
197
|
finder = ExceptionFinder(func_obj, to_filter_predicate)
|
|
174
198
|
try:
|
|
@@ -178,6 +202,7 @@ def _find_explicit_expection_recursively(
|
|
|
178
202
|
f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error
|
|
179
203
|
)
|
|
180
204
|
|
|
205
|
+
ExceptionFinder.visited[func_obj] = finder.exceptions
|
|
181
206
|
return finder.exceptions
|
|
182
207
|
|
|
183
208
|
|
|
@@ -227,12 +252,52 @@ def _resolve_full_attribute_path(node: ast.AST) -> Union[str, None]:
|
|
|
227
252
|
return None
|
|
228
253
|
|
|
229
254
|
|
|
230
|
-
def
|
|
255
|
+
def _retrieve_from_annotations(func: Callable, var_name: str) -> Optional[type]:
|
|
256
|
+
result = func.__annotations__.get(var_name, None)
|
|
257
|
+
if result is None:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
origin = typing.get_origin(result)
|
|
261
|
+
if origin is Annotated:
|
|
262
|
+
return result.__origin__
|
|
263
|
+
elif origin is not None and origin != Generic:
|
|
264
|
+
return origin
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolve_type_from_assigment(
|
|
269
|
+
resolved_value: Union[str, type, None],
|
|
270
|
+
assignments: dict[NodeIdentifier, str],
|
|
271
|
+
func: Callable,
|
|
272
|
+
):
|
|
273
|
+
if resolved_value is None:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
# because assigment are stored as string
|
|
277
|
+
# ! A string type could be also called like str("Foo").lower() so we fallback to parent_attr in case it remains as string
|
|
278
|
+
|
|
279
|
+
if isinstance(resolved_value, str):
|
|
280
|
+
resolved_value = func.__globals__.get(resolved_value, resolved_value)
|
|
281
|
+
|
|
282
|
+
if isinstance(resolved_value, str):
|
|
283
|
+
resolved_value = assignments.get(NodeIdentifier(resolved_value), resolved_value)
|
|
284
|
+
|
|
285
|
+
if isinstance(resolved_value, str):
|
|
286
|
+
resolved_value = (
|
|
287
|
+
_retrieve_from_annotations(func, resolved_value) or resolved_value
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if isinstance(resolved_value, str): # last fallback
|
|
291
|
+
resolved_value = _exctact_type(resolved_value, func.__globals__)
|
|
292
|
+
|
|
293
|
+
return resolved_value
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _resolve_functions_from_call_node(
|
|
231
297
|
call_node: ast.Call,
|
|
232
|
-
func_globals: dict,
|
|
233
298
|
func: Callable,
|
|
234
299
|
assignments: dict[NodeIdentifier, str],
|
|
235
|
-
):
|
|
300
|
+
) -> list[type]:
|
|
236
301
|
"""
|
|
237
302
|
Attempt to resolve the function object from a call node.
|
|
238
303
|
Handles both simple function calls and method calls.
|
|
@@ -241,7 +306,7 @@ def _resolve_function_from_call_node(
|
|
|
241
306
|
|
|
242
307
|
if isinstance(node, ast.Name):
|
|
243
308
|
# Simple function call: func()
|
|
244
|
-
value =
|
|
309
|
+
value = func.__globals__.get(node.id, None)
|
|
245
310
|
|
|
246
311
|
if value is None:
|
|
247
312
|
value = getattr(builtins, node.id, None)
|
|
@@ -249,7 +314,9 @@ def _resolve_function_from_call_node(
|
|
|
249
314
|
if value is None:
|
|
250
315
|
logger.debug(f"Function '{node.id}' not found in {func.__name__}")
|
|
251
316
|
|
|
252
|
-
|
|
317
|
+
if value is not None:
|
|
318
|
+
return [value]
|
|
319
|
+
return []
|
|
253
320
|
|
|
254
321
|
elif isinstance(node, ast.Attribute):
|
|
255
322
|
attr_chain = _resolve_full_attribute_path(node)
|
|
@@ -257,46 +324,85 @@ def _resolve_function_from_call_node(
|
|
|
257
324
|
logger.debug(
|
|
258
325
|
f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
|
|
259
326
|
)
|
|
260
|
-
return
|
|
327
|
+
return []
|
|
261
328
|
|
|
262
329
|
try:
|
|
263
330
|
parts = attr_chain.split(".")
|
|
264
331
|
parent_part = parts[0]
|
|
265
332
|
|
|
266
|
-
parent_attr =
|
|
267
|
-
|
|
268
|
-
if parent_attr is None:
|
|
269
|
-
if parent_part in func.__annotations__:
|
|
270
|
-
return func.__annotations__[parent_part]
|
|
271
|
-
|
|
272
|
-
parent_attr = getattr(builtins, parent_part, None)
|
|
333
|
+
parent_attr = func.__globals__.get(parent_part, None)
|
|
273
334
|
|
|
274
335
|
if parent_attr is None:
|
|
275
336
|
node_id = NodeIdentifier(parent_part)
|
|
276
337
|
parent_attr = assignments.get(node_id, None)
|
|
277
338
|
|
|
339
|
+
if parent_attr is None and parent_part in func.__annotations__:
|
|
340
|
+
parent_attr = _retrieve_from_annotations(func, parent_part)
|
|
341
|
+
|
|
278
342
|
if parent_attr is None:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
343
|
+
# use case where you're calling self.foo.do_something(), where self.foo is a class attribute that is a class itself
|
|
344
|
+
if parent_part == "self" or parent_part == "cls":
|
|
345
|
+
property_name = parts[1]
|
|
346
|
+
# ['class_name', 'method_name']
|
|
347
|
+
qual_names = func.__qualname__.split(".")
|
|
348
|
+
# ['class_name']
|
|
349
|
+
func_name_index = qual_names.index(func.__name__)
|
|
350
|
+
# 'class_name'
|
|
351
|
+
cls_name = qual_names[func_name_index - 1]
|
|
352
|
+
# class_name (type)
|
|
353
|
+
cls: Union[Callable, None] = func.__globals__.get(cls_name, None)
|
|
354
|
+
|
|
355
|
+
if cls is None:
|
|
356
|
+
logger.debug(
|
|
357
|
+
f"Failed to resolve parent class {cls} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
358
|
+
)
|
|
359
|
+
return []
|
|
360
|
+
attr_class = _retrieve_from_annotations(cls, property_name)
|
|
361
|
+
if attr_class is None: # part of convention
|
|
362
|
+
return []
|
|
363
|
+
|
|
364
|
+
results: list[type] = []
|
|
365
|
+
try:
|
|
366
|
+
obj = getattr(attr_class, "__init__", None)
|
|
367
|
+
if obj is not None:
|
|
368
|
+
results.append(obj)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
for attr in parts[2:]:
|
|
373
|
+
try:
|
|
374
|
+
obj = getattr(attr_class, attr, None)
|
|
375
|
+
|
|
376
|
+
if obj is not None:
|
|
377
|
+
results.append(obj)
|
|
378
|
+
except Exception:
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
return results
|
|
283
382
|
|
|
284
383
|
logger.debug(
|
|
285
384
|
f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
286
385
|
)
|
|
287
|
-
return
|
|
386
|
+
return []
|
|
288
387
|
|
|
388
|
+
parent_attr = _resolve_type_from_assigment(parent_attr, assignments, func)
|
|
389
|
+
results: list[type] = []
|
|
289
390
|
for attr in parts[1:]:
|
|
290
391
|
try:
|
|
291
392
|
obj = getattr(parent_attr, attr, None)
|
|
393
|
+
if obj is not None:
|
|
394
|
+
results.append(obj)
|
|
395
|
+
|
|
292
396
|
except Exception:
|
|
293
397
|
break
|
|
294
398
|
|
|
295
|
-
|
|
399
|
+
return results
|
|
296
400
|
|
|
297
401
|
except Exception:
|
|
298
402
|
logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
|
|
299
|
-
return
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
return []
|
|
300
406
|
|
|
301
407
|
|
|
302
408
|
def _extract_node_name(
|
|
@@ -349,7 +455,19 @@ def _exctact_type(var_tyep: str, func_globals: dict) -> Optional[type]:
|
|
|
349
455
|
logger.exception(f"Failed to resolve variable type: {var_tyep}")
|
|
350
456
|
|
|
351
457
|
|
|
352
|
-
|
|
458
|
+
def _should_be_visited(
|
|
459
|
+
module: ModuleType,
|
|
460
|
+
should_search_module_pred: Callable[[str], bool],
|
|
461
|
+
):
|
|
462
|
+
if (
|
|
463
|
+
is_stdlib(module.__name__)
|
|
464
|
+
or should_search_module_pred(module.__name__) is False
|
|
465
|
+
):
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
|
|
353
471
|
def is_stdlib(module_name: str) -> bool:
|
|
354
472
|
"""Check if a given module is part of the Python standard library."""
|
|
355
473
|
try:
|
|
@@ -364,17 +482,26 @@ def is_stdlib(module_name: str) -> bool:
|
|
|
364
482
|
|
|
365
483
|
|
|
366
484
|
class ExceptionFinder(ast.NodeVisitor):
|
|
367
|
-
visited:
|
|
485
|
+
visited: dict[Callable, list[tuple[Optional[type[Exception]], ast.Raise]]] = {}
|
|
368
486
|
|
|
369
487
|
def __init__(
|
|
370
488
|
self, func: Callable, should_search_module_pred: Callable[[str], bool]
|
|
371
489
|
):
|
|
372
490
|
self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
|
|
373
491
|
self.assignments: dict[NodeIdentifier, str] = {}
|
|
492
|
+
# self.cached_assignments[func] = self.assignments
|
|
374
493
|
self.func = func
|
|
375
|
-
self.func_globals = func.__globals__
|
|
376
494
|
self.should_search_module_pred = should_search_module_pred
|
|
377
|
-
logger.
|
|
495
|
+
logger.debug(f"Analyzing function {func.__name__}")
|
|
496
|
+
|
|
497
|
+
@classmethod
|
|
498
|
+
def clear_cache(cls):
|
|
499
|
+
cls.visited.clear()
|
|
500
|
+
|
|
501
|
+
def _should_be_visited(
|
|
502
|
+
self, module: ModuleType
|
|
503
|
+
) -> typing_extensions.TypeGuard[ModuleType]:
|
|
504
|
+
return _should_be_visited(module, self.should_search_module_pred)
|
|
378
505
|
|
|
379
506
|
def visit_Assign(self, node):
|
|
380
507
|
"""
|
|
@@ -401,10 +528,11 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
401
528
|
|
|
402
529
|
for target in targets:
|
|
403
530
|
if isinstance(target, ast.Name):
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
531
|
+
var_name = get_node_identifier(target)
|
|
532
|
+
var_result = _extract_node_name(value, self.assignments)
|
|
533
|
+
|
|
534
|
+
if var_result is not None:
|
|
535
|
+
self.assignments[var_name] = var_result
|
|
408
536
|
|
|
409
537
|
else:
|
|
410
538
|
# no need to log here. it would be very spammy
|
|
@@ -457,7 +585,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
457
585
|
logger.debug(f"Failed to get exception name: {ast.dump(node)}")
|
|
458
586
|
return
|
|
459
587
|
|
|
460
|
-
exc_type = _exctact_type(exc_variable_name, self.
|
|
588
|
+
exc_type = _exctact_type(exc_variable_name, self.func.__globals__)
|
|
461
589
|
|
|
462
590
|
if exc_type:
|
|
463
591
|
self.exceptions.append((exc_type, node))
|
|
@@ -469,50 +597,47 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
469
597
|
"""
|
|
470
598
|
Handle function calls and recursively analyze called functions.
|
|
471
599
|
"""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
600
|
+
func_objs = _resolve_functions_from_call_node(node, self.func, self.assignments)
|
|
601
|
+
for func_obj in func_objs:
|
|
602
|
+
if inspect.isfunction(func_obj):
|
|
603
|
+
module = inspect.getmodule(func_obj)
|
|
604
|
+
if module is None or self._should_be_visited(module) is False:
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
source = inspect.getsource(module)
|
|
608
|
+
try:
|
|
609
|
+
tree = ast.parse(source)
|
|
610
|
+
except BaseException as error:
|
|
611
|
+
logger.exception(
|
|
612
|
+
f"Failed to parse source code for {func_obj.__name__}.",
|
|
613
|
+
exc_info=error,
|
|
614
|
+
)
|
|
615
|
+
self.generic_visit(node)
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
founded_func_ast = _find_in_module(
|
|
619
|
+
tree,
|
|
620
|
+
lambda n: (
|
|
621
|
+
isinstance(n, ast.AsyncFunctionDef)
|
|
622
|
+
or isinstance(n, ast.FunctionDef)
|
|
623
|
+
)
|
|
624
|
+
and n.name == func_obj.__name__,
|
|
495
625
|
)
|
|
496
|
-
|
|
497
|
-
|
|
626
|
+
if founded_func_ast:
|
|
627
|
+
excs = _find_explicit_expection_recursively(
|
|
628
|
+
func_obj, self.should_search_module_pred, founded_func_ast
|
|
629
|
+
)
|
|
630
|
+
self.exceptions.extend(excs)
|
|
631
|
+
else:
|
|
632
|
+
logger.debug(
|
|
633
|
+
f"Failed to find function {func_obj.__name__} in module {module.__name__}"
|
|
634
|
+
)
|
|
498
635
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
isinstance(n, ast.AsyncFunctionDef)
|
|
503
|
-
or isinstance(n, ast.FunctionDef)
|
|
504
|
-
)
|
|
505
|
-
and n.name == func_obj.__name__,
|
|
506
|
-
)
|
|
507
|
-
if founded_func_ast:
|
|
508
|
-
excs = _find_explicit_expection_recursively(
|
|
509
|
-
func_obj, self.should_search_module_pred, founded_func_ast
|
|
510
|
-
)
|
|
511
|
-
self.exceptions.extend(excs)
|
|
512
|
-
else:
|
|
513
|
-
logger.debug(
|
|
514
|
-
f"Failed to find function {func_obj.__name__} in module {module.__name__}"
|
|
636
|
+
elif inspect.isclass(func_obj):
|
|
637
|
+
_excs = _find_all_class_exceptions(
|
|
638
|
+
func_obj, self.should_search_module_pred
|
|
515
639
|
)
|
|
640
|
+
self.exceptions.extend(_excs)
|
|
516
641
|
|
|
517
642
|
self.generic_visit(node)
|
|
518
643
|
return
|
|
@@ -527,11 +652,9 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
527
652
|
return
|
|
528
653
|
|
|
529
654
|
try:
|
|
530
|
-
caller_type = _exctact_type(caller_node_name, self.
|
|
655
|
+
caller_type = _exctact_type(caller_node_name, self.func.__globals__)
|
|
531
656
|
except Exception:
|
|
532
|
-
logger.
|
|
533
|
-
f"Failed to resolve attribute chain: {caller_node_name}"
|
|
534
|
-
)
|
|
657
|
+
logger.debug(f"Failed to resolve attribute chain: {caller_node_name}")
|
|
535
658
|
self.generic_visit(node)
|
|
536
659
|
return
|
|
537
660
|
|
|
@@ -545,7 +668,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
545
668
|
return
|
|
546
669
|
|
|
547
670
|
module = inspect.getmodule(caller_type)
|
|
548
|
-
if
|
|
671
|
+
if module is None or self._should_be_visited(module) is False:
|
|
549
672
|
self.generic_visit(node)
|
|
550
673
|
return
|
|
551
674
|
|
|
@@ -51,7 +51,8 @@ def enrich_openapi(
|
|
|
51
51
|
if target_module is None or target_module == "__main__":
|
|
52
52
|
raise BaseRichAPIException(
|
|
53
53
|
"Could not determine the module where the FastAPI instance was created.\n"
|
|
54
|
-
"Please provide the module name as a string or list of strings
|
|
54
|
+
"Please provide the module name as a string or list of strings.\n"
|
|
55
|
+
"Example: enrich_openapi(app, target_module='src')\n"
|
|
55
56
|
)
|
|
56
57
|
|
|
57
58
|
target_module = target_module.split(".")[0] # get the top-level module
|
|
@@ -91,7 +92,7 @@ def compile_openapi_from_fastapi(
|
|
|
91
92
|
|
|
92
93
|
_fill_openapi_with_excpetions(openapi_schema, route, exceptions)
|
|
93
94
|
|
|
94
|
-
ExceptionFinder.
|
|
95
|
+
ExceptionFinder.clear_cache()
|
|
95
96
|
|
|
96
97
|
return openapi_schema
|
|
97
98
|
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
6
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
7
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
8
|
+
|
|
9
|
+
app = fastapi.FastAPI()
|
|
10
|
+
app.openapi = enrich_openapi(app)
|
|
11
|
+
add_exc_handler(app)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Exception1(RichHTTPException):
|
|
16
|
+
status_code = 409
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Exception2(RichHTTPException):
|
|
21
|
+
status_code = 408
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Exception3(RichHTTPException):
|
|
26
|
+
status_code = 407
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def foo1():
|
|
30
|
+
raise Exception1()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def foo2():
|
|
34
|
+
raise Exception2()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def foo3():
|
|
38
|
+
raise Exception3()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaymentService:
|
|
42
|
+
def foo(self):
|
|
43
|
+
foo1()
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def create(self):
|
|
47
|
+
foo2()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def update(self):
|
|
51
|
+
foo3()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.post("/payment")
|
|
56
|
+
async def make_payment():
|
|
57
|
+
PaymentService().foo().create().update()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_class_is_detected():
|
|
61
|
+
openapi_json = compile_openapi_from_fastapi(
|
|
62
|
+
app, target_module="tests.test_builder_pattern"
|
|
63
|
+
)
|
|
64
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
65
|
+
print(home_responses)
|
|
66
|
+
assert "408" in home_responses
|
|
67
|
+
assert "407" in home_responses
|
|
68
|
+
assert "409" in home_responses
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from richapi.exc_parser.handler import add_exc_handler
|
|
6
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
7
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
8
|
+
|
|
9
|
+
app = fastapi.FastAPI()
|
|
10
|
+
app.openapi = enrich_openapi(app)
|
|
11
|
+
add_exc_handler(app)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Exception1(RichHTTPException):
|
|
16
|
+
status_code = 409
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Exception2(RichHTTPException):
|
|
21
|
+
status_code = 408
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Exception3(RichHTTPException):
|
|
26
|
+
status_code = 407
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def foo1():
|
|
30
|
+
raise Exception1()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def foo2():
|
|
34
|
+
raise Exception2()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def foo3():
|
|
38
|
+
raise Exception3()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaymentService:
|
|
42
|
+
def __init__(self):
|
|
43
|
+
foo1()
|
|
44
|
+
|
|
45
|
+
def create(self):
|
|
46
|
+
foo2()
|
|
47
|
+
|
|
48
|
+
def update(self):
|
|
49
|
+
foo3()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_payment_dep():
|
|
53
|
+
obj = PaymentService()
|
|
54
|
+
return obj
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.post("/payment")
|
|
58
|
+
async def make_payment(
|
|
59
|
+
obj2: PaymentService = fastapi.Depends(make_payment_dep),
|
|
60
|
+
):
|
|
61
|
+
obj = PaymentService()
|
|
62
|
+
obj.create()
|
|
63
|
+
obj2.update()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_class_is_detected():
|
|
67
|
+
openapi_json = compile_openapi_from_fastapi(app, target_module="tests.test_class")
|
|
68
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
69
|
+
print(home_responses)
|
|
70
|
+
assert "408" in home_responses
|
|
71
|
+
assert "407" in home_responses
|
|
72
|
+
assert "409" in home_responses
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from richapi.exc_parser.compiler import _retrieve_from_annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def foo(value: Annotated[int, Annotated[str, str]]): ...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_get_from_annotated():
|
|
10
|
+
result = _retrieve_from_annotations(foo, "value")
|
|
11
|
+
print(type(result))
|
|
12
|
+
assert result is int
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
import fastapi.security
|
|
3
|
+
|
|
4
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
5
|
+
from tests.app import foo1 as FooService
|
|
6
|
+
|
|
7
|
+
app = fastapi.FastAPI()
|
|
8
|
+
app.openapi = enrich_openapi(app)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.post("/payment")
|
|
12
|
+
async def make_payment():
|
|
13
|
+
FooService.raise_exc()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_class_is_detected():
|
|
17
|
+
openapi_json = compile_openapi_from_fastapi(app, target_module="tests")
|
|
18
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
19
|
+
print(home_responses)
|
|
20
|
+
assert "500" in home_responses
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import fastapi
|
|
4
|
+
import fastapi.security
|
|
5
|
+
from pydantic.dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
|
|
8
|
+
from richapi.exc_parser.protocol import RichHTTPException
|
|
9
|
+
|
|
10
|
+
logging.getLogger().addHandler(logging.StreamHandler())
|
|
11
|
+
|
|
12
|
+
app = fastapi.FastAPI()
|
|
13
|
+
app.openapi = enrich_openapi(app, target_module="tests.test_instance_attr_call")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Exception1(RichHTTPException):
|
|
18
|
+
status_code = 409
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Exception2(RichHTTPException):
|
|
23
|
+
status_code = 408
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Exception3(RichHTTPException):
|
|
28
|
+
status_code = 407
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def foo1():
|
|
32
|
+
raise Exception1()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def foo2():
|
|
36
|
+
raise Exception2()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def foo3():
|
|
40
|
+
raise Exception3()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PaymentService:
|
|
44
|
+
def __init__(self):
|
|
45
|
+
foo1()
|
|
46
|
+
|
|
47
|
+
def foo2(self):
|
|
48
|
+
foo2()
|
|
49
|
+
|
|
50
|
+
def foo3(self):
|
|
51
|
+
foo3()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PaymentOrchestrator:
|
|
55
|
+
service: PaymentService
|
|
56
|
+
|
|
57
|
+
def __init__(self, payment_service: PaymentService):
|
|
58
|
+
self.service = payment_service
|
|
59
|
+
|
|
60
|
+
def create_outer(self):
|
|
61
|
+
self.service.foo2()
|
|
62
|
+
self.update_outer()
|
|
63
|
+
|
|
64
|
+
def update_outer(self):
|
|
65
|
+
self.service.foo3()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.post("/payment")
|
|
69
|
+
async def make_payment():
|
|
70
|
+
obj = PaymentOrchestrator(payment_service=PaymentService())
|
|
71
|
+
obj.create_outer()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_class_is_detected():
|
|
75
|
+
openapi_json = compile_openapi_from_fastapi(
|
|
76
|
+
app, target_module="tests.test_instance_attr_call"
|
|
77
|
+
)
|
|
78
|
+
home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
|
|
79
|
+
print(home_responses)
|
|
80
|
+
assert "408" in home_responses
|
|
81
|
+
assert "407" not in home_responses
|
|
82
|
+
assert "409" in home_responses
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|