Skip to content

Commit

Permalink
Merge pull request #30 from niqzart/feat/composite_model
Browse files Browse the repository at this point in the history
Composite model
  • Loading branch information
niqzart authored Aug 11, 2024
2 parents 91780f4 + 266e94a commit 3f14d41
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 13 deletions.
3 changes: 2 additions & 1 deletion pydantic_marshals/base/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pydantic_marshals.base.composite import CompositeMarshalModel
from pydantic_marshals.base.fields.base import PatchDefault, PatchDefaultType
from pydantic_marshals.base.models import MarshalModel

__all__ = ("MarshalModel", "PatchDefault", "PatchDefaultType")
__all__ = ("MarshalModel", "PatchDefault", "PatchDefaultType", "CompositeMarshalModel")
37 changes: 37 additions & 0 deletions pydantic_marshals/base/composite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from typing import Any, get_origin

from pydantic import BaseModel, create_model
from pydantic.fields import FieldInfo

from pydantic_marshals.base.models import MarshalBaseModel
from pydantic_marshals.utils import is_optional, is_subtype


class CompositeMarshalModel(MarshalBaseModel):
@classmethod
def generate_marshal_model_name(cls) -> str:
return f"{cls.__name__}Marshal"

@classmethod
def convert_field(cls, field: FieldInfo) -> tuple[Any, Any]:
if len(field.metadata) == 1 and is_subtype(field.metadata[0], MarshalBaseModel):
if is_subtype(get_origin(field.annotation), list):
return list[field.metadata[0]], field.default # type: ignore
if is_optional(field.annotation):
return field.metadata[0] | None, field.default
return field.metadata[0], field.default
if field.annotation is None:
raise TypeError("Annotation is somehow None")
return field.annotation, field

@classmethod
def build_marshal(cls) -> type[BaseModel]:
return create_model( # type: ignore[call-overload, no-any-return]
cls.generate_marshal_model_name(),
**{
file_name: cls.convert_field(field)
for file_name, field in cls.model_fields.items()
},
)
2 changes: 1 addition & 1 deletion pydantic_marshals/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


class MarshalBaseModel(BaseModel):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict(from_attributes=True)


class FieldConverter:
Expand Down
1 change: 0 additions & 1 deletion pydantic_marshals/contains/fields/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ def convert(cls, source: Any = None, *_: Any) -> Self | None:
**fields,
__config__=ConfigDict( # TODO maybe move to `contains`
from_attributes=True,
populate_by_name=True,
arbitrary_types_allowed=True,
),
),
Expand Down
7 changes: 6 additions & 1 deletion pydantic_marshals/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, TypeVar
from types import NoneType, UnionType
from typing import Any, TypeVar, get_args, get_origin

from pydantic import BaseModel

Expand All @@ -8,3 +9,7 @@

def is_subtype(maybe_type: Any, klass: type) -> bool:
return isinstance(maybe_type, type) and issubclass(maybe_type, klass)


def is_optional(annotation: Any) -> bool:
return get_origin(annotation) == UnionType and NoneType in get_args(annotation)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydantic-marshals"
version = "0.3.13"
version = "0.3.14"
description = "Library for creating partial pydantic models (automatic converters) from different mappings"
authors = ["niqzart <niqzart@gmail.com>"]
readme = "README.md"
Expand Down
156 changes: 156 additions & 0 deletions tests/unit/base/test_composite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from typing import Annotated, Any
from unittest.mock import Mock

import pytest
from pydantic import create_model
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined

from pydantic_marshals.base import composite
from pydantic_marshals.base.models import MarshalBaseModel
from tests.unit.conftest import DummyFactory, MockStack


@pytest.fixture()
def composite_marshal_model_name() -> str:
return "T"


@pytest.fixture()
def composite_marshal_model(
composite_marshal_model_name: str,
) -> type[composite.CompositeMarshalModel]:
return create_model(
composite_marshal_model_name,
__base__=composite.CompositeMarshalModel,
)


def test_composite_model_config(
composite_marshal_model: type[composite.CompositeMarshalModel],
) -> None:
assert composite_marshal_model.model_config.get("from_attributes") is True


def test_generate_marshal_model_name(
composite_marshal_model_name: str,
composite_marshal_model: type[composite.CompositeMarshalModel],
) -> None:
real_marshal_model_name = composite_marshal_model.generate_marshal_model_name()
assert real_marshal_model_name == f"{composite_marshal_model_name}Marshal"


def test_convert_field_ignored(
dummy_factory: DummyFactory,
mock_stack: MockStack,
composite_marshal_model: type[composite.CompositeMarshalModel],
) -> None:
field_info_mock = Mock(FieldInfo)
field_info_mock.metadata = []
field_info_mock.annotation = dummy_factory("annotation")

converted_field = composite_marshal_model.convert_field(field_info_mock)

assert isinstance(converted_field, tuple)
assert len(converted_field) == 2

assert converted_field[0] is dummy_factory("annotation")
assert converted_field[1] is field_info_mock


some_marshal_model = create_model("T", __base__=MarshalBaseModel)


@pytest.mark.parametrize(
(
"initial_annotation",
"expected_annotation",
"default",
),
[
pytest.param(
Annotated[int, some_marshal_model],
some_marshal_model,
PydanticUndefined,
id="required",
),
pytest.param(
Annotated[int | None, some_marshal_model],
some_marshal_model | None,
PydanticUndefined,
id="required_union",
),
pytest.param(
Annotated[int | None, some_marshal_model],
some_marshal_model | None,
None,
id="optional",
),
pytest.param(
Annotated[list[int], some_marshal_model],
list[some_marshal_model], # type: ignore[valid-type]
PydanticUndefined,
id="list",
),
pytest.param(
Annotated[list[int], some_marshal_model],
list[some_marshal_model], # type: ignore[valid-type]
[],
id="list_with_default",
),
],
)
def test_convert_field_unpacked(
mock_stack: MockStack,
composite_marshal_model: type[composite.CompositeMarshalModel],
initial_annotation: Any,
expected_annotation: Any,
default: Any,
) -> None:
some_field_info = FieldInfo.from_annotated_attribute(initial_annotation, default)

converted_field = composite_marshal_model.convert_field(some_field_info)

assert isinstance(converted_field, tuple)
assert len(converted_field) == 2

assert converted_field[0] == expected_annotation
assert converted_field[1] is default


def test_build_marshal(
dummy_factory: DummyFactory,
mock_stack: MockStack,
composite_marshal_model: type[composite.CompositeMarshalModel],
) -> None:
model_fields_mock = mock_stack.enter_mock(
composite_marshal_model,
"model_fields",
property_value={"field_name": dummy_factory("initial_field")},
)
generate_marshal_model_name_mock = mock_stack.enter_mock(
composite_marshal_model,
"generate_marshal_model_name",
return_value=dummy_factory("name"),
)
convert_field_mock = mock_stack.enter_mock(
composite_marshal_model,
"convert_field",
return_value=dummy_factory("converted_field"),
)

create_model_mock = mock_stack.enter_mock(
composite, "create_model", return_value=dummy_factory("return")
)

assert composite_marshal_model.build_marshal() is dummy_factory("return")

create_model_mock.assert_called_once_with(
dummy_factory("name"),
field_name=dummy_factory("converted_field"),
)

convert_field_mock.assert_called_once_with(dummy_factory("initial_field"))

generate_marshal_model_name_mock.assert_called_once_with()
model_fields_mock.assert_called_once_with()
9 changes: 1 addition & 8 deletions tests/unit/base/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,12 @@

from pydantic_marshals.base import models
from pydantic_marshals.base.fields.base import MarshalField
from pydantic_marshals.contains import assert_contains
from tests.unit.conftest import DummyFactory, MockStack


def test_base_model_class() -> None:
pydantic_model = create_model("T", __base__=models.MarshalModel.model_base_class)
assert_contains(
pydantic_model.model_config,
{
"from_attributes": True,
"populate_by_name": True,
},
)
assert pydantic_model.model_config.get("from_attributes") is True


@pytest.mark.parametrize("base_count", [0, 1, 2])
Expand Down

0 comments on commit 3f14d41

Please # to comment.