fastapi-error-map 0.9.6__tar.gz → 0.9.7__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 (37) hide show
  1. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/.github/workflows/ci.yaml +3 -3
  2. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/.github/workflows/test-compatibility.yaml +2 -4
  3. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/PKG-INFO +15 -25
  4. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/README.md +11 -7
  5. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/examples/errors.py +3 -1
  6. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/error_handling.py +13 -6
  7. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/openapi.py +6 -1
  8. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/rules.py +13 -5
  9. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/translator_policy.py +10 -3
  10. fastapi_error_map-0.9.7/noxfile.py +14 -0
  11. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/pyproject.toml +18 -12
  12. fastapi_error_map-0.9.7/tests/integration/conftest.py +25 -0
  13. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/integration/test_example.py +11 -5
  14. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/integration/test_routing.py +11 -6
  15. fastapi_error_map-0.9.7/tests/integration/test_threadpool.py +79 -0
  16. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/test_error_handling.py +102 -3
  17. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/test_openapi.py +20 -0
  18. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/test_rules.py +20 -0
  19. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/uv.lock +67 -24
  20. fastapi_error_map-0.9.6/noxfile.py +0 -13
  21. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/.gitignore +0 -0
  22. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/.pre-commit-config.yaml +0 -0
  23. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/LICENSE +0 -0
  24. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/Makefile +0 -0
  25. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/docs/example-openapi.png +0 -0
  26. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/examples/__init__.py +0 -0
  27. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/examples/main.py +0 -0
  28. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/__init__.py +0 -0
  29. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/py.typed +0 -0
  30. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/routing.py +0 -0
  31. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/fastapi_error_map/translators.py +0 -0
  32. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/__init__.py +0 -0
  33. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/integration/__init__.py +0 -0
  34. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/__init__.py +0 -0
  35. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/error_stubs.py +0 -0
  36. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/test_translators.py +0 -0
  37. {fastapi_error_map-0.9.6 → fastapi_error_map-0.9.7}/tests/unit/translator_stubs.py +0 -0
@@ -18,13 +18,13 @@ jobs:
18
18
  uses: astral-sh/setup-uv@v6
19
19
 
20
20
  - name: Install dependencies
21
- run: uv pip install -e '.[dev,test]' --system
21
+ run: uv sync --locked --group all
22
22
 
23
23
  - name: Lint code
24
- run: make code.lint
24
+ run: uv run make code.lint
25
25
 
26
26
  - name: Run tests for Codecov
27
- run: pytest --cov=fastapi_error_map --cov-branch --cov-report=xml
27
+ run: uv run pytest --cov=fastapi_error_map --cov-branch --cov-report=xml
28
28
 
29
29
  - name: Upload coverage reports to Codecov
30
30
  uses: codecov/codecov-action@v5
@@ -20,6 +20,7 @@ jobs:
20
20
  - "3.11"
21
21
  - "3.12"
22
22
  - "3.13"
23
+ - "3.14"
23
24
 
24
25
  steps:
25
26
  - uses: actions/checkout@v4
@@ -31,8 +32,5 @@ jobs:
31
32
  - name: Install uv
32
33
  uses: astral-sh/setup-uv@v6
33
34
 
34
- - name: Install nox
35
- run: uv pip install nox --system
36
-
37
35
  - name: Run tests
38
- run: nox -s compatibility
36
+ run: uvx nox -s compatibility
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-error-map
3
- Version: 0.9.6
3
+ Version: 0.9.7
4
4
  Summary: Elegant per-endpoint error handling for FastAPI that keeps OpenAPI in sync
5
5
  Project-URL: Homepage, https://github.com/ivan-borovets/fastapi-error-map
6
6
  Project-URL: Repository, https://github.com/ivan-borovets/fastapi-error-map
@@ -17,32 +17,18 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
20
21
  Classifier: Topic :: Software Development :: Libraries
21
22
  Classifier: Typing :: Typed
22
23
  Requires-Python: >=3.9
23
- Requires-Dist: fastapi>=0.100.0
24
- Requires-Dist: orjson>=3.8.4
25
- Provides-Extra: dev
26
- Requires-Dist: mypy==1.8.0; extra == 'dev'
27
- Requires-Dist: pre-commit==4.2.0; extra == 'dev'
28
- Requires-Dist: ruff==0.11.2; extra == 'dev'
29
- Provides-Extra: examples
30
- Requires-Dist: uvicorn==0.35.0; extra == 'examples'
31
- Provides-Extra: publish
32
- Requires-Dist: hatch==1.14.1; extra == 'publish'
33
- Provides-Extra: test
34
- Requires-Dist: coverage==7.10.1; extra == 'test'
35
- Requires-Dist: httpx==0.28.1; extra == 'test'
36
- Requires-Dist: nox==2025.5.1; extra == 'test'
37
- Requires-Dist: pytest-asyncio==1.1.0; extra == 'test'
38
- Requires-Dist: pytest-cov==6.2.1; extra == 'test'
39
- Requires-Dist: pytest==8.4.1; extra == 'test'
24
+ Requires-Dist: fastapi==0.100.0
25
+ Requires-Dist: orjson==3.11.1
40
26
  Description-Content-Type: text/markdown
41
27
 
42
28
  ## FastAPI Error Map
43
29
 
44
30
  [![PyPI version](https://badge.fury.io/py/fastapi-error-map.svg?cacheBust=6)](https://badge.fury.io/py/fastapi-error-map)
45
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=1)
31
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=2)
46
32
  [![codecov](https://codecov.io/gh/ivan-borovets/fastapi-error-map/branch/master/graph/badge.svg?token=ABTVQLI0RL)](https://codecov.io/gh/ivan-borovets/fastapi-error-map)
47
33
  ![GitHub License](https://img.shields.io/github/license/ivan-borovets/fastapi-error-map?cacheBust=1)
48
34
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ivan-borovets/fastapi-error-map/ci.yaml?cacheBust=1)
@@ -128,6 +114,9 @@ See the [example in the 🚀 Quickstart](#-quickstart).
128
114
  Error handling rules are defined directly in the route declaration of an `ErrorAwareRouter`, using the `error_map`
129
115
  parameter.
130
116
 
117
+ > [!NOTE]
118
+ > `error_map` handles HTTP error responses only — statuses must be 4xx or 5xx.
119
+
131
120
  There are two ways to do it:
132
121
 
133
122
  #### 🔸 Short Form
@@ -173,10 +162,10 @@ error_map = {
173
162
 
174
163
  Parameters of `rule(...)`, * — required:
175
164
 
176
- - `status`* — HTTP status code to return (e.g. `404`, `409`, `422`)
177
- - `translator` — object that converts an exception into JSON response. If not provided, the default one is used (returns
178
- `{ "error": "..." }`)
179
- - `on_error` — function to call when an exception occurs (e.g. logging or alerting)
165
+ - `status`* — HTTP status code to return (must be 4xx or 5xx; e.g. `404`, `409`, `422`)
166
+ - `translator` — object that converts an exception into serializable payload. If not provided, the default one is used
167
+ (`{ "error": str(err) }` for 4xx; `{ "error": "Internal server error" }` for 5xx).
168
+ - `on_error` — function to call when an exception occurs (e.g. logging or alerting). Can be awaitable
180
169
 
181
170
  #### 🧩 Matching semantics
182
171
 
@@ -228,7 +217,7 @@ If `from_error(...)` fails at runtime, the exception will propagate to FastAPI
228
217
  ### 🔄 Side Effects (`on_error`)
229
218
 
230
219
  The `on_error` parameter in `rule(...)` allows specifying function to run when exception occurs, before response is
231
- generated.
220
+ generated. The awaitable function will be awaited.
232
221
  It doesn’t change the response status/body when it succeeds and is useful for:
233
222
 
234
223
  - logging
@@ -301,7 +290,8 @@ When an error occurs, `fastapi-error-map` processes it as follows:
301
290
  - If the status is `>= 500`, `default_server_error_translator` is used (if given)
302
291
  - If none are set, the built-in one is used:
303
292
  ```raw
304
- { "error": "..." } or { "error": "Internal server error" }
293
+ { "error": str(err) } for 4xx;
294
+ { "error": "Internal server error" } for 5xx
305
295
  ```
306
296
 
307
297
  3. `on_error`:
@@ -1,7 +1,7 @@
1
1
  ## FastAPI Error Map
2
2
 
3
3
  [![PyPI version](https://badge.fury.io/py/fastapi-error-map.svg?cacheBust=6)](https://badge.fury.io/py/fastapi-error-map)
4
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=1)
4
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=2)
5
5
  [![codecov](https://codecov.io/gh/ivan-borovets/fastapi-error-map/branch/master/graph/badge.svg?token=ABTVQLI0RL)](https://codecov.io/gh/ivan-borovets/fastapi-error-map)
6
6
  ![GitHub License](https://img.shields.io/github/license/ivan-borovets/fastapi-error-map?cacheBust=1)
7
7
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ivan-borovets/fastapi-error-map/ci.yaml?cacheBust=1)
@@ -87,6 +87,9 @@ See the [example in the 🚀 Quickstart](#-quickstart).
87
87
  Error handling rules are defined directly in the route declaration of an `ErrorAwareRouter`, using the `error_map`
88
88
  parameter.
89
89
 
90
+ > [!NOTE]
91
+ > `error_map` handles HTTP error responses only — statuses must be 4xx or 5xx.
92
+
90
93
  There are two ways to do it:
91
94
 
92
95
  #### 🔸 Short Form
@@ -132,10 +135,10 @@ error_map = {
132
135
 
133
136
  Parameters of `rule(...)`, * — required:
134
137
 
135
- - `status`* — HTTP status code to return (e.g. `404`, `409`, `422`)
136
- - `translator` — object that converts an exception into JSON response. If not provided, the default one is used (returns
137
- `{ "error": "..." }`)
138
- - `on_error` — function to call when an exception occurs (e.g. logging or alerting)
138
+ - `status`* — HTTP status code to return (must be 4xx or 5xx; e.g. `404`, `409`, `422`)
139
+ - `translator` — object that converts an exception into serializable payload. If not provided, the default one is used
140
+ (`{ "error": str(err) }` for 4xx; `{ "error": "Internal server error" }` for 5xx).
141
+ - `on_error` — function to call when an exception occurs (e.g. logging or alerting). Can be awaitable
139
142
 
140
143
  #### 🧩 Matching semantics
141
144
 
@@ -187,7 +190,7 @@ If `from_error(...)` fails at runtime, the exception will propagate to FastAPI
187
190
  ### 🔄 Side Effects (`on_error`)
188
191
 
189
192
  The `on_error` parameter in `rule(...)` allows specifying function to run when exception occurs, before response is
190
- generated.
193
+ generated. The awaitable function will be awaited.
191
194
  It doesn’t change the response status/body when it succeeds and is useful for:
192
195
 
193
196
  - logging
@@ -260,7 +263,8 @@ When an error occurs, `fastapi-error-map` processes it as follows:
260
263
  - If the status is `>= 500`, `default_server_error_translator` is used (if given)
261
264
  - If none are set, the built-in one is used:
262
265
  ```raw
263
- { "error": "..." } or { "error": "Internal server error" }
266
+ { "error": str(err) } for 4xx;
267
+ { "error": "Internal server error" } for 5xx
264
268
  ```
265
269
 
266
270
  3. `on_error`:
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from dataclasses import dataclass
2
3
 
3
4
  from fastapi_error_map.translators import ErrorTranslator
@@ -25,5 +26,6 @@ class OutOfStockTranslator(ErrorTranslator[ErrorResponseModel]):
25
26
  return ErrorResponseModel
26
27
 
27
28
 
28
- def notify(err: Exception) -> None:
29
+ async def notify(err: Exception) -> None:
30
+ await asyncio.sleep(0)
29
31
  print("Notified admin:", err)
@@ -1,9 +1,11 @@
1
1
  import inspect
2
+ from collections.abc import Awaitable
2
3
  from functools import wraps
3
- from typing import Any, Callable, Optional
4
+ from typing import Any, Callable, Optional, Union
4
5
 
5
6
  from fastapi.encoders import jsonable_encoder
6
7
  from fastapi.responses import ORJSONResponse
8
+ from starlette.concurrency import run_in_threadpool
7
9
 
8
10
  from fastapi_error_map.rules import ErrorMap, resolve_rule_for_error
9
11
  from fastapi_error_map.translators import ErrorTranslator
@@ -16,7 +18,7 @@ def wrap_with_error_handling(
16
18
  warn_on_unmapped: bool,
17
19
  default_client_error_translator: ErrorTranslator[Any],
18
20
  default_server_error_translator: ErrorTranslator[Any],
19
- default_on_error: Optional[Callable[[Exception], None]],
21
+ default_on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]],
20
22
  ) -> Callable[..., Any]:
21
23
  is_coro = inspect.iscoroutinefunction(func)
22
24
 
@@ -27,7 +29,7 @@ def wrap_with_error_handling(
27
29
  return await func(*args, **kwargs)
28
30
  return func(*args, **kwargs)
29
31
  except Exception as error:
30
- return handle_with_error_map(
32
+ return await handle_with_error_map(
31
33
  error=error,
32
34
  error_map=error_map,
33
35
  warn_on_unmapped=warn_on_unmapped,
@@ -39,14 +41,14 @@ def wrap_with_error_handling(
39
41
  return wrapped
40
42
 
41
43
 
42
- def handle_with_error_map(
44
+ async def handle_with_error_map(
43
45
  *,
44
46
  error: Exception,
45
47
  error_map: ErrorMap,
46
48
  warn_on_unmapped: bool,
47
49
  default_client_error_translator: ErrorTranslator[Any],
48
50
  default_server_error_translator: ErrorTranslator[Any],
49
- default_on_error: Optional[Callable[[Exception], None]],
51
+ default_on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]],
50
52
  ) -> ORJSONResponse:
51
53
  try:
52
54
  rule = resolve_rule_for_error(
@@ -63,7 +65,12 @@ def handle_with_error_map(
63
65
  raise original.with_traceback(original.__traceback__) from None
64
66
 
65
67
  if rule.on_error is not None:
66
- rule.on_error(error)
68
+ if inspect.iscoroutinefunction(rule.on_error):
69
+ await rule.on_error(error)
70
+ else:
71
+ result = await run_in_threadpool(rule.on_error, error)
72
+ if inspect.isawaitable(result):
73
+ await result
67
74
 
68
75
  content = rule.translator.from_error(error)
69
76
  return ORJSONResponse(
@@ -1,7 +1,10 @@
1
1
  from typing import Any, Union
2
2
 
3
3
  from fastapi_error_map.rules import ErrorMap
4
- from fastapi_error_map.translator_policy import pick_translator_for_status
4
+ from fastapi_error_map.translator_policy import (
5
+ pick_translator_for_status,
6
+ validate_error_status,
7
+ )
5
8
  from fastapi_error_map.translators import ErrorTranslator
6
9
 
7
10
 
@@ -21,6 +24,8 @@ def build_openapi_responses(
21
24
  status = value.status
22
25
  translator = value.translator
23
26
 
27
+ validate_error_status(status)
28
+
24
29
  if translator is None:
25
30
  translator = pick_translator_for_status(
26
31
  status=status,
@@ -1,7 +1,11 @@
1
+ from collections.abc import Awaitable
1
2
  from dataclasses import dataclass
2
3
  from typing import Any, Callable, Optional, Union
3
4
 
4
- from fastapi_error_map.translator_policy import pick_translator_for_status
5
+ from fastapi_error_map.translator_policy import (
6
+ pick_translator_for_status,
7
+ validate_error_status,
8
+ )
5
9
  from fastapi_error_map.translators import ErrorTranslator
6
10
 
7
11
 
@@ -9,14 +13,14 @@ from fastapi_error_map.translators import ErrorTranslator
9
13
  class Rule:
10
14
  status: int
11
15
  translator: Optional[ErrorTranslator[Any]]
12
- on_error: Optional[Callable[[Exception], None]]
16
+ on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]]
13
17
 
14
18
 
15
19
  def rule(
16
20
  *,
17
21
  status: int,
18
22
  translator: Optional[ErrorTranslator[Any]] = None,
19
- on_error: Optional[Callable[[Exception], None]] = None,
23
+ on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]] = None,
20
24
  ) -> Rule:
21
25
  """
22
26
  Defines full error handling rule for use in `error_map`.
@@ -46,7 +50,7 @@ ErrorMap = dict[type[Exception], Union[int, Rule]]
46
50
  class ResolvedRule:
47
51
  status: int
48
52
  translator: ErrorTranslator[Any]
49
- on_error: Optional[Callable[[Exception], None]]
53
+ on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]]
50
54
 
51
55
 
52
56
  def resolve_rule_for_error(
@@ -55,7 +59,9 @@ def resolve_rule_for_error(
55
59
  error_map: ErrorMap,
56
60
  default_client_error_translator: ErrorTranslator[Any],
57
61
  default_server_error_translator: ErrorTranslator[Any],
58
- default_on_error: Optional[Callable[[Exception], None]] = None,
62
+ default_on_error: Optional[
63
+ Callable[[Exception], Union[Awaitable[None], None]]
64
+ ] = None,
59
65
  ) -> ResolvedRule:
60
66
  try:
61
67
  status_or_rule = error_map[type(error)]
@@ -71,6 +77,8 @@ def resolve_rule_for_error(
71
77
  status_or_rule.on_error or default_on_error,
72
78
  )
73
79
 
80
+ validate_error_status(status)
81
+
74
82
  if translator is None:
75
83
  translator = pick_translator_for_status(
76
84
  status=status,
@@ -1,12 +1,19 @@
1
1
  from typing import Any
2
2
 
3
- from starlette import status as http_status
4
-
5
3
  from fastapi_error_map.translators import ErrorTranslator
6
4
 
7
5
 
8
6
  def is_server_error(status: int) -> bool:
9
- return status >= http_status.HTTP_500_INTERNAL_SERVER_ERROR
7
+ return status // 100 == 5
8
+
9
+
10
+ def is_client_error(status: int) -> bool:
11
+ return status // 100 == 4
12
+
13
+
14
+ def validate_error_status(status: int) -> None:
15
+ if not (is_client_error(status) or is_server_error(status)):
16
+ raise RuntimeError(f"Unsupported status for error_map: {status}. Use 4xx/5xx.")
10
17
 
11
18
 
12
19
  def pick_translator_for_status(
@@ -0,0 +1,14 @@
1
+ import nox
2
+
3
+
4
+ @nox.session(python=None)
5
+ @nox.parametrize("fastapi", ["0.100.0", "latest"])
6
+ def compatibility(session, fastapi):
7
+ session.run("uv", "sync", "--locked", "--group", "test", "--active", external=True)
8
+
9
+ if fastapi == "latest":
10
+ session.run("uv", "add", "fastapi", "--active", external=True)
11
+ else:
12
+ session.run("uv", "add", f"fastapi=={fastapi}", "--active", external=True)
13
+
14
+ session.run("uv", "run", "--active", "make", "code.test", external=True)
@@ -7,7 +7,7 @@ sources = ["src"]
7
7
 
8
8
  [project]
9
9
  name = "fastapi-error-map"
10
- version = "0.9.6"
10
+ version = "0.9.7"
11
11
  description = "Elegant per-endpoint error handling for FastAPI that keeps OpenAPI in sync"
12
12
  readme = "README.md"
13
13
  license = { text = "Apache-2.0" }
@@ -23,6 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.11",
24
24
  "Programming Language :: Python :: 3.12",
25
25
  "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
26
27
  "Topic :: Software Development :: Libraries",
27
28
  "Typing :: Typed",
28
29
  "Intended Audience :: Developers",
@@ -30,19 +31,15 @@ classifiers = [
30
31
  "Framework :: FastAPI",
31
32
  ]
32
33
  dependencies = [
33
- "fastapi>=0.100.0",
34
- "orjson>=3.8.4",
34
+ "fastapi==0.100.0",
35
+ "orjson==3.11.1",
35
36
  ]
36
-
37
- [project.urls]
38
- Homepage = "https://github.com/ivan-borovets/fastapi-error-map"
39
- Repository = "https://github.com/ivan-borovets/fastapi-error-map"
40
-
41
- [project.optional-dependencies]
37
+ [dependency-groups]
42
38
  dev = [
43
39
  "mypy==1.8.0",
44
40
  "pre-commit==4.2.0",
45
41
  "ruff==0.11.2",
42
+ { include-group = "test" },
46
43
  ]
47
44
  test = [
48
45
  "coverage==7.10.1",
@@ -58,6 +55,15 @@ publish = [
58
55
  examples = [
59
56
  "uvicorn==0.35.0",
60
57
  ]
58
+ all = [
59
+ { include-group = "dev" },
60
+ { include-group = "publish" },
61
+ { include-group = "examples" },
62
+ ]
63
+
64
+ [project.urls]
65
+ Homepage = "https://github.com/ivan-borovets/fastapi-error-map"
66
+ Repository = "https://github.com/ivan-borovets/fastapi-error-map"
61
67
 
62
68
  [tool.coverage.report]
63
69
  show_missing = true
@@ -179,6 +185,6 @@ split-on-trailing-comma = true
179
185
  "src/fastapi_error_map/translators.py" = [
180
186
  "ARG002", # unused-method-argument
181
187
  ]
182
-
183
- [tool.slotscheck]
184
- strict-imports = true
188
+ "src/fastapi_error_map/translator_policy.py" = [
189
+ "PLR2004", # magic-value-comparison
190
+ ]
@@ -0,0 +1,25 @@
1
+ from collections.abc import AsyncIterator
2
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
3
+ from typing import Callable
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+ from httpx import ASGITransport, AsyncClient
8
+
9
+ AsgiClientFactory = Callable[[FastAPI], AbstractAsyncContextManager[AsyncClient]]
10
+
11
+
12
+ @pytest.fixture
13
+ def app() -> FastAPI:
14
+ return FastAPI()
15
+
16
+
17
+ @pytest.fixture
18
+ def asgi_client_factory() -> AsgiClientFactory:
19
+ @asynccontextmanager
20
+ async def _make(app: FastAPI) -> AsyncIterator[AsyncClient]:
21
+ transport = ASGITransport(app=app)
22
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
23
+ yield client
24
+
25
+ return _make
@@ -1,16 +1,22 @@
1
- import httpx
1
+ from typing import TYPE_CHECKING
2
+
2
3
  import pytest
3
- from httpx import ASGITransport
4
4
 
5
5
  from examples.main import create_app
6
+ from tests.integration.conftest import AsgiClientFactory
7
+
8
+ if TYPE_CHECKING:
9
+ import httpx
6
10
 
7
11
 
8
12
  @pytest.mark.asyncio
9
- async def test_check_stock(capsys: pytest.CaptureFixture[str]) -> None:
13
+ async def test_check_stock(
14
+ asgi_client_factory: AsgiClientFactory,
15
+ capsys: pytest.CaptureFixture[str],
16
+ ) -> None:
10
17
  app = create_app()
11
18
 
12
- transport = ASGITransport(app=app)
13
- async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
19
+ async with asgi_client_factory(app) as client:
14
20
  authz_err_response: httpx.Response = await client.get("/stock?user_id=0")
15
21
  out_of_stock_err_response: httpx.Response = await client.get("/stock?user_id=1")
16
22
  openapi_response: httpx.Response = await client.get("/openapi.json")
@@ -1,12 +1,15 @@
1
1
  import asyncio
2
+ from typing import TYPE_CHECKING
2
3
  from unittest.mock import Mock
3
4
 
4
- import httpx
5
5
  import pytest
6
6
  from fastapi import FastAPI
7
- from httpx import ASGITransport
8
7
 
9
8
  from fastapi_error_map import ErrorAwareRouter, rule
9
+ from tests.integration.conftest import AsgiClientFactory
10
+
11
+ if TYPE_CHECKING:
12
+ import httpx
10
13
 
11
14
 
12
15
  class CustomError(Exception):
@@ -15,8 +18,11 @@ class CustomError(Exception):
15
18
 
16
19
  @pytest.mark.asyncio
17
20
  @pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete"])
18
- async def test_error_aware_router_routes(method: str) -> None:
19
- app = FastAPI()
21
+ async def test_error_aware_router_routes(
22
+ method: str,
23
+ app: FastAPI,
24
+ asgi_client_factory: AsgiClientFactory,
25
+ ) -> None:
20
26
  router = ErrorAwareRouter()
21
27
  router_path = "/fail"
22
28
  error_message = "This is a test"
@@ -35,8 +41,7 @@ async def test_error_aware_router_routes(method: str) -> None:
35
41
  )(failing_endpoint)
36
42
  app.include_router(router)
37
43
 
38
- transport = ASGITransport(app=app)
39
- async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
44
+ async with asgi_client_factory(app) as client:
40
45
  response: httpx.Response = await getattr(client, method)(router_path)
41
46
  openapi_response: httpx.Response = await client.get("/openapi.json")
42
47
 
@@ -0,0 +1,79 @@
1
+ import asyncio
2
+ from typing import TYPE_CHECKING
3
+
4
+ import pytest
5
+ from fastapi import FastAPI
6
+
7
+ from fastapi_error_map import ErrorAwareRouter, rule
8
+ from tests.integration.conftest import AsgiClientFactory
9
+
10
+ if TYPE_CHECKING:
11
+ import httpx
12
+
13
+
14
+ def has_running_loop_in_this_thread() -> bool:
15
+ try:
16
+ asyncio.get_running_loop()
17
+ return True
18
+ except RuntimeError:
19
+ return False
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_fastapi_sync_handler_runs_in_threadpool(
24
+ app: FastAPI,
25
+ asgi_client_factory: AsgiClientFactory,
26
+ ) -> None:
27
+ @app.get("/")
28
+ def index():
29
+ return {"in_loop": has_running_loop_in_this_thread()}
30
+
31
+ async with asgi_client_factory(app) as client:
32
+ index_response: httpx.Response = await client.get("/")
33
+
34
+ assert index_response.status_code == 200
35
+ assert index_response.json() == {"in_loop": False}
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_error_aware_router_sync_handler_runs_in_threadpool(
40
+ app: FastAPI,
41
+ asgi_client_factory: AsgiClientFactory,
42
+ ) -> None:
43
+ router = ErrorAwareRouter()
44
+
45
+ @app.get("/")
46
+ def index():
47
+ return {"in_loop": has_running_loop_in_this_thread()}
48
+
49
+ app.include_router(router)
50
+
51
+ async with asgi_client_factory(app) as client:
52
+ index_response: httpx.Response = await client.get("/")
53
+
54
+ assert index_response.status_code == 200
55
+ assert index_response.json() == {"in_loop": False}
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_error_aware_router_sync_on_error_runs_in_threadpool(
60
+ app: FastAPI,
61
+ asgi_client_factory: AsgiClientFactory,
62
+ ) -> None:
63
+ router = ErrorAwareRouter()
64
+ seen: dict[str, bool] = {}
65
+
66
+ def on_error(_: Exception) -> None:
67
+ seen["in_loop"] = has_running_loop_in_this_thread()
68
+
69
+ @router.get("/err", error_map={ValueError: rule(status=400, on_error=on_error)})
70
+ def index():
71
+ raise ValueError
72
+
73
+ app.include_router(router)
74
+
75
+ async with asgi_client_factory(app) as client:
76
+ index_response: httpx.Response = await client.get("/err")
77
+
78
+ assert index_response.status_code == 400
79
+ assert seen["in_loop"] is False
@@ -1,12 +1,17 @@
1
1
  import asyncio
2
- from typing import NoReturn
3
- from unittest.mock import Mock
2
+ from collections.abc import Awaitable
3
+ from functools import partial
4
+ from typing import NoReturn, cast
5
+ from unittest.mock import AsyncMock, Mock
4
6
 
5
7
  import pytest
6
8
  from starlette.responses import JSONResponse
7
9
 
8
10
  from fastapi_error_map import rule
9
- from fastapi_error_map.error_handling import wrap_with_error_handling
11
+ from fastapi_error_map.error_handling import (
12
+ handle_with_error_map,
13
+ wrap_with_error_handling,
14
+ )
10
15
  from fastapi_error_map.translators import (
11
16
  DefaultClientErrorTranslator,
12
17
  DefaultServerErrorTranslator,
@@ -126,3 +131,97 @@ async def test_wrap_calls_on_error_with_error_if_defined() -> None:
126
131
 
127
132
  mock_on_error.assert_called_once()
128
133
  assert isinstance(mock_on_error.call_args[0][0], ValidationError)
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_handles_sync_on_error_from_map() -> None:
138
+ mock_on_error = Mock()
139
+ error = ValidationError()
140
+
141
+ sut = await handle_with_error_map(
142
+ error=error,
143
+ error_map={ValidationError: rule(status=422, on_error=mock_on_error)},
144
+ warn_on_unmapped=True,
145
+ default_client_error_translator=DefaultClientErrorTranslator(),
146
+ default_server_error_translator=DefaultServerErrorTranslator(),
147
+ default_on_error=None,
148
+ )
149
+
150
+ assert sut.status_code == 422
151
+ mock_on_error.assert_called_once_with(error)
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_handles_default_on_error_from_defaults() -> None:
156
+ mock_on_error = AsyncMock()
157
+ error = ValidationError()
158
+
159
+ sut = await handle_with_error_map(
160
+ error=error,
161
+ error_map={ValidationError: 422},
162
+ warn_on_unmapped=True,
163
+ default_client_error_translator=DefaultClientErrorTranslator(),
164
+ default_server_error_translator=DefaultServerErrorTranslator(),
165
+ default_on_error=mock_on_error,
166
+ )
167
+
168
+ assert sut.status_code == 422
169
+ mock_on_error.assert_awaited_once_with(error)
170
+
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_handles_async_on_error() -> None:
174
+ mock_on_error = AsyncMock()
175
+ error = ValidationError()
176
+
177
+ sut = await handle_with_error_map(
178
+ error=error,
179
+ error_map={ValidationError: rule(status=422, on_error=mock_on_error)},
180
+ warn_on_unmapped=True,
181
+ default_client_error_translator=DefaultClientErrorTranslator(),
182
+ default_server_error_translator=DefaultServerErrorTranslator(),
183
+ default_on_error=None,
184
+ )
185
+
186
+ assert sut.status_code == 422
187
+ mock_on_error.assert_awaited_once_with(error)
188
+
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_handles_async_on_error_partial() -> None:
192
+ wrapped = AsyncMock()
193
+ mock_on_error = partial(wrapped)
194
+ error = ValidationError()
195
+
196
+ sut = await handle_with_error_map(
197
+ error=error,
198
+ error_map={ValidationError: rule(status=422, on_error=mock_on_error)},
199
+ warn_on_unmapped=True,
200
+ default_client_error_translator=DefaultClientErrorTranslator(),
201
+ default_server_error_translator=DefaultServerErrorTranslator(),
202
+ default_on_error=None,
203
+ )
204
+
205
+ assert sut.status_code == 422
206
+ wrapped.assert_awaited_once_with(error)
207
+
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_handles_sync_on_error_returning_awaitable() -> None:
211
+ coro = AsyncMock()
212
+ error = ValidationError()
213
+
214
+ def on_error(err: Exception) -> Awaitable[None]:
215
+ return cast("Awaitable[None]", coro(err))
216
+
217
+ sut = await handle_with_error_map(
218
+ error=error,
219
+ error_map={ValidationError: rule(status=422, on_error=on_error)},
220
+ warn_on_unmapped=True,
221
+ default_client_error_translator=DefaultClientErrorTranslator(),
222
+ default_server_error_translator=DefaultServerErrorTranslator(),
223
+ default_on_error=None,
224
+ )
225
+
226
+ assert sut.status_code == 422
227
+ coro.assert_awaited_once_with(error)
@@ -1,5 +1,8 @@
1
1
  from typing import TYPE_CHECKING
2
2
 
3
+ import pytest
4
+ from starlette import status
5
+
3
6
  from fastapi_error_map.openapi import build_openapi_responses
4
7
  from fastapi_error_map.rules import rule
5
8
  from tests.unit.error_stubs import DatabaseError, ValidationError
@@ -63,3 +66,20 @@ def test_builds_response_with_custom_translator() -> None:
63
66
  )
64
67
 
65
68
  assert responses == {400: {"model": dict}}
69
+
70
+
71
+ @pytest.mark.parametrize(
72
+ "bad_status",
73
+ [
74
+ status.HTTP_200_OK,
75
+ status.HTTP_300_MULTIPLE_CHOICES,
76
+ status.WS_1000_NORMAL_CLOSURE,
77
+ ],
78
+ )
79
+ def test_rejects_non_error_status_codes(bad_status: int) -> None:
80
+ with pytest.raises(RuntimeError):
81
+ build_openapi_responses(
82
+ error_map={ValidationError: bad_status},
83
+ default_client_error_translator=DummyClientErrorTranslator(),
84
+ default_server_error_translator=DummyServerErrorTranslator(),
85
+ )
@@ -1,6 +1,7 @@
1
1
  from typing import Union
2
2
 
3
3
  import pytest
4
+ from starlette import status
4
5
 
5
6
  from fastapi_error_map.rules import Rule, resolve_rule_for_error, rule
6
7
  from tests.unit.error_stubs import DatabaseError, UnknownError, ValidationError
@@ -55,6 +56,25 @@ def test_resolves_status_code_with_server_error_translator(
55
56
  assert isinstance(sut.translator, DummyServerErrorTranslator)
56
57
 
57
58
 
59
+ @pytest.mark.parametrize(
60
+ "bad_status",
61
+ [
62
+ status.HTTP_200_OK,
63
+ status.HTTP_300_MULTIPLE_CHOICES,
64
+ status.WS_1000_NORMAL_CLOSURE,
65
+ ],
66
+ )
67
+ def test_resolver_rejects_non_error_status_codes(bad_status: int) -> None:
68
+ with pytest.raises(RuntimeError):
69
+ resolve_rule_for_error(
70
+ error=ValidationError(),
71
+ error_map={ValidationError: bad_status},
72
+ default_client_error_translator=DummyClientErrorTranslator(),
73
+ default_server_error_translator=DummyServerErrorTranslator(),
74
+ default_on_error=None,
75
+ )
76
+
77
+
58
78
  def test_does_not_override_explicit_translator() -> None:
59
79
  custom_translator = CustomTranslator()
60
80
 
@@ -377,31 +377,50 @@ wheels = [
377
377
 
378
378
  [[package]]
379
379
  name = "fastapi"
380
- version = "0.116.1"
380
+ version = "0.100.0"
381
381
  source = { registry = "https://pypi.org/simple" }
382
382
  dependencies = [
383
383
  { name = "pydantic" },
384
384
  { name = "starlette" },
385
385
  { name = "typing-extensions" },
386
386
  ]
387
- sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
387
+ sdist = { url = "https://files.pythonhosted.org/packages/65/e0/f9d77b3a1569e2217abf260b0b1462401736973d1c5d3d335f6f2009daa2/fastapi-0.100.0.tar.gz", hash = "sha256:acb5f941ea8215663283c10018323ba7ea737c571b67fc7e88e9469c7eb1d12e", size = 10440814, upload-time = "2023-07-07T17:33:19.001Z" }
388
388
  wheels = [
389
- { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
389
+ { url = "https://files.pythonhosted.org/packages/49/f5/048206823aae9b3a4a61ba6b7a1dd1de36bd4c0a0283f2efb1f1f2289c8a/fastapi-0.100.0-py3-none-any.whl", hash = "sha256:271662daf986da8fa98dc2b7c7f61c4abdfdccfb4786d79ed8b2878f172c6d5f", size = 65736, upload-time = "2023-07-07T17:33:17.001Z" },
390
390
  ]
391
391
 
392
392
  [[package]]
393
393
  name = "fastapi-error-map"
394
- version = "0.9.0"
394
+ version = "0.9.7"
395
395
  source = { editable = "." }
396
396
  dependencies = [
397
397
  { name = "fastapi" },
398
398
  { name = "orjson" },
399
399
  ]
400
400
 
401
- [package.optional-dependencies]
401
+ [package.dev-dependencies]
402
+ all = [
403
+ { name = "coverage" },
404
+ { name = "hatch" },
405
+ { name = "httpx" },
406
+ { name = "mypy" },
407
+ { name = "nox" },
408
+ { name = "pre-commit" },
409
+ { name = "pytest" },
410
+ { name = "pytest-asyncio" },
411
+ { name = "pytest-cov" },
412
+ { name = "ruff" },
413
+ { name = "uvicorn" },
414
+ ]
402
415
  dev = [
416
+ { name = "coverage" },
417
+ { name = "httpx" },
403
418
  { name = "mypy" },
419
+ { name = "nox" },
404
420
  { name = "pre-commit" },
421
+ { name = "pytest" },
422
+ { name = "pytest-asyncio" },
423
+ { name = "pytest-cov" },
405
424
  { name = "ruff" },
406
425
  ]
407
426
  examples = [
@@ -421,21 +440,45 @@ test = [
421
440
 
422
441
  [package.metadata]
423
442
  requires-dist = [
424
- { name = "coverage", marker = "extra == 'test'", specifier = "==7.10.1" },
425
- { name = "fastapi", specifier = ">=0.100.0" },
426
- { name = "hatch", marker = "extra == 'publish'", specifier = "==1.14.1" },
427
- { name = "httpx", marker = "extra == 'test'", specifier = "==0.28.1" },
428
- { name = "mypy", marker = "extra == 'dev'", specifier = "==1.8.0" },
429
- { name = "nox", marker = "extra == 'test'", specifier = "==2025.5.1" },
430
- { name = "orjson", specifier = ">=3.11.1" },
431
- { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.2.0" },
432
- { name = "pytest", marker = "extra == 'test'", specifier = "==8.4.1" },
433
- { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==1.1.0" },
434
- { name = "pytest-cov", marker = "extra == 'test'", specifier = "==6.2.1" },
435
- { name = "ruff", marker = "extra == 'dev'", specifier = "==0.11.2" },
436
- { name = "uvicorn", marker = "extra == 'examples'", specifier = "==0.35.0" },
437
- ]
438
- provides-extras = ["dev", "test", "publish", "examples"]
443
+ { name = "fastapi", specifier = "==0.100.0" },
444
+ { name = "orjson", specifier = "==3.11.1" },
445
+ ]
446
+
447
+ [package.metadata.requires-dev]
448
+ all = [
449
+ { name = "coverage", specifier = "==7.10.1" },
450
+ { name = "hatch", specifier = "==1.14.1" },
451
+ { name = "httpx", specifier = "==0.28.1" },
452
+ { name = "mypy", specifier = "==1.8.0" },
453
+ { name = "nox", specifier = "==2025.5.1" },
454
+ { name = "pre-commit", specifier = "==4.2.0" },
455
+ { name = "pytest", specifier = "==8.4.1" },
456
+ { name = "pytest-asyncio", specifier = "==1.1.0" },
457
+ { name = "pytest-cov", specifier = "==6.2.1" },
458
+ { name = "ruff", specifier = "==0.11.2" },
459
+ { name = "uvicorn", specifier = "==0.35.0" },
460
+ ]
461
+ dev = [
462
+ { name = "coverage", specifier = "==7.10.1" },
463
+ { name = "httpx", specifier = "==0.28.1" },
464
+ { name = "mypy", specifier = "==1.8.0" },
465
+ { name = "nox", specifier = "==2025.5.1" },
466
+ { name = "pre-commit", specifier = "==4.2.0" },
467
+ { name = "pytest", specifier = "==8.4.1" },
468
+ { name = "pytest-asyncio", specifier = "==1.1.0" },
469
+ { name = "pytest-cov", specifier = "==6.2.1" },
470
+ { name = "ruff", specifier = "==0.11.2" },
471
+ ]
472
+ examples = [{ name = "uvicorn", specifier = "==0.35.0" }]
473
+ publish = [{ name = "hatch", specifier = "==1.14.1" }]
474
+ test = [
475
+ { name = "coverage", specifier = "==7.10.1" },
476
+ { name = "httpx", specifier = "==0.28.1" },
477
+ { name = "nox", specifier = "==2025.5.1" },
478
+ { name = "pytest", specifier = "==8.4.1" },
479
+ { name = "pytest-asyncio", specifier = "==1.1.0" },
480
+ { name = "pytest-cov", specifier = "==6.2.1" },
481
+ ]
439
482
 
440
483
  [[package]]
441
484
  name = "filelock"
@@ -1225,15 +1268,15 @@ wheels = [
1225
1268
 
1226
1269
  [[package]]
1227
1270
  name = "starlette"
1228
- version = "0.47.2"
1271
+ version = "0.27.0"
1229
1272
  source = { registry = "https://pypi.org/simple" }
1230
1273
  dependencies = [
1231
1274
  { name = "anyio" },
1232
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1275
+ { name = "typing-extensions", marker = "python_full_version < '3.10'" },
1233
1276
  ]
1234
- sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" }
1277
+ sdist = { url = "https://files.pythonhosted.org/packages/06/68/559bed5484e746f1ab2ebbe22312f2c25ec62e4b534916d41a8c21147bf8/starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", size = 51394, upload-time = "2023-05-16T10:59:56.286Z" }
1235
1278
  wheels = [
1236
- { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
1279
+ { url = "https://files.pythonhosted.org/packages/58/f8/e2cca22387965584a409795913b774235752be4176d276714e15e1a58884/starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91", size = 66978, upload-time = "2023-05-16T10:59:53.927Z" },
1237
1280
  ]
1238
1281
 
1239
1282
  [[package]]
@@ -1,13 +0,0 @@
1
- import nox
2
-
3
-
4
- @nox.session(python=None)
5
- @nox.parametrize("fastapi", ["0.100.0", "latest"])
6
- def compatibility(session, fastapi):
7
- if fastapi == "latest":
8
- session.run("uv", "pip", "install", "fastapi", external=True)
9
- else:
10
- session.run("uv", "pip", "install", f"fastapi=={fastapi}", external=True)
11
-
12
- session.run("uv", "pip", "install", "-e", ".[test]", external=True)
13
- session.run("make", "code.test", external=True)