|
1 | 1 | import json |
2 | | -from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast |
| 2 | +from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast |
3 | 3 | from datetime import datetime, timezone |
4 | | -from typing_extensions import Literal, Annotated, TypeAliasType |
| 4 | +from collections import deque |
| 5 | +from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType |
5 | 6 |
|
6 | 7 | import pytest |
7 | 8 | import pydantic |
8 | 9 | from pydantic import Field |
9 | 10 |
|
10 | 11 | from kernel._utils import PropertyInfo |
11 | 12 | from kernel._compat import PYDANTIC_V1, parse_obj, model_dump, model_json |
12 | | -from kernel._models import DISCRIMINATOR_CACHE, BaseModel, construct_type |
| 13 | +from kernel._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, construct_type |
13 | 14 |
|
14 | 15 |
|
15 | 16 | class BasicModel(BaseModel): |
@@ -961,3 +962,56 @@ def __getattr__(self, attr: str) -> Item: ... |
961 | 962 | assert model.a.prop == 1 |
962 | 963 | assert isinstance(model.a, Item) |
963 | 964 | assert model.other == "foo" |
| 965 | + |
| 966 | + |
| 967 | +# NOTE: Workaround for Pydantic Iterable behavior. |
| 968 | +# Iterable fields are replaced with a ValidatorIterator and may be consumed |
| 969 | +# during serialization, which can cause subsequent dumps to return empty data. |
| 970 | +# See: https://github.com/pydantic/pydantic/issues/9541 |
| 971 | +@pytest.mark.parametrize( |
| 972 | + "data, expected_validated", |
| 973 | + [ |
| 974 | + ([1, 2, 3], [1, 2, 3]), |
| 975 | + ((1, 2, 3), (1, 2, 3)), |
| 976 | + (set([1, 2, 3]), set([1, 2, 3])), |
| 977 | + (iter([1, 2, 3]), [1, 2, 3]), |
| 978 | + ([], []), |
| 979 | + ((x for x in [1, 2, 3]), [1, 2, 3]), |
| 980 | + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), |
| 981 | + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), |
| 982 | + (deque([1, 2, 3]), deque([1, 2, 3])), |
| 983 | + ], |
| 984 | + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], |
| 985 | +) |
| 986 | +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") |
| 987 | +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: |
| 988 | + class TypeWithIterable(TypedDict): |
| 989 | + items: EagerIterable[int] |
| 990 | + |
| 991 | + class Model(BaseModel): |
| 992 | + data: TypeWithIterable |
| 993 | + |
| 994 | + m = Model.model_validate({"data": {"items": data}}) |
| 995 | + assert m.data["items"] == expected_validated |
| 996 | + |
| 997 | + # Verify repeated dumps don't lose data (the original bug) |
| 998 | + assert m.model_dump()["data"]["items"] == list(expected_validated) |
| 999 | + assert m.model_dump()["data"]["items"] == list(expected_validated) |
| 1000 | + |
| 1001 | + |
| 1002 | +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") |
| 1003 | +def test_iterable_construction_str_falls_back_to_list() -> None: |
| 1004 | + # str is iterable (over chars), but str(list_of_chars) produces the list's repr |
| 1005 | + # rather than reconstructing a string from items. We special-case str to fall |
| 1006 | + # back to list instead of attempting reconstruction. |
| 1007 | + class TypeWithIterable(TypedDict): |
| 1008 | + items: EagerIterable[str] |
| 1009 | + |
| 1010 | + class Model(BaseModel): |
| 1011 | + data: TypeWithIterable |
| 1012 | + |
| 1013 | + m = Model.model_validate({"data": {"items": "hello"}}) |
| 1014 | + |
| 1015 | + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) |
| 1016 | + assert m.data["items"] == ["h", "e", "l", "l", "o"] |
| 1017 | + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"] |
0 commit comments