From 80a846109e6a4b3752f7558a6fa566cf4e1ce717 Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Sun, 19 Oct 2025 18:34:37 +0200 Subject: [PATCH 1/4] feat: implement FlatInitModel The change introduces a new flat kwarg model constructor -> `FlatInitModel`. Closes #23. --- lupl/__init__.py | 3 +- .../flat_init/flat_init_model.py | 66 ++++++++++++++ lupl/pydantic_tools/flat_init/utils.py | 90 +++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 lupl/pydantic_tools/flat_init/flat_init_model.py create mode 100644 lupl/pydantic_tools/flat_init/utils.py diff --git a/lupl/__init__.py b/lupl/__init__.py index 891e149..bff6894 100644 --- a/lupl/__init__.py +++ b/lupl/__init__.py @@ -1,5 +1,6 @@ from lupl.compose_router import ComposeRouter from lupl.ichunk import ichunk from lupl.pydantic_tools.curry_model import CurryModel, validate_model_field -from lupl.pydantic_tools.model_constructors import init_model_from_kwargs +from lupl.pydantic_tools.flat_init.flat_init_model import FlatInitModel +from lupl.pydantic_tools.flat_init.utils import ConfigDict from lupl.pydantic_tools.mutual_constraint_validator import _MutualConstraintMixin diff --git a/lupl/pydantic_tools/flat_init/flat_init_model.py b/lupl/pydantic_tools/flat_init/flat_init_model.py new file mode 100644 index 0000000..603014b --- /dev/null +++ b/lupl/pydantic_tools/flat_init/flat_init_model.py @@ -0,0 +1,66 @@ +import types +from typing import Any, TypeGuard, get_args, get_origin +import typing + +from lupl import CurryModel +from lupl.pydantic_tools.flat_init.utils import ( + ModelBoolPredicate, + _TModelBoolValue, + _is_pydantic_model_static_type, + _is_pydantic_model_union_static_type, + get_model_bool_predicate, +) +from pydantic import BaseModel + + +class FlatInitModel[_TModel: BaseModel]: + """Model constructor for initializing a potentially nested Pydantic model from flat kwargs. + + Nested model fields of a given model are recursively resolved; + for model union fields, the first model type of the union is processed. + """ + + def __init__(self, model: type[_TModel], fail_fast: bool = True): + self.model = model + self.fail_fast = fail_fast + + self._curried_model = CurryModel(model=model, fail_fast=fail_fast) + self._model_bool_value: _TModelBoolValue | None = self.model.model_config.get( + "model_bool", None + ) + + def __call__(self, **kwargs) -> _TModel: + """Run a FlatInitModel constructor to instantiate a Pydantic model from flat kwargs.""" + for field_name, field_info in self.model.model_fields.items(): + if _is_pydantic_model_static_type(field_info.annotation): + nested_model = field_info.annotation + field_value = FlatInitModel( + model=nested_model, fail_fast=self.fail_fast + )(**kwargs) + + elif _is_pydantic_model_union_static_type( + model_union := field_info.annotation + ): + nested_model_type: type[BaseModel] = next( + filter(_is_pydantic_model_static_type, get_args(model_union)) + ) + nested_model_instance = FlatInitModel( + model=nested_model_type, fail_fast=self.fail_fast + )(**kwargs) + model_bool_predicate: ModelBoolPredicate = get_model_bool_predicate( + model=nested_model_type + ) + field_value = ( + nested_model_instance + if model_bool_predicate(nested_model_instance) + else field_info.default + ) + else: + field_value = kwargs.get(field_name, field_info.default) + + self._curried_model(**{field_name: field_value}) + + model_instance = self._curried_model() + + assert isinstance(model_instance, self.model) + return model_instance diff --git a/lupl/pydantic_tools/flat_init/utils.py b/lupl/pydantic_tools/flat_init/utils.py new file mode 100644 index 0000000..4737d7d --- /dev/null +++ b/lupl/pydantic_tools/flat_init/utils.py @@ -0,0 +1,90 @@ +"""Utils for the lupl.FlatInitModel constructor.""" + +from types import UnionType +from typing import ( + Any, + Protocol, + TypeAlias, + TypeGuard, + Union, + cast, + get_args, + get_origin, + runtime_checkable, +) + +from pydantic import BaseModel, ConfigDict as PydanticConfigDict + + +@runtime_checkable +class ModelBoolPredicate[_TModel: BaseModel](Protocol): + """Type for model_bool predicate functions.""" + + def __call__(self, model: _TModel) -> bool: ... + + +_TModelBoolValue: TypeAlias = ModelBoolPredicate | str | set[str] + + +class ConfigDict(PydanticConfigDict, total=False): + model_bool: _TModelBoolValue + + +def default_model_bool_predicate(model: BaseModel) -> bool: + """Default predicate for determining model truthiness. + + Adheres to ModelBoolPredicate. + """ + return any(dict(model).values()) + + +def _get_model_bool_predicate_from_config_value( + model_bool_value: _TModelBoolValue, +) -> ModelBoolPredicate: + """Get a model_bool predicate function given the value of the model_bool config setting.""" + match model_bool_value: + case ModelBoolPredicate(): + return model_bool_value + case str(): + return lambda model: bool(dict(model)[model_bool_value]) + case set(): + return lambda model: all(map(lambda k: dict(model)[k], model_bool_value)) + case _: + msg = ( + f"Expected type {_TModelBoolValue} for model_bool config setting. " + f"Got '{model_bool_value}'." + ) + raise ValueError(msg) + + +def get_model_bool_predicate(model: type[BaseModel] | BaseModel) -> ModelBoolPredicate: + """Get the applicable model_bool predicate function given a model.""" + _missing = object() + if (model_bool_value := model.model_config.get("model_bool", _missing)) is _missing: + model_bool_predicate = default_model_bool_predicate + else: + model_bool_predicate = _get_model_bool_predicate_from_config_value( + # cast and see what happens at runtime... + cast(_TModelBoolValue, model_bool_value) + ) + + return model_bool_predicate + + +def _is_pydantic_model_static_type(obj: Any) -> TypeGuard[type[BaseModel]]: + """Check if object is a Pydantic model type.""" + return ( + isinstance(obj, type) and issubclass(obj, BaseModel) and (obj is not BaseModel) + ) + + +def _is_pydantic_model_union_static_type( + obj: Any, +) -> TypeGuard[UnionType]: + """Check if object is a union type of a Pydantic model.""" + is_union_type: bool = get_origin(obj) in (UnionType, Union) + has_any_model: bool = any( + _is_pydantic_model_static_type(obj) for obj in get_args(obj) + ) + + return is_union_type and has_any_model From f793db125e66c112b039dd029025c039900df767 Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Mon, 20 Oct 2025 08:20:25 +0200 Subject: [PATCH 2/4] chore!: remove init_model_from_kwargs constructor --- lupl/pydantic_tools/model_constructors.py | 57 ------------------- .../test_init_model_from_kwargs.py | 16 ------ 2 files changed, 73 deletions(-) delete mode 100644 lupl/pydantic_tools/model_constructors.py delete mode 100644 tests/tests_pydantic_tools/test_init_model_from_kwargs.py diff --git a/lupl/pydantic_tools/model_constructors.py b/lupl/pydantic_tools/model_constructors.py deleted file mode 100644 index 63b7cdd..0000000 --- a/lupl/pydantic_tools/model_constructors.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Pydantic model constructors.""" - -from pydantic import BaseModel - - -def init_model_from_kwargs[ModelType: BaseModel]( - model: type[ModelType], **kwargs -) -> ModelType: - """Instantiate a (potentially nested) model from (flat) kwargs. - - Example: - - class SimpleModel(BaseModel): - x: int - y: int - - - class NestedModel(BaseModel): - a: str - b: SimpleModel - - - class ComplexModel(BaseModel): - p: str - q: NestedModel - - - model = instantiate_model_from_kwargs(ComplexModel, x=1, y=2, a="a value", p="p value") - print(model) # p='p value' q=NestedModel(a='a value', b=SimpleModel(x=1, y=2)) - """ - - def _model_class_p(field_key, field_value, kwargs) -> bool: - """Helper for _get_bindings. - - Check if a field is annoted with a model type and there is no applicable model instance kwarg. - This triggers the recursive case in _get_bindings. - """ - return isinstance(field_value.annotation, type(BaseModel)) and not isinstance( - kwargs.get(field_key), BaseModel - ) - - def _construct_bindings(model: type[ModelType], **kwargs) -> dict: - """Construct bindings needed for model instantiation from kwargs. - - If a field is annotated with a model type and there is no applicable model instance kwarg, - trigger the recursive clause and consult kwargs for the nested model. - """ - return { - k: ( - v.annotation(**_construct_bindings(v.annotation, **kwargs)) - if _model_class_p(k, v, kwargs) - else kwargs.get(k, v.default) - ) - for k, v in model.model_fields.items() - } - - return model(**_construct_bindings(model, **kwargs)) diff --git a/tests/tests_pydantic_tools/test_init_model_from_kwargs.py b/tests/tests_pydantic_tools/test_init_model_from_kwargs.py deleted file mode 100644 index becf066..0000000 --- a/tests/tests_pydantic_tools/test_init_model_from_kwargs.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Pytest entry point for init_model_from_kwargs tests.""" - -import pytest - -from tests.data.init_model_from_kwargs_parameters import ( - init_model_from_kwargs_parameters, -) -from lupl import init_model_from_kwargs - - -@pytest.mark.parametrize(("model", "kwargs"), init_model_from_kwargs_parameters) -def test_init_model_from_kwargs(model, kwargs): - """Check if the init_model_from_kwargs constructor successfully inits a model based on kwargs.""" - for _kwargs in kwargs: - model_instance = init_model_from_kwargs(model, **_kwargs) - assert isinstance(model_instance, model) From 0313263d0a6e5b81e455f2fa9e5a049592d4776c Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Mon, 20 Oct 2025 11:32:06 +0200 Subject: [PATCH 3/4] test: implement basic tests for FlatInitModel --- .../test_flat_init_model_basic.py | 151 ++++++++++++++++++ .../test_flat_init_model_model_bool_fail.py | 37 +++++ .../test_flat_init_model_validation_fail.py | 137 ++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_basic.py create mode 100644 tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_model_bool_fail.py create mode 100644 tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_validation_fail.py diff --git a/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_basic.py b/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_basic.py new file mode 100644 index 0000000..dbf82e6 --- /dev/null +++ b/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_basic.py @@ -0,0 +1,151 @@ +"""Pytest entry point for for basic lupl.FlatInitModel test.""" + +from typing import Any, NamedTuple + +from lupl import ConfigDict, FlatInitModel +from pydantic import BaseModel +import pytest + + +class FlatInitTestParameter(NamedTuple): + model: type[BaseModel] + kwargs: dict[str, Any] + expected: dict[str, Any] + + +class DeeplyNestedModel1(BaseModel): + z: int + + +class NestedModel1(BaseModel): + y: int + deeply_nested: DeeplyNestedModel1 + + +class NestedModel2(NestedModel1): + model_config = ConfigDict(model_bool="y") + + +class NestedModel3(NestedModel1): + model_config = ConfigDict(model_bool=lambda model: model.y < 0) + + +class NestedModel4(NestedModel1): + model_config = ConfigDict(model_bool={"y", "y2"}) + + y2: int = 0 + + +class Model1(BaseModel): + x: int + nested: NestedModel1 + + +class Model2(Model1): + model_config = ConfigDict(extra="forbid") + + +class Model3(Model1): + x: int = 42 + + +class Model4(BaseModel): + x: int + nested: NestedModel2 | str = "default" + + +class Model5(BaseModel): + x: int + nested: NestedModel3 | str = "default" + + +class Model6(BaseModel): + x: int + nested: NestedModel4 | str = "default" + + +class Model7(BaseModel): + x: int + nested: NestedModel1 | None = None + + +class Model8(BaseModel): + x: int + nested: DeeplyNestedModel1 | None = None + + +params: list[FlatInitTestParameter] = [ + FlatInitTestParameter( + model=Model1, + kwargs={"x": 1, "y": 2, "z": 3}, + expected={"x": 1, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model2, + kwargs={"x": 1, "y": 2, "z": 3}, + expected={"x": 1, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model1, + kwargs={"x": 1.0, "y": 2.0, "z": 3.0}, + expected={"x": 1, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model3, + kwargs={"x": 1, "y": 2, "z": 3}, + expected={"x": 1, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model3, + kwargs={"y": 2, "z": 3}, + expected={"x": 42, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model4, + kwargs={"x": 1, "y": 2, "z": 3}, + expected={"x": 1, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model4, + kwargs={"x": 1, "y": 0, "z": 3}, + expected={"x": 1, "nested": "default"}, + ), + FlatInitTestParameter( + model=Model5, + kwargs={"x": 1, "y": 0, "z": 3}, + expected={"x": 1, "nested": "default"}, + ), + FlatInitTestParameter( + model=Model5, + kwargs={"x": 1, "y": -2, "z": 3}, + expected={"x": 1, "nested": {"y": -2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model6, + kwargs={"x": 1, "y": 2, "z": 3}, + expected={"x": 1, "nested": "default"}, + ), + FlatInitTestParameter( + model=Model7, + kwargs={"x": 1, "y": 2, "z": 3}, + expected={"x": 1, "nested": {"y": 2, "deeply_nested": {"z": 3}}}, + ), + FlatInitTestParameter( + model=Model8, + kwargs={"x": 1, "z": 3}, + expected={"x": 1, "nested": {"z": 3}}, + ), + FlatInitTestParameter( + model=Model8, + kwargs={"x": 1, "z": 0}, + expected={"x": 1, "nested": None}, + ), +] + + +@pytest.mark.parametrize("param", params) +def test_basic_flat_init_model(param): + constructor = FlatInitModel(model=param.model) + instance = constructor(**param.kwargs) + + assert instance.model_dump() == param.expected diff --git a/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_model_bool_fail.py b/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_model_bool_fail.py new file mode 100644 index 0000000..9b418ff --- /dev/null +++ b/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_model_bool_fail.py @@ -0,0 +1,37 @@ +"""Pytest entry point for lupl.FlatInitModel model_bool failure tests.""" + +from lupl import ConfigDict, FlatInitModel +from pydantic import BaseModel +import pytest + + +class NestedModel1(BaseModel): + model_config = ConfigDict(model_bool=["x"]) + + +class NestedModel2(BaseModel): + model_config = ConfigDict(model_bool=object()) + + +class NestedModel3(BaseModel): + model_config = ConfigDict(model_bool=None) + + +class Model1(BaseModel): + nested: NestedModel1 | None = None + + +class Model2(BaseModel): + nested: NestedModel2 | None = None + + +class Model3(BaseModel): + nested: NestedModel3 | None = None + + +@pytest.mark.parametrize("model", [Model1, Model2, Model3]) +def test_flat_init_model_model_bool_fail(model): + constructor = FlatInitModel(model=model) + + with pytest.raises(ValueError): + constructor() diff --git a/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_validation_fail.py b/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_validation_fail.py new file mode 100644 index 0000000..9efb072 --- /dev/null +++ b/tests/tests_pydantic_tools/tests_flat_init_model/test_flat_init_model_validation_fail.py @@ -0,0 +1,137 @@ +"""Pytest entry point for lupl.FlatInitBase fail tests.""" + +from typing import Any, NamedTuple + +from lupl import FlatInitModel +from pydantic import BaseModel, ValidationError +import pytest + + +class FlatInitFailParameter(NamedTuple): + model: type[BaseModel] + kwargs: dict[str, Any] + errors: list[dict[str, Any]] + fail_fast: bool = True + + +class DeeplyNestedModel(BaseModel): + z: int + + +class NestedModel(BaseModel): + y: int + deeply_nested: DeeplyNestedModel + + +class Model(BaseModel): + x: int + nested: NestedModel + + +class SimpleModel(BaseModel): + x: int + y: int + + +params = [ + FlatInitFailParameter( + model=Model, + kwargs={"x": 1, "y": 2.1, "z": 3}, + errors=[ + { + "type": "int_from_float", + "loc": ("y",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 2.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + } + ], + ), + FlatInitFailParameter( + model=Model, + kwargs={"x": 1, "y": 2, "z": 3.1}, + errors=[ + { + "type": "int_from_float", + "loc": ("z",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 3.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + } + ], + ), + FlatInitFailParameter( + model=Model, + kwargs={"x": 1, "y": 2.1, "z": 3.1}, + errors=[ + { + "type": "int_from_float", + "loc": ("y",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 2.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + } + ], + fail_fast=True, + ), + FlatInitFailParameter( + model=Model, + kwargs={"x": 1, "y": 2.1, "z": 3.1}, + errors=[ + { + "type": "int_from_float", + "loc": ("z",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 3.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + } + ], + fail_fast=False, + ), + FlatInitFailParameter( + model=SimpleModel, + kwargs={"x": 1.1, "y": 2.1}, + errors=[ + { + "type": "int_from_float", + "loc": ("x",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 1.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + }, + { + "type": "int_from_float", + "loc": ("y",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 2.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + }, + ], + fail_fast=False, + ), + FlatInitFailParameter( + model=SimpleModel, + kwargs={"x": 1.1, "y": 2.1}, + errors=[ + { + "type": "int_from_float", + "loc": ("x",), + "msg": "Input should be a valid integer, got a number with a fractional part", + "input": 1.1, + "url": "https://errors.pydantic.dev/2.12/v/int_from_float", + } + ], + fail_fast=True, + ), +] + + +@pytest.mark.parametrize("param", params) +def test_flat_init_model_validation_fail(param): + constructor = FlatInitModel(model=param.model, fail_fast=param.fail_fast) + + with pytest.raises(ValidationError) as excinfo: + constructor(**param.kwargs) + + errors = excinfo.value.errors() + assert param.errors == errors From 2b99419b522f2ba739589d8909c4b75e1a1eca84 Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Mon, 20 Oct 2025 21:20:35 +0200 Subject: [PATCH 4/4] docs: add documentation for FlatInitModel --- README.md | 82 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9d47c6e..1c282bf 100644 --- a/README.md +++ b/README.md @@ -112,38 +112,76 @@ model_instance_3 = curried_model_3(x="1", y=2)(z=("3", 4)) print(model_instance_3) ``` -#### init_model_from_kwargs +#### FlatInitModel -The `init_model_from_kwargs` constructor allows to initialize (potentially nested) models from (flat) kwargs. +The `FlatInitModel` constructor allows to instantiate a potentially deeply nested Pydantic model from flat kwargs. ```python -class SimpleModel(BaseModel): - x: int - y: int = 3 +from lupl import FlatInitModel +from pydantic import BaseModel +class DeeplyNestedModel(BaseModel): + z: int class NestedModel(BaseModel): - a: str - b: SimpleModel + y: int + deeply_nested: DeeplyNestedModel + +class Model(BaseModel): + x: int + nested: NestedModel + +constructor = FlatInitModel(model=Model) + +instance: Model = constructor(x=1, y=2, z=3) +instance.model_dump() # {'x': 1, 'nested': {'y': 2, 'deeply_nested': {'z': 3}}} +``` + + +`FlatInitModel` also handles model union types by processing the first model type of the union. + +A common use case for model union types is e.g. to assign a default value to a model union typed field in case a nested model instance does not meet certain criteria, i.e. fails a predicate. + +The `model_bool` parameter in `lupl.ConfigDict` allows to specify the condition for *model truthiness* - if the existential condition of a model is met, the model instance gets assigned to the model field, else the constructor falls back to the default value. + + +The default condition for model truthiness is that *any* model field must be truthy for the model to be considered truthy. +The `model_bool` parameter takes either -class ComplexModel(BaseModel): - p: str - q: NestedModel +- a callable object of arity 1 that receives the model instance at runtime, +- a `str` denoting a field of the model that must be truthy in order for the model to be truthy +- a `set[str]` denoting fields of the model, all of which must be truthy for the model to be truthy. -# p='p value' q=NestedModel(a='a value', b=SimpleModel(x=1, y=2)) -model_instance_1 = init_model_from_kwargs( - ComplexModel, x=1, y=2, a="a value", p="p value" -) +The following example defines the truth condition for `DeeplyNestedModel` to be `gt3`. `NestedModel` defines a model union type with a default value - if the `model_bool` predicate fails, the constructor falls back to the default: -# p='p value' q=NestedModel(a='a value', b=SimpleModel(x=1, y=3)) -model_instance_2 = init_model_from_kwargs( - ComplexModel, p="p value", q=NestedModel(a="a value", b=SimpleModel(x=1)) -) +```python +from lupl import ConfigDict, FlatInitModel +from pydantic import BaseModel + +class DeeplyNestedModel(BaseModel): + model_config = ConfigDict(model_bool=lambda model: model.z > 3) -# p='p value' q=NestedModel(a='a value', b=SimpleModel(x=1, y=3)) -model_instance_3 = init_model_from_kwargs( - ComplexModel, p="p value", q=init_model_from_kwargs(NestedModel, a="a value", x=1) -) + z: int + +class NestedModel(BaseModel): + y: int + deeply_nested: DeeplyNestedModel | str = "default" + +class Model(BaseModel): + x: int + nested: NestedModel + +constructor = FlatInitModel(model=Model) + +instance: Model = constructor(x=1, y=2, z=3) +instance.model_dump() # {'x': 1, 'nested': {'y': 2, 'deeply_nested': 'default'}} +``` + +If the existential condition of the model is met, the model instance gets assigned: + +```python +instance: Model = constructor(x=1, y=2, z=4) +instance.model_dump() # {'x': 1, 'nested': {'y': 2, 'deeply_nested': {'z': 4}}} ```