Skip to content

Commit b9c377e

Browse files
authored
Merge pull request #140 from binary-butterfly/mypy-plugin
Implement mypy plugin for type checking validataclasses
2 parents d726a61 + d7a1e7d commit b9c377e

31 files changed

Lines changed: 2530 additions & 136 deletions

.coveragerc

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,8 @@ omit =
1313
show_missing = True
1414
skip_empty = True
1515
skip_covered = True
16-
exclude_lines =
17-
pragma: no ?cover
16+
exclude_also =
1817
@abstractmethod
19-
@overload
20-
if TYPE_CHECKING:
2118

2219
[html]
2320
directory = reports/coverage_html/

.github/workflows/tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
name: Unit tests
44

5+
# TODO: Remove dev-mypy after merging it into main.
56
on:
67
push:
78
branches:
89
- main
10+
- dev-mypy
911
pull_request:
1012
branches:
1113
- main
14+
- dev-mypy
1215

1316
jobs:
1417
test:

docs/05-dataclasses.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,10 @@ default values for fields.
342342

343343
### Setting defaults with `validataclass_field()`
344344

345-
To set a default value with `validataclass_field()`, you simply specify the `default` parameter. This parameter can be
346-
set either to a special validataclass "default object" (which we will explain in a moment) or directly to a value.
345+
To set a default value with `validataclass_field()`, you need to set the `default` parameter to a "default object".
346+
These are special validataclass objects that will be explained in more detail in a moment.
347+
348+
Using raw default values instead of default objects has been deprecated in version 0.12.0.
347349

348350
**Example:**
349351

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

356358
@dataclass
357359
class ExampleDataclass:
358-
# The following fields are equivalent
359-
field_a: int = validataclass_field(IntegerValidator(), default=42) # Specify default as direct value
360-
field_b: int = validataclass_field(IntegerValidator(), default=Default(42)) # Specify default using a Default object
360+
# Field with integer default
361+
field1: int = validataclass_field(IntegerValidator(), default=Default(42))
362+
363+
# Field that defaults to None
364+
field2: int | None = validataclass_field(IntegerValidator(), default=Default(None))
361365
```
362366

363367

pyproject.toml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ files = ["src/", "tests/"]
1515
mypy_path = "src/"
1616
explicit_package_bases = true
1717

18+
# Enable mypy plugins
19+
plugins = [
20+
# Our custom mypy plugin for type checking validataclasses
21+
"validataclass.mypy.plugin",
22+
23+
# Plugin to type check mypy plugins (only for plugin development)
24+
"mypy.plugins.proper_plugin",
25+
]
26+
1827
# Enable strict type checking
1928
strict = true
2029

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

4250
# Don't enforce typed definitions in tests, this is a lot of unnecessary work (most parameters would be Any anyway).
4351
allow_untyped_defs = true
44-
45-
# TODO: This is the main issue with mypy and validataclass right now.
46-
# Defining dataclasses with validators using the @validataclass decorator, like `some_field: str = StringValidator()`,
47-
# will cause "Incompatible types in assignment" errors. Until we find a way to solve this, ignore this error for now.
48-
disable_error_code = "assignment"

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
addopts =
33
-ra
44
--import-mode=importlib
5+
--cov-config=.coveragerc
56
--cov-context=test
67
--cov-report=
78
--mypy-ini-file=tests/mypy/pytest_mypy.ini
89
--mypy-only-local-stub
10+
# TODO: This is needed to include the mypy plugin in coverage. However the docs say:
11+
# TODO "Useful for debugging, will create problems with import cache" - better solution?
12+
--mypy-same-process
913

1014
testpaths = tests
1115
python_files = *_test.py *Test.py

run_mypy.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
validataclass
3+
Copyright (c) 2026, binary butterfly GmbH and contributors
4+
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
5+
"""
6+
7+
# Helper script that wraps running mypy for easier debugging in PyCharm.
8+
9+
import sys
10+
from mypy import api
11+
12+
# Set this to True to disable the mypy cache (or pass --no-incremental via CLI args)
13+
disable_mypy_cache = False
14+
15+
# Run mypy
16+
mypy_args = ['--show-traceback']
17+
if disable_mypy_cache:
18+
mypy_args.append('--no-incremental')
19+
20+
result = api.run(mypy_args + sys.argv[1:])
21+
22+
if result[0]:
23+
print('\nType checking report:\n')
24+
print(result[0]) # stdout
25+
26+
if result[1]:
27+
print('\nError report:\n')
28+
print(result[1]) # stderr
29+
30+
print('\nExit status:', result[2])

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ where = src
4141
testing =
4242
pytest ~= 9.0
4343
pytest-cov ~= 7.0
44-
pytest-mypy-plugins ~= 3.2
44+
pytest-mypy-plugins ~= 3.3
4545
coverage ~= 7.13
4646
flake8 ~= 7.3
4747
mypy ~= 1.19

src/validataclass/dataclasses/defaults.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
'DefaultFactory',
2121
'DefaultUnset',
2222
'NoDefault',
23+
'_NoDefaultType',
2324
]
2425

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

190191

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

228229

229230
# Create sentinel object NoDefault, redefine __new__ to always return the same instance, and delete temporary class
230-
NoDefault = _NoDefault()
231-
_NoDefault.__new__ = lambda cls: NoDefault # type: ignore[assignment, method-assign, return-value]
232-
del _NoDefault
231+
NoDefault = _NoDefaultType()
232+
_NoDefaultType.__new__ = lambda cls: NoDefault # type: ignore[assignment, method-assign, return-value]

src/validataclass/dataclasses/validataclass.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class ExampleDataclass:
7575
# (Same as example_field1)
7676
example_field3: str = validataclass_field(StringValidator())
7777
# (Same as example_field2)
78-
example_field4: str = validataclass_field(StringValidator(), default='not set')
78+
example_field4: str = validataclass_field(StringValidator(), default=Default('not set'))
7979
# Post-init field without validator
8080
post_init_field: int = field(init=False, default=0)
8181
```
@@ -112,7 +112,7 @@ def _prepare_dataclass_metadata(cls: type[_T]) -> None:
112112
# In case of a subclassed validataclass, get the already existing fields
113113
existing_validator_fields = _get_existing_validator_fields(cls)
114114

115-
# Get class annotations
115+
# Get annotations of this class (ignores base classes)
116116
cls_annotations = get_annotations(cls)
117117

118118
# Check for fields/attributes that have validators defined but missing a type annotation (most likely an error)

src/validataclass/dataclasses/validataclass_field.py

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,65 @@
55
"""
66

77
import dataclasses
8+
import warnings
89
from typing import Any
910

11+
from typing_extensions import TypeVar, deprecated, overload
12+
1013
from validataclass.validators import Validator
11-
from .defaults import BaseDefault, Default, NoDefault
14+
from .defaults import BaseDefault, Default, NoDefault, _NoDefaultType
1215

1316
__all__ = [
1417
'validataclass_field',
1518
]
1619

20+
T_Validated = TypeVar('T_Validated')
21+
T_Default = TypeVar('T_Default')
22+
23+
24+
# NOTE: Actual return type of this function is `Field[T_Validated | T_Default]`. However, we will pretend here that it
25+
# returns `T_Validated | T_Default` instead, to help type checkers understand what happens within the context of a
26+
# dataclass. This is the same thing that typeshed does.
27+
# (Using this function outside the context of a dataclass usually doesn't make a lot of sense.)
28+
29+
@overload
30+
def validataclass_field(
31+
validator: Validator[T_Validated],
32+
*,
33+
default: _NoDefaultType = NoDefault,
34+
metadata: dict[str, Any] | None = None,
35+
**kwargs: Any,
36+
) -> T_Validated:
37+
...
38+
39+
40+
@overload
41+
def validataclass_field(
42+
validator: Validator[T_Validated],
43+
*,
44+
default: BaseDefault[T_Default],
45+
metadata: dict[str, Any] | None = None,
46+
**kwargs: Any,
47+
) -> T_Validated | T_Default:
48+
...
49+
50+
51+
@overload
52+
@deprecated('Use default objects instead of raw defaults or dataclasses.MISSING')
53+
def validataclass_field(
54+
validator: Validator[T_Validated],
55+
*,
56+
default: Any,
57+
metadata: dict[str, Any] | None = None,
58+
**kwargs: Any,
59+
) -> Any:
60+
...
61+
1762

1863
def validataclass_field(
19-
validator: Validator[Any],
20-
default: Any = NoDefault,
64+
validator: Validator[T_Validated],
2165
*,
66+
default: BaseDefault[T_Default] | Any = NoDefault,
2267
metadata: dict[str, Any] | None = None,
2368
**kwargs: Any,
2469
) -> Any:
@@ -29,15 +74,15 @@ def validataclass_field(
2974
3075
Additional keyword arguments will be passed to `dataclasses.field()`, with some exceptions:
3176
32-
- `default` is handled by this function to set metadata. It can be either a direct value or a validataclass default
33-
object, i.e. an object of a subclass of `BaseDefault` (e.g. `Default`, `DefaultFactory`, `NoDefault`).
34-
It is then converted to a direct value (or factory) if necessary and passed to `dataclasses.field()`.
77+
- `default` is handled by this function to set metadata. It must be a validataclass default object (e.g. an instance
78+
of `Default` or `DefaultFactory`, or the special value `NoDefault`). Using raw default values is **deprecated**
79+
and won't be supported in the future. The same applies to the `dataclasses.MISSING` sentinel.
3580
- `default_factory` is not allowed. Use `default=DefaultFactory(...)` instead.
3681
- `init` is not allowed. To create a non-init field, use `dataclasses.field(init=False)` instead.
3782
3883
Parameters:
3984
`validator`: Validator to use for validating the field (subclass of `Validator`).
40-
`default`: Default value when the field is missing in the input data (any value or subclass of `BaseDefault`).
85+
`default`: Default value when the field is missing (subclass of `BaseDefault`, defaults to `NoDefault`).
4186
`metadata`: Base dictionary for field metadata, gets merged with the metadata generated by this function.
4287
`**kwargs`: Additional keyword arguments that are passed to `dataclasses.field()`.
4388
"""
@@ -47,21 +92,32 @@ def validataclass_field(
4792

4893
# Check for incompatible keyword arguments
4994
if 'init' in kwargs:
50-
raise ValueError('Keyword argument "init" is not allowed in validator field.')
95+
raise ValueError('Keyword argument "init" is not allowed in validataclass_field.')
5196
if 'default_factory' in kwargs:
5297
raise ValueError(
53-
'Keyword argument "default_factory" is not allowed in validator field (use default=DefaultFactory(...) '
98+
'Keyword argument "default_factory" is not allowed in validataclass_field (use default=DefaultFactory(...) '
5499
'instead).'
55100
)
56101

57102
# Add validator metadata
58103
metadata['validator'] = validator
59104

60105
# Ensure default is a validataclass default object (any subclass of BaseDefault)
106+
# TODO: Remove deprecated behaviour for raw defaults and dataclasses.MISSING in a future version.
61107
if default is dataclasses.MISSING:
108+
warnings.warn(
109+
'Using validataclass_field() with `default=dataclasses.MISSING` is deprecated. '
110+
'Please use `default=NoDefault` instead.',
111+
DeprecationWarning
112+
)
62113
default = NoDefault
63114
elif not isinstance(default, BaseDefault):
64115
# Wrap value in a validataclass default object
116+
warnings.warn(
117+
'Using validataclass_field() with raw default values is deprecated. '
118+
'Please use default objects instead (e.g. `default=Default(...)`).',
119+
DeprecationWarning
120+
)
65121
default = Default(default)
66122

67123
if default is not NoDefault:

0 commit comments

Comments
 (0)