Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Change Log

## [2.1.1] - 2025-10-12

Changes:

- Fixed a bug where custom extra handler strings in settings could not be properly resolved, imported, or called.
- Dev: Update the dependencies

## [2.1.0] - 2025-08-17

Changes:
Expand Down
2 changes: 1 addition & 1 deletion drf_simple_api_errors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .exception_handler import exception_handler

__all__ = ("exception_handler",)
__version__ = "2.1.0"
__version__ = "2.1.1"
4 changes: 1 addition & 3 deletions drf_simple_api_errors/extra_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
from rest_framework import exceptions


def set_default_detail_to_formatted_exc_default_code(
exc: exceptions.APIException,
) -> exceptions.APIException:
def set_default_detail_to_formatted_exc_default_code(exc: Exception) -> Exception:
"""
Formats the `default_detail` for specific DRF exceptions
by setting it to a human-readable string based on the exception `default_code`.
Expand Down
45 changes: 38 additions & 7 deletions drf_simple_api_errors/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
- `get_response_headers`: Gets the response headers for the given exception.
"""

from typing import Dict, Union
import importlib
from typing import Callable, Dict, Union

from django.core.exceptions import (
PermissionDenied,
Expand All @@ -23,27 +24,57 @@
from drf_simple_api_errors import extra_handlers
from drf_simple_api_errors.settings import api_settings

DEFAULT_EXTRA_HANDLERS = [
extra_handlers.set_default_detail_to_formatted_exc_default_code
]
"""Default extra handlers to always apply."""


def apply_extra_handlers(exc: Exception):
"""
Apply any extra exception handlers defined in the settings.

Args:
exc (Exception): The exception to handle.

Returns:
int: The number of handlers applied (this is mainly for unit testing).
"""
# Get the default extra handlers and the ones defined in the settings.
# The default handlers are always applied to ensure that exceptions
# are formatted correctly.
default_extra_handlers = [
extra_handlers.set_default_detail_to_formatted_exc_default_code
]
settings_extra_handlers = api_settings.EXTRA_HANDLERS

extra_handlers_to_apply = default_extra_handlers + settings_extra_handlers
# Resolve the settings extra handlers.
# The settings extra handlers is a list of strings representing
# the import path to the handler function.
# This allows for lazy loading of the handlers.
settings_extra_handlers: list[Callable] = []
for handler_path in api_settings.EXTRA_HANDLERS or []:
if not isinstance(handler_path, str):
raise ValueError(
f"EXTRA_HANDLERS must be a list of strings. Found: {type(handler_path)}"
)

module_path, func_name = handler_path.rsplit(".", 1)
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError:
raise ValueError(f"Path {handler_path} not found.")

func = getattr(module, func_name, None)
if func is None:
raise ValueError(f"Handler {func_name} not found.")
if not callable(func):
raise ValueError(f"Handler {func_name} is not callable.")
else:
settings_extra_handlers.append(func)

extra_handlers_to_apply = DEFAULT_EXTRA_HANDLERS + settings_extra_handlers
if extra_handlers_to_apply:
for handler in extra_handlers_to_apply:
handler(exc)

return len(extra_handlers_to_apply)


def convert_django_exc_to_drf_api_exc(
exc: Exception,
Expand Down
756 changes: 554 additions & 202 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "drf-simple-api-errors"
version = "2.1.0"
version = "2.1.1"
description = "A library for Django Rest Framework returning consistent and easy-to-parse API error messages."
authors = ["Gian <[email protected]>"]
license = "MIT"
Expand Down Expand Up @@ -33,6 +33,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
include = ["drf_simple_api_errors", "LICENSE.md"]
requires-poetry = "^2.1"


[tool.poetry.dependencies]
Expand All @@ -46,6 +47,7 @@ djangorestframework = ">=3.0"
black = "^24.1.1"
factory-boy = "^3.3.0"
flake8 = "^7.0.0"
ipdb = "^0.13.13"
isort = "^5.13.2"
pytest = "^8.0.0"
pytest-cov = "^5.0.0"
Expand Down Expand Up @@ -82,5 +84,5 @@ pythonpath = [".", "integration_tests", "tests"]


[build-system]
requires = ["poetry-core"]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
147 changes: 129 additions & 18 deletions tests/test_hadlers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import importlib
import types

from django.core import exceptions as django_exceptions
from django.http import Http404
from rest_framework import exceptions as drf_exceptions
Expand All @@ -10,35 +13,143 @@
class TestApplyExtraHandlers:
"""Test cases for the apply_extra_handlers function in handlers module."""

def test_extra_handlers_called(self, mocker):
@pytest.fixture
def mock_importlib_import_module(self, mocker):
"""Mock importlib.import_module to control the import behavior in tests."""
return mocker.patch.object(importlib, "import_module")

def test_calls_expected_default_handlers(self, mocker):
"""Test that all the expected default handlers are called.

This is a sanity check to ensure that the default handlers are always called.
"""
# Always make sure that the settings handlers are empty for this test
# to isolate the default handlers.
mocker.patch("drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS", [])

extra_handlers_applied = handlers.apply_extra_handlers(Exception())

assert extra_handlers_applied == len(handlers.DEFAULT_EXTRA_HANDLERS)

def test_extra_handlers_calls_string_import(
self, monkeypatch, mocker, mock_importlib_import_module
):
"""Test that extra handlers with string imports are called correctly."""
# Set default handlers to empty for this test to isolate the settings one.
monkeypatch.setattr(handlers, "DEFAULT_EXTRA_HANDLERS", [])
# Make importlib.import_module return the test mock handler when called
mock_handler = mocker.MagicMock()
dummy_module = types.SimpleNamespace(mock_handler=mock_handler)
mock_importlib_import_module.return_value = dummy_module
# Patch the settings to include the test mock handler
monkeypatch.setattr(
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
# Add dummy path to have at least one mock handler to import from a string
["path.to.module.mock_handler"],
)

# exc = mocker.MagicMock()
exc = Exception()
extra_handlers_applied = handlers.apply_extra_handlers(exc)

assert extra_handlers_applied == 1
mock_handler.assert_called_once_with(exc)

def test_extra_handlers_calls_multiple(
self, monkeypatch, mocker, mock_importlib_import_module
):
"""
Test that both default and settings extra handlers are called correctly.
"""
# Make importlib.import_module return the test mock handler when called
mock_handler = mocker.MagicMock()
dummy_module = types.SimpleNamespace(mock_handler=mock_handler)
mock_importlib_import_module.return_value = dummy_module
# Patch the settings to include the test mock handler
monkeypatch.setattr(
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
# Add dummy path to have at least one mock handler to import from a string
["path.to.module.mock_handler"],
)

# exc = mocker.MagicMock()
exc = Exception()
extra_handlers_applied = handlers.apply_extra_handlers(exc)

assert (
extra_handlers_applied == len(handlers.DEFAULT_EXTRA_HANDLERS) + 1
) # 1 for the settings handler
mock_handler.assert_called_once_with(exc)

def test_extra_handlers_value_error_on_non_string_import(self, monkeypatch, mocker):
"""
Test that extra handlers are called with the exception.
Test that a ValueError is raised when EXTRA_HANDLERS contains
a non-string import.
"""
monkeypatch.setattr(
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
[mocker.MagicMock()],
)

with pytest.raises(ValueError) as e:
handlers.apply_extra_handlers(Exception())

Testing with multiple handlers to ensure all are invoked, and this gives
confidence one or more than two handlers can be used correctly.
assert "EXTRA_HANDLERS must be a list of strings" in str(e.value)

def test_extra_handlers_value_error_when_path_to_extra_handler_not_found(
self, monkeypatch, mocker
):
"""
Test that a ValueError is raised when the path to the extra handler
does not exist.
"""
mock_extra_handler = mocker.MagicMock()
mock_another_extra_handler = mocker.MagicMock()
monkeypatch.setattr(
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
["path.to.non_existent_handler"],
)

mocker.patch(
with pytest.raises(ValueError) as e:
handlers.apply_extra_handlers(Exception())

assert "Path path.to.non_existent_handler not found." in str(e.value)

def test_extra_handlers_value_error_when_extra_handler_is_none(
self, monkeypatch, mocker, mock_importlib_import_module
):
"""
Test that a ValueError is raised when the extra handler is None.
"""
# Make importlib.import_module return a module without the expected attribute
dummy_module = types.SimpleNamespace()
mock_importlib_import_module.return_value = dummy_module
monkeypatch.setattr(
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
[mock_extra_handler, mock_another_extra_handler],
["path.to.module.non_existent_handler"],
)

exc = mocker.MagicMock()
handlers.apply_extra_handlers(exc)
with pytest.raises(ValueError) as e:
handlers.apply_extra_handlers(Exception())

mock_extra_handler.assert_called_once_with(exc)
mock_another_extra_handler.assert_called_once_with(exc)
assert "Handler non_existent_handler not found." in str(e.value)

def test_no_extra_handlers(self, mocker):
"""Test that no extra handlers are called when EXTRA_HANDLERS is empty."""
mock_extra_handler = mocker.MagicMock()
def test_extra_handlers_value_error_when_extra_handler_not_callable(
self, monkeypatch, mocker, mock_importlib_import_module
):
"""
Test that a ValueError is raised when the extra handler is not callable.
"""
# Make importlib.import_module return a non-callable attribute
dummy_module = types.SimpleNamespace(mock_handler="not_a_function")
mock_importlib_import_module.return_value = dummy_module
monkeypatch.setattr(
"drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS",
["path.to.module.mock_handler"],
)

exc = mocker.MagicMock()
handlers.apply_extra_handlers(exc)
with pytest.raises(ValueError) as e:
handlers.apply_extra_handlers(Exception())

mock_extra_handler.assert_not_called()
assert "Handler mock_handler is not callable." in str(e.value)


class TestConvertDjangoExcToDRFAPIExc:
Expand Down