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

Open
wants to merge 20 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 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
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ 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"]

[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.
81 changes: 81 additions & 0 deletions src/adaptix/_internal/integrations/msgspec/native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from typing import Any, Callable, Iterable, Mapping, Optional, TypedDict, TypeVar

from adaptix import Dumper, Loader, Mediator, Omittable, Omitted, 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

try:
from msgspec import ValidationError, convert, to_builtins
except ImportError:
pass

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[Convert] = None,
) -> Provider:
return bound_by_any(
preds,
NativeMsgspecProvider(
conversion_params=convert,
to_builtins_params=to_builtins,
),
)
108 changes: 108 additions & 0 deletions src/adaptix/_internal/model_tools/introspection/msgspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import inspect
from types import MappingProxyType
from typing import Mapping

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_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]))
)
input_param_kind = ParamKind.KW_ONLY if "*" in inspect.signature(tp) else ParamKind.POS_OR_KW
zhPavel marked this conversation as resolved.
Show resolved Hide resolved
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=input_param_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__.keys()
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"}
6 changes: 6 additions & 0 deletions tests/tests_helpers/tests_helpers/model_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from adaptix._internal.feature_requirement import (
HAS_ATTRS_PKG,
HAS_MSGSPEC_PKG,
HAS_PY_311,
HAS_PYDANTIC_PKG,
HAS_SQLALCHEMY_PKG,
Expand Down Expand Up @@ -38,13 +39,15 @@ class ModelSpec(Enum):
ATTRS = "attrs"
SQLALCHEMY = "sqlalchemy"
PYDANTIC = "pydantic"
MSGSPEC = "msgspec"

@classmethod
def default_requirements(cls):
return {
cls.ATTRS: HAS_ATTRS_PKG,
cls.SQLALCHEMY: HAS_SQLALCHEMY_PKG,
cls.PYDANTIC: HAS_PYDANTIC_PKG,
cls.MSGSPEC: HAS_MSGSPEC_PKG,
}


Expand Down Expand Up @@ -105,6 +108,9 @@ class CustomBaseModel(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)

return ModelSpecSchema(decorator=lambda x: x, bases=(CustomBaseModel, ), get_field=getattr, kind=spec)
if spec == ModelSpec.MSGSPEC:
from msgspec import Struct
return ModelSpecSchema(decorator=lambda x: x, bases=(Struct,), get_field=getattr, kind=spec)
raise ValueError


Expand Down
Empty file.
69 changes: 69 additions & 0 deletions tests/unit/integrations/msgspec/test_native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from msgspec import Struct, ValidationError
from tests_helpers import raises_exc, with_cause

from adaptix import Retort
from adaptix._internal.integrations.msgspec.native import native_msgspec
from adaptix._internal.morphing.load_error import LoadError


def create_stub_validation_error():
error = ValidationError()
error.args = ["Expected `int`, got `str` - at `$.a`"]
return error


def test_validation_without_params():
class MyModel(Struct):
a: int
b: str

retort = Retort(
recipe=[native_msgspec(MyModel)],
)

loader_ = retort.get_loader(MyModel)
assert loader_({"a": 1, "b": "value"}) == MyModel(a=1, b="value")
raises_exc(
with_cause(LoadError(), create_stub_validation_error()),
lambda: loader_({"a": "abc", "b": "value"}),
)


def test_with_conversion_params():
class MyModel(Struct):
a: int
b: str

retort = Retort(
recipe=[native_msgspec(MyModel, convert={"strict": True})],
)

loader_ = retort.get_loader(MyModel)
assert loader_({"a": 1, "b": "value"}) == MyModel(a=1, b="value")
raises_exc(
with_cause(LoadError(), create_stub_validation_error()),
lambda: loader_({"a": "1", "b": "value"}),
)

dumper_ = retort.get_dumper(MyModel)
assert dumper_(MyModel(a=1, b="value")) == {"a": 1, "b": "value"}


def test_to_builtins_with_params():
class MyModel(Struct):
a: int
b: str

retort = Retort(
recipe=[native_msgspec(MyModel, to_builtins={"str_keys": False})],
)

loader_ = retort.get_loader(MyModel)
assert loader_({"a": 1, "b": "value"}) == MyModel(a=1, b="value")
raises_exc(
with_cause(LoadError(), create_stub_validation_error()),
lambda: loader_({"a": "1", "b": "value"}),
)

dumper_ = retort.get_dumper(MyModel)
assert dumper_(MyModel(a=1, b="value")) == {"a": 1, "b": "value"}
Loading