fastapi-error-map 0.9.8__tar.gz → 0.9.9__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 (40) hide show
  1. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/.github/workflows/ci.yaml +5 -4
  2. fastapi_error_map-0.9.9/.github/workflows/publish.yaml +59 -0
  3. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/.github/workflows/test-compatibility.yaml +5 -3
  4. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/.pre-commit-config.yaml +3 -3
  5. fastapi_error_map-0.9.9/Makefile +24 -0
  6. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/PKG-INFO +14 -5
  7. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/README.md +13 -4
  8. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/error_handling.py +8 -2
  9. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/routing.py +16 -0
  10. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/rules.py +8 -3
  11. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/noxfile.py +1 -1
  12. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/pyproject.toml +5 -2
  13. fastapi_error_map-0.9.9/tests/integration/conftest.py +19 -0
  14. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/integration/test_example.py +5 -8
  15. fastapi_error_map-0.9.9/tests/integration/test_exclude_none.py +85 -0
  16. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/integration/test_routing.py +4 -9
  17. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/integration/test_threadpool.py +15 -18
  18. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/test_error_handling.py +5 -0
  19. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/test_rules.py +67 -0
  20. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/uv.lock +33 -7
  21. fastapi_error_map-0.9.8/Makefile +0 -23
  22. fastapi_error_map-0.9.8/tests/integration/conftest.py +0 -25
  23. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/.gitignore +0 -0
  24. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/LICENSE +0 -0
  25. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/docs/example-openapi.png +0 -0
  26. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/examples/__init__.py +0 -0
  27. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/examples/errors.py +0 -0
  28. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/examples/main.py +0 -0
  29. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/__init__.py +0 -0
  30. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/openapi.py +0 -0
  31. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/py.typed +0 -0
  32. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/translator_policy.py +0 -0
  33. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/fastapi_error_map/translators.py +0 -0
  34. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/__init__.py +0 -0
  35. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/integration/__init__.py +0 -0
  36. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/__init__.py +0 -0
  37. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/error_stubs.py +0 -0
  38. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/test_openapi.py +0 -0
  39. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/test_translators.py +0 -0
  40. {fastapi_error_map-0.9.8 → fastapi_error_map-0.9.9}/tests/unit/translator_stubs.py +0 -0
@@ -7,21 +7,22 @@ jobs:
7
7
  runs-on: ubuntu-latest
8
8
 
9
9
  steps:
10
- - uses: actions/checkout@v4
10
+ - name: Checkout
11
+ uses: actions/checkout@v6
11
12
 
12
13
  - name: Set up Python
13
- uses: actions/setup-python@v5
14
+ uses: actions/setup-python@v6
14
15
  with:
15
16
  python-version: "3.9"
16
17
 
17
18
  - name: Install uv
18
- uses: astral-sh/setup-uv@v6
19
+ uses: astral-sh/setup-uv@v7
19
20
 
20
21
  - name: Install dependencies
21
22
  run: uv sync --locked --group all
22
23
 
23
24
  - name: Lint code
24
- run: uv run make code.lint
25
+ run: uv run make lint
25
26
 
26
27
  - name: Run tests for Codecov
27
28
  run: uv run pytest --cov=fastapi_error_map --cov-branch --cov-report=xml
@@ -0,0 +1,59 @@
1
+ name: Publish to PyPI on GitHub Release
2
+
3
+ on:
4
+ release:
5
+ types: [ published ]
6
+
7
+ env:
8
+ UV_PYTHON_DOWNLOADS: 0
9
+
10
+ jobs:
11
+ build:
12
+ name: Build distribution
13
+ runs-on: ubuntu-latest
14
+ permissions: { }
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v6
18
+ with:
19
+ persist-credentials: false
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v6
23
+ with:
24
+ python-version: "3.13"
25
+
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v7
28
+
29
+ - name: Build
30
+ run: uv build
31
+
32
+ - name: Upload artifact
33
+ uses: actions/upload-artifact@v6
34
+ with:
35
+ name: python-package-distributions
36
+ path: dist/*
37
+
38
+ publish:
39
+ name: Publish to PyPI (OIDC)
40
+ needs: build
41
+ runs-on: ubuntu-latest
42
+ environment:
43
+ name: pypi
44
+ permissions:
45
+ id-token: write
46
+ contents: read
47
+
48
+ steps:
49
+ - name: Download artifact
50
+ uses: actions/download-artifact@v7
51
+ with:
52
+ name: python-package-distributions
53
+ path: dist/
54
+
55
+ - name: Install uv
56
+ uses: astral-sh/setup-uv@v7
57
+
58
+ - name: Publish
59
+ run: uv publish
@@ -23,14 +23,16 @@ jobs:
23
23
  - "3.14"
24
24
 
25
25
  steps:
26
- - uses: actions/checkout@v4
26
+ - name: Checkout
27
+ uses: actions/checkout@v6
28
+
27
29
  - name: Set up ${{ matrix.python-version }} on ${{ matrix.os }}
28
- uses: actions/setup-python@v5
30
+ uses: actions/setup-python@v6
29
31
  with:
30
32
  python-version: ${{ matrix.python-version }}
31
33
 
32
34
  - name: Install uv
33
- uses: astral-sh/setup-uv@v6
35
+ uses: astral-sh/setup-uv@v7
34
36
 
35
37
  - name: Run tests
36
38
  run: uvx nox -s compatibility
@@ -1,9 +1,9 @@
1
1
  repos:
2
2
  - repo: local
3
3
  hooks:
4
- - id: make-check
5
- name: source-code-check
6
- entry: make code.check
4
+ - id: code-check
5
+ name: code-check (local)
6
+ entry: uv run make check
7
7
  language: system
8
8
  pass_filenames: false
9
9
  always_run: true
@@ -0,0 +1,24 @@
1
+ # Make config
2
+ .SILENT:
3
+ MAKEFLAGS += --no-print-directory
4
+
5
+ # Code quality
6
+ .PHONY: lint test check coverage
7
+ lint:
8
+ ruff check --fix
9
+ ruff format
10
+ mypy
11
+
12
+ test:
13
+ pytest -v
14
+
15
+ check: lint test
16
+
17
+ coverage: check
18
+ coverage html
19
+
20
+ # Project structure visualization
21
+ .PHONY: pycache-del
22
+ pycache-del:
23
+ find . -type d -name '__pycache__' -prune -exec rm -rf {} +; \
24
+ find . -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-error-map
3
- Version: 0.9.8
3
+ Version: 0.9.9
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
@@ -169,10 +169,13 @@ Parameters of `rule(...)`, * — required:
169
169
 
170
170
  #### 🧩 Matching semantics
171
171
 
172
- `error_map` matches **exact** exception types only (no inheritance).
173
- If you map `BaseError` and raise `ChildError(BaseError)`, the rule won’t apply.
174
- This is by design to keep routing explicit.
175
- If there’s demand, inheritance-based resolving may be added later as an opt-in.
172
+ `error_map` resolves exceptions using Python's Method Resolution Order (MRO).
173
+ The most specific exception type is matched first.
174
+ If no exact match is found, parent classes are checked in MRO order.
175
+
176
+ For example, if you map `BaseError` and raise `ChildError(BaseError)`,
177
+ the rule for `BaseError` will apply — unless a specific rule for
178
+ `ChildError` is defined, in which case it takes precedence.
176
179
 
177
180
  ### 🧰 Custom Translators
178
181
 
@@ -271,6 +274,7 @@ In addition to `error_map`, you can also pass:
271
274
  warn_on_unmapped=...,
272
275
  default_client_error_translator=...,
273
276
  default_server_error_translator=...,
277
+ exclude_none=...,
274
278
  )
275
279
  ```
276
280
 
@@ -299,6 +303,11 @@ When an error occurs, `fastapi-error-map` processes it as follows:
299
303
  - Otherwise, `default_on_error` is used if provided
300
304
  - If neither is set, nothing is called
301
305
 
306
+ 4. `exclude_none`:
307
+ - If `True`, fields with value `None` are omitted from the serialized
308
+ error response body
309
+ - If `False` (default), `None` values are included as `null`
310
+
302
311
  #### 🧾 OpenAPI: `responses` Takes Priority
303
312
 
304
313
  If you explicitly pass the `responses=...` parameter to `.get(...)` / `.post(...)`, it overrides the schema generation
@@ -142,10 +142,13 @@ Parameters of `rule(...)`, * — required:
142
142
 
143
143
  #### 🧩 Matching semantics
144
144
 
145
- `error_map` matches **exact** exception types only (no inheritance).
146
- If you map `BaseError` and raise `ChildError(BaseError)`, the rule won’t apply.
147
- This is by design to keep routing explicit.
148
- If there’s demand, inheritance-based resolving may be added later as an opt-in.
145
+ `error_map` resolves exceptions using Python's Method Resolution Order (MRO).
146
+ The most specific exception type is matched first.
147
+ If no exact match is found, parent classes are checked in MRO order.
148
+
149
+ For example, if you map `BaseError` and raise `ChildError(BaseError)`,
150
+ the rule for `BaseError` will apply — unless a specific rule for
151
+ `ChildError` is defined, in which case it takes precedence.
149
152
 
150
153
  ### 🧰 Custom Translators
151
154
 
@@ -244,6 +247,7 @@ In addition to `error_map`, you can also pass:
244
247
  warn_on_unmapped=...,
245
248
  default_client_error_translator=...,
246
249
  default_server_error_translator=...,
250
+ exclude_none=...,
247
251
  )
248
252
  ```
249
253
 
@@ -272,6 +276,11 @@ When an error occurs, `fastapi-error-map` processes it as follows:
272
276
  - Otherwise, `default_on_error` is used if provided
273
277
  - If neither is set, nothing is called
274
278
 
279
+ 4. `exclude_none`:
280
+ - If `True`, fields with value `None` are omitted from the serialized
281
+ error response body
282
+ - If `False` (default), `None` values are included as `null`
283
+
275
284
  #### 🧾 OpenAPI: `responses` Takes Priority
276
285
 
277
286
  If you explicitly pass the `responses=...` parameter to `.get(...)` / `.post(...)`, it overrides the schema generation
@@ -19,6 +19,7 @@ def wrap_with_error_handling(
19
19
  default_client_error_translator: ErrorTranslator[Any],
20
20
  default_server_error_translator: ErrorTranslator[Any],
21
21
  default_on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]],
22
+ exclude_none: bool = False,
22
23
  ) -> Callable[..., Any]:
23
24
  is_coro = inspect.iscoroutinefunction(func)
24
25
 
@@ -27,7 +28,7 @@ def wrap_with_error_handling(
27
28
  try:
28
29
  if is_coro:
29
30
  return await func(*args, **kwargs)
30
- return func(*args, **kwargs)
31
+ return await run_in_threadpool(func, *args, **kwargs)
31
32
  except Exception as error:
32
33
  return await handle_with_error_map(
33
34
  error=error,
@@ -36,6 +37,7 @@ def wrap_with_error_handling(
36
37
  default_client_error_translator=default_client_error_translator,
37
38
  default_server_error_translator=default_server_error_translator,
38
39
  default_on_error=default_on_error,
40
+ exclude_none=exclude_none,
39
41
  )
40
42
 
41
43
  return wrapped
@@ -49,6 +51,7 @@ async def handle_with_error_map(
49
51
  default_client_error_translator: ErrorTranslator[Any],
50
52
  default_server_error_translator: ErrorTranslator[Any],
51
53
  default_on_error: Optional[Callable[[Exception], Union[Awaitable[None], None]]],
54
+ exclude_none: bool,
52
55
  ) -> ORJSONResponse:
53
56
  try:
54
57
  rule = resolve_rule_for_error(
@@ -75,5 +78,8 @@ async def handle_with_error_map(
75
78
  content = rule.translator.from_error(error)
76
79
  return ORJSONResponse(
77
80
  status_code=rule.status,
78
- content=jsonable_encoder(content),
81
+ content=jsonable_encoder(
82
+ content,
83
+ exclude_none=exclude_none,
84
+ ),
79
85
  )
@@ -48,6 +48,7 @@ class ErrorAwareRoute(APIRoute):
48
48
  warn_on_unmapped: bool = True,
49
49
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
50
50
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
51
+ exclude_none: bool = False,
51
52
  # --- FastAPI ---
52
53
  response_model: Any = Default(None),
53
54
  status_code: Optional[int] = None,
@@ -98,6 +99,7 @@ class ErrorAwareRoute(APIRoute):
98
99
  default_on_error=self.default_on_error,
99
100
  default_client_error_translator=self.default_client_error_translator,
100
101
  default_server_error_translator=self.default_server_error_translator,
102
+ exclude_none=exclude_none,
101
103
  )
102
104
  responses = {
103
105
  **build_openapi_responses(
@@ -405,6 +407,7 @@ class ErrorAwareRouter(APIRouter):
405
407
  warn_on_unmapped: bool = True,
406
408
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
407
409
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
410
+ exclude_none: bool = False,
408
411
  # --- FastAPI ---
409
412
  response_model: Any = Default(None),
410
413
  status_code: Optional[int] = None,
@@ -462,6 +465,7 @@ class ErrorAwareRouter(APIRouter):
462
465
  default_on_error=default_on_error,
463
466
  default_client_error_translator=default_client_error_translator,
464
467
  default_server_error_translator=default_server_error_translator,
468
+ exclude_none=exclude_none,
465
469
  response_model=response_model,
466
470
  status_code=status_code,
467
471
  tags=current_tags,
@@ -528,6 +532,7 @@ class ErrorAwareRouter(APIRouter):
528
532
  warn_on_unmapped: bool = True,
529
533
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
530
534
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
535
+ exclude_none: bool = False,
531
536
  # --- FastAPI ---
532
537
  response_model: Any = Default(None),
533
538
  status_code: Optional[int] = None,
@@ -564,6 +569,7 @@ class ErrorAwareRouter(APIRouter):
564
569
  default_on_error=default_on_error,
565
570
  default_client_error_translator=default_client_error_translator,
566
571
  default_server_error_translator=default_server_error_translator,
572
+ exclude_none=exclude_none,
567
573
  response_model=response_model,
568
574
  status_code=status_code,
569
575
  tags=tags,
@@ -612,6 +618,7 @@ class ErrorAwareRouter(APIRouter):
612
618
  warn_on_unmapped: bool = True,
613
619
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
614
620
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
621
+ exclude_none: bool = False,
615
622
  # --- FastAPI ---
616
623
  response_model: Annotated[
617
624
  Any,
@@ -957,6 +964,7 @@ class ErrorAwareRouter(APIRouter):
957
964
  default_on_error=default_on_error,
958
965
  default_client_error_translator=default_client_error_translator,
959
966
  default_server_error_translator=default_server_error_translator,
967
+ exclude_none=exclude_none,
960
968
  response_model=response_model,
961
969
  status_code=status_code,
962
970
  tags=tags,
@@ -1002,6 +1010,7 @@ class ErrorAwareRouter(APIRouter):
1002
1010
  warn_on_unmapped: bool = True,
1003
1011
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
1004
1012
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
1013
+ exclude_none: bool = False,
1005
1014
  # --- FastAPI ---
1006
1015
  response_model: Annotated[
1007
1016
  Any,
@@ -1352,6 +1361,7 @@ class ErrorAwareRouter(APIRouter):
1352
1361
  default_on_error=default_on_error,
1353
1362
  default_client_error_translator=default_client_error_translator,
1354
1363
  default_server_error_translator=default_server_error_translator,
1364
+ exclude_none=exclude_none,
1355
1365
  response_model=response_model,
1356
1366
  status_code=status_code,
1357
1367
  tags=tags,
@@ -1397,6 +1407,7 @@ class ErrorAwareRouter(APIRouter):
1397
1407
  warn_on_unmapped: bool = True,
1398
1408
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
1399
1409
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
1410
+ exclude_none: bool = False,
1400
1411
  # --- FastAPI ---
1401
1412
  response_model: Annotated[
1402
1413
  Any,
@@ -1747,6 +1758,7 @@ class ErrorAwareRouter(APIRouter):
1747
1758
  default_on_error=default_on_error,
1748
1759
  default_client_error_translator=default_client_error_translator,
1749
1760
  default_server_error_translator=default_server_error_translator,
1761
+ exclude_none=exclude_none,
1750
1762
  response_model=response_model,
1751
1763
  status_code=status_code,
1752
1764
  tags=tags,
@@ -1792,6 +1804,7 @@ class ErrorAwareRouter(APIRouter):
1792
1804
  warn_on_unmapped: bool = True,
1793
1805
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
1794
1806
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
1807
+ exclude_none: bool = False,
1795
1808
  # --- FastAPI ---
1796
1809
  response_model: Annotated[
1797
1810
  Any,
@@ -2142,6 +2155,7 @@ class ErrorAwareRouter(APIRouter):
2142
2155
  default_on_error=default_on_error,
2143
2156
  default_client_error_translator=default_client_error_translator,
2144
2157
  default_server_error_translator=default_server_error_translator,
2158
+ exclude_none=exclude_none,
2145
2159
  response_model=response_model,
2146
2160
  status_code=status_code,
2147
2161
  tags=tags,
@@ -2187,6 +2201,7 @@ class ErrorAwareRouter(APIRouter):
2187
2201
  warn_on_unmapped: bool = True,
2188
2202
  default_client_error_translator: Optional[ErrorTranslator[Any]] = None,
2189
2203
  default_server_error_translator: Optional[ErrorTranslator[Any]] = None,
2204
+ exclude_none: bool = False,
2190
2205
  # --- FastAPI ---
2191
2206
  response_model: Annotated[
2192
2207
  Any,
@@ -2532,6 +2547,7 @@ class ErrorAwareRouter(APIRouter):
2532
2547
  default_on_error=default_on_error,
2533
2548
  default_client_error_translator=default_client_error_translator,
2534
2549
  default_server_error_translator=default_server_error_translator,
2550
+ exclude_none=exclude_none,
2535
2551
  response_model=response_model,
2536
2552
  status_code=status_code,
2537
2553
  tags=tags,
@@ -63,9 +63,14 @@ def resolve_rule_for_error(
63
63
  Callable[[Exception], Union[Awaitable[None], None]]
64
64
  ] = None,
65
65
  ) -> ResolvedRule:
66
- try:
67
- status_or_rule = error_map[type(error)]
68
- except KeyError:
66
+ status_or_rule: Union[int, Rule, None] = None
67
+ for cls in type(error).mro():
68
+ if not issubclass(cls, Exception):
69
+ continue
70
+ if cls in error_map:
71
+ status_or_rule = error_map[cls]
72
+ break
73
+ if status_or_rule is None:
69
74
  raise RuntimeError(f"No rule defined for {type(error).__name__}") from error
70
75
 
71
76
  if isinstance(status_or_rule, int):
@@ -11,4 +11,4 @@ def compatibility(session, fastapi):
11
11
  else:
12
12
  session.run("uv", "add", f"fastapi=={fastapi}", "--active", external=True)
13
13
 
14
- session.run("uv", "run", "--active", "make", "code.test", external=True)
14
+ session.run("uv", "run", "--active", "make", "test", external=True)
@@ -7,7 +7,7 @@ sources = ["src"]
7
7
 
8
8
  [project]
9
9
  name = "fastapi-error-map"
10
- version = "0.9.8"
10
+ version = "0.9.9"
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" }
@@ -44,7 +44,7 @@ dev = [
44
44
  test = [
45
45
  "coverage==7.10.1",
46
46
  "httpx==0.28.1",
47
- "nox==2025.5.1",
47
+ "nox==2025.11.12",
48
48
  "pytest==8.4.1",
49
49
  "pytest-asyncio==1.1.0",
50
50
  "pytest-cov==6.2.1",
@@ -92,6 +92,7 @@ parallel = true
92
92
  branch = true
93
93
 
94
94
  [tool.mypy]
95
+ python_version = "3.9"
95
96
  files = [
96
97
  "examples",
97
98
  "src",
@@ -110,9 +111,11 @@ testpaths = ["tests", ]
110
111
  markers = ["slow", ]
111
112
  addopts = "-m 'not slow'"
112
113
  asyncio_default_fixture_loop_scope = "function"
114
+ asyncio_mode = "auto"
113
115
 
114
116
  [tool.ruff]
115
117
  line-length = 88
118
+ target-version = "py39"
116
119
  preview = true # experimental
117
120
 
118
121
  [tool.ruff.format]
@@ -0,0 +1,19 @@
1
+ from collections.abc import AsyncIterator
2
+
3
+ import httpx
4
+ import pytest
5
+ from fastapi import FastAPI
6
+
7
+
8
+ @pytest.fixture
9
+ def app() -> FastAPI:
10
+ return FastAPI()
11
+
12
+
13
+ @pytest.fixture
14
+ async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
15
+ async with httpx.AsyncClient(
16
+ transport=httpx.ASGITransport(app=app),
17
+ base_url="http://test",
18
+ ) as client:
19
+ yield client
@@ -1,22 +1,19 @@
1
- from typing import TYPE_CHECKING
2
-
1
+ import httpx
3
2
  import pytest
4
3
 
5
4
  from examples.main import create_app
6
- from tests.integration.conftest import AsgiClientFactory
7
-
8
- if TYPE_CHECKING:
9
- import httpx
10
5
 
11
6
 
12
7
  @pytest.mark.asyncio
13
8
  async def test_check_stock(
14
- asgi_client_factory: AsgiClientFactory,
15
9
  capsys: pytest.CaptureFixture[str],
16
10
  ) -> None:
17
11
  app = create_app()
18
12
 
19
- async with asgi_client_factory(app) as client:
13
+ async with httpx.AsyncClient(
14
+ transport=httpx.ASGITransport(app=app),
15
+ base_url="http://test",
16
+ ) as client:
20
17
  authz_err_response: httpx.Response = await client.get("/stock?user_id=0")
21
18
  out_of_stock_err_response: httpx.Response = await client.get("/stock?user_id=1")
22
19
  openapi_response: httpx.Response = await client.get("/openapi.json")
@@ -0,0 +1,85 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ import httpx
6
+ import pytest
7
+ from fastapi import FastAPI
8
+
9
+ from fastapi_error_map import ErrorAwareRouter, rule
10
+ from fastapi_error_map.translators import ErrorTranslator
11
+
12
+
13
+ class CustomError(Exception):
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class ClientErrorWithOptionalDetails:
19
+ error: str
20
+ details: Optional[str] = None
21
+
22
+
23
+ class OptionalDetailsTranslator(ErrorTranslator[ClientErrorWithOptionalDetails]):
24
+ @property
25
+ def error_response_model_cls(self) -> type[ClientErrorWithOptionalDetails]:
26
+ return ClientErrorWithOptionalDetails
27
+
28
+ def from_error(self, err: Exception) -> ClientErrorWithOptionalDetails:
29
+ return ClientErrorWithOptionalDetails(error=str(err), details=None)
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ @pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete"])
34
+ async def test_exclude_none_false_keeps_null_fields(
35
+ method: str,
36
+ app: FastAPI,
37
+ client: httpx.AsyncClient,
38
+ ) -> None:
39
+ router = ErrorAwareRouter()
40
+ translator = OptionalDetailsTranslator()
41
+
42
+ async def failing_endpoint() -> None:
43
+ await asyncio.sleep(0)
44
+ raise CustomError("boom")
45
+
46
+ getattr(router, method)(
47
+ "/fail",
48
+ error_map={CustomError: rule(status=418, translator=translator)},
49
+ exclude_none=False,
50
+ )(failing_endpoint)
51
+ app.include_router(router)
52
+
53
+ response: httpx.Response = await getattr(client, method)("/fail")
54
+
55
+ assert response.status_code == 418
56
+ assert response.headers["content-type"].startswith("application/json")
57
+ assert response.json() == {"error": "boom", "details": None}
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ @pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete"])
62
+ async def test_exclude_none_true_drops_null_fields(
63
+ method: str,
64
+ app: FastAPI,
65
+ client: httpx.AsyncClient,
66
+ ) -> None:
67
+ router = ErrorAwareRouter()
68
+ translator = OptionalDetailsTranslator()
69
+
70
+ async def failing_endpoint() -> None:
71
+ await asyncio.sleep(0)
72
+ raise CustomError("boom")
73
+
74
+ getattr(router, method)(
75
+ "/fail",
76
+ error_map={CustomError: rule(status=418, translator=translator)},
77
+ exclude_none=True,
78
+ )(failing_endpoint)
79
+ app.include_router(router)
80
+
81
+ response: httpx.Response = await getattr(client, method)("/fail")
82
+
83
+ assert response.status_code == 418
84
+ assert response.headers["content-type"].startswith("application/json")
85
+ assert response.json() == {"error": "boom"}
@@ -1,15 +1,11 @@
1
1
  import asyncio
2
- from typing import TYPE_CHECKING
3
2
  from unittest.mock import Mock
4
3
 
4
+ import httpx
5
5
  import pytest
6
6
  from fastapi import FastAPI
7
7
 
8
8
  from fastapi_error_map import ErrorAwareRouter, rule
9
- from tests.integration.conftest import AsgiClientFactory
10
-
11
- if TYPE_CHECKING:
12
- import httpx
13
9
 
14
10
 
15
11
  class CustomError(Exception):
@@ -21,7 +17,7 @@ class CustomError(Exception):
21
17
  async def test_error_aware_router_routes(
22
18
  method: str,
23
19
  app: FastAPI,
24
- asgi_client_factory: AsgiClientFactory,
20
+ client: httpx.AsyncClient,
25
21
  ) -> None:
26
22
  router = ErrorAwareRouter()
27
23
  router_path = "/fail"
@@ -41,9 +37,8 @@ async def test_error_aware_router_routes(
41
37
  )(failing_endpoint)
42
38
  app.include_router(router)
43
39
 
44
- async with asgi_client_factory(app) as client:
45
- response: httpx.Response = await getattr(client, method)(router_path)
46
- openapi_response: httpx.Response = await client.get("/openapi.json")
40
+ response: httpx.Response = await getattr(client, method)(router_path)
41
+ openapi_response: httpx.Response = await client.get("/openapi.json")
47
42
 
48
43
  response_data = response.json()
49
44
  assert response.status_code == error_status_code
@@ -1,14 +1,10 @@
1
1
  import asyncio
2
- from typing import TYPE_CHECKING
3
2
 
3
+ import httpx
4
4
  import pytest
5
- from fastapi import FastAPI
5
+ from fastapi import APIRouter, FastAPI
6
6
 
7
7
  from fastapi_error_map import ErrorAwareRouter, rule
8
- from tests.integration.conftest import AsgiClientFactory
9
-
10
- if TYPE_CHECKING:
11
- import httpx
12
8
 
13
9
 
14
10
  def has_running_loop_in_this_thread() -> bool:
@@ -20,16 +16,19 @@ def has_running_loop_in_this_thread() -> bool:
20
16
 
21
17
 
22
18
  @pytest.mark.asyncio
23
- async def test_fastapi_sync_handler_runs_in_threadpool(
19
+ async def test_fastapi_api_router_sync_handler_runs_in_threadpool(
24
20
  app: FastAPI,
25
- asgi_client_factory: AsgiClientFactory,
21
+ client: httpx.AsyncClient,
26
22
  ) -> None:
27
- @app.get("/")
23
+ router = APIRouter()
24
+
25
+ @router.get("/")
28
26
  def index():
29
27
  return {"in_loop": has_running_loop_in_this_thread()}
30
28
 
31
- async with asgi_client_factory(app) as client:
32
- index_response: httpx.Response = await client.get("/")
29
+ app.include_router(router)
30
+
31
+ index_response: httpx.Response = await client.get("/")
33
32
 
34
33
  assert index_response.status_code == 200
35
34
  assert index_response.json() == {"in_loop": False}
@@ -38,18 +37,17 @@ async def test_fastapi_sync_handler_runs_in_threadpool(
38
37
  @pytest.mark.asyncio
39
38
  async def test_error_aware_router_sync_handler_runs_in_threadpool(
40
39
  app: FastAPI,
41
- asgi_client_factory: AsgiClientFactory,
40
+ client: httpx.AsyncClient,
42
41
  ) -> None:
43
42
  router = ErrorAwareRouter()
44
43
 
45
- @app.get("/")
44
+ @router.get("/", error_map={ValueError: 400})
46
45
  def index():
47
46
  return {"in_loop": has_running_loop_in_this_thread()}
48
47
 
49
48
  app.include_router(router)
50
49
 
51
- async with asgi_client_factory(app) as client:
52
- index_response: httpx.Response = await client.get("/")
50
+ index_response: httpx.Response = await client.get("/")
53
51
 
54
52
  assert index_response.status_code == 200
55
53
  assert index_response.json() == {"in_loop": False}
@@ -58,7 +56,7 @@ async def test_error_aware_router_sync_handler_runs_in_threadpool(
58
56
  @pytest.mark.asyncio
59
57
  async def test_error_aware_router_sync_on_error_runs_in_threadpool(
60
58
  app: FastAPI,
61
- asgi_client_factory: AsgiClientFactory,
59
+ client: httpx.AsyncClient,
62
60
  ) -> None:
63
61
  router = ErrorAwareRouter()
64
62
  seen: dict[str, bool] = {}
@@ -72,8 +70,7 @@ async def test_error_aware_router_sync_on_error_runs_in_threadpool(
72
70
 
73
71
  app.include_router(router)
74
72
 
75
- async with asgi_client_factory(app) as client:
76
- index_response: httpx.Response = await client.get("/err")
73
+ index_response: httpx.Response = await client.get("/err")
77
74
 
78
75
  assert index_response.status_code == 400
79
76
  assert seen["in_loop"] is False
@@ -145,6 +145,7 @@ async def test_handles_sync_on_error_from_map() -> None:
145
145
  default_client_error_translator=DefaultClientErrorTranslator(),
146
146
  default_server_error_translator=DefaultServerErrorTranslator(),
147
147
  default_on_error=None,
148
+ exclude_none=False,
148
149
  )
149
150
 
150
151
  assert sut.status_code == 422
@@ -163,6 +164,7 @@ async def test_handles_default_on_error_from_defaults() -> None:
163
164
  default_client_error_translator=DefaultClientErrorTranslator(),
164
165
  default_server_error_translator=DefaultServerErrorTranslator(),
165
166
  default_on_error=mock_on_error,
167
+ exclude_none=False,
166
168
  )
167
169
 
168
170
  assert sut.status_code == 422
@@ -181,6 +183,7 @@ async def test_handles_async_on_error() -> None:
181
183
  default_client_error_translator=DefaultClientErrorTranslator(),
182
184
  default_server_error_translator=DefaultServerErrorTranslator(),
183
185
  default_on_error=None,
186
+ exclude_none=False,
184
187
  )
185
188
 
186
189
  assert sut.status_code == 422
@@ -200,6 +203,7 @@ async def test_handles_async_on_error_partial() -> None:
200
203
  default_client_error_translator=DefaultClientErrorTranslator(),
201
204
  default_server_error_translator=DefaultServerErrorTranslator(),
202
205
  default_on_error=None,
206
+ exclude_none=False,
203
207
  )
204
208
 
205
209
  assert sut.status_code == 422
@@ -221,6 +225,7 @@ async def test_handles_sync_on_error_returning_awaitable() -> None:
221
225
  default_client_error_translator=DefaultClientErrorTranslator(),
222
226
  default_server_error_translator=DefaultServerErrorTranslator(),
223
227
  default_on_error=None,
228
+ exclude_none=False,
224
229
  )
225
230
 
226
231
  assert sut.status_code == 422
@@ -14,6 +14,22 @@ from tests.unit.translator_stubs import (
14
14
  ErrorMap = dict[type[Exception], Union[int, Rule]]
15
15
 
16
16
 
17
+ class ParentError(Exception):
18
+ pass
19
+
20
+
21
+ class ChildError(ParentError):
22
+ pass
23
+
24
+
25
+ class OtherParentError(Exception):
26
+ pass
27
+
28
+
29
+ class MultipleInheritanceError(ChildError, OtherParentError):
30
+ pass
31
+
32
+
17
33
  @pytest.mark.parametrize(
18
34
  "rule",
19
35
  [
@@ -105,3 +121,54 @@ def test_unmapped_error_raises_runtime_error() -> None:
105
121
  default_server_error_translator=DummyServerErrorTranslator(),
106
122
  default_on_error=None,
107
123
  )
124
+
125
+
126
+ def test_resolves_child_over_parent() -> None:
127
+ sut = resolve_rule_for_error(
128
+ error=ChildError(),
129
+ error_map={
130
+ ParentError: 400,
131
+ ChildError: 409,
132
+ },
133
+ default_client_error_translator=DummyClientErrorTranslator(),
134
+ default_server_error_translator=DummyServerErrorTranslator(),
135
+ )
136
+
137
+ assert sut.status == 409
138
+
139
+
140
+ def test_falls_back_to_parent_when_child_missing() -> None:
141
+ sut = resolve_rule_for_error(
142
+ error=ChildError(),
143
+ error_map={
144
+ ParentError: 400,
145
+ },
146
+ default_client_error_translator=DummyClientErrorTranslator(),
147
+ default_server_error_translator=DummyServerErrorTranslator(),
148
+ )
149
+
150
+ assert sut.status == 400
151
+
152
+
153
+ def test_multiple_inheritance_prefers_first_match_in_mro() -> None:
154
+ sut = resolve_rule_for_error(
155
+ error=MultipleInheritanceError(),
156
+ error_map={
157
+ OtherParentError: 402,
158
+ ChildError: 409,
159
+ },
160
+ default_client_error_translator=DummyClientErrorTranslator(),
161
+ default_server_error_translator=DummyServerErrorTranslator(),
162
+ )
163
+
164
+ assert sut.status == 409
165
+
166
+
167
+ def test_does_not_resolve_base_exception_subclasses() -> None:
168
+ with pytest.raises(RuntimeError):
169
+ resolve_rule_for_error(
170
+ error=KeyboardInterrupt(), # type: ignore
171
+ error_map={},
172
+ default_client_error_translator=DummyClientErrorTranslator(),
173
+ default_server_error_translator=DummyServerErrorTranslator(),
174
+ )
@@ -405,7 +405,7 @@ wheels = [
405
405
 
406
406
  [[package]]
407
407
  name = "fastapi-error-map"
408
- version = "0.9.8"
408
+ version = "0.9.9"
409
409
  source = { editable = "." }
410
410
  dependencies = [
411
411
  { name = "fastapi" },
@@ -464,7 +464,7 @@ all = [
464
464
  { name = "hatch", specifier = "==1.14.1" },
465
465
  { name = "httpx", specifier = "==0.28.1" },
466
466
  { name = "mypy", specifier = "==1.8.0" },
467
- { name = "nox", specifier = "==2025.5.1" },
467
+ { name = "nox", specifier = "==2025.11.12" },
468
468
  { name = "pre-commit", specifier = "==4.2.0" },
469
469
  { name = "pytest", specifier = "==8.4.1" },
470
470
  { name = "pytest-asyncio", specifier = "==1.1.0" },
@@ -476,7 +476,7 @@ dev = [
476
476
  { name = "coverage", specifier = "==7.10.1" },
477
477
  { name = "httpx", specifier = "==0.28.1" },
478
478
  { name = "mypy", specifier = "==1.8.0" },
479
- { name = "nox", specifier = "==2025.5.1" },
479
+ { name = "nox", specifier = "==2025.11.12" },
480
480
  { name = "pre-commit", specifier = "==4.2.0" },
481
481
  { name = "pytest", specifier = "==8.4.1" },
482
482
  { name = "pytest-asyncio", specifier = "==1.1.0" },
@@ -488,7 +488,7 @@ publish = [{ name = "hatch", specifier = "==1.14.1" }]
488
488
  test = [
489
489
  { name = "coverage", specifier = "==7.10.1" },
490
490
  { name = "httpx", specifier = "==0.28.1" },
491
- { name = "nox", specifier = "==2025.5.1" },
491
+ { name = "nox", specifier = "==2025.11.12" },
492
492
  { name = "pytest", specifier = "==8.4.1" },
493
493
  { name = "pytest-asyncio", specifier = "==1.1.0" },
494
494
  { name = "pytest-cov", specifier = "==6.2.1" },
@@ -600,6 +600,30 @@ wheels = [
600
600
  { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
601
601
  ]
602
602
 
603
+ [[package]]
604
+ name = "humanize"
605
+ version = "4.13.0"
606
+ source = { registry = "https://pypi.org/simple" }
607
+ resolution-markers = [
608
+ "python_full_version < '3.10'",
609
+ ]
610
+ sdist = { url = "https://files.pythonhosted.org/packages/98/1d/3062fcc89ee05a715c0b9bfe6490c00c576314f27ffee3a704122c6fd259/humanize-4.13.0.tar.gz", hash = "sha256:78f79e68f76f0b04d711c4e55d32bebef5be387148862cb1ef83d2b58e7935a0", size = 81884, upload-time = "2025-08-25T09:39:20.04Z" }
611
+ wheels = [
612
+ { url = "https://files.pythonhosted.org/packages/1e/c7/316e7ca04d26695ef0635dc81683d628350810eb8e9b2299fc08ba49f366/humanize-4.13.0-py3-none-any.whl", hash = "sha256:b810820b31891813b1673e8fec7f1ed3312061eab2f26e3fa192c393d11ed25f", size = 128869, upload-time = "2025-08-25T09:39:18.54Z" },
613
+ ]
614
+
615
+ [[package]]
616
+ name = "humanize"
617
+ version = "4.15.0"
618
+ source = { registry = "https://pypi.org/simple" }
619
+ resolution-markers = [
620
+ "python_full_version >= '3.10'",
621
+ ]
622
+ sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" }
623
+ wheels = [
624
+ { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
625
+ ]
626
+
603
627
  [[package]]
604
628
  name = "hyperlink"
605
629
  version = "21.0.0"
@@ -832,20 +856,22 @@ wheels = [
832
856
 
833
857
  [[package]]
834
858
  name = "nox"
835
- version = "2025.5.1"
859
+ version = "2025.11.12"
836
860
  source = { registry = "https://pypi.org/simple" }
837
861
  dependencies = [
838
862
  { name = "argcomplete" },
839
863
  { name = "attrs" },
840
864
  { name = "colorlog" },
841
865
  { name = "dependency-groups" },
866
+ { name = "humanize", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
867
+ { name = "humanize", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
842
868
  { name = "packaging" },
843
869
  { name = "tomli", marker = "python_full_version < '3.11'" },
844
870
  { name = "virtualenv" },
845
871
  ]
846
- sdist = { url = "https://files.pythonhosted.org/packages/b4/80/47712208c410defec169992e57c179f0f4d92f5dd17ba8daca50a8077e23/nox-2025.5.1.tar.gz", hash = "sha256:2a571dfa7a58acc726521ac3cd8184455ebcdcbf26401c7b737b5bc6701427b2", size = 4023334, upload-time = "2025-05-01T16:35:48.056Z" }
872
+ sdist = { url = "https://files.pythonhosted.org/packages/b4/a8/e169497599266d176832e2232c08557ffba97eef87bf8a18f9f918e0c6aa/nox-2025.11.12.tar.gz", hash = "sha256:3d317f9e61f49d6bde39cf2f59695bb4e1722960457eee3ae19dacfe03c07259", size = 4030561, upload-time = "2025-11-12T18:39:03.319Z" }
847
873
  wheels = [
848
- { url = "https://files.pythonhosted.org/packages/a6/be/7b423b02b09eb856beffe76fe8c4121c99852db74dd12a422dcb72d1134e/nox-2025.5.1-py3-none-any.whl", hash = "sha256:56abd55cf37ff523c254fcec4d152ed51e5fe80e2ab8317221d8b828ac970a31", size = 71753, upload-time = "2025-05-01T16:35:46.037Z" },
874
+ { url = "https://files.pythonhosted.org/packages/b9/34/434c594e0125a16b05a7bedaea33e63c90abbfbe47e5729a735a8a8a90ea/nox-2025.11.12-py3-none-any.whl", hash = "sha256:707171f9f63bc685da9d00edd8c2ceec8405b8e38b5fb4e46114a860070ef0ff", size = 74447, upload-time = "2025-11-12T18:39:01.575Z" },
849
875
  ]
850
876
 
851
877
  [[package]]
@@ -1,23 +0,0 @@
1
- # Code quality
2
- .PHONY: code.format code.lint code.test code.cov code.cov.html code.check
3
- code.format:
4
- ruff format
5
-
6
- code.lint: code.format
7
- ruff check --exit-non-zero-on-fix
8
- mypy
9
-
10
- code.test:
11
- pytest -v
12
-
13
- code.cov:
14
- coverage run -m pytest
15
- coverage combine
16
- coverage report
17
-
18
- code.cov.html:
19
- coverage run -m pytest
20
- coverage combine
21
- coverage html
22
-
23
- code.check: code.lint code.test
@@ -1,25 +0,0 @@
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