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 ADDED
@@ -0,0 +1,4 @@
1
+ from richapi.exc_parser.openapi import enrich_openapi, load_openapi
2
+ from richapi.exc_parser.protocol import BaseHTTPException
3
+
4
+ __all__ = ["enrich_openapi", "load_openapi", "BaseHTTPException"]
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,4 @@
1
+ from richapi.exc_parser.openapi import enrich_openapi, load_openapi
2
+ from richapi.exc_parser.protocol import BaseHTTPException, HTTPExceptionSchema
3
+
4
+ __all__ = ["enrich_openapi", "load_openapi", "BaseHTTPException", "HTTPExceptionSchema"]
@@ -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,2 @@
1
+ class BaseRichAPIException(Exception):
2
+ """Base class for all exceptions in richapi module"""
@@ -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
+ ![Screenshot](docs/images/without-richapi.png)
35
+
36
+ After compiling OpenAPI WITH RichAPI (extended with all possible exceptions coming from your router):
37
+ ![Screenshot](docs/images/with-richapi.png)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.25.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ richapi = richapi.cli:app