Skip to content

Commit

Permalink
fix interaction between extra != 'ignore' and from_attributes=True (
Browse files Browse the repository at this point in the history
  • Loading branch information
davidhewitt authored Apr 22, 2024
1 parent 91c3541 commit 3240277
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 5 deletions.
6 changes: 5 additions & 1 deletion src/input/input_python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ fn from_attributes_applicable(obj: &Bound<'_, PyAny>) -> bool {
// 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
// the most egregious foot guns, it's mostly just to catch "builtins"
// still happy to add more or do something completely different if anyone has a better idea???
// dbg!(obj, module_name);
// dbg!(obj, &module_name);
!matches!(module_name.to_str(), Ok("builtins" | "datetime" | "collections"))
}

Expand Down Expand Up @@ -808,6 +808,10 @@ impl<'py> ValidatedDict<'py> for GenericPyMapping<'_, 'py> {
}
}

fn is_py_get_attr(&self) -> bool {
matches!(self, Self::GetAttr(..))
}

fn as_py_dict(&self) -> Option<&Bound<'py, PyDict>> {
match self {
Self::Dict(dict) => Some(dict),
Expand Down
10 changes: 6 additions & 4 deletions src/validators/typed_dict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,12 @@ impl Validator for TypedDictValidator {

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

{
let state = &mut state.rebind_extra(|extra| extra.data = Some(output_dict.clone()));
Expand Down
93 changes: 93 additions & 0 deletions tests/validators/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,99 @@ class MyModel:
assert m.__dict__ == {'field_a': 'test', 'field_b': 12}


def test_model_class_extra_forbid():
class MyModel:
class Meta:
pass

# this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__`
__slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__'
field_a: str
field_b: int

class Wrapper:
def __init__(self, inner):
self._inner = inner

def __dir__(self):
return dir(self._inner)

def __getattr__(self, key):
return getattr(self._inner, key)

v = SchemaValidator(
core_schema.model_schema(
MyModel,
core_schema.model_fields_schema(
{
'field_a': core_schema.model_field(core_schema.str_schema()),
'field_b': core_schema.model_field(core_schema.int_schema()),
},
extra_behavior='forbid',
),
)
)
m = v.validate_python({'field_a': 'test', 'field_b': 12})
assert isinstance(m, MyModel)
assert m.field_a == 'test'
assert m.field_b == 12

# try revalidating from the model's attributes
m = v.validate_python(Wrapper(m), from_attributes=True)

with pytest.raises(ValidationError) as exc_info:
m = v.validate_python({'field_a': 'test', 'field_b': 12, 'field_c': 'extra'})

assert exc_info.value.errors(include_url=False) == [
{'type': 'extra_forbidden', 'loc': ('field_c',), 'msg': 'Extra inputs are not permitted', 'input': 'extra'}
]


@pytest.mark.parametrize('extra_behavior', ['allow', 'ignore', 'forbid'])
def test_model_class_extra_forbid_from_attributes(extra_behavior: str):
# iterating attributes includes much more than just __dict__, so need
# careful interaction with __extra__

class MyModel:
# this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__`
__slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__'
field_a: str
field_b: int

class Data:
# https://github.com/pydantic/pydantic/issues/9242
class Meta:
pass

def __init__(self, **values):
self.__dict__.update(values)

v = SchemaValidator(
core_schema.model_schema(
MyModel,
core_schema.model_fields_schema(
{
'field_a': core_schema.model_field(core_schema.str_schema()),
'field_b': core_schema.model_field(core_schema.int_schema()),
},
extra_behavior=extra_behavior,
from_attributes=True,
),
)
)
m = v.validate_python(Data(field_a='test', field_b=12))
assert isinstance(m, MyModel)
assert m.field_a == 'test'
assert m.field_b == 12

# with from_attributes, extra is basically ignored
m = v.validate_python(Data(field_a='test', field_b=12, field_c='extra'))
assert isinstance(m, MyModel)
assert m.field_a == 'test'
assert m.field_b == 12
assert not hasattr(m, 'field_c')


def test_model_class_setattr():
setattr_calls = []

Expand Down

0 comments on commit 3240277

Please sign in to comment.