Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 109 additions & 67 deletions docs/05-dataclasses.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*'

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ testing =
pytest-mypy-plugins ~= 3.2
coverage ~= 7.9
flake8 ~= 7.3
mypy ~= 1.17
mypy ~= 1.19
3 changes: 2 additions & 1 deletion src/validataclass/dataclasses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
179 changes: 114 additions & 65 deletions src/validataclass/dataclasses/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand All @@ -77,87 +138,75 @@ 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.

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


Expand Down
28 changes: 14 additions & 14 deletions src/validataclass/dataclasses/validataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand Down Expand Up @@ -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()`.
Expand All @@ -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')
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.)
"""
Expand All @@ -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.')

Expand All @@ -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__)
Expand Down
Loading