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.
Files changed (43) hide show
  1. {richapi-0.3.0 → richapi-0.3.1}/PKG-INFO +1 -1
  2. {richapi-0.3.0 → richapi-0.3.1}/docs/conventions.md +26 -0
  3. {richapi-0.3.0 → richapi-0.3.1}/pyproject.toml +1 -1
  4. richapi-0.3.1/richapi/__version__.py +1 -0
  5. {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/compiler.py +192 -85
  6. {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/openapi.py +1 -1
  7. richapi-0.3.1/tests/app/__init__.py +0 -0
  8. richapi-0.3.1/tests/app/foo1.py +10 -0
  9. richapi-0.3.1/tests/test_builder_pattern.py +68 -0
  10. richapi-0.3.1/tests/test_class.py +72 -0
  11. richapi-0.3.1/tests/test_compiler.py +12 -0
  12. richapi-0.3.1/tests/test_duplicated_function.py +0 -0
  13. richapi-0.3.1/tests/test_import_from_other_module.py +20 -0
  14. richapi-0.3.1/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.1}/.envrc +0 -0
  17. {richapi-0.3.0 → richapi-0.3.1}/.github/workflows/deploy-docs-on-demand.yml +0 -0
  18. {richapi-0.3.0 → richapi-0.3.1}/.github/workflows/main.yml +0 -0
  19. {richapi-0.3.0 → richapi-0.3.1}/.github/workflows/on-release-main.yml +0 -0
  20. {richapi-0.3.0 → richapi-0.3.1}/.gitignore +0 -0
  21. {richapi-0.3.0 → richapi-0.3.1}/README.md +0 -0
  22. {richapi-0.3.0 → richapi-0.3.1}/deploy_docs.py +0 -0
  23. {richapi-0.3.0 → richapi-0.3.1}/docs/advance.md +0 -0
  24. {richapi-0.3.0 → richapi-0.3.1}/docs/images/custom-exception.png +0 -0
  25. {richapi-0.3.0 → richapi-0.3.1}/docs/images/with-richapi.png +0 -0
  26. {richapi-0.3.0 → richapi-0.3.1}/docs/images/without-richapi.png +0 -0
  27. {richapi-0.3.0 → richapi-0.3.1}/docs/implementation.md +0 -0
  28. {richapi-0.3.0 → richapi-0.3.1}/docs/index.md +0 -0
  29. {richapi-0.3.0 → richapi-0.3.1}/makefile +0 -0
  30. {richapi-0.3.0 → richapi-0.3.1}/mkdocs.yml +0 -0
  31. {richapi-0.3.0 → richapi-0.3.1}/richapi/__init__.py +0 -0
  32. {richapi-0.3.0 → richapi-0.3.1}/richapi/cli.py +0 -0
  33. {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/__init__.py +0 -0
  34. {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/handler.py +0 -0
  35. {richapi-0.3.0 → richapi-0.3.1}/richapi/exc_parser/protocol.py +0 -0
  36. {richapi-0.3.0 → richapi-0.3.1}/richapi/exceptions.py +0 -0
  37. {richapi-0.3.0 → richapi-0.3.1}/tests/__init__.py +0 -0
  38. {richapi-0.3.0 → richapi-0.3.1}/tests/test_custom_exc.py +0 -0
  39. {richapi-0.3.0 → richapi-0.3.1}/tests/test_exc_raising_cases.py +0 -0
  40. {richapi-0.3.0 → richapi-0.3.1}/tests/test_exception_edge_cases.py +0 -0
  41. {richapi-0.3.0 → richapi-0.3.1}/tests/test_fastapi_auth_functions.py +0 -0
  42. {richapi-0.3.0 → richapi-0.3.1}/tox.ini +0 -0
  43. {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.0
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "richapi"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Find HTTPExceptions and turn them into documented responses!"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -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 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
 
@@ -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
- logger.exception(
99
- f"Failed to parse source code for {cls.__name__}.", exc_info=error
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 and is_stdlib(module.__name__):
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
- cls_module = inspect.getmodule(type(func_obj))
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(type(func_obj), to_filter_predicate, tree)
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 _resolve_function_from_call_node(
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 = func_globals.get(node.id, None)
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
- return value
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 None
303
+ return []
261
304
 
262
305
  try:
263
306
  parts = attr_chain.split(".")
264
307
  parent_part = parts[0]
265
308
 
266
- parent_attr = func_globals.get(parent_part, None)
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
- return func.__annotations__[parent_part]
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
- node_id = NodeIdentifier(parent_part)
276
- parent_attr = assignments.get(node_id, None)
277
-
278
- 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
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 None
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
- return obj
379
+ return results
296
380
 
297
381
  except Exception:
298
382
  logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
299
- return None
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
- @lru_cache
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: set[Callable] = set()
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
- 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
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.func_globals)
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
- 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,
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
- self.generic_visit(node)
497
- return
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
- 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__}"
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.func_globals)
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 not module:
655
+ if module is None or self._should_be_visited(module) is False:
549
656
  self.generic_visit(node)
550
657
  return
551
658
 
@@ -91,7 +91,7 @@ def compile_openapi_from_fastapi(
91
91
 
92
92
  _fill_openapi_with_excpetions(openapi_schema, route, exceptions)
93
93
 
94
- ExceptionFinder.visited.clear()
94
+ ExceptionFinder.clear_cache()
95
95
 
96
96
  return openapi_schema
97
97
 
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 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