richapi 0.3.2__tar.gz → 0.4.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 (52) hide show
  1. {richapi-0.3.2 → richapi-0.4.0}/.github/workflows/main.yml +1 -1
  2. richapi-0.4.0/.github/workflows/on-release-main.yml +34 -0
  3. {richapi-0.3.2 → richapi-0.4.0}/PKG-INFO +3 -3
  4. richapi-0.4.0/benchmarks/bench.py +59 -0
  5. richapi-0.4.0/benchmarks/compare.py +82 -0
  6. richapi-0.4.0/benchmarks/generate_project.py +172 -0
  7. {richapi-0.3.2 → richapi-0.4.0}/docs/advance.md +24 -0
  8. {richapi-0.3.2 → richapi-0.4.0}/makefile +3 -0
  9. {richapi-0.3.2 → richapi-0.4.0}/pyproject.toml +3 -3
  10. {richapi-0.3.2 → richapi-0.4.0}/richapi/__init__.py +6 -1
  11. richapi-0.4.0/richapi/__version__.py +1 -0
  12. {richapi-0.3.2 → richapi-0.4.0}/richapi/exc_parser/compiler.py +355 -53
  13. {richapi-0.3.2 → richapi-0.4.0}/richapi/exc_parser/openapi.py +162 -35
  14. {richapi-0.3.2 → richapi-0.4.0}/richapi/exc_parser/protocol.py +41 -12
  15. {richapi-0.3.2 → richapi-0.4.0}/tests/test_fastapi_auth_functions.py +3 -1
  16. richapi-0.4.0/tests/test_include_router.py +72 -0
  17. richapi-0.4.0/tests/test_performance.py +222 -0
  18. richapi-0.4.0/tests/test_raise_returned_exception.py +53 -0
  19. richapi-0.4.0/tests/test_recursive_func.py +57 -0
  20. richapi-0.4.0/tests/test_router_exception.py +57 -0
  21. {richapi-0.3.2 → richapi-0.4.0}/tox.ini +10 -10
  22. {richapi-0.3.2 → richapi-0.4.0}/uv.lock +468 -555
  23. richapi-0.3.2/.github/workflows/on-release-main.yml +0 -40
  24. richapi-0.3.2/richapi/__version__.py +0 -1
  25. {richapi-0.3.2 → richapi-0.4.0}/.envrc +0 -0
  26. {richapi-0.3.2 → richapi-0.4.0}/.github/workflows/deploy-docs-on-demand.yml +0 -0
  27. {richapi-0.3.2 → richapi-0.4.0}/.gitignore +0 -0
  28. {richapi-0.3.2 → richapi-0.4.0}/README.md +0 -0
  29. {richapi-0.3.2 → richapi-0.4.0}/deploy_docs.py +0 -0
  30. {richapi-0.3.2 → richapi-0.4.0}/docs/conventions.md +0 -0
  31. {richapi-0.3.2 → richapi-0.4.0}/docs/images/custom-exception.png +0 -0
  32. {richapi-0.3.2 → richapi-0.4.0}/docs/images/with-richapi.png +0 -0
  33. {richapi-0.3.2 → richapi-0.4.0}/docs/images/without-richapi.png +0 -0
  34. {richapi-0.3.2 → richapi-0.4.0}/docs/implementation.md +0 -0
  35. {richapi-0.3.2 → richapi-0.4.0}/docs/index.md +0 -0
  36. {richapi-0.3.2 → richapi-0.4.0}/mkdocs.yml +0 -0
  37. {richapi-0.3.2 → richapi-0.4.0}/richapi/cli.py +0 -0
  38. {richapi-0.3.2 → richapi-0.4.0}/richapi/exc_parser/__init__.py +0 -0
  39. {richapi-0.3.2 → richapi-0.4.0}/richapi/exc_parser/handler.py +0 -0
  40. {richapi-0.3.2 → richapi-0.4.0}/richapi/exceptions.py +0 -0
  41. {richapi-0.3.2 → richapi-0.4.0}/tests/__init__.py +0 -0
  42. {richapi-0.3.2 → richapi-0.4.0}/tests/app/__init__.py +0 -0
  43. {richapi-0.3.2 → richapi-0.4.0}/tests/app/foo1.py +0 -0
  44. {richapi-0.3.2 → richapi-0.4.0}/tests/test_builder_pattern.py +0 -0
  45. {richapi-0.3.2 → richapi-0.4.0}/tests/test_class.py +0 -0
  46. {richapi-0.3.2 → richapi-0.4.0}/tests/test_compiler.py +0 -0
  47. {richapi-0.3.2 → richapi-0.4.0}/tests/test_custom_exc.py +0 -0
  48. {richapi-0.3.2 → richapi-0.4.0}/tests/test_duplicated_function.py +0 -0
  49. {richapi-0.3.2 → richapi-0.4.0}/tests/test_exc_raising_cases.py +0 -0
  50. {richapi-0.3.2 → richapi-0.4.0}/tests/test_exception_edge_cases.py +0 -0
  51. {richapi-0.3.2 → richapi-0.4.0}/tests/test_import_from_other_module.py +0 -0
  52. {richapi-0.3.2 → richapi-0.4.0}/tests/test_instance_attr_call.py +0 -0
@@ -12,7 +12,7 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
  strategy:
14
14
  matrix:
15
- python-version: ["3.9", "3.10", "3.11"]
15
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
16
16
  fail-fast: false
17
17
  steps:
18
18
  - uses: actions/checkout@v4
@@ -0,0 +1,34 @@
1
+ name: release-main
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Check out
12
+ uses: actions/checkout@v4
13
+
14
+ - name: Set up uv
15
+ uses: astral-sh/setup-uv@v6
16
+
17
+ - name: Check version matches the release tag
18
+ run: |
19
+ tag="${GITHUB_REF_NAME#v}"
20
+ pkg="$(uv version --short)"
21
+ init="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' richapi/__version__.py)"
22
+ if [ "$tag" != "$pkg" ] || [ "$tag" != "$init" ]; then
23
+ echo "::error::Release tag is $tag but pyproject.toml has $pkg and __version__.py has $init. Bump both before releasing."
24
+ exit 1
25
+ fi
26
+
27
+ # rye publish bundles an old twine that rejects current hatchling metadata (2.4+).
28
+ - name: Build
29
+ run: uv build
30
+
31
+ - name: Publish
32
+ run: uv publish --token "$PYPI_TOKEN"
33
+ env:
34
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
@@ -1,13 +1,13 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.5
2
2
  Name: richapi
3
- Version: 0.3.2
3
+ Version: 0.4.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
7
7
  Project-URL: Documentation, https://github.com/ManiMozaffar/richapi
8
8
  Project-URL: Source, https://github.com/ManiMozaffar/richapi
9
9
  License-Expression: MIT
10
- Requires-Python: >=3.9
10
+ Requires-Python: >=3.10
11
11
  Requires-Dist: fastapi>=0.105
12
12
  Requires-Dist: typer>=0.12
13
13
  Description-Content-Type: text/markdown
@@ -0,0 +1,59 @@
1
+ """Benchmark richapi on a generated project.
2
+
3
+ Usage:
4
+ uv run python benchmarks/bench.py [--size small|medium|large] [--out schema.json]
5
+
6
+ Runs in a fresh process each time so no state leaks between runs.
7
+ """
8
+
9
+ import argparse
10
+ import importlib
11
+ import json
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ sys.path.insert(0, str(Path(__file__).parent))
19
+ from generate_project import generate # noqa: E402
20
+
21
+ SIZES: dict[str, dict[str, Any]] = {
22
+ "small": dict(n_services=5, n_funcs=20, n_routers=4, n_endpoints=5),
23
+ "medium": dict(n_services=30, n_funcs=40, n_routers=20, n_endpoints=15),
24
+ "large": dict(n_services=80, n_funcs=80, n_routers=60, n_endpoints=20),
25
+ }
26
+
27
+
28
+ def main() -> None:
29
+ parser = argparse.ArgumentParser()
30
+ parser.add_argument("--size", choices=SIZES, default="medium")
31
+ parser.add_argument("--out", type=Path, default=None)
32
+ parser.add_argument("--repeat", type=int, default=1)
33
+ args = parser.parse_args()
34
+
35
+ with tempfile.TemporaryDirectory() as tmp:
36
+ pkg = generate(Path(tmp), **SIZES[args.size])
37
+ sys.path.insert(0, tmp)
38
+ app = importlib.import_module(f"{pkg}.main").app
39
+
40
+ from richapi.exc_parser.openapi import compile_openapi_from_fastapi
41
+
42
+ n_routes = len(app.routes)
43
+ timings = []
44
+ schema = None
45
+ for _ in range(args.repeat):
46
+ start = time.perf_counter()
47
+ schema = compile_openapi_from_fastapi(app, pkg)
48
+ timings.append(time.perf_counter() - start)
49
+
50
+ print(
51
+ f"size={args.size} routes={n_routes} "
52
+ f"best={min(timings):.3f}s runs={[round(t, 3) for t in timings]}"
53
+ )
54
+ if args.out:
55
+ args.out.write_text(json.dumps(schema, indent=2, sort_keys=True))
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -0,0 +1,82 @@
1
+ """Compare richapi from a git ref (default: main) against the working tree.
2
+
3
+ Usage:
4
+ uv run python benchmarks/compare.py [--ref main] [--size medium]
5
+
6
+ Checks out <ref> into a temporary git worktree, runs bench.py with each
7
+ version on the same generated project, and verifies both produce the
8
+ exact same OpenAPI schema.
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ ROOT = Path(__file__).resolve().parent.parent
19
+ BENCH = Path(__file__).resolve().parent / "bench.py"
20
+
21
+
22
+ def run(richapi_root: Path, size: str, out: Path, repeat: int) -> str:
23
+ env = {**os.environ, "PYTHONPATH": str(richapi_root)}
24
+ cmd = [sys.executable, str(BENCH), "--size", size, "--out", str(out)]
25
+ cmd += ["--repeat", str(repeat)]
26
+ return subprocess.run(
27
+ cmd, env=env, check=True, capture_output=True, text=True
28
+ ).stdout.strip()
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser()
33
+ parser.add_argument("--ref", default="main")
34
+ parser.add_argument("--size", default="medium")
35
+ parser.add_argument("--repeat", type=int, default=1)
36
+ args = parser.parse_args()
37
+
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ tmp = Path(tmp)
40
+ worktree = tmp / "ref"
41
+ subprocess.run(
42
+ [
43
+ "git",
44
+ "-C",
45
+ str(ROOT),
46
+ "worktree",
47
+ "add",
48
+ "-q",
49
+ "--detach",
50
+ str(worktree),
51
+ args.ref,
52
+ ],
53
+ check=True,
54
+ )
55
+ try:
56
+ print(
57
+ f"{args.ref:>10}: {run(worktree, args.size, tmp / 'ref.json', args.repeat)}"
58
+ )
59
+ print(
60
+ f"{'current':>10}: {run(ROOT, args.size, tmp / 'cur.json', args.repeat)}"
61
+ )
62
+ same = (tmp / "ref.json").read_text() == (tmp / "cur.json").read_text()
63
+ print("schemas identical" if same else "SCHEMAS DIFFER")
64
+ finally:
65
+ subprocess.run(
66
+ [
67
+ "git",
68
+ "-C",
69
+ str(ROOT),
70
+ "worktree",
71
+ "remove",
72
+ "--force",
73
+ str(worktree),
74
+ ],
75
+ check=True,
76
+ )
77
+ if not same:
78
+ sys.exit(1)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,172 @@
1
+ """Generate a synthetic, large FastAPI project to benchmark richapi's analysis.
2
+
3
+ The layout mimics a real service: exceptions, many service modules full of
4
+ helpers/classes calling each other, shared dependencies and routers.
5
+ """
6
+
7
+ import random
8
+ import textwrap
9
+ from pathlib import Path
10
+
11
+
12
+ def _w(path: Path, body: str) -> None:
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ path.write_text(textwrap.dedent(body).lstrip())
15
+
16
+
17
+ def generate(
18
+ root: Path,
19
+ pkg: str = "bench_app",
20
+ n_services: int = 30,
21
+ n_funcs: int = 40,
22
+ n_routers: int = 20,
23
+ n_endpoints: int = 15,
24
+ seed: int = 0,
25
+ ) -> str:
26
+ rnd = random.Random(seed)
27
+ base = root / pkg
28
+ _w(base / "__init__.py", "")
29
+ _w(base / "services" / "__init__.py", "")
30
+ _w(base / "routes" / "__init__.py", "")
31
+
32
+ exc_lines = [
33
+ "from fastapi import HTTPException",
34
+ "from pydantic.dataclasses import dataclass",
35
+ "from richapi import BaseHTTPException, RichHTTPException",
36
+ "",
37
+ ]
38
+ for i in range(40):
39
+ exc_lines += [
40
+ "@dataclass",
41
+ f"class Rich{i}(RichHTTPException):",
42
+ f' """Rich error {i}"""',
43
+ f" status_code = {400 + i % 30}",
44
+ f" field_{i}: int = 0",
45
+ "",
46
+ f"class Plain{i}(HTTPException): ...",
47
+ "",
48
+ f"class Base{i}(BaseHTTPException):",
49
+ f" status_code = {400 + (i * 7) % 30}",
50
+ f' detail = "base error {i}"',
51
+ "",
52
+ ]
53
+ _w(base / "exceptions.py", "\n".join(exc_lines) + "\n")
54
+
55
+ for s in range(n_services):
56
+ lines = [
57
+ "from fastapi import HTTPException, status",
58
+ f"from {pkg} import exceptions as E",
59
+ f"from {pkg}.exceptions import Rich{s % 40}, Plain{s % 40}",
60
+ ]
61
+ if s > 0:
62
+ lines.append(f"from {pkg}.services import svc_{s - 1} as prev")
63
+ lines.append("")
64
+ for f in range(n_funcs):
65
+ kind = rnd.randrange(5)
66
+ lines.append(f"def helper_{f}(x: int = 0):")
67
+ if kind == 0:
68
+ lines.append(f" raise Rich{s % 40}()")
69
+ elif kind == 1:
70
+ lines.append(
71
+ f' raise Plain{s % 40}(status_code={400 + f % 30}, detail="svc {s} helper {f}")'
72
+ )
73
+ elif kind == 2:
74
+ lines.append(f" exc = E.Base{(s + f) % 40}()")
75
+ lines.append(" raise exc")
76
+ elif kind == 3 and f > 0:
77
+ lines.append(f" helper_{rnd.randrange(f)}(x)")
78
+ lines.append(
79
+ ' raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="missing")'
80
+ )
81
+ else:
82
+ lines.append(" total = 0")
83
+ lines.append(" for i in range(x):")
84
+ lines.append(" total += i")
85
+ lines.append(" return total")
86
+ if s > 0 and rnd.random() < 0.3:
87
+ lines.append(f" prev.helper_{rnd.randrange(n_funcs)}(x)")
88
+ lines.append("")
89
+ # filler to make modules realistically large
90
+ for f in range(n_funcs):
91
+ lines += [
92
+ f"def _private_{f}(a, b, c=None):",
93
+ ' """Some unrelated code that richapi has to parse anyway."""',
94
+ " data = {'a': a, 'b': b, 'c': c}",
95
+ " return [v for v in data.values() if v is not None]",
96
+ "",
97
+ ]
98
+ lines += [
99
+ f"class Repo{s}:",
100
+ " def get(self, x):",
101
+ f" helper_{rnd.randrange(n_funcs)}(x)",
102
+ " return x",
103
+ "",
104
+ f"class Service{s}:",
105
+ f" repo: Repo{s}",
106
+ "",
107
+ " def __init__(self):",
108
+ f" self.repo = Repo{s}()",
109
+ f" helper_{rnd.randrange(n_funcs)}()",
110
+ "",
111
+ ]
112
+ for m in range(10):
113
+ lines += [
114
+ f" def method_{m}(self, x: int):",
115
+ f" helper_{rnd.randrange(n_funcs)}(x)",
116
+ " self.repo.get(x)",
117
+ " return self",
118
+ "",
119
+ ]
120
+ _w(base / "services" / f"svc_{s}.py", "\n".join(lines) + "\n")
121
+
122
+ _w(
123
+ base / "deps.py",
124
+ f"""
125
+ import fastapi
126
+ import fastapi.security
127
+ from {pkg}.services import svc_0
128
+
129
+ bearer = fastapi.security.HTTPBearer()
130
+
131
+ def get_token(token=fastapi.Depends(bearer)):
132
+ svc_0.helper_0()
133
+ return token
134
+
135
+ def get_user(token: str = fastapi.Depends(get_token)):
136
+ if not token:
137
+ raise fastapi.HTTPException(status_code=401, detail="unauthorized")
138
+ return token
139
+ """,
140
+ )
141
+
142
+ for r in range(n_routers):
143
+ lines = [
144
+ "from fastapi import APIRouter, Depends, HTTPException",
145
+ f"from {pkg}.deps import get_user",
146
+ ]
147
+ used = sorted({rnd.randrange(n_services) for _ in range(4)})
148
+ for s in used:
149
+ lines.append(f"from {pkg}.services import svc_{s}")
150
+ lines.append(f"from {pkg}.services.svc_{s} import Service{s}")
151
+ lines += ["", "router = APIRouter()", ""]
152
+ for e in range(n_endpoints):
153
+ s = rnd.choice(used)
154
+ lines += [
155
+ f'@router.get("/r{r}/e{e}")',
156
+ f"async def endpoint_{r}_{e}(x: int, user: str = Depends(get_user)):",
157
+ f" svc = Service{s}()",
158
+ f" svc.method_{rnd.randrange(10)}(x).method_{rnd.randrange(10)}(x)",
159
+ f" svc_{s}.helper_{rnd.randrange(n_funcs)}(x)",
160
+ " if x < 0:",
161
+ f' raise HTTPException(status_code={400 + e % 20}, detail="route {r} {e}")',
162
+ " return {'ok': True}",
163
+ "",
164
+ ]
165
+ _w(base / "routes" / f"r_{r}.py", "\n".join(lines) + "\n")
166
+
167
+ lines = ["from fastapi import FastAPI"]
168
+ lines += [f"from {pkg}.routes import r_{r}" for r in range(n_routers)]
169
+ lines += ["", "app = FastAPI()"]
170
+ lines += [f"app.include_router(r_{r}.router)" for r in range(n_routers)]
171
+ _w(base / "main.py", "\n".join(lines) + "\n")
172
+ return pkg
@@ -49,3 +49,27 @@ app = FastAPI()
49
49
  # add your routers ....
50
50
  app.openapi = enrich_openapi(app, ["your_module_name", "some-other-package"])
51
51
  ```
52
+
53
+ ### Finding exceptions in specific API functions of FastAPI
54
+
55
+ If you want to limit the search of exceptions to specific functions, you can pass the list of functions to the `find_exceptions_in_api_functions` function.
56
+
57
+ ```python
58
+ from richapi import find_exceptions_in_api_functions, BaseHTTPException
59
+ from fastapi import FastAPI
60
+
61
+ app = FastAPI()
62
+
63
+ class NotFoundException(BaseHTTPException):
64
+ status_code = 404
65
+ detail: str = "Item not found"
66
+
67
+ @app.get("/items/{item_id}")
68
+ async def read_item(item_id: int):
69
+ if item_id != 42:
70
+ raise NotFoundException()
71
+ return {"item_id": item_id}
72
+
73
+ exceptions = find_exceptions_in_api_functions(funcs=[read_item], fastapi_app=app)
74
+ print(exceptions) # {NotFoundException}
75
+ ```
@@ -14,6 +14,9 @@ test: ## Test the code with pytest
14
14
  @echo "🚀 Testing code: Running pytest"
15
15
  uv run pytest
16
16
 
17
+ .PHONY: bench
18
+ bench: ## Benchmark analysis speed against main on a generated project
19
+ uv run python benchmarks/compare.py --ref main --size medium
17
20
 
18
21
  .PHONY: docs
19
22
  docs: ## Build and serve the documentation
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "richapi"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Find HTTPExceptions and turn them into documented responses!"
5
5
  readme = "README.md"
6
- requires-python = ">=3.9"
6
+ requires-python = ">=3.10"
7
7
  dependencies = ["typer>=0.12", "fastapi>=0.105"]
8
8
  license = 'MIT'
9
9
 
@@ -36,7 +36,7 @@ packages = ["richapi"]
36
36
  [tool.uv]
37
37
  dev-dependencies = [
38
38
  "ruff>=0.6.9",
39
- "pyright>=1.1.384",
39
+ "pyright>=1.1.397",
40
40
  "rich>=13.9.2",
41
41
  "pytest>=8.3.3",
42
42
  "pytest-asyncio>=0.24.0",
@@ -1,5 +1,9 @@
1
1
  from richapi.exc_parser.handler import add_exc_handler
2
- from richapi.exc_parser.openapi import enrich_openapi, load_openapi
2
+ from richapi.exc_parser.openapi import (
3
+ enrich_openapi,
4
+ find_exceptions_in_api_functions,
5
+ load_openapi,
6
+ )
3
7
  from richapi.exc_parser.protocol import BaseHTTPException, RichHTTPException
4
8
 
5
9
  __all__ = [
@@ -8,4 +12,5 @@ __all__ = [
8
12
  "BaseHTTPException",
9
13
  "RichHTTPException",
10
14
  "add_exc_handler",
15
+ "find_exceptions_in_api_functions",
11
16
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"