fastapi-error-map 0.9.7__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 (41) hide show
  1. {fastapi_error_map-0.9.7 → 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.7 → fastapi_error_map-0.9.9}/.github/workflows/test-compatibility.yaml +5 -3
  4. {fastapi_error_map-0.9.7 → 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.7 → fastapi_error_map-0.9.9}/PKG-INFO +18 -9
  7. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/README.md +15 -6
  8. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/error_handling.py +8 -2
  9. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/routing.py +16 -0
  10. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/rules.py +8 -3
  11. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/noxfile.py +1 -1
  12. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/pyproject.toml +7 -4
  13. fastapi_error_map-0.9.9/tests/integration/conftest.py +19 -0
  14. {fastapi_error_map-0.9.7 → 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.7 → fastapi_error_map-0.9.9}/tests/integration/test_routing.py +4 -9
  17. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/integration/test_threadpool.py +15 -18
  18. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/unit/test_error_handling.py +5 -0
  19. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/unit/test_rules.py +67 -0
  20. fastapi_error_map-0.9.9/uv.lock +1731 -0
  21. fastapi_error_map-0.9.7/Makefile +0 -23
  22. fastapi_error_map-0.9.7/tests/integration/conftest.py +0 -25
  23. fastapi_error_map-0.9.7/uv.lock +0 -1535
  24. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/.gitignore +0 -0
  25. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/LICENSE +0 -0
  26. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/docs/example-openapi.png +0 -0
  27. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/examples/__init__.py +0 -0
  28. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/examples/errors.py +0 -0
  29. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/examples/main.py +0 -0
  30. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/__init__.py +0 -0
  31. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/openapi.py +0 -0
  32. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/py.typed +0 -0
  33. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/translator_policy.py +0 -0
  34. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/fastapi_error_map/translators.py +0 -0
  35. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/__init__.py +0 -0
  36. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/integration/__init__.py +0 -0
  37. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/unit/__init__.py +0 -0
  38. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/unit/error_stubs.py +0 -0
  39. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/unit/test_openapi.py +0 -0
  40. {fastapi_error_map-0.9.7 → fastapi_error_map-0.9.9}/tests/unit/test_translators.py +0 -0
  41. {fastapi_error_map-0.9.7 → 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.7
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
@@ -21,14 +21,14 @@ Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Topic :: Software Development :: Libraries
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.9
24
- Requires-Dist: fastapi==0.100.0
25
- Requires-Dist: orjson==3.11.1
24
+ Requires-Dist: fastapi<1.0,>=0.100
25
+ Requires-Dist: orjson>=3.11.4
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  ## FastAPI Error Map
29
29
 
30
- [![PyPI version](https://badge.fury.io/py/fastapi-error-map.svg?cacheBust=6)](https://badge.fury.io/py/fastapi-error-map)
31
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=2)
30
+ [![PyPI version](https://badge.fury.io/py/fastapi-error-map.svg?cacheBust=7)](https://badge.fury.io/py/fastapi-error-map)
31
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=4)
32
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)
33
33
  ![GitHub License](https://img.shields.io/github/license/ivan-borovets/fastapi-error-map?cacheBust=1)
34
34
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ivan-borovets/fastapi-error-map/ci.yaml?cacheBust=1)
@@ -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
@@ -1,7 +1,7 @@
1
1
  ## FastAPI Error Map
2
2
 
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=2)
3
+ [![PyPI version](https://badge.fury.io/py/fastapi-error-map.svg?cacheBust=7)](https://badge.fury.io/py/fastapi-error-map)
4
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map?cacheBust=4)
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)
@@ -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.7"
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" }
@@ -31,8 +31,8 @@ classifiers = [
31
31
  "Framework :: FastAPI",
32
32
  ]
33
33
  dependencies = [
34
- "fastapi==0.100.0",
35
- "orjson==3.11.1",
34
+ "fastapi>=0.100,<1.0",
35
+ "orjson>=3.11.4",
36
36
  ]
37
37
  [dependency-groups]
38
38
  dev = [
@@ -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