diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8482009..5adb39f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,11 +15,11 @@ jobs: steps: - uses: actions/checkout@v3 - # We use Python 3.7 here because it's the minimum Python version supported by this library. - - name: Setup Python 3.7 + # We use Python 3.8 here because it's the minimum Python version supported by this library. + - name: Setup Python 3.8 uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: pip install --upgrade pip build @@ -42,10 +42,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Python 3.7 + - name: Setup Python 3.8 uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.8 - name: Download build artifacts uses: actions/download-artifact@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c834a88..81873ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,6 @@ jobs: fail-fast: false matrix: python-version: - - '3.7' - '3.8' - '3.9' - '3.10' diff --git a/Makefile b/Makefile index 454ac8d..6c073f0 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ docker-tox: tox run --workdir .tox_docker $(TOX_ARGS) # Run partial tox test suites in Docker -.PHONY: docker-tox-py312 docker-tox-py311 docker-tox-py310 docker-tox-py39 docker-tox-py38 docker-tox-py37 +.PHONY: docker-tox-py312 docker-tox-py311 docker-tox-py310 docker-tox-py39 docker-tox-py38 docker-tox-py312: TOX_ARGS="-e clean,py312,py312-report" docker-tox-py312: docker-tox docker-tox-py311: TOX_ARGS="-e clean,py311,py311-report" @@ -72,13 +72,10 @@ docker-tox-py39: TOX_ARGS="-e clean,py39,py39-report" docker-tox-py39: docker-tox docker-tox-py38: TOX_ARGS="-e clean,py38,py38-report" docker-tox-py38: docker-tox -docker-tox-py37: TOX_ARGS="-e clean,py37,py37-report" -docker-tox-py37: docker-tox # Run all tox test suites, but separately to check code coverage individually .PHONY: docker-tox-all docker-tox-all: - make docker-tox-py37 make docker-tox-py38 make docker-tox-py39 make docker-tox-py310 diff --git a/docs/05-dataclasses.md b/docs/05-dataclasses.md index 1a63bae..feef633 100644 --- a/docs/05-dataclasses.md +++ b/docs/05-dataclasses.md @@ -368,9 +368,6 @@ class ExampleDataclass: To set a default value for a field using the `@validataclass` decorator, you have to define the field as a **tuple** consisting of the validator and a `Default` object, e.g. `IntegerValidator(), Default(42)`. -Please note that in Python 3.7 for some reason these tuples require parentheses (see example). Unless you're writing -code for Python 3.7, it is recommended to omit the parentheses for a more consistent look, though. - **Example:** ```python @@ -381,9 +378,6 @@ from validataclass.validators import IntegerValidator class ExampleDataclass: example_field: int = IntegerValidator() optional_field: int = IntegerValidator(), Default(42) - - # Compatibility note: In Python 3.7 parentheses are required when using the tuple notation: - optional_field2: int = (IntegerValidator(), Default(42)) ``` diff --git a/setup.cfg b/setup.cfg index 5c66732..b4ed763 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,6 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -31,7 +30,7 @@ classifiers = package_dir = = src packages = find: -python_requires = >=3.7 +python_requires = >=3.8 install_requires = typing-extensions ~= 4.3 python-dateutil diff --git a/src/validataclass/dataclasses/validataclass.py b/src/validataclass/dataclasses/validataclass.py index 6800284..4764f1e 100644 --- a/src/validataclass/dataclasses/validataclass.py +++ b/src/validataclass/dataclasses/validataclass.py @@ -31,7 +31,7 @@ def validataclass(cls: Type[_T]) -> Type[_T]: @overload -def validataclass(cls: None = None, **kwargs) -> Callable[[Type[_T]], Type[_T]]: +def validataclass(cls: None = None, /, **kwargs) -> Callable[[Type[_T]], Type[_T]]: ... @@ -39,7 +39,11 @@ def validataclass(cls: None = None, **kwargs) -> Callable[[Type[_T]], Type[_T]]: kw_only_default=True, field_specifiers=(dataclasses.field, dataclasses.Field, validataclass_field), ) -def validataclass(cls: Optional[Type[_T]] = None, **kwargs) -> Union[Type[_T], Callable[[Type[_T]], Type[_T]]]: +def validataclass( + cls: Optional[Type[_T]] = None, + /, + **kwargs, +) -> Union[Type[_T], Callable[[Type[_T]], Type[_T]]]: """ Decorator that turns a normal class into a DataclassValidator-compatible dataclass. @@ -67,9 +71,6 @@ class ExampleDataclass: example_field3: str = validataclass_field(StringValidator()) # Same as example_field1 example_field4: str = validataclass_field(StringValidator(), default='not set') # Same as example_field2 post_init_field: int = field(init=False, default=0) # Post-init field without validator - - # COMPATIBILITY NOTE: In Python 3.7 parentheses are required when setting a Default using the tuple notation: - # field_with_default: str = (StringValidator(), Default('not set')) ``` Note: As of now, InitVars are not supported because they are not recognized as proper fields. This might change in a diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index e4b5f74..ce00279 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -51,9 +51,6 @@ class ExampleDataclass: example_field: str = StringValidator() optional_field: str = StringValidator(), Default('') - # Compatibility note: In Python 3.7 parentheses are required when setting a Default using the tuple notation: - # optional_field: str = (StringValidator(), Default('')) - # Equivalent definition using validataclass_field(): # example_field: str = validataclass_field(StringValidator()) # optional_field: str = validataclass_field(StringValidator(), default='') diff --git a/tests/dataclasses/validataclass_mixin_test.py b/tests/dataclasses/validataclass_mixin_test.py index f174707..1b1adf4 100644 --- a/tests/dataclasses/validataclass_mixin_test.py +++ b/tests/dataclasses/validataclass_mixin_test.py @@ -16,8 +16,8 @@ @validataclass class UnitTestDataclass(ValidataclassMixin): foo: int = IntegerValidator() # required field - bar: str = (StringValidator(), Default('bloop')) - baz: OptionalUnset[Decimal] = (DecimalValidator(), DefaultUnset) + bar: str = StringValidator(), Default('bloop') + baz: OptionalUnset[Decimal] = DecimalValidator(), DefaultUnset class ValidataclassMixinTest: @@ -27,7 +27,7 @@ class ValidataclassMixinTest: @staticmethod def test_validataclass_to_dict(): - """ Tests the to_dict() method of the ValidataclassMixin class using the regular constructor. """ + """ Tests ValidataclassMixin.to_dict() using the regular constructor. """ obj = UnitTestDataclass(foo=42, bar='meep', baz=Decimal('-1.23')) assert obj.to_dict() == { 'foo': 42, @@ -37,7 +37,7 @@ def test_validataclass_to_dict(): @staticmethod def test_validataclass_to_dict_validated(): - """ Tests the to_dict() method of the ValidataclassMixin class using a DataclassValidator. """ + """ Tests ValidataclassMixin.to_dict() using a DataclassValidator. """ validator = DataclassValidator(UnitTestDataclass) obj: UnitTestDataclass = validator.validate({'foo': 42, 'bar': 'meep', 'baz': '-1.23'}) assert obj.to_dict() == { @@ -48,7 +48,7 @@ def test_validataclass_to_dict_validated(): @staticmethod def test_validataclass_to_dict_validated_with_defaults(): - """ Tests the to_dict() method of the ValidataclassMixin class using a DataclassValidator, with default values. """ + """ Tests ValidataclassMixin.to_dict() using a DataclassValidator, with default values. """ validator = DataclassValidator(UnitTestDataclass) obj: UnitTestDataclass = validator.validate({'foo': 42}) assert obj.to_dict() == { @@ -58,7 +58,7 @@ def test_validataclass_to_dict_validated_with_defaults(): @staticmethod def test_validataclass_to_dict_validated_keep_unset_values(): - """ Tests the to_dict() method of the ValidataclassMixin class with the parameter keep_unset_value=True. """ + """ Tests ValidataclassMixin.to_dict() with the parameter keep_unset_value=True. """ validator = DataclassValidator(UnitTestDataclass) obj: UnitTestDataclass = validator.validate({'foo': 42}) obj_as_dict = obj.to_dict(keep_unset_values=True) diff --git a/tests/dataclasses/validataclass_test.py b/tests/dataclasses/validataclass_test.py index 316066d..04d13b0 100644 --- a/tests/dataclasses/validataclass_test.py +++ b/tests/dataclasses/validataclass_test.py @@ -10,7 +10,14 @@ import pytest from tests.dataclasses._helpers import assert_field_default, assert_field_no_default, get_dataclass_fields -from validataclass.dataclasses import validataclass, validataclass_field, Default, DefaultFactory, DefaultUnset, NoDefault +from validataclass.dataclasses import ( + validataclass, + validataclass_field, + Default, + DefaultFactory, + DefaultUnset, + NoDefault, +) from validataclass.exceptions import DataclassValidatorFieldException from validataclass.helpers import OptionalUnset, UnsetValue from validataclass.validators import IntegerValidator, StringValidator, Noneable, ListValidator, DictValidator @@ -63,10 +70,11 @@ class UnitTestValidatorDataclass: @staticmethod def test_validataclass_with_kwargs(): - """ Create a dataclass using @validataclass(...) with arguments and check that they are passed to @dataclass(). """ + """ Create a dataclass using @validataclass() with arguments and check that they are passed to @dataclass(). """ - # Create two dataclasses, one without any arguments and one with unsafe_hash=True. The first won't have a __hash__ function, - # but the latter will have one. We can use this to check that the argument was really passed to @dataclass. + # Create two dataclasses, one without any arguments and one with unsafe_hash=True. + # The first won't have a __hash__ function, but the latter will have one. + # We can use this to check that the argument was really passed to @dataclass. @validataclass() class FooDataclass: @@ -90,9 +98,9 @@ def test_validataclass_with_tuples(): @validataclass class UnitTestValidatorDataclass: - foo: int = (IntegerValidator(), NoDefault) - bar: int = (IntegerValidator(), Default(42)) - baz: Optional[int] = (IntegerValidator(), Default(None)) + foo: int = IntegerValidator(), NoDefault + bar: int = IntegerValidator(), Default(42) + baz: Optional[int] = IntegerValidator(), Default(None) # Get fields from dataclass fields = get_dataclass_fields(UnitTestValidatorDataclass) @@ -156,8 +164,8 @@ def counter(): @validataclass class UnitTestDataclass: field1: int = IntegerValidator() - field2: int = (IntegerValidator(), Default(100)) - field3: int = (IntegerValidator(), DefaultFactory(counter)) + field2: int = IntegerValidator(), Default(100) + field3: int = IntegerValidator(), DefaultFactory(counter) # Create an instance where all fields are specified explicitly instance = UnitTestDataclass(field1=42, field2=13, field3=12) @@ -173,12 +181,14 @@ class UnitTestDataclass: @staticmethod def test_validataclass_create_objects_invalid(): - """ Create a dataclass using @validataclass and try to instantiate objects from it, but missing a required value. """ + """ + Create a dataclass using @validataclass and try to instantiate objects from it, but missing a required value. + """ @validataclass class UnitTestDataclass: required_field: int = IntegerValidator() - optional_field: int = (IntegerValidator(), Default(10)) + optional_field: int = IntegerValidator(), Default(10) # Try to instantiate without the required field with pytest.raises(TypeError, match="required keyword-only argument"): @@ -197,8 +207,11 @@ def test_validataclass_with_mutable_defaults(): @validataclass class UnitTestDataclass: - field_list: List[int] = (ListValidator(IntegerValidator()), Default([])) - field_dict: Dict[str, int] = (DictValidator(field_validators={'foo': IntegerValidator()}), Default({'foo': 0})) + field_list: List[int] = ListValidator(IntegerValidator()), Default([]) + field_dict: Dict[str, int] = ( + DictValidator(field_validators={'foo': IntegerValidator()}), + Default({'foo': 0}), + ) # Try to instantiate the class using the regular constructor obj1 = UnitTestDataclass() @@ -216,7 +229,7 @@ class UnitTestDataclass: @staticmethod def test_validataclass_subclassing_defaults(): - """ Test the @validataclass decorator with a subclassed validataclass with different defaults. """ + """ Test @validataclass decorator with a subclassed validataclass with different defaults. """ @validataclass class BaseClass: @@ -227,10 +240,10 @@ class BaseClass: required4: int = IntegerValidator() # Optional fields - optional1: Optional[int] = (IntegerValidator(), Default(None)) - optional2: Optional[int] = (IntegerValidator(), Default(None)) - optional3: int = (IntegerValidator(), Default(3)) - optional4: OptionalUnset[int] = (IntegerValidator(), DefaultUnset) + optional1: Optional[int] = IntegerValidator(), Default(None) + optional2: Optional[int] = IntegerValidator(), Default(None) + optional3: int = IntegerValidator(), Default(3) + optional4: OptionalUnset[int] = IntegerValidator(), DefaultUnset @validataclass class SubClass(BaseClass): @@ -252,8 +265,10 @@ class SubClass(BaseClass): fields = get_dataclass_fields(SubClass) # Check that all fields exist - assert list(fields.keys()) == \ - ['required1', 'required2', 'required3', 'required4', 'optional1', 'optional2', 'optional3', 'optional4'] + assert list(fields.keys()) == [ + 'required1', 'required2', 'required3', 'required4', + 'optional1', 'optional2', 'optional3', 'optional4', + ] # Check type annotations assert all(fields[field].type is int for field in ['required1', 'required2', 'optional2', 'optional4']) @@ -277,7 +292,7 @@ class SubClass(BaseClass): @staticmethod def test_validataclass_subclassing_validators(): - """ Test the @validataclass decorator with a subclassed validataclass with different validators and new fields. """ + """ Test @validataclass decorator with a subclassed validataclass with different validators and new fields. """ @validataclass class BaseClass: @@ -286,22 +301,22 @@ class BaseClass: required2: int = IntegerValidator() # Optional fields - optional1: int = (IntegerValidator(), Default(3)) - optional2: int = (IntegerValidator(), Default(3)) + optional1: int = IntegerValidator(), Default(3) + optional2: int = IntegerValidator(), Default(3) @validataclass class SubClass(BaseClass): # Required fields required1: str = StringValidator() - required2: Optional[str] = (StringValidator(), Default(None)) + required2: Optional[str] = StringValidator(), Default(None) # Optional fields - optional1: str = StringValidator() # No default override, so Default(3) from base class should still be set! - optional2: Optional[str] = (StringValidator(), Default(None)) + optional1: str = StringValidator() # No default override, so the default should still be Default(3) + optional2: Optional[str] = StringValidator(), Default(None) # New fields new1: str = StringValidator() - new2: Optional[str] = (StringValidator(), Default(None)) + new2: Optional[str] = StringValidator(), Default(None) # Get fields from dataclass fields = get_dataclass_fields(SubClass) @@ -341,7 +356,7 @@ class BaseClass: @validataclass class SubClass(BaseClass): # Modify the validated field - validated: int = (IntegerValidator(), Default(42)) + validated: int = IntegerValidator(), Default(42) # Get fields from dataclass fields = get_dataclass_fields(SubClass) @@ -362,7 +377,7 @@ def test_validataclass_multiple_inheritance(): @validataclass class BaseA: field_a: int = IntegerValidator() - field_both: int = (IntegerValidator(), Default(1)) + field_both: int = IntegerValidator(), Default(1) @validataclass class BaseB: @@ -385,9 +400,9 @@ class SubClass(BaseB, BaseA): assert fields['field_a'].metadata.get('validator_default') == Default(42) assert fields['field_b'].metadata.get('validator_default') == Default(None) - # For fields defined in both base classes, BaseB should take precedence over BaseA (MRO: SubClass, BaseB, BaseA, object). - # Since BaseB does NOT inherit from BaseA, there should NOT be partial overrides in the field, i.e. field_both in - # SubClass does not have a default, since the field has no default in BaseB. + # For fields defined in both base classes, BaseB should take precedence over BaseA (MRO: SubClass, BaseB, BaseA, + # object). Since BaseB does NOT inherit from BaseA, there should NOT be partial overrides in the field, i.e. + # field_both in SubClass does not have a default, since the field has no default in BaseB. assert isinstance((fields['field_both'].metadata.get('validator')), StringValidator) assert fields['field_both'].metadata.get('validator_default') is None @@ -395,7 +410,10 @@ class SubClass(BaseB, BaseA): @staticmethod def test_validataclass_with_invalid_values(): - """ Test that @validataclass raises exceptions when a field is not predefined (e.g. with field()) and has no Validator. """ + """ + Test that @validataclass raises exceptions if a field is not already defined (e.g. with field()) and has no + Validator. + """ with pytest.raises(DataclassValidatorFieldException) as exception_info: @validataclass @@ -462,7 +480,9 @@ class InvalidDataclass: ] ) def test_validataclass_with_missing_annotations_invalid(cls_name): - """ Test that @validataclass raises exceptions when it detects a field with a validator but with a missing type annotation. """ + """ + Test that @validataclass raises exceptions when it detects a field with a validator but no type annotation. + """ class InvalidDataclassA: foo = IntegerValidator() @@ -471,7 +491,7 @@ class InvalidDataclassB: foo = Default(0) class InvalidDataclassC: - foo = (IntegerValidator(), Default(0)) + foo = IntegerValidator(), Default(0) classes = { 'InvalidDataclassA': InvalidDataclassA, @@ -481,7 +501,11 @@ class InvalidDataclassC: with pytest.raises(DataclassValidatorFieldException) as exception_info: validataclass(classes[cls_name]) - assert str(exception_info.value) == 'Dataclass field "foo" has a defined Validator and/or Default object, but no type annotation.' + + assert ( + str(exception_info.value) + == 'Dataclass field "foo" has a defined Validator and/or Default object, but no type annotation.' + ) @staticmethod def test_validataclass_with_missing_annotations_valid(): @@ -501,10 +525,13 @@ class InvalidDataclass: @staticmethod def test_validataclass_with_init_vars_exception(): - """ Test that @validataclass raises an exception when it detects InitVars (because they don't work currently). """ + """ Test that @validataclass raises an exception when it detects InitVars (they don't work currently). """ with pytest.raises(DataclassValidatorFieldException) as exception_info: @validataclass class InvalidDataclass: foo: dataclasses.InitVar[int] = IntegerValidator() - assert str(exception_info.value) == 'Dataclass field "foo": InitVars currently not supported by DataclassValidator.' + assert ( + str(exception_info.value) + == 'Dataclass field "foo": InitVars currently not supported by DataclassValidator.' + ) diff --git a/tests/validators/dataclass_validator_test.py b/tests/validators/dataclass_validator_test.py index eaf7ff1..ebcaf1e 100644 --- a/tests/validators/dataclass_validator_test.py +++ b/tests/validators/dataclass_validator_test.py @@ -40,7 +40,7 @@ class UnitTestDataclass: Simple dataclass for testing DataclassValidator. """ name: str = StringValidator() - color: str = (StringValidator(), Default('unknown color')) + color: str = StringValidator(), Default('unknown color') amount: int = IntegerValidator() weight: Decimal = DecimalValidator() @@ -108,7 +108,7 @@ class UnitTestContextSensitiveDataclass: when the context argument "value_required" is set. """ name: str = UnitTestContextValidator() - value: Optional[int] = (IntegerValidator(), Default(None)) + value: Optional[int] = IntegerValidator(), Default(None) def __post_validate__(self, *, value_required: bool = False): if value_required and self.value is None: @@ -391,10 +391,10 @@ def counter(): @validataclass class DataclassWithDefaults: - default_str: str = (StringValidator(), Default('example default')) - default_list: List[int] = (ListValidator(IntegerValidator()), Default([])) - default_counter: int = (IntegerValidator(), DefaultFactory(counter)) - default_unset: OptionalUnset[str] = (StringValidator(), DefaultUnset) + default_str: str = StringValidator(), Default('example default') + default_list: List[int] = ListValidator(IntegerValidator()), Default([]) + default_counter: int = IntegerValidator(), DefaultFactory(counter) + default_unset: OptionalUnset[str] = StringValidator(), DefaultUnset validator = DataclassValidator(DataclassWithDefaults) diff --git a/tox.ini b/tox.ini index 367a89d..40b077f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 4.5.1 -envlist = clean,py{312,311,310,39,38,37},flake8,report +envlist = clean,py{312,311,310,39,38},flake8,report skip_missing_interpreters = true isolated_build = true @@ -28,7 +28,7 @@ skip_install = true deps = {[testenv:report]deps} commands = coverage erase -[testenv:report,py{312,311,310,39,38,37}-report] +[testenv:report,py{312,311,310,39,38}-report] skip_install = true deps = coverage