diff --git a/.gitignore b/.gitignore index 8b0c9d5..d18610b 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ dmypy.json # MacOS Desktop Services Store *.DS_Store + +# IntelliJ +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a19020b..2e21641 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: - - prettier@2.7.1 - exclude: node_modules - types_or: [javascript, ts] - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 7.0.0 hooks: - id: flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0f504..d7ac99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ----- diff --git a/README.md b/README.md index 56bd976..af3d7cb 100644 --- a/README.md +++ b/README.md @@ -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: + 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 diff --git a/data_spec_validator/__version__.py b/data_spec_validator/__version__.py index 6a157dc..f631007 100644 --- a/data_spec_validator/__version__.py +++ b/data_spec_validator/__version__.py @@ -1 +1 @@ -__version__ = '3.3.0' +__version__ = '3.4.0' diff --git a/data_spec_validator/decorator/__init__.py b/data_spec_validator/decorator/__init__.py index ccf273c..0be535d 100644 --- a/data_spec_validator/decorator/__init__.py +++ b/data_spec_validator/decorator/__init__.py @@ -1 +1,3 @@ from .decorators import dsv, dsv_request_meta + +__all__ = ['dsv', 'dsv_request_meta'] diff --git a/data_spec_validator/decorator/decorators.py b/data_spec_validator/decorator/decorators.py index 30087ca..45ce6ae 100644 --- a/data_spec_validator/decorator/decorators.py +++ b/data_spec_validator/decorator/decorators.py @@ -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}') @@ -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 @@ -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): diff --git a/data_spec_validator/spec/__init__.py b/data_spec_validator/spec/__init__.py index 688b332..a72d09b 100644 --- a/data_spec_validator/spec/__init__.py +++ b/data_spec_validator/spec/__init__.py @@ -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", +] diff --git a/data_spec_validator/spec/custom_spec/__init__.py b/data_spec_validator/spec/custom_spec/__init__.py index f169571..d7392e9 100644 --- a/data_spec_validator/spec/custom_spec/__init__.py +++ b/data_spec_validator/spec/custom_spec/__init__.py @@ -1 +1,3 @@ from .defines import register + +__all__ = ["register"] diff --git a/data_spec_validator/spec/defines.py b/data_spec_validator/spec/defines.py index 9bf8ee6..ffd0dc4 100644 --- a/data_spec_validator/spec/defines.py +++ b/data_spec_validator/spec/defines.py @@ -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 diff --git a/data_spec_validator/spec/features.py b/data_spec_validator/spec/features.py index 4f90402..80524a7 100644 --- a/data_spec_validator/spec/features.py +++ b/data_spec_validator/spec/features.py @@ -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: @@ -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 diff --git a/setup.cfg b/setup.cfg index 93d43f6..508b48e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py index cd63a04..69a1513 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,6 @@ 'decorator': ['Django>=3.0', 'djangorestframework'], 'decorator-dj': ['Django>=3.0'], }, - python_requires=">=3.6", + python_requires=">=3.8", project_urls={"Changelog": "https://github.com/hardcoretech/data-spec-validator/blob/develop/CHANGELOG.md"}, ) diff --git a/test/test_decorator_dj.py b/test/test_decorator_dj.py index 3fb89fc..e82d5e3 100644 --- a/test/test_decorator_dj.py +++ b/test/test_decorator_dj.py @@ -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 @@ -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() @@ -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 @@ -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() diff --git a/test/test_decorator_drf.py b/test/test_decorator_drf.py index 6ad06bf..c813328 100644 --- a/test/test_decorator_drf.py +++ b/test/test_decorator_drf.py @@ -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() diff --git a/test/test_nested_spec.py b/test/test_nested_spec.py index d43d340..279e4b9 100644 --- a/test/test_nested_spec.py +++ b/test/test_nested_spec.py @@ -1,6 +1,18 @@ import unittest -from data_spec_validator.spec import BOOL, DICT, DIGIT_STR, FLOAT, INT, NONE, SPEC, STR, Checker, validate_data_spec +from data_spec_validator.spec import ( + BOOL, + DICT, + DIGIT_STR, + FLOAT, + INT, + NONE, + SPEC, + STR, + Checker, + dsv_feature, + validate_data_spec, +) from .utils import is_something_error @@ -61,3 +73,28 @@ class ChildSpec3: c3_f=[], ) assert is_something_error(TypeError, validate_data_spec, nok_data, NestedSpec) + + def test_nested_error_field_name(self): + class NestedSpec: + class ChildSpec1: + @dsv_feature(spec_name='NestedSpec.c1_f.s_1') + class ChildSpec11: + f_11 = Checker([FLOAT]) + + s_1 = Checker([SPEC], SPEC=ChildSpec11) + + c1_f = Checker([SPEC], SPEC=ChildSpec1) + + nok_data = dict( + c1_f=dict( + s_1=dict( + f_11=None, + ), + ), + ) + + with self.assertRaises(TypeError) as exc_info: + validate_data_spec(nok_data, NestedSpec) + + exc_msg = str(exc_info.exception) + self.assertEqual(exc_msg, 'field: NestedSpec.c1_f.s_1.f_11, reason: None is not a float')