Skip to content

Commit 3240277

Browse files
authored
fix interaction between extra != 'ignore' and from_attributes=True (#1275)
1 parent 91c3541 commit 3240277

File tree

3 files changed

+104
-5
lines changed

3 files changed

+104
-5
lines changed

src/input/input_python.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ fn from_attributes_applicable(obj: &Bound<'_, PyAny>) -> bool {
622622
// I don't think it's a very good list at all! But it doesn't have to be at perfect, it just needs to avoid
623623
// the most egregious foot guns, it's mostly just to catch "builtins"
624624
// still happy to add more or do something completely different if anyone has a better idea???
625-
// dbg!(obj, module_name);
625+
// dbg!(obj, &module_name);
626626
!matches!(module_name.to_str(), Ok("builtins" | "datetime" | "collections"))
627627
}
628628

@@ -808,6 +808,10 @@ impl<'py> ValidatedDict<'py> for GenericPyMapping<'_, 'py> {
808808
}
809809
}
810810

811+
fn is_py_get_attr(&self) -> bool {
812+
matches!(self, Self::GetAttr(..))
813+
}
814+
811815
fn as_py_dict(&self) -> Option<&Bound<'py, PyDict>> {
812816
match self {
813817
Self::Dict(dict) => Some(dict),

src/validators/typed_dict.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,12 @@ impl Validator for TypedDictValidator {
156156

157157
// we only care about which keys have been used if we're iterating over the object for extra after
158158
// the first pass
159-
let mut used_keys: Option<AHashSet<&str>> = match self.extra_behavior {
160-
ExtraBehavior::Allow | ExtraBehavior::Forbid => Some(AHashSet::with_capacity(self.fields.len())),
161-
ExtraBehavior::Ignore => None,
162-
};
159+
let mut used_keys: Option<AHashSet<&str>> =
160+
if self.extra_behavior == ExtraBehavior::Ignore || dict.is_py_get_attr() {
161+
None
162+
} else {
163+
Some(AHashSet::with_capacity(self.fields.len()))
164+
};
163165

164166
{
165167
let state = &mut state.rebind_extra(|extra| extra.data = Some(output_dict.clone()));

tests/validators/test_model.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,99 @@ class MyModel:
7272
assert m.__dict__ == {'field_a': 'test', 'field_b': 12}
7373

7474

75+
def test_model_class_extra_forbid():
76+
class MyModel:
77+
class Meta:
78+
pass
79+
80+
# this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__`
81+
__slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__'
82+
field_a: str
83+
field_b: int
84+
85+
class Wrapper:
86+
def __init__(self, inner):
87+
self._inner = inner
88+
89+
def __dir__(self):
90+
return dir(self._inner)
91+
92+
def __getattr__(self, key):
93+
return getattr(self._inner, key)
94+
95+
v = SchemaValidator(
96+
core_schema.model_schema(
97+
MyModel,
98+
core_schema.model_fields_schema(
99+
{
100+
'field_a': core_schema.model_field(core_schema.str_schema()),
101+
'field_b': core_schema.model_field(core_schema.int_schema()),
102+
},
103+
extra_behavior='forbid',
104+
),
105+
)
106+
)
107+
m = v.validate_python({'field_a': 'test', 'field_b': 12})
108+
assert isinstance(m, MyModel)
109+
assert m.field_a == 'test'
110+
assert m.field_b == 12
111+
112+
# try revalidating from the model's attributes
113+
m = v.validate_python(Wrapper(m), from_attributes=True)
114+
115+
with pytest.raises(ValidationError) as exc_info:
116+
m = v.validate_python({'field_a': 'test', 'field_b': 12, 'field_c': 'extra'})
117+
118+
assert exc_info.value.errors(include_url=False) == [
119+
{'type': 'extra_forbidden', 'loc': ('field_c',), 'msg': 'Extra inputs are not permitted', 'input': 'extra'}
120+
]
121+
122+
123+
@pytest.mark.parametrize('extra_behavior', ['allow', 'ignore', 'forbid'])
124+
def test_model_class_extra_forbid_from_attributes(extra_behavior: str):
125+
# iterating attributes includes much more than just __dict__, so need
126+
# careful interaction with __extra__
127+
128+
class MyModel:
129+
# this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__`
130+
__slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__'
131+
field_a: str
132+
field_b: int
133+
134+
class Data:
135+
# https://github.com/pydantic/pydantic/issues/9242
136+
class Meta:
137+
pass
138+
139+
def __init__(self, **values):
140+
self.__dict__.update(values)
141+
142+
v = SchemaValidator(
143+
core_schema.model_schema(
144+
MyModel,
145+
core_schema.model_fields_schema(
146+
{
147+
'field_a': core_schema.model_field(core_schema.str_schema()),
148+
'field_b': core_schema.model_field(core_schema.int_schema()),
149+
},
150+
extra_behavior=extra_behavior,
151+
from_attributes=True,
152+
),
153+
)
154+
)
155+
m = v.validate_python(Data(field_a='test', field_b=12))
156+
assert isinstance(m, MyModel)
157+
assert m.field_a == 'test'
158+
assert m.field_b == 12
159+
160+
# with from_attributes, extra is basically ignored
161+
m = v.validate_python(Data(field_a='test', field_b=12, field_c='extra'))
162+
assert isinstance(m, MyModel)
163+
assert m.field_a == 'test'
164+
assert m.field_b == 12
165+
assert not hasattr(m, 'field_c')
166+
167+
75168
def test_model_class_setattr():
76169
setattr_calls = []
77170

0 commit comments

Comments
 (0)