Skip to content

Commit ed2b4c7

Browse files
authored
[dataclasses plugin] Support kw_only=True (#10867)
Fixes #10865
1 parent c90026b commit ed2b4c7

File tree

3 files changed

+189
-10
lines changed

3 files changed

+189
-10
lines changed

mypy/plugins/dataclasses.py

+50-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing_extensions import Final
55

66
from mypy.nodes import (
7-
ARG_OPT, ARG_POS, MDEF, Argument, AssignmentStmt, CallExpr,
7+
ARG_OPT, ARG_NAMED, ARG_NAMED_OPT, ARG_POS, MDEF, Argument, AssignmentStmt, CallExpr,
88
Context, Expression, JsonDict, NameExpr, RefExpr,
99
SymbolTableNode, TempNode, TypeInfo, Var, TypeVarExpr, PlaceholderNode
1010
)
@@ -36,6 +36,7 @@ def __init__(
3636
column: int,
3737
type: Optional[Type],
3838
info: TypeInfo,
39+
kw_only: bool,
3940
) -> None:
4041
self.name = name
4142
self.is_in_init = is_in_init
@@ -45,13 +46,21 @@ def __init__(
4546
self.column = column
4647
self.type = type
4748
self.info = info
49+
self.kw_only = kw_only
4850

4951
def to_argument(self) -> Argument:
52+
arg_kind = ARG_POS
53+
if self.kw_only and self.has_default:
54+
arg_kind = ARG_NAMED_OPT
55+
elif self.kw_only and not self.has_default:
56+
arg_kind = ARG_NAMED
57+
elif not self.kw_only and self.has_default:
58+
arg_kind = ARG_OPT
5059
return Argument(
5160
variable=self.to_var(),
5261
type_annotation=self.type,
5362
initializer=None,
54-
kind=ARG_OPT if self.has_default else ARG_POS,
63+
kind=arg_kind,
5564
)
5665

5766
def to_var(self) -> Var:
@@ -67,13 +76,16 @@ def serialize(self) -> JsonDict:
6776
'line': self.line,
6877
'column': self.column,
6978
'type': self.type.serialize(),
79+
'kw_only': self.kw_only,
7080
}
7181

7282
@classmethod
7383
def deserialize(
7484
cls, info: TypeInfo, data: JsonDict, api: SemanticAnalyzerPluginInterface
7585
) -> 'DataclassAttribute':
7686
data = data.copy()
87+
if data.get('kw_only') is None:
88+
data['kw_only'] = False
7789
typ = deserialize_and_fixup_type(data.pop('type'), api)
7890
return cls(type=typ, info=info, **data)
7991

@@ -122,7 +134,8 @@ def transform(self) -> None:
122134
add_method(
123135
ctx,
124136
'__init__',
125-
args=[attr.to_argument() for attr in attributes if attr.is_in_init],
137+
args=[attr.to_argument() for attr in attributes if attr.is_in_init
138+
and not self._is_kw_only_type(attr.type)],
126139
return_type=NoneType(),
127140
)
128141

@@ -211,6 +224,7 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
211224
cls = self._ctx.cls
212225
attrs: List[DataclassAttribute] = []
213226
known_attrs: Set[str] = set()
227+
kw_only = _get_decorator_bool_argument(ctx, 'kw_only', False)
214228
for stmt in cls.defs.body:
215229
# Any assignment that doesn't use the new type declaration
216230
# syntax can be ignored out of hand.
@@ -247,6 +261,9 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
247261
is_init_var = True
248262
node.type = node_type.args[0]
249263

264+
if self._is_kw_only_type(node_type):
265+
kw_only = True
266+
250267
has_field_call, field_args = _collect_field_args(stmt.rvalue)
251268

252269
is_in_init_param = field_args.get('init')
@@ -270,6 +287,13 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
270287
# on self in the generated __init__(), not in the class body.
271288
sym.implicit = True
272289

290+
is_kw_only = kw_only
291+
# Use the kw_only field arg if it is provided. Otherwise use the
292+
# kw_only value from the decorator parameter.
293+
field_kw_only_param = field_args.get('kw_only')
294+
if field_kw_only_param is not None:
295+
is_kw_only = bool(ctx.api.parse_bool(field_kw_only_param))
296+
273297
known_attrs.add(lhs.name)
274298
attrs.append(DataclassAttribute(
275299
name=lhs.name,
@@ -280,6 +304,7 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
280304
column=stmt.column,
281305
type=sym.type,
282306
info=cls.info,
307+
kw_only=is_kw_only,
283308
))
284309

285310
# Next, collect attributes belonging to any class in the MRO
@@ -314,15 +339,18 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
314339
super_attrs.append(attr)
315340
break
316341
all_attrs = super_attrs + all_attrs
342+
all_attrs.sort(key=lambda a: a.kw_only)
317343

318344
# Ensure that arguments without a default don't follow
319345
# arguments that have a default.
320346
found_default = False
347+
# Ensure that the KW_ONLY sentinel is only provided once
348+
found_kw_sentinel = False
321349
for attr in all_attrs:
322-
# If we find any attribute that is_in_init but that
350+
# If we find any attribute that is_in_init, not kw_only, and that
323351
# doesn't have a default after one that does have one,
324352
# then that's an error.
325-
if found_default and attr.is_in_init and not attr.has_default:
353+
if found_default and attr.is_in_init and not attr.has_default and not attr.kw_only:
326354
# If the issue comes from merging different classes, report it
327355
# at the class definition point.
328356
context = (Context(line=attr.line, column=attr.column) if attr in attrs
@@ -333,6 +361,14 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
333361
)
334362

335363
found_default = found_default or (attr.has_default and attr.is_in_init)
364+
if found_kw_sentinel and self._is_kw_only_type(attr.type):
365+
context = (Context(line=attr.line, column=attr.column) if attr in attrs
366+
else ctx.cls)
367+
ctx.api.fail(
368+
'There may not be more than one field with the KW_ONLY type',
369+
context,
370+
)
371+
found_kw_sentinel = found_kw_sentinel or self._is_kw_only_type(attr.type)
336372

337373
return all_attrs
338374

@@ -372,6 +408,15 @@ def _propertize_callables(self, attributes: List[DataclassAttribute]) -> None:
372408
var._fullname = info.fullname + '.' + var.name
373409
info.names[var.name] = SymbolTableNode(MDEF, var)
374410

411+
def _is_kw_only_type(self, node: Optional[Type]) -> bool:
412+
"""Checks if the type of the node is the KW_ONLY sentinel value."""
413+
if node is None:
414+
return False
415+
node_type = get_proper_type(node)
416+
if not isinstance(node_type, Instance):
417+
return False
418+
return node_type.type.fullname == 'dataclasses.KW_ONLY'
419+
375420

376421
def dataclass_class_maker_callback(ctx: ClassDefContext) -> None:
377422
"""Hooks into the class typechecking process to add support for dataclasses.

test-data/unit/check-dataclasses.test

+133-1
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ class Person:
225225
name: str
226226
age: int = field(init=None) # E: No overload variant of "field" matches argument type "None" \
227227
# N: Possible overload variant: \
228-
# N: def field(*, init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ..., metadata: Optional[Mapping[str, Any]] = ...) -> Any \
228+
# N: def field(*, init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ..., metadata: Optional[Mapping[str, Any]] = ..., kw_only: bool = ...) -> Any \
229229
# N: <2 more non-matching overloads not shown>
230230

231231
[builtins fixtures/list.pyi]
@@ -311,6 +311,138 @@ class Application:
311311

312312
[builtins fixtures/list.pyi]
313313

314+
[case testDataclassesOrderingKwOnly]
315+
# flags: --python-version 3.10
316+
from dataclasses import dataclass
317+
318+
@dataclass(kw_only=True)
319+
class Application:
320+
name: str = 'Unnamed'
321+
rating: int
322+
323+
Application(rating=5)
324+
Application(name='name', rating=5)
325+
Application() # E: Missing named argument "rating" for "Application"
326+
Application('name') # E: Too many positional arguments for "Application" # E: Missing named argument "rating" for "Application"
327+
Application('name', 123) # E: Too many positional arguments for "Application"
328+
Application('name', rating=123) # E: Too many positional arguments for "Application"
329+
Application(name=123, rating='name') # E: Argument "name" to "Application" has incompatible type "int"; expected "str" # E: Argument "rating" to "Application" has incompatible type "str"; expected "int"
330+
Application(rating='name', name=123) # E: Argument "rating" to "Application" has incompatible type "str"; expected "int" # E: Argument "name" to "Application" has incompatible type "int"; expected "str"
331+
332+
[builtins fixtures/list.pyi]
333+
334+
[case testDataclassesOrderingKwOnlyOnField]
335+
# flags: --python-version 3.10
336+
from dataclasses import dataclass, field
337+
338+
@dataclass
339+
class Application:
340+
name: str = 'Unnamed'
341+
rating: int = field(kw_only=True)
342+
343+
Application(rating=5)
344+
Application('name', rating=123)
345+
Application(name='name', rating=5)
346+
Application() # E: Missing named argument "rating" for "Application"
347+
Application('name') # E: Missing named argument "rating" for "Application"
348+
Application('name', 123) # E: Too many positional arguments for "Application"
349+
Application(123, rating='name') # E: Argument 1 to "Application" has incompatible type "int"; expected "str" # E: Argument "rating" to "Application" has incompatible type "str"; expected "int"
350+
351+
[builtins fixtures/list.pyi]
352+
353+
[case testDataclassesOrderingKwOnlyOnFieldFalse]
354+
# flags: --python-version 3.10
355+
from dataclasses import dataclass, field
356+
357+
@dataclass
358+
class Application:
359+
name: str = 'Unnamed'
360+
rating: int = field(kw_only=False) # E: Attributes without a default cannot follow attributes with one
361+
362+
Application(name='name', rating=5)
363+
Application('name', 123)
364+
Application('name', rating=123)
365+
Application() # E: Missing positional argument "name" in call to "Application"
366+
Application('name') # E: Too few arguments for "Application"
367+
368+
[builtins fixtures/list.pyi]
369+
370+
[case testDataclassesOrderingKwOnlyWithSentinel]
371+
# flags: --python-version 3.10
372+
from dataclasses import dataclass, KW_ONLY
373+
374+
@dataclass
375+
class Application:
376+
_: KW_ONLY
377+
name: str = 'Unnamed'
378+
rating: int
379+
380+
Application(rating=5)
381+
Application(name='name', rating=5)
382+
Application() # E: Missing named argument "rating" for "Application"
383+
Application('name') # E: Too many positional arguments for "Application" # E: Missing named argument "rating" for "Application"
384+
Application('name', 123) # E: Too many positional arguments for "Application"
385+
Application('name', rating=123) # E: Too many positional arguments for "Application"
386+
387+
[builtins fixtures/list.pyi]
388+
389+
[case testDataclassesOrderingKwOnlyWithSentinelAndFieldOverride]
390+
# flags: --python-version 3.10
391+
from dataclasses import dataclass, field, KW_ONLY
392+
393+
@dataclass
394+
class Application:
395+
_: KW_ONLY
396+
name: str = 'Unnamed'
397+
rating: int = field(kw_only=False) # E: Attributes without a default cannot follow attributes with one
398+
399+
Application(name='name', rating=5)
400+
Application() # E: Missing positional argument "name" in call to "Application"
401+
Application('name') # E: Too many positional arguments for "Application" # E: Too few arguments for "Application"
402+
Application('name', 123) # E: Too many positional arguments for "Application"
403+
Application('name', rating=123) # E: Too many positional arguments for "Application"
404+
405+
[builtins fixtures/list.pyi]
406+
407+
[case testDataclassesOrderingKwOnlyWithSentinelAndSubclass]
408+
# flags: --python-version 3.10
409+
from dataclasses import dataclass, field, KW_ONLY
410+
411+
@dataclass
412+
class Base:
413+
x: str
414+
_: KW_ONLY
415+
y: int = 0
416+
w: int = 1
417+
418+
@dataclass
419+
class D(Base):
420+
z: str
421+
a: str = "a"
422+
423+
D("Hello", "World")
424+
D(x="Hello", z="World")
425+
D("Hello", "World", y=1, w=2, a="b")
426+
D("Hello") # E: Missing positional argument "z" in call to "D"
427+
D() # E: Missing positional arguments "x", "z" in call to "D"
428+
D(123, "World") # E: Argument 1 to "D" has incompatible type "int"; expected "str"
429+
D("Hello", False) # E: Argument 2 to "D" has incompatible type "bool"; expected "str"
430+
D(123, False) # E: Argument 1 to "D" has incompatible type "int"; expected "str" # E: Argument 2 to "D" has incompatible type "bool"; expected "str"
431+
432+
[case testDataclassesOrderingKwOnlyWithMultipleSentinel]
433+
# flags: --python-version 3.10
434+
from dataclasses import dataclass, field, KW_ONLY
435+
436+
@dataclass
437+
class Base:
438+
x: str
439+
_: KW_ONLY
440+
y: int = 0
441+
__: KW_ONLY # E: There may not be more than one field with the KW_ONLY type
442+
w: int = 1
443+
444+
[builtins fixtures/list.pyi]
445+
314446
[case testDataclassesClassmethods]
315447
# flags: --python-version 3.7
316448
from dataclasses import dataclass

test-data/unit/lib-stub/dataclasses.pyi

+6-4
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,28 @@ _T = TypeVar('_T')
55
class InitVar(Generic[_T]):
66
...
77

8+
class KW_ONLY: ...
89

910
@overload
1011
def dataclass(_cls: Type[_T]) -> Type[_T]: ...
1112

1213
@overload
1314
def dataclass(*, init: bool = ..., repr: bool = ..., eq: bool = ..., order: bool = ...,
14-
unsafe_hash: bool = ..., frozen: bool = ...) -> Callable[[Type[_T]], Type[_T]]: ...
15+
unsafe_hash: bool = ..., frozen: bool = ..., match_args: bool = ...,
16+
kw_only: bool = ..., slots: bool = ...) -> Callable[[Type[_T]], Type[_T]]: ...
1517

1618

1719
@overload
1820
def field(*, default: _T,
1921
init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ...,
20-
metadata: Optional[Mapping[str, Any]] = ...) -> _T: ...
22+
metadata: Optional[Mapping[str, Any]] = ..., kw_only: bool = ...,) -> _T: ...
2123

2224
@overload
2325
def field(*, default_factory: Callable[[], _T],
2426
init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ...,
25-
metadata: Optional[Mapping[str, Any]] = ...) -> _T: ...
27+
metadata: Optional[Mapping[str, Any]] = ..., kw_only: bool = ...,) -> _T: ...
2628

2729
@overload
2830
def field(*,
2931
init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ...,
30-
metadata: Optional[Mapping[str, Any]] = ...) -> Any: ...
32+
metadata: Optional[Mapping[str, Any]] = ..., kw_only: bool = ...,) -> Any: ...

0 commit comments

Comments
 (0)