Skip to content

Commit 09fc70f

Browse files
rchen152facebook-github-bot
authored andcommitted
Fix TypedDict against PartialTypedDict matching in is_subset_eq
Summary: * Forbid updating a read-only field. This is in the spec: https://typing.python.org/en/latest/spec/typeddict.html#update-method. * Do an is_subset_eq check on field types. Mypy and pyright disagree on whether the field types need to be identical or one needs to be a subtype of the other, so I took the more lenient of the two approaches. * Don't error on extra items, as neither mypy nor pyright does so, presumably because a TypedDict can have extra items through inheritance. Reviewed By: samwgoldman Differential Revision: D78768100 fbshipit-source-id: d3c24de975fa2620e9c3174e8e453d5f08e492fe
1 parent 31b8737 commit 09fc70f

5 files changed

Lines changed: 27 additions & 18 deletions

File tree

conformance/third_party/conformance.exp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10225,13 +10225,13 @@
1022510225
"typeddicts_readonly_update.py": [
1022610226
{
1022710227
"code": -2,
10228-
"column": 13,
10228+
"column": 10,
1022910229
"concise_description": "No matching overload found for function `A.update`",
1023010230
"description": "No matching overload found for function `A.update`\n Possible overloads:\n (__m: Partial[A], /) -> None [closest match]\n (__m: Iterable[tuple[Literal['y'], int]], /) -> None\n (*, y: int) -> None",
10231-
"line": 34,
10231+
"line": 23,
1023210232
"name": "no-matching-overload",
10233-
"stop_column": 16,
10234-
"stop_line": 34
10233+
"stop_column": 14,
10234+
"stop_line": 23
1023510235
}
1023610236
],
1023710237
"typeddicts_required.py": [

conformance/third_party/conformance.result

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -480,10 +480,7 @@
480480
"Line 132: Expected 1 errors"
481481
],
482482
"typeddicts_readonly_kwargs.py": [],
483-
"typeddicts_readonly_update.py": [
484-
"Line 23: Expected 1 errors",
485-
"Line 34: Unexpected errors [\"No matching overload found for function `A.update`\\n Possible overloads:\\n (__m: Partial[A], /) -> None [closest match]\\n (__m: Iterable[tuple[Literal['y'], int]], /) -> None\\n (*, y: int) -> None\"]"
486-
],
483+
"typeddicts_readonly_update.py": [],
487484
"typeddicts_required.py": [
488485
"Line 62: Expected 1 errors",
489486
"Line 74: Unexpected errors [\"Expected a type form, got instance of `Literal['RecursiveMovie']`\"]"

conformance/third_party/results.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"total": 136,
3-
"pass": 70,
4-
"fail": 66,
5-
"pass_rate": 0.51,
6-
"differences": 290,
3+
"pass": 71,
4+
"fail": 65,
5+
"pass_rate": 0.52,
6+
"differences": 288,
77
"passing": [
88
"aliases_explicit.py",
99
"aliases_newtype.py",
@@ -74,6 +74,7 @@
7474
"typeddicts_operations.py",
7575
"typeddicts_readonly.py",
7676
"typeddicts_readonly_kwargs.py",
77+
"typeddicts_readonly_update.py",
7778
"typeddicts_usage.py"
7879
],
7980
"failing": {
@@ -140,7 +141,6 @@
140141
"typeddicts_inheritance.py": 1,
141142
"typeddicts_readonly_consistency.py": 1,
142143
"typeddicts_readonly_inheritance.py": 4,
143-
"typeddicts_readonly_update.py": 2,
144144
"typeddicts_required.py": 2,
145145
"typeddicts_type_consistency.py": 1
146146
},

pyrefly/lib/solver/subset.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -737,8 +737,7 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
737737
};
738738
args_subset && self.is_subset_eq(&l.ret, &u.ret)
739739
}
740-
(Type::TypedDict(got), Type::TypedDict(want))
741-
| (Type::TypedDict(got), Type::PartialTypedDict(want)) => {
740+
(Type::TypedDict(got), Type::TypedDict(want)) => {
742741
// For each key in `want`, `got` has the corresponding key
743742
// and the corresponding value type in `got` is consistent with the value type in `want`.
744743
// For each required key in `got`, the corresponding key is required in `want`.
@@ -763,6 +762,20 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
763762
.is_none_or(|want_v| got_v.required == want_v.required)
764763
})
765764
}
765+
(Type::TypedDict(got), Type::PartialTypedDict(want)) => {
766+
let got_fields = self.type_order.typed_dict_fields(got);
767+
let want_fields = self.type_order.typed_dict_fields(want);
768+
want_fields.iter().all(|(k, want_v)| {
769+
got_fields.get(k).is_some_and(|got_v| {
770+
if want_v.is_read_only() {
771+
// ReadOnly can only be updated with Never (i.e., no update)
772+
self.is_subset_eq(&got_v.ty, &Type::never())
773+
} else {
774+
self.is_subset_eq(&got_v.ty, &want_v.ty)
775+
}
776+
})
777+
})
778+
}
766779
(Type::TypedDict(_), Type::SelfType(cls))
767780
if cls == self.type_order.stdlib().typed_dict_fallback() =>
768781
{

pyrefly/lib/test/typed_dict.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@ def f(c: C | Any):
123123
);
124124

125125
testcase!(
126-
bug = "a1.update(a2) should be an error and a.update(b) should not be",
127126
test_typed_dict_readonly_partial_update,
128127
r#"
129128
from typing import Never, NotRequired, TypedDict, ReadOnly
@@ -135,14 +134,14 @@ class A(TypedDict):
135134
136135
a1: A = {"x": 1, "y": 2}
137136
a2: A = {"x": 3, "y": 4}
138-
a1.update(a2)
137+
a1.update(a2) # E: No matching overload
139138
140139
class B(TypedDict):
141140
x: NotRequired[Never]
142141
y: ReadOnly[int]
143142
144143
def update_a(a: A, b: B) -> None:
145-
a.update(b) # E: No matching overload found for function `A.update`
144+
a.update(b)
146145
"#,
147146
);
148147

0 commit comments

Comments
 (0)