Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
fail-fast: false
steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "richapi"
version = "0.3.2"
description = "Find HTTPExceptions and turn them into documented responses!"
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = ["typer>=0.12", "fastapi>=0.105"]
license = 'MIT'

Expand Down Expand Up @@ -36,7 +36,7 @@ packages = ["richapi"]
[tool.uv]
dev-dependencies = [
"ruff>=0.6.9",
"pyright>=1.1.384",
"pyright>=1.1.397",
"rich>=13.9.2",
"pytest>=8.3.3",
"pytest-asyncio>=0.24.0",
Expand Down
44 changes: 35 additions & 9 deletions richapi/exc_parser/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,32 @@
import typing
from importlib.util import find_spec
from types import ModuleType
from typing import Annotated, Callable, Generic, List, NewType, Optional, Tuple, Union
from typing import (
Annotated,
Callable,
Generic,
NamedTuple,
NewType,
Optional,
Union,
)

import typing_extensions
from starlette.exceptions import HTTPException as StarletteHTTPException

logger = logging.getLogger(__name__)


class RaisedExceptionInfo(NamedTuple):
exception: type[Exception] | None
raise_ast: ast.Raise


class RaisedHTTPExceptionInfo(NamedTuple):
exception: type[StarletteHTTPException]
raise_ast: ast.Raise


def not_supported(
value: typing.NoReturn,
) -> typing.NoReturn: ... # don't raise any exceptions just type checking
Expand Down Expand Up @@ -80,7 +99,7 @@ def find_explicit_exceptions(
func: Callable,
target_modules: list[str],
should_search_module_pred: Union[Callable[[str], bool], None] = None,
) -> List[Tuple[Optional[type[Exception]], ast.Raise]]:
) -> list[RaisedExceptionInfo]:
"""
Analyze the given function and find all explicitly raised exceptions,
including those in nested functions and class methods.
Expand Down Expand Up @@ -140,7 +159,7 @@ def _find_all_class_exceptions(
and n.name == "__init__",
)

result: list[tuple[Optional[type[Exception]], ast.Raise]] = []
result: list[RaisedExceptionInfo] = []
if init_tree is not None:
result.extend(
_find_explicit_expection_recursively(
Expand Down Expand Up @@ -180,7 +199,7 @@ def _find_explicit_expection_recursively(
func_obj: Callable,
to_filter_predicate: Callable[[str], bool],
tree: ast.AST,
) -> list[tuple[Optional[type[Exception]], ast.Raise]]:
) -> list[RaisedExceptionInfo]:
if func_obj in ExceptionFinder.visited:
return ExceptionFinder.visited[func_obj]

Expand Down Expand Up @@ -482,12 +501,12 @@ def is_stdlib(module_name: str) -> bool:


class ExceptionFinder(ast.NodeVisitor):
visited: dict[Callable, list[tuple[Optional[type[Exception]], ast.Raise]]] = {}
visited: dict[Callable, list[RaisedExceptionInfo]] = {}

def __init__(
self, func: Callable, should_search_module_pred: Callable[[str], bool]
):
self.exceptions: list[tuple[Optional[type[Exception]], ast.Raise]] = []
self.exceptions: list[RaisedExceptionInfo] = []
self.assignments: dict[NodeIdentifier, str] = {}
# self.cached_assignments[func] = self.assignments
self.func = func
Expand All @@ -498,6 +517,10 @@ def __init__(
def clear_cache(cls):
cls.visited.clear()

def populate_cache_if_not_exists(self, func_obj: Callable):
if func_obj not in ExceptionFinder.visited:
ExceptionFinder.visited[func_obj] = []

def _should_be_visited(
self, module: ModuleType
) -> typing_extensions.TypeGuard[ModuleType]:
Expand Down Expand Up @@ -570,7 +593,7 @@ def visit_Raise(self, node):
# Could not resolve exception name -> maybe just using 'raise' without an exception
# this edge case basically can raise anything
logger.debug("Please don't use 'raise' without declaring what to raise")
self.exceptions.append((None, node))
self.exceptions.append(RaisedExceptionInfo(None, node))
self.generic_visit(node)
return

Expand All @@ -580,15 +603,15 @@ def visit_Raise(self, node):
if exc_variable_name is None:
# We are raising an exception that is assigned to a variable we don't know
# Maybe global variable ?
self.exceptions.append((None, node))
self.exceptions.append(RaisedExceptionInfo(None, node))
self.generic_visit(node)
logger.debug(f"Failed to get exception name: {ast.dump(node)}")
return

exc_type = _exctact_type(exc_variable_name, self.func.__globals__)

if exc_type:
self.exceptions.append((exc_type, node))
self.exceptions.append(RaisedExceptionInfo(exc_type, node))

self.generic_visit(node)
return
Expand All @@ -598,6 +621,7 @@ def visit_Call(self, node):
Handle function calls and recursively analyze called functions.
"""
func_objs = _resolve_functions_from_call_node(node, self.func, self.assignments)

for func_obj in func_objs:
if inspect.isfunction(func_obj):
module = inspect.getmodule(func_obj)
Expand Down Expand Up @@ -638,8 +662,10 @@ def visit_Call(self, node):
func_obj, self.should_search_module_pred
)
self.exceptions.extend(_excs)
self.populate_cache_if_not_exists(func_obj)

self.generic_visit(node)
self.populate_cache_if_not_exists(self.func)
return

def visit_Attribute(self, node):
Expand Down
53 changes: 38 additions & 15 deletions richapi/exc_parser/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import typing
from functools import reduce
from logging import getLogger
from types import NoneType
from typing import Callable, Union

import fastapi
Expand All @@ -14,6 +15,7 @@

from richapi.exc_parser.compiler import (
ExceptionFinder,
RaisedHTTPExceptionInfo,
)
from richapi.exc_parser.compiler import (
find_explicit_exceptions as _find_explicit_exceptions,
Expand Down Expand Up @@ -47,7 +49,6 @@ def enrich_openapi(
) -> Callable:
if target_module is None:
target_module = _find_module_name_where_app_defined_in(app)

if target_module is None or target_module == "__main__":
raise BaseRichAPIException(
"Could not determine the module where the FastAPI instance was created.\n"
Expand Down Expand Up @@ -81,7 +82,6 @@ def compile_openapi_from_fastapi(
) -> dict:
target_module = [target_module] if isinstance(target_module, str) else target_module
target_module.append("fastapi")

openapi_schema = open_api_getter(app)
for route in app.routes:
if not isinstance(route, APIRoute):
Expand Down Expand Up @@ -138,7 +138,19 @@ def _resolve_status_and_detail_from_exc_type(
status_code_value = kwarg.value

if isinstance(status_code_value, ast.Constant):
found_status_code: Union[int, None] = int(status_code_value.value)
if not isinstance(
status_code_value.value,
(int, str, NoneType),
):
raise ValueError(
f"Status code value must be an integer, string or None, got {type(status_code_value.value)}"
)
if status_code_value.value is not None:
found_status_code = int(status_code_value.value)

assert isinstance(
status_code_value.value, (str, bytes, bool, int, float)
)

elif isinstance(status_code_value, ast.Attribute):
# maybe used like: status.HTTP_404_NOT_FOUND
Expand All @@ -153,6 +165,11 @@ def _resolve_status_and_detail_from_exc_type(
elif kwarg.arg == "detail":
detail_value = kwarg.value
if isinstance(detail_value, ast.Constant):
if not isinstance(detail_value.value, (str, NoneType)):
raise ValueError(
f"Detail value must be a string or None, got {type(detail_value.value)}"
)

found_detail: Union[str, None] = detail_value.value

else:
Expand Down Expand Up @@ -219,7 +236,7 @@ def _extract_json_schema(
def _fill_openapi_with_excpetions(
api_schema: dict,
route: APIRoute,
exceptions: list[tuple[type[StarletteHTTPException], ast.Raise]],
exceptions: list[RaisedHTTPExceptionInfo],
) -> None:
added_schema_names = set()

Expand Down Expand Up @@ -307,19 +324,25 @@ def flatten(to_be_flatten: list[list[T]]) -> list[T]:

def _extract_starlette_exceptions(
route: APIRoute, target_module: list[str]
) -> list[tuple[type[StarletteHTTPException], ast.Raise]]:
) -> list[RaisedHTTPExceptionInfo]:
dependency_tree = build_dependency_tree(route.dependant)
exceptions = flatten(
[_find_explicit_exceptions(dep, target_module) for dep in dependency_tree]
)

starlette_http_exc_types = [
exc
for exc in exceptions
if exc[0] is not None and issubclass(exc[0], StarletteHTTPException)
]
casted_exceptions = typing.cast(
list[tuple[type[StarletteHTTPException], ast.Raise]],
starlette_http_exc_types,
)
return casted_exceptions
result: list[RaisedHTTPExceptionInfo] = []
for exc in exceptions:
if exc.exception is None:
logger.debug(
f"Could not resolve exception type for {ast.dump(exc.raise_ast)}"
)
continue
if not issubclass(exc.exception, StarletteHTTPException):
logger.debug(
f"Skipping non-StarletteHTTPException exception: {exc.exception}"
)
continue

result.append(RaisedHTTPExceptionInfo(exc.exception, exc.raise_ast))

return result
57 changes: 57 additions & 0 deletions tests/test_recursive_func.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# from dataclasses import dataclass
# from typing import Literal

import random
from dataclasses import dataclass

from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from richapi.exc_parser.handler import add_exc_handler
from richapi.exc_parser.openapi import compile_openapi_from_fastapi, enrich_openapi
from richapi.exc_parser.protocol import RichHTTPException


@dataclass
class Exception1(RichHTTPException):
status_code = 409


@dataclass
class Exception2(RichHTTPException):
status_code = 408


class NWrapper(BaseModel):
value: int


def lol():
if random.randint(1, 3) == 2:
raise Exception1()
return lol()


app = FastAPI()

app.openapi = enrich_openapi(app, target_module="tests.test_recursive_func")
add_exc_handler(app)


@app.get("/{n}", response_model=NWrapper)
async def index(n: int) -> JSONResponse:
lol()
if n % 2 == 0:
raise Exception2()
return JSONResponse(content=jsonable_encoder(NWrapper(value=n)))


def test_recursive_error_in_function():
openapi_json = compile_openapi_from_fastapi(
app, target_module="tests.test_recursive_func"
)
openapi_responses = openapi_json["paths"]["/{n}"]["get"]["responses"]
assert "409" in openapi_responses
assert "408" in openapi_responses
20 changes: 10 additions & 10 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
[tox]
skipsdist = true
envlist = py39, py310, py311, py312
envlist = py310, py311, py312

[gh-actions]
python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312

[testenv:py39]
passenv = PYTHON_VERSION
allowlist_externals = uv,pytest,pyright
commands =
uv python pin 3.9
uv run pytest
3.13: py313

[testenv:py310]
passenv = PYTHON_VERSION
Expand All @@ -35,4 +28,11 @@ passenv = PYTHON_VERSION
allowlist_externals = uv,pytest,pyright
commands =
uv python pin 3.12
uv run pytest
uv run pytest

[testenv:py313]
passenv = PYTHON_VERSION
allowlist_externals = uv,pytest,pyright
commands =
uv python pin 3.13
uv run pytest
Loading
Loading