Skip to content
Merged
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
5 changes: 1 addition & 4 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@ omit =
show_missing = True
skip_empty = True
skip_covered = True
exclude_lines =
pragma: no ?cover
exclude_also =
@abstractmethod
@overload
if TYPE_CHECKING:

[html]
directory = reports/coverage_html/
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

name: Unit tests

# TODO: Remove dev-mypy after merging it into main.
on:
push:
branches:
- main
- dev-mypy
pull_request:
branches:
- main
- dev-mypy

jobs:
test:
Expand Down
14 changes: 9 additions & 5 deletions docs/05-dataclasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,10 @@ 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 special validataclass "default object" (which we will explain in a moment) or directly to a value.
To set a default value with `validataclass_field()`, you need to set the `default` parameter to a "default object".
These are special validataclass objects that will be explained in more detail in a moment.

Using raw default values instead of default objects has been deprecated in version 0.12.0.

**Example:**

Expand All @@ -355,9 +357,11 @@ from validataclass.validators import IntegerValidator

@dataclass
class ExampleDataclass:
# The following fields are equivalent
field_a: int = validataclass_field(IntegerValidator(), default=42) # Specify default as direct value
field_b: int = validataclass_field(IntegerValidator(), default=Default(42)) # Specify default using a Default object
# Field with integer default
field1: int = validataclass_field(IntegerValidator(), default=Default(42))

# Field that defaults to None
field2: int | None = validataclass_field(IntegerValidator(), default=Default(None))
```


Expand Down
17 changes: 10 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ files = ["src/", "tests/"]
mypy_path = "src/"
explicit_package_bases = true

# Enable mypy plugins
plugins = [
# Our custom mypy plugin for type checking validataclasses
"validataclass.mypy.plugin",

# Plugin to type check mypy plugins (only for plugin development)
"mypy.plugins.proper_plugin",
]

# Enable strict type checking
strict = true

Expand All @@ -26,8 +35,7 @@ enable_error_code = [
"deprecated",
"explicit-override",
"ignore-without-code",
# TODO: Maybe enable this is in the future (when we have the mypy plugin)
# "mutable-override",
"mutable-override",
"possibly-undefined",
"redundant-expr",
"redundant-self",
Expand All @@ -41,8 +49,3 @@ module = 'tests.*'

# Don't enforce typed definitions in tests, this is a lot of unnecessary work (most parameters would be Any anyway).
allow_untyped_defs = true

# TODO: This is the main issue with mypy and validataclass right now.
# Defining dataclasses with validators using the @validataclass decorator, like `some_field: str = StringValidator()`,
# will cause "Incompatible types in assignment" errors. Until we find a way to solve this, ignore this error for now.
disable_error_code = "assignment"
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
addopts =
-ra
--import-mode=importlib
--cov-config=.coveragerc
--cov-context=test
--cov-report=
--mypy-ini-file=tests/mypy/pytest_mypy.ini
--mypy-only-local-stub
# TODO: This is needed to include the mypy plugin in coverage. However the docs say:
# TODO "Useful for debugging, will create problems with import cache" - better solution?
--mypy-same-process

testpaths = tests
python_files = *_test.py *Test.py
Expand Down
30 changes: 30 additions & 0 deletions run_mypy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
validataclass
Copyright (c) 2026, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

# Helper script that wraps running mypy for easier debugging in PyCharm.

import sys
from mypy import api

# Set this to True to disable the mypy cache (or pass --no-incremental via CLI args)
disable_mypy_cache = False

# Run mypy
mypy_args = ['--show-traceback']
if disable_mypy_cache:
mypy_args.append('--no-incremental')

result = api.run(mypy_args + sys.argv[1:])

if result[0]:
print('\nType checking report:\n')
print(result[0]) # stdout

if result[1]:
print('\nError report:\n')
print(result[1]) # stderr

print('\nExit status:', result[2])
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ where = src
testing =
pytest ~= 9.0
pytest-cov ~= 7.0
pytest-mypy-plugins ~= 3.2
pytest-mypy-plugins ~= 3.3
coverage ~= 7.13
flake8 ~= 7.3
mypy ~= 1.19
8 changes: 4 additions & 4 deletions src/validataclass/dataclasses/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
'DefaultFactory',
'DefaultUnset',
'NoDefault',
'_NoDefaultType',
]

# Helper objects for setting default values for validator fields
Expand Down Expand Up @@ -189,7 +190,7 @@ def needs_factory(self) -> bool:


# Temporary class to create the NoDefault sentinel, class will be deleted afterwards
class _NoDefault(BaseDefault[Never]):
class _NoDefaultType(BaseDefault[Never]):
"""
Class for creating the sentinel object `NoDefault` which specifies that a field has no default value, i.e. the field
is required.
Expand Down Expand Up @@ -227,6 +228,5 @@ def __call__(self) -> Self:


# Create sentinel object NoDefault, redefine __new__ to always return the same instance, and delete temporary class
NoDefault = _NoDefault()
_NoDefault.__new__ = lambda cls: NoDefault # type: ignore[assignment, method-assign, return-value]
del _NoDefault
NoDefault = _NoDefaultType()
_NoDefaultType.__new__ = lambda cls: NoDefault # type: ignore[assignment, method-assign, return-value]
4 changes: 2 additions & 2 deletions src/validataclass/dataclasses/validataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class ExampleDataclass:
# (Same as example_field1)
example_field3: str = validataclass_field(StringValidator())
# (Same as example_field2)
example_field4: str = validataclass_field(StringValidator(), default='not set')
example_field4: str = validataclass_field(StringValidator(), default=Default('not set'))
# Post-init field without validator
post_init_field: int = field(init=False, default=0)
```
Expand Down Expand Up @@ -112,7 +112,7 @@ def _prepare_dataclass_metadata(cls: type[_T]) -> None:
# In case of a subclassed validataclass, get the already existing fields
existing_validator_fields = _get_existing_validator_fields(cls)

# Get class annotations
# Get annotations of this class (ignores base classes)
cls_annotations = get_annotations(cls)

# Check for fields/attributes that have validators defined but missing a type annotation (most likely an error)
Expand Down
74 changes: 65 additions & 9 deletions src/validataclass/dataclasses/validataclass_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,65 @@
"""

import dataclasses
import warnings
from typing import Any

from typing_extensions import TypeVar, deprecated, overload

from validataclass.validators import Validator
from .defaults import BaseDefault, Default, NoDefault
from .defaults import BaseDefault, Default, NoDefault, _NoDefaultType

__all__ = [
'validataclass_field',
]

T_Validated = TypeVar('T_Validated')
T_Default = TypeVar('T_Default')


# NOTE: Actual return type of this function is `Field[T_Validated | T_Default]`. However, we will pretend here that it
# returns `T_Validated | T_Default` instead, to help type checkers understand what happens within the context of a
# dataclass. This is the same thing that typeshed does.
# (Using this function outside the context of a dataclass usually doesn't make a lot of sense.)

@overload
def validataclass_field(
validator: Validator[T_Validated],
*,
default: _NoDefaultType = NoDefault,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> T_Validated:
...


@overload
def validataclass_field(
validator: Validator[T_Validated],
*,
default: BaseDefault[T_Default],
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> T_Validated | T_Default:
...


@overload
@deprecated('Use default objects instead of raw defaults or dataclasses.MISSING')
def validataclass_field(
validator: Validator[T_Validated],
*,
default: Any,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> Any:
...


def validataclass_field(
validator: Validator[Any],
default: Any = NoDefault,
validator: Validator[T_Validated],
*,
default: BaseDefault[T_Default] | Any = NoDefault,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> Any:
Expand All @@ -29,15 +74,15 @@ 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 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` is handled by this function to set metadata. It must be a validataclass default object (e.g. an instance
of `Default` or `DefaultFactory`, or the special value `NoDefault`). Using raw default values is **deprecated**
and won't be supported in the future. The same applies to the `dataclasses.MISSING` sentinel.
- `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 (subclass of `Validator`).
`default`: Default value when the field is missing in the input data (any value or subclass of `BaseDefault`).
`default`: Default value when the field is missing (subclass of `BaseDefault`, defaults to `NoDefault`).
`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()`.
"""
Expand All @@ -47,21 +92,32 @@ def validataclass_field(

# Check for incompatible keyword arguments
if 'init' in kwargs:
raise ValueError('Keyword argument "init" is not allowed in validator field.')
raise ValueError('Keyword argument "init" is not allowed in validataclass_field.')
if 'default_factory' in kwargs:
raise ValueError(
'Keyword argument "default_factory" is not allowed in validator field (use default=DefaultFactory(...) '
'Keyword argument "default_factory" is not allowed in validataclass_field (use default=DefaultFactory(...) '
'instead).'
)

# Add validator metadata
metadata['validator'] = validator

# Ensure default is a validataclass default object (any subclass of BaseDefault)
# TODO: Remove deprecated behaviour for raw defaults and dataclasses.MISSING in a future version.
if default is dataclasses.MISSING:
warnings.warn(
'Using validataclass_field() with `default=dataclasses.MISSING` is deprecated. '
'Please use `default=NoDefault` instead.',
DeprecationWarning
)
default = NoDefault
elif not isinstance(default, BaseDefault):
# Wrap value in a validataclass default object
warnings.warn(
'Using validataclass_field() with raw default values is deprecated. '
'Please use default objects instead (e.g. `default=Default(...)`).',
DeprecationWarning
)
default = Default(default)

if default is not NoDefault:
Expand Down
5 changes: 5 additions & 0 deletions src/validataclass/mypy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
validataclass
Copyright (c) 2026, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""
11 changes: 11 additions & 0 deletions src/validataclass/mypy/plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""
validataclass
Copyright (c) 2026, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

from .plugin import plugin

__all__ = [
'plugin',
]
29 changes: 29 additions & 0 deletions src/validataclass/mypy/plugin/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
validataclass
Copyright (c) 2026, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

from typing import Final

# Decorators that turn a class to a validataclass
# (Not Final so it can be modified by other plugins that add their own validataclass-style decorators.)
VALIDATACLASS_DECORATORS = {
'validataclass.dataclasses.validataclass.validataclass',
}

# Functions that create validataclass fields (must have similar signature as validataclass_field)
VALIDATACLASS_FIELD_FUNCS = {
'validataclass.dataclasses.validataclass_field.validataclass_field',
}

# Full name of base class for validators
VALIDATOR_BASE_CLASS: Final = 'validataclass.validators.validator.Validator'

# Full name of base class for default objects
FIELD_DEFAULT_BASE_CLASS: Final = 'validataclass.dataclasses.defaults.BaseDefault'

# Full and short name of the virtual field wrapper function
# NOTE: This function does not actually exist in the code, it only exists for mypy.
VIRTUAL_FIELD_WRAPPER_FUNC: Final = 'validataclass.mypy._virtual_field_wrapper'
VIRTUAL_FIELD_WRAPPER_FUNC_NAME: Final = '_virtual_field_wrapper'
Loading