richapi 0.2.1__tar.gz → 0.3.0__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 (36) hide show
  1. {richapi-0.2.1 → richapi-0.3.0}/PKG-INFO +1 -1
  2. {richapi-0.2.1 → richapi-0.3.0}/docs/advance.md +1 -4
  3. {richapi-0.2.1 → richapi-0.3.0}/docs/conventions.md +1 -1
  4. richapi-0.3.0/docs/images/custom-exception.png +0 -0
  5. {richapi-0.2.1 → richapi-0.3.0}/docs/index.md +51 -4
  6. {richapi-0.2.1 → richapi-0.3.0}/pyproject.toml +1 -1
  7. richapi-0.3.0/richapi/__init__.py +11 -0
  8. richapi-0.3.0/richapi/__version__.py +1 -0
  9. {richapi-0.2.1 → richapi-0.3.0}/richapi/exc_parser/compiler.py +36 -13
  10. richapi-0.3.0/richapi/exc_parser/handler.py +22 -0
  11. {richapi-0.2.1 → richapi-0.3.0}/richapi/exc_parser/openapi.py +5 -0
  12. {richapi-0.2.1 → richapi-0.3.0}/richapi/exc_parser/protocol.py +32 -3
  13. richapi-0.3.0/tests/test_custom_exc.py +52 -0
  14. richapi-0.2.1/richapi/__init__.py +0 -4
  15. richapi-0.2.1/richapi/__version__.py +0 -1
  16. {richapi-0.2.1 → richapi-0.3.0}/.envrc +0 -0
  17. {richapi-0.2.1 → richapi-0.3.0}/.github/workflows/deploy-docs-on-demand.yml +0 -0
  18. {richapi-0.2.1 → richapi-0.3.0}/.github/workflows/main.yml +0 -0
  19. {richapi-0.2.1 → richapi-0.3.0}/.github/workflows/on-release-main.yml +0 -0
  20. {richapi-0.2.1 → richapi-0.3.0}/.gitignore +0 -0
  21. {richapi-0.2.1 → richapi-0.3.0}/README.md +0 -0
  22. {richapi-0.2.1 → richapi-0.3.0}/deploy_docs.py +0 -0
  23. {richapi-0.2.1 → richapi-0.3.0}/docs/images/with-richapi.png +0 -0
  24. {richapi-0.2.1 → richapi-0.3.0}/docs/images/without-richapi.png +0 -0
  25. {richapi-0.2.1 → richapi-0.3.0}/docs/implementation.md +0 -0
  26. {richapi-0.2.1 → richapi-0.3.0}/makefile +0 -0
  27. {richapi-0.2.1 → richapi-0.3.0}/mkdocs.yml +0 -0
  28. {richapi-0.2.1 → richapi-0.3.0}/richapi/cli.py +0 -0
  29. {richapi-0.2.1 → richapi-0.3.0}/richapi/exc_parser/__init__.py +0 -0
  30. {richapi-0.2.1 → richapi-0.3.0}/richapi/exceptions.py +0 -0
  31. {richapi-0.2.1 → richapi-0.3.0}/tests/__init__.py +0 -0
  32. {richapi-0.2.1 → richapi-0.3.0}/tests/test_exc_raising_cases.py +0 -0
  33. {richapi-0.2.1 → richapi-0.3.0}/tests/test_exception_edge_cases.py +0 -0
  34. {richapi-0.2.1 → richapi-0.3.0}/tests/test_fastapi_auth_functions.py +0 -0
  35. {richapi-0.2.1 → richapi-0.3.0}/tox.ini +0 -0
  36. {richapi-0.2.1 → richapi-0.3.0}/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.0
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.
@@ -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.0"
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.0"
@@ -95,10 +95,14 @@ def _find_all_class_exceptions(
95
95
  try:
96
96
  tree = tree or ast.parse(inspect.getsource(cls))
97
97
  except Exception as error:
98
- logger.exception(f"Failed to parse source code for {cls.__name__}.", exc_info=error)
98
+ logger.exception(
99
+ f"Failed to parse source code for {cls.__name__}.", exc_info=error
100
+ )
99
101
  return []
100
102
 
101
- result = _find_explicit_expection_recursively(cls.__init__, to_filter_predicate, tree)
103
+ result = _find_explicit_expection_recursively(
104
+ cls.__init__, to_filter_predicate, tree
105
+ )
102
106
  if hasattr(cls, "__call__"):
103
107
  # Not sure if __call__ was called or init, so check for both
104
108
 
@@ -110,13 +114,14 @@ def _find_all_class_exceptions(
110
114
  )
111
115
  )
112
116
 
113
- ExceptionFinder.visited = set() # clean up memory
114
117
  return result
115
118
 
116
119
 
117
120
  def is_absolutely_callable_object(obj: Callable) -> bool:
118
121
  return (
119
- inspect.isclass(type(obj)) and (not inspect.isfunction(obj)) and (not inspect.ismethod(obj))
122
+ inspect.isclass(type(obj))
123
+ and (not inspect.isfunction(obj))
124
+ and (not inspect.ismethod(obj))
120
125
  )
121
126
 
122
127
 
@@ -169,7 +174,9 @@ 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
 
174
181
  return finder.exceptions
175
182
 
@@ -247,7 +254,9 @@ def _resolve_function_from_call_node(
247
254
  elif isinstance(node, ast.Attribute):
248
255
  attr_chain = _resolve_full_attribute_path(node)
249
256
  if attr_chain is None:
250
- logger.debug(f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}")
257
+ logger.debug(
258
+ f"Failed to get full attribute name: {ast.dump(node)} in {func.__name__}"
259
+ )
251
260
  return None
252
261
 
253
262
  try:
@@ -267,7 +276,9 @@ def _resolve_function_from_call_node(
267
276
  parent_attr = assignments.get(node_id, None)
268
277
 
269
278
  if parent_attr is None:
270
- if parent_part == "self": # don't need to log -> we already know this is class
279
+ if (
280
+ parent_part == "self"
281
+ ): # don't need to log -> we already know this is class
271
282
  return None
272
283
 
273
284
  logger.debug(
@@ -295,7 +306,9 @@ def _extract_node_name(
295
306
  Extract the exception name a node recursively.
296
307
  """
297
308
  if isinstance(node, ast.Call):
298
- return _extract_node_name(typing.cast(SupportedAstNodes, node.func), assignments)
309
+ return _extract_node_name(
310
+ typing.cast(SupportedAstNodes, node.func), assignments
311
+ )
299
312
  elif isinstance(node, ast.Name):
300
313
  var_name = get_node_identifier(node)
301
314
  return assignments.get(var_name, var_name)
@@ -303,7 +316,9 @@ def _extract_node_name(
303
316
  return _resolve_full_attribute_path(node)
304
317
 
305
318
  elif isinstance(node, ast.Await):
306
- return _extract_node_name(typing.cast(SupportedAstNodes, node.value), assignments)
319
+ return _extract_node_name(
320
+ typing.cast(SupportedAstNodes, node.value), assignments
321
+ )
307
322
 
308
323
  elif isinstance(
309
324
  node,
@@ -351,7 +366,9 @@ def is_stdlib(module_name: str) -> bool:
351
366
  class ExceptionFinder(ast.NodeVisitor):
352
367
  visited: set[Callable] = set()
353
368
 
354
- def __init__(self, func: Callable, should_search_module_pred: Callable[[str], bool]):
369
+ def __init__(
370
+ self, func: Callable, should_search_module_pred: Callable[[str], bool]
371
+ ):
355
372
  self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
356
373
  self.assignments: dict[NodeIdentifier, str] = {}
357
374
  self.func = func
@@ -481,7 +498,10 @@ class ExceptionFinder(ast.NodeVisitor):
481
498
 
482
499
  founded_func_ast = _find_in_module(
483
500
  tree,
484
- lambda n: (isinstance(n, ast.AsyncFunctionDef) or isinstance(n, ast.FunctionDef))
501
+ lambda n: (
502
+ isinstance(n, ast.AsyncFunctionDef)
503
+ or isinstance(n, ast.FunctionDef)
504
+ )
485
505
  and n.name == func_obj.__name__,
486
506
  )
487
507
  if founded_func_ast:
@@ -509,7 +529,9 @@ class ExceptionFinder(ast.NodeVisitor):
509
529
  try:
510
530
  caller_type = _exctact_type(caller_node_name, self.func_globals)
511
531
  except Exception:
512
- logger.critical(f"Failed to resolve attribute chain: {caller_node_name}")
532
+ logger.critical(
533
+ f"Failed to resolve attribute chain: {caller_node_name}"
534
+ )
513
535
  self.generic_visit(node)
514
536
  return
515
537
 
@@ -540,7 +562,8 @@ class ExceptionFinder(ast.NodeVisitor):
540
562
 
541
563
  cls_ast = _find_in_module(
542
564
  tree,
543
- lambda n: isinstance(n, ast.ClassDef) and n.name == caller_type.__name__,
565
+ lambda n: isinstance(n, ast.ClassDef)
566
+ and n.name == caller_type.__name__,
544
567
  )
545
568
  if cls_ast:
546
569
  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.visited.clear()
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
@@ -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
@@ -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