Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/msgspec #360

Merged
merged 22 commits into from
Feb 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ae7f63b
Merge pull request #352 from reagento/develop
zhPavel Dec 14, 2024
6dbc529
connected msgspec shape builder
close2code-palm Jan 23, 2025
de48632
added overriden_types to introspection and package_flags_checks
close2code-palm Jan 24, 2025
e207393
ClassVar and kw_only support
close2code-palm Jan 24, 2025
d67dbfe
basic test and tests setup
close2code-palm Jan 24, 2025
6dafd25
required param from introspection
close2code-palm Jan 24, 2025
e8fa2e8
no kw_only data in struct documented and inheritance test
close2code-palm Jan 24, 2025
abadb4d
msgspec features switched, annotated and forward ref
close2code-palm Jan 24, 2025
91c5080
generic struct test
close2code-palm Jan 24, 2025
524e728
msgspec is not installed expectation
close2code-palm Jan 24, 2025
7c77cf2
msgspec as an optional dependency
close2code-palm Jan 25, 2025
521c1e0
native msgspec struct -> native -> struct conversion
close2code-palm Jan 25, 2025
781e64c
unit and integration tests, fixes
close2code-palm Jan 25, 2025
d304417
rm trash docstring
close2code-palm Jan 25, 2025
7dbc485
typed dicts for convert and to_builtins for accuracy and required onl…
close2code-palm Jan 25, 2025
5340966
conventions fix, dead code rm, ParamKind from signature
close2code-palm Jan 26, 2025
a019ce4
introspection improvements
close2code-palm Jan 27, 2025
55fad9f
short msgspec documentation with example, __all__ fix
close2code-palm Jan 28, 2025
7b1af44
Typos, content and styling fixes, example
close2code-palm Jan 30, 2025
959bd67
description of default behaviour and native msgspec feature
close2code-palm Feb 8, 2025
79a2811
linting and strict version
close2code-palm Feb 8, 2025
87d0673
missing file
close2code-palm Feb 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/examples/reference/integrations/native_msgspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import datetime

from msgspec import Struct

from adaptix import Retort
from adaptix.integrations.msgspec import native_msgspec


class Music(Struct):
released: datetime.date
composition: str

data = {
"released": datetime.date(2007,1,20),
"composition": "Espacio de silencio",
}

retort = Retort(
recipe=[
native_msgspec(Music, to_builtins={"builtin_types":[datetime.date]}),
],
)

assert data == retort.dump(
Music(
datetime.date(2007, 1, 20),
"Espacio de silencio",
),
)
12 changes: 12 additions & 0 deletions docs/reference/integrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Models that are supported out of the box:
- `attrs <https://www.attrs.org/en/stable/>`__ (only from ``>=21.3.0``)
- `sqlalchemy <https://docs.sqlalchemy.org/en/20/>`__ (only from ``>=2.0.0``)
- `pydantic <https://docs.pydantic.dev/latest/>`__ (only from ``>=2.0.0``)
- `msgspec <https://jcristharif.com/msgspec/>`__ (only from ``>=0.14.0``)

Arbitrary types also are supported to be loaded by introspection of ``__init__`` method,
but it can not be dumped.
Expand Down Expand Up @@ -108,6 +109,17 @@ You can override this behavior to use a native pydantic validation/serialization

.. literalinclude:: /examples/reference/integrations/native_pydantic.py

.. _msgspec:

Working with msgspec
=============

By default, any msgspec Struct is loaded, dumped and converted like any other model.
If your code uses specific options for ``to_builtins`` or ``convert`` functions, you can specify
them with native msgspec mechanism defined in Retort as shown in example.

.. literalinclude:: /examples/reference/integrations/native_msgspec.py
zhPavel marked this conversation as resolved.
Show resolved Hide resolved

.. _sqlalchemy_json:

SQLAlchemy JSON
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ sqlalchemy = ['sqlalchemy >= 2.0.0']
sqlalchemy-strict = ['sqlalchemy >= 2.0.0, <= 2.0.36']
pydantic = ['pydantic >= 2.0.0']
pydantic-strict = ['pydantic >= 2.0.0, <= 2.10.3']
msgspec = ['msgspec >= 0.14.0']
msgspec-strict = ['msgspec >= 0.14.0, <= 0.19.0']

[project.urls]
'Homepage' = 'https://github.com/reagento/adaptix'
Expand Down
3 changes: 3 additions & 0 deletions src/adaptix/_internal/feature_requirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ def fail_reason(self) -> str:
HAS_SUPPORTED_ATTRS_PKG = DistributionVersionRequirement("attrs", "21.3.0")
HAS_ATTRS_PKG = DistributionRequirement("attrs")

HAS_SUPPORTED_MSGSPEC_PKG = DistributionVersionRequirement("msgspec", "0.14.0")
HAS_MSGSPEC_PKG = DistributionRequirement("msgspec")

HAS_SUPPORTED_SQLALCHEMY_PKG = DistributionVersionRequirement("sqlalchemy", "2.0.0")
HAS_SQLALCHEMY_PKG = DistributionRequirement("sqlalchemy")

Expand Down
Empty file.
82 changes: 82 additions & 0 deletions src/adaptix/_internal/integrations/msgspec/native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import contextlib
from collections.abc import Iterable
from typing import Any, Callable, Optional, TypedDict, TypeVar

from adaptix import Dumper, Loader, Mediator, Provider
from adaptix._internal.morphing.load_error import LoadError
from adaptix._internal.morphing.provider_template import DumperProvider, LoaderProvider
from adaptix._internal.morphing.request_cls import DumperRequest, LoaderRequest
from adaptix._internal.provider.facade.provider import bound_by_any
from adaptix._internal.provider.loc_stack_filtering import Pred

with contextlib.suppress(ImportError):
from msgspec import ValidationError, convert, to_builtins


T = TypeVar("T")


class Convert(TypedDict, total=False):
builtin_types: Iterable[type]
str_keys: bool
strict: bool
from_attributes: bool
dec_hook: Callable[[Any], Any]


class ToBuiltins(TypedDict, total=False):
builtin_types: Iterable[type]
str_keys: bool
enc_hook: Callable[[Any], Any]


class NativeMsgspecProvider(LoaderProvider, DumperProvider):
zhPavel marked this conversation as resolved.
Show resolved Hide resolved
def __init__(
self,
conversion_params: Optional[Convert],
to_builtins_params: Optional[ToBuiltins],
):
self.conversion_params = conversion_params
self.to_builtins_params = to_builtins_params

def provide_loader(self, mediator: Mediator[Loader], request: LoaderRequest) -> Loader:
tp = request.last_loc.type
if conversion_params := self.conversion_params:
def native_msgspec_loader(data):
try:
return convert(data, type=tp, **conversion_params)
except ValidationError as e:
raise LoadError() from e

return native_msgspec_loader

def native_msgspec_loader_no_params(data):
try:
return convert(data, type=tp)
except ValidationError as e:
raise LoadError() from e

return native_msgspec_loader_no_params

def provide_dumper(self, mediator: Mediator[Dumper], request: DumperRequest) -> Dumper:
if to_builtins_params := self.to_builtins_params:
def native_msgspec_dumper_with_params(data):
return to_builtins(data, **to_builtins_params)

return native_msgspec_dumper_with_params

return to_builtins


def native_msgspec(
*preds: Pred,
convert: Optional[Convert] = None,
to_builtins: Optional[ToBuiltins] = None,
) -> Provider:
return bound_by_any(
preds,
NativeMsgspecProvider(
conversion_params=convert,
to_builtins_params=to_builtins,
),
)
118 changes: 118 additions & 0 deletions src/adaptix/_internal/model_tools/introspection/msgspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import inspect
from collections.abc import Mapping
from types import MappingProxyType

try:
from msgspec import NODEFAULT
from msgspec.structs import FieldInfo, fields
except ImportError:
pass
from ...feature_requirement import HAS_MSGSPEC_PKG, HAS_SUPPORTED_MSGSPEC_PKG
from ...type_tools import get_all_type_hints, is_class_var, normalize_type
from ..definitions import (
Default,
DefaultFactory,
DefaultValue,
FullShape,
InputField,
InputShape,
IntrospectionError,
NoDefault,
NoTargetPackageError,
OutputField,
OutputShape,
Param,
ParamKind,
TooOldPackageError,
create_attr_accessor,
)


def _get_default_from_field_info(fi: "FieldInfo") -> Default:
if fi.default is not NODEFAULT:
return DefaultValue(fi.default)
if fi.default_factory is not NODEFAULT:
return DefaultFactory(fi.default_factory)
return NoDefault()


def _create_input_field_from_structs_field_info(fi: "FieldInfo", type_hints: Mapping[str, type]) -> InputField:
default = _get_default_from_field_info(fi)
return InputField(
id=fi.name,
type=type_hints[fi.name],
default=default,
is_required=fi.required,
original=fi,
metadata=MappingProxyType({}),
)


def _get_kind_from_sig(param_kind):
if param_kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
return ParamKind.POS_OR_KW
if param_kind == inspect.Parameter.KEYWORD_ONLY:
return ParamKind.KW_ONLY
if param_kind == inspect.Parameter.POSITIONAL_ONLY:
return ParamKind.POS_ONLY
raise IntrospectionError


def get_struct_shape(tp) -> FullShape:
if not HAS_SUPPORTED_MSGSPEC_PKG:
if not HAS_MSGSPEC_PKG:
raise NoTargetPackageError(HAS_MSGSPEC_PKG)
raise TooOldPackageError(HAS_SUPPORTED_MSGSPEC_PKG)

try:
fields_info = fields(tp)
except TypeError:
raise IntrospectionError

type_hints = get_all_type_hints(tp)
init_fields = tuple(
field_name
for field_name in type_hints
if not is_class_var(normalize_type(type_hints[field_name]))
)
param_sig = inspect.signature(tp).parameters
return FullShape(
InputShape(
constructor=tp,
fields=tuple(
_create_input_field_from_structs_field_info(fi, type_hints)
for fi in fields_info if fi.name in init_fields
),
params=tuple(
Param(
field_id=field_id,
name=field_id,
kind=_get_kind_from_sig(param_sig[field_id].kind),
)
for field_id in type_hints
if field_id in init_fields
),
kwargs=None,
overriden_types=frozenset(
annotation
for annotation in tp.__annotations__
if annotation in init_fields
),
),
OutputShape(
fields=tuple(
OutputField(
id=fi.name,
type=type_hints[fi.name],
original=fi,
metadata=MappingProxyType({}),
default=_get_default_from_field_info(fi),
accessor=create_attr_accessor(attr_name=fi.name, is_required=True),
) for fi in fields_info),
overriden_types=frozenset(
annotation
for annotation in tp.__annotations__
if annotation in init_fields
),
),
)
2 changes: 2 additions & 0 deletions src/adaptix/_internal/provider/shape_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ..model_tools.introspection.attrs import get_attrs_shape
from ..model_tools.introspection.class_init import get_class_init_shape
from ..model_tools.introspection.dataclass import get_dataclass_shape
from ..model_tools.introspection.msgspec import get_struct_shape
from ..model_tools.introspection.named_tuple import get_named_tuple_shape
from ..model_tools.introspection.pydantic import get_pydantic_shape
from ..model_tools.introspection.sqlalchemy import get_sqlalchemy_shape
Expand Down Expand Up @@ -77,6 +78,7 @@ def _provide_output_shape(self, mediator: Mediator, request: OutputShapeRequest)
ShapeProvider(get_named_tuple_shape),
ShapeProvider(get_typed_dict_shape),
ShapeProvider(get_dataclass_shape),
ShapeProvider(get_struct_shape),
ShapeProvider(get_attrs_shape),
ShapeProvider(get_sqlalchemy_shape),
ShapeProvider(get_pydantic_shape),
Expand Down
5 changes: 5 additions & 0 deletions src/adaptix/integrations/msgspec/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from adaptix._internal.integrations.msgspec.native import native_msgspec

__all__ = (
"native_msgspec",
)
9 changes: 8 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
from tests_helpers import ByTrailSelector, ModelSpecSchema, cond_list, parametrize_model_spec

from adaptix import DebugTrail
from adaptix._internal.feature_requirement import HAS_ATTRS_PKG, HAS_PY_312, HAS_PYDANTIC_PKG, HAS_SQLALCHEMY_PKG
from adaptix._internal.feature_requirement import (
HAS_ATTRS_PKG,
HAS_MSGSPEC_PKG,
HAS_PY_312,
HAS_PYDANTIC_PKG,
HAS_SQLALCHEMY_PKG,
)


@pytest.fixture(params=[False, True], ids=lambda x: f"strict_coercion={x}")
Expand Down Expand Up @@ -46,4 +52,5 @@ def pytest_generate_tests(metafunc):
*cond_list(not HAS_ATTRS_PKG, ["*_attrs.py", "*_attrs_*.py", "**/attrs/**"]),
*cond_list(not HAS_PYDANTIC_PKG, ["*_pydantic.py", "*_pydantic_*.py", "**/pydantic/**"]),
*cond_list(not HAS_SQLALCHEMY_PKG, ["*_sqlalchemy.py", "*_sqlalchemy_*.py", "**/sqlalchemy/**"]),
*cond_list(not HAS_MSGSPEC_PKG,["*_msgspec.py", "*_msgspec_*.py", "**/msgspec/**"]),
]
29 changes: 29 additions & 0 deletions tests/integration/morphing/test_msgspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import ClassVar, Generic, TypeVar

from msgspec import Struct, field

from adaptix import Retort


def test_basic(accum):
class MyModel(Struct):
f1: int
f2: str

retort = Retort(recipe=[accum])
assert retort.load({"f1": 0, "f2": "a"}, MyModel) == MyModel(f1=0, f2="a")
assert retort.dump(MyModel(f1=0, f2="a")) == {"f1": 0, "f2": "a"}

T = TypeVar("T")

def test_all_field_kinds(accum):
class MyModel(Struct, Generic[T]):
a: int
b: T
c: str = field(default="c", name="_c")
d: ClassVar[float] = 2.11


retort = Retort(recipe=[accum])
assert retort.load({"a": 0, "b": 3}, MyModel[int]) == MyModel(a=0, b=3)
assert retort.dump(MyModel(a=0, b=True), MyModel[bool]) == {"a": 0, "b": True, "c": "c"}
2 changes: 2 additions & 0 deletions tests/test_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from adaptix._internal.feature_requirement import (
HAS_PY_311,
HAS_PY_312,
HAS_SUPPORTED_MSGSPEC_PKG,
HAS_SUPPORTED_PYDANTIC_PKG,
HAS_SUPPORTED_SQLALCHEMY_PKG,
Requirement,
Expand Down Expand Up @@ -52,6 +53,7 @@ def pytest_generate_tests(metafunc):
"conversion/tutorial/tldr": HAS_SUPPORTED_SQLALCHEMY_PKG,
"why_not_pydantic/instantiating_penalty*": AndRequirement(HAS_PY_312, HAS_SUPPORTED_PYDANTIC_PKG),
"why_not_pydantic/*": HAS_SUPPORTED_PYDANTIC_PKG,
"reference/integrations/native_msgspec": HAS_SUPPORTED_MSGSPEC_PKG,
}


Expand Down
Loading
Loading