Skip to content

Commit 6accf12

Browse files
rchen152facebook-github-bot
authored andcommitted
Allow clear() and popitem() when all TypedDict items are non-read-only and non-required
Summary: For #946. Note that the non-read-only case covers open TypedDicts, as they are considered to have extra items `ReadOnly[object]`. Reviewed By: samwgoldman Differential Revision: D80870303 fbshipit-source-id: 981ee7b4137fa04fe124d207e79303c09c2e3b9b
1 parent 0c86469 commit 6accf12

5 files changed

Lines changed: 82 additions & 35 deletions

File tree

conformance/third_party/conformance.exp

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10121,36 +10121,6 @@
1012110121
"stop_column": 44,
1012210122
"stop_line": 294
1012310123
},
10124-
{
10125-
"code": -2,
10126-
"column": 1,
10127-
"concise_description": "Object of class `IntDictWithNum` has no attribute `clear`",
10128-
"description": "Object of class `IntDictWithNum` has no attribute `clear`",
10129-
"line": 328,
10130-
"name": "missing-attribute",
10131-
"stop_column": 28,
10132-
"stop_line": 328
10133-
},
10134-
{
10135-
"code": -2,
10136-
"column": 12,
10137-
"concise_description": "assert_type(Any, tuple[str, int]) failed",
10138-
"description": "assert_type(Any, tuple[str, int]) failed",
10139-
"line": 330,
10140-
"name": "assert-type",
10141-
"stop_column": 62,
10142-
"stop_line": 330
10143-
},
10144-
{
10145-
"code": -2,
10146-
"column": 13,
10147-
"concise_description": "Object of class `IntDictWithNum` has no attribute `popitem`",
10148-
"description": "Object of class `IntDictWithNum` has no attribute `popitem`",
10149-
"line": 330,
10150-
"name": "missing-attribute",
10151-
"stop_column": 42,
10152-
"stop_line": 330
10153-
},
1015410124
{
1015510125
"code": -2,
1015610126
"column": 25,

conformance/third_party/conformance.result

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,9 +424,7 @@
424424
"Line 68: Unexpected errors ['Cannot extend closed TypedDict `ClosedBase` with extra item `age`']",
425425
"Line 74: Unexpected errors ['TypedDict `ExtraItemsBase` with non-read-only `extra_items` cannot be extended with required extra item `age`']",
426426
"Line 182: Unexpected errors ['TypedDict `MovieBase2` with non-read-only `extra_items` cannot be extended with required extra item `year`']",
427-
"Line 185: Unexpected errors ['`int` is not consistent with `extra_items` type `int | None` of TypedDict `MovieBase2`']",
428-
"Line 328: Unexpected errors ['Object of class `IntDictWithNum` has no attribute `clear`']",
429-
"Line 330: Unexpected errors ['assert_type(Any, tuple[str, int]) failed', 'Object of class `IntDictWithNum` has no attribute `popitem`']"
427+
"Line 185: Unexpected errors ['`int` is not consistent with `extra_items` type `int | None` of TypedDict `MovieBase2`']"
430428
],
431429
"typeddicts_final.py": [],
432430
"typeddicts_inheritance.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": 81,
44
"fail": 57,
55
"pass_rate": 0.59,
6-
"differences": 253,
6+
"differences": 251,
77
"passing": [
88
"aliases_explicit.py",
99
"aliases_newtype.py",
@@ -141,7 +141,7 @@
141141
"qualifiers_final_decorator.py": 1,
142142
"specialtypes_never.py": 1,
143143
"specialtypes_type.py": 8,
144-
"typeddicts_extra_items.py": 8,
144+
"typeddicts_extra_items.py": 6,
145145
"typeddicts_inheritance.py": 1,
146146
"typeddicts_readonly_inheritance.py": 4,
147147
"typeddicts_required.py": 1

pyrefly/lib/alt/class/typed_dict.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ use crate::types::types::TParam;
5151
use crate::types::types::TParams;
5252
use crate::types::types::Type;
5353

54+
const CLEAR_METHOD: Name = Name::new_static("clear");
5455
const GET_METHOD: Name = Name::new_static("get");
5556
const POP_METHOD: Name = Name::new_static("pop");
57+
const POPITEM_METHOD: Name = Name::new_static("popitem");
5658
const SETDEFAULT_METHOD: Name = Name::new_static("setdefault");
5759
const KEY_PARAM: Name = Name::new_static("key");
5860
const DEFAULT_PARAM: Name = Name::new_static("default");
@@ -681,6 +683,61 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
681683
})))
682684
}
683685

686+
fn all_items_are_removable(&self, cls: &Class, fields: &SmallMap<Name, bool>) -> bool {
687+
!self
688+
.typed_dict_extra_items(cls)
689+
.extra_item(self.stdlib)
690+
.read_only
691+
&& self
692+
.names_to_fields(cls, fields)
693+
.all(|(_, field)| !field.is_read_only() && !field.required)
694+
}
695+
696+
fn get_typed_dict_clear(
697+
&self,
698+
cls: &Class,
699+
fields: &SmallMap<Name, bool>,
700+
) -> Option<ClassSynthesizedField> {
701+
if !self.all_items_are_removable(cls, fields) {
702+
return None;
703+
}
704+
Some(ClassSynthesizedField::new(Type::Function(Box::new(
705+
Function {
706+
signature: Callable::list(
707+
ParamList::new(vec![self.class_self_param(cls, true)]),
708+
Type::None,
709+
),
710+
metadata: FuncMetadata::def(self.module().name(), cls.name().clone(), CLEAR_METHOD),
711+
},
712+
))))
713+
}
714+
715+
fn get_typed_dict_popitem(
716+
&self,
717+
cls: &Class,
718+
fields: &SmallMap<Name, bool>,
719+
) -> Option<ClassSynthesizedField> {
720+
if !self.all_items_are_removable(cls, fields) {
721+
return None;
722+
}
723+
Some(ClassSynthesizedField::new(Type::Function(Box::new(
724+
Function {
725+
signature: Callable::list(
726+
ParamList::new(vec![self.class_self_param(cls, true)]),
727+
Type::Tuple(Tuple::Concrete(vec![
728+
self.stdlib.str().clone().to_type(),
729+
self.get_typed_dict_value_type_from_fields(cls, fields),
730+
])),
731+
),
732+
metadata: FuncMetadata::def(
733+
self.module().name(),
734+
cls.name().clone(),
735+
POPITEM_METHOD,
736+
),
737+
},
738+
))))
739+
}
740+
684741
pub fn get_typed_dict_synthesized_fields(&self, cls: &Class) -> Option<ClassSynthesizedFields> {
685742
let metadata = self.get_metadata_for_class(cls);
686743
let td = metadata.typed_dict_metadata()?;
@@ -691,9 +748,15 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
691748
UPDATE_METHOD => self.get_typed_dict_update(cls, &td.fields),
692749
VALUES_METHOD => self.get_typed_dict_values(cls, &td.fields),
693750
};
751+
if let Some(m) = self.get_typed_dict_clear(cls, &td.fields) {
752+
fields.insert(CLEAR_METHOD, m);
753+
}
694754
if let Some(m) = self.get_typed_dict_pop(cls, &td.fields) {
695755
fields.insert(POP_METHOD, m);
696756
}
757+
if let Some(m) = self.get_typed_dict_popitem(cls, &td.fields) {
758+
fields.insert(POPITEM_METHOD, m);
759+
}
697760
if let Some(m) = self.get_typed_dict_setdefault(cls, &td.fields) {
698761
fields.insert(SETDEFAULT_METHOD, m);
699762
}

pyrefly/lib/test/typed_dict.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,3 +1693,19 @@ def f(a: A, k: str):
16931693
assert_type(a.get(k, b'hello world'), str | int | bytes)
16941694
"#,
16951695
);
1696+
1697+
testcase!(
1698+
test_remove_arbitrary_items,
1699+
r#"
1700+
from typing import assert_type, NotRequired, TypedDict
1701+
class A(TypedDict, extra_items=int):
1702+
x: NotRequired[str]
1703+
class B(TypedDict, extra_items=int):
1704+
x: str
1705+
def f(a: A, b: B):
1706+
assert_type(a.popitem(), tuple[str, int | str])
1707+
a.clear()
1708+
b.popitem() # E: `B` has no attribute `popitem`
1709+
b.clear() # E: `B` has no attribute `clear`
1710+
"#,
1711+
);

0 commit comments

Comments
 (0)