diff --git a/README.md b/README.md index d6c0df83..630cd02a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Happy coding with pyserde! 🚀 - `set`, `collections.abc.Set`, `collections.abc.MutableSet` - `dict`, `collections.abc.Mapping`, `collections.abc.MutableMapping` - [`frozenset`](https://docs.python.org/3/library/stdtypes.html#frozenset), [`defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict), [`deque`](https://docs.python.org/3/library/collections.html#collections.deque), [`Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) + - [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) - [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional) - [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union) - User defined class with [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) diff --git a/docs/en/types.md b/docs/en/types.md index 9494673f..b7f2631b 100644 --- a/docs/en/types.md +++ b/docs/en/types.md @@ -14,6 +14,7 @@ Here is the list of the supported types. See the simple example for each type in * [`defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) [^4] * [`deque`](https://docs.python.org/3/library/collections.html#collections.deque) [^25] * [`Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) [^26] +* [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) [^27] * [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional) [^5] * [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union) [^6] [^7] [^8] * User defined class with [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) [^9] [^10] @@ -50,6 +51,49 @@ class Foo: f: Bar ``` +## TypedDict + +[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) can be used as a field type in a `@serde` dataclass, or deserialized directly via `from_dict` / `from_json`. + +```python +from typing import NotRequired, TypedDict +from serde import serde +from serde.json import to_json, from_json + +class Movie(TypedDict): + title: str + year: int + director: str + +class Person(TypedDict): + name: str + age: int + email: NotRequired[str] # optional field + +@serde +class Cinema: + location: str + featured: Movie +``` + +```python +>>> movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"} +>>> cinema = Cinema(location="Downtown", featured=movie) + +>>> to_json(cinema) +'{"location":"Downtown","featured":{"title":"Inception","year":2010,"director":"Christopher Nolan"}}' + +>>> from_json(Cinema, to_json(cinema)) +Cinema(location='Downtown', featured={'title': 'Inception', 'year': 2010, 'director': 'Christopher Nolan'}) +``` + +`NotRequired` fields are omitted from the output when absent, and optional on input: + +```python +>>> person_with_email: Person = {"name": "Alice", "age": 30, "email": "alice@example.com"} +>>> person_no_email: Person = {"name": "Bob", "age": 25} +``` + ## Numpy All of the above (de)serialization methods can transparently handle most numpy types with the "numpy" extras package. @@ -157,3 +201,5 @@ If you need to use a type which is currently not supported in the standard libra [^25]: See [examples/deque.py](https://github.com/yukinarit/pyserde/blob/main/examples/deque.py) [^26]: See [examples/counter.py](https://github.com/yukinarit/pyserde/blob/main/examples/counter.py) + +[^27]: See [examples/typeddict.py](https://github.com/yukinarit/pyserde/blob/main/examples/typeddict.py) diff --git a/docs/ja/types.md b/docs/ja/types.md index f1454bff..9977537c 100644 --- a/docs/ja/types.md +++ b/docs/ja/types.md @@ -14,6 +14,7 @@ * [`defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) [^4] * [`deque`](https://docs.python.org/3/library/collections.html#collections.deque) [^25] * [`Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) [^26] +* [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) [^27] * [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[^5] * [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union) [^6] [^7] [^8] * [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) を用いたユーザ定義クラス [^9] [^10] @@ -50,6 +51,49 @@ class Foo: f: Bar ``` +## TypedDict + +[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) は `@serde` デコレータを付けたデータクラスのフィールド型として使用できます。また、`from_dict` / `from_json` で直接デシリアライズすることも可能です。 + +```python +from typing import NotRequired, TypedDict +from serde import serde +from serde.json import to_json, from_json + +class Movie(TypedDict): + title: str + year: int + director: str + +class Person(TypedDict): + name: str + age: int + email: NotRequired[str] # 省略可能なフィールド + +@serde +class Cinema: + location: str + featured: Movie +``` + +```python +>>> movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"} +>>> cinema = Cinema(location="Downtown", featured=movie) + +>>> to_json(cinema) +'{"location":"Downtown","featured":{"title":"Inception","year":2010,"director":"Christopher Nolan"}}' + +>>> from_json(Cinema, to_json(cinema)) +Cinema(location='Downtown', featured={'title': 'Inception', 'year': 2010, 'director': 'Christopher Nolan'}) +``` + +`NotRequired` フィールドは値が存在しない場合に出力から省略され、入力でも省略できます。 + +```python +>>> person_with_email: Person = {"name": "Alice", "age": 30, "email": "alice@example.com"} +>>> person_no_email: Person = {"name": "Bob", "age": 25} +``` + ## Numpy 上記のすべての(デ)シリアライズ方法は、`numpy`追加パッケージを使用することで、ほとんどのnumpyデータ型を透過的に扱うことができます。 @@ -162,3 +206,5 @@ SQLAlchemy宣言的データクラスマッピング統合の実験的サポー [^25]: [examples/deque.py](https://github.com/yukinarit/pyserde/blob/main/examples/deque.py) を参照 [^26]: [examples/counter.py](https://github.com/yukinarit/pyserde/blob/main/examples/counter.py) を参照 + +[^27]: [examples/typeddict.py](https://github.com/yukinarit/pyserde/blob/main/examples/typeddict.py) を参照 diff --git a/examples/runner.py b/examples/runner.py index c9423826..6684cc29 100644 --- a/examples/runner.py +++ b/examples/runner.py @@ -1,7 +1,6 @@ import sys import typing - if sys.version_info[:3] < (3, 12, 0): print("examples require at least Python 3.12") sys.exit(1) @@ -72,6 +71,7 @@ def run_all() -> None: import enum34 import kw_only import self_type + import typeddict run(any) run(simple) @@ -138,6 +138,7 @@ def run_all() -> None: run(type_uuid) run(type_numpy) run(self_type) + run(typeddict) try: import type_sqlalchemy diff --git a/examples/type_statement.py b/examples/type_statement.py index 53b893a8..e93a46ad 100644 --- a/examples/type_statement.py +++ b/examples/type_statement.py @@ -2,7 +2,6 @@ from serde import serde - type Baz = tuple[float, float] type Bar = tuple[Baz, ...] diff --git a/examples/typeddict.py b/examples/typeddict.py new file mode 100644 index 00000000..869be619 --- /dev/null +++ b/examples/typeddict.py @@ -0,0 +1,71 @@ +""" +TypedDict example + +TypedDict allows defining dict types with specific named keys and per-key types. +This is useful for JSON API responses where you want type safety without +full dataclass overhead. +""" + +from typing import NotRequired, TypedDict + +from serde import serde +from serde.json import from_json, to_json + + +# TypedDict with all required fields +class Movie(TypedDict): + title: str + year: int + director: str + + +# TypedDict with optional fields +class Person(TypedDict): + name: str + age: int + email: NotRequired[str] # Optional field + + +# Nested TypedDict +class Library(TypedDict): + name: str + movies: list[Movie] + + +# Using TypedDict as a field in a serde dataclass +@serde +class Cinema: + location: str + featured: Movie + + +def main() -> None: + # Create a Movie TypedDict + movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"} + print(f"Movie: {movie}") + + # Create a Cinema with a Movie field + cinema = Cinema(location="Downtown", featured=movie) + print(f"Cinema: {cinema}") + + # Serialize to JSON + json_str = to_json(cinema) + print(f"To JSON: {json_str}") + + # Deserialize from JSON + restored = from_json(Cinema, json_str) + print(f"From JSON: {restored}") + + # Verify the nested TypedDict + print(f"Featured movie title: {restored.featured['title']}") + + # Test with optional fields + person: Person = {"name": "Alice", "age": 30} # email is optional + print(f"Person without email: {person}") + + person_with_email: Person = {"name": "Bob", "age": 25, "email": "bob@example.com"} + print(f"Person with email: {person_with_email}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index c60a1391..106baf02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,7 +171,7 @@ select = [ ignore = ["B904"] [tool.ruff.lint.mccabe] -max-complexity = 30 +max-complexity = 34 [tool.ruff.lint.per-file-ignores] # https://docs.kidger.site/jaxtyping/faq/#flake8-or-ruff-are-throwing-an-error diff --git a/serde/compat.py b/serde/compat.py index 74b7f615..cc846159 100644 --- a/serde/compat.py +++ b/serde/compat.py @@ -389,6 +389,10 @@ def recursive(cls: Union[type[Any], Any]) -> None: lst.add(tuple) for arg in type_args(cls): recursive(arg) + elif is_typeddict(cls): + lst.add(cls) + for _name, (field_type, _is_req) in typeddict_fields(cls).items(): + recursive(field_type) elif is_dict(cls): lst.add(dict) args = type_args(cls) @@ -1073,6 +1077,57 @@ def is_pep695_type_alias(typ: Any) -> bool: return isinstance(typ, _PEP695_TYPES) +def is_typeddict(typ: Any) -> bool: + """ + Test if the type is a TypedDict. + + >>> from typing import TypedDict + >>> class Movie(TypedDict): + ... title: str + ... year: int + >>> is_typeddict(Movie) + True + >>> is_typeddict(dict) + False + """ + return typing_extensions.is_typeddict(typ) + + +def typeddict_fields(typ: type[Any]) -> dict[str, tuple[type[Any], bool]]: + """ + Get fields from a TypedDict with their types and required status. + + Returns a dict mapping field name to (unwrapped_type, is_required). + + >>> from typing import TypedDict, NotRequired + >>> class Movie(TypedDict): + ... title: str + ... year: NotRequired[int] + >>> fields = typeddict_fields(Movie) + >>> fields['title'] + (, True) + >>> fields['year'] + (, False) + """ + hints = typing.get_type_hints(typ, include_extras=True) + required_keys = frozenset(getattr(typ, "__required_keys__", hints.keys())) + + result: dict[str, tuple[type[Any], bool]] = {} + for name, field_type in hints.items(): + origin = get_origin(field_type) + # Unwrap NotRequired[T] or Required[T] wrappers + if origin is typing_extensions.NotRequired: + inner = type_args(field_type)[0] + result[name] = (inner, False) + elif origin is typing_extensions.Required: + inner = type_args(field_type)[0] + result[name] = (inner, True) + else: + result[name] = (field_type, name in required_keys) + + return result + + @cache def get_type_var_names(cls: type[Any]) -> list[str] | None: """ diff --git a/serde/core.py b/serde/core.py index 86aa953f..8efa5667 100644 --- a/serde/core.py +++ b/serde/core.py @@ -49,9 +49,11 @@ is_opt_dataclass, is_set, is_tuple, + is_typeddict, is_union, is_variable_tuple, type_args, + typeddict_fields, typename, _WithTagging, ) @@ -368,7 +370,9 @@ def is_instance(obj: Any, typ: Any) -> bool: pyserde's own `isinstance` helper. It accepts subscripted generics e.g. `list[int]` and deeply check object against declared type. """ - if dataclasses.is_dataclass(typ): + if is_typeddict(typ): + return is_typeddict_instance(obj, typ) + elif dataclasses.is_dataclass(typ): if not isinstance(typ, type): raise SerdeError("expect dataclass class but dataclass instance received") return isinstance(obj, typ) @@ -421,6 +425,18 @@ def is_union_instance(obj: Any, typ: type[Any]) -> bool: return False +def is_typeddict_instance(obj: Any, typ: type[Any]) -> bool: + if not isinstance(obj, dict): + return False + td_fields = typeddict_fields(typ) + for name, (field_type, is_required) in td_fields.items(): + if is_required and name not in obj: + return False + if name in obj and not is_instance(obj[name], field_type): + return False + return True + + def is_list_instance(obj: Any, typ: type[Any]) -> bool: origin = get_origin(typ) or typ if origin is list: diff --git a/serde/de.py b/serde/de.py index 01479390..55e0518d 100644 --- a/serde/de.py +++ b/serde/de.py @@ -56,6 +56,7 @@ is_set, is_str_serializable, is_tuple, + is_typeddict, is_union, is_variable_tuple, is_pep695_type_alias, @@ -63,6 +64,7 @@ iter_types, iter_unions, type_args, + typeddict_fields, typename, ) from .core import ( @@ -535,6 +537,15 @@ def deserializable_to_obj(cls: type[T]) -> T: res = tuple(e for e in o) else: res = tuple(thisfunc(type_args(c)[i], e) for i, e in enumerate(o)) + elif is_typeddict(c): + td_fields = typeddict_fields(c) + result: dict[str, Any] = {} + for name, (field_type, is_required) in td_fields.items(): + if name in o: + result[name] = thisfunc(field_type, o[name]) + elif is_required: + raise SerdeError(f"Missing required key '{name}' for TypedDict {typename(c)}") + res = result elif is_dict(c): if is_bare_dict(c): res = o @@ -879,6 +890,8 @@ def render(self, arg: DeField[Any]) -> str: res = self.deque(arg) elif is_counter(arg.type): res = self.counter(arg) + elif is_typeddict(arg.type): + res = self.typeddict(arg) elif is_dict(arg.type): res = self.dict(arg) elif is_tuple(arg.type): @@ -1103,6 +1116,23 @@ def dict(self, arg: DeField[Any]) -> str: v = arg.value_field() return f"{{{self.render(k)}: {self.render(v)} for k, v in {arg.data}.items()}}" + def typeddict(self, arg: DeField[Any]) -> str: + """ + Render rvalue for TypedDict deserialization. + """ + td_fields = typeddict_fields(arg.type) + parts = [] + for name, (field_type, is_required) in td_fields.items(): + inner = DeField(field_type, name, datavar=arg.data) + rendered_value = self.render(inner) + if is_required: + parts.append(f'"{name}": {rendered_value}') + else: + parts.append( + f'**({{"{name}": {rendered_value}}} if "{name}" in {arg.data} else {{}})' + ) + return "{" + ", ".join(parts) + "}" + def enum(self, arg: DeField[Any]) -> str: return f"{typename(arg.type)}({self.primitive(arg)})" diff --git a/serde/se.py b/serde/se.py index c3f5a204..614e5787 100644 --- a/serde/se.py +++ b/serde/se.py @@ -57,12 +57,14 @@ is_str_serializable, is_str_serializable_instance, is_tuple, + is_typeddict, is_union, is_variable_tuple, is_pep695_type_alias, iter_types, iter_unions, type_args, + typeddict_fields, typename, ) from .core import ( @@ -422,6 +424,11 @@ def serializable_to_obj(object: Any) -> Any: return [thisfunc(e) for e in o] elif is_bearable(o, tuple): # type: ignore[arg-type] # pyright: ignore[reportArgumentType] return tuple(thisfunc(e) for e in o) + elif is_typeddict(type(o)): + td_fields = typeddict_fields(type(o)) + return { + name: thisfunc(o[name]) for name, (_ft, _is_req) in td_fields.items() if name in o + } elif isinstance(o, Mapping): return {k: thisfunc(v) for k, v in o.items()} elif isinstance(o, Set): @@ -895,6 +902,8 @@ def render(self, arg: SeField[Any]) -> str: res = self.deque(arg) elif is_counter(arg.type): res = self.counter(arg) + elif is_typeddict(arg.type): + res = self.typeddict(arg) elif is_dict(arg.type): res = self.dict(arg) elif is_tuple(arg.type): @@ -1071,6 +1080,23 @@ def dict(self, arg: SeField[Any]) -> str: varg.name = "v" return f"{{{self.render(karg)}: {self.render(varg)} for k, v in {arg.varname}.items()}}" + def typeddict(self, arg: SeField[Any]) -> str: + """ + Render rvalue for TypedDict serialization. + """ + td_fields = typeddict_fields(arg.type) + parts = [] + for name, (field_type, is_required) in td_fields.items(): + inner = SeField(field_type, name=f'{arg.varname}["{name}"]') + rendered_value = self.render(inner) + if is_required: + parts.append(f'"{name}": {rendered_value}') + else: + parts.append( + f'**({{"{name}": {rendered_value}}} if "{name}" in {arg.varname} else {{}})' + ) + return "{" + ", ".join(parts) + "}" + def enum(self, arg: SeField[Any]) -> str: return f"enum_value({typename(arg.type)}, {arg.varname})" diff --git a/tests/test_toml.py b/tests/test_toml.py index 9d2e2da9..bda77085 100644 --- a/tests/test_toml.py +++ b/tests/test_toml.py @@ -35,21 +35,15 @@ class Foo: b: int | None f = Foo(10, 100) - assert ( - to_toml(f) - == """\ + assert to_toml(f) == """\ a = 10 b = 100 """ - ) f = Foo(10, None) - assert ( - to_toml(f) - == """\ + assert to_toml(f) == """\ a = 10 """ - ) def test_skip_none_container_not_supported_yet() -> None: diff --git a/tests/test_type_alias.py b/tests/test_type_alias.py index 2fc886a3..362c18f8 100644 --- a/tests/test_type_alias.py +++ b/tests/test_type_alias.py @@ -5,7 +5,6 @@ from serde import serde, from_dict, to_dict - try: from typing import TypeAliasType except ImportError: # pragma: no cover diff --git a/tests/test_typeddict.py b/tests/test_typeddict.py new file mode 100644 index 00000000..6b109044 --- /dev/null +++ b/tests/test_typeddict.py @@ -0,0 +1,266 @@ +""" +Tests for TypedDict support in pyserde. +""" + +from __future__ import annotations + +from typing import NotRequired, TypedDict + +import pytest + +from serde import serde +from serde.core import is_instance +from serde.compat import SerdeError +from serde.de import from_dict +from serde.json import from_json, to_json +from serde.se import to_dict + +# --- TypedDict type definitions --- + + +class Movie(TypedDict): + title: str + year: int + director: str + + +class PersonOptEmail(TypedDict): + name: str + age: int + email: NotRequired[str] + + +class PersonTotalFalse(TypedDict, total=False): + name: str + age: int + + +class Library(TypedDict): + name: str + movies: list[Movie] + + +class NestedTypedDict(TypedDict): + library: Library + active: bool + + +# --- Dataclasses with TypedDict fields --- + + +@serde +class Cinema: + location: str + featured: Movie + + +@serde +class CinemaOptPerson: + name: str + contact: PersonOptEmail + + +@serde +class CinemaTotalFalse: + name: str + staff: PersonTotalFalse + + +@serde +class CinemaLibrary: + region: str + library: Library + + +@serde +class CinemaDeep: + city: str + data: NestedTypedDict + + +# --- Tests: TypedDict as field in serde dataclass --- + + +def test_typeddict_field_to_dict(): + movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"} + cinema = Cinema(location="Downtown", featured=movie) + d = to_dict(cinema) + assert d == { + "location": "Downtown", + "featured": {"title": "Inception", "year": 2010, "director": "Christopher Nolan"}, + } + + +def test_typeddict_field_from_dict(): + d = { + "location": "Downtown", + "featured": {"title": "Inception", "year": 2010, "director": "Christopher Nolan"}, + } + cinema = from_dict(Cinema, d) + assert cinema.location == "Downtown" + assert cinema.featured["title"] == "Inception" + assert cinema.featured["year"] == 2010 + + +def test_typeddict_field_json_roundtrip(): + movie: Movie = {"title": "Inception", "year": 2010, "director": "Christopher Nolan"} + cinema = Cinema(location="Downtown", featured=movie) + json_str = to_json(cinema) + restored = from_json(Cinema, json_str) + assert restored == cinema + + +# --- Tests: NotRequired fields --- + + +def test_notrequired_present(): + person: PersonOptEmail = {"name": "Alice", "age": 30, "email": "alice@example.com"} + c = CinemaOptPerson(name="Grand", contact=person) + d = to_dict(c) + assert d["contact"]["email"] == "alice@example.com" + restored = from_dict(CinemaOptPerson, d) + assert restored.contact.get("email") == "alice@example.com" + + +def test_notrequired_absent(): + person: PersonOptEmail = {"name": "Alice", "age": 30} + c = CinemaOptPerson(name="Grand", contact=person) + d = to_dict(c) + assert "email" not in d["contact"] + restored = from_dict(CinemaOptPerson, d) + assert "email" not in restored.contact + + +def test_notrequired_json_roundtrip_present(): + person: PersonOptEmail = {"name": "Bob", "age": 25, "email": "bob@example.com"} + c = CinemaOptPerson(name="Ritz", contact=person) + json_str = to_json(c) + restored = from_json(CinemaOptPerson, json_str) + assert restored == c + + +def test_notrequired_json_roundtrip_absent(): + person: PersonOptEmail = {"name": "Bob", "age": 25} + c = CinemaOptPerson(name="Ritz", contact=person) + json_str = to_json(c) + restored = from_json(CinemaOptPerson, json_str) + assert restored == c + + +# --- Tests: total=False TypedDict --- + + +def test_total_false_all_fields(): + staff: PersonTotalFalse = {"name": "Alice", "age": 30} + c = CinemaTotalFalse(name="Grand", staff=staff) + d = to_dict(c) + assert d["staff"] == {"name": "Alice", "age": 30} + restored = from_dict(CinemaTotalFalse, d) + assert restored.staff == {"name": "Alice", "age": 30} + + +def test_total_false_partial_fields(): + staff: PersonTotalFalse = {"name": "Alice"} + c = CinemaTotalFalse(name="Grand", staff=staff) + d = to_dict(c) + assert "age" not in d["staff"] + restored = from_dict(CinemaTotalFalse, d) + assert "age" not in restored.staff + + +def test_total_false_empty(): + staff: PersonTotalFalse = {} + c = CinemaTotalFalse(name="Grand", staff=staff) + d = to_dict(c) + assert d["staff"] == {} + restored = from_dict(CinemaTotalFalse, d) + assert restored.staff == {} + + +# --- Tests: Nested TypedDict --- + + +def test_nested_typeddict(): + library: Library = { + "name": "City Library", + "movies": [ + {"title": "Inception", "year": 2010, "director": "Nolan"}, + {"title": "Interstellar", "year": 2014, "director": "Nolan"}, + ], + } + c = CinemaLibrary(region="North", library=library) + d = to_dict(c) + assert d["library"]["name"] == "City Library" + assert len(d["library"]["movies"]) == 2 + restored = from_dict(CinemaLibrary, d) + assert restored.library["movies"][0]["title"] == "Inception" + + +def test_deeply_nested_typeddict(): + data: NestedTypedDict = { + "library": { + "name": "City Library", + "movies": [{"title": "Inception", "year": 2010, "director": "Nolan"}], + }, + "active": True, + } + c = CinemaDeep(city="NYC", data=data) + d = to_dict(c) + assert d["data"]["library"]["movies"][0]["title"] == "Inception" + restored = from_dict(CinemaDeep, d) + assert restored.data["library"]["movies"][0]["title"] == "Inception" + assert restored.data["active"] is True + + +# --- Tests: Direct TypedDict serialization --- + + +def test_direct_from_dict_required(): + d = {"title": "Inception", "year": 2010, "director": "Nolan"} + movie = from_dict(Movie, d) + assert movie == d + + +def test_direct_from_dict_notrequired_present(): + d = {"name": "Alice", "age": 30, "email": "alice@example.com"} + person = from_dict(PersonOptEmail, d) + assert person.get("email") == "alice@example.com" + + +def test_direct_from_dict_notrequired_absent(): + d = {"name": "Alice", "age": 30} + person = from_dict(PersonOptEmail, d) + assert "email" not in person + + +def test_direct_from_dict_missing_required_raises(): + d = {"title": "Inception", "year": 2010} # missing 'director' + with pytest.raises(SerdeError): + from_dict(Movie, d) + + +# --- Tests: is_instance with TypedDict --- + + +def test_is_instance_valid(): + movie: Movie = {"title": "Inception", "year": 2010, "director": "Nolan"} + assert is_instance(movie, Movie) + + +def test_is_instance_missing_required_key(): + d = {"title": "Inception", "year": 2010} # missing 'director' + assert not is_instance(d, Movie) + + +def test_is_instance_wrong_type(): + assert not is_instance("not a dict", Movie) + + +def test_is_instance_notrequired_absent(): + person = {"name": "Alice", "age": 30} + assert is_instance(person, PersonOptEmail) + + +def test_is_instance_notrequired_present(): + person = {"name": "Alice", "age": 30, "email": "alice@example.com"} + assert is_instance(person, PersonOptEmail) diff --git a/tests/test_yaml.py b/tests/test_yaml.py index c1e0b5e9..969d343b 100644 --- a/tests/test_yaml.py +++ b/tests/test_yaml.py @@ -30,21 +30,15 @@ class Foo: b: int | None f = Foo(10, 100) - assert ( - to_yaml(f, skip_none=True) - == """\ + assert to_yaml(f, skip_none=True) == """\ a: 10 b: 100 """ - ) f = Foo(10, None) - assert ( - to_yaml(f, skip_none=True) - == """\ + assert to_yaml(f, skip_none=True) == """\ a: 10 """ - ) def test_coerce_numbers_yaml() -> None: