richapi 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {richapi-0.2.1 → richapi-0.3.1}/PKG-INFO +1 -1
  2. {richapi-0.2.1 → richapi-0.3.1}/docs/advance.md +1 -4
  3. {richapi-0.2.1 → richapi-0.3.1}/docs/conventions.md +27 -1
  4. richapi-0.3.1/docs/images/custom-exception.png +0 -0
  5. {richapi-0.2.1 → richapi-0.3.1}/docs/index.md +51 -4
  6. {richapi-0.2.1 → richapi-0.3.1}/pyproject.toml +1 -1
  7. richapi-0.3.1/richapi/__init__.py +11 -0
  8. richapi-0.3.1/richapi/__version__.py +1 -0
  9. {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/compiler.py +218 -88
  10. richapi-0.3.1/richapi/exc_parser/handler.py +22 -0
  11. {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/openapi.py +5 -0
  12. {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/protocol.py +32 -3
  13. richapi-0.3.1/tests/app/__init__.py +0 -0
  14. richapi-0.3.1/tests/app/foo1.py +10 -0
  15. richapi-0.3.1/tests/test_builder_pattern.py +68 -0
  16. richapi-0.3.1/tests/test_class.py +72 -0
  17. richapi-0.3.1/tests/test_compiler.py +12 -0
  18. richapi-0.3.1/tests/test_custom_exc.py +52 -0
  19. richapi-0.3.1/tests/test_duplicated_function.py +0 -0
  20. richapi-0.3.1/tests/test_import_from_other_module.py +20 -0
  21. richapi-0.3.1/tests/test_instance_attr_call.py +82 -0
  22. richapi-0.2.1/richapi/__init__.py +0 -4
  23. richapi-0.2.1/richapi/__version__.py +0 -1
  24. {richapi-0.2.1 → richapi-0.3.1}/.envrc +0 -0
  25. {richapi-0.2.1 → richapi-0.3.1}/.github/workflows/deploy-docs-on-demand.yml +0 -0
  26. {richapi-0.2.1 → richapi-0.3.1}/.github/workflows/main.yml +0 -0
  27. {richapi-0.2.1 → richapi-0.3.1}/.github/workflows/on-release-main.yml +0 -0
  28. {richapi-0.2.1 → richapi-0.3.1}/.gitignore +0 -0
  29. {richapi-0.2.1 → richapi-0.3.1}/README.md +0 -0
  30. {richapi-0.2.1 → richapi-0.3.1}/deploy_docs.py +0 -0
  31. {richapi-0.2.1 → richapi-0.3.1}/docs/images/with-richapi.png +0 -0
  32. {richapi-0.2.1 → richapi-0.3.1}/docs/images/without-richapi.png +0 -0
  33. {richapi-0.2.1 → richapi-0.3.1}/docs/implementation.md +0 -0
  34. {richapi-0.2.1 → richapi-0.3.1}/makefile +0 -0
  35. {richapi-0.2.1 → richapi-0.3.1}/mkdocs.yml +0 -0
  36. {richapi-0.2.1 → richapi-0.3.1}/richapi/cli.py +0 -0
  37. {richapi-0.2.1 → richapi-0.3.1}/richapi/exc_parser/__init__.py +0 -0
  38. {richapi-0.2.1 → richapi-0.3.1}/richapi/exceptions.py +0 -0
  39. {richapi-0.2.1 → richapi-0.3.1}/tests/__init__.py +0 -0
  40. {richapi-0.2.1 → richapi-0.3.1}/tests/test_exc_raising_cases.py +0 -0
  41. {richapi-0.2.1 → richapi-0.3.1}/tests/test_exception_edge_cases.py +0 -0
  42. {richapi-0.2.1 → richapi-0.3.1}/tests/test_fastapi_auth_functions.py +0 -0
  43. {richapi-0.2.1 → richapi-0.3.1}/tox.ini +0 -0
  44. {richapi-0.2.1 → richapi-0.3.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: richapi
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Find HTTPExceptions and turn them into documented responses!
5
5
  Project-URL: repository, https://github.com/ManiMozaffar/richapi
6
6
  Project-URL: Homepage, https://github.com/ManiMozaffar/richapi
@@ -4,9 +4,6 @@ This is guideline for how to use the library in more advanced ways.
4
4
 
5
5
  ### Customization of the OpenAPI schema
6
6
 
7
- Sometimes, your exceptions is more than 'details'.
8
- In that case, you can very explicitly define the exception JSON schema inside the exception class. You may even use Pydantic to define the schema dynamically so you don't need to write the schema manually.
9
-
10
7
  All you need to do, is to return type HTTPExceptionSchema correctly on the method `get_json_schema` of your exception class which you inherited from `BaseHTTPException`.
11
8
 
12
9
  Imagine you are writing an exception that is returning a user balance and a time to retry again a failed bank transaction, you can define the schema like this:
@@ -33,7 +30,7 @@ class YourException(BaseHTTPException):
33
30
 
34
31
  ```
35
32
 
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.
33
+ If your intention is to have a custom response schema, then recommended way is to use [custom exception feature](index.md#customization-of-exception).
37
34
 
38
35
  ### Finding exceptions in other modules
39
36
 
@@ -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` or `richapi.RichHTTPException`
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.
@@ -89,3 +89,29 @@ Keep it simple, keep the exception type next to raise keyword; without any dynam
89
89
  !!! info "Expected Behavior"
90
90
 
91
91
  Library **MIGHT skip** adding the exception to the OpenAPI schema depending on how complicated the exception raising is.
92
+
93
+ ## Always Provide Type Annotations For Class/Instance Attributes And Function Arguments
94
+
95
+ To enable accurate type resolution during static analysis, always provide type annotations for your variables, function parameters, and return types. Without type annotations, it's nearly impossible for the library to infer types correctly, which may lead to false positives or negatives. That stands for class attributes and function arguments.
96
+
97
+ Example:
98
+
99
+ ```Python hl_lines="6 12"
100
+ class Foo:
101
+ def foo(self) -> None:
102
+ raise HTTPException(status_code=400, detail="This is not allowed")
103
+
104
+ class Baz:
105
+ foo: Foo # this is very important to have type annotations for class/instance attributes
106
+
107
+ def __init__(self, foo: Foo):
108
+ self.foo = foo
109
+
110
+ def foo(self) -> None:
111
+ # if you don't type annotate, RichAPI doesn't have any idea about the type of self.fo
112
+ self.foo.foo()
113
+ ```
114
+
115
+ !!! info "Expected Behavior"
116
+
117
+ Library **WILL skip** adding exceptions to the OpenAPI schema if it cannot resolve types due to missing annotations.
@@ -46,6 +46,57 @@ with open("openapi.json") as open_api_json:
46
46
 
47
47
  I personally recommend the second way, because it is better to have it compiled and saved to file, because there are static analysis running on the OpanAPI generator and your source code which isn't very ideal to be running in production.
48
48
 
49
+ ### Customization of Exception
50
+
51
+ Sometimes, your exceptions is more than 'details'. You may want to put some extra information for the client to understand and parse the error better. In that case, please follow these steps:
52
+
53
+ 1. Add the exception handler from richapi to your FastAPI app.
54
+ 2. Inhert from `richapi.RichHTTPException`, and make it a dataclass.
55
+ 3. To override status, simply set the `status_code` attribute on the class without type annotation.
56
+ 4. Raise that exception somewhere in your code and you'll see the schema on swagger!
57
+
58
+ Example:
59
+
60
+ ```python
61
+ import fastapi
62
+ import fastapi.security
63
+ from pydantic import BaseModel
64
+ from pydantic.dataclasses import dataclass
65
+
66
+ from richapi.exc_parser.handler import add_exc_handler
67
+ from richapi.exc_parser.openapi import enrich_openapi
68
+ from richapi.exc_parser.protocol import RichHTTPException
69
+
70
+ app = fastapi.FastAPI()
71
+ app.openapi = enrich_openapi(app)
72
+ add_exc_handler(app)
73
+
74
+
75
+ @dataclass
76
+ class NotEnoughBalance(RichHTTPException):
77
+ """Custom exception for user balance"""
78
+
79
+ user_id: int
80
+ balance: float
81
+ status_code = 403
82
+
83
+
84
+ class SuccessResponse(BaseModel):
85
+ balance: float
86
+
87
+
88
+ @app.post("/payment")
89
+ async def make_payment() -> SuccessResponse:
90
+ raise NotEnoughBalance(user_id=1, balance=0.5)
91
+ ```
92
+
93
+ Final Result:
94
+ ![Screenshot](./images/custom-exception.png)
95
+
96
+ ### Conventions (VERY IMPORTANT)
97
+
98
+ There are some conventions about how to raise exceptions and etc. You may read the [conventions](conventions.md) to know more about them. The way library works is that it is a mini compiler using Abstract Syntax Tree and functions global to extract exceptions and their responses, and then it generates the OpenAPI schema from them. So there are limitations to what it can do, and you may need to follow the conventions to get the best out of it. Anything that is not following the conventions MAY be ignored by the compiler, and there is no plan to support them in the future, because it is not possible to support all the ways you can raise exceptions in Python... To give you an example, you may download a source code from internet and then use 'eval' function to run it dynamically, and there is no way to know what exceptions you may raise in that code, so it is impossible to support all the ways you can raise exceptions in Python.
99
+
49
100
  ### Example
50
101
 
51
102
  Imagine this application;
@@ -105,7 +156,3 @@ What would you see WITHOUT RichAPI:
105
156
 
106
157
  After compiling OpenAPI WITH RichAPI (extended with all possible exceptions coming from your router):
107
158
  ![Screenshot](./images/with-richapi.png)
108
-
109
- ### Conventions (VERY IMPORTANT)
110
-
111
- There are some conventions about how to raise exceptions and etc. You may read the [conventions](conventions.md) to know more about them. The way library works is that it is a mini compiler using Abstract Syntax Tree and functions global to extract exceptions and their responses, and then it generates the OpenAPI schema from them. So there are limitations to what it can do, and you may need to follow the conventions to get the best out of it. Anything that is not following the conventions MAY be ignored by the compiler, and there is no plan to support them in the future, because it is not possible to support all the ways you can raise exceptions in Python... To give you an example, you may download a source code from internet and then use 'eval' function to run it dynamically, and there is no way to know what exceptions you may raise in that code, so it is impossible to support all the ways you can raise exceptions in Python.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "richapi"
3
- version = "0.2.1"
3
+ version = "0.3.1"
4
4
  description = "Find HTTPExceptions and turn them into documented responses!"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,11 @@
1
+ from richapi.exc_parser.handler import add_exc_handler
2
+ from richapi.exc_parser.openapi import enrich_openapi, load_openapi
3
+ from richapi.exc_parser.protocol import BaseHTTPException, RichHTTPException
4
+
5
+ __all__ = [
6
+ "enrich_openapi",
7
+ "load_openapi",
8
+ "BaseHTTPException",
9
+ "RichHTTPException",
10
+ "add_exc_handler",
11
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -4,9 +4,11 @@ import inspect
4
4
  import logging
5
5
  import sysconfig
6
6
  import typing
7
- from functools import lru_cache
8
7
  from importlib.util import find_spec
9
- from typing import Callable, List, NewType, Optional, Tuple, Union
8
+ from types import ModuleType
9
+ from typing import Annotated, Callable, Generic, List, NewType, Optional, Tuple, Union
10
+
11
+ import typing_extensions
10
12
 
11
13
  logger = logging.getLogger(__name__)
12
14
 
@@ -88,17 +90,20 @@ def get_func_name(func: Callable) -> str:
88
90
 
89
91
 
90
92
  def _find_all_class_exceptions(
91
- cls: type,
93
+ cls: Union[type, Callable],
92
94
  to_filter_predicate: Callable[[str], bool],
93
95
  tree: Optional[ast.AST] = None,
94
96
  ):
95
97
  try:
96
98
  tree = tree or ast.parse(inspect.getsource(cls))
97
99
  except Exception as error:
98
- logger.exception(f"Failed to parse source code for {cls.__name__}.", exc_info=error)
100
+ cls_name = cls.__name__ if hasattr(cls, "__name__") else cls.__str__()
101
+ logger.exception(f"Failed to parse source code for {cls_name}.", exc_info=error)
99
102
  return []
100
103
 
101
- result = _find_explicit_expection_recursively(cls.__init__, to_filter_predicate, tree)
104
+ result = _find_explicit_expection_recursively(
105
+ cls.__init__, to_filter_predicate, tree
106
+ )
102
107
  if hasattr(cls, "__call__"):
103
108
  # Not sure if __call__ was called or init, so check for both
104
109
 
@@ -110,13 +115,14 @@ def _find_all_class_exceptions(
110
115
  )
111
116
  )
112
117
 
113
- ExceptionFinder.visited = set() # clean up memory
114
118
  return result
115
119
 
116
120
 
117
121
  def is_absolutely_callable_object(obj: Callable) -> bool:
118
122
  return (
119
- inspect.isclass(type(obj)) and (not inspect.isfunction(obj)) and (not inspect.ismethod(obj))
123
+ inspect.isclass(type(obj))
124
+ and (not inspect.isfunction(obj))
125
+ and (not inspect.ismethod(obj))
120
126
  )
121
127
 
122
128
 
@@ -126,25 +132,24 @@ def _find_explicit_expection_recursively(
126
132
  tree: Optional[ast.AST] = None,
127
133
  ) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
128
134
  if func_obj in ExceptionFinder.visited:
129
- return []
135
+ return ExceptionFinder.visited[func_obj]
130
136
 
131
137
  module = inspect.getmodule(func_obj)
132
- if module and is_stdlib(module.__name__):
133
- return []
134
-
135
- if module and to_filter_predicate(module.__name__) is False:
138
+ if module is None or _should_be_visited(module, to_filter_predicate) is False:
136
139
  return []
137
140
 
138
141
  if is_absolutely_callable_object(func_obj):
139
- cls_module = inspect.getmodule(type(func_obj))
142
+ class_type: Union[type, Callable] = (
143
+ obj_type if (obj_type := type(func_obj)) is not type else func_obj
144
+ )
145
+
146
+ cls_module = inspect.getmodule(class_type)
140
147
  if cls_module and is_stdlib(cls_module.__name__):
141
148
  return []
142
149
  if cls_module and to_filter_predicate(cls_module.__name__) is False:
143
150
  return []
144
151
 
145
- return _find_all_class_exceptions(type(func_obj), to_filter_predicate, tree)
146
-
147
- ExceptionFinder.visited.add(func_obj)
152
+ return _find_all_class_exceptions(class_type, to_filter_predicate, tree)
148
153
 
149
154
  if tree is None:
150
155
  try:
@@ -169,8 +174,11 @@ def _find_explicit_expection_recursively(
169
174
  try:
170
175
  finder.visit(tree)
171
176
  except Exception as error:
172
- logger.exception(f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error)
177
+ logger.exception(
178
+ f"Failed to analyze function {get_func_name(func_obj)}.", exc_info=error
179
+ )
173
180
 
181
+ ExceptionFinder.visited[func_obj] = finder.exceptions
174
182
  return finder.exceptions
175
183
 
176
184
 
@@ -220,12 +228,52 @@ def _resolve_full_attribute_path(node: ast.AST) -> Union[str, None]:
220
228
  return None
221
229
 
222
230
 
223
- def _resolve_function_from_call_node(
231
+ def _retrieve_from_annotations(func: Callable, var_name: str) -> Optional[type]:
232
+ result = func.__annotations__.get(var_name, None)
233
+ if result is None:
234
+ return None
235
+
236
+ origin = typing.get_origin(result)
237
+ if origin is Annotated:
238
+ return result.__origin__
239
+ elif origin is not None and origin != Generic:
240
+ return origin
241
+ return result
242
+
243
+
244
+ def _resolve_type_from_assigment(
245
+ resolved_value: Union[str, type, None],
246
+ assignments: dict[NodeIdentifier, str],
247
+ func: Callable,
248
+ ):
249
+ if resolved_value is None:
250
+ return None
251
+
252
+ # because assigment are stored as string
253
+ # ! A string type could be also called like str("Foo").lower() so we fallback to parent_attr in case it remains as string
254
+
255
+ if isinstance(resolved_value, str):
256
+ resolved_value = func.__globals__.get(resolved_value, resolved_value)
257
+
258
+ if isinstance(resolved_value, str):
259
+ resolved_value = assignments.get(NodeIdentifier(resolved_value), resolved_value)
260
+
261
+ if isinstance(resolved_value, str):
262
+ resolved_value = (
263
+ _retrieve_from_annotations(func, resolved_value) or resolved_value
264
+ )
265
+
266
+ if isinstance(resolved_value, str): # last fallback
267
+ resolved_value = _exctact_type(resolved_value, func.__globals__)
268
+
269
+ return resolved_value
270
+
271
+
272
+ def _resolve_functions_from_call_node(
224
273
  call_node: ast.Call,
225
- func_globals: dict,
226
274
  func: Callable,
227
275
  assignments: dict[NodeIdentifier, str],
228
- ):
276
+ ) -> list[type]:
229
277
  """
230
278
  Attempt to resolve the function object from a call node.
231
279
  Handles both simple function calls and method calls.
@@ -234,7 +282,7 @@ def _resolve_function_from_call_node(
234
282
 
235
283
  if isinstance(node, ast.Name):
236
284
  # Simple function call: func()
237
- value = func_globals.get(node.id, None)
285
+ value = func.__globals__.get(node.id, None)
238
286
 
239
287
  if value is None:
240
288
  value = getattr(builtins, node.id, None)
@@ -242,50 +290,99 @@ def _resolve_function_from_call_node(
242
290
  if value is None:
243
291
  logger.debug(f"Function '{node.id}' not found in {func.__name__}")
244
292
 
245
- return value
293
+ if value is not None:
294
+ return [value]
295
+ return []
246
296
 
247
297
  elif isinstance(node, ast.Attribute):
248
298
  attr_chain = _resolve_full_attribute_path(node)
249
299
  if attr_chain is None:
250
- logger.debug(f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}")
251
- return None
300
+ logger.debug(
301
+ f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
302
+ )
303
+ return []
252
304
 
253
305
  try:
254
306
  parts = attr_chain.split(".")
255
307
  parent_part = parts[0]
256
308
 
257
- parent_attr = func_globals.get(parent_part, None)
309
+ parent_attr = func.__globals__.get(parent_part, None)
310
+
311
+ if parent_attr is None:
312
+ node_id = NodeIdentifier(parent_part)
313
+ parent_attr = assignments.get(node_id, None)
258
314
 
259
315
  if parent_attr is None:
260
316
  if parent_part in func.__annotations__:
261
- return func.__annotations__[parent_part]
317
+ result = _retrieve_from_annotations(func, parent_part)
318
+ return [] if result is None else [result]
262
319
 
263
320
  parent_attr = getattr(builtins, parent_part, None)
264
321
 
265
322
  if parent_attr is None:
266
- node_id = NodeIdentifier(parent_part)
267
- parent_attr = assignments.get(node_id, None)
268
-
269
- if parent_attr is None:
270
- if parent_part == "self": # don't need to log -> we already know this is class
271
- return None
323
+ # use case where you're calling self.foo.do_something(), where self.foo is a class attribute that is a class itself
324
+ if parent_part == "self" or parent_part == "cls":
325
+ property_name = parts[1]
326
+ # ['class_name', 'method_name']
327
+ qual_names = func.__qualname__.split(".")
328
+ # ['class_name']
329
+ func_name_index = qual_names.index(func.__name__)
330
+ # 'class_name'
331
+ cls_name = qual_names[func_name_index - 1]
332
+ # class_name (type)
333
+ cls: Union[Callable, None] = func.__globals__.get(cls_name, None)
334
+
335
+ if cls is None:
336
+ logger.debug(
337
+ f"Failed to resolve parent class {cls} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
338
+ )
339
+ return []
340
+ attr_class = _retrieve_from_annotations(cls, property_name)
341
+ if attr_class is None: # part of convention
342
+ return []
343
+
344
+ results: list[type] = []
345
+ try:
346
+ obj = getattr(attr_class, "__init__", None)
347
+ if obj is not None:
348
+ results.append(obj)
349
+ except Exception:
350
+ pass
351
+
352
+ for attr in parts[2:]:
353
+ try:
354
+ obj = getattr(attr_class, attr, None)
355
+
356
+ if obj is not None:
357
+ results.append(obj)
358
+ except Exception:
359
+ break
360
+
361
+ return results
272
362
 
273
363
  logger.debug(
274
364
  f"Failed to resolve parent attribute {parent_part} for parts {attr_chain}. Chain: {ast.dump(node)} for func {func.__name__}"
275
365
  )
276
- return None
366
+ return []
277
367
 
368
+ parent_attr = _resolve_type_from_assigment(parent_attr, assignments, func)
369
+ results: list[type] = []
278
370
  for attr in parts[1:]:
279
371
  try:
280
372
  obj = getattr(parent_attr, attr, None)
373
+ if obj is not None:
374
+ results.append(obj)
375
+
281
376
  except Exception:
282
377
  break
283
378
 
284
- return obj
379
+ return results
285
380
 
286
381
  except Exception:
287
382
  logger.exception(f"Failed to resolve attribute chain: {attr_chain}")
288
- return None
383
+ return []
384
+
385
+ return []
289
386
 
290
387
 
291
388
  def _extract_node_name(
@@ -295,7 +392,9 @@ def _extract_node_name(
295
392
  Extract the exception name a node recursively.
296
393
  """
297
394
  if isinstance(node, ast.Call):
298
- return _extract_node_name(typing.cast(SupportedAstNodes, node.func), assignments)
395
+ return _extract_node_name(
396
+ typing.cast(SupportedAstNodes, node.func), assignments
397
+ )
299
398
  elif isinstance(node, ast.Name):
300
399
  var_name = get_node_identifier(node)
301
400
  return assignments.get(var_name, var_name)
@@ -303,7 +402,9 @@ def _extract_node_name(
303
402
  return _resolve_full_attribute_path(node)
304
403
 
305
404
  elif isinstance(node, ast.Await):
306
- return _extract_node_name(typing.cast(SupportedAstNodes, node.value), assignments)
405
+ return _extract_node_name(
406
+ typing.cast(SupportedAstNodes, node.value), assignments
407
+ )
307
408
 
308
409
  elif isinstance(
309
410
  node,
@@ -334,7 +435,19 @@ def _exctact_type(var_tyep: str, func_globals: dict) -> Optional[type]:
334
435
  logger.exception(f"Failed to resolve variable type: {var_tyep}")
335
436
 
336
437
 
337
- @lru_cache
438
+ def _should_be_visited(
439
+ module: ModuleType,
440
+ should_search_module_pred: Callable[[str], bool],
441
+ ):
442
+ if (
443
+ is_stdlib(module.__name__)
444
+ or should_search_module_pred(module.__name__) is False
445
+ ):
446
+ return False
447
+
448
+ return True
449
+
450
+
338
451
  def is_stdlib(module_name: str) -> bool:
339
452
  """Check if a given module is part of the Python standard library."""
340
453
  try:
@@ -349,16 +462,29 @@ def is_stdlib(module_name: str) -> bool:
349
462
 
350
463
 
351
464
  class ExceptionFinder(ast.NodeVisitor):
352
- visited: set[Callable] = set()
465
+ visited: dict[Callable, list[tuple[Optional[type[Exception]], ast.Raise]]] = {}
466
+ # cached_assignments: dict[Callable, dict[NodeIdentifier, str]] = {}
353
467
 
354
- def __init__(self, func: Callable, should_search_module_pred: Callable[[str], bool]):
468
+ def __init__(
469
+ self, func: Callable, should_search_module_pred: Callable[[str], bool]
470
+ ):
355
471
  self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
356
472
  self.assignments: dict[NodeIdentifier, str] = {}
473
+ # self.cached_assignments[func] = self.assignments
357
474
  self.func = func
358
- self.func_globals = func.__globals__
359
475
  self.should_search_module_pred = should_search_module_pred
360
476
  logger.info(f"Analyzing function {func.__name__}")
361
477
 
478
+ @classmethod
479
+ def clear_cache(cls):
480
+ cls.visited.clear()
481
+ # cls.cached_assignments.clear()
482
+
483
+ def _should_be_visited(
484
+ self, module: ModuleType
485
+ ) -> typing_extensions.TypeGuard[ModuleType]:
486
+ return _should_be_visited(module, self.should_search_module_pred)
487
+
362
488
  def visit_Assign(self, node):
363
489
  """
364
490
  Track assignments to variables that are exceptions or exception instances.
@@ -384,10 +510,11 @@ class ExceptionFinder(ast.NodeVisitor):
384
510
 
385
511
  for target in targets:
386
512
  if isinstance(target, ast.Name):
387
- var_identifier = get_node_identifier(target)
388
- var_name = _extract_node_name(value, self.assignments)
389
- if var_name is not None:
390
- self.assignments[var_identifier] = var_name
513
+ var_name = get_node_identifier(target)
514
+ var_result = _extract_node_name(value, self.assignments)
515
+
516
+ if var_result is not None:
517
+ self.assignments[var_name] = var_result
391
518
 
392
519
  else:
393
520
  # no need to log here. it would be very spammy
@@ -440,7 +567,7 @@ class ExceptionFinder(ast.NodeVisitor):
440
567
  logger.debug(f"Failed to get exception name: {ast.dump(node)}")
441
568
  return
442
569
 
443
- exc_type = _exctact_type(exc_variable_name, self.func_globals)
570
+ exc_type = _exctact_type(exc_variable_name, self.func.__globals__)
444
571
 
445
572
  if exc_type:
446
573
  self.exceptions.append((exc_type, node))
@@ -452,47 +579,47 @@ class ExceptionFinder(ast.NodeVisitor):
452
579
  """
453
580
  Handle function calls and recursively analyze called functions.
454
581
  """
455
- func_obj = _resolve_function_from_call_node(
456
- node, self.func_globals, self.func, self.assignments
457
- )
458
- if func_obj and inspect.isfunction(func_obj):
459
- module = inspect.getmodule(func_obj)
460
- if not module:
461
- self.generic_visit(node)
462
- return
463
-
464
- if (
465
- is_stdlib(module.__name__)
466
- or self.should_search_module_pred(module.__name__) is False
467
- ):
468
- self.generic_visit(node)
469
- return
470
-
471
- source = inspect.getsource(module)
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,
582
+ func_objs = _resolve_functions_from_call_node(node, self.func, self.assignments)
583
+ for func_obj in func_objs:
584
+ if inspect.isfunction(func_obj):
585
+ module = inspect.getmodule(func_obj)
586
+ if module is None or self._should_be_visited(module) is False:
587
+ continue
588
+
589
+ source = inspect.getsource(module)
590
+ try:
591
+ tree = ast.parse(source)
592
+ except BaseException as error:
593
+ logger.exception(
594
+ f"Failed to parse source code for {func_obj.__name__}.",
595
+ exc_info=error,
596
+ )
597
+ self.generic_visit(node)
598
+ return
599
+
600
+ founded_func_ast = _find_in_module(
601
+ tree,
602
+ lambda n: (
603
+ isinstance(n, ast.AsyncFunctionDef)
604
+ or isinstance(n, ast.FunctionDef)
605
+ )
606
+ and n.name == func_obj.__name__,
478
607
  )
479
- self.generic_visit(node)
480
- return
608
+ if founded_func_ast:
609
+ excs = _find_explicit_expection_recursively(
610
+ func_obj, self.should_search_module_pred, founded_func_ast
611
+ )
612
+ self.exceptions.extend(excs)
613
+ else:
614
+ logger.debug(
615
+ f"Failed to find function {func_obj.__name__} in module {module.__name__}"
616
+ )
481
617
 
482
- founded_func_ast = _find_in_module(
483
- tree,
484
- lambda n: (isinstance(n, ast.AsyncFunctionDef) or isinstance(n, ast.FunctionDef))
485
- and n.name == func_obj.__name__,
486
- )
487
- if founded_func_ast:
488
- excs = _find_explicit_expection_recursively(
489
- func_obj, self.should_search_module_pred, founded_func_ast
490
- )
491
- self.exceptions.extend(excs)
492
- else:
493
- logger.debug(
494
- f"Failed to find function {func_obj.__name__} in module {module.__name__}"
618
+ elif inspect.isclass(func_obj):
619
+ _excs = _find_explicit_expection_recursively(
620
+ func_obj, self.should_search_module_pred
495
621
  )
622
+ self.exceptions.extend(_excs)
496
623
 
497
624
  self.generic_visit(node)
498
625
  return
@@ -507,9 +634,11 @@ class ExceptionFinder(ast.NodeVisitor):
507
634
  return
508
635
 
509
636
  try:
510
- caller_type = _exctact_type(caller_node_name, self.func_globals)
637
+ caller_type = _exctact_type(caller_node_name, self.func.__globals__)
511
638
  except Exception:
512
- logger.critical(f"Failed to resolve attribute chain: {caller_node_name}")
639
+ logger.critical(
640
+ f"Failed to resolve attribute chain: {caller_node_name}"
641
+ )
513
642
  self.generic_visit(node)
514
643
  return
515
644
 
@@ -523,7 +652,7 @@ class ExceptionFinder(ast.NodeVisitor):
523
652
  return
524
653
 
525
654
  module = inspect.getmodule(caller_type)
526
- if not module:
655
+ if module is None or self._should_be_visited(module) is False:
527
656
  self.generic_visit(node)
528
657
  return
529
658
 
@@ -540,7 +669,8 @@ class ExceptionFinder(ast.NodeVisitor):
540
669
 
541
670
  cls_ast = _find_in_module(
542
671
  tree,
543
- lambda n: isinstance(n, ast.ClassDef) and n.name == caller_type.__name__,
672
+ lambda n: isinstance(n, ast.ClassDef)
673
+ and n.name == caller_type.__name__,
544
674
  )
545
675
  if cls_ast:
546
676
  casted_cls_ast = typing.cast(ast.ClassDef, cls_ast)
@@ -0,0 +1,22 @@
1
+ from fastapi import FastAPI
2
+ from fastapi.responses import JSONResponse
3
+ from pydantic import TypeAdapter
4
+ from starlette.requests import Request
5
+ from starlette.responses import Response
6
+
7
+ from richapi.exc_parser.protocol import RichHTTPException
8
+
9
+
10
+ async def _handle_rich_http_exception(request: Request, exc: Exception) -> Response:
11
+ assert isinstance(exc, RichHTTPException)
12
+
13
+ adapter = TypeAdapter(exc.__class__)
14
+ if exc._validate_arg_types:
15
+ adapter.validate_python(exc)
16
+
17
+ json_response = adapter.dump_python(exc)
18
+ return JSONResponse(content=json_response, status_code=exc.status_code)
19
+
20
+
21
+ def add_exc_handler(app: FastAPI):
22
+ app.add_exception_handler(RichHTTPException, handler=_handle_rich_http_exception)
@@ -12,6 +12,9 @@ from fastapi.openapi.utils import get_openapi as _get_openapi
12
12
  from fastapi.routing import APIRoute
13
13
  from starlette.exceptions import HTTPException as StarletteHTTPException
14
14
 
15
+ from richapi.exc_parser.compiler import (
16
+ ExceptionFinder,
17
+ )
15
18
  from richapi.exc_parser.compiler import (
16
19
  find_explicit_exceptions as _find_explicit_exceptions,
17
20
  )
@@ -88,6 +91,8 @@ def compile_openapi_from_fastapi(
88
91
 
89
92
  _fill_openapi_with_excpetions(openapi_schema, route, exceptions)
90
93
 
94
+ ExceptionFinder.clear_cache()
95
+
91
96
  return openapi_schema
92
97
 
93
98
 
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Literal
3
+ from typing import Any, ClassVar, Literal
4
4
 
5
5
  from fastapi import HTTPException
6
- from pydantic import BaseModel, create_model
6
+ from pydantic import BaseModel, TypeAdapter, create_model
7
+ from pydantic.dataclasses import dataclass
7
8
 
8
9
 
9
10
  class HTTPExceptionSchema(BaseModel):
@@ -27,10 +28,38 @@ class BaseHTTPException(HTTPException):
27
28
  @classmethod
28
29
  def get_json_schema(cls) -> HTTPExceptionSchema:
29
30
  value = _generic_json_schema_builder(cls)
30
- assert value is not None # should never happen because status code is always set
31
+ assert (
32
+ value is not None
33
+ ) # should never happen because status code is always set
31
34
  return value
32
35
 
33
36
 
37
+ @dataclass
38
+ class RichHTTPException(HTTPException):
39
+ status_code: ClassVar[int] = 422
40
+ """HTTP Status code to raise"""
41
+
42
+ _validate_arg_types: ClassVar[bool] = True
43
+ """Whether to run validation on arguements"""
44
+
45
+ @classmethod
46
+ def get_json_schema(cls) -> HTTPExceptionSchema:
47
+ adapter = TypeAdapter(cls)
48
+ schema = adapter.json_schema()
49
+ schema.pop("status_code", None)
50
+
51
+ schema_desc = cls.__doc__
52
+ if schema_desc == cls.__name__ + "()" or schema_desc is None:
53
+ schema_desc = "No description provided"
54
+
55
+ return HTTPExceptionSchema(
56
+ response_schema_json=schema,
57
+ schema_name=f"{cls.__name__}Schema",
58
+ status_code=cls.status_code,
59
+ schema_desc=schema_desc,
60
+ )
61
+
62
+
34
63
  def try_to_camel_case(string: str) -> str:
35
64
  final_str = string
36
65
  if " " in final_str: # with space
File without changes
@@ -0,0 +1,10 @@
1
+ from richapi.exc_parser.protocol import BaseHTTPException
2
+
3
+
4
+ class HTTP500Error(BaseHTTPException):
5
+ status_code = 500
6
+ detail = "Internal Server Error"
7
+
8
+
9
+ def raise_exc():
10
+ raise HTTP500Error
@@ -0,0 +1,68 @@
1
+ import fastapi
2
+ import fastapi.security
3
+ from pydantic.dataclasses import dataclass
4
+
5
+ from richapi.exc_parser.handler import add_exc_handler
6
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
7
+ from richapi.exc_parser.protocol import RichHTTPException
8
+
9
+ app = fastapi.FastAPI()
10
+ app.openapi = enrich_openapi(app)
11
+ add_exc_handler(app)
12
+
13
+
14
+ @dataclass
15
+ class Exception1(RichHTTPException):
16
+ status_code = 409
17
+
18
+
19
+ @dataclass
20
+ class Exception2(RichHTTPException):
21
+ status_code = 408
22
+
23
+
24
+ @dataclass
25
+ class Exception3(RichHTTPException):
26
+ status_code = 407
27
+
28
+
29
+ def foo1():
30
+ raise Exception1()
31
+
32
+
33
+ def foo2():
34
+ raise Exception2()
35
+
36
+
37
+ def foo3():
38
+ raise Exception3()
39
+
40
+
41
+ class PaymentService:
42
+ def foo(self):
43
+ foo1()
44
+ return self
45
+
46
+ def create(self):
47
+ foo2()
48
+ return self
49
+
50
+ def update(self):
51
+ foo3()
52
+ return self
53
+
54
+
55
+ @app.post("/payment")
56
+ async def make_payment():
57
+ PaymentService().foo().create().update()
58
+
59
+
60
+ def test_class_is_detected():
61
+ openapi_json = compile_openapi_from_fastapi(
62
+ app, target_module="tests.test_builder_pattern"
63
+ )
64
+ home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
65
+ print(home_responses)
66
+ assert "408" in home_responses
67
+ assert "407" in home_responses
68
+ assert "409" in home_responses
@@ -0,0 +1,72 @@
1
+ import fastapi
2
+ import fastapi.security
3
+ from pydantic.dataclasses import dataclass
4
+
5
+ from richapi.exc_parser.handler import add_exc_handler
6
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
7
+ from richapi.exc_parser.protocol import RichHTTPException
8
+
9
+ app = fastapi.FastAPI()
10
+ app.openapi = enrich_openapi(app)
11
+ add_exc_handler(app)
12
+
13
+
14
+ @dataclass
15
+ class Exception1(RichHTTPException):
16
+ status_code = 409
17
+
18
+
19
+ @dataclass
20
+ class Exception2(RichHTTPException):
21
+ status_code = 408
22
+
23
+
24
+ @dataclass
25
+ class Exception3(RichHTTPException):
26
+ status_code = 407
27
+
28
+
29
+ def foo1():
30
+ raise Exception1()
31
+
32
+
33
+ def foo2():
34
+ raise Exception2()
35
+
36
+
37
+ def foo3():
38
+ raise Exception3()
39
+
40
+
41
+ class PaymentService:
42
+ def __init__(self):
43
+ foo1()
44
+
45
+ def create(self):
46
+ foo2()
47
+
48
+ def update(self):
49
+ foo3()
50
+
51
+
52
+ def make_payment_dep():
53
+ obj = PaymentService()
54
+ return obj
55
+
56
+
57
+ @app.post("/payment")
58
+ async def make_payment(
59
+ obj2: PaymentService = fastapi.Depends(make_payment_dep),
60
+ ):
61
+ obj = PaymentService()
62
+ obj.create()
63
+ obj2.update()
64
+
65
+
66
+ def test_class_is_detected():
67
+ openapi_json = compile_openapi_from_fastapi(app, target_module="tests.test_class")
68
+ home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
69
+ print(home_responses)
70
+ assert "408" in home_responses
71
+ assert "407" in home_responses
72
+ assert "409" in home_responses
@@ -0,0 +1,12 @@
1
+ from typing import Annotated
2
+
3
+ from richapi.exc_parser.compiler import _retrieve_from_annotations
4
+
5
+
6
+ def foo(value: Annotated[int, Annotated[str, str]]): ...
7
+
8
+
9
+ def test_get_from_annotated():
10
+ result = _retrieve_from_annotations(foo, "value")
11
+ print(type(result))
12
+ assert result is int
@@ -0,0 +1,52 @@
1
+ import fastapi
2
+ import fastapi.security
3
+ import pytest
4
+ from httpx import ASGITransport, AsyncClient
5
+ from pydantic import BaseModel
6
+ from pydantic.dataclasses import dataclass
7
+
8
+ from richapi.exc_parser.handler import add_exc_handler
9
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
10
+ from richapi.exc_parser.protocol import RichHTTPException
11
+
12
+ app = fastapi.FastAPI()
13
+ app.openapi = enrich_openapi(app)
14
+ add_exc_handler(app)
15
+
16
+
17
+ @dataclass
18
+ class NotEnoughBalance(RichHTTPException):
19
+ """Custom exception for user balance"""
20
+
21
+ user_id: int
22
+ balance: float
23
+ status_code = 409
24
+
25
+
26
+ class SuccessResponse(BaseModel):
27
+ balance: float
28
+
29
+
30
+ @app.post("/payment")
31
+ async def make_payment() -> SuccessResponse:
32
+ raise NotEnoughBalance(user_id=1, balance=0.5)
33
+
34
+
35
+ def test_custom_exc_detected():
36
+ openapi_json = compile_openapi_from_fastapi(
37
+ app, target_module="tests.test_custom_exc"
38
+ )
39
+ home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
40
+ assert "409" in home_responses
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_make_payment():
45
+ async with AsyncClient(
46
+ transport=ASGITransport(app=app), base_url="http://test"
47
+ ) as ac:
48
+ response = await ac.post("/payment")
49
+
50
+ assert response.status_code == 409
51
+ expected_response = {"user_id": 1, "balance": 0.5}
52
+ assert response.json() == expected_response
File without changes
@@ -0,0 +1,20 @@
1
+ import fastapi
2
+ import fastapi.security
3
+
4
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
5
+ from tests.app import foo1 as FooService
6
+
7
+ app = fastapi.FastAPI()
8
+ app.openapi = enrich_openapi(app)
9
+
10
+
11
+ @app.post("/payment")
12
+ async def make_payment():
13
+ FooService.raise_exc()
14
+
15
+
16
+ def test_class_is_detected():
17
+ openapi_json = compile_openapi_from_fastapi(app, target_module="tests")
18
+ home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
19
+ print(home_responses)
20
+ assert "500" in home_responses
@@ -0,0 +1,82 @@
1
+ import logging
2
+
3
+ import fastapi
4
+ import fastapi.security
5
+ from pydantic.dataclasses import dataclass
6
+
7
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
8
+ from richapi.exc_parser.protocol import RichHTTPException
9
+
10
+ logging.getLogger().addHandler(logging.StreamHandler())
11
+
12
+ app = fastapi.FastAPI()
13
+ app.openapi = enrich_openapi(app, target_module="tests.test_instance_attr_call")
14
+
15
+
16
+ @dataclass
17
+ class Exception1(RichHTTPException):
18
+ status_code = 409
19
+
20
+
21
+ @dataclass
22
+ class Exception2(RichHTTPException):
23
+ status_code = 408
24
+
25
+
26
+ @dataclass
27
+ class Exception3(RichHTTPException):
28
+ status_code = 407
29
+
30
+
31
+ def foo1():
32
+ raise Exception1()
33
+
34
+
35
+ def foo2():
36
+ raise Exception2()
37
+
38
+
39
+ def foo3():
40
+ raise Exception3()
41
+
42
+
43
+ class PaymentService:
44
+ def __init__(self):
45
+ foo1()
46
+
47
+ def create_inner(self):
48
+ foo2()
49
+
50
+ def update_inner(self):
51
+ foo3()
52
+
53
+
54
+ class PaymentOrchestrator:
55
+ service: PaymentService
56
+
57
+ def __init__(self, payment_service: PaymentService):
58
+ self.service = payment_service
59
+
60
+ def create_outer(self):
61
+ self.service.create_inner()
62
+ self.update_outer()
63
+
64
+ def update_outer(self):
65
+ self.service.update_inner()
66
+
67
+
68
+ @app.post("/payment")
69
+ async def make_payment():
70
+ obj = PaymentOrchestrator(payment_service=PaymentService())
71
+ obj.create_outer()
72
+
73
+
74
+ def test_class_is_detected():
75
+ openapi_json = compile_openapi_from_fastapi(
76
+ app, target_module="tests.test_instance_attr_call"
77
+ )
78
+ home_responses = openapi_json["paths"]["/payment"]["post"]["responses"]
79
+ print(home_responses)
80
+ assert "408" in home_responses
81
+ assert "407" in home_responses
82
+ assert "409" in home_responses
@@ -1,4 +0,0 @@
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"]
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes