Skip to content

Commit

Permalink
Merge pull request #119 from binary-butterfly/code-reformatting
Browse files Browse the repository at this point in the history
Code reformatting and small test refactoring
  • Loading branch information
binaryDiv committed Apr 30, 2024
2 parents f42dbdb + 81378de commit ca53a4b
Show file tree
Hide file tree
Showing 90 changed files with 4,281 additions and 1,640 deletions.
27 changes: 20 additions & 7 deletions src/validataclass/dataclasses/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"""

from copy import copy, deepcopy
from typing import Any, NoReturn, Callable
from typing import Any, Callable, NoReturn

from validataclass.helpers import UnsetValue, UnsetValueType

Expand All @@ -21,7 +21,8 @@

class Default:
"""
(Base) class for specifying default values for dataclass validator fields. Values are deepcopied on initialization and on retrieval.
(Base) 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([])`
Expand Down Expand Up @@ -51,7 +52,8 @@ 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
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)
# 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


Expand All @@ -60,9 +62,17 @@ class DefaultFactory(Default):
Class for specifying factories (functions or classes) to dynamically generate default values.
Examples:
`DefaultFactory(list)` (generates an empty list)
`DefaultFactory(SomeClass)` (generates new instances of `SomeClass`)
`DefaultFactory(lambda: some_expression)` (uses a lambda to evaluate an expression to generate default values)
```
# Generates an empty list (i.e. list())
DefaultFactory(list)
# Generates new instances of SomeClass (i.e. SomeClass())
DefaultFactory(SomeClass)
# Uses a lambda to evaluate an expression to generate default values (here: a Date object with the current day)
DefaultFactory(lambda: date.today())
```
"""
factory: Callable

Expand Down Expand Up @@ -120,7 +130,10 @@ def __call__(self):
# Temporary class to create the NoDefault sentinel, class will be deleted afterwards
class _NoDefault(Default):
"""
Class for creating the sentinel object `NoDefault` which specifies that a field has no default value (meaning it is required).
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):
Expand Down
67 changes: 39 additions & 28 deletions src/validataclass/dataclasses/validataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,32 +45,38 @@ def validataclass(
**kwargs,
) -> Union[Type[_T], Callable[[Type[_T]], Type[_T]]]:
"""
Decorator that turns a normal class into a DataclassValidator-compatible dataclass.
Decorator that turns a normal class into a `DataclassValidator`-compatible dataclass.
Prepares the class by generating dataclass metadata that is needed by the DataclassValidator, then turns the class
into a dataclass using the regular @dataclass decorator.
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 `validataclass_field()` or `dataclasses.field()`.
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()`.
For an attribute to be recognized as a dataclass field, the attribute must have a type annotation (e.g. `foo: int = ...`).
The decorator will raise an error if it detects a field that has a defined validator but no type annotation (unless
the attribute's name begins with an underscore, e.g. `_foo = IntegerValidator()` would not raise an error, but would
NOT result in a datafield either).
For an attribute to be recognized as a dataclass field, the attribute MUST have a type annotation. For example,
`foo: int = IntegerValidator()`.
If an attribute is defined with a validator but WITHOUT a type annotation (e.g. `foo = IntegerValidator()`), this
is most likely a mistake, so the decorator will raise an error. However, attributes that start with an underscore
will be ignored, so `_foo = IntegerValidator()` would not raise an error (but also not result in a datafield).
Example:
```
@validataclass
class ExampleDataclass:
# Implicit field definitions (using validators only or tuples)
example_field1: str = StringValidator() # This field is required because it has no default
example_field2: str = StringValidator(), Default('not set') # This field is optional
# Field definitions using validataclass_field() and regular dataclasses field()
example_field3: str = validataclass_field(StringValidator()) # Same as example_field1
example_field4: str = validataclass_field(StringValidator(), default='not set') # Same as example_field2
post_init_field: int = field(init=False, default=0) # Post-init field without validator
# 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')
# Explicit field definitions using validataclass_field() and regular dataclasses field()
# (Same as example_field1)
example_field3: str = validataclass_field(StringValidator())
# (Same as example_field2)
example_field4: str = validataclass_field(StringValidator(), default='not set')
# Post-init field without validator
post_init_field: int = field(init=False, default=0)
```
Note: As of now, InitVars are not supported because they are not recognized as proper fields. This might change in a
Expand All @@ -82,9 +88,9 @@ class ExampleDataclass:
"""

def decorator(_cls: Type[_T]) -> Type[_T]:
# In Python 3.10 and higher, we use kw_only=True by default to allow for 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.
# 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
Expand All @@ -99,10 +105,10 @@ def decorator(_cls: Type[_T]) -> Type[_T]:

def _prepare_dataclass_metadata(cls) -> None:
"""
Prepares a soon-to-be dataclass (before it is decorated with @dataclass) to be usable with DataclassValidator by
checking it for Validator objects and setting dataclass metadata.
Prepares a soon-to-be dataclass (before it is decorated with `@dataclass`) to be usable with `DataclassValidator`
by checking it for `Validator` objects and setting dataclass metadata.
(Used internally by the @validataclass decorator.)
(Used internally by the `@validataclass` decorator.)
"""
# In case of a subclassed validataclass, get the already existing fields
existing_validator_fields = _get_existing_validator_fields(cls)
Expand All @@ -128,7 +134,9 @@ def _prepare_dataclass_metadata(cls) -> None:

# InitVars currently do not work, so better raise an Exception right here to avoid confusing error messages
if field_type is dataclasses.InitVar or type(field_type) is dataclasses.InitVar:
raise DataclassValidatorFieldException(f'Dataclass field "{name}": InitVars currently not supported by DataclassValidator.')
raise DataclassValidatorFieldException(
f'Dataclass field "{name}": InitVars currently not supported by DataclassValidator.'
)

# Skip field if it is already a dataclass Field object (created by field() or validataclass_field())
if isinstance(value, dataclasses.Field):
Expand All @@ -140,7 +148,8 @@ def _prepare_dataclass_metadata(cls) -> None:
except Exception as e:
raise DataclassValidatorFieldException(f'Dataclass field "{name}": {e}')

# If the field is already existing in a superclass and has a validator and/or default, overwrite them with new values
# If the field already exists in a superclass, the validator and/or default defined in this class will override
# those of the superclass. E.g. setting a default will override the default, but leave the validator intact.
if name in existing_validator_fields:
existing_field = existing_validator_fields.get(name)
if field_validator is None:
Expand All @@ -154,7 +163,8 @@ def _prepare_dataclass_metadata(cls) -> 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 in Python < 3.10)
# 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))


Expand All @@ -164,7 +174,7 @@ def _get_existing_validator_fields(cls) -> Dict[str, _ValidatorField]:
validator set in their metadata, or an empty dictionary if the class is not a dataclass (yet).
Existing dataclass fields are determined by looking at all direct parent classes that are dataclasses themselves.
If two (unrelated) base classes define a field with the same name, the most-left class takes precedence (for example,
If two unrelated base classes define a field with the same name, the most-left class takes precedence (for example,
in `class C(B, A)`, the definitions of B take precendence over A).
(Internal helper function.)
Expand All @@ -188,7 +198,8 @@ def _get_existing_validator_fields(cls) -> Dict[str, _ValidatorField]:

def _parse_validator_tuple(args: Union[tuple, None, Validator, Default]) -> _ValidatorField:
"""
Parses field arguments (the value of a field in not yet decorated dataclass) to a tuple of a Validator and a Default object.
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.
(Internal helper function.)
"""
Expand Down
31 changes: 19 additions & 12 deletions src/validataclass/dataclasses/validataclass_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import dataclasses
import sys
from typing import Any, Optional, NoReturn
from typing import Any, NoReturn, Optional

from validataclass.validators import Validator
from .defaults import Default, NoDefault
Expand All @@ -25,21 +25,22 @@ def validataclass_field(
**kwargs
):
"""
Define a dataclass field compatible with DataclassValidator.
Defines a dataclass field compatible with DataclassValidator.
Wraps the regular `dataclasses.field()` function, but has special parameters to add validator metadata to the 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. It
is then converted to a direct value (or factory) if neccessary 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.
- `default` is handled by this function to set metadata. It can be either a direct value or a `Default` object.
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 (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()`
"""
# If metadata is specified as argument, use it as the base for the field's metadata
if metadata is None:
Expand All @@ -49,7 +50,10 @@ def validataclass_field(
if 'init' in kwargs:
raise ValueError('Keyword argument "init" is not allowed in validator field.')
if 'default_factory' in kwargs:
raise ValueError('Keyword argument "default_factory" is not allowed in validator field (use default=DefaultFactory(...) instead).')
raise ValueError(
'Keyword argument "default_factory" is not allowed in validator field (use default=DefaultFactory(...) '
'instead).'
)

# Add validator metadata
metadata['validator'] = validator
Expand Down Expand Up @@ -85,4 +89,7 @@ def _raise_field_required(name: 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')
raise TypeError(
f"Missing required keyword-only argument: '{name}'"
if name else 'Missing required keyword-only argument'
)
11 changes: 7 additions & 4 deletions src/validataclass/dataclasses/validataclass_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def to_dict(self, *, keep_unset_values: bool = False) -> dict:
Filters out all fields with `UnsetValue`, unless the optional parameter `keep_unset_values` is True.
Parameters:
keep_unset_values: If true, fields with value `UnsetValue` are NOT removed from the dictionary (default: False)
`keep_unset_values`: If true, keep fields with value `UnsetValue` in the dictionary (default: False)
"""
data = dataclasses.asdict(self) # noqa

Expand All @@ -47,16 +47,19 @@ def to_dict(self, *, keep_unset_values: bool = False) -> dict:
@classmethod
def create_with_defaults(cls, **kwargs):
"""
(Deprecated.) Creates an object of the dataclass (with its default values).
(Deprecated.)
Creates an object of the dataclass (with its default values).
Since version 0.6.0, this method is no longer necessary and therefore deprecated. You can now use the regular
dataclass constructor, e.g. `MyDataclass(foo=42)` instead of `MyDataclass.create_with_defaults(foo=42)`.
This method will be removed in a future version (presumably in version 1.0.0).
"""
warnings.warn(
"create_with_defaults() is deprecated and will be removed in a future version. To instantiate a validataclass, "
"you can now use the regular constructor, e.g. `MyDataclass(...)` instead of `MyDataclass.create_with_defaults(...)`.",
"create_with_defaults() is deprecated and will be removed in a future version. To instantiate a "
"validataclass, you can now use the regular constructor, e.g. `MyDataclass(...)` instead of "
"`MyDataclass.create_with_defaults(...)`.",
DeprecationWarning
)
return cls(**kwargs) # noqa
22 changes: 13 additions & 9 deletions src/validataclass/exceptions/base_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ class ValidationError(Exception):
"""
Exception that is raised by validators if the input data is not valid. Can be subclassed for specific errors.
Contains a string error code (usually in snake_case) to describe the error that can be used by frontends to generate human readable
error messages. Optionally it can contain additional fields for further information, e.g. for an 'invalid_length' error there could
be fields like 'min' and 'max' to tell the client what length an input string is supposed to have. Exceptions for combound validators
(like `ListValidator` and `DictValidator`) could also contain nested exceptions.
Contains a string error code (usually in snake_case) to describe the error that can be used by frontends to generate
human readable error messages. Optionally it can contain additional fields for further information, e.g. for an
`invalid_length` error there could be fields like `min` and `max` to tell the client what length an input string is
supposed to have. Exceptions for compound validators (like `ListValidator` and `DictValidator`) could also contain
nested exceptions.
The optional 'reason' attribute can be used to further describe an error with a human readable string (e.g. if some input is only
invalid under certain conditions and the error code alone does not make enough sense, for example a 'required_field' error on a field
that usually is optional could have a 'reason' string like "Field is required when $someOtherField is defined."
The optional `reason` attribute can be used to further describe an error with a human readable string. For example,
if an optional field is required under certain conditions (e.g. when a certain other field has a value), the
`required_field` error could have a `reason` string like `This field is required when [some_other_field] is set.`.
Use `exception.to_dict()` to get a dictionary suitable for generating JSON responses.
"""
Expand All @@ -47,7 +48,10 @@ def __str__(self):
def _get_repr_dict(self) -> Dict[str, str]:
"""
Returns a dictionary representing the error fields as strings (e.g. by applying `repr()` on the values).
Used by `__repr__` to generate a string representation of the form "ExampleValidationError(code='foo', reason='foo', ...)".
Used by `__repr__` to generate a string representation of the form:
`ExampleValidationError(code='foo', reason='foo', ...)`
The default implementation calls `to_dict()` and applies `repr()` on all values.
"""
return {
Expand All @@ -56,7 +60,7 @@ def _get_repr_dict(self) -> Dict[str, str]:

def to_dict(self) -> dict:
"""
Generate a dictionary containing error information, suitable as response to the user.
Generates a dictionary containing error information, suitable as response to the user.
May be overridden by subclasses to extend the dictionary.
"""
reason = {'reason': self.reason} if self.reason is not None else {}
Expand Down
Loading

0 comments on commit ca53a4b

Please sign in to comment.