diff --git a/src/validators/dataclass.rs b/src/validators/dataclass.rs index 4e98538a8..72d738440 100644 --- a/src/validators/dataclass.rs +++ b/src/validators/dataclass.rs @@ -154,6 +154,7 @@ impl Validator for DataclassArgsValidator { let mut used_keys: AHashSet<&str> = AHashSet::with_capacity(self.fields.len()); let state = &mut state.rebind_extra(|extra| extra.data = Some(output_dict.clone())); + let mut fields_set_count: usize = 0; macro_rules! set_item { ($field:ident, $value:expr) => {{ @@ -175,6 +176,7 @@ impl Validator for DataclassArgsValidator { Ok(Some(value)) => { // Default value exists, and passed validation if required set_item!(field, value); + fields_set_count += 1; } Ok(None) | Err(ValError::Omit) => continue, // Note: this will always use the field name even if there is an alias @@ -214,7 +216,10 @@ impl Validator for DataclassArgsValidator { } // found a positional argument, validate it (Some(pos_value), None) => match field.validator.validate(py, pos_value.borrow_input(), state) { - Ok(value) => set_item!(field, value), + Ok(value) => { + set_item!(field, value); + fields_set_count += 1; + } Err(ValError::LineErrors(line_errors)) => { errors.extend(line_errors.into_iter().map(|err| err.with_outer_location(index))); } @@ -222,7 +227,10 @@ impl Validator for DataclassArgsValidator { }, // found a keyword argument, validate it (None, Some((lookup_path, kw_value))) => match field.validator.validate(py, kw_value, state) { - Ok(value) => set_item!(field, value), + Ok(value) => { + set_item!(field, value); + fields_set_count += 1; + } Err(ValError::LineErrors(line_errors)) => { errors.extend( line_errors @@ -336,6 +344,8 @@ impl Validator for DataclassArgsValidator { } } + state.add_fields_set(fields_set_count); + if errors.is_empty() { if let Some(init_only_args) = init_only_args { Ok((output_dict, PyTuple::new_bound(py, init_only_args)).to_object(py)) diff --git a/src/validators/model.rs b/src/validators/model.rs index 741b6f9f0..fc4ca75d3 100644 --- a/src/validators/model.rs +++ b/src/validators/model.rs @@ -204,6 +204,7 @@ impl Validator for ModelValidator { for field_name in validated_fields_set { fields_set.add(field_name)?; } + state.add_fields_set(fields_set.len()); } force_setattr(py, model, intern!(py, DUNDER_DICT), validated_dict.to_object(py))?; @@ -241,10 +242,13 @@ impl ModelValidator { } else { PySet::new_bound(py, [&String::from(ROOT_FIELD)])? }; - force_setattr(py, self_instance, intern!(py, DUNDER_FIELDS_SET_KEY), fields_set)?; + force_setattr(py, self_instance, intern!(py, DUNDER_FIELDS_SET_KEY), &fields_set)?; force_setattr(py, self_instance, intern!(py, ROOT_FIELD), &output)?; + state.add_fields_set(fields_set.len()); } else { - let (model_dict, model_extra, fields_set) = output.extract(py)?; + let (model_dict, model_extra, fields_set): (Bound, Bound, Bound) = + output.extract(py)?; + state.add_fields_set(fields_set.len().unwrap_or(0)); set_model_attrs(self_instance, &model_dict, &model_extra, &fields_set)?; } self.call_post_init(py, self_instance.clone(), input, state.extra()) @@ -281,11 +285,13 @@ impl ModelValidator { } else { PySet::new_bound(py, [&String::from(ROOT_FIELD)])? }; - force_setattr(py, &instance, intern!(py, DUNDER_FIELDS_SET_KEY), fields_set)?; + force_setattr(py, &instance, intern!(py, DUNDER_FIELDS_SET_KEY), &fields_set)?; force_setattr(py, &instance, intern!(py, ROOT_FIELD), output)?; + state.add_fields_set(fields_set.len()); } else { let (model_dict, model_extra, val_fields_set) = output.extract(py)?; let fields_set = existing_fields_set.unwrap_or(&val_fields_set); + state.add_fields_set(fields_set.len().unwrap_or(0)); set_model_attrs(&instance, &model_dict, &model_extra, fields_set)?; } self.call_post_init(py, instance, input, state.extra()) diff --git a/src/validators/typed_dict.rs b/src/validators/typed_dict.rs index 5aa8a5f00..f72d969a8 100644 --- a/src/validators/typed_dict.rs +++ b/src/validators/typed_dict.rs @@ -165,6 +165,7 @@ impl Validator for TypedDictValidator { { let state = &mut state.rebind_extra(|extra| extra.data = Some(output_dict.clone())); + let mut fields_set_count: usize = 0; for field in &self.fields { let op_key_value = match dict.get_item(&field.lookup_key) { @@ -186,6 +187,7 @@ impl Validator for TypedDictValidator { match field.validator.validate(py, value.borrow_input(), state) { Ok(value) => { output_dict.set_item(&field.name_py, value)?; + fields_set_count += 1; } Err(ValError::Omit) => continue, Err(ValError::LineErrors(line_errors)) => { @@ -227,6 +229,8 @@ impl Validator for TypedDictValidator { Err(err) => return Err(err), } } + + state.add_fields_set(fields_set_count); } if let Some(used_keys) = used_keys { diff --git a/src/validators/union.rs b/src/validators/union.rs index 8a33870ec..dc46bf52f 100644 --- a/src/validators/union.rs +++ b/src/validators/union.rs @@ -108,10 +108,12 @@ impl UnionValidator { state: &mut ValidationState<'_, 'py>, ) -> ValResult { let old_exactness = state.exactness; + let old_fields_set_count = state.fields_set_count; + let strict = state.strict_or(self.strict); let mut errors = MaybeErrors::new(self.custom_error.as_ref()); - let mut success = None; + let mut best_match: Option<(Py, Exactness, Option)> = None; for (choice, label) in &self.choices { let state = &mut state.rebind_extra(|extra| { @@ -120,47 +122,67 @@ impl UnionValidator { } }); state.exactness = Some(Exactness::Exact); + state.fields_set_count = None; let result = choice.validate(py, input, state); match result { - Ok(new_success) => match state.exactness { - // exact match, return - Some(Exactness::Exact) => { + Ok(new_success) => match (state.exactness, state.fields_set_count) { + (Some(Exactness::Exact), None) => { + // exact match with no fields set data, return immediately return { // exact match, return, restore any previous exactness state.exactness = old_exactness; + state.fields_set_count = old_fields_set_count; Ok(new_success) }; } _ => { // success should always have an exactness debug_assert_ne!(state.exactness, None); + let new_exactness = state.exactness.unwrap_or(Exactness::Lax); - // if the new result has higher exactness than the current success, replace it - if success - .as_ref() - .map_or(true, |(_, current_exactness)| *current_exactness < new_exactness) - { - // TODO: is there a possible optimization here, where once there has - // been one success, we turn on strict mode, to avoid unnecessary - // coercions for further validation? - success = Some((new_success, new_exactness)); + let new_fields_set_count = state.fields_set_count; + + // we use both the exactness and the fields_set_count to determine the best union member match + // if fields_set_count is available for the current best match and the new candidate, we use this + // as the primary metric. If the new fields_set_count is greater, the new candidate is better. + // if the fields_set_count is the same, we use the exactness as a tie breaker to determine the best match. + // if the fields_set_count is not available for either the current best match or the new candidate, + // we use the exactness to determine the best match. + let new_success_is_best_match: bool = + best_match + .as_ref() + .map_or(true, |(_, cur_exactness, cur_fields_set_count)| { + match (*cur_fields_set_count, new_fields_set_count) { + (Some(cur), Some(new)) if cur != new => cur < new, + _ => *cur_exactness < new_exactness, + } + }); + + if new_success_is_best_match { + best_match = Some((new_success, new_exactness, new_fields_set_count)); } } }, Err(ValError::LineErrors(lines)) => { // if we don't yet know this validation will succeed, record the error - if success.is_none() { + if best_match.is_none() { errors.push(choice, label.as_deref(), lines); } } otherwise => return otherwise, } } + + // restore previous validation state to prepare for any future validations state.exactness = old_exactness; + state.fields_set_count = old_fields_set_count; - if let Some((success, exactness)) = success { + if let Some((best_match, exactness, fields_set_count)) = best_match { state.floor_exactness(exactness); - return Ok(success); + if let Some(count) = fields_set_count { + state.add_fields_set(count); + } + return Ok(best_match); } // no matches, build errors diff --git a/src/validators/validation_state.rs b/src/validators/validation_state.rs index ef6954618..92edfbbe9 100644 --- a/src/validators/validation_state.rs +++ b/src/validators/validation_state.rs @@ -18,6 +18,7 @@ pub enum Exactness { pub struct ValidationState<'a, 'py> { pub recursion_guard: &'a mut RecursionState, pub exactness: Option, + pub fields_set_count: Option, // deliberately make Extra readonly extra: Extra<'a, 'py>, } @@ -27,6 +28,7 @@ impl<'a, 'py> ValidationState<'a, 'py> { Self { recursion_guard, // Don't care about exactness unless doing union validation exactness: None, + fields_set_count: None, extra, } } @@ -68,6 +70,10 @@ impl<'a, 'py> ValidationState<'a, 'py> { } } + pub fn add_fields_set(&mut self, fields_set_count: usize) { + *self.fields_set_count.get_or_insert(0) += fields_set_count; + } + pub fn cache_str(&self) -> StringCacheMode { self.extra.cache_str } diff --git a/tests/validators/test_union.py b/tests/validators/test_union.py index 332b23257..f2d10f36b 100644 --- a/tests/validators/test_union.py +++ b/tests/validators/test_union.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from datetime import date, time from enum import Enum, IntEnum -from typing import Any +from itertools import permutations +from typing import Any, List, Optional, Union from uuid import UUID import pytest @@ -170,13 +171,15 @@ def test_model_a(self, schema_validator: SchemaValidator): assert m.b == 'hello' assert not hasattr(m, 'c') - def test_model_b_ignored(self, schema_validator: SchemaValidator): - # first choice works, so second choice is not used + def test_model_b_preferred(self, schema_validator: SchemaValidator): + # Note, this is a different behavior to previous smart union behavior, + # where the first match would be preferred. However, we believe is it better + # to prefer the match with the greatest number of valid fields set. m = schema_validator.validate_python({'a': 1, 'b': 'hello', 'c': 2.0}) - assert isinstance(m, self.ModelA) + assert isinstance(m, self.ModelB) assert m.a == 1 assert m.b == 'hello' - assert not hasattr(m, 'c') + assert m.c == 2.0 def test_model_b_not_ignored(self, schema_validator: SchemaValidator): m1 = self.ModelB() @@ -803,3 +806,477 @@ class ModelA: assert isinstance(m, ModelA) assert m.a == 42 assert validator.validate_python(True) is True + + +def permute_choices(choices: List[core_schema.CoreSchema]) -> List[List[core_schema.CoreSchema]]: + return [list(p) for p in permutations(choices)] + + +class TestSmartUnionWithSubclass: + class ModelA: + a: int + + class ModelB(ModelA): + b: int + + model_a_schema = core_schema.model_schema( + ModelA, core_schema.model_fields_schema(fields={'a': core_schema.model_field(core_schema.int_schema())}) + ) + model_b_schema = core_schema.model_schema( + ModelB, + core_schema.model_fields_schema( + fields={ + 'a': core_schema.model_field(core_schema.int_schema()), + 'b': core_schema.model_field(core_schema.int_schema()), + } + ), + ) + + @pytest.mark.parametrize('choices', permute_choices([model_a_schema, model_b_schema])) + def test_more_specific_data_matches_subclass(self, choices) -> None: + validator = SchemaValidator(schema=core_schema.union_schema(choices)) + assert isinstance(validator.validate_python({'a': 1}), self.ModelA) + assert isinstance(validator.validate_python({'a': 1, 'b': 2}), self.ModelB) + + assert isinstance(validator.validate_python({'a': 1, 'b': 2}), self.ModelB) + + # confirm that a model that matches in lax mode with 2 fields + # is preferred over a model that matches in strict mode with 1 field + assert isinstance(validator.validate_python({'a': '1', 'b': '2'}), self.ModelB) + assert isinstance(validator.validate_python({'a': '1', 'b': 2}), self.ModelB) + assert isinstance(validator.validate_python({'a': 1, 'b': '2'}), self.ModelB) + + +class TestSmartUnionWithDefaults: + class ModelA: + a: int = 0 + + class ModelB: + b: int = 0 + + model_a_schema = core_schema.model_schema( + ModelA, + core_schema.model_fields_schema( + fields={'a': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0))} + ), + ) + model_b_schema = core_schema.model_schema( + ModelB, + core_schema.model_fields_schema( + fields={'b': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0))} + ), + ) + + @pytest.mark.parametrize('choices', permute_choices([model_a_schema, model_b_schema])) + def test_fields_set_ensures_best_match(self, choices) -> None: + validator = SchemaValidator(schema=core_schema.union_schema(choices)) + assert isinstance(validator.validate_python({'a': 1}), self.ModelA) + assert isinstance(validator.validate_python({'b': 1}), self.ModelB) + + # defaults to leftmost choice if there's a tie + assert isinstance(validator.validate_python({}), choices[0]['cls']) + + @pytest.mark.parametrize('choices', permute_choices([model_a_schema, model_b_schema])) + def test_optional_union_with_members_having_defaults(self, choices) -> None: + class WrapModel: + val: Optional[Union[self.ModelA, self.ModelB]] = None + + val = SchemaValidator( + core_schema.model_schema( + WrapModel, + core_schema.model_fields_schema( + fields={ + 'val': core_schema.model_field( + core_schema.with_default_schema( + core_schema.union_schema(choices), + default=None, + ) + ) + } + ), + ) + ) + + assert isinstance(val.validate_python({'val': {'a': 1}}).val, self.ModelA) + assert isinstance(val.validate_python({'val': {'b': 1}}).val, self.ModelB) + assert val.validate_python({}).val is None + + +def test_dc_smart_union_by_fields_set() -> None: + @dataclass + class ModelA: + x: int + + @dataclass + class ModelB(ModelA): + y: int + + dc_a_schema = core_schema.dataclass_schema( + ModelA, + core_schema.dataclass_args_schema('ModelA', [core_schema.dataclass_field('x', core_schema.int_schema())]), + ['x'], + ) + + dc_b_schema = core_schema.dataclass_schema( + ModelB, + core_schema.dataclass_args_schema( + 'ModelB', + [ + core_schema.dataclass_field('x', core_schema.int_schema()), + core_schema.dataclass_field('y', core_schema.int_schema()), + ], + ), + ['x', 'y'], + ) + + for choices in permute_choices([dc_a_schema, dc_b_schema]): + validator = SchemaValidator(core_schema.union_schema(choices=choices)) + + assert isinstance(validator.validate_python({'x': 1}), ModelA) + assert isinstance(validator.validate_python({'x': '1'}), ModelA) + + assert isinstance(validator.validate_python({'x': 1, 'y': 2}), ModelB) + assert isinstance(validator.validate_python({'x': 1, 'y': '2'}), ModelB) + assert isinstance(validator.validate_python({'x': '1', 'y': 2}), ModelB) + assert isinstance(validator.validate_python({'x': '1', 'y': '2'}), ModelB) + + +def test_dc_smart_union_with_defaults() -> None: + @dataclass + class ModelA: + a: int = 0 + + @dataclass + class ModelB: + b: int = 0 + + dc_a_schema = core_schema.dataclass_schema( + ModelA, + core_schema.dataclass_args_schema( + 'ModelA', + [ + core_schema.dataclass_field( + 'a', core_schema.with_default_schema(schema=core_schema.int_schema(), default=0) + ) + ], + ), + ['a'], + ) + + dc_b_schema = core_schema.dataclass_schema( + ModelB, + core_schema.dataclass_args_schema( + 'ModelB', + [ + core_schema.dataclass_field( + 'b', core_schema.with_default_schema(schema=core_schema.int_schema(), default=0) + ) + ], + ), + ['b'], + ) + + for choices in permute_choices([dc_a_schema, dc_b_schema]): + validator = SchemaValidator(core_schema.union_schema(choices=choices)) + + assert isinstance(validator.validate_python({'a': 1}), ModelA) + assert isinstance(validator.validate_python({'b': 1}), ModelB) + + +def test_td_smart_union_by_fields_set() -> None: + td_a_schema = core_schema.typed_dict_schema( + fields={'x': core_schema.typed_dict_field(core_schema.int_schema())}, + ) + + td_b_schema = core_schema.typed_dict_schema( + fields={ + 'x': core_schema.typed_dict_field(core_schema.int_schema()), + 'y': core_schema.typed_dict_field(core_schema.int_schema()), + }, + ) + + for choices in permute_choices([td_a_schema, td_b_schema]): + validator = SchemaValidator(core_schema.union_schema(choices=choices)) + + assert set(validator.validate_python({'x': 1}).keys()) == {'x'} + assert set(validator.validate_python({'x': '1'}).keys()) == {'x'} + + assert set(validator.validate_python({'x': 1, 'y': 2}).keys()) == {'x', 'y'} + assert set(validator.validate_python({'x': 1, 'y': '2'}).keys()) == {'x', 'y'} + assert set(validator.validate_python({'x': '1', 'y': 2}).keys()) == {'x', 'y'} + assert set(validator.validate_python({'x': '1', 'y': '2'}).keys()) == {'x', 'y'} + + +def test_smart_union_does_nested_model_field_counting() -> None: + class SubModelA: + x: int = 1 + + class SubModelB: + y: int = 2 + + class ModelA: + sub: SubModelA + + class ModelB: + sub: SubModelB + + model_a_schema = core_schema.model_schema( + ModelA, + core_schema.model_fields_schema( + fields={ + 'sub': core_schema.model_field( + core_schema.model_schema( + SubModelA, + core_schema.model_fields_schema( + fields={ + 'x': core_schema.model_field( + core_schema.with_default_schema(core_schema.int_schema(), default=1) + ) + } + ), + ) + ) + } + ), + ) + + model_b_schema = core_schema.model_schema( + ModelB, + core_schema.model_fields_schema( + fields={ + 'sub': core_schema.model_field( + core_schema.model_schema( + SubModelB, + core_schema.model_fields_schema( + fields={ + 'y': core_schema.model_field( + core_schema.with_default_schema(core_schema.int_schema(), default=2) + ) + } + ), + ) + ) + } + ), + ) + + for choices in permute_choices([model_a_schema, model_b_schema]): + validator = SchemaValidator(core_schema.union_schema(choices=choices)) + + assert isinstance(validator.validate_python({'sub': {'x': 1}}), ModelA) + assert isinstance(validator.validate_python({'sub': {'y': 3}}), ModelB) + + # defaults to leftmost choice if there's a tie + assert isinstance(validator.validate_python({'sub': {}}), choices[0]['cls']) + + +def test_smart_union_does_nested_dataclass_field_counting() -> None: + @dataclass + class SubModelA: + x: int = 1 + + @dataclass + class SubModelB: + y: int = 2 + + @dataclass + class ModelA: + sub: SubModelA + + @dataclass + class ModelB: + sub: SubModelB + + dc_a_schema = core_schema.dataclass_schema( + ModelA, + core_schema.dataclass_args_schema( + 'ModelA', + [ + core_schema.dataclass_field( + 'sub', + core_schema.with_default_schema( + core_schema.dataclass_schema( + SubModelA, + core_schema.dataclass_args_schema( + 'SubModelA', + [ + core_schema.dataclass_field( + 'x', core_schema.with_default_schema(core_schema.int_schema(), default=1) + ) + ], + ), + ['x'], + ), + default=SubModelA(), + ), + ) + ], + ), + ['sub'], + ) + + dc_b_schema = core_schema.dataclass_schema( + ModelB, + core_schema.dataclass_args_schema( + 'ModelB', + [ + core_schema.dataclass_field( + 'sub', + core_schema.with_default_schema( + core_schema.dataclass_schema( + SubModelB, + core_schema.dataclass_args_schema( + 'SubModelB', + [ + core_schema.dataclass_field( + 'y', core_schema.with_default_schema(core_schema.int_schema(), default=2) + ) + ], + ), + ['y'], + ), + default=SubModelB(), + ), + ) + ], + ), + ['sub'], + ) + + for choices in permute_choices([dc_a_schema, dc_b_schema]): + validator = SchemaValidator(core_schema.union_schema(choices=choices)) + + assert isinstance(validator.validate_python({'sub': {'x': 1}}), ModelA) + assert isinstance(validator.validate_python({'sub': {'y': 3}}), ModelB) + + # defaults to leftmost choice if there's a tie + assert isinstance(validator.validate_python({'sub': {}}), choices[0]['cls']) + + +def test_smart_union_does_nested_typed_dict_field_counting() -> None: + td_a_schema = core_schema.typed_dict_schema( + fields={ + 'sub': core_schema.typed_dict_field( + core_schema.typed_dict_schema(fields={'x': core_schema.typed_dict_field(core_schema.int_schema())}) + ) + } + ) + + td_b_schema = core_schema.typed_dict_schema( + fields={ + 'sub': core_schema.typed_dict_field( + core_schema.typed_dict_schema(fields={'y': core_schema.typed_dict_field(core_schema.int_schema())}) + ) + } + ) + + for choices in permute_choices([td_a_schema, td_b_schema]): + validator = SchemaValidator(core_schema.union_schema(choices=choices)) + + assert set(validator.validate_python({'sub': {'x': 1}})['sub'].keys()) == {'x'} + assert set(validator.validate_python({'sub': {'y': 2}})['sub'].keys()) == {'y'} + + +def test_nested_unions_bubble_up_field_count() -> None: + class SubModelX: + x1: int = 0 + x2: int = 0 + x3: int = 0 + + class SubModelY: + x1: int = 0 + x2: int = 0 + x3: int = 0 + + class SubModelZ: + z1: int = 0 + z2: int = 0 + z3: int = 0 + + class SubModelW: + w1: int = 0 + w2: int = 0 + w3: int = 0 + + class ModelA: + a: Union[SubModelX, SubModelY] + + class ModelB: + b: Union[SubModelZ, SubModelW] + + model_x_schema = core_schema.model_schema( + SubModelX, + core_schema.model_fields_schema( + fields={ + 'x1': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'x2': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'x3': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + } + ), + ) + + model_y_schema = core_schema.model_schema( + SubModelY, + core_schema.model_fields_schema( + fields={ + 'x1': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'x2': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'x3': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + } + ), + ) + + model_z_schema = core_schema.model_schema( + SubModelZ, + core_schema.model_fields_schema( + fields={ + 'z1': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'z2': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'z3': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + } + ), + ) + + model_w_schema = core_schema.model_schema( + SubModelW, + core_schema.model_fields_schema( + fields={ + 'w1': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'w2': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + 'w3': core_schema.model_field(core_schema.with_default_schema(core_schema.int_schema(), default=0)), + } + ), + ) + + model_a_schema_options = [ + core_schema.union_schema([model_x_schema, model_y_schema]), + core_schema.union_schema([model_y_schema, model_x_schema]), + ] + + model_b_schema_options = [ + core_schema.union_schema([model_z_schema, model_w_schema]), + core_schema.union_schema([model_w_schema, model_z_schema]), + ] + + for model_a_schema in model_a_schema_options: + for model_b_schema in model_b_schema_options: + validator = SchemaValidator( + core_schema.union_schema( + [ + core_schema.model_schema( + ModelA, + core_schema.model_fields_schema(fields={'a': core_schema.model_field(model_a_schema)}), + ), + core_schema.model_schema( + ModelB, + core_schema.model_fields_schema(fields={'b': core_schema.model_field(model_b_schema)}), + ), + ] + ) + ) + + result = validator.validate_python( + {'a': {'x1': 1, 'x2': 2, 'y1': 1, 'y2': 2}, 'b': {'w1': 1, 'w2': 2, 'w3': 3}} + ) + assert isinstance(result, ModelB) + assert isinstance(result.b, SubModelW)