Skip to content

Commit

Permalink
Merge pull request #117 from binary-butterfly/move-base-validation-er…
Browse files Browse the repository at this point in the history
…ror-to-separate-module

Move base ValidationError to new module base_exceptions
  • Loading branch information
binaryDiv authored Apr 18, 2024
2 parents f126b8e + 17bd3fc commit cf7e679
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 141 deletions.
7 changes: 5 additions & 2 deletions src/validataclass/exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
InvalidValidatorOptionException,
)

# Base and common validation error exceptions (base class ValidationError)
from .common_exceptions import ValidationError, RequiredValueError, FieldNotAllowedError, InvalidTypeError
# Base exception classes for validation errors
from .base_exceptions import ValidationError

# Common validation errors used throughout the library
from .common_exceptions import RequiredValueError, FieldNotAllowedError, InvalidTypeError

# More specific validation errors
from .dataclass_exceptions import DataclassPostValidationError
Expand Down
68 changes: 68 additions & 0 deletions src/validataclass/exceptions/base_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
validataclass
Copyright (c) 2024, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

from typing import Dict, Optional

__all__ = [
'ValidationError',
]


class ValidationError(Exception):
"""
Exception that is raised by validators if the input data is not valid. Can be subclassed for specific errors.
Contains a string error code (usually in snake_case) to describe the error that can be used by frontends to generate human readable
error messages. Optionally it can contain additional fields for further information, e.g. for an 'invalid_length' error there could
be fields like 'min' and 'max' to tell the client what length an input string is supposed to have. Exceptions for combound validators
(like `ListValidator` and `DictValidator`) could also contain nested exceptions.
The optional 'reason' attribute can be used to further describe an error with a human readable string (e.g. if some input is only
invalid under certain conditions and the error code alone does not make enough sense, for example a 'required_field' error on a field
that usually is optional could have a 'reason' string like "Field is required when $someOtherField is defined."
Use `exception.to_dict()` to get a dictionary suitable for generating JSON responses.
"""
code: str = 'unknown_error'
reason: Optional[str] = None
extra_data: Optional[dict] = None

def __init__(self, *, code: Optional[str] = None, reason: Optional[str] = None, **kwargs):
if code is not None:
self.code = code
if reason is not None:
self.reason = reason
self.extra_data = {key: value for key, value in kwargs.items() if value is not None}

def __repr__(self):
params_string = ', '.join(f'{key}={value}' for key, value in self._get_repr_dict().items() if value is not None)
return f'{type(self).__name__}({params_string})'

def __str__(self):
return self.__repr__()

def _get_repr_dict(self) -> Dict[str, str]:
"""
Returns a dictionary representing the error fields as strings (e.g. by applying `repr()` on the values).
Used by `__repr__` to generate a string representation of the form "ExampleValidationError(code='foo', reason='foo', ...)".
The default implementation calls `to_dict()` and applies `repr()` on all values.
"""
return {
key: repr(value) for key, value in self.to_dict().items() if value is not None
}

def to_dict(self) -> dict:
"""
Generate a dictionary containing error information, suitable as response to the user.
May be overridden by subclasses to extend the dictionary.
"""
reason = {'reason': self.reason} if self.reason is not None else {}
extra_data = self.extra_data if self.extra_data is not None else {}
return {
'code': self.code,
**reason,
**extra_data,
}
62 changes: 3 additions & 59 deletions src/validataclass/exceptions/common_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,73 +4,17 @@
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

from typing import Union, Optional, Dict, List
from typing import List, Union

from .base_exceptions import ValidationError

__all__ = [
'ValidationError',
'RequiredValueError',
'FieldNotAllowedError',
'InvalidTypeError',
]


class ValidationError(Exception):
"""
Exception that is raised by validators if the input data is not valid. Can be subclassed for specific errors.
Contains a string error code (usually in snake_case) to describe the error that can be used by frontends to generate human readable
error messages. Optionally it can contain additional fields for further information, e.g. for an 'invalid_length' error there could
be fields like 'min' and 'max' to tell the client what length an input string is supposed to have. Exceptions for combound validators
(like `ListValidator` and `DictValidator`) could also contain nested exceptions.
The optional 'reason' attribute can be used to further describe an error with a human readable string (e.g. if some input is only
invalid under certain conditions and the error code alone does not make enough sense, for example a 'required_field' error on a field
that usually is optional could have a 'reason' string like "Field is required when $someOtherField is defined."
Use `exception.to_dict()` to get a dictionary suitable for generating JSON responses.
"""
code: str = 'unknown_error'
reason: Optional[str] = None
extra_data: Optional[dict] = None

def __init__(self, *, code: Optional[str] = None, reason: Optional[str] = None, **kwargs):
if code is not None:
self.code = code
if reason is not None:
self.reason = reason
self.extra_data = {key: value for key, value in kwargs.items() if value is not None}

def __repr__(self):
params_string = ', '.join(f'{key}={value}' for key, value in self._get_repr_dict().items() if value is not None)
return f'{type(self).__name__}({params_string})'

def __str__(self):
return self.__repr__()

def _get_repr_dict(self) -> Dict[str, str]:
"""
Returns a dictionary representing the error fields as strings (e.g. by applying `repr()` on the values).
Used by `__repr__` to generate a string representation of the form "ExampleValidationError(code='foo', reason='foo', ...)".
The default implementation calls `to_dict()` and applies `repr()` on all values.
"""
return {
key: repr(value) for key, value in self.to_dict().items() if value is not None
}

def to_dict(self) -> dict:
"""
Generate a dictionary containing error information, suitable as response to the user.
May be overridden by subclasses to extend the dictionary.
"""
reason = {'reason': self.reason} if self.reason is not None else {}
extra_data = self.extra_data if self.extra_data is not None else {}
return {
'code': self.code,
**reason,
**extra_data,
}


class RequiredValueError(ValidationError):
"""
Validation error raised when None is passed as input data (unless using `Noneable`).
Expand Down
88 changes: 88 additions & 0 deletions tests/exceptions/base_exceptions_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
validataclass
Copyright (c) 2024, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

import pytest

from validataclass.exceptions import ValidationError


class ValidationErrorTest:
"""
Tests for the ValidationError exception class.
"""

@staticmethod
@pytest.mark.parametrize(
'code, kwargs, expected_repr', [
(
None,
{},
"ValidationError(code='unknown_error')"
),
(
'unit_test_error',
{},
"ValidationError(code='unit_test_error')"
),
(
None,
{'reason': 'This is fine.'},
"ValidationError(code='unknown_error', reason='This is fine.')"
),
(
'unit_test_error',
{'reason': 'This is fine.', 'fruit': 'banana', 'number': 123},
"ValidationError(code='unit_test_error', reason='This is fine.', fruit='banana', number=123)",
),
]
)
def test_validation_error_with_parameters(code, kwargs, expected_repr):
""" Tests ValidationError with various parameters. """
error = ValidationError(code=code, **kwargs)

expected_code = code if code is not None else 'unknown_error'
assert repr(error) == expected_repr
assert str(error) == repr(error)
assert error.to_dict() == {
'code': expected_code,
**kwargs,
}

@staticmethod
@pytest.mark.parametrize(
'code, kwargs, expected_repr', [
(
None,
{},
"UnitTestValidatonError(code='unit_test_error')"
),
(
None,
{'reason': 'This is fine.'},
"UnitTestValidatonError(code='unit_test_error', reason='This is fine.')"
),
(
'unit_test_error',
{'reason': 'This is fine.', 'fruit': 'banana', 'number': 123},
"UnitTestValidatonError(code='unit_test_error', reason='This is fine.', fruit='banana', number=123)",
),
]
)
def test_validation_error_subclass(code, kwargs, expected_repr):
""" Tests subclassing ValidationError. """

class UnitTestValidatonError(ValidationError):
code = 'unit_test_error'

error = UnitTestValidatonError(code=code, **kwargs)

expected_code = code if code is not None else 'unit_test_error'
assert repr(error) == expected_repr
assert str(error) == repr(error)
assert error.to_dict() == {
'code': expected_code,
**kwargs,
}
81 changes: 1 addition & 80 deletions tests/exceptions/common_exceptions_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,86 +6,7 @@

import pytest

from validataclass.exceptions import ValidationError, InvalidTypeError


class ValidationErrorTest:
"""
Tests for the ValidationError exception class.
"""

@staticmethod
@pytest.mark.parametrize(
'code, kwargs, expected_repr', [
(
None,
{},
"ValidationError(code='unknown_error')"
),
(
'unit_test_error',
{},
"ValidationError(code='unit_test_error')"
),
(
None,
{'reason': 'This is fine.'},
"ValidationError(code='unknown_error', reason='This is fine.')"
),
(
'unit_test_error',
{'reason': 'This is fine.', 'fruit': 'banana', 'number': 123},
"ValidationError(code='unit_test_error', reason='This is fine.', fruit='banana', number=123)",
),
]
)
def test_validation_error_with_parameters(code, kwargs, expected_repr):
""" Tests ValidationError with various parameters. """
error = ValidationError(code=code, **kwargs)

expected_code = code if code is not None else 'unknown_error'
assert repr(error) == expected_repr
assert str(error) == repr(error)
assert error.to_dict() == {
'code': expected_code,
**kwargs,
}

@staticmethod
@pytest.mark.parametrize(
'code, kwargs, expected_repr', [
(
None,
{},
"UnitTestValidatonError(code='unit_test_error')"
),
(
None,
{'reason': 'This is fine.'},
"UnitTestValidatonError(code='unit_test_error', reason='This is fine.')"
),
(
'unit_test_error',
{'reason': 'This is fine.', 'fruit': 'banana', 'number': 123},
"UnitTestValidatonError(code='unit_test_error', reason='This is fine.', fruit='banana', number=123)",
),
]
)
def test_validation_error_subclass(code, kwargs, expected_repr):
""" Tests subclassing ValidationError. """

class UnitTestValidatonError(ValidationError):
code = 'unit_test_error'

error = UnitTestValidatonError(code=code, **kwargs)

expected_code = code if code is not None else 'unit_test_error'
assert repr(error) == expected_repr
assert str(error) == repr(error)
assert error.to_dict() == {
'code': expected_code,
**kwargs,
}
from validataclass.exceptions import InvalidTypeError


class InvalidTypeErrorTest:
Expand Down

0 comments on commit cf7e679

Please sign in to comment.