Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bring enhancement for providing fingrained erorr messages #41

Merged
merged 8 commits into from
Jun 12, 2024
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,6 @@ dmypy.json

# MacOS Desktop Services Store
*.DS_Store

# IntelliJ
.idea/
23 changes: 7 additions & 16 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,32 @@ default_stages: [commit, push]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.6.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/myint/autoflake
rev: v2.1.1
rev: v2.3.1
hooks:
- id: autoflake
args:
- --in-place
- --remove-unused-variables
- --remove-all-unused-imports
- --ignore-init-module-imports # TODO: remove this
- repo: https://github.com/pycqa/isort
rev: 5.12.0
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 23.3.0
rev: 24.4.2
hooks:
- id: black
language_version: python3.11
language_version: python3.8
args:
- --target-version=py311
- --target-version=py38
- --line-length=120
- --skip-string-normalization
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
additional_dependencies:
- [email protected]
exclude: node_modules
types_or: [javascript, ts]
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
rev: 7.0.0
hooks:
- id: flake8
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Changelog
=========

3.4.0
-----

Changes:

- Drop support for Python 3.6
- [feature] `dsv` decorator now have identical behavior for Django and DRF.
- [feature] new dsv_feature `spec_name` to customize the spec name in error messages.

3.3.0
-----

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,24 @@ A DSVError is raised with 3 errors in args.

"""
```
---
### Feature: Self-defined Spec name in error message
```python
from data_spec_validator.spec import Checker, dsv_feature, validate_data_spec, INT

@dsv_feature(spec_name='CustomSpecName')
class _MySpec:
mattwang44 marked this conversation as resolved.
Show resolved Hide resolved
a = Checker([INT])

nok_data = dict(
a='abc',
)

validate_data_spec(nok_data, _MySpec)
"""
TypeError: field: CustomSpecName.a, reason: 'abc' is not an integer
"""
```

---
## Test
Expand Down
2 changes: 1 addition & 1 deletion data_spec_validator/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '3.3.0'
__version__ = '3.4.0'
2 changes: 2 additions & 0 deletions data_spec_validator/decorator/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .decorators import dsv, dsv_request_meta

__all__ = ['dsv', 'dsv_request_meta']
16 changes: 10 additions & 6 deletions data_spec_validator/decorator/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
try:
from django.core.handlers.asgi import ASGIRequest
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponseBadRequest, HttpResponseForbidden, QueryDict
from django.http import HttpResponseBadRequest, HttpResponseForbidden, JsonResponse, QueryDict
from django.views.generic.base import View
except ModuleNotFoundError as e:
print(f'[DSV][WARNING] decorator: "dsv" cannot be used, {e}')
Expand Down Expand Up @@ -150,11 +150,11 @@ def _do_validate(data, spec, multirow):
is_multirow = _eval_is_multirow(multirow, data)
validate_data_spec(data, spec, multirow=is_multirow)
except ValueError as value_err:
error = ValidationError(str(value_err.args))
error = ValidationError(value_err.args)
except PermissionError as perm_err:
error = PermissionDenied(str(perm_err.args))
error = PermissionDenied(perm_err.args)
except (LookupError, TypeError, RuntimeError, DSVError) as parse_err:
error = ParseError(str(parse_err.args))
error = ParseError(parse_err.args)

if error:
raise error
Expand All @@ -165,20 +165,24 @@ def _get_error_response(error, use_drf):
Return the error response based on the error type.
If the attribute use_drf is True, Raise DRF's exception to let DRF's exception handler do something about it.
"""
error_msg = {'messages': error.message}

if use_drf:
err_map = {
ValidationError: drf_exceptions.ValidationError,
PermissionDenied: drf_exceptions.PermissionDenied,
ParseError: drf_exceptions.ParseError,
}
raise err_map[error.__class__](error.message)
raise err_map[error.__class__](error_msg)

resp_map = {
ValidationError: HttpResponseBadRequest,
PermissionDenied: HttpResponseForbidden,
ParseError: HttpResponseBadRequest,
}
return resp_map[error.__class__](error.message)

status_code = resp_map[error.__class__].status_code
return JsonResponse(error_msg, status=status_code)


def dsv(spec, multirow=False):
Expand Down
41 changes: 41 additions & 0 deletions data_spec_validator/spec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,44 @@
)
from .features import dsv_feature
from .utils import raise_if

__all__ = [
"Checker",
"CheckerOP",
"validate_data_spec",
"AMOUNT",
"AMOUNT_RANGE",
"BOOL",
"COND_EXIST",
"DATE",
"DATE_OBJECT",
"DATE_RANGE",
"DATETIME_OBJECT",
"DECIMAL_PLACE",
"DICT",
"DIGIT_STR",
"DUMMY",
"EMAIL",
"FLOAT",
"FOREACH",
"INT",
"JSON",
"JSON_BOOL",
"LENGTH",
"LIST",
"LIST_OF",
"NONE",
"ONE_OF",
"REGEX",
"SELF",
"SPEC",
"STR",
"UUID",
"BaseValidator",
"DSVError",
"ErrorMode",
"not_",
"reset_msg_level",
"dsv_feature",
"raise_if",
]
2 changes: 2 additions & 0 deletions data_spec_validator/spec/custom_spec/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .defines import register

__all__ = ["register"]
4 changes: 3 additions & 1 deletion data_spec_validator/spec/defines.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ def __str__(self, *args, **kwargs):

class ValidateResult:
def __init__(self, spec: Type = None, field: str = None, value: Any = None, check: str = None, error=None):
from .features import get_spec_name # FIXME: refine the structure to avoid circular import

# TODO: Output spec & check information when there's a debug message level for development.
self.__spec = spec.__name__ if spec else None
self.__spec = get_spec_name(spec) if spec else None
self.__field = field
self.__value = value
self.__check = check
Expand Down
30 changes: 23 additions & 7 deletions data_spec_validator/spec/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@


class _DSVFeatureParams:
__slots__ = ('_strict', '_any_keys_set', '_err_mode')
__slots__ = ('_strict', '_any_keys_set', '_err_mode', '_spec_name')

def __init__(self, strict, any_keys_set: Union[Set[Tuple[str, ...]], None], err_mode):
def __init__(self, strict, any_keys_set: Union[Set[Tuple[str, ...]], None], err_mode, spec_name):
self._strict = strict
self._any_keys_set = any_keys_set or set()
self._err_mode = err_mode
self._spec_name = spec_name

@property
def err_mode(self) -> ErrorMode:
Expand All @@ -24,30 +25,45 @@ def strict(self) -> bool:
def any_keys_set(self) -> set:
return self._any_keys_set

@property
def spec_name(self) -> str:
return self._spec_name

def __repr__(self):
return f'_DSVFeatureParams(strict={self._strict}, any_keys_set={self._any_keys_set}, err_mode={self._err_mode})'
return f'_DSVFeatureParams(strict={self._strict}, any_keys_set={self._any_keys_set}, err_mode={self._err_mode}), spec_name={self._spec_name}'


_FEAT_PARAMS = '__feat_params__'


def _process_class(
cls: Type, strict: bool, any_keys_set: Union[Set[Tuple[str, ...]], None], err_mode: ErrorMode
cls: Type,
strict: bool,
any_keys_set: Union[Set[Tuple[str, ...]], None],
err_mode: ErrorMode,
spec_name: Optional[str],
) -> Type:
setattr(cls, _FEAT_PARAMS, _DSVFeatureParams(strict, any_keys_set, err_mode))
setattr(cls, _FEAT_PARAMS, _DSVFeatureParams(strict, any_keys_set, err_mode, spec_name))

return cls


def dsv_feature(
strict: bool = False, any_keys_set: Optional[Set[Tuple[str, ...]]] = None, err_mode=ErrorMode.MSE
strict: bool = False,
any_keys_set: Optional[Set[Tuple[str, ...]]] = None,
err_mode=ErrorMode.MSE,
spec_name: Optional[str] = None,
) -> Callable:
def wrap(cls: Type) -> Type:
return _process_class(cls, strict, any_keys_set, err_mode)
return _process_class(cls, strict, any_keys_set, err_mode, spec_name)

return wrap


def get_spec_name(spec) -> str:
return getattr(spec, _FEAT_PARAMS).spec_name if hasattr(spec, _FEAT_PARAMS) else spec.__name__


def get_err_mode(spec) -> ErrorMode:
feat_params: Union[_DSVFeatureParams, None] = getattr(spec, _FEAT_PARAMS, None)
return feat_params.err_mode if feat_params else ErrorMode.MSE
Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
extend-ignore=
# See https://github.com/PyCQA/pycodestyle/issues/373
E203,
# follow existing code style
E721,
# style related, follow black
E501,
# style related, follow black
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@
'decorator': ['Django>=3.0', 'djangorestframework'],
'decorator-dj': ['Django>=3.0'],
},
python_requires=">=3.6",
python_requires=">=3.8",
mattwang44 marked this conversation as resolved.
Show resolved Hide resolved
project_urls={"Changelog": "https://github.com/hardcoretech/data-spec-validator/blob/develop/CHANGELOG.md"},
)
36 changes: 33 additions & 3 deletions test/test_decorator_dj.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from django.conf import settings
from django.core.handlers.asgi import ASGIRequest
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse, HttpResponseBadRequest
from django.http import HttpResponse, JsonResponse
from django.test import RequestFactory
from django.views import View

Expand Down Expand Up @@ -58,7 +58,7 @@ class _ViewSpec:
class _View(View):
@dsv(_ViewSpec)
def decorated_func(self, request, named_arg):
pass
return HttpResponse(status=200)

factory = RequestFactory()
wsgi_req = factory.request()
Expand All @@ -68,7 +68,8 @@ def decorated_func(self, request, named_arg):
view.decorated_func(wsgi_req, named_arg='1') # should pass validation

resp = view.decorated_func(wsgi_req, named_arg='')
assert isinstance(resp, HttpResponseBadRequest)
self.assertIsInstance(resp, JsonResponse)
self.assertEqual(resp.status_code, 400)

def test_data_and_url_params_should_not_have_intersection(self):
# arrange
Expand Down Expand Up @@ -220,6 +221,35 @@ def decorated_func(self, request, field_a):
with self.assertRaises(Exception):
non_view.decorated_func(fake_args, field_a='1')

def test_json_response_content(self):
# arrange
class _ViewSpec:
named_arg = Checker([DIGIT_STR])

class _View(View):
@dsv(_ViewSpec)
def decorated_func(self, request, named_arg):
return HttpResponse(status=200)

factory = RequestFactory()
req = factory.request()
view = _View()

# action
resp_valid = view.decorated_func(req, named_arg='1')
resp_invalid = view.decorated_func(req, named_arg='hi')

# assert
self.assertIsInstance(resp_valid, HttpResponse)
self.assertEqual(resp_valid.status_code, 200)

self.assertIsInstance(resp_invalid, JsonResponse)
self.assertEqual(resp_invalid.status_code, 400)
self.assertEqual(
json.loads(resp_invalid.content),
{'messages': ["field: _ViewSpec.named_arg, reason: 'hi' is not a digit str"]},
)


if __name__ == '__main__':
unittest.main()
23 changes: 23 additions & 0 deletions test/test_decorator_drf.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ def decorated_func(self, request, field_a):
with self.assertRaises(Exception):
non_view.decorated_func(fake_args, field_a='1')

def test_json_response_content(self):
# arrange
class _ViewSpec:
field_a = Checker([DIGIT_STR])

class _View(View):
@dsv(_ViewSpec)
def decorated_func(self, request, field_a):
pass

factory = RequestFactory()
wsgi_req = factory.request()
req = Request(wsgi_req)
view = _View()

# action & assert
with self.assertRaises(Exception) as exc_info:
view.decorated_func(req, field_a='hi')

self.assertEqual(
exc_info.exception.detail, {'messages': ["field: _ViewSpec.field_a, reason: 'hi' is not a digit str"]}
)


if __name__ == '__main__':
unittest.main()
Loading
Loading