richapi 0.1.0__py3-none-any.whl
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/__init__.py +4 -0
- richapi/__version__.py +1 -0
- richapi/cli.py +87 -0
- richapi/exc_parser/__init__.py +4 -0
- richapi/exc_parser/compiler.py +495 -0
- richapi/exc_parser/openapi.py +309 -0
- richapi/exc_parser/protocol.py +64 -0
- richapi/exceptions.py +2 -0
- richapi-0.1.0.dist-info/METADATA +37 -0
- richapi-0.1.0.dist-info/RECORD +12 -0
- richapi-0.1.0.dist-info/WHEEL +4 -0
- richapi-0.1.0.dist-info/entry_points.txt +2 -0
richapi/__init__.py
ADDED
richapi/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
richapi/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Optional, Union
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
from richapi.exc_parser.openapi import compile_openapi_from_fastapi
|
|
10
|
+
|
|
11
|
+
app = typer.Typer()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dynamic_import(module_name: str, attribute: Optional[str] = None):
|
|
15
|
+
"""
|
|
16
|
+
Dynamically import a module and optionally get an attribute from it.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
module = importlib.import_module(module_name)
|
|
20
|
+
if attribute:
|
|
21
|
+
return getattr(module, attribute)
|
|
22
|
+
return module
|
|
23
|
+
except ModuleNotFoundError:
|
|
24
|
+
raise typer.BadParameter(f"Module '{module_name}' not found.")
|
|
25
|
+
except AttributeError:
|
|
26
|
+
raise typer.BadParameter(
|
|
27
|
+
f"Attribute '{attribute}' not found in module '{module_name}'."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def compile(
|
|
33
|
+
fastapi_path: Annotated[
|
|
34
|
+
str,
|
|
35
|
+
typer.Argument(help="The FastAPI application path."),
|
|
36
|
+
],
|
|
37
|
+
target_path: Annotated[
|
|
38
|
+
Union[Path, None],
|
|
39
|
+
typer.Argument(help="The target path to save the OpenAPI schema."),
|
|
40
|
+
] = None,
|
|
41
|
+
module_to_compile: Annotated[
|
|
42
|
+
Optional[str],
|
|
43
|
+
typer.Argument(help="The module to look for the FastAPI application."),
|
|
44
|
+
] = None,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Compile an OpenAPI schema from a FastAPI application, extending all possible FastAPI exceptions to the OpenAPI schema.
|
|
48
|
+
Compile the module and attribute using the format {module_name}:{attribute}.
|
|
49
|
+
"""
|
|
50
|
+
if ":" not in fastapi_path:
|
|
51
|
+
raise typer.BadParameter(
|
|
52
|
+
"Invalid format. Please use the format {module}.{module}:{app}."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if not target_path:
|
|
56
|
+
target_path = Path.cwd() / "openapi.json"
|
|
57
|
+
|
|
58
|
+
module_name, attribute = fastapi_path.split(":")
|
|
59
|
+
|
|
60
|
+
if module_to_compile is None:
|
|
61
|
+
module_to_compile = module_name.split(".")[0]
|
|
62
|
+
|
|
63
|
+
app_object = dynamic_import(module_name, attribute)
|
|
64
|
+
if not isinstance(app_object, FastAPI):
|
|
65
|
+
raise typer.BadParameter(
|
|
66
|
+
f"Expected FastAPI object, but got `{type(app_object).__name__}`."
|
|
67
|
+
)
|
|
68
|
+
typer.echo(
|
|
69
|
+
f"Successfully accessed '{attribute}' in module '{module_name}', compiling OpenAPI..."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
openapi_json = compile_openapi_from_fastapi(app_object, module_to_compile)
|
|
73
|
+
|
|
74
|
+
with open(target_path, "w") as file:
|
|
75
|
+
file.write(json.dumps(openapi_json, indent=2))
|
|
76
|
+
|
|
77
|
+
typer.echo(f"OpenAPI schema saved to '{target_path}'")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command(name="test-compile")
|
|
82
|
+
def test():
|
|
83
|
+
typer.echo("Hello, World!")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
app()
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import builtins
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import sysconfig
|
|
6
|
+
import typing
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from importlib.util import find_spec
|
|
9
|
+
from typing import Callable, List, NewType, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def not_supported(
|
|
15
|
+
value: typing.NoReturn,
|
|
16
|
+
) -> typing.NoReturn: ... # don't raise any exceptions just type checking
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Extending this list will raise typing checks on all codes.
|
|
20
|
+
# To support a new node, just add it to list and resolve all typing checks.
|
|
21
|
+
|
|
22
|
+
SupportedAstNodes = Union[
|
|
23
|
+
ast.Call, # Function call
|
|
24
|
+
ast.FunctionDef, # Function definition
|
|
25
|
+
ast.AsyncFunctionDef, # Async function definition
|
|
26
|
+
ast.ClassDef, # Class definition
|
|
27
|
+
ast.Raise, # Raise statement
|
|
28
|
+
ast.Assign, # Assignment statement
|
|
29
|
+
ast.Attribute, # Attribute access
|
|
30
|
+
ast.Name, # Variable name
|
|
31
|
+
ast.Await, # Await expression
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
NodeIdentifier = NewType("NodeIdentifier", str)
|
|
35
|
+
"""Branded type that represents a node identifier that can be used a key in a dictionary."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_node_identifier(node: ast.Name) -> NodeIdentifier:
|
|
39
|
+
"""Get the identifier of the given node."""
|
|
40
|
+
|
|
41
|
+
return NodeIdentifier(node.id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_in_module(module: str, target_modules: list[str]) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Module is like 'module.submodule.Class.method'
|
|
47
|
+
target modules are like ['module.submodule', 'module.submodule2']
|
|
48
|
+
"""
|
|
49
|
+
if module == "__main__": # always look into main module
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
if "." not in module:
|
|
53
|
+
module = module + "." # to match the module.* pattern
|
|
54
|
+
|
|
55
|
+
for target in target_modules:
|
|
56
|
+
if module.startswith(target):
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_explicit_exceptions(
|
|
62
|
+
func: Callable,
|
|
63
|
+
target_modules: Union[list[str], str],
|
|
64
|
+
should_search_module_pred: Union[Callable[[str], bool], None] = None,
|
|
65
|
+
) -> List[Tuple[Optional[type[Exception]], ast.Raise]]:
|
|
66
|
+
"""
|
|
67
|
+
Analyze the given function and find all explicitly raised exceptions,
|
|
68
|
+
including those in nested functions and class methods.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A list of tuples where each tuple contains:
|
|
72
|
+
- The exception class (or None if it couldn't be resolved)
|
|
73
|
+
- The ast.Raise node
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if isinstance(target_modules, str):
|
|
77
|
+
target_modules = [target_modules]
|
|
78
|
+
|
|
79
|
+
if should_search_module_pred is None:
|
|
80
|
+
|
|
81
|
+
def should_search_module_pred_func(target):
|
|
82
|
+
return is_in_module(target, target_modules)
|
|
83
|
+
|
|
84
|
+
should_search_module_pred = should_search_module_pred_func
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
source = inspect.getsource(func)
|
|
88
|
+
except (OSError, TypeError):
|
|
89
|
+
raise Exception(func)
|
|
90
|
+
|
|
91
|
+
tree = ast.parse(source)
|
|
92
|
+
|
|
93
|
+
return _find_explicit_expection_recursively(func, tree, should_search_module_pred)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _find_explicit_expection_recursively(
|
|
97
|
+
func_obj: Callable,
|
|
98
|
+
expr: ast.AST,
|
|
99
|
+
to_filter_predicate: Callable[[str], bool],
|
|
100
|
+
):
|
|
101
|
+
if func_obj in ExceptionFinder.visited:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
module = inspect.getmodule(func_obj)
|
|
105
|
+
if module and is_stdlib(module.__name__):
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
if module and to_filter_predicate(module.__name__) is False:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
finder = ExceptionFinder(func_obj, to_filter_predicate)
|
|
112
|
+
finder.visit(expr)
|
|
113
|
+
ExceptionFinder.visited.add(func_obj)
|
|
114
|
+
return finder.exceptions
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _find_in_module(module: ast.Module, predicate: Callable[[ast.AST], bool]):
|
|
118
|
+
for node in ast.walk(module):
|
|
119
|
+
if predicate(node):
|
|
120
|
+
return node
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _find_method_node_in_cls(cls: ast.ClassDef, method_name: str):
|
|
125
|
+
for node in cls.body:
|
|
126
|
+
if isinstance(node, ast.FunctionDef) and node.name == method_name:
|
|
127
|
+
return node
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_full_attribute_path(node: ast.AST) -> Union[str, None]:
|
|
132
|
+
"""
|
|
133
|
+
Reconstruct the full attribute name from an AST node, handling nested attributes and calls.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
sa.select(...).where(...) -> "sa.select.where"
|
|
137
|
+
obj.method1().method2 -> "obj.method1.method2"
|
|
138
|
+
module.submodule.Class.method -> "module.submodule.Class.method"
|
|
139
|
+
"""
|
|
140
|
+
names: list[str] = []
|
|
141
|
+
|
|
142
|
+
def recurse(n):
|
|
143
|
+
if isinstance(n, ast.Attribute):
|
|
144
|
+
names.append(n.attr)
|
|
145
|
+
recurse(n.value)
|
|
146
|
+
elif isinstance(n, ast.Call):
|
|
147
|
+
recurse(n.func)
|
|
148
|
+
elif isinstance(n, ast.Name):
|
|
149
|
+
names.append(n.id)
|
|
150
|
+
else:
|
|
151
|
+
# Unsupported node type encountered
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
recurse(node)
|
|
155
|
+
|
|
156
|
+
if names:
|
|
157
|
+
# The names are collected in reverse order, so reverse them back
|
|
158
|
+
return ".".join(reversed(names))
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_function_from_call_node(
|
|
164
|
+
call_node: ast.Call,
|
|
165
|
+
func_globals: dict,
|
|
166
|
+
func: Callable,
|
|
167
|
+
assignments: dict[NodeIdentifier, str],
|
|
168
|
+
):
|
|
169
|
+
"""
|
|
170
|
+
Attempt to resolve the function object from a call node.
|
|
171
|
+
Handles both simple function calls and method calls.
|
|
172
|
+
"""
|
|
173
|
+
node = call_node.func
|
|
174
|
+
|
|
175
|
+
if isinstance(node, ast.Name):
|
|
176
|
+
# Simple function call: func()
|
|
177
|
+
value = func_globals.get(node.id, None)
|
|
178
|
+
|
|
179
|
+
if value is None:
|
|
180
|
+
value = getattr(builtins, node.id, None)
|
|
181
|
+
|
|
182
|
+
if value is None:
|
|
183
|
+
logger.debug(f"Function '{node.id}' not found in {func.__name__}")
|
|
184
|
+
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
elif isinstance(node, ast.Attribute):
|
|
188
|
+
attr_chain = _resolve_full_attribute_path(node)
|
|
189
|
+
if attr_chain is None:
|
|
190
|
+
logger.debug(
|
|
191
|
+
f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
|
|
192
|
+
)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
parts = attr_chain.split(".")
|
|
197
|
+
parent_part = parts[0]
|
|
198
|
+
|
|
199
|
+
parent_attr = func_globals.get(parent_part, None)
|
|
200
|
+
|
|
201
|
+
if parent_attr is None:
|
|
202
|
+
if parent_part in func.__annotations__:
|
|
203
|
+
return func.__annotations__[parent_part]
|
|
204
|
+
|
|
205
|
+
parent_attr = getattr(builtins, parent_part, None)
|
|
206
|
+
|
|
207
|
+
if parent_attr is None:
|
|
208
|
+
node_id = NodeIdentifier(parent_part)
|
|
209
|
+
parent_attr = assignments.get(node_id, None)
|
|
210
|
+
|
|
211
|
+
if parent_attr is None:
|
|
212
|
+
if (
|
|
213
|
+
parent_part == "self"
|
|
214
|
+
): # don't need to log -> we already know this is class
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
print(attr_chain, parent_part)
|
|
218
|
+
logger.debug(
|
|
219
|
+
f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
|
|
220
|
+
)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
for attr in parts[1:]:
|
|
224
|
+
try:
|
|
225
|
+
obj = getattr(parent_attr, attr, None)
|
|
226
|
+
except Exception:
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
return obj
|
|
230
|
+
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _extract_node_name(
|
|
237
|
+
node: SupportedAstNodes, assignments: dict[NodeIdentifier, str]
|
|
238
|
+
) -> Optional[str]:
|
|
239
|
+
"""
|
|
240
|
+
Extract the exception name a node recursively.
|
|
241
|
+
"""
|
|
242
|
+
if isinstance(node, ast.Call):
|
|
243
|
+
return _extract_node_name(
|
|
244
|
+
typing.cast(SupportedAstNodes, node.func), assignments
|
|
245
|
+
)
|
|
246
|
+
elif isinstance(node, ast.Name):
|
|
247
|
+
var_name = get_node_identifier(node)
|
|
248
|
+
return assignments.get(var_name, var_name)
|
|
249
|
+
elif isinstance(node, ast.Attribute):
|
|
250
|
+
return _resolve_full_attribute_path(node)
|
|
251
|
+
|
|
252
|
+
elif isinstance(node, ast.Await):
|
|
253
|
+
return _extract_node_name(
|
|
254
|
+
typing.cast(SupportedAstNodes, node.value), assignments
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
elif isinstance(
|
|
258
|
+
node,
|
|
259
|
+
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Raise, ast.Assign),
|
|
260
|
+
):
|
|
261
|
+
... # not supported -> we cannot resolve the exception name
|
|
262
|
+
|
|
263
|
+
else:
|
|
264
|
+
not_supported(node)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _exctact_type(var_tyep: str, func_globals: dict) -> Optional[type]:
|
|
269
|
+
"""
|
|
270
|
+
Attempt to resolve the exception name to an actual exception class.
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
exc_type = eval(var_tyep, func_globals)
|
|
274
|
+
if isinstance(exc_type, type) and issubclass(exc_type, BaseException):
|
|
275
|
+
return exc_type
|
|
276
|
+
except BaseException:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
# Fallback to builtins
|
|
281
|
+
return getattr(builtins, var_tyep, None)
|
|
282
|
+
except Exception:
|
|
283
|
+
logger.exception(f"Failed to resolve variable type: {var_tyep}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@lru_cache
|
|
287
|
+
def is_stdlib(module_name: str) -> bool:
|
|
288
|
+
"""Check if a given module is part of the Python standard library."""
|
|
289
|
+
try:
|
|
290
|
+
module_spec = find_spec(module_name)
|
|
291
|
+
except Exception:
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
if module_spec is None or module_spec.origin is None:
|
|
295
|
+
return False
|
|
296
|
+
std_lib_paths = sysconfig.get_paths()["stdlib"]
|
|
297
|
+
return module_spec.origin.startswith(std_lib_paths)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ExceptionFinder(ast.NodeVisitor):
|
|
301
|
+
visited: set[Callable] = set()
|
|
302
|
+
|
|
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]] = []
|
|
307
|
+
self.assignments: dict[NodeIdentifier, str] = {}
|
|
308
|
+
self.func = func
|
|
309
|
+
self.func_globals = func.__globals__
|
|
310
|
+
self.should_search_module_pred = should_search_module_pred
|
|
311
|
+
logger.info(f"Analyzing function {func.__name__}")
|
|
312
|
+
|
|
313
|
+
def visit_Assign(self, node):
|
|
314
|
+
"""
|
|
315
|
+
Track assignments to variables that are exceptions or exception instances.
|
|
316
|
+
Since we don't know the type of the variable, we just store the name.
|
|
317
|
+
|
|
318
|
+
Handled scenarios:
|
|
319
|
+
- Case 1: Name assigment
|
|
320
|
+
exc = ValueError("An error occurred")
|
|
321
|
+
raise exc
|
|
322
|
+
|
|
323
|
+
- Other cases:
|
|
324
|
+
They should be handled by raise statement visitor.
|
|
325
|
+
They are assigned to a variable and assuming we visit and resolve it, should be enough.
|
|
326
|
+
But sometimes it's impossible to resolve the exception name from these nodes.
|
|
327
|
+
|
|
328
|
+
Example:
|
|
329
|
+
|
|
330
|
+
exc_callback = lambda: ValueError("An error occurred")
|
|
331
|
+
raise exc_callback()
|
|
332
|
+
"""
|
|
333
|
+
value = typing.cast(SupportedAstNodes, node.value)
|
|
334
|
+
targets = typing.cast(list[SupportedAstNodes], node.targets)
|
|
335
|
+
|
|
336
|
+
for target in targets:
|
|
337
|
+
if isinstance(target, ast.Name):
|
|
338
|
+
var_identifier = get_node_identifier(target)
|
|
339
|
+
var_name = _extract_node_name(value, self.assignments)
|
|
340
|
+
if var_name is not None:
|
|
341
|
+
self.assignments[var_identifier] = var_name
|
|
342
|
+
|
|
343
|
+
else:
|
|
344
|
+
# no need to log here. it would be very spammy
|
|
345
|
+
# basically anything can happen here because, and won't be very unexpected
|
|
346
|
+
# imagine a lambda function assigned to a variable and raised later.
|
|
347
|
+
# so we only support whatever cases handled by `_extract_node_name`
|
|
348
|
+
...
|
|
349
|
+
|
|
350
|
+
elif isinstance(
|
|
351
|
+
target,
|
|
352
|
+
(
|
|
353
|
+
ast.Call,
|
|
354
|
+
ast.FunctionDef,
|
|
355
|
+
ast.AsyncFunctionDef,
|
|
356
|
+
ast.ClassDef,
|
|
357
|
+
ast.Raise,
|
|
358
|
+
ast.Assign,
|
|
359
|
+
ast.Attribute,
|
|
360
|
+
ast.Await,
|
|
361
|
+
),
|
|
362
|
+
):
|
|
363
|
+
pass # should be handled by raise statement visitor
|
|
364
|
+
|
|
365
|
+
else:
|
|
366
|
+
not_supported(target)
|
|
367
|
+
|
|
368
|
+
self.generic_visit(node)
|
|
369
|
+
|
|
370
|
+
def visit_Raise(self, node):
|
|
371
|
+
"""
|
|
372
|
+
Handle raise statements and collect exceptions.
|
|
373
|
+
"""
|
|
374
|
+
exc = node.exc
|
|
375
|
+
if exc is None:
|
|
376
|
+
# Could not resolve exception name -> maybe just using 'raise' without an exception
|
|
377
|
+
# this edge case basically can raise anything
|
|
378
|
+
logger.debug("Please don't use 'raise' without declaring what to raise")
|
|
379
|
+
self.exceptions.append((None, node))
|
|
380
|
+
self.generic_visit(node)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
exc_variable_name = _extract_node_name(
|
|
384
|
+
typing.cast(SupportedAstNodes, exc), self.assignments
|
|
385
|
+
)
|
|
386
|
+
if exc_variable_name is None:
|
|
387
|
+
# We are raising an exception that is assigned to a variable we don't know
|
|
388
|
+
# Maybe global variable ?
|
|
389
|
+
self.exceptions.append((None, node))
|
|
390
|
+
self.generic_visit(node)
|
|
391
|
+
logger.debug(f"Failed to get exception name: {ast.dump(node)}")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
exc_type = _exctact_type(exc_variable_name, self.func_globals)
|
|
395
|
+
|
|
396
|
+
if exc_type:
|
|
397
|
+
self.exceptions.append((exc_type, node))
|
|
398
|
+
|
|
399
|
+
self.generic_visit(node)
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
def visit_Call(self, node):
|
|
403
|
+
"""
|
|
404
|
+
Handle function calls and recursively analyze called functions.
|
|
405
|
+
"""
|
|
406
|
+
func_obj = _resolve_function_from_call_node(
|
|
407
|
+
node, self.func_globals, self.func, self.assignments
|
|
408
|
+
)
|
|
409
|
+
if func_obj and inspect.isfunction(func_obj):
|
|
410
|
+
module = inspect.getmodule(func_obj)
|
|
411
|
+
if not module:
|
|
412
|
+
self.generic_visit(node)
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
if (
|
|
416
|
+
is_stdlib(module.__name__)
|
|
417
|
+
or self.should_search_module_pred(module.__name__) is False
|
|
418
|
+
):
|
|
419
|
+
self.generic_visit(node)
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
source = inspect.getsource(module)
|
|
423
|
+
tree = ast.parse(source)
|
|
424
|
+
|
|
425
|
+
founded_func = _find_in_module(
|
|
426
|
+
tree,
|
|
427
|
+
lambda n: (
|
|
428
|
+
isinstance(n, ast.AsyncFunctionDef)
|
|
429
|
+
or isinstance(n, ast.FunctionDef)
|
|
430
|
+
)
|
|
431
|
+
and n.name == func_obj.__name__,
|
|
432
|
+
)
|
|
433
|
+
if founded_func:
|
|
434
|
+
excs = _find_explicit_expection_recursively(
|
|
435
|
+
func_obj, founded_func, self.should_search_module_pred
|
|
436
|
+
)
|
|
437
|
+
self.exceptions.extend(excs)
|
|
438
|
+
else:
|
|
439
|
+
logger.debug(
|
|
440
|
+
f"Failed to find function {func_obj.__name__} in module {module.__name__}"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
self.generic_visit(node)
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
def visit_Attribute(self, node):
|
|
447
|
+
node_value = typing.cast(SupportedAstNodes, node.value)
|
|
448
|
+
|
|
449
|
+
if isinstance(node_value, ast.Name):
|
|
450
|
+
caller_node_name = self.assignments.get(get_node_identifier(node_value))
|
|
451
|
+
if not caller_node_name:
|
|
452
|
+
self.generic_visit(node)
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
caller_type = _exctact_type(caller_node_name, self.func_globals)
|
|
457
|
+
except Exception:
|
|
458
|
+
logger.critical(
|
|
459
|
+
f"Failed to resolve attribute chain: {caller_node_name}"
|
|
460
|
+
)
|
|
461
|
+
self.generic_visit(node)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if not caller_type:
|
|
465
|
+
self.generic_visit(node)
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
method_func = getattr(caller_type, node.attr, None)
|
|
469
|
+
if not method_func:
|
|
470
|
+
self.generic_visit(node)
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
module = inspect.getmodule(caller_type)
|
|
474
|
+
if not module:
|
|
475
|
+
self.generic_visit(node)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
source = inspect.getsource(module)
|
|
479
|
+
tree = ast.parse(source)
|
|
480
|
+
cls_ast = _find_in_module(
|
|
481
|
+
tree,
|
|
482
|
+
lambda n: isinstance(n, ast.ClassDef)
|
|
483
|
+
and n.name == caller_type.__name__,
|
|
484
|
+
)
|
|
485
|
+
if cls_ast:
|
|
486
|
+
casted_cls_ast = typing.cast(ast.ClassDef, cls_ast)
|
|
487
|
+
submethod = _find_method_node_in_cls(casted_cls_ast, node.attr)
|
|
488
|
+
if submethod:
|
|
489
|
+
excs = _find_explicit_expection_recursively(
|
|
490
|
+
method_func, submethod, self.should_search_module_pred
|
|
491
|
+
)
|
|
492
|
+
self.exceptions.extend(excs)
|
|
493
|
+
|
|
494
|
+
self.generic_visit(node)
|
|
495
|
+
return
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import inspect
|
|
3
|
+
import typing
|
|
4
|
+
from functools import reduce
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import Callable, Union
|
|
7
|
+
|
|
8
|
+
import fastapi
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.dependencies.models import Dependant
|
|
11
|
+
from fastapi.openapi.utils import get_openapi as _get_openapi
|
|
12
|
+
from fastapi.routing import APIRoute
|
|
13
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
14
|
+
|
|
15
|
+
from richapi.exc_parser.compiler import (
|
|
16
|
+
find_explicit_exceptions as _find_explicit_exceptions,
|
|
17
|
+
)
|
|
18
|
+
from richapi.exc_parser.protocol import (
|
|
19
|
+
BaseHTTPException,
|
|
20
|
+
HTTPExceptionSchema,
|
|
21
|
+
_generic_json_schema_builder,
|
|
22
|
+
)
|
|
23
|
+
from richapi.exceptions import BaseRichAPIException
|
|
24
|
+
|
|
25
|
+
logger = getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_openapi(
|
|
29
|
+
app: FastAPI,
|
|
30
|
+
openapi_json: dict,
|
|
31
|
+
) -> Callable:
|
|
32
|
+
return lambda: openapi_json
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def enrich_openapi(
|
|
36
|
+
app: FastAPI,
|
|
37
|
+
target_module: Union[list[str], str, None] = None,
|
|
38
|
+
open_api_getter: Callable[[FastAPI], dict] = lambda app: _get_openapi(
|
|
39
|
+
title=app.title,
|
|
40
|
+
version=app.version,
|
|
41
|
+
description=app.description,
|
|
42
|
+
routes=app.routes,
|
|
43
|
+
),
|
|
44
|
+
) -> Callable:
|
|
45
|
+
if target_module is None:
|
|
46
|
+
target_module = _find_module_name_where_app_defined_in(app)
|
|
47
|
+
if target_module is None or target_module == "__main__":
|
|
48
|
+
raise BaseRichAPIException(
|
|
49
|
+
"Could not determine the module where the FastAPI instance was created.\n"
|
|
50
|
+
"Please provide the module name as a string or list of strings."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _custom_openapi() -> dict:
|
|
54
|
+
if app.openapi_schema: # pragma: no cover
|
|
55
|
+
return app.openapi_schema
|
|
56
|
+
|
|
57
|
+
app.openapi_schema = compile_openapi_from_fastapi(
|
|
58
|
+
app, target_module, open_api_getter
|
|
59
|
+
)
|
|
60
|
+
return app.openapi_schema
|
|
61
|
+
|
|
62
|
+
return _custom_openapi
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compile_openapi_from_fastapi(
|
|
66
|
+
app: FastAPI,
|
|
67
|
+
target_module: Union[list[str], str],
|
|
68
|
+
open_api_getter: Callable[[FastAPI], dict] = lambda app: _get_openapi(
|
|
69
|
+
title=app.title,
|
|
70
|
+
version=app.version,
|
|
71
|
+
description=app.description,
|
|
72
|
+
routes=app.routes,
|
|
73
|
+
),
|
|
74
|
+
) -> dict:
|
|
75
|
+
openapi_schema = open_api_getter(app)
|
|
76
|
+
for route in app.routes:
|
|
77
|
+
if not isinstance(route, APIRoute):
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if route.include_in_schema:
|
|
81
|
+
exceptions = _extract_starlette_exceptions(route, target_module)
|
|
82
|
+
|
|
83
|
+
_fill_openapi_with_excpetions(openapi_schema, route, exceptions)
|
|
84
|
+
|
|
85
|
+
return openapi_schema
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_module_name_where_app_defined_in(app: FastAPI) -> Union[str, None]:
|
|
89
|
+
frame = inspect.currentframe()
|
|
90
|
+
target_module = None
|
|
91
|
+
while frame:
|
|
92
|
+
for var_name, var_value in frame.f_globals.items():
|
|
93
|
+
if isinstance(var_value, FastAPI) and var_value is app:
|
|
94
|
+
target_module = frame.f_globals["__name__"]
|
|
95
|
+
break
|
|
96
|
+
if target_module:
|
|
97
|
+
break
|
|
98
|
+
frame = frame.f_back
|
|
99
|
+
|
|
100
|
+
return target_module
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve_status_and_detail_from_exc_type(
|
|
104
|
+
exc_type: type[Exception],
|
|
105
|
+
ast_raise: ast.Raise,
|
|
106
|
+
) -> Union[tuple[int, Union[str, None]], None]:
|
|
107
|
+
if hasattr(exc_type, "status_code") and hasattr(exc_type, "detail"):
|
|
108
|
+
return (int(exc_type.status_code), exc_type.detail) # type: ignore
|
|
109
|
+
|
|
110
|
+
ast_exc = ast_raise.exc
|
|
111
|
+
if not ast_exc:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
ast_kwargs = getattr(ast_exc, "keywords", None)
|
|
115
|
+
kwargs = {}
|
|
116
|
+
found_status_code: Union[int, None] = None
|
|
117
|
+
found_detail: Union[str, None] = None
|
|
118
|
+
|
|
119
|
+
if isinstance(ast_kwargs, list):
|
|
120
|
+
for kwarg in ast_kwargs:
|
|
121
|
+
if not hasattr(kwarg, "arg"):
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# that's the keyword argument in StarletteHTTPException
|
|
125
|
+
if kwarg.arg == "status_code":
|
|
126
|
+
status_code_value = kwarg.value
|
|
127
|
+
|
|
128
|
+
if isinstance(status_code_value, ast.Constant):
|
|
129
|
+
found_status_code: Union[int, None] = int(status_code_value.value)
|
|
130
|
+
|
|
131
|
+
elif isinstance(status_code_value, ast.Attribute):
|
|
132
|
+
# maybe used like: status.HTTP_404_NOT_FOUND
|
|
133
|
+
left_hand_side = status_code_value.attr
|
|
134
|
+
found_status_code = getattr(fastapi.status, left_hand_side, None)
|
|
135
|
+
|
|
136
|
+
elif isinstance(status_code_value, ast.Name):
|
|
137
|
+
# maybe used like: HTTP_404_NOT_FOUND
|
|
138
|
+
left_hand_side = status_code_value.id
|
|
139
|
+
found_status_code = getattr(fastapi.status, left_hand_side, None)
|
|
140
|
+
|
|
141
|
+
elif kwarg.arg == "detail":
|
|
142
|
+
detail_value = kwarg.value
|
|
143
|
+
if isinstance(detail_value, ast.Constant):
|
|
144
|
+
found_detail: Union[str, None] = detail_value.value
|
|
145
|
+
|
|
146
|
+
else:
|
|
147
|
+
if isinstance(kwarg.value, ast.Constant):
|
|
148
|
+
kwargs[kwarg.arg] = kwarg.value.value
|
|
149
|
+
|
|
150
|
+
if found_detail and found_status_code:
|
|
151
|
+
return found_status_code, found_detail
|
|
152
|
+
|
|
153
|
+
ast_args = getattr(ast_exc, "args", None)
|
|
154
|
+
args = []
|
|
155
|
+
|
|
156
|
+
if isinstance(ast_args, list):
|
|
157
|
+
arg_values = [arg.value for arg in ast_args if isinstance(arg, ast.Constant)]
|
|
158
|
+
|
|
159
|
+
# we have some non-constant args and some constant args
|
|
160
|
+
if arg_values and len(arg_values) != len(ast_args):
|
|
161
|
+
status_arg = [arg for arg in arg_values if isinstance(arg, int)]
|
|
162
|
+
# We can't construct the exception object anymore
|
|
163
|
+
if found_status_code: # we have found status code in kwargs
|
|
164
|
+
return found_status_code, None
|
|
165
|
+
elif status_arg: # fall back to finding any integer value
|
|
166
|
+
return status_arg[0], None
|
|
167
|
+
return None # give up
|
|
168
|
+
|
|
169
|
+
args.extend(arg_values)
|
|
170
|
+
else:
|
|
171
|
+
logger.debug(
|
|
172
|
+
f"Could not extract args from exception {exc_type}: {ast.dump(ast_exc)}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
exc = exc_type(*args, **kwargs)
|
|
177
|
+
if hasattr(exc, "status_code") and hasattr(exc, "detail"):
|
|
178
|
+
return (int(exc.status_code), exc.detail) # type: ignore
|
|
179
|
+
|
|
180
|
+
if found_status_code:
|
|
181
|
+
return found_status_code, None
|
|
182
|
+
|
|
183
|
+
except Exception as err:
|
|
184
|
+
logger.debug(
|
|
185
|
+
f"Could not construct exception {exc_type} with args: {args} and kwargs: {kwargs}",
|
|
186
|
+
exc_info=err,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if found_status_code:
|
|
190
|
+
return found_status_code, None
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _extract_json_schema(
|
|
196
|
+
ast_node: ast.Raise, exc: type[StarletteHTTPException]
|
|
197
|
+
) -> Union[HTTPExceptionSchema, None]:
|
|
198
|
+
status_detail_pair = _resolve_status_and_detail_from_exc_type(exc, ast_node)
|
|
199
|
+
if status_detail_pair is None:
|
|
200
|
+
logger.debug(f"Could not resolve status code and detail for exception {exc}")
|
|
201
|
+
return None
|
|
202
|
+
status_code, detail = status_detail_pair
|
|
203
|
+
schema = _generic_json_schema_builder(exc, detail, status_code)
|
|
204
|
+
return schema
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _fill_openapi_with_excpetions(
|
|
208
|
+
api_schema: dict,
|
|
209
|
+
route: APIRoute,
|
|
210
|
+
exceptions: list[tuple[type[StarletteHTTPException], ast.Raise]],
|
|
211
|
+
) -> None:
|
|
212
|
+
for exc, ast_node in exceptions:
|
|
213
|
+
if hasattr(exc, "get_json_schema"):
|
|
214
|
+
casted_exc = typing.cast(type[BaseHTTPException], exc)
|
|
215
|
+
schema = casted_exc.get_json_schema()
|
|
216
|
+
else:
|
|
217
|
+
schema = _extract_json_schema(ast_node, exc)
|
|
218
|
+
|
|
219
|
+
if schema is None:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
str_status_code = str(schema.status_code)
|
|
223
|
+
component_schemas = api_schema.get("components", {}).get("schemas", {})
|
|
224
|
+
if schema.schema_name not in component_schemas:
|
|
225
|
+
if "components" not in api_schema:
|
|
226
|
+
api_schema["components"] = {"schemas": {}}
|
|
227
|
+
if "schemas" not in api_schema["components"]:
|
|
228
|
+
api_schema["components"]["schemas"] = {}
|
|
229
|
+
|
|
230
|
+
api_schema["components"]["schemas"][schema.schema_name] = (
|
|
231
|
+
schema.response_schema_json
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
path = route.path
|
|
235
|
+
methods = [method.lower() for method in getattr(route, "methods")]
|
|
236
|
+
|
|
237
|
+
for method in methods:
|
|
238
|
+
this_status_resp = api_schema["paths"][path][method]["responses"].get(
|
|
239
|
+
str_status_code, None
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if this_status_resp is None:
|
|
243
|
+
api_schema["paths"][path][method]["responses"][str_status_code] = {
|
|
244
|
+
"description": schema.schema_desc,
|
|
245
|
+
"content": {
|
|
246
|
+
"application/json": {
|
|
247
|
+
"schema": {
|
|
248
|
+
"$ref": f"#/components/schemas/{schema.schema_name}"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
else:
|
|
255
|
+
# HANDLE UNION
|
|
256
|
+
this_schema = this_status_resp["content"]["application/json"]["schema"]
|
|
257
|
+
|
|
258
|
+
if "anyOf" not in this_schema:
|
|
259
|
+
this_status_resp["content"]["application/json"]["schema"] = {
|
|
260
|
+
"anyOf": [
|
|
261
|
+
this_schema,
|
|
262
|
+
{"$ref": f"#/components/schemas/{schema.schema_name}"},
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
this_schema["anyOf"].append(
|
|
268
|
+
{"$ref": f"#/components/schemas/{schema.schema_name}"}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def build_dependency_tree(dependant: Dependant) -> list[Callable]:
|
|
273
|
+
"""
|
|
274
|
+
Traverse the dependency tree and find all functions ('call' attribute)
|
|
275
|
+
from the dependencies that include 'func' in their name or signature.
|
|
276
|
+
"""
|
|
277
|
+
dependencies_with_func: list[Callable] = []
|
|
278
|
+
if dependant.call:
|
|
279
|
+
dependencies_with_func.append(dependant.call)
|
|
280
|
+
for inner_dep in dependant.dependencies:
|
|
281
|
+
dependencies_with_func.extend(build_dependency_tree(inner_dep))
|
|
282
|
+
return dependencies_with_func
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
T = typing.TypeVar("T")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def flatten(to_be_flatten: list[list[T]]) -> list[T]:
|
|
289
|
+
return list(reduce(lambda x, y: x + y, to_be_flatten, []))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _extract_starlette_exceptions(
|
|
293
|
+
route: APIRoute, target_module: Union[list[str], str]
|
|
294
|
+
) -> list[tuple[type[StarletteHTTPException], ast.Raise]]:
|
|
295
|
+
dependency_tree = build_dependency_tree(route.dependant)
|
|
296
|
+
exceptions = flatten(
|
|
297
|
+
[_find_explicit_exceptions(dep, target_module) for dep in dependency_tree]
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
starlette_http_exc_types = [
|
|
301
|
+
exc
|
|
302
|
+
for exc in exceptions
|
|
303
|
+
if exc[0] is not None and issubclass(exc[0], StarletteHTTPException)
|
|
304
|
+
]
|
|
305
|
+
casted_exceptions = typing.cast(
|
|
306
|
+
list[tuple[type[StarletteHTTPException], ast.Raise]],
|
|
307
|
+
starlette_http_exc_types,
|
|
308
|
+
)
|
|
309
|
+
return casted_exceptions
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
from pydantic import BaseModel, create_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HTTPExceptionSchema(BaseModel):
|
|
10
|
+
response_schema_json: dict[str, Any]
|
|
11
|
+
"""Json schema representing the respone of the exception"""
|
|
12
|
+
schema_name: str
|
|
13
|
+
"""Name of the schema"""
|
|
14
|
+
schema_desc: str
|
|
15
|
+
"""Description of the schema"""
|
|
16
|
+
status_code: int
|
|
17
|
+
"""Status code of the exception"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseHTTPException(HTTPException):
|
|
21
|
+
status_code: int
|
|
22
|
+
detail: str
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
super().__init__(status_code=self.status_code, detail=self.detail)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def get_json_schema(cls) -> HTTPExceptionSchema:
|
|
29
|
+
value = _generic_json_schema_builder(cls)
|
|
30
|
+
assert (
|
|
31
|
+
value is not None
|
|
32
|
+
) # should never happen because status code is always set
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _generic_json_schema_builder(
|
|
37
|
+
cls: type[Exception],
|
|
38
|
+
detail: str | None = None,
|
|
39
|
+
status_code: int | None = None,
|
|
40
|
+
) -> HTTPExceptionSchema | None:
|
|
41
|
+
name = f"{cls.__name__}ErrorSchema"
|
|
42
|
+
|
|
43
|
+
if hasattr(cls, "detail") and cls.detail and detail is None: # type: ignore
|
|
44
|
+
detail = cls.detail # type: ignore
|
|
45
|
+
|
|
46
|
+
if hasattr(cls, "status_code") and cls.status_code and status_code is None: # type: ignore
|
|
47
|
+
status_code = cls.status_code # type: ignore
|
|
48
|
+
|
|
49
|
+
if status_code is None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if detail is not None:
|
|
53
|
+
detail_type = Literal[detail] # type: ignore
|
|
54
|
+
else:
|
|
55
|
+
detail_type = str
|
|
56
|
+
|
|
57
|
+
schema = create_model(name, detail=(detail_type, ...))
|
|
58
|
+
|
|
59
|
+
return HTTPExceptionSchema(
|
|
60
|
+
response_schema_json=schema.model_json_schema(),
|
|
61
|
+
schema_name=name,
|
|
62
|
+
status_code=status_code,
|
|
63
|
+
schema_desc=detail or "No description provided", # type: ignore
|
|
64
|
+
)
|
richapi/exceptions.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: richapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find HTTPExceptions and turn them into documented responses!
|
|
5
|
+
Project-URL: repository, https://github.com/ManiMozaffar/richapi
|
|
6
|
+
Project-URL: Homepage, https://github.com/ManiMozaffar/richapi
|
|
7
|
+
Project-URL: Documentation, https://github.com/ManiMozaffar/richapi
|
|
8
|
+
Project-URL: Source, https://github.com/ManiMozaffar/richapi
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: loguru>=0.7.2
|
|
12
|
+
Requires-Dist: typer>=0.12.5
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
### Installation
|
|
16
|
+
|
|
17
|
+
To install the package, run the following command:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install richapi
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Help
|
|
24
|
+
|
|
25
|
+
See [documentation](https://manimozaffar.github.io/richapi/latest/) for more details.
|
|
26
|
+
|
|
27
|
+
### Use Cases
|
|
28
|
+
|
|
29
|
+
RichAPI is designed to simplify FastAPI projects by automatically generating documented HTTP responses from HTTPException without extra effort! 🎉
|
|
30
|
+
|
|
31
|
+
This package is planned to have more features for now that might be essential for any FastAPI project later.
|
|
32
|
+
|
|
33
|
+
What would you see WITHOUT RichAPI:
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
After compiling OpenAPI WITH RichAPI (extended with all possible exceptions coming from your router):
|
|
37
|
+

|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
richapi/__init__.py,sha256=zCC0XGgbQxAlKKNWHwpZyO4pqgtLDsGbxm-JDHG0FlU,193
|
|
2
|
+
richapi/__version__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
richapi/cli.py,sha256=d2DPwp5tavypSszPHYuAPOA2ktO1nxtOTnHqHDlK1KM,2536
|
|
4
|
+
richapi/exceptions.py,sha256=DQU5gj8veUHhn7BxrhFH-3lQzIfuJcWqlJE2fKh-U60,97
|
|
5
|
+
richapi/exc_parser/__init__.py,sha256=M88S8H5DBfYUt9odbUJH62gliiRwMS4TnN9O5AAGFS0,237
|
|
6
|
+
richapi/exc_parser/compiler.py,sha256=e78HwSKTPHqpmtv8YVk0sTlr89BnmWvKCe3NAsrdN44,15924
|
|
7
|
+
richapi/exc_parser/openapi.py,sha256=WLSdp-obrM3sshDaVk8JzWNQpxxbLCM1w5UA7zMSFfA,10540
|
|
8
|
+
richapi/exc_parser/protocol.py,sha256=xXLSV9djwd4DtS4DN-iHKQB1QgeDIvr_nr6KwMe40ck,1829
|
|
9
|
+
richapi-0.1.0.dist-info/METADATA,sha256=lBvINz74br9GPfafp5Cy3c361PyxT5vScMclOjb7QTw,1228
|
|
10
|
+
richapi-0.1.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
11
|
+
richapi-0.1.0.dist-info/entry_points.txt,sha256=BjKovYW30iyw_HgNL4kWqNRtg6RDpVUnJ3WzZFO5aDA,44
|
|
12
|
+
richapi-0.1.0.dist-info/RECORD,,
|