diff --git a/docs/05-dataclasses.md b/docs/05-dataclasses.md index bfd4cb8..b5ab029 100644 --- a/docs/05-dataclasses.md +++ b/docs/05-dataclasses.md @@ -343,7 +343,7 @@ default values for fields. ### Setting defaults with `validataclass_field()` To set a default value with `validataclass_field()`, you simply specify the `default` parameter. This parameter can be -set either to a `Default` object (which we will explain in a moment) or directly to a value. +set either to a special validataclass "default object" (which we will explain in a moment) or directly to a value. **Example:** @@ -364,7 +364,7 @@ class ExampleDataclass: ### Setting defaults with `@validataclass` 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)`. +consisting of the validator and a default object, e.g. `IntegerValidator(), Default(42)`. **Example:** @@ -379,67 +379,91 @@ class ExampleDataclass: ``` -### The `Default` classes +### The default objects -To specify default values for fields, the helper class `Default` can be used. There also are some subclasses of `Default` which will be -covered in a moment: `DefaultFactory`, `DefaultUnset` and `NoDefault`. +To specify default values for fields, there are special helper classes and objects, most notably the `Default` class. +They all are based on the abstract base class `BaseDefault`. +As of now, there are the two classes `Default` and `DefaultFactory`, as well as the special values `DefaultUnset` and +`NoDefault`. -#### Default (base class) +You can also implement custom default classes if you need to, though usually this isn't necessary. -Use the `Default` class if you want to specify a single, constant value as a field default. This class is basically just a wrapper that -encapsulates a "raw" value in an object and returns this value if needed. The value can be of any type (including `None` or some object). -For example, with `Default('')` the default would always be an empty string, `Default(42)` would set the default to the integer `42`, -and `Default(None)` would result in the default value being `None`. +#### Default -The value will be deepcopied, which means that if you use lists, dictionaries or objects as default values, every validated object will -have a **copy** of the value. For example, with `Default([])` the default would always be a new empty list. Modifying the list of one -validated object would **not** result in a change of the list of other validated objects. +Use the `Default` class if you want to specify a single, constant value as a field default. + +This class is basically just a wrapper that encapsulates a "raw" value in an object and returns this value if needed. +The value can be of any type (including `None` or some object). + +For example, with `Default('')` the default value would be an empty string, `Default(42)` would set the default to the +integer `42`, and `Default(None)` would result in the default value being `None`. + +The value will be deepcopied, which means that if you use mutable objects like lists, dictionaries or custom classes as +defaults, every validated object will have a unique **copy** of the value. For example, with `Default([])` you will +always get a unique empty list as the default, rather than a shared instance. + +(In the context of regular dataclasses, this means that mutable objects will automatically result in a `default_factory` +rather than a `default`. In regular dataclasses, using mutable objects as `default` would result in an error.) #### DefaultFactory -The `DefaultFactory` class uses **callables** to generate default values dynamically at validation time. Use this class if you need -dynamic default values. You can use a `DefaultFactory` with any callable, e.g. with a function reference or a lambda function. +The `DefaultFactory` class uses callables to generate default values dynamically at validation time, for example a +class type, a function reference or a lambda function. + +Use this if you need dynamic default values or want to create instances of a class. -For example, specifying `DefaultFactory(datetime.now)` (with `from datetime import datetime`) would result in the default value always -being the datetime at which the input dictionary was validated. To use the current year as default, you could use a lambda function: -`DefaultFactory(lambda: datetime.now().year)`. +For example, specifying `DefaultFactory(datetime.now)` (with `from datetime import datetime`) would result in the +default value always being the datetime at which the input dictionary was validated. To use the current year as default, +you could use a lambda function: `DefaultFactory(lambda: datetime.now().year)`. -Contrary to `Default` the values will **not** be deepcopied. This also means that you can use a `DefaultFactory` as a workaround if you -actually want to use an object **reference** as a default value. For example, if you have a list `some_list = []` and use a -`DefaultFactory(lambda: some_list)` as default for a dataclass field, all objects validated using this dataclass will use **the same** -list as their default. Modifying the list of one validated object will modify the list for **all** objects. +Please note that `Default` can be used with (simple) mutable objects, so you don't need to write `DefaultFactory(list)` +to create new lists, but can just use `Default([])`. +If you ever want to use a **shared instance** of an object as a default value (i.e. bypassing the deepcopy that's done +by `Default`), you can use a `DefaultFactory` with a lambda function. For example, if you have a list `some_list = []`, +you can use `DefaultFactory(lambda: some_list)`. Every validated object with this default value will point to the same +instance `some_list`. -#### DefaultUnset and the `UnsetValue` object -If you set a default value for a field in a dataclass, this field will **always** have a value: Either the value from the input dictionary -if the field exists, or the default value. If you don't set a default value, the field is **required**, meaning an input dictionary -without this field will fail validation. +#### Unset values: The `UnsetValue` object and `DefaultUnset` -Sometimes you want optional fields **without** default values though: If a string field uses `Default('')` for example, it will always -have a string value (either empty or not empty), and you cannot distinguish whether the field was omitted in the input dictionary or +If you set a default value for a field in a dataclass, this field will **always** have a value: Either the value from +the input dictionary if the field exists, or the default value. If you don't set a default value, the field is +**required**, meaning an input dictionary without this field will fail validation. + +Sometimes you want optional fields **without** default values though: If a string field uses `Default('')` for example, +it will always have a string value, and you cannot distinguish whether the field was omitted in the input dictionary or whether the user explicitly set the field to an empty string. -One solution is to use `Default(None)` instead: If the field is `None`, you know that the field did not exist in the input dictionary. +One solution is to use `Default(None)` instead: If the field is `None`, you know that the field did not exist in the +input dictionary. + +This is sufficient in many cases, but sometimes you have a field where `None` is a valid value (e.g. when using the +`Noneable` wrapper validator), and you need to distinguish between `None` and "the field does not exist". -This is sufficient in a lot of cases, but sometimes `None` is an allowed value for a field (e.g. when using the `Noneable` -wrapper) and you need to distinguish between "the field did not exist" and "the field was explicitly set to `None`". +(Example: You have an API to modify objects from a database. The object has a nullable datetime field which is either a +valid point in time or NULL (`None` in Python). The user can set the field to a datetime string or to null (`None`) to +reset the field, e.g. with a `Noneable(DateTimeValidator())`. But you only want to modify database fields that are set +in the API request, others should stay unmodified. So you cannot use `Default(None)` and need a distinct default value.) -For this case, we defined a special value called `UnsetValue`, which you can use similarly to `None`. These values are so called -[sentinel objects](https://python-patterns.guide/python/sentinel-object/): Their purpose is to represent a missing value. They also are -unique objects, which means you cannot copy them: Trying to create a new object using `UnsetValue()` or even using `deepcopy(UnsetValue)` -will always result in a reference to the **same** object. This ensures that you can check for this value using the identity operator `is`, -e.g. `if some_value is UnsetValue` (just like `is None` or `is True`). +For this case, we defined the special value `UnsetValue`, which works similar to Python's `None` but is its own unique +value. It has the type `UnsetValueType` and is the only possible instance of this type (copying it will result in the +same object). You can compare values with it using the identity operator `is`, e.g. `if some_value is UnsetValue` (just +like `is None`). -So, to define an **optional field without default** you can simply specify `UnsetValue` as the default value, and then use `is UnsetValue` -in your code to distinguish it from other values like `None`. +(For reference: Special values like `None` or `UnsetValue` are so called +[sentinel objects](https://python-patterns.guide/python/sentinel-object/).) -For this you can use the `DefaultUnset` object, which is a shortcut for `Default(UnsetValue)`. +So, to define an "optional field without default" you can simply specify `UnsetValue` as the default value, and then +use `is UnsetValue` in your code to distinguish it from other values, including `None`. (Using `==` works too, but `is` +is generally recommended.) -Remember to adjust the type hints in your dataclass though. There is a type alias `OptionalUnset[T]` which you can use +As a shortcut, the library defines the object `DefaultUnset`, which is equivalent to `Default(UnsetValue)`. + +Remember to adjust the type hints in your dataclass. 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`. @@ -447,16 +471,19 @@ can be both `None` and `UnsetValue`, there is also the type alias `OptionalUnset #### NoDefault -Specifying the `NoDefault` object for a field default literally means that the field does not have any default value. This is equivalent -to **not specifying a default value at all**, meaning the field will be **required** and not optional. +If a field has no default value, it is a **required field**. This is represented by the `NoDefault` object (which is a +sentinel object of an internal subclass of `BaseDefault`). + +In most cases, you don't need to specify this. `NoDefault` is the implicit "default default" of a validataclass field. -In most cases you won't need this value, but it can be useful to overwrite an existing default in a subclass (see the "Subclassing" -section below). +However, in some cases you might need to set `NoDefault` explicitly. This can be the case when you create a subclass of +a validataclass and want to remove the default of a field from the base class. Overriding the field's default with +`NoDefault` will make it a required field again. (See the "Subclassing" section below for details.) -#### Examples for the various Default classes +#### Examples for the various default classes -The following code contains examples for all the various `Default` classes that we've seen above. +The following code contains examples for all the various default classes that we've seen above. ```python from datetime import datetime @@ -468,19 +495,26 @@ from validataclass.validators import IntegerValidator, ListValidator, DateTimeVa @validataclass class ExampleClass: - # Simple defaults for integer fields - field_a: int = IntegerValidator(), Default(42) # Default value is 42 - field_b: Optional[int] = IntegerValidator(), Default(None) # Default value is None - field_c: OptionalUnset[int] = IntegerValidator(), DefaultUnset # Default value is UnsetValue - - # Defaults for lists - field_d: list = ListValidator(IntegerValidator()), Default([]) # Default value is an empty list - - # DefaultFactories for datetime fields - field_e: datetime = DateTimeValidator(), DefaultFactory(datetime.now) # Default value will be datetime of validation - field_f: int = IntegerValidator(), DefaultFactory(lambda: datetime.now().year) # Default value will be YEAR of validation (as int) - - # No default: The following two fields are exactly the same (both are required) + # Validated as an integer if set, defaults to 42 + field_a: int = IntegerValidator(), Default(42) + + # Validated as an integer if set, defaults to the value None + field_b: Optional[int] = IntegerValidator(), Default(None) + + # Validated as an integer if set, defaults to the special value UnsetValue + field_c: OptionalUnset[int] = IntegerValidator(), DefaultUnset + + # Validated as a list of integers if set, defaults to an empty list + field_d: list = ListValidator(IntegerValidator()), Default([]) + + # Validated as a datetime string and converted to a datetime object if set. + # Defaults to the current datetime (time when the field was validated) + field_e: datetime = DateTimeValidator(), DefaultFactory(datetime.now) + + # Validated as an integer, defaults to the current year (as integer) + field_f: int = IntegerValidator(), DefaultFactory(lambda: datetime.now().year) + + # No default: The following two fields are exactly the same (both are required fields) field_g: int = IntegerValidator() field_h: int = IntegerValidator(), NoDefault ``` @@ -490,9 +524,10 @@ class ExampleClass: One more important thing to understand about optional fields is what "optional" exactly means. -When a field is optional, this means that the field is allowed to be **omitted completely** in the input dictionary. However, this -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. +When a field is optional, this means that the field is allowed to be **omitted completely** in the input dictionary. +However, this 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: int | None = IntegerValidator(), Default(None)`. An empty input dictionary `{}` would result in an object with the default value `some_var = None`, but the input @@ -503,12 +538,19 @@ Instead, to explicitly allow `None` as value, you can use the `Noneable` wrapper **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: int | None = Noneable(IntegerValidator()), Default(None)`. +To make a field both optional **and** allow `None` as value, you can simply combine `Noneable()` and a default value. +For example: -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`: \ -`some_var: int = Noneable(IntegerValidator(), default=0), Default(0)`. +``` +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, the following +field will always be an integer. If it's set to `None` **or** is missing in the input, it defaults to 0: + +``` +some_var: int = Noneable(IntegerValidator(), default=0), Default(0) +``` ## Subclassing diff --git a/pyproject.toml b/pyproject.toml index 2ba1514..ed82ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,11 @@ explicit_package_bases = true # Enable strict type checking strict = true +# Enable further checks that are not included in strict mode +enable_error_code = [ + "deprecated", +] + [[tool.mypy.overrides]] module = 'tests.*' diff --git a/setup.cfg b/setup.cfg index 914a9ed..66ad052 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,4 +43,4 @@ testing = pytest-mypy-plugins ~= 3.2 coverage ~= 7.9 flake8 ~= 7.3 - mypy ~= 1.17 + mypy ~= 1.19 diff --git a/src/validataclass/dataclasses/__init__.py b/src/validataclass/dataclasses/__init__.py index 9d1c855..f2f2d48 100644 --- a/src/validataclass/dataclasses/__init__.py +++ b/src/validataclass/dataclasses/__init__.py @@ -4,12 +4,13 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from .defaults import Default, DefaultFactory, DefaultUnset, NoDefault +from .defaults import BaseDefault, Default, DefaultFactory, DefaultUnset, NoDefault from .validataclass import validataclass from .validataclass_field import validataclass_field from .validataclass_mixin import ValidataclassMixin __all__ = [ + 'BaseDefault', 'Default', 'DefaultFactory', 'DefaultUnset', diff --git a/src/validataclass/dataclasses/defaults.py b/src/validataclass/dataclasses/defaults.py index 6fab3b5..dfa78cd 100644 --- a/src/validataclass/dataclasses/defaults.py +++ b/src/validataclass/dataclasses/defaults.py @@ -4,66 +4,127 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ +import warnings +from abc import ABC, abstractmethod from collections.abc import Callable -from copy import copy, deepcopy -from typing import Any, NoReturn +from copy import deepcopy +from typing import Any, Generic, TypeVar -from typing_extensions import Self +from typing_extensions import Never, Self from validataclass.helpers import UnsetValue, UnsetValueType __all__ = [ + 'BaseDefault', 'Default', 'DefaultFactory', 'DefaultUnset', 'NoDefault', ] - # Helper objects for setting default values for validator fields -class Default: +# Type parameter for the value of a default object +T_Default = TypeVar('T_Default') + + +class BaseDefault(Generic[T_Default], ABC): """ - (Base) class for specifying default values for dataclass validator fields. + Base class for defining default values for dataclass validator fields. + + See also: `Default`, `DefaultFactory()`, `DefaultUnset`, `NoDefault` + """ + + def __repr__(self) -> str: + return type(self).__name__ + + def __eq__(self, other: Any) -> bool: + return NotImplemented + + __hash__ = object.__hash__ + + @abstractmethod + def get_value(self) -> T_Default: + """ + Get actual default value. + """ + raise NotImplementedError + + @abstractmethod + def needs_factory(self) -> bool: + """ + Return True if a dataclass `default_factory` is needed for this default object, for example if the value is a + mutable object (e.g. a list) that needs to be copied. + """ + raise NotImplementedError + + +class Default(BaseDefault[T_Default]): + """ + Class for specifying default values for dataclass validator fields. Values are deepcopied on initialization and on retrieval. Examples: `Default(None)`, `Default(42)`, `Default('empty')`, `Default([])` See also: `DefaultFactory()`, `DefaultUnset`, `NoDefault` """ - value: Any = None - def __init__(self, value: Any = None): - self.value = deepcopy(value) + _value: T_Default + _needs_factory: bool + + def __init__(self, value: T_Default): + # Deepcopy the value to avoid reusing mutable objects + self._value = deepcopy(value) + + # If copying the value resulted in the identical object, no factory is needed + self._needs_factory = self._value is not value def __repr__(self) -> str: - return f'{type(self).__name__}({self.value!r})' + return f'{type(self).__name__}({self._value!r})' def __eq__(self, other: Any) -> bool: + # Only handle this if self is of the same type as other OR self is a subclass of other. + # In other words, don't handle this if other is a completely different type or more specialized than self. if isinstance(self, type(other)): - return bool(self.value == other.value) + # A Default object is only equal to another Default object and only if their values are equal + return isinstance(other, Default) and bool(self._value == other._value) return NotImplemented def __hash__(self) -> int: - return hash(self.value) + return hash(self._value) - def get_value(self) -> Any: - return deepcopy(self.value) + # Deprecated: Allow DefaultUnset to be used as `DefaultUnset()`, returning the object itself. + # TODO: Remove this in a future version. + def __call__(self) -> Self: + # Only allow calling if it's Default(UnsetValue) / DefaultUnset. Don't introduce new deprecated features. + if self._value is UnsetValue: + warnings.warn( + "Calling default objects is deprecated. Please use `DefaultUnset` instead of `DefaultUnset()`.", + DeprecationWarning + ) + return self + raise TypeError(f"'{type(self).__name__}' object is not callable") + + def get_value(self) -> T_Default: + """ + Get actual default value. + """ + return deepcopy(self._value) def needs_factory(self) -> bool: """ - Returns True if a dataclass default_factory is needed for this Default object, for example if the value is a + Return True if a dataclass `default_factory` is needed for this default object, for example if the value is a mutable object (e.g. a list) that needs to be copied. """ - # If copying the value results in the identical object, no factory is needed (a shallow copy is sufficient to - # test this) - return copy(self.value) is not self.value + return self._needs_factory -class DefaultFactory(Default): +class DefaultFactory(BaseDefault[T_Default]): """ Class for specifying factories (functions or classes) to dynamically generate default values. + The factory must be a callable without arguments. + Examples: ``` @@ -77,61 +138,46 @@ class DefaultFactory(Default): DefaultFactory(lambda: date.today()) ``` """ - factory: Callable[[], Any] - def __init__(self, factory: Callable[[], Any]): - super().__init__() - self.factory = factory + _factory: Callable[[], T_Default] + + def __init__(self, factory: Callable[[], T_Default]): + self._factory = factory def __repr__(self) -> str: - return f'{type(self).__name__}({self.factory!r})' + return f'{type(self).__name__}({self._factory!r})' def __eq__(self, other: Any) -> bool: + # Only handle this if self is of the same type as other OR self is a subclass of other. + # In other words, don't handle this if other is a completely different type or more specialized than self. if isinstance(self, type(other)): - return isinstance(other, DefaultFactory) and bool(self.factory == other.factory) + # A DefaultFactory object is only equal to another DefaultFactory object with the same factory function + return isinstance(other, DefaultFactory) and bool(self._factory == other._factory) return NotImplemented def __hash__(self) -> int: - return hash(self.factory) + return hash(self._factory) - def get_value(self) -> Any: - return self.factory() + def get_value(self) -> T_Default: + """ + Get an actual default value by calling the factory function. + """ + return self._factory() def needs_factory(self) -> bool: + """ + Return True if a dataclass `default_factory` is needed for this default object. + Always true for `DefaultFactory`. + """ return True -# Temporary class to create the DefaultUnset sentinel, class will be deleted afterwards -class _DefaultUnset(Default): - """ - Class for creating the sentinel object `DefaultUnset`, which is a shortcut for `Default(UnsetValue)`. - """ - - def __init__(self) -> None: - super().__init__(UnsetValue) - - def __repr__(self) -> str: - return 'DefaultUnset' - - def get_value(self) -> UnsetValueType: - return UnsetValue - - def needs_factory(self) -> bool: - return False - - # For convenience: Allow DefaultUnset to be used as `DefaultUnset()`, returning the sentinel itself. - def __call__(self) -> Self: - return self - - -# Create sentinel object DefaultUnset, redefine __new__ to always return the same instance, and delete temporary class -DefaultUnset = _DefaultUnset() -_DefaultUnset.__new__ = lambda cls: DefaultUnset # type: ignore -del _DefaultUnset +# Define common shortcut/alias for Default(UnsetValue) +DefaultUnset: Default[UnsetValueType] = Default(UnsetValue) # Temporary class to create the NoDefault sentinel, class will be deleted afterwards -class _NoDefault(Default): +class _NoDefault(BaseDefault[Never]): """ Class for creating the sentinel object `NoDefault` which specifies that a field has no default value, i.e. the field is required. @@ -139,25 +185,28 @@ class _NoDefault(Default): A validataclass field with `NoDefault` is equivalent to a validataclass field without specified default. """ - def __init__(self) -> None: - super().__init__() - def __repr__(self) -> str: return 'NoDefault' def __eq__(self, other: Any) -> bool: # Nothing is equal to NoDefault except itself - return type(self) is type(other) + return self is other - def __hash__(self) -> int: - # Use default implementation - return object.__hash__(self) + __hash__ = BaseDefault.__hash__ - def get_value(self) -> NoReturn: + def get_value(self) -> Never: raise ValueError('No default value specified!') - # For convenience: Allow NoDefault to be used as `NoDefault()`, returning the sentinel itself. + def needs_factory(self) -> bool: + raise NotImplementedError('NoDefault can be used neither as a value nor as a factory.') + + # Deprecated: Allow NoDefault to be used as `NoDefault()`, returning the object itself. + # TODO: Remove this in a future version. def __call__(self) -> Self: + warnings.warn( + "Calling default objects is deprecated. Please use `NoDefault` instead of `NoDefault()`.", + DeprecationWarning + ) return self diff --git a/src/validataclass/dataclasses/validataclass.py b/src/validataclass/dataclasses/validataclass.py index e9a6174..c0c01c9 100644 --- a/src/validataclass/dataclasses/validataclass.py +++ b/src/validataclass/dataclasses/validataclass.py @@ -13,7 +13,7 @@ from validataclass.exceptions import DataclassValidatorFieldException from validataclass.validators import Validator -from .defaults import Default, NoDefault +from .defaults import BaseDefault, NoDefault from .validataclass_field import validataclass_field __all__ = [ @@ -50,8 +50,8 @@ def validataclass( Prepares the class by generating dataclass metadata that is needed by the `DataclassValidator` (which contains the field validators and defaults). Then turns the class into a dataclass using the regular `@dataclass` decorator. - Dataclass fields can be defined by specifying a `Validator` object and optionally a `Default` object - (comma-separated as a tuple), or by using either `validataclass_field()` or `dataclasses.field()`. + Dataclass fields can be defined by specifying a `Validator` object and optionally a default object (subclass of + `BaseDefault`, e.g. `Default`) as a tuple, or by using either `validataclass_field()` or `dataclasses.field()`. For an attribute to be recognized as a dataclass field, the attribute MUST have a type annotation. For example, `foo: int = IntegerValidator()`. @@ -65,7 +65,7 @@ def validataclass( ``` @validataclass class ExampleDataclass: - # This field is required because it has no defined Default. + # This field is required because it has no defined default. example_field1: str = StringValidator() # This field is optional. If it's not set, it will have the string value "not set". example_field2: str = StringValidator(), Default('not set') @@ -122,9 +122,9 @@ def _prepare_dataclass_metadata(cls: type[_T]) -> None: # Check if attribute has a validator and/or default object (as single value or as part of a tuple) value_tuple = value if isinstance(value, tuple) else (value,) - if any(isinstance(v, (Validator, Default)) for v in value_tuple): + if any(isinstance(v, (Validator, BaseDefault)) for v in value_tuple): raise DataclassValidatorFieldException( - f'Dataclass field "{name}" has a defined Validator and/or Default object, but no type annotation.') + f'Dataclass field "{name}" has a defined validator and/or default object, but no type annotation.') # Prepare dataclass fields by checking for validators and setting metadata accordingly for name, field_type in cls_annotations.items(): @@ -157,8 +157,8 @@ def _prepare_dataclass_metadata(cls: type[_T]) -> None: # Ensure that a validator is set, as well as a default (defaulting to NoDefault) if not isinstance(field_validator, Validator): - raise DataclassValidatorFieldException(f'Dataclass field "{name}" must specify a Validator.') - if not isinstance(field_default, Default): + raise DataclassValidatorFieldException(f'Dataclass field "{name}" must specify a validator.') + if not isinstance(field_default, BaseDefault): field_default = NoDefault # Create dataclass field @@ -193,10 +193,10 @@ def _get_existing_validator_fields(cls: type[_T]) -> dict[str, _ValidatorField]: return validator_fields -def _parse_validator_tuple(args: tuple[Any, ...] | Validator[Any] | Default | None) -> _ValidatorField: +def _parse_validator_tuple(args: tuple[Any, ...] | Validator[Any] | BaseDefault[Any] | 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. + tuple of a validator and a default object. (Internal helper function.) """ @@ -206,7 +206,7 @@ def _parse_validator_tuple(args: tuple[Any, ...] | Validator[Any] | Default | No elif not isinstance(args, tuple): args = (args,) - # Currently a field can only have two arguments (a validator and/or a Default object) + # Currently a field can only have two arguments (a validator and/or a default object) if len(args) > 2: raise ValueError('Unexpected number of arguments.') @@ -217,11 +217,11 @@ def _parse_validator_tuple(args: tuple[Any, ...] | Validator[Any] | Default | No for arg in args: if isinstance(arg, Validator): if arg_validator is not None: - raise ValueError('Only one Validator can be specified.') + raise ValueError('Only one validator can be specified.') arg_validator = arg - elif isinstance(arg, Default): + elif isinstance(arg, BaseDefault): if arg_default is not None: - raise ValueError('Only one Default can be specified.') + raise ValueError('Only one default can be specified.') arg_default = arg else: raise TypeError('Unexpected type of argument: ' + type(arg).__name__) diff --git a/src/validataclass/dataclasses/validataclass_field.py b/src/validataclass/dataclasses/validataclass_field.py index dbf4a92..34d6f52 100644 --- a/src/validataclass/dataclasses/validataclass_field.py +++ b/src/validataclass/dataclasses/validataclass_field.py @@ -8,7 +8,7 @@ from typing import Any from validataclass.validators import Validator -from .defaults import Default, NoDefault +from .defaults import BaseDefault, Default, NoDefault __all__ = [ 'validataclass_field', @@ -29,16 +29,17 @@ def validataclass_field( Additional keyword arguments will be passed to `dataclasses.field()`, with some exceptions: - - `default` is handled by this function to set metadata. It can be either a direct value or a `Default` object. + - `default` is handled by this function to set metadata. It can be either a direct value or a validataclass default + object, i.e. an object of a subclass of `BaseDefault` (e.g. `Default`, `DefaultFactory`, `NoDefault`). It is then converted to a direct value (or factory) if necessary and passed to `dataclasses.field()`. - `default_factory` is not allowed. Use `default=DefaultFactory(...)` instead. - `init` is not allowed. To create a non-init field, use `dataclasses.field(init=False)` instead. Parameters: - `validator`: Validator to use for validating the field (saved as metadata) - `default`: Default value to use when the field does not exist in the input data (preferably a `Default` object) - `metadata`: Base dictionary for field metadata, gets merged with the metadata generated by this function - `**kwargs`: Additional keyword arguments that are passed to `dataclasses.field()` + `validator`: Validator to use for validating the field (subclass of `Validator`). + `default`: Default value when the field is missing in the input data (any value or subclass of `BaseDefault`). + `metadata`: Base dictionary for field metadata, gets merged with the metadata generated by this function. + `**kwargs`: Additional keyword arguments that are passed to `dataclasses.field()`. """ # If metadata is specified as argument, use it as the base for the field's metadata if metadata is None: @@ -56,10 +57,11 @@ def validataclass_field( # Add validator metadata metadata['validator'] = validator - # Ensure default is a Default object (or any subclass of Default) + # Ensure default is a validataclass default object (any subclass of BaseDefault) if default is dataclasses.MISSING: default = NoDefault - elif not isinstance(default, Default): + elif not isinstance(default, BaseDefault): + # Wrap value in a validataclass default object default = Default(default) if default is not NoDefault: diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index f47e0ba..02e5b2d 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -9,7 +9,7 @@ import warnings from typing import Any, Callable, TypeGuard, TypeVar -from validataclass.dataclasses import Default, NoDefault +from validataclass.dataclasses import BaseDefault, NoDefault from validataclass.exceptions import ( DataclassInvalidPreValidateSignatureException, DataclassPostValidationError, @@ -113,7 +113,7 @@ def __post_validate__(self, *, require_optional_field: bool = False): dataclass_cls: type[T_Dataclass] # Field default values - field_defaults: dict[str, Default] + field_defaults: dict[str, BaseDefault[Any]] 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 @@ -165,17 +165,17 @@ def _get_field_validator(field: dataclasses.Field[Any]) -> Validator[Any]: # Ensure that validator is defined and has a valid type if validator is None: - raise DataclassValidatorFieldException(f'Dataclass field "{field.name}" has no defined Validator.') + raise DataclassValidatorFieldException(f'Dataclass field "{field.name}" has no defined validator.') if not isinstance(validator, Validator): raise DataclassValidatorFieldException( - f'Validator specified for dataclass field "{field.name}" is not of type "Validator".' + f'Validator specified for dataclass field "{field.name}" is not an instance of "Validator".' ) return validator @staticmethod - def _get_field_default(field: dataclasses.Field[Any]) -> Default: - # Parse field metadata to get Default object + def _get_field_default(field: dataclasses.Field[Any]) -> BaseDefault[Any]: + # Parse field metadata to get default object default = field.metadata.get('validator_default', NoDefault) # Default is optional @@ -183,9 +183,9 @@ def _get_field_default(field: dataclasses.Field[Any]) -> Default: return NoDefault # Ensure valid type - if not isinstance(default, Default): + if not isinstance(default, BaseDefault): raise DataclassValidatorFieldException( - f'Default specified for dataclass field "{field.name}" is not of type "Default".' + f'Default specified for dataclass field "{field.name}" is not an instance of "BaseDefault".' ) return default diff --git a/tests/mypy/dataclasses/test_defaults.yml b/tests/mypy/dataclasses/test_defaults.yml new file mode 100644 index 0000000..f2259e6 --- /dev/null +++ b/tests/mypy/dataclasses/test_defaults.yml @@ -0,0 +1,81 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/typeddjango/pytest-mypy-plugins/master/pytest_mypy_plugins/schema.json + +# Check that Default has correct type parameter for BaseDefault[T] and Default[T] +- case: default_value_type_params + main: | + from typing import Any + from validataclass.dataclasses import BaseDefault, Default + reveal_type(Default(None)) + reveal_type(Default(None).get_value()) + reveal_type(Default(42)) + reveal_type(Default(42).get_value()) + reveal_type(Default([])) + reveal_type(Default([]).get_value()) + var1: BaseDefault[int] = Default(42) # correct + var2: BaseDefault[str] = Default(42) # error (10) + var3: BaseDefault[list[Any]] = Default([]) # correct + var4: BaseDefault[list[int]] = Default([]) # correct + var5: BaseDefault[int] = Default([]) # error (13) + out: | + main:3: note: Revealed type is "validataclass.dataclasses.defaults.Default[None]" + main:4: note: Revealed type is "None" + main:5: note: Revealed type is "validataclass.dataclasses.defaults.Default[builtins.int]" + main:6: note: Revealed type is "builtins.int" + main:7: note: Revealed type is "validataclass.dataclasses.defaults.Default[builtins.list[Never]]" + main:8: note: Revealed type is "builtins.list[Never]" + main:10: error: Argument 1 to "Default" has incompatible type "int"; expected "str" [arg-type] + main:13: error: Argument 1 to "Default" has incompatible type "list[Never]"; expected "int" [arg-type] + +# Check that DefaultFactory has correct type parameter for BaseDefault[T] and DefaultFactory[T] +- case: default_factory_type_params + main: | + from typing import Any + from validataclass.dataclasses import BaseDefault, DefaultFactory + reveal_type(DefaultFactory(lambda: 42)) + reveal_type(DefaultFactory(lambda: 42).get_value()) + reveal_type(DefaultFactory(list)) + reveal_type(DefaultFactory(list).get_value()) + var1: BaseDefault[int] = DefaultFactory(lambda: 42) # correct + var2: BaseDefault[str] = DefaultFactory(lambda: 42) # error (8) + var3: BaseDefault[list[Any]] = DefaultFactory(list) # correct + var4: BaseDefault[list[int]] = DefaultFactory(list) # correct + var5: BaseDefault[int] = DefaultFactory(list) # error (11) + out: | + main:3: note: Revealed type is "validataclass.dataclasses.defaults.DefaultFactory[builtins.int]" + main:4: note: Revealed type is "builtins.int" + main:5: note: Revealed type is "validataclass.dataclasses.defaults.DefaultFactory[builtins.list[Never]]" + main:6: note: Revealed type is "builtins.list[Never]" + main:8: error: Argument 1 to "DefaultFactory" has incompatible type "Callable[[], int]"; expected "Callable[[], str]" [arg-type] + main:8: error: Incompatible return value type (got "int", expected "str") [return-value] + main:11: error: Argument 1 to "DefaultFactory" has incompatible type "type[list[_T]]"; expected "Callable[[], int]" [arg-type] + +# Check that DefaultUnset has correct type parameter for BaseDefault[T] and Default[T] +- case: default_unset_type_params + main: | + from validataclass.dataclasses import BaseDefault, Default, DefaultUnset + from validataclass.helpers import UnsetValueType + reveal_type(DefaultUnset) + reveal_type(DefaultUnset.get_value()) + var1: Default[UnsetValueType] = DefaultUnset # correct + var2: Default[str] = DefaultUnset # error (6) + out: | + main:3: note: Revealed type is "validataclass.dataclasses.defaults.Default[validataclass.helpers.unset_value.UnsetValueType]" + main:4: note: Revealed type is "validataclass.helpers.unset_value.UnsetValueType" + main:6: error: Incompatible types in assignment (expression has type "Default[UnsetValueType]", variable has type "Default[str]") [assignment] + +# Check that NoDefault has correct type parameter for BaseDefault[T] +- case: no_default_type_params + main: | + from typing_extensions import Any, Never + from validataclass.dataclasses import BaseDefault, Default, NoDefault + reveal_type(NoDefault) + try: + reveal_type(NoDefault.get_value()) + except: + pass + var1: BaseDefault[Never] = NoDefault # correct + var2: BaseDefault[str] = NoDefault # error (9) + out: | + main:3: note: Revealed type is "validataclass.dataclasses.defaults._NoDefault" + main:5: note: Revealed type is "Never" + main:9: error: Incompatible types in assignment (expression has type "_NoDefault", variable has type "BaseDefault[str]") [assignment] diff --git a/tests/mypy/pytest_mypy.ini b/tests/mypy/pytest_mypy.ini index a74f8dd..9b567b4 100644 --- a/tests/mypy/pytest_mypy.ini +++ b/tests/mypy/pytest_mypy.ini @@ -1,3 +1,7 @@ # This mypy config is used exclusively by the pytest-mypy-plugins plugin. [mypy] strict = true + +# Enable further checks that are not included in strict mode +enable_error_code = + deprecated diff --git a/tests/unit/dataclasses/defaults_test.py b/tests/unit/dataclasses/defaults_test.py index 0a55201..40dfc94 100644 --- a/tests/unit/dataclasses/defaults_test.py +++ b/tests/unit/dataclasses/defaults_test.py @@ -9,12 +9,41 @@ import pytest -from validataclass.dataclasses import Default, DefaultFactory, DefaultUnset, NoDefault +from validataclass.dataclasses import BaseDefault, Default, DefaultFactory, DefaultUnset, NoDefault from validataclass.helpers import UnsetValue +# Create a non-abstract subclass of BaseDefault to test methods inherited from BaseDefault +class ExampleDefaultClass(BaseDefault[int]): + def get_value(self) -> int: + return 42 + + def needs_factory(self) -> bool: + return False + + +class BaseDefaultTest: + """ Tests for the BaseDefault abstract base class. """ + + @staticmethod + def test_base_default_repr(): + assert repr(ExampleDefaultClass()) == 'ExampleDefaultClass' + + @staticmethod + def test_base_default_equality(): + default1 = ExampleDefaultClass() + default2 = ExampleDefaultClass() + assert default1 == default1 + assert default1 != default2 + + @staticmethod + def test_base_default_hashable(): + default = ExampleDefaultClass() + assert hash(default) == object.__hash__(default) + + class DefaultTest: - """ Tests for the base Default class. """ + """ Tests for the Default class. """ @staticmethod @pytest.mark.parametrize( @@ -102,6 +131,13 @@ def test_default_hashable(value): """ Test hashability (__hash__) of Default objects. """ assert hash(Default(value)) == hash(value) + @staticmethod + def test_default_not_callable(): + """ Test that default objects are not callable, except for the (deprecated) case of UnsetValue. """ + default = Default(None) + with pytest.raises(TypeError, match="'Default' object is not callable"): + default() + class DefaultFactoryTest: """ Tests for the DefaultFactory class. """ @@ -109,13 +145,12 @@ class DefaultFactoryTest: @staticmethod def test_default_factory_repr(): """ Test DefaultFactory __repr__ method. """ - default_factory = DefaultFactory(list) - assert repr(default_factory) == "DefaultFactory()" + assert repr(DefaultFactory(list)) == "DefaultFactory()" @staticmethod def test_default_factory_list(): """ Test DefaultFactory with `list` as default generator. """ - default_factory = DefaultFactory(list) + default_factory: DefaultFactory[list[int]] = DefaultFactory(list) assert default_factory.needs_factory() # Generate values and test that they are not the same objects @@ -191,33 +226,22 @@ def test_default_factory_hashable(): class DefaultUnsetTest: - """ Tests for the DefaultUnset sentinel object. """ + """ Tests for the DefaultUnset object, formerly a subclass, now an alias for `Default(UnsetValue)`. """ @staticmethod def test_default_unset(): - """ Test the DefaultUnset sentinel object. """ - default = DefaultUnset - assert repr(default) == 'DefaultUnset' - assert default.get_value() is UnsetValue - assert not default.needs_factory() - - @staticmethod - def test_default_unset_is_unique(): - """ Test that DefaultUnset cannot be cloned. """ - default1 = DefaultUnset - default2 = copy(DefaultUnset) - assert default1 is default2 is DefaultUnset + """ Test the DefaultUnset object. """ + assert isinstance(DefaultUnset, Default) + assert repr(DefaultUnset) == 'Default(UnsetValue)' + assert DefaultUnset.get_value() is UnsetValue + assert not DefaultUnset.needs_factory() @staticmethod def test_default_unset_equality(): - """ Test equality between DefaultUnset and Default(UnsetValue) objects. """ - assert DefaultUnset == DefaultUnset + """ Test equality of DefaultUnset with Default(UnsetValue). """ assert DefaultUnset == Default(UnsetValue) assert Default(UnsetValue) == DefaultUnset - assert DefaultUnset != Default(None) - assert Default(None) != DefaultUnset - @staticmethod @pytest.mark.parametrize( 'other', @@ -235,9 +259,10 @@ def test_default_unset_non_equality(other): assert other != DefaultUnset @staticmethod - def test_default_unset_call(): - """ Test that calling DefaultUnset returns the sentinel object itself. """ - assert DefaultUnset() is DefaultUnset + def test_default_unset_call_is_deprecated(): + """ Test that calling DefaultUnset returns the object itself, but issues a deprecation warning. """ + with pytest.deprecated_call(): + assert DefaultUnset() is DefaultUnset class NoDefaultTest: @@ -246,13 +271,15 @@ class NoDefaultTest: @staticmethod def test_no_default(): """ Test the NoDefault sentinel's behaviour as a Default object. """ - default = NoDefault - assert repr(default) == 'NoDefault' + assert repr(NoDefault) == 'NoDefault' # get_value() must raise an exception - with pytest.raises(ValueError) as exception_info: - default.get_value() - assert str(exception_info.value) == 'No default value specified!' + with pytest.raises(ValueError, match=r'^No default value specified!$'): + NoDefault.get_value() + + # needs_factory() must raise an exception + with pytest.raises(NotImplementedError, match=r'^NoDefault can be used neither as a value nor as a factory\.$'): + NoDefault.needs_factory() @staticmethod def test_no_default_is_unique(): @@ -284,6 +311,7 @@ def test_no_default_hashable(): assert hash(NoDefault) == object.__hash__(NoDefault) @staticmethod - def test_no_default_call(): - """ Test that calling NoDefault returns the sentinel itself. """ - assert NoDefault() is NoDefault + def test_no_default_call_is_deprecated(): + """ Test that calling NoDefault returns the sentinel object itself, but issues a deprecation warning. """ + with pytest.deprecated_call(): + assert NoDefault() is NoDefault diff --git a/tests/unit/dataclasses/validataclass_field_test.py b/tests/unit/dataclasses/validataclass_field_test.py index 707d405..8afb36f 100644 --- a/tests/unit/dataclasses/validataclass_field_test.py +++ b/tests/unit/dataclasses/validataclass_field_test.py @@ -5,13 +5,12 @@ """ import dataclasses -from typing import Any import pytest from tests.unit.dataclasses._helpers import assert_field_default, assert_field_no_default, get_dataclass_fields from tests.test_utils import UNSET_PARAMETER -from validataclass.dataclasses import Default, DefaultFactory, DefaultUnset, NoDefault, validataclass_field +from validataclass.dataclasses import BaseDefault, Default, DefaultFactory, DefaultUnset, NoDefault, validataclass_field from validataclass.helpers import UnsetValue from validataclass.validators import IntegerValidator @@ -47,28 +46,22 @@ def test_validataclass_field_without_default(param_default): @staticmethod @pytest.mark.parametrize( - 'param_default, expected_default, expected_as_factory', + 'param_default, expected_default', [ # Explicit Default objects - (Default(42), 42, False), - (Default(None), None, False), - (Default(UnsetValue), UnsetValue, False), - (DefaultUnset, UnsetValue, False), - - # Default object with mutable value (should result in a default_factory) - (Default([]), [], True), - - # DefaultFactory object - (DefaultFactory(lambda: 3), 3, True), + (Default(42), 42), + (Default(None), None), + (Default(UnsetValue), UnsetValue), + (DefaultUnset, UnsetValue), # Regular values (automatically converted to Default objects) - (42, 42, False), - (None, None, False), - (UnsetValue, UnsetValue, False), + (42, 42), + (None, None), + (UnsetValue, UnsetValue), ], ) - def test_validataclass_field_with_default(param_default, expected_default, expected_as_factory): - """ Test validataclass_field function on its own, with various default values. """ + def test_validataclass_field_with_default(param_default, expected_default): + """ Test validataclass_field function on its own, with various static default values. """ # Create field field = validataclass_field(IntegerValidator(), default=param_default) @@ -76,39 +69,48 @@ def test_validataclass_field_with_default(param_default, expected_default, expec assert type(field.metadata.get('validator')) is IntegerValidator assert isinstance(field.metadata.get('validator_default'), Default) assert field.metadata.get('validator_default').get_value() == expected_default + assert field.metadata.get('validator_default').needs_factory() is False # Check field default and default_factory - if expected_as_factory: - assert field.default is dataclasses.MISSING - assert field.default_factory() == expected_default - else: - assert field.default == expected_default - assert field.default_factory is dataclasses.MISSING + assert field.default == expected_default + assert field.default_factory is dataclasses.MISSING @staticmethod - def test_validataclass_field_with_default_factory(): - """ Test validataclass_field function on its own, with a default factory. """ + @pytest.mark.parametrize( + 'param_default, expected_default, expected_default_cls', + [ + # Default object with mutable value (should result in a default_factory) + (Default([]), [], Default), + + # DefaultFactory object + (DefaultFactory(lambda: 3), 3, DefaultFactory), + ], + ) + def test_validataclass_field_with_default_factory(param_default, expected_default, expected_default_cls): + """ Test validataclass_field function on its own, with default objects that require a default_factory. """ # Create field - field = validataclass_field(IntegerValidator(), default=DefaultFactory(lambda: 3)) + field = validataclass_field(IntegerValidator(), default=param_default) # Check field metadata assert type(field.metadata.get('validator')) is IntegerValidator - assert isinstance(field.metadata.get('validator_default'), DefaultFactory) - assert field.metadata.get('validator_default').get_value() == 3 + assert isinstance(field.metadata.get('validator_default'), BaseDefault) + assert isinstance(field.metadata.get('validator_default'), expected_default_cls) + assert field.metadata.get('validator_default').get_value() == expected_default + assert field.metadata.get('validator_default').needs_factory() is True # Check field default and default_factory assert field.default is dataclasses.MISSING - assert callable(field.default_factory) and field.default_factory() == 3 + assert field.default_factory() == expected_default @staticmethod def test_validataclass_field_with_custom_default_class(): """ Test validataclass_field() on its own with a custom default class (which generates a default factory). """ # Create a custom Default subclass - class CustomDefault(Default): + class CustomDefault(BaseDefault[int]): counter: int = 0 - def get_value(self) -> Any: + def get_value(self) -> int: self.counter += 1 return self.counter @@ -123,6 +125,7 @@ def needs_factory(self) -> bool: assert isinstance(field.metadata.get('validator_default'), CustomDefault) assert field.metadata.get('validator_default').get_value() == 1 assert field.metadata.get('validator_default').get_value() == 2 + assert field.metadata.get('validator_default').needs_factory() is True # Check field default and default_factory assert field.default is dataclasses.MISSING diff --git a/tests/unit/dataclasses/validataclass_test.py b/tests/unit/dataclasses/validataclass_test.py index 4eadb5f..6c706fb 100644 --- a/tests/unit/dataclasses/validataclass_test.py +++ b/tests/unit/dataclasses/validataclass_test.py @@ -99,7 +99,7 @@ class BarDataclass: @staticmethod def test_validataclass_with_tuples(): - """ Create a dataclass using @validataclass with tuple syntax for setting Defaults. """ + """ Create a dataclass using @validataclass with tuple syntax for setting defaults. """ @validataclass class UnitTestValidatorDataclass: @@ -426,7 +426,7 @@ def test_validataclass_with_invalid_values(): class InvalidDataclass: foo: int - assert str(exception_info.value) == 'Dataclass field "foo" must specify a Validator.' + assert str(exception_info.value) == 'Dataclass field "foo" must specify a validator.' @staticmethod @pytest.mark.parametrize( @@ -435,12 +435,12 @@ class InvalidDataclass: ( # None, missing validator None, - 'Dataclass field "foo" must specify a Validator.', + 'Dataclass field "foo" must specify a validator.', ), ( # Default only, missing validator (Default(3)), - 'Dataclass field "foo" must specify a Validator.', + 'Dataclass field "foo" must specify a validator.', ), ( # Tuple with invalid length @@ -460,12 +460,12 @@ class InvalidDataclass: ( # Two validators in a tuple (IntegerValidator(), StringValidator()), - 'Dataclass field "foo": Only one Validator can be specified.', + 'Dataclass field "foo": Only one validator can be specified.', ), ( # Two defaults in a tuple (Default(3), Default(None)), - 'Dataclass field "foo": Only one Default can be specified.', + 'Dataclass field "foo": Only one default can be specified.', ), ], ) @@ -512,7 +512,7 @@ class InvalidDataclassC: assert ( str(exception_info.value) - == 'Dataclass field "foo" has a defined Validator and/or Default object, but no type annotation.' + == 'Dataclass field "foo" has a defined validator and/or default object, but no type annotation.' ) @staticmethod diff --git a/tests/unit/helpers/_compatibility_imports_test.py b/tests/unit/helpers/_compatibility_imports_test.py index bebc2ee..9debd5e 100644 --- a/tests/unit/helpers/_compatibility_imports_test.py +++ b/tests/unit/helpers/_compatibility_imports_test.py @@ -6,6 +6,8 @@ import pytest +import validataclass.dataclasses as vdc_dataclasses + class HelpersCompatibilityImportsTest: """ Tests backwards compatibility imports from validataclass.helpers. """ @@ -16,8 +18,8 @@ def test_import_from_dataclasses(): with pytest.deprecated_call(): from validataclass.helpers.dataclasses import validataclass, validataclass_field - assert callable(validataclass) - assert callable(validataclass_field) + assert validataclass is vdc_dataclasses.validataclass + assert validataclass_field is vdc_dataclasses.validataclass_field @staticmethod def test_import_from_dataclass_defaults(): @@ -25,10 +27,10 @@ def test_import_from_dataclass_defaults(): with pytest.deprecated_call(): from validataclass.helpers.dataclass_defaults import Default, DefaultFactory, DefaultUnset, NoDefault - assert type(Default) is type - assert issubclass(DefaultFactory, Default) - assert isinstance(DefaultUnset, Default) - assert isinstance(NoDefault, Default) + assert Default is vdc_dataclasses.Default + assert DefaultFactory is vdc_dataclasses.DefaultFactory + assert DefaultUnset is vdc_dataclasses.DefaultUnset + assert NoDefault is vdc_dataclasses.NoDefault @staticmethod def test_import_from_dataclass_mixins(): @@ -36,7 +38,7 @@ def test_import_from_dataclass_mixins(): with pytest.deprecated_call(): from validataclass.helpers.dataclass_mixins import ValidataclassMixin - assert type(ValidataclassMixin) is type + assert ValidataclassMixin is vdc_dataclasses.ValidataclassMixin @staticmethod def test_import_from_helpers_package(): @@ -47,13 +49,13 @@ def test_import_from_helpers_package(): validataclass, validataclass_field, ValidataclassMixin, # noqa (not declared in __all__) ) - assert type(Default) is type - assert issubclass(DefaultFactory, Default) - assert isinstance(DefaultUnset, Default) - assert isinstance(NoDefault, Default) - assert callable(validataclass) - assert callable(validataclass_field) - assert type(ValidataclassMixin) is type + assert Default is vdc_dataclasses.Default + assert DefaultFactory is vdc_dataclasses.DefaultFactory + assert DefaultUnset is vdc_dataclasses.DefaultUnset + assert NoDefault is vdc_dataclasses.NoDefault + assert validataclass is vdc_dataclasses.validataclass + assert validataclass_field is vdc_dataclasses.validataclass_field + assert ValidataclassMixin is vdc_dataclasses.ValidataclassMixin @staticmethod def test_import_from_helpers_package_fallback_to_exception(): diff --git a/tests/unit/validators/dataclass_validator_test.py b/tests/unit/validators/dataclass_validator_test.py index 746db40..c9293df 100644 --- a/tests/unit/validators/dataclass_validator_test.py +++ b/tests/unit/validators/dataclass_validator_test.py @@ -386,8 +386,8 @@ def test_dataclass_optional_field(): @staticmethod def test_dataclass_with_various_default_classes(): """ - Test DataclassValidator with a dataclass with all kinds of Default objects (Default, DefaultUnset, - DefaultFactory). + Test DataclassValidator with a dataclass with all kinds of default objects (`Default`, `DefaultUnset`, + `DefaultFactory`). """ def counter(): @@ -1089,7 +1089,7 @@ def test_invalid_dataclass_validator_with_invalid_dataclass(dataclass_cls_param) @staticmethod def test_dataclass_field_without_validator(): - """ Test that DataclassValidator only allows dataclasses where every field has a defined Validator. """ + """ Test that DataclassValidator only allows dataclasses where every field has a defined validator. """ @dataclass class IncompatibleDataclass: @@ -1099,21 +1099,24 @@ class IncompatibleDataclass: with pytest.raises(DataclassValidatorFieldException) as exception_info: DataclassValidator(IncompatibleDataclass) - assert str(exception_info.value) == 'Dataclass field "foo" has no defined Validator.' + assert str(exception_info.value) == 'Dataclass field "foo" has no defined validator.' @staticmethod def test_dataclass_field_with_invalid_validator(): - """ Test that DataclassValidator only allows dataclasses where every field has a valid Validator. """ + """ Test that DataclassValidator only allows dataclasses where every field has a valid validator. """ @dataclass class IncompatibleDataclass: - # Metadata contains 'validator' but it is not of type Validator + # Metadata contains 'validator' but it is not an instance of Validator or a subclass foo: str = field(default='unknown', metadata={'validator': 'foobar'}) with pytest.raises(DataclassValidatorFieldException) as exception_info: DataclassValidator(IncompatibleDataclass) - assert str(exception_info.value) == 'Validator specified for dataclass field "foo" is not of type "Validator".' + assert ( + str(exception_info.value) + == 'Validator specified for dataclass field "foo" is not an instance of "Validator".' + ) @staticmethod def test_dataclass_field_with_invalid_default(): @@ -1121,7 +1124,7 @@ def test_dataclass_field_with_invalid_default(): @dataclass class IncompatibleDataclass: - # Metadata contains 'validator_default' but it is not of type Default + # Metadata contains 'validator_default' but it is not an instance of BaseDefault or a subclass foo: str = field(default='unknown', metadata={ 'validator': StringValidator(), 'validator_default': 'foobar', @@ -1130,4 +1133,7 @@ class IncompatibleDataclass: with pytest.raises(DataclassValidatorFieldException) as exception_info: DataclassValidator(IncompatibleDataclass) - assert str(exception_info.value) == 'Default specified for dataclass field "foo" is not of type "Default".' + assert ( + str(exception_info.value) + == 'Default specified for dataclass field "foo" is not an instance of "BaseDefault".' + ) diff --git a/tox.ini b/tox.ini index 8499ce3..3c2f9ab 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ commands = python -m pytest --cov --cov-append {posargs} [testenv:pytest-mypy] extras = testing -commands = python -m pytest tests/mypy +commands = python -m pytest {posargs:tests/mypy} [testenv:flake8] commands = flake8 src/ tests/