richapi 0.1.0__tar.gz → 0.2.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 (33) hide show
  1. {richapi-0.1.0 → richapi-0.2.1}/.gitignore +1 -0
  2. {richapi-0.1.0 → richapi-0.2.1}/PKG-INFO +4 -3
  3. {richapi-0.1.0 → richapi-0.2.1}/README.md +1 -0
  4. {richapi-0.1.0 → richapi-0.2.1}/docs/advance.md +18 -0
  5. {richapi-0.1.0 → richapi-0.2.1}/docs/conventions.md +24 -5
  6. richapi-0.2.1/docs/implementation.md +55 -0
  7. {richapi-0.1.0 → richapi-0.2.1}/docs/index.md +2 -2
  8. {richapi-0.1.0 → richapi-0.2.1}/mkdocs.yml +1 -0
  9. {richapi-0.1.0 → richapi-0.2.1}/pyproject.toml +2 -3
  10. richapi-0.2.1/richapi/__version__.py +1 -0
  11. {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/compiler.py +107 -47
  12. {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/openapi.py +12 -2
  13. {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/protocol.py +20 -3
  14. {richapi-0.1.0 → richapi-0.2.1}/tests/test_exc_raising_cases.py +1 -15
  15. richapi-0.2.1/tests/test_fastapi_auth_functions.py +30 -0
  16. {richapi-0.1.0 → richapi-0.2.1}/uv.lock +3 -27
  17. richapi-0.1.0/.python-version +0 -1
  18. richapi-0.1.0/richapi/__version__.py +0 -1
  19. {richapi-0.1.0 → richapi-0.2.1}/.envrc +0 -0
  20. {richapi-0.1.0 → richapi-0.2.1}/.github/workflows/deploy-docs-on-demand.yml +0 -0
  21. {richapi-0.1.0 → richapi-0.2.1}/.github/workflows/main.yml +0 -0
  22. {richapi-0.1.0 → richapi-0.2.1}/.github/workflows/on-release-main.yml +0 -0
  23. {richapi-0.1.0 → richapi-0.2.1}/deploy_docs.py +0 -0
  24. {richapi-0.1.0 → richapi-0.2.1}/docs/images/with-richapi.png +0 -0
  25. {richapi-0.1.0 → richapi-0.2.1}/docs/images/without-richapi.png +0 -0
  26. {richapi-0.1.0 → richapi-0.2.1}/makefile +0 -0
  27. {richapi-0.1.0 → richapi-0.2.1}/richapi/__init__.py +0 -0
  28. {richapi-0.1.0 → richapi-0.2.1}/richapi/cli.py +0 -0
  29. {richapi-0.1.0 → richapi-0.2.1}/richapi/exc_parser/__init__.py +0 -0
  30. {richapi-0.1.0 → richapi-0.2.1}/richapi/exceptions.py +0 -0
  31. {richapi-0.1.0 → richapi-0.2.1}/tests/__init__.py +0 -0
  32. {richapi-0.1.0 → richapi-0.2.1}/tests/test_exception_edge_cases.py +0 -0
  33. {richapi-0.1.0 → richapi-0.2.1}/tox.ini +0 -0
@@ -6,6 +6,7 @@ docs/source
6
6
  __pycache__/
7
7
  *.py[cod]
8
8
  *$py.class
9
+ .python_version
9
10
 
10
11
  # C extensions
11
12
  *.so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: richapi
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -8,8 +8,8 @@ Project-URL: Documentation, https://github.com/ManiMozaffar/richapi
8
8
  Project-URL: Source, https://github.com/ManiMozaffar/richapi
9
9
  License-Expression: MIT
10
10
  Requires-Python: >=3.9
11
- Requires-Dist: loguru>=0.7.2
12
- Requires-Dist: typer>=0.12.5
11
+ Requires-Dist: fastapi>=0.105
12
+ Requires-Dist: typer>=0.12
13
13
  Description-Content-Type: text/markdown
14
14
 
15
15
  ### Installation
@@ -28,6 +28,7 @@ See [documentation](https://manimozaffar.github.io/richapi/latest/) for more det
28
28
 
29
29
  RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
30
30
 
31
+ This project is inspired and completer version of [Kludex's Fast-Response project](https://github.com/Kludex/fastapi-responses)
31
32
  This package is planned to have more features for now that might be essential for any FastAPI project later.
32
33
 
33
34
  What would you see WITHOUT RichAPI:
@@ -14,6 +14,7 @@ See [documentation](https://manimozaffar.github.io/richapi/latest/) for more det
14
14
 
15
15
  RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
16
16
 
17
+ This project is inspired and completer version of [Kludex's Fast-Response project](https://github.com/Kludex/fastapi-responses)
17
18
  This package is planned to have more features for now that might be essential for any FastAPI project later.
18
19
 
19
20
  What would you see WITHOUT RichAPI:
@@ -34,3 +34,21 @@ class YourException(BaseHTTPException):
34
34
  ```
35
35
 
36
36
  Remember that the responsbility of raising and returning the correct data on FastAPI is not library responsibility, but the responsibility of the developer. The library is only responsible for generating the OpenAPI schema by looking at the functions and classes that are raising exceptions.
37
+
38
+ ### Finding exceptions in other modules
39
+
40
+ By default, the library only looks at the FastAPI app module to search for exceptions.
41
+ This is to avoid parsing countless dependency modules that are not related to the FastAPI app.
42
+ But sometimes you may be using some other libraries that raises HTTPExceptions.
43
+ For example using a third library for JWT authentication.
44
+
45
+ In that case, you can simply pass the module name to the `parse_exceptions` function.
46
+
47
+ ```python
48
+ from richapi import enrich_openapi
49
+ from fastapi import FastAPI
50
+
51
+ app = FastAPI()
52
+ # add your routers ....
53
+ app.openapi = enrich_openapi(app, ["your_module_name", "some-other-package"])
54
+ ```
@@ -1,4 +1,4 @@
1
- ## Conventions
1
+ # Conventions
2
2
 
3
3
  This is guideline for how to raise exceptions so that the library can generate the OpenAPI schema from them.
4
4
 
@@ -6,7 +6,7 @@ Since the library is a mini compiler using Abstract Syntax Tree and functions gl
6
6
 
7
7
  Keep in mind that if you don't follow these conventions, there would be alot false positive and false negative cases, and the library may not be able to extract the exceptions and their responses correctly.
8
8
 
9
- ### Inherit from `richapi.BaseHTTPException`
9
+ ## Inherit from `richapi.BaseHTTPException`
10
10
 
11
11
  Exceptions are better to be very concrete, without having any dynamic data in them.
12
12
  So that using `raise Class` is enough to raise them.
@@ -23,7 +23,11 @@ class UserNotFound(BaseHTTPException):
23
23
 
24
24
  And then, only writing `raise UserNotFound` is enough to raise the exception. This is most ideal case.
25
25
 
26
- ### Avoiding dynamic data in the constructor of the exceptions
26
+ !!! info "Expected Behavior"
27
+
28
+ Library **MIGHT skip** adding the exception to the OpenAPI schema if it could not gather necessary and missing informations from the abstract syntax tree.
29
+
30
+ ## Avoiding dynamic data in the constructor of the exceptions and stringifying errors
27
31
 
28
32
  Exceptions should not have any dynamic data in them.
29
33
  For example the code below is NOT ideal:
@@ -35,9 +39,16 @@ error_message = f"User {user_id} is not allowed to perform this action"
35
39
  raise FooException(detail=error_message)
36
40
  ```
37
41
 
42
+ This is not even a good practice to begin with, because ideally your RestAPI should not be aware of what text shown to the user. The error message should be handled by the client, not the server. There is a better way you can do to pass information to client about the error.
43
+ Read the [advance usage](advance.md) section for more information.
44
+
38
45
  Since the library is using static analyzers to extract the exceptions, it is not possible to extract the dynamic data from the exceptions. (or rather very hard to do so)
39
46
 
40
- ### Avoiding usage of magic methods
47
+ !!! info "Expected Behavior"
48
+
49
+ Library **WILL add** the exception to the OpenAPI schema, but the detail of response won't be precisely shown and instead you will see `str` as the response type. However, if you follow this convention, library exactly show the string that you have written in the `detail` attribute of the exception as a **literal string type**.
50
+
51
+ ## Avoiding usage of magic methods
41
52
 
42
53
  Magic methods are implicit, therefore extracting them from the abstract syntax tree is exteremly hard and equal to rewriting the Python interpreter.
43
54
 
@@ -52,7 +63,11 @@ class Foo:
52
63
  raise HTTPException(status_code=400, detail="This is not allowed")
53
64
  ```
54
65
 
55
- ### Keep it simple
66
+ !!! info "Expected Behavior"
67
+
68
+ Library **WILL skip** adding the exception to the OpenAPI schema.
69
+
70
+ ## Keep it simple
56
71
 
57
72
  The ideal way to raise exceptions is to keep it simple from syntax point of view;
58
73
 
@@ -70,3 +85,7 @@ raise exc()(status_code=400, detail="This is not allowed")
70
85
  ```
71
86
 
72
87
  Keep it simple, keep the exception type next to raise keyword; without any dynamic data in the constructor. This help the library to extract the exceptions and their responses and guarantee that they are correctly compiled into the OpenAPI schema.
88
+
89
+ !!! info "Expected Behavior"
90
+
91
+ Library **MIGHT skip** adding the exception to the OpenAPI schema depending on how complicated the exception raising is.
@@ -0,0 +1,55 @@
1
+ # Implementation detail
2
+
3
+ Library is made of few components working together to generate the OpenAPI schema.
4
+
5
+ ## Exception Finder
6
+
7
+ - **Input**: A function
8
+ - **Output**: List of exception type with their `raise` abstract syntax tree nodes
9
+
10
+ 1. Check if this function was ever analyzed before, if yes, then skip.
11
+ 2. Get source code of the callable object.
12
+ 3. Parse the source code to AST.
13
+ 4. Start visiting every node in the callable.
14
+ 5. Track assigmnets to a variable.
15
+ 6. On visiting a node ...
16
+ 1. If the node is a `raise` statement, then extract the exception type and return as output.
17
+ 2. If a node is a `call` statement, then recursively visit the callable that is being called.
18
+ 3. If a node is an `assign` statement, then track the variable name and the assigned value.
19
+ 4. If a node is an `attribute` statement, then track the attribute name and the parent object. Might have to recursively visit the parent object.
20
+
21
+ Note that the only way to extract the exception type is to use the tracked assigmnets with `eval` function so that the type is returned.
22
+ That's why it's best to run the library on CLI rather than on the fly. To understand this better look at below code;
23
+
24
+ ```python
25
+
26
+ def foo():
27
+ err = Exception
28
+ raise err
29
+ ```
30
+
31
+ The only way that you can understand err is Exception is to keep tracking the assignments and then use `eval` to get the type. Running `eval("err")` will get you the type of the error which is a requirement to function properly.
32
+
33
+ ## FastAPI Excption Parser
34
+
35
+ - **Input**: An excpetion with its `raise` AST node
36
+ - **Output**: Json schema
37
+
38
+ 1. Check if the exception type already has a schema defined by itself.
39
+ - If yes, then simply construct the Json schema from that.
40
+ - If not, continue to the next step.
41
+ 2. Analyze and extract arg and kwargs from the `raise` AST node.
42
+ 3. Try to construct the exception schema from the arguments.
43
+ 4. Try to dynamically generate the schema using Pydantic.
44
+ 5. Return the schema if successfully generated, otherwise return None.
45
+
46
+ ## OpenAPI Generator
47
+
48
+ - **Input**: FastAPI Module, Previous OpenAPI schema
49
+ - **Output**: OpenAPI schema
50
+
51
+ 1. Iterating over all fastapi routers to analyze each at a time.
52
+ 2. Each router has few callable dependency. All callable dependencies are analyzed using `Exception Finder`.
53
+ 3. Result of the `Exception Finder` is passed to the `FastAPI Excption Parser` to get the Json schema.
54
+ 4. Using the Json schema, the OpenAPI schema is extended with new responses for that router.
55
+ 5. The result is cached so that the same router is not analyzed again.
@@ -8,8 +8,8 @@ pip install richapi
8
8
 
9
9
  ### Usage
10
10
 
11
- **RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
12
- **
11
+ RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
12
+
13
13
  This package is planned to have more features for now that might be essential for any FastAPI project later.
14
14
 
15
15
  There are multiple way you can do that,
@@ -103,3 +103,4 @@ nav:
103
103
  - Welcome: index.md
104
104
  - Conventions: conventions.md
105
105
  - Advance Usage: advance.md
106
+ - Implemenetion Detail: implementation.md
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "richapi"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "Find HTTPExceptions and turn them into documented responses!"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
7
- dependencies = ["loguru>=0.7.2", "typer>=0.12.5"]
7
+ dependencies = ["typer>=0.12", "fastapi>=0.105"]
8
8
  license = 'MIT'
9
9
 
10
10
  [project.urls]
@@ -44,7 +44,6 @@ dev-dependencies = [
44
44
  "mkdocstrings[python]>=0.25.1",
45
45
  "mkdocs-simple-hooks>=0.1.5",
46
46
  "mkdocs-material-extensions>=1.3.1",
47
- "fastapi>=0.115.0",
48
47
  "mdx-truly-sane-lists>=1.3",
49
48
  "mike==2.0.0",
50
49
  "psutil>=6.0.0",
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -60,7 +60,7 @@ def is_in_module(module: str, target_modules: list[str]) -> bool:
60
60
 
61
61
  def find_explicit_exceptions(
62
62
  func: Callable,
63
- target_modules: Union[list[str], str],
63
+ target_modules: list[str],
64
64
  should_search_module_pred: Union[Callable[[str], bool], None] = None,
65
65
  ) -> List[Tuple[Optional[type[Exception]], ast.Raise]]:
66
66
  """
@@ -73,9 +73,6 @@ def find_explicit_exceptions(
73
73
  - The ast.Raise node
74
74
  """
75
75
 
76
- if isinstance(target_modules, str):
77
- target_modules = [target_modules]
78
-
79
76
  if should_search_module_pred is None:
80
77
 
81
78
  def should_search_module_pred_func(target):
@@ -83,21 +80,51 @@ def find_explicit_exceptions(
83
80
 
84
81
  should_search_module_pred = should_search_module_pred_func
85
82
 
83
+ return _find_explicit_expection_recursively(func, should_search_module_pred)
84
+
85
+
86
+ def get_func_name(func: Callable) -> str:
87
+ return func.__name__ if hasattr(func, "__name__") else func.__str__()
88
+
89
+
90
+ def _find_all_class_exceptions(
91
+ cls: type,
92
+ to_filter_predicate: Callable[[str], bool],
93
+ tree: Optional[ast.AST] = None,
94
+ ):
86
95
  try:
87
- source = inspect.getsource(func)
88
- except (OSError, TypeError):
89
- raise Exception(func)
96
+ tree = tree or ast.parse(inspect.getsource(cls))
97
+ except Exception as error:
98
+ logger.exception(f"Failed to parse source code for {cls.__name__}.", exc_info=error)
99
+ return []
100
+
101
+ result = _find_explicit_expection_recursively(cls.__init__, to_filter_predicate, tree)
102
+ if hasattr(cls, "__call__"):
103
+ # Not sure if __call__ was called or init, so check for both
90
104
 
91
- tree = ast.parse(source)
105
+ result.extend(
106
+ _find_explicit_expection_recursively(
107
+ cls.__call__, # type: ignore
108
+ to_filter_predicate,
109
+ tree,
110
+ )
111
+ )
92
112
 
93
- return _find_explicit_expection_recursively(func, tree, should_search_module_pred)
113
+ ExceptionFinder.visited = set() # clean up memory
114
+ return result
115
+
116
+
117
+ def is_absolutely_callable_object(obj: Callable) -> bool:
118
+ return (
119
+ inspect.isclass(type(obj)) and (not inspect.isfunction(obj)) and (not inspect.ismethod(obj))
120
+ )
94
121
 
95
122
 
96
123
  def _find_explicit_expection_recursively(
97
124
  func_obj: Callable,
98
- expr: ast.AST,
99
125
  to_filter_predicate: Callable[[str], bool],
100
- ):
126
+ tree: Optional[ast.AST] = None,
127
+ ) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
101
128
  if func_obj in ExceptionFinder.visited:
102
129
  return []
103
130
 
@@ -108,9 +135,42 @@ def _find_explicit_expection_recursively(
108
135
  if module and to_filter_predicate(module.__name__) is False:
109
136
  return []
110
137
 
111
- finder = ExceptionFinder(func_obj, to_filter_predicate)
112
- finder.visit(expr)
138
+ if is_absolutely_callable_object(func_obj):
139
+ cls_module = inspect.getmodule(type(func_obj))
140
+ if cls_module and is_stdlib(cls_module.__name__):
141
+ return []
142
+ if cls_module and to_filter_predicate(cls_module.__name__) is False:
143
+ return []
144
+
145
+ return _find_all_class_exceptions(type(func_obj), to_filter_predicate, tree)
146
+
113
147
  ExceptionFinder.visited.add(func_obj)
148
+
149
+ if tree is None:
150
+ try:
151
+ source = inspect.getsource(func_obj)
152
+ except (OSError, TypeError) as error:
153
+ logger.exception(
154
+ f"Failed to get source code for {get_func_name(func_obj)}.",
155
+ exc_info=error,
156
+ )
157
+ return []
158
+
159
+ try:
160
+ tree = ast.parse(source)
161
+ except IndentationError as error:
162
+ logger.exception(
163
+ f"Failed to get source code for {get_func_name(func_obj)}.",
164
+ exc_info=error,
165
+ )
166
+ return []
167
+
168
+ finder = ExceptionFinder(func_obj, to_filter_predicate)
169
+ try:
170
+ finder.visit(tree)
171
+ except Exception as error:
172
+ logger.exception(f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error)
173
+
114
174
  return finder.exceptions
115
175
 
116
176
 
@@ -187,9 +247,7 @@ def _resolve_function_from_call_node(
187
247
  elif isinstance(node, ast.Attribute):
188
248
  attr_chain = _resolve_full_attribute_path(node)
189
249
  if attr_chain is None:
190
- logger.debug(
191
- f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
192
- )
250
+ logger.debug(f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}")
193
251
  return None
194
252
 
195
253
  try:
@@ -209,12 +267,9 @@ def _resolve_function_from_call_node(
209
267
  parent_attr = assignments.get(node_id, None)
210
268
 
211
269
  if parent_attr is None:
212
- if (
213
- parent_part == "self"
214
- ): # don't need to log -> we already know this is class
270
+ if parent_part == "self": # don't need to log -> we already know this is class
215
271
  return None
216
272
 
217
- print(attr_chain, parent_part)
218
273
  logger.debug(
219
274
  f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
220
275
  )
@@ -240,9 +295,7 @@ def _extract_node_name(
240
295
  Extract the exception name a node recursively.
241
296
  """
242
297
  if isinstance(node, ast.Call):
243
- return _extract_node_name(
244
- typing.cast(SupportedAstNodes, node.func), assignments
245
- )
298
+ return _extract_node_name(typing.cast(SupportedAstNodes, node.func), assignments)
246
299
  elif isinstance(node, ast.Name):
247
300
  var_name = get_node_identifier(node)
248
301
  return assignments.get(var_name, var_name)
@@ -250,9 +303,7 @@ def _extract_node_name(
250
303
  return _resolve_full_attribute_path(node)
251
304
 
252
305
  elif isinstance(node, ast.Await):
253
- return _extract_node_name(
254
- typing.cast(SupportedAstNodes, node.value), assignments
255
- )
306
+ return _extract_node_name(typing.cast(SupportedAstNodes, node.value), assignments)
256
307
 
257
308
  elif isinstance(
258
309
  node,
@@ -300,10 +351,8 @@ def is_stdlib(module_name: str) -> bool:
300
351
  class ExceptionFinder(ast.NodeVisitor):
301
352
  visited: set[Callable] = set()
302
353
 
303
- def __init__(
304
- self, func: Callable, should_search_module_pred: Callable[[str], bool]
305
- ):
306
- self.exceptions: List[Tuple[Optional[type[Exception]], ast.Raise]] = []
354
+ def __init__(self, func: Callable, should_search_module_pred: Callable[[str], bool]):
355
+ self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
307
356
  self.assignments: dict[NodeIdentifier, str] = {}
308
357
  self.func = func
309
358
  self.func_globals = func.__globals__
@@ -420,19 +469,24 @@ class ExceptionFinder(ast.NodeVisitor):
420
469
  return
421
470
 
422
471
  source = inspect.getsource(module)
423
- tree = ast.parse(source)
472
+ try:
473
+ tree = ast.parse(source)
474
+ except BaseException as error:
475
+ logger.exception(
476
+ f"Failed to parse source code for {func_obj.__name__}.",
477
+ exc_info=error,
478
+ )
479
+ self.generic_visit(node)
480
+ return
424
481
 
425
- founded_func = _find_in_module(
482
+ founded_func_ast = _find_in_module(
426
483
  tree,
427
- lambda n: (
428
- isinstance(n, ast.AsyncFunctionDef)
429
- or isinstance(n, ast.FunctionDef)
430
- )
484
+ lambda n: (isinstance(n, ast.AsyncFunctionDef) or isinstance(n, ast.FunctionDef))
431
485
  and n.name == func_obj.__name__,
432
486
  )
433
- if founded_func:
487
+ if founded_func_ast:
434
488
  excs = _find_explicit_expection_recursively(
435
- func_obj, founded_func, self.should_search_module_pred
489
+ func_obj, self.should_search_module_pred, founded_func_ast
436
490
  )
437
491
  self.exceptions.extend(excs)
438
492
  else:
@@ -455,9 +509,7 @@ class ExceptionFinder(ast.NodeVisitor):
455
509
  try:
456
510
  caller_type = _exctact_type(caller_node_name, self.func_globals)
457
511
  except Exception:
458
- logger.critical(
459
- f"Failed to resolve attribute chain: {caller_node_name}"
460
- )
512
+ logger.critical(f"Failed to resolve attribute chain: {caller_node_name}")
461
513
  self.generic_visit(node)
462
514
  return
463
515
 
@@ -476,18 +528,26 @@ class ExceptionFinder(ast.NodeVisitor):
476
528
  return
477
529
 
478
530
  source = inspect.getsource(module)
479
- tree = ast.parse(source)
531
+ try:
532
+ tree = ast.parse(source)
533
+ except BaseException as error:
534
+ logger.exception(
535
+ f"Failed to parse source code for {caller_type.__name__}.",
536
+ exc_info=error,
537
+ )
538
+ self.generic_visit(node)
539
+ return
540
+
480
541
  cls_ast = _find_in_module(
481
542
  tree,
482
- lambda n: isinstance(n, ast.ClassDef)
483
- and n.name == caller_type.__name__,
543
+ lambda n: isinstance(n, ast.ClassDef) and n.name == caller_type.__name__,
484
544
  )
485
545
  if cls_ast:
486
546
  casted_cls_ast = typing.cast(ast.ClassDef, cls_ast)
487
- submethod = _find_method_node_in_cls(casted_cls_ast, node.attr)
488
- if submethod:
547
+ submethod_ast = _find_method_node_in_cls(casted_cls_ast, node.attr)
548
+ if submethod_ast:
489
549
  excs = _find_explicit_expection_recursively(
490
- method_func, submethod, self.should_search_module_pred
550
+ method_func, self.should_search_module_pred, submethod_ast
491
551
  )
492
552
  self.exceptions.extend(excs)
493
553
 
@@ -44,12 +44,15 @@ def enrich_openapi(
44
44
  ) -> Callable:
45
45
  if target_module is None:
46
46
  target_module = _find_module_name_where_app_defined_in(app)
47
+
47
48
  if target_module is None or target_module == "__main__":
48
49
  raise BaseRichAPIException(
49
50
  "Could not determine the module where the FastAPI instance was created.\n"
50
51
  "Please provide the module name as a string or list of strings."
51
52
  )
52
53
 
54
+ target_module = target_module.split(".")[0] # get the top-level module
55
+
53
56
  def _custom_openapi() -> dict:
54
57
  if app.openapi_schema: # pragma: no cover
55
58
  return app.openapi_schema
@@ -72,6 +75,9 @@ def compile_openapi_from_fastapi(
72
75
  routes=app.routes,
73
76
  ),
74
77
  ) -> dict:
78
+ target_module = [target_module] if isinstance(target_module, str) else target_module
79
+ target_module.append("fastapi")
80
+
75
81
  openapi_schema = open_api_getter(app)
76
82
  for route in app.routes:
77
83
  if not isinstance(route, APIRoute):
@@ -209,6 +215,8 @@ def _fill_openapi_with_excpetions(
209
215
  route: APIRoute,
210
216
  exceptions: list[tuple[type[StarletteHTTPException], ast.Raise]],
211
217
  ) -> None:
218
+ added_schema_names = set()
219
+
212
220
  for exc, ast_node in exceptions:
213
221
  if hasattr(exc, "get_json_schema"):
214
222
  casted_exc = typing.cast(type[BaseHTTPException], exc)
@@ -216,9 +224,11 @@ def _fill_openapi_with_excpetions(
216
224
  else:
217
225
  schema = _extract_json_schema(ast_node, exc)
218
226
 
219
- if schema is None:
227
+ if schema is None or schema.schema_name in added_schema_names:
220
228
  continue
221
229
 
230
+ added_schema_names.add(schema.schema_name)
231
+
222
232
  str_status_code = str(schema.status_code)
223
233
  component_schemas = api_schema.get("components", {}).get("schemas", {})
224
234
  if schema.schema_name not in component_schemas:
@@ -290,7 +300,7 @@ def flatten(to_be_flatten: list[list[T]]) -> list[T]:
290
300
 
291
301
 
292
302
  def _extract_starlette_exceptions(
293
- route: APIRoute, target_module: Union[list[str], str]
303
+ route: APIRoute, target_module: list[str]
294
304
  ) -> list[tuple[type[StarletteHTTPException], ast.Raise]]:
295
305
  dependency_tree = build_dependency_tree(route.dependant)
296
306
  exceptions = flatten(
@@ -27,12 +27,27 @@ class BaseHTTPException(HTTPException):
27
27
  @classmethod
28
28
  def get_json_schema(cls) -> HTTPExceptionSchema:
29
29
  value = _generic_json_schema_builder(cls)
30
- assert (
31
- value is not None
32
- ) # should never happen because status code is always set
30
+ assert value is not None # should never happen because status code is always set
33
31
  return value
34
32
 
35
33
 
34
+ def try_to_camel_case(string: str) -> str:
35
+ final_str = string
36
+ if " " in final_str: # with space
37
+ components = string.split(" ")
38
+ final_str = components[0] + "".join(x.title() for x in components[1:])
39
+
40
+ if "_" in final_str: # snake case
41
+ components = string.split("_")
42
+ final_str = components[0] + "".join(x.title() for x in components[1:])
43
+
44
+ if "-" in final_str: # kebab case
45
+ components = string.split("-")
46
+ final_str = components[0] + "".join(x.title() for x in components[1:])
47
+
48
+ return final_str
49
+
50
+
36
51
  def _generic_json_schema_builder(
37
52
  cls: type[Exception],
38
53
  detail: str | None = None,
@@ -51,6 +66,8 @@ def _generic_json_schema_builder(
51
66
 
52
67
  if detail is not None:
53
68
  detail_type = Literal[detail] # type: ignore
69
+
70
+ name = f"{try_to_camel_case(detail)}Schema"
54
71
  else:
55
72
  detail_type = str
56
73
 
@@ -1,5 +1,4 @@
1
1
  import logging
2
- import sys
3
2
  from functools import lru_cache
4
3
 
5
4
  from fastapi import Depends, FastAPI, HTTPException, status
@@ -10,12 +9,8 @@ from richapi.exc_parser.protocol import BaseHTTPException
10
9
 
11
10
  app = FastAPI()
12
11
 
13
- root = logging.getLogger()
14
- root.setLevel(logging.DEBUG)
15
12
 
16
- handler = logging.StreamHandler(sys.stdout)
17
- handler.setLevel(logging.DEBUG)
18
- root.addHandler(handler)
13
+ logging.getLogger().addHandler(logging.StreamHandler())
19
14
 
20
15
 
21
16
  class FirstException(HTTPException): ...
@@ -104,12 +99,3 @@ def test_case():
104
99
  assert "407" in home_responses
105
100
  assert "408" in home_responses
106
101
  assert "409" in home_responses
107
- schemas = openapi_json["components"]["schemas"]
108
- assert "SecondExceptionErrorSchema" in schemas
109
- assert "ThirdExceptionErrorSchema" in schemas
110
- assert "FourthExceptionErrorSchema" in schemas
111
- assert "FifthExceptionErrorSchema" in schemas
112
- assert "SixthExceptionErrorSchema" in schemas
113
- assert "NoNeedParanthesisErrorSchema" in schemas
114
- assert "SeventhExceptionErrorSchema" in schemas
115
- assert "EighthExceptionErrorSchema" in schemas
@@ -0,0 +1,30 @@
1
+ import fastapi
2
+ import fastapi.security
3
+
4
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi
5
+
6
+ app = fastapi.FastAPI()
7
+
8
+
9
+ http_bearer = fastapi.security.HTTPBearer()
10
+
11
+
12
+ def get_at_11(
13
+ token: fastapi.security.HTTPAuthorizationCredentials = fastapi.Depends(http_bearer),
14
+ ) -> str:
15
+ return "12312"
16
+
17
+
18
+ @app.get("/refresh")
19
+ async def refresh_token(
20
+ token_data: str = fastapi.Depends(get_at_11),
21
+ ) -> None:
22
+ pass
23
+
24
+
25
+ def test_fastapi_call_detected():
26
+ openapi_json = compile_openapi_from_fastapi(
27
+ app, target_module="tests.test_fastapi_auth_functions"
28
+ )
29
+ home_responses = openapi_json["paths"]["/refresh"]["get"]["responses"]
30
+ assert "403" in home_responses
@@ -327,19 +327,6 @@ wheels = [
327
327
  { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
328
328
  ]
329
329
 
330
- [[package]]
331
- name = "loguru"
332
- version = "0.7.2"
333
- source = { registry = "https://pypi.org/simple" }
334
- dependencies = [
335
- { name = "colorama", marker = "sys_platform == 'win32'" },
336
- { name = "win32-setctime", marker = "sys_platform == 'win32'" },
337
- ]
338
- sdist = { url = "https://files.pythonhosted.org/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac", size = 145103 }
339
- wheels = [
340
- { url = "https://files.pythonhosted.org/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549 },
341
- ]
342
-
343
330
  [[package]]
344
331
  name = "markdown"
345
332
  version = "3.7"
@@ -1071,13 +1058,12 @@ name = "richapi"
1071
1058
  version = "0.1.0"
1072
1059
  source = { editable = "." }
1073
1060
  dependencies = [
1074
- { name = "loguru" },
1061
+ { name = "fastapi" },
1075
1062
  { name = "typer" },
1076
1063
  ]
1077
1064
 
1078
1065
  [package.dev-dependencies]
1079
1066
  dev = [
1080
- { name = "fastapi" },
1081
1067
  { name = "httpx" },
1082
1068
  { name = "mdx-truly-sane-lists" },
1083
1069
  { name = "mike" },
@@ -1098,13 +1084,12 @@ dev = [
1098
1084
 
1099
1085
  [package.metadata]
1100
1086
  requires-dist = [
1101
- { name = "loguru", specifier = ">=0.7.2" },
1102
- { name = "typer", specifier = ">=0.12.5" },
1087
+ { name = "fastapi", specifier = ">=0.105" },
1088
+ { name = "typer", specifier = ">=0.12" },
1103
1089
  ]
1104
1090
 
1105
1091
  [package.metadata.requires-dev]
1106
1092
  dev = [
1107
- { name = "fastapi", specifier = ">=0.115.0" },
1108
1093
  { name = "httpx", specifier = ">=0.27.2" },
1109
1094
  { name = "mdx-truly-sane-lists", specifier = ">=1.3" },
1110
1095
  { name = "mike", specifier = "==2.0.0" },
@@ -1326,15 +1311,6 @@ wheels = [
1326
1311
  { url = "https://files.pythonhosted.org/packages/38/b8/0aa69337651b3005f161f7f494e59188a1d8d94171666900d26d29d10f69/watchdog-5.0.3-py3-none-win_ia64.whl", hash = "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45", size = 79324 },
1327
1312
  ]
1328
1313
 
1329
- [[package]]
1330
- name = "win32-setctime"
1331
- version = "1.1.0"
1332
- source = { registry = "https://pypi.org/simple" }
1333
- sdist = { url = "https://files.pythonhosted.org/packages/6b/dd/f95a13d2b235a28d613ba23ebad55191514550debb968b46aab99f2e3a30/win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2", size = 3676 }
1334
- wheels = [
1335
- { url = "https://files.pythonhosted.org/packages/0a/e6/a7d828fef907843b2a5773ebff47fb79ac0c1c88d60c0ca9530ee941e248/win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad", size = 3604 },
1336
- ]
1337
-
1338
1314
  [[package]]
1339
1315
  name = "zipp"
1340
1316
  version = "3.20.2"
@@ -1 +0,0 @@
1
- 3.9.19
@@ -1 +0,0 @@
1
- __version__ = "0.1.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