richapi 0.3.0__tar.gz → 0.3.1__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.1}/PKG-INFO +1 -1
- {richapi-0.3.0 → richapi-0.3.1}/docs/conventions.md +26 -0
- {richapi-0.3.0 → richapi-0.3.1}/pyproject.toml +1 -1
- richapi-0.3.1/richapi/__version__.py +1 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/compiler.py +192 -85
- {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/openapi.py +1 -1
- richapi-0.3.1/tests/app/__init__.py +0 -0
- richapi-0.3.1/tests/app/foo1.py +10 -0
- richapi-0.3.1/tests/test_builder_pattern.py +68 -0
- richapi-0.3.1/tests/test_class.py +72 -0
- richapi-0.3.1/tests/test_compiler.py +12 -0
- richapi-0.3.1/tests/test_duplicated_function.py +0 -0
- richapi-0.3.1/tests/test_import_from_other_module.py +20 -0
- richapi-0.3.1/tests/test_instance_attr_call.py +82 -0
- richapi-0.3.0/richapi/__version__.py +0 -1
- {richapi-0.3.0 → richapi-0.3.1}/.envrc +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/.github/workflows/deploy-docs-on-demand.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/.github/workflows/main.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/.github/workflows/on-release-main.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/.gitignore +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/README.md +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/deploy_docs.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/docs/advance.md +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/docs/images/custom-exception.png +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/docs/images/with-richapi.png +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/docs/images/without-richapi.png +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/docs/implementation.md +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/docs/index.md +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/makefile +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/mkdocs.yml +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/__init__.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/cli.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/__init__.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/handler.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/protocol.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/richapi/exceptions.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/tests/__init__.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/tests/test_custom_exc.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/tests/test_exc_raising_cases.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/tests/test_exception_edge_cases.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/tests/test_fastapi_auth_functions.py +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/tox.ini +0 -0
- {richapi-0.3.0 → richapi-0.3.1}/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.1
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -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
|
|
|
@@ -88,16 +90,15 @@ def get_func_name(func: Callable) -> str:
|
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
def _find_all_class_exceptions(
|
|
91
|
-
cls: type,
|
|
93
|
+
cls: Union[type, Callable],
|
|
92
94
|
to_filter_predicate: Callable[[str], bool],
|
|
93
95
|
tree: Optional[ast.AST] = None,
|
|
94
96
|
):
|
|
95
97
|
try:
|
|
96
98
|
tree = tree or ast.parse(inspect.getsource(cls))
|
|
97
99
|
except Exception as error:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
100
|
+
cls_name = cls.__name__ if hasattr(cls, "__name__") else cls.__str__()
|
|
101
|
+
logger.exception(f"Failed to parse source code for {cls_name}.", exc_info=error)
|
|
101
102
|
return []
|
|
102
103
|
|
|
103
104
|
result = _find_explicit_expection_recursively(
|
|
@@ -131,25 +132,24 @@ def _find_explicit_expection_recursively(
|
|
|
131
132
|
tree: Optional[ast.AST] = None,
|
|
132
133
|
) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
|
|
133
134
|
if func_obj in ExceptionFinder.visited:
|
|
134
|
-
return []
|
|
135
|
+
return ExceptionFinder.visited[func_obj]
|
|
135
136
|
|
|
136
137
|
module = inspect.getmodule(func_obj)
|
|
137
|
-
if module
|
|
138
|
-
return []
|
|
139
|
-
|
|
140
|
-
if module and to_filter_predicate(module.__name__) is False:
|
|
138
|
+
if module is None or _should_be_visited(module, to_filter_predicate) is False:
|
|
141
139
|
return []
|
|
142
140
|
|
|
143
141
|
if is_absolutely_callable_object(func_obj):
|
|
144
|
-
|
|
142
|
+
class_type: Union[type, Callable] = (
|
|
143
|
+
obj_type if (obj_type := type(func_obj)) is not type else func_obj
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
cls_module = inspect.getmodule(class_type)
|
|
145
147
|
if cls_module and is_stdlib(cls_module.__name__):
|
|
146
148
|
return []
|
|
147
149
|
if cls_module and to_filter_predicate(cls_module.__name__) is False:
|
|
148
150
|
return []
|
|
149
151
|
|
|
150
|
-
return _find_all_class_exceptions(
|
|
151
|
-
|
|
152
|
-
ExceptionFinder.visited.add(func_obj)
|
|
152
|
+
return _find_all_class_exceptions(class_type, to_filter_predicate, tree)
|
|
153
153
|
|
|
154
154
|
if tree is None:
|
|
155
155
|
try:
|
|
@@ -178,6 +178,7 @@ def _find_explicit_expection_recursively(
|
|
|
178
178
|
f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error
|
|
179
179
|
)
|
|
180
180
|
|
|
181
|
+
ExceptionFinder.visited[func_obj] = finder.exceptions
|
|
181
182
|
return finder.exceptions
|
|
182
183
|
|
|
183
184
|
|
|
@@ -227,12 +228,52 @@ def _resolve_full_attribute_path(node: ast.AST) -> Union[str, None]:
|
|
|
227
228
|
return None
|
|
228
229
|
|
|
229
230
|
|
|
230
|
-
def
|
|
231
|
+
def _retrieve_from_annotations(func: Callable, var_name: str) -> Optional[type]:
|
|
232
|
+
result = func.__annotations__.get(var_name, None)
|
|
233
|
+
if result is None:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
origin = typing.get_origin(result)
|
|
237
|
+
if origin is Annotated:
|
|
238
|
+
return result.__origin__
|
|
239
|
+
elif origin is not None and origin != Generic:
|
|
240
|
+
return origin
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve_type_from_assigment(
|
|
245
|
+
resolved_value: Union[str, type, None],
|
|
246
|
+
assignments: dict[NodeIdentifier, str],
|
|
247
|
+
func: Callable,
|
|
248
|
+
):
|
|
249
|
+
if resolved_value is None:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
# because assigment are stored as string
|
|
253
|
+
# ! A string type could be also called like str("Foo").lower() so we fallback to parent_attr in case it remains as string
|
|
254
|
+
|
|
255
|
+
if isinstance(resolved_value, str):
|
|
256
|
+
resolved_value = func.__globals__.get(resolved_value, resolved_value)
|
|
257
|
+
|
|
258
|
+
if isinstance(resolved_value, str):
|
|
259
|
+
resolved_value = assignments.get(NodeIdentifier(resolved_value), resolved_value)
|
|
260
|
+
|
|
261
|
+
if isinstance(resolved_value, str):
|
|
262
|
+
resolved_value = (
|
|
263
|
+
_retrieve_from_annotations(func, resolved_value) or resolved_value
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if isinstance(resolved_value, str): # last fallback
|
|
267
|
+
resolved_value = _exctact_type(resolved_value, func.__globals__)
|
|
268
|
+
|
|
269
|
+
return resolved_value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _resolve_functions_from_call_node(
|
|
231
273
|
call_node: ast.Call,
|
|
232
|
-
func_globals: dict,
|
|
233
274
|
func: Callable,
|
|
234
275
|
assignments: dict[NodeIdentifier, str],
|
|
235
|
-
):
|
|
276
|
+
) -> list[type]:
|
|
236
277
|
"""
|
|
237
278
|
Attempt to resolve the function object from a call node.
|
|
238
279
|
Handles both simple function calls and method calls.
|
|
@@ -241,7 +282,7 @@ def _resolve_function_from_call_node(
|
|
|
241
282
|
|
|
242
283
|
if isinstance(node, ast.Name):
|
|
243
284
|
# Simple function call: func()
|
|
244
|
-
value =
|
|
285
|
+
value = func.__globals__.get(node.id, None)
|
|
245
286
|
|
|
246
287
|
if value is None:
|
|
247
288
|
value = getattr(builtins, node.id, None)
|
|
@@ -249,7 +290,9 @@ def _resolve_function_from_call_node(
|
|
|
249
290
|
if value is None:
|
|
250
291
|
logger.debug(f"Function '{node.id}' not found in {func.__name__}")
|
|
251
292
|
|
|
252
|
-
|
|
293
|
+
if value is not None:
|
|
294
|
+
return [value]
|
|
295
|
+
return []
|
|
253
296
|
|
|
254
297
|
elif isinstance(node, ast.Attribute):
|
|
255
298
|
attr_chain = _resolve_full_attribute_path(node)
|
|
@@ -257,46 +300,89 @@ def _resolve_function_from_call_node(
|
|
|
257
300
|
logger.debug(
|
|
258
301
|
f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
|
|
259
302
|
)
|
|
260
|
-
return
|
|
303
|
+
return []
|
|
261
304
|
|
|
262
305
|
try:
|
|
263
306
|
parts = attr_chain.split(".")
|
|
264
307
|
parent_part = parts[0]
|
|
265
308
|
|
|
266
|
-
parent_attr =
|
|
309
|
+
parent_attr = func.__globals__.get(parent_part, None)
|
|
310
|
+
|
|
311
|
+
if parent_attr is None:
|
|
312
|
+
node_id = NodeIdentifier(parent_part)
|
|
313
|
+
parent_attr = assignments.get(node_id, None)
|
|
267
314
|
|
|
268
315
|
if parent_attr is None:
|
|
269
316
|
if parent_part in func.__annotations__:
|
|
270
|
-
|
|
317
|
+
result = _retrieve_from_annotations(func, parent_part)
|
|
318
|
+
return [] if result is None else [result]
|
|
271
319
|
|
|
272
320
|
parent_attr = getattr(builtins, parent_part, None)
|
|
273
321
|
|
|
274
322
|
if parent_attr is None:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
323
|
+
# use case where you're calling self.foo.do_something(), where self.foo is a class attribute that is a class itself
|
|
324
|
+
if parent_part == "self" or parent_part == "cls":
|
|
325
|
+
property_name = parts[1]
|
|
326
|
+
# ['class_name', 'method_name']
|
|
327
|
+
qual_names = func.__qualname__.split(".")
|
|
328
|
+
# ['class_name']
|
|
329
|
+
func_name_index = qual_names.index(func.__name__)
|
|
330
|
+
# 'class_name'
|
|
331
|
+
cls_name = qual_names[func_name_index - 1]
|
|
332
|
+
# class_name (type)
|
|
333
|
+
cls: Union[Callable, None] = func.__globals__.get(cls_name, None)
|
|
334
|
+
|
|
335
|
+
if cls is None:
|
|
336
|
+
logger.debug(
|
|
337
|
+
f"Failed to resolve parent class {cls} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
338
|
+
)
|
|
339
|
+
return []
|
|
340
|
+
attr_class = _retrieve_from_annotations(cls, property_name)
|
|
341
|
+
if attr_class is None: # part of convention
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
results: list[type] = []
|
|
345
|
+
try:
|
|
346
|
+
obj = getattr(attr_class, "__init__", None)
|
|
347
|
+
if obj is not None:
|
|
348
|
+
results.append(obj)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
for attr in parts[2:]:
|
|
353
|
+
try:
|
|
354
|
+
obj = getattr(attr_class, attr, None)
|
|
355
|
+
|
|
356
|
+
if obj is not None:
|
|
357
|
+
results.append(obj)
|
|
358
|
+
except Exception:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
return results
|
|
283
362
|
|
|
284
363
|
logger.debug(
|
|
285
364
|
f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
286
365
|
)
|
|
287
|
-
return
|
|
366
|
+
return []
|
|
288
367
|
|
|
368
|
+
parent_attr = _resolve_type_from_assigment(parent_attr, assignments, func)
|
|
369
|
+
results: list[type] = []
|
|
289
370
|
for attr in parts[1:]:
|
|
290
371
|
try:
|
|
291
372
|
obj = getattr(parent_attr, attr, None)
|
|
373
|
+
if obj is not None:
|
|
374
|
+
results.append(obj)
|
|
375
|
+
|
|
292
376
|
except Exception:
|
|
293
377
|
break
|
|
294
378
|
|
|
295
|
-
|
|
379
|
+
return results
|
|
296
380
|
|
|
297
381
|
except Exception:
|
|
298
382
|
logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
|
|
299
|
-
return
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
return []
|
|
300
386
|
|
|
301
387
|
|
|
302
388
|
def _extract_node_name(
|
|
@@ -349,7 +435,19 @@ def _exctact_type(var_tyep: str, func_globals: dict) -> Optional[type]:
|
|
|
349
435
|
logger.exception(f"Failed to resolve variable type: {var_tyep}")
|
|
350
436
|
|
|
351
437
|
|
|
352
|
-
|
|
438
|
+
def _should_be_visited(
|
|
439
|
+
module: ModuleType,
|
|
440
|
+
should_search_module_pred: Callable[[str], bool],
|
|
441
|
+
):
|
|
442
|
+
if (
|
|
443
|
+
is_stdlib(module.__name__)
|
|
444
|
+
or should_search_module_pred(module.__name__) is False
|
|
445
|
+
):
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
353
451
|
def is_stdlib(module_name: str) -> bool:
|
|
354
452
|
"""Check if a given module is part of the Python standard library."""
|
|
355
453
|
try:
|
|
@@ -364,18 +462,29 @@ def is_stdlib(module_name: str) -> bool:
|
|
|
364
462
|
|
|
365
463
|
|
|
366
464
|
class ExceptionFinder(ast.NodeVisitor):
|
|
367
|
-
visited:
|
|
465
|
+
visited: dict[Callable, list[tuple[Optional[type[Exception]], ast.Raise]]] = {}
|
|
466
|
+
# cached_assignments: dict[Callable, dict[NodeIdentifier, str]] = {}
|
|
368
467
|
|
|
369
468
|
def __init__(
|
|
370
469
|
self, func: Callable, should_search_module_pred: Callable[[str], bool]
|
|
371
470
|
):
|
|
372
471
|
self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
|
|
373
472
|
self.assignments: dict[NodeIdentifier, str] = {}
|
|
473
|
+
# self.cached_assignments[func] = self.assignments
|
|
374
474
|
self.func = func
|
|
375
|
-
self.func_globals = func.__globals__
|
|
376
475
|
self.should_search_module_pred = should_search_module_pred
|
|
377
476
|
logger.info(f"Analyzing function {func.__name__}")
|
|
378
477
|
|
|
478
|
+
@classmethod
|
|
479
|
+
def clear_cache(cls):
|
|
480
|
+
cls.visited.clear()
|
|
481
|
+
# cls.cached_assignments.clear()
|
|
482
|
+
|
|
483
|
+
def _should_be_visited(
|
|
484
|
+
self, module: ModuleType
|
|
485
|
+
) -> typing_extensions.TypeGuard[ModuleType]:
|
|
486
|
+
return _should_be_visited(module, self.should_search_module_pred)
|
|
487
|
+
|
|
379
488
|
def visit_Assign(self, node):
|
|
380
489
|
"""
|
|
381
490
|
Track assignments to variables that are exceptions or exception instances.
|
|
@@ -401,10 +510,11 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
401
510
|
|
|
402
511
|
for target in targets:
|
|
403
512
|
if isinstance(target, ast.Name):
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
513
|
+
var_name = get_node_identifier(target)
|
|
514
|
+
var_result = _extract_node_name(value, self.assignments)
|
|
515
|
+
|
|
516
|
+
if var_result is not None:
|
|
517
|
+
self.assignments[var_name] = var_result
|
|
408
518
|
|
|
409
519
|
else:
|
|
410
520
|
# no need to log here. it would be very spammy
|
|
@@ -457,7 +567,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
457
567
|
logger.debug(f"Failed to get exception name: {ast.dump(node)}")
|
|
458
568
|
return
|
|
459
569
|
|
|
460
|
-
exc_type = _exctact_type(exc_variable_name, self.
|
|
570
|
+
exc_type = _exctact_type(exc_variable_name, self.func.__globals__)
|
|
461
571
|
|
|
462
572
|
if exc_type:
|
|
463
573
|
self.exceptions.append((exc_type, node))
|
|
@@ -469,50 +579,47 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
469
579
|
"""
|
|
470
580
|
Handle function calls and recursively analyze called functions.
|
|
471
581
|
"""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
582
|
+
func_objs = _resolve_functions_from_call_node(node, self.func, self.assignments)
|
|
583
|
+
for func_obj in func_objs:
|
|
584
|
+
if inspect.isfunction(func_obj):
|
|
585
|
+
module = inspect.getmodule(func_obj)
|
|
586
|
+
if module is None or self._should_be_visited(module) is False:
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
source = inspect.getsource(module)
|
|
590
|
+
try:
|
|
591
|
+
tree = ast.parse(source)
|
|
592
|
+
except BaseException as error:
|
|
593
|
+
logger.exception(
|
|
594
|
+
f"Failed to parse source code for {func_obj.__name__}.",
|
|
595
|
+
exc_info=error,
|
|
596
|
+
)
|
|
597
|
+
self.generic_visit(node)
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
founded_func_ast = _find_in_module(
|
|
601
|
+
tree,
|
|
602
|
+
lambda n: (
|
|
603
|
+
isinstance(n, ast.AsyncFunctionDef)
|
|
604
|
+
or isinstance(n, ast.FunctionDef)
|
|
605
|
+
)
|
|
606
|
+
and n.name == func_obj.__name__,
|
|
495
607
|
)
|
|
496
|
-
|
|
497
|
-
|
|
608
|
+
if founded_func_ast:
|
|
609
|
+
excs = _find_explicit_expection_recursively(
|
|
610
|
+
func_obj, self.should_search_module_pred, founded_func_ast
|
|
611
|
+
)
|
|
612
|
+
self.exceptions.extend(excs)
|
|
613
|
+
else:
|
|
614
|
+
logger.debug(
|
|
615
|
+
f"Failed to find function {func_obj.__name__} in module {module.__name__}"
|
|
616
|
+
)
|
|
498
617
|
|
|
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__}"
|
|
618
|
+
elif inspect.isclass(func_obj):
|
|
619
|
+
_excs = _find_explicit_expection_recursively(
|
|
620
|
+
func_obj, self.should_search_module_pred
|
|
515
621
|
)
|
|
622
|
+
self.exceptions.extend(_excs)
|
|
516
623
|
|
|
517
624
|
self.generic_visit(node)
|
|
518
625
|
return
|
|
@@ -527,7 +634,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
527
634
|
return
|
|
528
635
|
|
|
529
636
|
try:
|
|
530
|
-
caller_type = _exctact_type(caller_node_name, self.
|
|
637
|
+
caller_type = _exctact_type(caller_node_name, self.func.__globals__)
|
|
531
638
|
except Exception:
|
|
532
639
|
logger.critical(
|
|
533
640
|
f"Failed to resolve attribute chain: {caller_node_name}"
|
|
@@ -545,7 +652,7 @@ class ExceptionFinder(ast.NodeVisitor):
|
|
|
545
652
|
return
|
|
546
653
|
|
|
547
654
|
module = inspect.getmodule(caller_type)
|
|
548
|
-
if
|
|
655
|
+
if module is None or self._should_be_visited(module) is False:
|
|
549
656
|
self.generic_visit(node)
|
|
550
657
|
return
|
|
551
658
|
|
|
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 create_inner(self):
|
|
48
|
+
foo2()
|
|
49
|
+
|
|
50
|
+
def update_inner(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.create_inner()
|
|
62
|
+
self.update_outer()
|
|
63
|
+
|
|
64
|
+
def update_outer(self):
|
|
65
|
+
self.service.update_inner()
|
|
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" 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
|