diff --git a/.coveragerc b/.coveragerc index 30fb440..9509b63 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,8 +8,6 @@ branch = True source = validataclass omit = */_version.py -plugins = - coverage_conditional_plugin [report] show_missing = True @@ -25,10 +23,3 @@ exclude_lines = directory = reports/coverage_html/ skip_covered = False show_contexts = True - -[coverage_conditional_plugin] -# These rules are a bit contraintuitive as they define when to IGNORE code from coverage, instead of when to include it, -# so we prefix the pragmas with "ignore-" to make it more clear. -rules = - "sys_version_info < (3, 10)": ignore-py-lt-310 - "sys_version_info >= (3, 10)": ignore-py-gte-310 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2910a62..56396b2 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.9 here because it's the minimum Python version supported by this library. - - name: Setup Python 3.9 + # We use Python 3.10 here because it's the minimum Python version supported by this library. + - name: Setup Python 3.10 uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.10 - name: Install dependencies run: pip install --upgrade pip build @@ -42,10 +42,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Python 3.9 + - name: Setup Python 3.10 uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.10 - name: Download build artifacts uses: actions/download-artifact@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f4e0f7..2836551 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,6 @@ jobs: fail-fast: false matrix: python-version: - - '3.9' - '3.10' - '3.11' - '3.12' diff --git a/Makefile b/Makefile index 789c618..fb02ade 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ open-coverage: # Run complete tox test suite in a multi-python Docker container .PHONY: docker-tox -docker-tox: TOX_ARGS='-e clean,py312,py311,py310,py39,report,flake8,py312-mypy' +docker-tox: TOX_ARGS='-e clean,py312,py311,py310,report,flake8,py312-mypy' docker-tox: docker run --rm --tty \ --user $(DOCKER_USER) \ @@ -67,27 +67,24 @@ 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 +.PHONY: docker-tox-py312 docker-tox-py311 docker-tox-py310 docker-test-py312: TOX_ARGS="-e clean,py312,py312-report" docker-test-py312: docker-tox docker-test-py311: TOX_ARGS="-e clean,py311,py311-report" docker-test-py311: docker-tox docker-test-py310: TOX_ARGS="-e clean,py310,py310-report" docker-test-py310: docker-tox -docker-test-py39: TOX_ARGS="-e clean,py39,py39-report" -docker-test-py39: docker-tox # Run all tox test suites, but separately to check code coverage individually .PHONY: docker-test-all docker-test-all: - make docker-test-py39 make docker-test-py310 make docker-test-py311 make docker-test-py312 # Run mypy using all different (or specific) Python versions in Docker -.PHONY: docker-mypy-all docker-mypy-py312 docker-mypy-py311 docker-mypy-py310 docker-mypy-py39 -docker-mypy-all: TOX_ARGS="-e py312-mypy,py311-mypy,py310-mypy,py39-mypy" +.PHONY: docker-mypy-all docker-mypy-py312 docker-mypy-py311 docker-mypy-py310 +docker-mypy-all: TOX_ARGS="-e py312-mypy,py311-mypy,py310-mypy" docker-mypy-all: docker-tox docker-mypy-py312: TOX_ARGS="-e py312-mypy" docker-mypy-py312: docker-tox @@ -95,8 +92,6 @@ docker-mypy-py311: TOX_ARGS="-e py311-mypy" docker-mypy-py311: docker-tox docker-mypy-py310: TOX_ARGS="-e py310-mypy" docker-mypy-py310: docker-tox -docker-mypy-py39: TOX_ARGS="-e py39-mypy" -docker-mypy-py39: docker-tox # Pull the latest image of the multi-python Docker image .PHONY: docker-pull diff --git a/docs/05-dataclasses.md b/docs/05-dataclasses.md index c672c45..c4f483f 100644 --- a/docs/05-dataclasses.md +++ b/docs/05-dataclasses.md @@ -330,9 +330,7 @@ still use the defaults stored in the field metadata, but you can now also create default values in the same way as with a regular dataclass. This is accomplished by creating dataclasses with the option `kw_only=True` (which modifies the auto-generated class -constructor to only accept keyword arguments, so that the order of arguments doesn't matter anymore). This option was -only introduced in Python 3.10, though, so for older versions of Python a slightly hacky workaround was implemented -(take a look at the code of `validataclass_field()` if you're curious). +constructor to only accept keyword arguments, so that the order of arguments doesn't matter anymore). In general, all fields that do **not** have any default value are required fields (i.e. `DataclassValidator` will reject any input where one of these fields is missing). To define an optional field **without** a default value, you can use @@ -441,9 +439,10 @@ in your code to distinguish it from other values like `None`. For this you can use the `DefaultUnset` object, which is a shortcut for `Default(UnsetValue)`. -Remember to adjust the type hints in your dataclass though. There is a type alias `OptionalUnset[T]` which you can use for this, for -example: `some_var: OptionalUnset[int]`, which is equivalent to `Union[int, UnsetValueType]`. For fields that can be both `None` and -`UnsetValue`, there is also the type alias `OptionalUnsetNone[T]` as a shortcut for `OptionalUnset[Optional[T]]`. +Remember to adjust the type hints in your dataclass though. There is a type alias `OptionalUnset[T]` which you can use +for this, for example: `some_var: OptionalUnset[int]`, which is equivalent to `int | UnsetValueType`. For fields that +can be both `None` and `UnsetValue`, there is also the type alias `OptionalUnsetNone[T]` as a shortcut for +`OptionalUnset[Optional[T]]` or `T | UnsetValueType | None`. #### NoDefault @@ -495,16 +494,17 @@ When a field is optional, this means that the field is allowed to be **omitted c does **not** automatically mean that the input value is allowed to have the value `None`. A field with a default value would still raise a `RequiredValueError` if the input value is `None`. This is, unless a field validator that explicitly allows `None` as value is used. -For example, imagine a dataclass with only one field: `some_var: Optional[int] = IntegerValidator(), Default(None)`. An empty input -dictionary `{}` would result in an object with the default value `some_var = None`, but the input dictionary `{"some_var": None}` itself -would **not** be valid at all. +For example, imagine a dataclass with only one field: `some_var: int | None = IntegerValidator(), Default(None)`. An +empty input dictionary `{}` would result in an object with the default value `some_var = None`, but the input +dictionary `{"some_var": None}` itself would **not** be valid at all. -Instead, to explicitly allow `None` as value, you can use the `Noneable` wrapper (introduced [earlier](03-basic-validators.md)), -e.g. `some_var: Optional[int] = Noneable(IntegerValidator())`. This however does **not** make the field optional, so an input dictionary -with the value `None` would be allowed, but omitting the field in an input dictionary would be invalid. +Instead, to explicitly allow `None` as value, you can use the `Noneable` wrapper (introduced +[earlier](03-basic-validators.md)), e.g. `some_var: int | None = Noneable(IntegerValidator())`. This however does +**not** make the field optional, so an input dictionary with the value `None` would be allowed, but omitting the field +in an input dictionary would be invalid. To make a field both optional **and** allow `None` as value, you can simply combine `Noneable()` and a `Default` value. \ -For example: `some_var: Optional[int] = Noneable(IntegerValidator()), Default(None)`. +For example: `some_var: int | None = Noneable(IntegerValidator()), Default(None)`. You can also configure the `Noneable` wrapper to use a different default value than `None`. For example, to always use `0` as the default value, regardless of whether the field is missing in the input dictionary or whether the field has the input value `None`: \ @@ -554,7 +554,6 @@ the "modify" dataclass from the "create" dataclass and change all field defaults ```python from decimal import Decimal -from typing import Optional from validataclass.dataclasses import validataclass, Default, DefaultUnset from validataclass.helpers import OptionalUnset, OptionalUnsetNone @@ -564,7 +563,7 @@ from validataclass.validators import IntegerValidator, StringValidator, DecimalV class CreateStuffRequest: name: str = StringValidator() some_value: int = IntegerValidator() - some_decimal: Optional[Decimal] = DecimalValidator(), Default(None) + some_decimal: Decimal | None = DecimalValidator(), Default(None) @validataclass class ModifyStuffRequest(CreateStuffRequest): @@ -845,8 +844,6 @@ dictionary: Here is another code example for a dataclass with conditionally required fields: ```python -from typing import Optional - from validataclass.dataclasses import validataclass, Default from validataclass.exceptions import RequiredValueError, DataclassPostValidationError from validataclass.validators import DataclassValidator, BooleanValidator, IntegerValidator @@ -857,7 +854,7 @@ class ExampleClass: enable_something: bool = BooleanValidator() # This field is required only if enable_something is True. Otherwise it will be ignored. - some_value: Optional[int] = IntegerValidator(), Default(None) + some_value: int | None = IntegerValidator(), Default(None) def __post_validate__(self): # If enable_something is True, ensure that some_value is set! @@ -917,8 +914,6 @@ The `DataclassValidator` will make sure to only pass the arguments that the meth Example: ```python -from typing import Optional - from validataclass.dataclasses import validataclass, Default from validataclass.exceptions import RequiredValueError, DataclassPostValidationError from validataclass.validators import DataclassValidator, IntegerValidator @@ -926,7 +921,7 @@ from validataclass.validators import DataclassValidator, IntegerValidator @validataclass class ContextSensitiveExampleClass: # This field is optional, unless the context says otherwise. - some_value: Optional[int] = IntegerValidator(), Default(None) + some_value: int | None = IntegerValidator(), Default(None) # Note: You can also specify **kwargs here to get all context arguments. def __post_validate__(self, *, require_some_value: bool = False): @@ -1046,7 +1041,6 @@ In conclusion, let's take a look at a final example. This one is a bit more exte from datetime import datetime from decimal import Decimal from enum import Enum -from typing import Optional from validataclass.dataclasses import validataclass, Default from validataclass.exceptions import ValidationError, DataclassPostValidationError @@ -1063,7 +1057,7 @@ class Color(Enum): class OrderItem: name: str = StringValidator() price: Decimal = DecimalValidator() - color: Optional[Color] = EnumValidator(Color), Default(None) + color: Color | None = EnumValidator(Color), Default(None) @validataclass class Order: diff --git a/setup.cfg b/setup.cfg index 69a2d0c..f7e98db 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.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 @@ -29,18 +28,17 @@ classifiers = package_dir = = src packages = find: -python_requires = ~=3.9 +python_requires = ~=3.10 install_requires = - typing-extensions ~= 4.3 + typing-extensions ~= 4.12 [options.packages.find] where = src [options.extras_require] testing = - pytest ~= 7.2 - pytest-cov - coverage ~= 6.5 - coverage-conditional-plugin ~= 0.5 - flake8 ~= 7.0 - mypy ~= 1.9 + pytest ~= 8.3 + pytest-cov ~= 6.0 + coverage ~= 7.6 + flake8 ~= 7.1 + mypy ~= 1.13 diff --git a/src/validataclass/dataclasses/validataclass.py b/src/validataclass/dataclasses/validataclass.py index 39317a8..6aebe3a 100644 --- a/src/validataclass/dataclasses/validataclass.py +++ b/src/validataclass/dataclasses/validataclass.py @@ -5,10 +5,9 @@ """ import dataclasses -import sys from collections import namedtuple from collections.abc import Callable -from typing import Any, Optional, TypeVar, Union, overload +from typing import Any, TypeVar, overload from typing_extensions import dataclass_transform @@ -41,10 +40,10 @@ def validataclass(cls: None = None, /, **kwargs: Any) -> Callable[[type[_T]], ty field_specifiers=(dataclasses.field, dataclasses.Field, validataclass_field), ) def validataclass( - cls: Optional[type[_T]] = None, + cls: type[_T] | None = None, /, **kwargs: Any, -) -> Union[type[_T], Callable[[type[_T]], type[_T]]]: +) -> type[_T] | Callable[[type[_T]], type[_T]]: """ Decorator that turns a normal class into a `DataclassValidator`-compatible dataclass. @@ -85,19 +84,17 @@ class ExampleDataclass: variables. The only difference to real InitVars is that this field will still exist after initialization. Optional parameters to the decorator will be passed directly to the `@dataclass` decorator. In most cases no - parameters are necessary. In Python 3.10 and upwards, the argument `kw_only=True` will be used by default. + parameters are necessary. By default, the argument `kw_only=True` will be used for validataclasses. """ def decorator(_cls: type[_T]) -> type[_T]: - # In Python 3.10 and higher, we use kw_only=True to allow both required and optional fields in any order. - # In older Python versions, we use a workaround by setting default_factory to a function that raises an - # exception for required fields. - if sys.version_info >= (3, 10): # pragma: ignore-py-lt-310 - kwargs.setdefault('kw_only', True) - else: # pragma: ignore-py-gte-310 - pass + # Set kw_only=True as the default to allow required and optional fields in any order + kwargs.setdefault('kw_only', True) + # Prepare class to become a validataclass _prepare_dataclass_metadata(_cls) + + # Use @dataclass decorator to turn class into a dataclass return dataclasses.dataclass(**kwargs)(_cls) # Wrap actual decorator if called with parentheses @@ -164,9 +161,8 @@ def _prepare_dataclass_metadata(cls: type[_T]) -> None: if not isinstance(field_default, Default): field_default = NoDefault - # Create dataclass field (_name is only needed for generating the default_factory for required fields for - # compatibility with Python < 3.10) - setattr(cls, name, validataclass_field(validator=field_validator, default=field_default, _name=name)) + # Create dataclass field + setattr(cls, name, validataclass_field(validator=field_validator, default=field_default)) def _get_existing_validator_fields(cls: type[_T]) -> dict[str, _ValidatorField]: @@ -197,7 +193,7 @@ def _get_existing_validator_fields(cls: type[_T]) -> dict[str, _ValidatorField]: return validator_fields -def _parse_validator_tuple(args: Union[tuple[Any, ...], Validator, Default, None]) -> _ValidatorField: +def _parse_validator_tuple(args: tuple[Any, ...] | Validator | Default | None) -> _ValidatorField: """ Parses field arguments (the value of a field in a dataclass that has not been parsed by `@dataclass` yet) to a tuple of a Validator and a Default object. diff --git a/src/validataclass/dataclasses/validataclass_field.py b/src/validataclass/dataclasses/validataclass_field.py index 202ab76..c3cf7f9 100644 --- a/src/validataclass/dataclasses/validataclass_field.py +++ b/src/validataclass/dataclasses/validataclass_field.py @@ -5,8 +5,7 @@ """ import dataclasses -import sys -from typing import Any, NoReturn, Optional +from typing import Any from validataclass.validators import Validator from .defaults import Default, NoDefault @@ -20,8 +19,7 @@ def validataclass_field( validator: Validator, default: Any = NoDefault, *, - metadata: Optional[dict[str, Any]] = None, - _name: Optional[str] = None, # noqa (undocumented parameter, only used internally) + metadata: dict[str, Any] | None = None, **kwargs: Any, ) -> Any: """ @@ -74,22 +72,5 @@ def validataclass_field( else: kwargs['default'] = default.get_value() - # Compatibility for Python 3.9 and older: Use a workaround to allow required and optional fields to be defined in - # any order. (In Python 3.10 the kw_only=True option for dataclasses is introduced, which can be used instead.) - if default is NoDefault and sys.version_info < (3, 10): # pragma: ignore-py-gte-310 - # Use a default_factory that raises an exception for required fields. - kwargs['default_factory'] = lambda: _raise_field_required(_name) - # Create a dataclass field with our metadata return dataclasses.field(metadata=metadata, **kwargs) - - -def _raise_field_required(name: Optional[str]) -> NoReturn: # pragma: ignore-py-gte-310 - """ - Raises a TypeError exception. Used for required fields (only in Python 3.9 or lower where the kw_only option is not - supported yet). - """ - raise TypeError( - f"Missing required keyword-only argument: '{name}'" - if name else 'Missing required keyword-only argument' - ) diff --git a/src/validataclass/exceptions/base_exceptions.py b/src/validataclass/exceptions/base_exceptions.py index 7ad3b98..b53c2e3 100644 --- a/src/validataclass/exceptions/base_exceptions.py +++ b/src/validataclass/exceptions/base_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any __all__ = [ 'ValidationError', @@ -28,10 +28,10 @@ class ValidationError(Exception): 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[str, Any]] = None + reason: str | None = None + extra_data: dict[str, Any] | None = None - def __init__(self, *, code: Optional[str] = None, reason: Optional[str] = None, **kwargs: Any): + def __init__(self, *, code: str | None = None, reason: str | None = None, **kwargs: Any): if code is not None: self.code = code if reason is not None: diff --git a/src/validataclass/exceptions/common_exceptions.py b/src/validataclass/exceptions/common_exceptions.py index e94b8ad..43229c5 100644 --- a/src/validataclass/exceptions/common_exceptions.py +++ b/src/validataclass/exceptions/common_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Union +from typing import Any from .base_exceptions import ValidationError @@ -47,7 +47,7 @@ class InvalidTypeError(ValidationError): code = 'invalid_type' expected_types: list[str] - def __init__(self, *, expected_types: Union[type, str, list[Union[type, str]]], **kwargs: Any): + def __init__(self, *, expected_types: list[type | str] | type | str, **kwargs: Any): super().__init__(**kwargs) if not isinstance(expected_types, list): @@ -55,13 +55,13 @@ def __init__(self, *, expected_types: Union[type, str, list[Union[type, str]]], self.expected_types = [self._type_to_string(t) for t in expected_types] @staticmethod - def _type_to_string(_type: Union[type, str]) -> str: + def _type_to_string(_type: type | str) -> str: type_str = _type if isinstance(_type, str) else _type.__name__ if type_str == 'NoneType': return 'none' return type_str - def add_expected_type(self, new_type: Union[type, str]) -> None: + def add_expected_type(self, new_type: type | str) -> None: """ Adds a type to `expected_types` in an existing `InvalidTypeError` exception, automatically removing duplicates. """ diff --git a/src/validataclass/exceptions/dataclass_exceptions.py b/src/validataclass/exceptions/dataclass_exceptions.py index 0e15f4e..20d9d07 100644 --- a/src/validataclass/exceptions/dataclass_exceptions.py +++ b/src/validataclass/exceptions/dataclass_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from .base_exceptions import ValidationError @@ -52,14 +52,14 @@ class DataclassPostValidationError(ValidationError): ``` """ code = 'post_validation_errors' - wrapped_error: Optional[ValidationError] = None - field_errors: Optional[dict[str, ValidationError]] = None + wrapped_error: ValidationError | None = None + field_errors: dict[str, ValidationError] | None = None def __init__( self, *, - error: Optional[ValidationError] = None, - field_errors: Optional[dict[str, ValidationError]] = None, + error: ValidationError | None = None, + field_errors: dict[str, ValidationError] | None = None, **kwargs: Any, ): super().__init__(**kwargs) diff --git a/src/validataclass/exceptions/list_exceptions.py b/src/validataclass/exceptions/list_exceptions.py index 0d25396..6bda565 100644 --- a/src/validataclass/exceptions/list_exceptions.py +++ b/src/validataclass/exceptions/list_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from .base_exceptions import ValidationError @@ -58,7 +58,7 @@ class ListLengthError(ValidationError): """ code = 'list_invalid_length' - def __init__(self, *, min_length: Optional[int] = None, max_length: Optional[int] = None, **kwargs: Any): + def __init__(self, *, min_length: int | None = None, max_length: int | None = None, **kwargs: Any): if min_length is not None: kwargs.update(min_length=min_length) if max_length is not None: diff --git a/src/validataclass/exceptions/misc_exceptions.py b/src/validataclass/exceptions/misc_exceptions.py index 6f40e7c..98ee17b 100644 --- a/src/validataclass/exceptions/misc_exceptions.py +++ b/src/validataclass/exceptions/misc_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from .base_exceptions import ValidationError @@ -22,5 +22,5 @@ class ValueNotAllowedError(ValidationError): """ code = 'value_not_allowed' - def __init__(self, *, allowed_values: Optional[list[Any]] = None, **kwargs: Any): + def __init__(self, *, allowed_values: list[Any] | None = None, **kwargs: Any): super().__init__(allowed_values=allowed_values, **kwargs) diff --git a/src/validataclass/exceptions/number_exceptions.py b/src/validataclass/exceptions/number_exceptions.py index 88a7c08..4d3b5af 100644 --- a/src/validataclass/exceptions/number_exceptions.py +++ b/src/validataclass/exceptions/number_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from .base_exceptions import ValidationError @@ -27,7 +27,7 @@ class NumberRangeError(ValidationError): """ code = 'number_range_error' - def __init__(self, *, min_value: Optional[Any] = None, max_value: Optional[Any] = None, **kwargs: Any): + def __init__(self, *, min_value: Any | None = None, max_value: Any | None = None, **kwargs: Any): if min_value is not None: kwargs.update(min_value=min_value) if max_value is not None: @@ -44,7 +44,7 @@ class DecimalPlacesError(ValidationError): """ code = 'decimal_places' - def __init__(self, *, min_places: Optional[Any] = None, max_places: Optional[Any] = None, **kwargs: Any): + def __init__(self, *, min_places: Any | None = None, max_places: Any | None = None, **kwargs: Any): if min_places is not None: kwargs.update(min_places=min_places) if max_places is not None: diff --git a/src/validataclass/exceptions/string_exceptions.py b/src/validataclass/exceptions/string_exceptions.py index 3986877..8b571e4 100644 --- a/src/validataclass/exceptions/string_exceptions.py +++ b/src/validataclass/exceptions/string_exceptions.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from .base_exceptions import ValidationError @@ -26,7 +26,7 @@ class StringInvalidLengthError(ValidationError): # Placeholder, will be overridden by the subclasses code = 'string_invalid_length' - def __init__(self, *, min_length: Optional[int] = None, max_length: Optional[int] = None, **kwargs: Any): + def __init__(self, *, min_length: int | None = None, max_length: int | None = None, **kwargs: Any): if min_length is not None: kwargs.update(min_length=min_length) if max_length is not None: @@ -43,7 +43,7 @@ class StringTooShortError(StringInvalidLengthError): """ code = 'string_too_short' - def __init__(self, *, min_length: int, max_length: Optional[int] = None, **kwargs: Any): + def __init__(self, *, min_length: int, max_length: int | None = None, **kwargs: Any): super().__init__(min_length=min_length, max_length=max_length, **kwargs) @@ -56,7 +56,7 @@ class StringTooLongError(StringInvalidLengthError): """ code = 'string_too_long' - def __init__(self, *, min_length: Optional[int] = None, max_length: int, **kwargs: Any): + def __init__(self, *, min_length: int | None = None, max_length: int, **kwargs: Any): super().__init__(min_length=min_length, max_length=max_length, **kwargs) diff --git a/src/validataclass/helpers/datetime_range.py b/src/validataclass/helpers/datetime_range.py index 3a637ba..ec59720 100644 --- a/src/validataclass/helpers/datetime_range.py +++ b/src/validataclass/helpers/datetime_range.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from datetime import datetime, timedelta, timezone, tzinfo -from typing import Optional, Union +from typing import TypeAlias __all__ = [ 'BaseDateTimeRange', @@ -16,8 +16,8 @@ ] # Type aliases used for type hinting -_DateTimeCallable = Callable[[], datetime] -_DateTimeBoundary = Union[datetime, _DateTimeCallable] +_DateTimeCallable: TypeAlias = Callable[[], datetime] +_DateTimeBoundary: TypeAlias = datetime | _DateTimeCallable class BaseDateTimeRange(ABC): @@ -26,14 +26,14 @@ class BaseDateTimeRange(ABC): """ @abstractmethod - def contains_datetime(self, dt: datetime, local_timezone: Optional[tzinfo] = None) -> bool: + def contains_datetime(self, dt: datetime, local_timezone: tzinfo | None = None) -> bool: """ Abstract method to be implemented by subclasses. Should return `True` if a datetime is contained in the range. """ raise NotImplementedError() @abstractmethod - def to_dict(self, local_timezone: Optional[tzinfo] = None) -> dict[str, str]: + def to_dict(self, local_timezone: tzinfo | None = None) -> dict[str, str]: """ Abstract method to be implemented by subclasses. Should return a dictionary with string representations of the range boundaries, suitable for the `DateTimeRangeError` exception to generate JSON error responses. @@ -45,7 +45,7 @@ def to_dict(self, local_timezone: Optional[tzinfo] = None) -> dict[str, str]: @staticmethod def _resolve_datetime_boundary( boundary: _DateTimeBoundary, - local_timezone: Optional[tzinfo] = None, + local_timezone: tzinfo | None = None, ) -> datetime: """ Helper method to resolve callables to datetime objects and to apply `local_timezone` if necessary. @@ -74,13 +74,13 @@ class DateTimeRange(BaseDateTimeRange): """ # Boundaries (static datetimes or callables) - lower_boundary: Optional[_DateTimeBoundary] = None - upper_boundary: Optional[_DateTimeBoundary] = None + lower_boundary: _DateTimeBoundary | None = None + upper_boundary: _DateTimeBoundary | None = None def __init__( self, - lower_boundary: Optional[_DateTimeBoundary] = None, - upper_boundary: Optional[_DateTimeBoundary] = None, + lower_boundary: _DateTimeBoundary | None = None, + upper_boundary: _DateTimeBoundary | None = None, ): """ Creates a `DateTimeRange` with a lower and an upper boundary (both are optional and can be either static @@ -104,7 +104,7 @@ def __init__( def __repr__(self) -> str: return f'{type(self).__name__}(lower_boundary={self.lower_boundary!r}, upper_boundary={self.upper_boundary!r})' - def contains_datetime(self, dt: datetime, local_timezone: Optional[tzinfo] = None) -> bool: + def contains_datetime(self, dt: datetime, local_timezone: tzinfo | None = None) -> bool: """ Returns `True` if the datetime is contained in the datetime range. @@ -117,7 +117,7 @@ def contains_datetime(self, dt: datetime, local_timezone: Optional[tzinfo] = Non # Note: These comparisons will raise TypeErrors when mixing datetimes with and without timezones return (lower_datetime is None or dt >= lower_datetime) and (upper_datetime is None or dt <= upper_datetime) - def to_dict(self, local_timezone: Optional[tzinfo] = None) -> dict[str, str]: + def to_dict(self, local_timezone: tzinfo | None = None) -> dict[str, str]: """ Returns a dictionary with string representations of the range boundaries, suitable for the `DateTimeRangeError` exception to generate JSON error responses. @@ -130,7 +130,7 @@ def to_dict(self, local_timezone: Optional[tzinfo] = None) -> dict[str, str]: **({'upper_boundary': upper_datetime.isoformat()} if upper_datetime is not None else {}), } - def _get_lower_datetime(self, local_timezone: Optional[tzinfo] = None) -> Optional[datetime]: + def _get_lower_datetime(self, local_timezone: tzinfo | None = None) -> datetime | None: """ Helper method to get the lower boundary as a `datetime` (or `None`), resolving callables and applying `local_timezone` if necessary. @@ -140,7 +140,7 @@ def _get_lower_datetime(self, local_timezone: Optional[tzinfo] = None) -> Option else: return self._resolve_datetime_boundary(self.lower_boundary, local_timezone) - def _get_upper_datetime(self, local_timezone: Optional[tzinfo] = None) -> Optional[datetime]: + def _get_upper_datetime(self, local_timezone: tzinfo | None = None) -> datetime | None: """ Helper method to get the upper boundary as a `datetime` (or `None`), resolving callables and applying `local_timezone` if necessary. @@ -170,17 +170,17 @@ class DateTimeOffsetRange(BaseDateTimeRange): """ # Pivot datetime (static or callable) - pivot: Optional[_DateTimeBoundary] = None + pivot: _DateTimeBoundary | None = None # Offset timedeltas - offset_minus: Optional[timedelta] = None - offset_plus: Optional[timedelta] = None + offset_minus: timedelta | None = None + offset_plus: timedelta | None = None def __init__( self, - pivot: Optional[_DateTimeBoundary] = None, - offset_minus: Optional[timedelta] = None, - offset_plus: Optional[timedelta] = None, + pivot: _DateTimeBoundary | None = None, + offset_minus: timedelta | None = None, + offset_plus: timedelta | None = None, ): """ Creates a `DateTimeOffsetRange` with a pivot datetime (static `datetime` or callable that returns `datetime` @@ -210,7 +210,7 @@ def __repr__(self) -> str: f'offset_plus={self.offset_plus!r})' ) - def contains_datetime(self, dt: datetime, local_timezone: Optional[tzinfo] = None) -> bool: + def contains_datetime(self, dt: datetime, local_timezone: tzinfo | None = None) -> bool: """ Returns `True` if the datetime is contained in the datetime range. @@ -223,7 +223,7 @@ def contains_datetime(self, dt: datetime, local_timezone: Optional[tzinfo] = Non # Note: These comparisons will raise TypeErrors when mixing datetimes with and without timezones return lower_datetime <= dt <= upper_datetime - def to_dict(self, local_timezone: Optional[tzinfo] = None) -> dict[str, str]: + def to_dict(self, local_timezone: tzinfo | None = None) -> dict[str, str]: """ Returns a dictionary with string representations of the range boundaries (calculating `lower_datetime` and `upper_datetime` from the pivot minus/plus the offsets), suitable for the `DateTimeRangeError` exception to @@ -239,7 +239,7 @@ def to_dict(self, local_timezone: Optional[tzinfo] = None) -> dict[str, str]: # Helper methods to resolve callables to datetimes and apply local_timezone - def _get_pivot_datetime(self, local_timezone: Optional[tzinfo] = None) -> datetime: + def _get_pivot_datetime(self, local_timezone: tzinfo | None = None) -> datetime: """ Helper method to get the pivot as a datetime, resolving callables and applying `local_timezone` if necessary, and defaulting to the current time in UTC. @@ -249,7 +249,7 @@ def _get_pivot_datetime(self, local_timezone: Optional[tzinfo] = None) -> dateti else: return self._resolve_datetime_boundary(self.pivot, local_timezone) - def _get_boundaries(self, local_timezone: Optional[tzinfo] = None) -> tuple[datetime, datetime]: + def _get_boundaries(self, local_timezone: tzinfo | None = None) -> tuple[datetime, datetime]: """ Helper method to get the lower and upper boundaries as datetimes, resolving callables and applying `local_timezone` if necessary. diff --git a/src/validataclass/helpers/unset_value.py b/src/validataclass/helpers/unset_value.py index 9187363..3f658b3 100644 --- a/src/validataclass/helpers/unset_value.py +++ b/src/validataclass/helpers/unset_value.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Optional, TypeVar, Union +from typing import TypeAlias, TypeVar from typing_extensions import Self @@ -51,14 +51,14 @@ def __bool__(self) -> bool: UnsetValueType.__new__ = lambda cls: UnsetValue # type: ignore # Type alias OptionalUnset[T] for fields with DefaultUnset: Allows either the type T or UnsetValue -OptionalUnset = Union[T, UnsetValueType] +OptionalUnset: TypeAlias = T | UnsetValueType -# Type alias OptionalUnsetNone[T] for fields that can be None OR UnsetValue (equivalent to OptionalUnset[Optional[T]]) -OptionalUnsetNone = OptionalUnset[Optional[T]] +# Type alias OptionalUnsetNone[T] for fields that can be None OR UnsetValue (equivalent to OptionalUnset[T | None]) +OptionalUnsetNone: TypeAlias = T | UnsetValueType | None # Small helper function for easier conversion of UnsetValue to None -def unset_to_none(value: OptionalUnset[T]) -> Optional[T]: +def unset_to_none(value: OptionalUnset[T]) -> T | None: """ Converts `UnsetValue` to `None`. diff --git a/src/validataclass/validators/allow_empty_string.py b/src/validataclass/validators/allow_empty_string.py index 7d69849..48207ba 100644 --- a/src/validataclass/validators/allow_empty_string.py +++ b/src/validataclass/validators/allow_empty_string.py @@ -5,7 +5,7 @@ """ from copy import deepcopy -from typing import Any, Optional +from typing import Any from validataclass.exceptions import InvalidTypeError from .validator import Validator @@ -62,7 +62,7 @@ def __init__(self, validator: Validator, *, default: Any = ''): self.wrapped_validator = validator self.default_value = default - def validate(self, input_data: Any, **kwargs: Any) -> Optional[Any]: + def validate(self, input_data: Any, **kwargs: Any) -> Any | None: """ Validates input data. diff --git a/src/validataclass/validators/any_of_validator.py b/src/validataclass/validators/any_of_validator.py index 289e9eb..f2f59d5 100644 --- a/src/validataclass/validators/any_of_validator.py +++ b/src/validataclass/validators/any_of_validator.py @@ -6,7 +6,7 @@ import warnings from collections.abc import Iterable -from typing import Any, Optional, Union +from typing import Any from validataclass.exceptions import ValueNotAllowedError, InvalidValidatorOptionException from .validator import Validator @@ -71,9 +71,9 @@ def __init__( self, allowed_values: Iterable[Any], *, - allowed_types: Optional[Union[type, Iterable[type]]] = None, - case_sensitive: Optional[bool] = None, - case_insensitive: Optional[bool] = None, + allowed_types: Iterable[type] | type | None = None, + case_sensitive: bool | None = None, + case_insensitive: bool | None = None, ): """ Creates an `AnyOfValidator` with a specified list of allowed values. diff --git a/src/validataclass/validators/anything_validator.py b/src/validataclass/validators/anything_validator.py index c040f38..34b88ea 100644 --- a/src/validataclass/validators/anything_validator.py +++ b/src/validataclass/validators/anything_validator.py @@ -5,7 +5,7 @@ """ from collections.abc import Iterable -from typing import Any, Optional, Union +from typing import Any from validataclass.exceptions import InvalidValidatorOptionException from .validator import Validator @@ -65,13 +65,13 @@ class AnythingValidator(Validator): allow_none: bool # Which input types to allow (None for anything) - allowed_types: Optional[list[type]] + allowed_types: list[type] | None def __init__( self, *, - allow_none: Optional[bool] = None, - allowed_types: Union[Iterable[Union[type, None]], type, None] = None, + allow_none: bool | None = None, + allowed_types: Iterable[type | None] | type | None = None, ): """ Creates an `AnythingValidator` that accepts any input. @@ -109,8 +109,8 @@ def __init__( @staticmethod def _normalize_allowed_types( *, - allowed_types: Union[Iterable[Union[type, None]], type, None], - allow_none: Optional[bool], + allowed_types: Iterable[type | None] | type | None, + allow_none: bool | None, ) -> list[type]: """ Helper method to normalize the `allowed_types` parameter to a unique list that contains only types. diff --git a/src/validataclass/validators/big_integer_validator.py b/src/validataclass/validators/big_integer_validator.py index 6170647..3376c7b 100644 --- a/src/validataclass/validators/big_integer_validator.py +++ b/src/validataclass/validators/big_integer_validator.py @@ -4,8 +4,6 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Optional - from .integer_validator import IntegerValidator __all__ = [ @@ -52,8 +50,8 @@ class BigIntegerValidator(IntegerValidator): def __init__( self, *, - min_value: Optional[int] = None, - max_value: Optional[int] = None, + min_value: int | None = None, + max_value: int | None = None, allow_strings: bool = False, ): """ diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index 247bc3e..645bf19 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -7,9 +7,7 @@ import dataclasses import inspect import warnings -from typing import Any, Generic, Optional, TypeVar - -from typing_extensions import TypeGuard +from typing import Any, Generic, TypeGuard, TypeVar from validataclass.dataclasses import Default, NoDefault from validataclass.exceptions import ( @@ -114,7 +112,7 @@ def __post_validate__(self, *, require_optional_field: bool = False): # Field default values field_defaults: dict[str, Default] - def __init__(self, dataclass_cls: Optional[type[T_Dataclass]] = None) -> None: + def __init__(self, dataclass_cls: type[T_Dataclass] | None = None) -> None: # For easier subclassing: If 'self.dataclass_cls' is already set (e.g. as class member in a subclass), use that # class as the default. if dataclass_cls is None: diff --git a/src/validataclass/validators/datetime_validator.py b/src/validataclass/validators/datetime_validator.py index ff0523d..60e4516 100644 --- a/src/validataclass/validators/datetime_validator.py +++ b/src/validataclass/validators/datetime_validator.py @@ -7,7 +7,7 @@ import re from datetime import datetime, tzinfo from enum import Enum -from typing import Any, Optional +from typing import Any from validataclass.exceptions import DateTimeRangeError, InvalidDateTimeError, InvalidValidatorOptionException from validataclass.helpers import BaseDateTimeRange @@ -286,22 +286,22 @@ class DateTimeValidator(StringValidator): discard_milliseconds: bool = False # Timezone to use for datetime strings without a specified timezone (None: no default timezone info in datetime) - local_timezone: Optional[tzinfo] = None + local_timezone: tzinfo | None = None # Target timezone that all datetimes will be converted to (None: no timezone conversion) - target_timezone: Optional[tzinfo] = None + target_timezone: tzinfo | None = None # Datetime range that defines which values are allowed - datetime_range: Optional[BaseDateTimeRange] = None + datetime_range: BaseDateTimeRange | None = None def __init__( self, datetime_format: DateTimeFormat = DateTimeFormat.ALLOW_TIMEZONE, *, discard_milliseconds: bool = False, - local_timezone: Optional[tzinfo] = None, - target_timezone: Optional[tzinfo] = None, - datetime_range: Optional[BaseDateTimeRange] = None, + local_timezone: tzinfo | None = None, + target_timezone: tzinfo | None = None, + datetime_range: BaseDateTimeRange | None = None, ): """ Creates a `DateTimeValidator` with a specified datetime string format, optionally a local timezone, a target diff --git a/src/validataclass/validators/decimal_validator.py b/src/validataclass/validators/decimal_validator.py index 980a346..93e45a3 100644 --- a/src/validataclass/validators/decimal_validator.py +++ b/src/validataclass/validators/decimal_validator.py @@ -7,7 +7,7 @@ import decimal import re from decimal import Decimal, InvalidOperation -from typing import Any, Optional, Union +from typing import Any from validataclass.exceptions import ( DecimalPlacesError, @@ -74,18 +74,18 @@ class DecimalValidator(StringValidator): """ # Value constraints - min_value: Optional[Decimal] = None - max_value: Optional[Decimal] = None + min_value: Decimal | None = None + max_value: Decimal | None = None # Minimum/maximum number of decimal places - min_places: Optional[int] = None - max_places: Optional[int] = None + min_places: int | None = None + max_places: int | None = None # Quantum used in `.quantize()` to set a fixed number of decimal places (from constructor argument output_places) - output_quantum: Optional[Decimal] = None + output_quantum: Decimal | None = None # Rounding mode (constant from decimal module) - rounding: Optional[str] = None + rounding: str | None = None # Precompiled regular expression for decimal values decimal_regex: re.Pattern[str] = re.compile(r'[+-]?([0-9]+\.[0-9]*|\.?[0-9]+)') @@ -93,12 +93,12 @@ class DecimalValidator(StringValidator): def __init__( self, *, - min_value: Optional[Union[Decimal, int, str]] = None, - max_value: Optional[Union[Decimal, int, str]] = None, - min_places: Optional[int] = None, - max_places: Optional[int] = None, - output_places: Optional[int] = None, - rounding: Optional[str] = decimal.ROUND_HALF_UP, + min_value: Decimal | int | str | None = None, + max_value: Decimal | int | str | None = None, + min_places: int | None = None, + max_places: int | None = None, + output_places: int | None = None, + rounding: str | None = decimal.ROUND_HALF_UP, ): """ Creates a `DecimalValidator` with optional value range, optional minimum/maximum number of decimal places and diff --git a/src/validataclass/validators/dict_validator.py b/src/validataclass/validators/dict_validator.py index a77038d..73afd79 100644 --- a/src/validataclass/validators/dict_validator.py +++ b/src/validataclass/validators/dict_validator.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from validataclass.exceptions import ( DictFieldsValidationError, @@ -69,7 +69,7 @@ class DictValidator(Validator): field_validators: dict[str, Validator] # Validator that is applied to all fields not specified in field_validators - default_validator: Optional[Validator] + default_validator: Validator | None # Set of required fields required_fields: set[str] @@ -77,10 +77,10 @@ class DictValidator(Validator): def __init__( self, *, - field_validators: Optional[dict[str, Validator]] = None, - default_validator: Optional[Validator] = None, - required_fields: Optional[list[str]] = None, - optional_fields: Optional[list[str]] = None + field_validators: dict[str, Validator] | None = None, + default_validator: Validator | None = None, + required_fields: list[str] | None = None, + optional_fields: list[str] | None = None ): """ Creates a `DictValidator`. diff --git a/src/validataclass/validators/enum_validator.py b/src/validataclass/validators/enum_validator.py index aaf33dd..004e9b3 100644 --- a/src/validataclass/validators/enum_validator.py +++ b/src/validataclass/validators/enum_validator.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from enum import Enum -from typing import Any, Generic, Optional, TypeVar, Union +from typing import Any, Generic, TypeVar from validataclass.exceptions import InvalidValidatorOptionException, ValueNotAllowedError from .any_of_validator import AnyOfValidator @@ -78,10 +78,10 @@ def __init__( self, enum_cls: type[T_Enum], *, - allowed_values: Optional[Iterable[Any]] = None, - allowed_types: Optional[Union[type, Iterable[type]]] = None, - case_sensitive: Optional[bool] = None, - case_insensitive: Optional[bool] = None, + allowed_values: Iterable[Any] | None = None, + allowed_types: Iterable[type] | type | None = None, + case_sensitive: bool | None = None, + case_insensitive: bool | None = None, ): """ Creates a `EnumValidator` for a specified Enum class, optionally with a restricted list of allowed values. diff --git a/src/validataclass/validators/float_to_decimal_validator.py b/src/validataclass/validators/float_to_decimal_validator.py index 78d6e76..2a036b2 100644 --- a/src/validataclass/validators/float_to_decimal_validator.py +++ b/src/validataclass/validators/float_to_decimal_validator.py @@ -7,7 +7,7 @@ import decimal import math from decimal import Decimal -from typing import Any, Optional, Union +from typing import Any from validataclass.exceptions import NonFiniteNumberError from .decimal_validator import DecimalValidator @@ -70,10 +70,10 @@ class FloatToDecimalValidator(DecimalValidator): def __init__( self, *, - min_value: Optional[Union[Decimal, str, float, int]] = None, - max_value: Optional[Union[Decimal, str, float, int]] = None, - output_places: Optional[int] = None, - rounding: Optional[str] = decimal.ROUND_HALF_UP, + min_value: Decimal | str | float | int | None = None, + max_value: Decimal | str | float | int | None = None, + output_places: int | None = None, + rounding: str | None = decimal.ROUND_HALF_UP, allow_integers: bool = False, allow_strings: bool = False, ): diff --git a/src/validataclass/validators/float_validator.py b/src/validataclass/validators/float_validator.py index 4b10035..9074807 100644 --- a/src/validataclass/validators/float_validator.py +++ b/src/validataclass/validators/float_validator.py @@ -5,7 +5,7 @@ """ import math -from typing import Any, Optional, Union +from typing import Any from validataclass.exceptions import InvalidValidatorOptionException, NumberRangeError, NonFiniteNumberError from .validator import Validator @@ -50,8 +50,8 @@ class FloatValidator(Validator): """ # Value constraints - min_value: Optional[float] = None - max_value: Optional[float] = None + min_value: float | None = None + max_value: float | None = None # Whether to accept integers and convert them to floats allow_integers: bool = False @@ -59,8 +59,8 @@ class FloatValidator(Validator): def __init__( self, *, - min_value: Optional[Union[float, int]] = None, - max_value: Optional[Union[float, int]] = None, + min_value: float | int | None = None, + max_value: float | int | None = None, allow_integers: bool = False, ): """ diff --git a/src/validataclass/validators/integer_validator.py b/src/validataclass/validators/integer_validator.py index 3326d8b..d02fc7f 100644 --- a/src/validataclass/validators/integer_validator.py +++ b/src/validataclass/validators/integer_validator.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from validataclass.exceptions import InvalidIntegerError, InvalidValidatorOptionException, NumberRangeError from .validator import Validator @@ -60,8 +60,8 @@ class IntegerValidator(Validator): DEFAULT_MAX_VALUE = 2147483647 # 2^31 - 1 # Value constraints - min_value: Optional[int] = None - max_value: Optional[int] = None + min_value: int | None = None + max_value: int | None = None # Whether to allow integers as strings allow_strings: bool = False @@ -69,8 +69,8 @@ class IntegerValidator(Validator): def __init__( self, *, - min_value: Optional[int] = DEFAULT_MIN_VALUE, - max_value: Optional[int] = DEFAULT_MAX_VALUE, + min_value: int | None = DEFAULT_MIN_VALUE, + max_value: int | None = DEFAULT_MAX_VALUE, allow_strings: bool = False, ): """ diff --git a/src/validataclass/validators/list_validator.py b/src/validataclass/validators/list_validator.py index 9308a69..6de2bf9 100644 --- a/src/validataclass/validators/list_validator.py +++ b/src/validataclass/validators/list_validator.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Generic, Optional, TypeVar +from typing import Any, Generic, TypeVar from validataclass.exceptions import ( InvalidValidatorOptionException, @@ -71,8 +71,8 @@ class ListValidator(Generic[T_ListItem], Validator): item_validator: Validator # List length constraints - min_length: Optional[int] = None - max_length: Optional[int] = None + min_length: int | None = None + max_length: int | None = None # Discard invalid items instead of raising error discard_invalid: bool = False @@ -81,8 +81,8 @@ def __init__( self, item_validator: Validator, *, - min_length: Optional[int] = None, - max_length: Optional[int] = None, + min_length: int | None = None, + max_length: int | None = None, discard_invalid: bool = False ): """ diff --git a/src/validataclass/validators/noneable.py b/src/validataclass/validators/noneable.py index 7b8b359..8193478 100644 --- a/src/validataclass/validators/noneable.py +++ b/src/validataclass/validators/noneable.py @@ -5,7 +5,7 @@ """ from copy import deepcopy -from typing import Any, Optional +from typing import Any from validataclass.exceptions import InvalidTypeError from .validator import Validator @@ -62,7 +62,7 @@ def __init__(self, validator: Validator, *, default: Any = None): self.wrapped_validator = validator self.default_value = default - def validate(self, input_data: Any, **kwargs: Any) -> Optional[Any]: + def validate(self, input_data: Any, **kwargs: Any) -> Any | None: """ Validates input data. diff --git a/src/validataclass/validators/numeric_validator.py b/src/validataclass/validators/numeric_validator.py index 2fb3a46..7654135 100644 --- a/src/validataclass/validators/numeric_validator.py +++ b/src/validataclass/validators/numeric_validator.py @@ -6,7 +6,6 @@ import decimal from decimal import Decimal -from typing import Optional, Union from .float_to_decimal_validator import FloatToDecimalValidator @@ -60,10 +59,10 @@ class NumericValidator(FloatToDecimalValidator): def __init__( self, *, - min_value: Optional[Union[Decimal, str, float, int]] = None, - max_value: Optional[Union[Decimal, str, float, int]] = None, - output_places: Optional[int] = None, - rounding: Optional[str] = decimal.ROUND_HALF_UP, + min_value: Decimal | str | float | int | None = None, + max_value: Decimal | str | float | int | None = None, + output_places: int | None = None, + rounding: str | None = decimal.ROUND_HALF_UP, ): """ Creates a `NumericValidator` with optional value range and optional number of decimal places in output value. diff --git a/src/validataclass/validators/regex_validator.py b/src/validataclass/validators/regex_validator.py index 00ff923..7529a99 100644 --- a/src/validataclass/validators/regex_validator.py +++ b/src/validataclass/validators/regex_validator.py @@ -5,7 +5,7 @@ """ import re -from typing import Any, Optional, Union +from typing import Any from validataclass.exceptions import RegexMatchError, ValidationError from .string_validator import StringValidator @@ -88,24 +88,24 @@ class InvalidHexNumberError(RegexMatchError): regex_pattern: re.Pattern[str] # Output template - output_template: Optional[str] + output_template: str | None # Exception class to use when regex matching fails custom_error_class: type[ValidationError] # Custom error code to use in the regex match exception (use default if None) - custom_error_code: Optional[str] + custom_error_code: str | None # Whether to accept empty strings allow_empty: bool = False def __init__( self, - pattern: Union[re.Pattern[str], str], - output_template: Optional[str] = None, + pattern: re.Pattern[str] | str, + output_template: str | None = None, *, custom_error_class: type[ValidationError] = RegexMatchError, - custom_error_code: Optional[str] = None, + custom_error_code: str | None = None, allow_empty: bool = False, **kwargs: Any, ): diff --git a/src/validataclass/validators/reject_validator.py b/src/validataclass/validators/reject_validator.py index cd456d2..19f57f2 100644 --- a/src/validataclass/validators/reject_validator.py +++ b/src/validataclass/validators/reject_validator.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from validataclass.exceptions import ValidationError, FieldNotAllowedError from .validator import Validator @@ -63,16 +63,16 @@ class CustomValidationError(ValidationError): # Validation error to raise when rejecting input error_class: type[ValidationError] - error_code: Optional[str] - error_reason: Optional[str] + error_code: str | None + error_reason: str | None def __init__( self, *, allow_none: bool = False, error_class: type[ValidationError] = FieldNotAllowedError, - error_code: Optional[str] = None, - error_reason: Optional[str] = None, + error_code: str | None = None, + error_reason: str | None = None, ): """ Creates a `RejectValidator` that rejects any input. diff --git a/src/validataclass/validators/string_validator.py b/src/validataclass/validators/string_validator.py index c8f1d79..6ff60f9 100644 --- a/src/validataclass/validators/string_validator.py +++ b/src/validataclass/validators/string_validator.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any, Optional +from typing import Any from validataclass.exceptions import ( StringTooShortError, @@ -67,8 +67,8 @@ class StringValidator(Validator): """ # Length constraints - min_length: Optional[int] = None - max_length: Optional[int] = None + min_length: int | None = None + max_length: int | None = None # Whether or not to allow multiline strings (i.e. strings containing newlines) allow_multiline: bool = False @@ -79,8 +79,8 @@ class StringValidator(Validator): def __init__( self, *, - min_length: Optional[int] = None, - max_length: Optional[int] = None, + min_length: int | None = None, + max_length: int | None = None, multiline: bool = False, unsafe: bool = False, ): diff --git a/src/validataclass/validators/url_validator.py b/src/validataclass/validators/url_validator.py index c8747db..bc29595 100644 --- a/src/validataclass/validators/url_validator.py +++ b/src/validataclass/validators/url_validator.py @@ -5,7 +5,7 @@ """ import re -from typing import Any, Optional +from typing import Any from validataclass.exceptions import InvalidUrlError from validataclass.internal import internet_helpers @@ -99,7 +99,7 @@ class UrlValidator(StringValidator): def __init__( self, *, - allowed_schemes: Optional[list[str]] = None, + allowed_schemes: list[str] | None = None, require_tld: bool = True, allow_ip: bool = True, allow_userinfo: bool = False, diff --git a/src/validataclass/validators/validator.py b/src/validataclass/validators/validator.py index ff31aae..1c6f4f1 100644 --- a/src/validataclass/validators/validator.py +++ b/src/validataclass/validators/validator.py @@ -7,7 +7,7 @@ import inspect import warnings from abc import ABC, abstractmethod -from typing import Any, Union +from typing import Any from validataclass.exceptions import InvalidTypeError, RequiredValueError @@ -62,7 +62,7 @@ def validate_with_context(self, input_data: Any, **kwargs: Any) -> Any: validator accepts keyword arguments (e.g. because you don't know the class of the validator). """ if inspect.getfullargspec(self.validate).varkw is not None: - return self.validate(input_data, **kwargs) # noqa (unexpected argument) + return self.validate(input_data, **kwargs) else: return self.validate(input_data) @@ -75,7 +75,7 @@ def _ensure_not_none(input_data: Any) -> None: if input_data is None: raise RequiredValueError() - def _ensure_type(self, input_data: Any, expected_types: Union[type, list[type]]) -> None: + def _ensure_type(self, input_data: Any, expected_types: list[type] | type) -> None: """ Checks if input data is not `None` and has the expected type (or one of multiple expected types). diff --git a/tests/dataclasses/_helpers.py b/tests/dataclasses/_helpers.py index 1a24f39..4625400 100644 --- a/tests/dataclasses/_helpers.py +++ b/tests/dataclasses/_helpers.py @@ -5,11 +5,8 @@ """ import dataclasses -import sys from typing import Any -import pytest - from validataclass.dataclasses import Default from validataclass.validators import T_Dataclass @@ -42,18 +39,11 @@ def assert_field_no_default(field: dataclasses.Field[Any]) -> None: """ # Check regular dataclass defaults assert field.default is dataclasses.MISSING + assert field.default_factory is dataclasses.MISSING # Check defaults in dataclass metadata assert 'validator_default' not in field.metadata - # For Python under 3.10, check that an exception raising default_factory is set - if sys.version_info < (3, 10): - assert field.default_factory is not dataclasses.MISSING - with pytest.raises(TypeError, match="required keyword-only argument"): - field.default_factory() - else: - assert field.default_factory is dataclasses.MISSING - def get_dataclass_fields(cls: type[T_Dataclass]) -> dict[str, dataclasses.Field[Any]]: """ diff --git a/tests/dataclasses/validataclass_field_test.py b/tests/dataclasses/validataclass_field_test.py index 9a872e0..347b06c 100644 --- a/tests/dataclasses/validataclass_field_test.py +++ b/tests/dataclasses/validataclass_field_test.py @@ -5,7 +5,6 @@ """ import dataclasses -import sys from typing import Any import pytest @@ -44,13 +43,7 @@ def test_validataclass_field_without_default(param_default): # Check field default assert field.default is dataclasses.MISSING - - # For Python under 3.10, check that an exception raising default_factory is set - if sys.version_info < (3, 10): - with pytest.raises(TypeError, match="required keyword-only argument"): - field.default_factory() - else: - assert field.default_factory is dataclasses.MISSING + assert field.default_factory is dataclasses.MISSING @staticmethod @pytest.mark.parametrize( diff --git a/tests/dataclasses/validataclass_test.py b/tests/dataclasses/validataclass_test.py index 0e58d4c..b9311c1 100644 --- a/tests/dataclasses/validataclass_test.py +++ b/tests/dataclasses/validataclass_test.py @@ -3,11 +3,8 @@ Copyright (c) 2021, 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. """ -# (Ignore comparison-overlap errors, which happen in comparisons like `assert fields['baz'].type is Optional[str]`) -# mypy: no-strict-equality import dataclasses -from typing import Optional, Union import pytest @@ -52,7 +49,7 @@ def test_validataclass_without_kwargs(): class UnitTestValidatorDataclass: foo: int = IntegerValidator() bar: int = validataclass_field(IntegerValidator(), default=Default(0)) - baz: Optional[str] = validataclass_field(Noneable(StringValidator()), default=None) + baz: str | None = validataclass_field(Noneable(StringValidator()), default=None) # Check that @validataclass actually created a dataclass (i.e. used @dataclass on the class) assert dataclasses.is_dataclass(UnitTestValidatorDataclass) @@ -62,9 +59,9 @@ class UnitTestValidatorDataclass: # Check names and types of all fields assert list(fields.keys()) == ['foo', 'bar', 'baz'] - assert fields['foo'].type is int - assert fields['bar'].type is int - assert fields['baz'].type is Optional[str] + assert fields['foo'].type == int + assert fields['bar'].type == int + assert fields['baz'].type == str | None # Check field defaults assert_field_no_default(fields['foo']) @@ -108,16 +105,16 @@ def test_validataclass_with_tuples(): class UnitTestValidatorDataclass: foo: int = IntegerValidator(), NoDefault bar: int = IntegerValidator(), Default(42) - baz: Optional[int] = IntegerValidator(), Default(None) + baz: int | None = IntegerValidator(), Default(None) # Get fields from dataclass fields = get_dataclass_fields(UnitTestValidatorDataclass) # Check names and types of all fields assert list(fields.keys()) == ['foo', 'bar', 'baz'] - assert fields['foo'].type is int - assert fields['bar'].type is int - assert fields['baz'].type is Optional[int] + assert fields['foo'].type == int + assert fields['bar'].type == int + assert fields['baz'].type == int | None # Check field defaults assert_field_no_default(fields['foo']) @@ -144,8 +141,8 @@ class UnitTestValidatorDataclass: # Check names and types of all fields assert list(fields.keys()) == ['validated', 'non_init'] - assert fields['validated'].type is int - assert fields['non_init'].type is int + assert fields['validated'].type == int + assert fields['non_init'].type == int # Check 'init' value assert fields['validated'].init is True @@ -248,8 +245,8 @@ class BaseClass: required4: int = IntegerValidator() # Optional fields - optional1: Optional[int] = IntegerValidator(), Default(None) - optional2: Optional[int] = IntegerValidator(), Default(None) + optional1: int | None = IntegerValidator(), Default(None) + optional2: int | None = IntegerValidator(), Default(None) optional3: int = IntegerValidator(), Default(3) optional4: OptionalUnset[int] = IntegerValidator(), DefaultUnset @@ -261,7 +258,7 @@ class SubClass(BaseClass): # Required fields that are optional now required2: int = Default(42) - required3: Optional[int] = Default(None) + required3: int | None = Default(None) required4: OptionalUnset[int] = DefaultUnset # Optional fields that are required now or have new defaults @@ -279,9 +276,9 @@ class SubClass(BaseClass): ] # Check type annotations - assert all(fields[field].type is int for field in ['required1', 'required2', 'optional2', 'optional4']) - assert all(fields[field].type is Optional[int] for field in ['required3', 'optional1']) - assert all(fields[field].type is Union[int, UnsetValueType] for field in ['required4', 'optional3']) + assert all(fields[field].type == int for field in ['required1', 'required2', 'optional2', 'optional4']) + assert all(fields[field].type == int | None for field in ['required3', 'optional1']) + assert all(fields[field].type == int | UnsetValueType for field in ['required4', 'optional3']) # Check validators assert all(type(field.metadata.get('validator')) is IntegerValidator for field in fields.values()) @@ -316,15 +313,15 @@ class BaseClass: class SubClass(BaseClass): # Required fields required1: str = StringValidator() - required2: Optional[str] = StringValidator(), Default(None) + required2: str | None = StringValidator(), Default(None) # Optional fields optional1: str = StringValidator() # No default override, so the default should still be Default(3) - optional2: Optional[str] = StringValidator(), Default(None) + optional2: str | None = StringValidator(), Default(None) # New fields new1: str = StringValidator() - new2: Optional[str] = StringValidator(), Default(None) + new2: str | None = StringValidator(), Default(None) # Get fields from dataclass fields = get_dataclass_fields(SubClass) @@ -333,8 +330,8 @@ class SubClass(BaseClass): assert list(fields.keys()) == ['required1', 'required2', 'optional1', 'optional2', 'new1', 'new2'] # Check type annotations - assert all(fields[field].type is str for field in ['required1', 'optional1', 'new1']) - assert all(fields[field].type is Optional[str] for field in ['required2', 'optional2', 'new2']) + assert all(fields[field].type == str for field in ['required1', 'optional1', 'new1']) + assert all(fields[field].type == str | None for field in ['required2', 'optional2', 'new2']) # Check validators assert all(type(field.metadata.get('validator')) is StringValidator for field in fields.values()) @@ -371,7 +368,7 @@ class SubClass(BaseClass): # Check names and types of all fields assert list(fields.keys()) == ['validated', 'non_init'] - assert all(f.type is int for f in fields.values()) + assert all(f.type == int for f in fields.values()) # Check non-init field assert fields['non_init'].init is False @@ -397,7 +394,7 @@ class SubClass(BaseB, BaseA): # type: ignore[misc] # Override the defaults to test that the decorator recognizes all fields of both base classes. # If it does not, a "no validator for field X" error would be raised. field_a: int = Default(42) - field_b: Optional[str] = Default(None) + field_b: str | None = Default(None) # Get fields from dataclass fields = get_dataclass_fields(SubClass) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0777ff1..ba0b8bc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,7 @@ """ from decimal import Decimal -from typing import Any, Union +from typing import Any from validataclass.validators import Validator @@ -87,7 +87,7 @@ def unpack_params(*args: Any) -> list[tuple[Any, ...]]: UNSET_PARAMETER = object() -def assert_decimal(actual: Decimal, expected: Union[Decimal, str]) -> None: +def assert_decimal(actual: Decimal, expected: Decimal | str) -> None: """ Assert that `actual` is of type `Decimal` and has the same decimal value (string comparison) as `expected`. """ diff --git a/tests/validators/dataclass_validator_test.py b/tests/validators/dataclass_validator_test.py index b9990ed..746db40 100644 --- a/tests/validators/dataclass_validator_test.py +++ b/tests/validators/dataclass_validator_test.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from decimal import Decimal -from typing import Any, Optional +from typing import Any import pytest @@ -54,7 +54,7 @@ class UnitTestNestedDataclass: """ name: str = StringValidator() test_fruit: UnitTestDataclass = DataclassValidator(UnitTestDataclass) - test_vegetable: Optional[UnitTestDataclass] = \ + test_vegetable: UnitTestDataclass | None = \ validataclass_field(DataclassValidator(UnitTestDataclass), default=None) @@ -108,7 +108,7 @@ class UnitTestContextSensitiveDataclass: when the context argument "value_required" is set. """ name: str = UnitTestContextValidator() - value: Optional[int] = IntegerValidator(), Default(None) + value: int | None = IntegerValidator(), Default(None) def __post_validate__(self, *, value_required: bool = False) -> None: if value_required and self.value is None: diff --git a/tox.ini b/tox.ini index dc57a3a..ea1ea53 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,7 @@ [tox] minversion = 4.5.1 -envlist = clean,py{312,311,310,39},report,flake8,mypy +envlist = clean,py{312,311,310},report,flake8,mypy skip_missing_interpreters = true -isolated_build = true [flake8] max-line-length = 120 @@ -19,24 +18,15 @@ extras = testing commands = python -m pytest --cov --cov-append {posargs} [testenv:flake8] -skip_install = true -deps = flake8 commands = flake8 src/ tests/ -[testenv:mypy,py{312,311,310,39}-mypy] -extras = testing +[testenv:mypy,py{312,311,310}-mypy] commands = mypy [testenv:clean] -skip_install = true -deps = {[testenv:report]deps} commands = coverage erase -[testenv:report,py{312,311,310,39}-report] -skip_install = true -deps = - coverage - coverage-conditional-plugin +[testenv:report,py{312,311,310}-report] commands = coverage html coverage report --fail-under=100