Skip to content

Commit 1eb9d4c

Browse files
authored
Support properties with setter type different from getter type (python#18510)
Fixes python#3004 Fixes python#11892 Fixes python#12892 Fixes python#14301 _Note:_ this PR should be reviewed with "hide whitespace" option (in couple long functions I replace huge `if x: ...` with `if not x: return; ...` to reduce indent level). The core logic is quite straightforward (almost trivial). The only couple things are: * We should be careful with binder (we simpy can't use it for properties with setter type different from getter type, since we don't know underlying property implementation) * We need to handle gracefully existing settable properties that are generated by plugins The tricky part is subclassing and protocols. The summary is as following: * For protocols I simply implement everything the "correct way", i.e. for settable attributes (whether it is a variable or a settable property) compare getter types covariantly and setter types contravariantly. The tricky part here is generating meaningful error messages that are also not too verbose. * For subclassing I cannot simply do the same, because there is a flag about covariant mutable override, that is off by default. So instead what I do is if either subclass node, or superclass node is a "custom property" (i.e. a property with setter type different from getter type), then I use the "correct way", otherwise the old logic (i.e. flag dependent check) is used. Two things that are not implemented are multiple inheritance, and new generic syntax (inferred variance). In these cases setter types are simply ignored. There is nothing conceptually difficult about these, I simply run out of steam (and the PR is getting big). I left `TODO`s in code for these. In most cases these will generate false negatives (and they are already kind of corner cases) so I think it is OK to postpone these indefinitely.
1 parent 3ced11a commit 1eb9d4c

15 files changed

+1077
-233
lines changed

mypy/checker.py

+356-194
Large diffs are not rendered by default.

mypy/checkmember.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,10 @@ def analyze_descriptor_access(
658658
if isinstance(descriptor_type, UnionType):
659659
# Map the access over union types
660660
return make_simplified_union(
661-
[analyze_descriptor_access(typ, mx) for typ in descriptor_type.items]
661+
[
662+
analyze_descriptor_access(typ, mx, assignment=assignment)
663+
for typ in descriptor_type.items
664+
]
662665
)
663666
elif not isinstance(descriptor_type, Instance):
664667
return orig_descriptor_type
@@ -776,7 +779,13 @@ def analyze_var(
776779
# Found a member variable.
777780
original_itype = itype
778781
itype = map_instance_to_supertype(itype, var.info)
779-
typ = var.type
782+
if var.is_settable_property and mx.is_lvalue:
783+
typ: Type | None = var.setter_type
784+
if typ is None and var.is_ready:
785+
# Existing synthetic properties may not set setter type. Fall back to getter.
786+
typ = var.type
787+
else:
788+
typ = var.type
780789
if typ:
781790
if isinstance(typ, PartialType):
782791
return mx.chk.handle_partial_var_type(typ, mx.is_lvalue, var, mx.context)
@@ -834,7 +843,10 @@ def analyze_var(
834843
if var.is_property:
835844
# A property cannot have an overloaded type => the cast is fine.
836845
assert isinstance(expanded_signature, CallableType)
837-
result = expanded_signature.ret_type
846+
if var.is_settable_property and mx.is_lvalue and var.setter_type is not None:
847+
result = expanded_signature.arg_types[0]
848+
else:
849+
result = expanded_signature.ret_type
838850
else:
839851
result = expanded_signature
840852
else:

mypy/errors.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
codes.OVERRIDE,
4040
}
4141

42-
allowed_duplicates: Final = ["@overload", "Got:", "Expected:"]
42+
allowed_duplicates: Final = ["@overload", "Got:", "Expected:", "Expected setter type:"]
4343

4444
BASE_RTD_URL: Final = "https://mypy.rtfd.io/en/stable/_refs.html#code"
4545

@@ -172,10 +172,12 @@ def __init__(
172172
*,
173173
filter_errors: bool | Callable[[str, ErrorInfo], bool] = False,
174174
save_filtered_errors: bool = False,
175+
filter_deprecated: bool = False,
175176
) -> None:
176177
self.errors = errors
177178
self._has_new_errors = False
178179
self._filter = filter_errors
180+
self._filter_deprecated = filter_deprecated
179181
self._filtered: list[ErrorInfo] | None = [] if save_filtered_errors else None
180182

181183
def __enter__(self) -> ErrorWatcher:
@@ -196,7 +198,8 @@ def on_error(self, file: str, info: ErrorInfo) -> bool:
196198
ErrorWatcher further down the stack and from being recorded by Errors
197199
"""
198200
if info.code == codes.DEPRECATED:
199-
return False
201+
# Deprecated is not a type error, so it is handled on opt-in basis here.
202+
return self._filter_deprecated
200203

201204
self._has_new_errors = True
202205
if isinstance(self._filter, bool):

mypy/fixup.py

+2
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ def visit_var(self, v: Var) -> None:
210210
v.info = self.current_info
211211
if v.type is not None:
212212
v.type.accept(self.type_fixer)
213+
if v.setter_type is not None:
214+
v.setter_type.accept(self.type_fixer)
213215

214216
def visit_type_alias(self, a: TypeAlias) -> None:
215217
a.target.accept(self.type_fixer)

mypy/messages.py

+61-11
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from mypy.subtypes import (
5757
IS_CLASS_OR_STATIC,
5858
IS_CLASSVAR,
59+
IS_EXPLICIT_SETTER,
5960
IS_SETTABLE,
6061
IS_VAR,
6162
find_member,
@@ -186,9 +187,13 @@ def filter_errors(
186187
*,
187188
filter_errors: bool | Callable[[str, ErrorInfo], bool] = True,
188189
save_filtered_errors: bool = False,
190+
filter_deprecated: bool = False,
189191
) -> ErrorWatcher:
190192
return ErrorWatcher(
191-
self.errors, filter_errors=filter_errors, save_filtered_errors=save_filtered_errors
193+
self.errors,
194+
filter_errors=filter_errors,
195+
save_filtered_errors=save_filtered_errors,
196+
filter_deprecated=filter_deprecated,
192197
)
193198

194199
def add_errors(self, errors: list[ErrorInfo]) -> None:
@@ -1164,6 +1169,20 @@ def overload_signature_incompatible_with_supertype(
11641169
note_template = 'Overload variants must be defined in the same order as they are in "{}"'
11651170
self.note(note_template.format(supertype), context, code=codes.OVERRIDE)
11661171

1172+
def incompatible_setter_override(
1173+
self, defn: Context, typ: Type, original_type: Type, base: TypeInfo
1174+
) -> None:
1175+
self.fail("Incompatible override of a setter type", defn, code=codes.OVERRIDE)
1176+
base_str, override_str = format_type_distinctly(original_type, typ, options=self.options)
1177+
self.note(
1178+
f' (base class "{base.name}" defined the type as {base_str},',
1179+
defn,
1180+
code=codes.OVERRIDE,
1181+
)
1182+
self.note(f" override has type {override_str})", defn, code=codes.OVERRIDE)
1183+
if is_subtype(typ, original_type):
1184+
self.note(" Setter types should behave contravariantly", defn, code=codes.OVERRIDE)
1185+
11671186
def signature_incompatible_with_supertype(
11681187
self,
11691188
name: str,
@@ -2201,22 +2220,34 @@ def report_protocol_problems(
22012220
):
22022221
type_name = format_type(subtype, self.options, module_names=True)
22032222
self.note(f"Following member(s) of {type_name} have conflicts:", context, code=code)
2204-
for name, got, exp in conflict_types[:MAX_ITEMS]:
2223+
for name, got, exp, is_lvalue in conflict_types[:MAX_ITEMS]:
22052224
exp = get_proper_type(exp)
22062225
got = get_proper_type(got)
2226+
setter_suffix = " setter type" if is_lvalue else ""
22072227
if not isinstance(exp, (CallableType, Overloaded)) or not isinstance(
22082228
got, (CallableType, Overloaded)
22092229
):
22102230
self.note(
2211-
"{}: expected {}, got {}".format(
2212-
name, *format_type_distinctly(exp, got, options=self.options)
2231+
"{}: expected{} {}, got {}".format(
2232+
name,
2233+
setter_suffix,
2234+
*format_type_distinctly(exp, got, options=self.options),
22132235
),
22142236
context,
22152237
offset=OFFSET,
22162238
code=code,
22172239
)
2240+
if is_lvalue and is_subtype(got, exp, options=self.options):
2241+
self.note(
2242+
"Setter types should behave contravariantly",
2243+
context,
2244+
offset=OFFSET,
2245+
code=code,
2246+
)
22182247
else:
2219-
self.note("Expected:", context, offset=OFFSET, code=code)
2248+
self.note(
2249+
"Expected{}:".format(setter_suffix), context, offset=OFFSET, code=code
2250+
)
22202251
if isinstance(exp, CallableType):
22212252
self.note(
22222253
pretty_callable(exp, self.options, skip_self=class_obj or is_module),
@@ -3029,12 +3060,12 @@ def get_missing_protocol_members(left: Instance, right: Instance, skip: list[str
30293060

30303061
def get_conflict_protocol_types(
30313062
left: Instance, right: Instance, class_obj: bool = False, options: Options | None = None
3032-
) -> list[tuple[str, Type, Type]]:
3063+
) -> list[tuple[str, Type, Type, bool]]:
30333064
"""Find members that are defined in 'left' but have incompatible types.
3034-
Return them as a list of ('member', 'got', 'expected').
3065+
Return them as a list of ('member', 'got', 'expected', 'is_lvalue').
30353066
"""
30363067
assert right.type.is_protocol
3037-
conflicts: list[tuple[str, Type, Type]] = []
3068+
conflicts: list[tuple[str, Type, Type, bool]] = []
30383069
for member in right.type.protocol_members:
30393070
if member in ("__init__", "__new__"):
30403071
continue
@@ -3044,10 +3075,29 @@ def get_conflict_protocol_types(
30443075
if not subtype:
30453076
continue
30463077
is_compat = is_subtype(subtype, supertype, ignore_pos_arg_names=True, options=options)
3047-
if IS_SETTABLE in get_member_flags(member, right):
3048-
is_compat = is_compat and is_subtype(supertype, subtype, options=options)
30493078
if not is_compat:
3050-
conflicts.append((member, subtype, supertype))
3079+
conflicts.append((member, subtype, supertype, False))
3080+
superflags = get_member_flags(member, right)
3081+
if IS_SETTABLE not in superflags:
3082+
continue
3083+
different_setter = False
3084+
if IS_EXPLICIT_SETTER in superflags:
3085+
set_supertype = find_member(member, right, left, is_lvalue=True)
3086+
if set_supertype and not is_same_type(set_supertype, supertype):
3087+
different_setter = True
3088+
supertype = set_supertype
3089+
if IS_EXPLICIT_SETTER in get_member_flags(member, left):
3090+
set_subtype = mypy.typeops.get_protocol_member(left, member, class_obj, is_lvalue=True)
3091+
if set_subtype and not is_same_type(set_subtype, subtype):
3092+
different_setter = True
3093+
subtype = set_subtype
3094+
if not is_compat and not different_setter:
3095+
# We already have this conflict listed, avoid duplicates.
3096+
continue
3097+
assert supertype is not None and subtype is not None
3098+
is_compat = is_subtype(supertype, subtype, options=options)
3099+
if not is_compat:
3100+
conflicts.append((member, subtype, supertype, different_setter))
30513101
return conflicts
30523102

30533103

mypy/nodes.py

+15
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,7 @@ class Var(SymbolNode):
977977
"_fullname",
978978
"info",
979979
"type",
980+
"setter_type",
980981
"final_value",
981982
"is_self",
982983
"is_cls",
@@ -1011,6 +1012,8 @@ def __init__(self, name: str, type: mypy.types.Type | None = None) -> None:
10111012
# TODO: Should be Optional[TypeInfo]
10121013
self.info = VAR_NO_INFO
10131014
self.type: mypy.types.Type | None = type # Declared or inferred type, or None
1015+
# The setter type for settable properties.
1016+
self.setter_type: mypy.types.CallableType | None = None
10141017
# Is this the first argument to an ordinary method (usually "self")?
10151018
self.is_self = False
10161019
# Is this the first argument to a classmethod (typically "cls")?
@@ -1076,6 +1079,7 @@ def serialize(self) -> JsonDict:
10761079
"name": self._name,
10771080
"fullname": self._fullname,
10781081
"type": None if self.type is None else self.type.serialize(),
1082+
"setter_type": None if self.setter_type is None else self.setter_type.serialize(),
10791083
"flags": get_flags(self, VAR_FLAGS),
10801084
}
10811085
if self.final_value is not None:
@@ -1087,7 +1091,18 @@ def deserialize(cls, data: JsonDict) -> Var:
10871091
assert data[".class"] == "Var"
10881092
name = data["name"]
10891093
type = None if data["type"] is None else mypy.types.deserialize_type(data["type"])
1094+
setter_type = (
1095+
None
1096+
if data["setter_type"] is None
1097+
else mypy.types.deserialize_type(data["setter_type"])
1098+
)
10901099
v = Var(name, type)
1100+
assert (
1101+
setter_type is None
1102+
or isinstance(setter_type, mypy.types.ProperType)
1103+
and isinstance(setter_type, mypy.types.CallableType)
1104+
)
1105+
v.setter_type = setter_type
10911106
v.is_ready = False # Override True default set in __init__
10921107
v._fullname = data["fullname"]
10931108
set_flags(v, data["flags"])

mypy/server/astdiff.py

+6
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ def snapshot_definition(node: SymbolNode | None, common: SymbolSnapshot) -> Symb
245245
impl = node
246246
elif isinstance(node, OverloadedFuncDef) and node.impl:
247247
impl = node.impl.func if isinstance(node.impl, Decorator) else node.impl
248+
setter_type = None
249+
if isinstance(node, OverloadedFuncDef) and node.items:
250+
first_item = node.items[0]
251+
if isinstance(first_item, Decorator) and first_item.func.is_property:
252+
setter_type = snapshot_optional_type(first_item.var.setter_type)
248253
is_trivial_body = impl.is_trivial_body if impl else False
249254
dataclass_transform_spec = find_dataclass_transform_spec(node)
250255
return (
@@ -258,6 +263,7 @@ def snapshot_definition(node: SymbolNode | None, common: SymbolSnapshot) -> Symb
258263
is_trivial_body,
259264
dataclass_transform_spec.serialize() if dataclass_transform_spec is not None else None,
260265
node.deprecated if isinstance(node, FuncDef) else None,
266+
setter_type, # multi-part properties are stored as OverloadedFuncDef
261267
)
262268
elif isinstance(node, Var):
263269
return ("Var", common, snapshot_optional_type(node.type), node.is_final)

mypy/server/astmerge.py

+1
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ def visit_enum_call_expr(self, node: EnumCallExpr) -> None:
330330
def visit_var(self, node: Var) -> None:
331331
node.info = self.fixup(node.info)
332332
self.fixup_type(node.type)
333+
self.fixup_type(node.setter_type)
333334
super().visit_var(node)
334335

335336
def visit_type_alias(self, node: TypeAlias) -> None:

0 commit comments

Comments
 (0)