Skip to content

Commit 46ffedb

Browse files
asukaminato0721meta-codesync[bot]
authored andcommitted
fix Feature: inheritance checks for typed dictionaries (#1346)
Summary: part of #52 Pull Request resolved: #1346 Reviewed By: yangdanny97 Differential Revision: D85366496 Pulled By: stroxler fbshipit-source-id: 06604ecdb576d0043b9c1454b52d265df7bd6e39
1 parent 98f7611 commit 46ffedb

File tree

5 files changed

+244
-32
lines changed

5 files changed

+244
-32
lines changed

conformance/third_party/conformance.exp

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11242,13 +11242,46 @@
1124211242
{
1124311243
"code": -2,
1124411244
"column": 5,
11245-
"concise_description": "Class member `F3.a` overrides parent class `F1` in an inconsistent manner",
11246-
"description": "Class member `F3.a` overrides parent class `F1` in an inconsistent manner\n `F3.a` is read-only, but `F1.a` is read-write",
11245+
"concise_description": "TypedDict field `a` in `F3` cannot be marked read-only; parent TypedDict `F1` defines it as mutable",
11246+
"description": "TypedDict field `a` in `F3` cannot be marked read-only; parent TypedDict `F1` defines it as mutable",
1124711247
"line": 94,
11248-
"name": "bad-override",
11248+
"name": "bad-typed-dict-key",
1124911249
"severity": "error",
1125011250
"stop_column": 6,
1125111251
"stop_line": 94
11252+
},
11253+
{
11254+
"code": -2,
11255+
"column": 5,
11256+
"concise_description": "TypedDict field `a` in `F4` must remain required because parent TypedDict `F1` defines it as required",
11257+
"description": "TypedDict field `a` in `F4` must remain required because parent TypedDict `F1` defines it as required",
11258+
"line": 98,
11259+
"name": "bad-typed-dict-key",
11260+
"severity": "error",
11261+
"stop_column": 6,
11262+
"stop_line": 98
11263+
},
11264+
{
11265+
"code": -2,
11266+
"column": 5,
11267+
"concise_description": "TypedDict field `c` in `F6` cannot be made non-required; parent TypedDict `F1` defines it as required",
11268+
"description": "TypedDict field `c` in `F6` cannot be made non-required; parent TypedDict `F1` defines it as required",
11269+
"line": 106,
11270+
"name": "bad-typed-dict-key",
11271+
"severity": "error",
11272+
"stop_column": 6,
11273+
"stop_line": 106
11274+
},
11275+
{
11276+
"code": -2,
11277+
"column": 7,
11278+
"concise_description": "TypedDict field `x` in `TD_B` cannot be made non-required; parent TypedDict `TD_B2` defines it as required",
11279+
"description": "TypedDict field `x` in `TD_B` cannot be made non-required; parent TypedDict `TD_B2` defines it as required",
11280+
"line": 132,
11281+
"name": "bad-typed-dict-key",
11282+
"severity": "error",
11283+
"stop_column": 11,
11284+
"stop_line": 132
1125211285
}
1125311286
],
1125411287
"typeddicts_readonly_kwargs.py": [

conformance/third_party/conformance.result

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,7 @@
331331
"typeddicts_readonly.py": [],
332332
"typeddicts_readonly_consistency.py": [],
333333
"typeddicts_readonly_inheritance.py": [
334-
"Line 98: Expected 1 errors",
335-
"Line 106: Expected 1 errors",
336-
"Line 119: Expected 1 errors",
337-
"Line 132: Expected 1 errors"
334+
"Line 119: Expected 1 errors"
338335
],
339336
"typeddicts_readonly_kwargs.py": [],
340337
"typeddicts_readonly_update.py": [],

conformance/third_party/results.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"pass": 97,
44
"fail": 41,
55
"pass_rate": 0.7,
6-
"differences": 164,
6+
"differences": 161,
77
"passing": [
88
"aliases_explicit.py",
99
"aliases_newtype.py",
@@ -143,7 +143,7 @@
143143
"qualifiers_final_annotation.py": 12,
144144
"specialtypes_never.py": 1,
145145
"specialtypes_type.py": 8,
146-
"typeddicts_readonly_inheritance.py": 4,
146+
"typeddicts_readonly_inheritance.py": 1,
147147
"typeddicts_required.py": 1
148148
},
149149
"comment": "@generated"

pyrefly/lib/alt/class/class_field.rs

Lines changed: 180 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,100 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
17801780
.is_some_and(|metadata| metadata.fields.contains_key(field_name))
17811781
}
17821782

1783+
fn typed_dict_field_info(
1784+
&self,
1785+
metadata: &ClassMetadata,
1786+
field_name: &Name,
1787+
field: &ClassField,
1788+
) -> Option<TypedDictField> {
1789+
metadata
1790+
.typed_dict_metadata()
1791+
.and_then(|typed_dict| typed_dict.fields.get(field_name))
1792+
.and_then(|is_total| field.clone().as_typed_dict_field_info(*is_total))
1793+
}
1794+
1795+
fn validate_typed_dict_field_override(
1796+
&self,
1797+
child_cls: &Class,
1798+
child_metadata: &ClassMetadata,
1799+
parent_cls: &Class,
1800+
parent_metadata: &ClassMetadata,
1801+
field_name: &Name,
1802+
child_field: &ClassField,
1803+
parent_field: &ClassField,
1804+
range: TextRange,
1805+
errors: &ErrorCollector,
1806+
) -> bool {
1807+
let Some(child_info) = self.typed_dict_field_info(child_metadata, field_name, child_field)
1808+
else {
1809+
return true;
1810+
};
1811+
let Some(parent_info) =
1812+
self.typed_dict_field_info(parent_metadata, field_name, parent_field)
1813+
else {
1814+
return true;
1815+
};
1816+
1817+
let parent_mutable = !parent_info.is_read_only();
1818+
let child_mutable = !child_info.is_read_only();
1819+
1820+
if parent_mutable {
1821+
if !child_mutable {
1822+
self.error(
1823+
errors,
1824+
range,
1825+
ErrorInfo::Kind(ErrorKind::BadTypedDictKey),
1826+
format!(
1827+
"TypedDict field `{field_name}` in `{}` cannot be marked read-only; parent TypedDict `{}` defines it as mutable",
1828+
child_cls.name(),
1829+
parent_cls.name()
1830+
),
1831+
);
1832+
return false;
1833+
}
1834+
if parent_info.required && !child_info.required {
1835+
self.error(
1836+
errors,
1837+
range,
1838+
ErrorInfo::Kind(ErrorKind::BadTypedDictKey),
1839+
format!(
1840+
"TypedDict field `{field_name}` in `{}` must remain required because parent TypedDict `{}` defines it as required",
1841+
child_cls.name(),
1842+
parent_cls.name()
1843+
),
1844+
);
1845+
return false;
1846+
}
1847+
if !parent_info.required && child_info.required {
1848+
self.error(
1849+
errors,
1850+
range,
1851+
ErrorInfo::Kind(ErrorKind::BadTypedDictKey),
1852+
format!(
1853+
"TypedDict field `{field_name}` in `{}` cannot be made required; parent TypedDict `{}` defines it as non-required",
1854+
child_cls.name(),
1855+
parent_cls.name()
1856+
),
1857+
);
1858+
return false;
1859+
}
1860+
} else if parent_info.required && !child_info.required {
1861+
self.error(
1862+
errors,
1863+
range,
1864+
ErrorInfo::Kind(ErrorKind::BadTypedDictKey),
1865+
format!(
1866+
"TypedDict field `{field_name}` in `{}` cannot be made non-required; parent TypedDict `{}` defines it as required",
1867+
child_cls.name(),
1868+
parent_cls.name()
1869+
),
1870+
);
1871+
return false;
1872+
}
1873+
1874+
true
1875+
}
1876+
17831877
fn should_check_field_for_override_consistency(&self, field_name: &Name) -> bool {
17841878
// Object construction (`__new__`, `__init__`, `__init_subclass__`) should not participate in override checks
17851879
if field_name == &dunder::NEW
@@ -1855,8 +1949,8 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
18551949
let mut got_attribute = None;
18561950
let mut parent_attr_found = false;
18571951
let mut parent_has_any = false;
1858-
let is_typed_dict_field =
1859-
self.is_typed_dict_field(&self.get_metadata_for_class(cls), field_name);
1952+
let metadata = self.get_metadata_for_class(cls);
1953+
let is_typed_dict_field = self.is_typed_dict_field(metadata.as_ref(), field_name);
18601954

18611955
let bases_to_check: Box<dyn Iterator<Item = &ClassType>> = if bases.is_empty() {
18621956
// If the class doesn't have any base type, we should just use `object` as base to ensure
@@ -1935,6 +2029,21 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
19352029
// keys but not regular fields.
19362030
continue;
19372031
}
2032+
if is_typed_dict_field
2033+
&& !self.validate_typed_dict_field_override(
2034+
cls,
2035+
metadata.as_ref(),
2036+
parent_cls,
2037+
parent_metadata.as_ref(),
2038+
field_name,
2039+
class_field,
2040+
&want_class_field,
2041+
range,
2042+
errors,
2043+
)
2044+
{
2045+
continue;
2046+
}
19382047
// Substitute `Self` with derived class to support contravariant occurrences of `Self`
19392048
let want_attribute = self.as_instance_attribute(
19402049
&want_class_field,
@@ -2005,38 +2114,56 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
20052114

20062115
/// For classes with multiple inheritance, check that fields inherited from multiple base classes are consistent.
20072116
pub fn check_consistent_multiple_inheritance(&self, cls: &Class, errors: &ErrorCollector) {
2008-
// Maps field from inherited class
2117+
struct InheritedFieldInfo {
2118+
class: Class,
2119+
metadata: Arc<ClassMetadata>,
2120+
field: ClassField,
2121+
ty: Type,
2122+
}
2123+
20092124
let mro = self.get_mro_for_class(cls);
2010-
let mut inherited_fields: SmallMap<&Name, Vec<(&Name, Type)>> = SmallMap::new();
2125+
let mut inherited_fields: SmallMap<&Name, Vec<InheritedFieldInfo>> = SmallMap::new();
20112126
let current_class_fields: SmallSet<_> = cls.fields().collect();
20122127

20132128
for parent_cls in mro.ancestors_no_object().iter() {
2014-
let class_fields = parent_cls.class_object().fields();
2129+
let class_object = parent_cls.class_object();
2130+
let class_fields = class_object.fields();
2131+
let parent_metadata = self.get_metadata_for_class(class_object);
20152132
for field in class_fields {
2016-
// Skip fields that are not relevant for override consistency checks
20172133
if !self.should_check_field_for_override_consistency(field) {
20182134
continue;
20192135
}
2020-
let key = KeyClassField(parent_cls.class_object().index(), field.clone());
2136+
if current_class_fields.contains(field) {
2137+
continue;
2138+
}
2139+
let key = KeyClassField(class_object.index(), field.clone());
20212140
let field_entry = self.get_from_class(cls, &key);
2022-
if let Some(field_entry) = field_entry.as_ref() {
2023-
// Skip fields that are overridden in the current class,
2024-
// they will have been checked by
2025-
// `check_consistent_override_for_field` already
2026-
if current_class_fields.contains(field) {
2027-
continue;
2028-
}
2141+
let Some(field_entry) = field_entry.as_ref() else {
2142+
continue;
2143+
};
2144+
if let Some(parent_member) = self.get_class_member(class_object, field) {
2145+
let parent_field = Arc::unwrap_or_clone(parent_member.value.clone());
20292146
inherited_fields
20302147
.entry(field)
20312148
.or_default()
2032-
.push((parent_cls.name(), field_entry.ty()));
2149+
.push(InheritedFieldInfo {
2150+
class: class_object.dupe(),
2151+
metadata: parent_metadata.clone(),
2152+
field: parent_field,
2153+
ty: field_entry.ty(),
2154+
});
20332155
}
20342156
}
20352157
}
20362158

2037-
for (field_name, class_and_types) in inherited_fields.iter() {
2038-
if class_and_types.len() > 1 {
2039-
let types: Vec<Type> = class_and_types.iter().map(|(_, ty)| ty.clone()).collect();
2159+
let child_metadata = self.get_metadata_for_class(cls);
2160+
2161+
for (field_name, inherited_field_infos_by_ancestor) in inherited_fields.iter() {
2162+
if inherited_field_infos_by_ancestor.len() > 1 {
2163+
let types: Vec<Type> = inherited_field_infos_by_ancestor
2164+
.iter()
2165+
.map(|info| info.ty.clone())
2166+
.collect();
20402167
if types.iter().any(|ty| {
20412168
matches!(
20422169
ty,
@@ -2046,8 +2173,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
20462173
| Type::Overload(_)
20472174
)
20482175
}) {
2049-
// TODO(fangyizhou): Handle bound methods and functions properly.
2050-
// This is a leftover from https://github.com/facebook/pyrefly/pull/1196
20512176
continue;
20522177
}
20532178
let intersect = self.intersects(&types);
@@ -2058,11 +2183,11 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
20582183
),
20592184
"Inherited types include:".to_owned()
20602185
];
2061-
for (cls, ty) in class_and_types.iter() {
2186+
for info in inherited_field_infos_by_ancestor.iter() {
20622187
error_msg.push(format!(
20632188
" `{}` from `{}`",
2064-
self.for_display(ty.clone()),
2065-
cls
2189+
self.for_display(info.ty.clone()),
2190+
info.class.name()
20662191
));
20672192
}
20682193
errors.add(
@@ -2071,6 +2196,38 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
20712196
error_msg,
20722197
);
20732198
}
2199+
2200+
if let Some(child_member) = self.get_class_member(cls, field_name) {
2201+
let child_field = Arc::unwrap_or_clone(child_member.value.clone());
2202+
if self
2203+
.typed_dict_field_info(child_metadata.as_ref(), field_name, &child_field)
2204+
.is_some()
2205+
{
2206+
for info in inherited_field_infos_by_ancestor {
2207+
if self
2208+
.typed_dict_field_info(
2209+
info.metadata.as_ref(),
2210+
field_name,
2211+
&info.field,
2212+
)
2213+
.is_some()
2214+
&& !self.validate_typed_dict_field_override(
2215+
cls,
2216+
child_metadata.as_ref(),
2217+
&info.class,
2218+
info.metadata.as_ref(),
2219+
field_name,
2220+
&child_field,
2221+
&info.field,
2222+
cls.range(),
2223+
errors,
2224+
)
2225+
{
2226+
continue;
2227+
}
2228+
}
2229+
}
2230+
}
20742231
}
20752232
}
20762233
}

pyrefly/lib/test/typed_dict.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,31 @@ def test(a: A) -> None:
173173
"#,
174174
);
175175

176+
testcase!(
177+
test_typed_dict_inheritance_field_qualifiers,
178+
r#"
179+
from typing import NotRequired, ReadOnly, Required, TypedDict
180+
181+
class ParentRequired(TypedDict):
182+
x: int
183+
184+
class ChildOptional(ParentRequired):
185+
x: NotRequired[int] # E: TypedDict field `x` in `ChildOptional` must remain required because parent TypedDict `ParentRequired` defines it as required
186+
187+
class ParentOptional(TypedDict, total=False):
188+
x: int
189+
190+
class ChildRequired(ParentOptional):
191+
x: Required[int] # E: TypedDict field `x` in `ChildRequired` cannot be made required; parent TypedDict `ParentOptional` defines it as non-required
192+
193+
class ParentMutable(TypedDict):
194+
x: int
195+
196+
class ChildReadOnly(ParentMutable):
197+
x: ReadOnly[int] # E: TypedDict field `x` in `ChildReadOnly` cannot be marked read-only; parent TypedDict `ParentMutable` defines it as mutable
198+
"#,
199+
);
200+
176201
testcase!(
177202
test_typed_dict_contextual,
178203
r#"

0 commit comments

Comments
 (0)