Skip to content

Commit 402f5ef

Browse files
authored
v2.1.1 (#8)
1 parent 222f6c3 commit 402f5ef

File tree

7 files changed

+734
-233
lines changed

7 files changed

+734
-233
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Change Log
22

3+
## [2.1.1] - 2025-10-12
4+
5+
Changes:
6+
7+
- Fixed a bug where custom extra handler strings in settings could not be properly resolved, imported, or called.
8+
- Dev: Update the dependencies
9+
310
## [2.1.0] - 2025-08-17
411

512
Changes:

drf_simple_api_errors/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .exception_handler import exception_handler
22

33
__all__ = ("exception_handler",)
4-
__version__ = "2.1.0"
4+
__version__ = "2.1.1"

drf_simple_api_errors/extra_handlers.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
from rest_framework import exceptions
1414

1515

16-
def set_default_detail_to_formatted_exc_default_code(
17-
exc: exceptions.APIException,
18-
) -> exceptions.APIException:
16+
def set_default_detail_to_formatted_exc_default_code(exc: Exception) -> Exception:
1917
"""
2018
Formats the `default_detail` for specific DRF exceptions
2119
by setting it to a human-readable string based on the exception `default_code`.

drf_simple_api_errors/handlers.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
- `get_response_headers`: Gets the response headers for the given exception.
1111
"""
1212

13-
from typing import Dict, Union
13+
import importlib
14+
from typing import Callable, Dict, Union
1415

1516
from django.core.exceptions import (
1617
PermissionDenied,
@@ -23,27 +24,57 @@
2324
from drf_simple_api_errors import extra_handlers
2425
from drf_simple_api_errors.settings import api_settings
2526

27+
DEFAULT_EXTRA_HANDLERS = [
28+
extra_handlers.set_default_detail_to_formatted_exc_default_code
29+
]
30+
"""Default extra handlers to always apply."""
31+
2632

2733
def apply_extra_handlers(exc: Exception):
2834
"""
2935
Apply any extra exception handlers defined in the settings.
3036
3137
Args:
3238
exc (Exception): The exception to handle.
39+
40+
Returns:
41+
int: The number of handlers applied (this is mainly for unit testing).
3342
"""
3443
# Get the default extra handlers and the ones defined in the settings.
3544
# The default handlers are always applied to ensure that exceptions
3645
# are formatted correctly.
37-
default_extra_handlers = [
38-
extra_handlers.set_default_detail_to_formatted_exc_default_code
39-
]
40-
settings_extra_handlers = api_settings.EXTRA_HANDLERS
41-
42-
extra_handlers_to_apply = default_extra_handlers + settings_extra_handlers
46+
# Resolve the settings extra handlers.
47+
# The settings extra handlers is a list of strings representing
48+
# the import path to the handler function.
49+
# This allows for lazy loading of the handlers.
50+
settings_extra_handlers: list[Callable] = []
51+
for handler_path in api_settings.EXTRA_HANDLERS or []:
52+
if not isinstance(handler_path, str):
53+
raise ValueError(
54+
f"EXTRA_HANDLERS must be a list of strings. Found: {type(handler_path)}"
55+
)
56+
57+
module_path, func_name = handler_path.rsplit(".", 1)
58+
try:
59+
module = importlib.import_module(module_path)
60+
except ModuleNotFoundError:
61+
raise ValueError(f"Path {handler_path} not found.")
62+
63+
func = getattr(module, func_name, None)
64+
if func is None:
65+
raise ValueError(f"Handler {func_name} not found.")
66+
if not callable(func):
67+
raise ValueError(f"Handler {func_name} is not callable.")
68+
else:
69+
settings_extra_handlers.append(func)
70+
71+
extra_handlers_to_apply = DEFAULT_EXTRA_HANDLERS + settings_extra_handlers
4372
if extra_handlers_to_apply:
4473
for handler in extra_handlers_to_apply:
4574
handler(exc)
4675

76+
return len(extra_handlers_to_apply)
77+
4778

4879
def convert_django_exc_to_drf_api_exc(
4980
exc: Exception,

poetry.lock

Lines changed: 554 additions & 202 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "drf-simple-api-errors"
3-
version = "2.1.0"
3+
version = "2.1.1"
44
description = "A library for Django Rest Framework returning consistent and easy-to-parse API error messages."
55
authors = ["Gian <[email protected]>"]
66
license = "MIT"
@@ -33,6 +33,7 @@ classifiers = [
3333
"Topic :: Software Development :: Libraries :: Python Modules",
3434
]
3535
include = ["drf_simple_api_errors", "LICENSE.md"]
36+
requires-poetry = "^2.1"
3637

3738

3839
[tool.poetry.dependencies]
@@ -46,6 +47,7 @@ djangorestframework = ">=3.0"
4647
black = "^24.1.1"
4748
factory-boy = "^3.3.0"
4849
flake8 = "^7.0.0"
50+
ipdb = "^0.13.13"
4951
isort = "^5.13.2"
5052
pytest = "^8.0.0"
5153
pytest-cov = "^5.0.0"
@@ -82,5 +84,5 @@ pythonpath = [".", "integration_tests", "tests"]
8284

8385

8486
[build-system]
85-
requires = ["poetry-core"]
87+
requires = ["poetry-core>=2.0.0,<3.0.0"]
8688
build-backend = "poetry.core.masonry.api"

tests/test_hadlers.py

Lines changed: 129 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import importlib
2+
import types
3+
14
from django.core import exceptions as django_exceptions
25
from django.http import Http404
36
from rest_framework import exceptions as drf_exceptions
@@ -10,35 +13,143 @@
1013
class TestApplyExtraHandlers:
1114
"""Test cases for the apply_extra_handlers function in handlers module."""
1215

13-
def test_extra_handlers_called(self, mocker):
16+
@pytest.fixture
17+
def mock_importlib_import_module(self, mocker):
18+
"""Mock importlib.import_module to control the import behavior in tests."""
19+
return mocker.patch.object(importlib, "import_module")
20+
21+
def test_calls_expected_default_handlers(self, mocker):
22+
"""Test that all the expected default handlers are called.
23+
24+
This is a sanity check to ensure that the default handlers are always called.
25+
"""
26+
# Always make sure that the settings handlers are empty for this test
27+
# to isolate the default handlers.
28+
mocker.patch("drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS", [])
29+
30+
extra_handlers_applied = handlers.apply_extra_handlers(Exception())
31+
32+
assert extra_handlers_applied == len(handlers.DEFAULT_EXTRA_HANDLERS)
33+
34+
def test_extra_handlers_calls_string_import(
35+
self, monkeypatch, mocker, mock_importlib_import_module
36+
):
37+
"""Test that extra handlers with string imports are called correctly."""
38+
# Set default handlers to empty for this test to isolate the settings one.
39+
monkeypatch.setattr(handlers, "DEFAULT_EXTRA_HANDLERS", [])
40+
# Make importlib.import_module return the test mock handler when called
41+
mock_handler = mocker.MagicMock()
42+
dummy_module = types.SimpleNamespace(mock_handler=mock_handler)
43+
mock_importlib_import_module.return_value = dummy_module
44+
# Patch the settings to include the test mock handler
45+
monkeypatch.setattr(
46+
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
47+
# Add dummy path to have at least one mock handler to import from a string
48+
["path.to.module.mock_handler"],
49+
)
50+
51+
# exc = mocker.MagicMock()
52+
exc = Exception()
53+
extra_handlers_applied = handlers.apply_extra_handlers(exc)
54+
55+
assert extra_handlers_applied == 1
56+
mock_handler.assert_called_once_with(exc)
57+
58+
def test_extra_handlers_calls_multiple(
59+
self, monkeypatch, mocker, mock_importlib_import_module
60+
):
61+
"""
62+
Test that both default and settings extra handlers are called correctly.
63+
"""
64+
# Make importlib.import_module return the test mock handler when called
65+
mock_handler = mocker.MagicMock()
66+
dummy_module = types.SimpleNamespace(mock_handler=mock_handler)
67+
mock_importlib_import_module.return_value = dummy_module
68+
# Patch the settings to include the test mock handler
69+
monkeypatch.setattr(
70+
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
71+
# Add dummy path to have at least one mock handler to import from a string
72+
["path.to.module.mock_handler"],
73+
)
74+
75+
# exc = mocker.MagicMock()
76+
exc = Exception()
77+
extra_handlers_applied = handlers.apply_extra_handlers(exc)
78+
79+
assert (
80+
extra_handlers_applied == len(handlers.DEFAULT_EXTRA_HANDLERS) + 1
81+
) # 1 for the settings handler
82+
mock_handler.assert_called_once_with(exc)
83+
84+
def test_extra_handlers_value_error_on_non_string_import(self, monkeypatch, mocker):
1485
"""
15-
Test that extra handlers are called with the exception.
86+
Test that a ValueError is raised when EXTRA_HANDLERS contains
87+
a non-string import.
88+
"""
89+
monkeypatch.setattr(
90+
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
91+
[mocker.MagicMock()],
92+
)
93+
94+
with pytest.raises(ValueError) as e:
95+
handlers.apply_extra_handlers(Exception())
1696

17-
Testing with multiple handlers to ensure all are invoked, and this gives
18-
confidence one or more than two handlers can be used correctly.
97+
assert "EXTRA_HANDLERS must be a list of strings" in str(e.value)
98+
99+
def test_extra_handlers_value_error_when_path_to_extra_handler_not_found(
100+
self, monkeypatch, mocker
101+
):
102+
"""
103+
Test that a ValueError is raised when the path to the extra handler
104+
does not exist.
19105
"""
20-
mock_extra_handler = mocker.MagicMock()
21-
mock_another_extra_handler = mocker.MagicMock()
106+
monkeypatch.setattr(
107+
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
108+
["path.to.non_existent_handler"],
109+
)
22110

23-
mocker.patch(
111+
with pytest.raises(ValueError) as e:
112+
handlers.apply_extra_handlers(Exception())
113+
114+
assert "Path path.to.non_existent_handler not found." in str(e.value)
115+
116+
def test_extra_handlers_value_error_when_extra_handler_is_none(
117+
self, monkeypatch, mocker, mock_importlib_import_module
118+
):
119+
"""
120+
Test that a ValueError is raised when the extra handler is None.
121+
"""
122+
# Make importlib.import_module return a module without the expected attribute
123+
dummy_module = types.SimpleNamespace()
124+
mock_importlib_import_module.return_value = dummy_module
125+
monkeypatch.setattr(
24126
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
25-
[mock_extra_handler, mock_another_extra_handler],
127+
["path.to.module.non_existent_handler"],
26128
)
27129

28-
exc = mocker.MagicMock()
29-
handlers.apply_extra_handlers(exc)
130+
with pytest.raises(ValueError) as e:
131+
handlers.apply_extra_handlers(Exception())
30132

31-
mock_extra_handler.assert_called_once_with(exc)
32-
mock_another_extra_handler.assert_called_once_with(exc)
133+
assert "Handler non_existent_handler not found." in str(e.value)
33134

34-
def test_no_extra_handlers(self, mocker):
35-
"""Test that no extra handlers are called when EXTRA_HANDLERS is empty."""
36-
mock_extra_handler = mocker.MagicMock()
135+
def test_extra_handlers_value_error_when_extra_handler_not_callable(
136+
self, monkeypatch, mocker, mock_importlib_import_module
137+
):
138+
"""
139+
Test that a ValueError is raised when the extra handler is not callable.
140+
"""
141+
# Make importlib.import_module return a non-callable attribute
142+
dummy_module = types.SimpleNamespace(mock_handler="not_a_function")
143+
mock_importlib_import_module.return_value = dummy_module
144+
monkeypatch.setattr(
145+
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
146+
["path.to.module.mock_handler"],
147+
)
37148

38-
exc = mocker.MagicMock()
39-
handlers.apply_extra_handlers(exc)
149+
with pytest.raises(ValueError) as e:
150+
handlers.apply_extra_handlers(Exception())
40151

41-
mock_extra_handler.assert_not_called()
152+
assert "Handler mock_handler is not callable." in str(e.value)
42153

43154

44155
class TestConvertDjangoExcToDRFAPIExc:

0 commit comments

Comments
 (0)