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.
Files changed (43) hide show
  1. {richapi-0.3.0 → richapi-0.3.2}/PKG-INFO +1 -1
  2. {richapi-0.3.0 → richapi-0.3.2}/docs/conventions.md +26 -0
  3. {richapi-0.3.0 → richapi-0.3.2}/pyproject.toml +2 -1
  4. richapi-0.3.2/richapi/__version__.py +1 -0
  5. {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/compiler.py +247 -124
  6. {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/openapi.py +3 -2
  7. richapi-0.3.2/tests/app/__init__.py +0 -0
  8. richapi-0.3.2/tests/app/foo1.py +10 -0
  9. richapi-0.3.2/tests/test_builder_pattern.py +68 -0
  10. richapi-0.3.2/tests/test_class.py +72 -0
  11. richapi-0.3.2/tests/test_compiler.py +12 -0
  12. richapi-0.3.2/tests/test_duplicated_function.py +0 -0
  13. richapi-0.3.2/tests/test_import_from_other_module.py +20 -0
  14. richapi-0.3.2/tests/test_instance_attr_call.py +82 -0
  15. richapi-0.3.0/richapi/__version__.py +0 -1
  16. {richapi-0.3.0 → richapi-0.3.2}/.envrc +0 -0
  17. {richapi-0.3.0 → richapi-0.3.2}/.github/workflows/deploy-docs-on-demand.yml +0 -0
  18. {richapi-0.3.0 → richapi-0.3.2}/.github/workflows/main.yml +0 -0
  19. {richapi-0.3.0 → richapi-0.3.2}/.github/workflows/on-release-main.yml +0 -0
  20. {richapi-0.3.0 → richapi-0.3.2}/.gitignore +0 -0
  21. {richapi-0.3.0 → richapi-0.3.2}/README.md +0 -0
  22. {richapi-0.3.0 → richapi-0.3.2}/deploy_docs.py +0 -0
  23. {richapi-0.3.0 → richapi-0.3.2}/docs/advance.md +0 -0
  24. {richapi-0.3.0 → richapi-0.3.2}/docs/images/custom-exception.png +0 -0
  25. {richapi-0.3.0 → richapi-0.3.2}/docs/images/with-richapi.png +0 -0
  26. {richapi-0.3.0 → richapi-0.3.2}/docs/images/without-richapi.png +0 -0
  27. {richapi-0.3.0 → richapi-0.3.2}/docs/implementation.md +0 -0
  28. {richapi-0.3.0 → richapi-0.3.2}/docs/index.md +0 -0
  29. {richapi-0.3.0 → richapi-0.3.2}/makefile +0 -0
  30. {richapi-0.3.0 → richapi-0.3.2}/mkdocs.yml +0 -0
  31. {richapi-0.3.0 → richapi-0.3.2}/richapi/__init__.py +0 -0
  32. {richapi-0.3.0 → richapi-0.3.2}/richapi/cli.py +0 -0
  33. {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/__init__.py +0 -0
  34. {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/handler.py +0 -0
  35. {richapi-0.3.0 → richapi-0.3.2}/richapi/exc_parser/protocol.py +0 -0
  36. {richapi-0.3.0 → richapi-0.3.2}/richapi/exceptions.py +0 -0
  37. {richapi-0.3.0 → richapi-0.3.2}/tests/__init__.py +0 -0
  38. {richapi-0.3.0 → richapi-0.3.2}/tests/test_custom_exc.py +0 -0
  39. {richapi-0.3.0 → richapi-0.3.2}/tests/test_exc_raising_cases.py +0 -0
  40. {richapi-0.3.0 → richapi-0.3.2}/tests/test_exception_edge_cases.py +0 -0
  41. {richapi-0.3.0 → richapi-0.3.2}/tests/test_fastapi_auth_functions.py +0 -0
  42. {richapi-0.3.0 → richapi-0.3.2}/tox.ini +0 -0
  43. {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.0
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.0"
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 typing import Callable, List, NewType, Optional, Tuple, Union
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
- return _find_explicit_expection_recursively(func, should_search_module_pred)
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
- tree = tree or ast.parse(inspect.getsource(cls))
127
+ cls_tree = ast.parse(inspect.getsource(cls))
97
128
  except Exception as error:
98
- logger.exception(
99
- f"Failed to parse source code for {cls.__name__}.", exc_info=error
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
- result = _find_explicit_expection_recursively(
104
- cls.__init__, to_filter_predicate, tree
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.__call__, # type: ignore
112
- to_filter_predicate,
113
- tree,
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: Optional[ast.AST] = None,
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 and is_stdlib(module.__name__):
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
- cls_module = inspect.getmodule(type(func_obj))
145
- if cls_module and is_stdlib(cls_module.__name__):
146
- return []
147
- if cls_module and to_filter_predicate(cls_module.__name__) is False:
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 _resolve_function_from_call_node(
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 = func_globals.get(node.id, None)
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
- return value
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 None
327
+ return []
261
328
 
262
329
  try:
263
330
  parts = attr_chain.split(".")
264
331
  parent_part = parts[0]
265
332
 
266
- parent_attr = func_globals.get(parent_part, None)
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
- if (
280
- parent_part == "self"
281
- ): # don't need to log -> we already know this is class
282
- return None
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 None
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
- return obj
399
+ return results
296
400
 
297
401
  except Exception:
298
402
  logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
299
- return None
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
- @lru_cache
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: set[Callable] = set()
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.info(f"Analyzing function {func.__name__}")
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
- var_identifier = get_node_identifier(target)
405
- var_name = _extract_node_name(value, self.assignments)
406
- if var_name is not None:
407
- self.assignments[var_identifier] = var_name
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.func_globals)
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
- func_obj = _resolve_function_from_call_node(
473
- node, self.func_globals, self.func, self.assignments
474
- )
475
- if func_obj and inspect.isfunction(func_obj):
476
- module = inspect.getmodule(func_obj)
477
- if not module:
478
- self.generic_visit(node)
479
- return
480
-
481
- if (
482
- is_stdlib(module.__name__)
483
- or self.should_search_module_pred(module.__name__) is False
484
- ):
485
- self.generic_visit(node)
486
- return
487
-
488
- source = inspect.getsource(module)
489
- try:
490
- tree = ast.parse(source)
491
- except BaseException as error:
492
- logger.exception(
493
- f"Failed to parse source code for {func_obj.__name__}.",
494
- exc_info=error,
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
- self.generic_visit(node)
497
- return
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
- founded_func_ast = _find_in_module(
500
- tree,
501
- lambda n: (
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.func_globals)
655
+ caller_type = _exctact_type(caller_node_name, self.func.__globals__)
531
656
  except Exception:
532
- logger.critical(
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 not module:
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.visited.clear()
95
+ ExceptionFinder.clear_cache()
95
96
 
96
97
  return openapi_schema
97
98
 
File without changes
@@ -0,0 +1,10 @@
1
+ from richapi.exc_parser.protocol import BaseHTTPException
2
+
3
+
4
+ class HTTP500Error(BaseHTTPException):
5
+ status_code = 500
6
+ detail = "Internal Server Error"
7
+
8
+
9
+ def raise_exc():
10
+ raise HTTP500Error
@@ -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