cocoindex 0.2.15__cp311-abi3-win_amd64.whl → 0.2.17__cp311-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import dataclasses
2
+ import datetime
3
+ from typing import TypedDict, NamedTuple, Literal
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+ import pytest
8
+
9
+ from cocoindex.typing import Vector
10
+ from cocoindex.engine_object import dump_engine_object, load_engine_object
11
+
12
+ # Optional Pydantic support for testing
13
+ try:
14
+ import pydantic
15
+
16
+ PYDANTIC_AVAILABLE = True
17
+ except ImportError:
18
+ PYDANTIC_AVAILABLE = False
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class LocalTargetFieldMapping:
23
+ source: str
24
+ target: str | None = None
25
+
26
+
27
+ @dataclasses.dataclass
28
+ class LocalNodeFromFields:
29
+ label: str
30
+ fields: list[LocalTargetFieldMapping]
31
+
32
+
33
+ @dataclasses.dataclass
34
+ class LocalNodes:
35
+ kind = "Node"
36
+ label: str
37
+
38
+
39
+ @dataclasses.dataclass
40
+ class LocalRelationships:
41
+ kind = "Relationship"
42
+ rel_type: str
43
+ source: LocalNodeFromFields
44
+ target: LocalNodeFromFields
45
+
46
+
47
+ class LocalPoint(NamedTuple):
48
+ x: int
49
+ y: int
50
+
51
+
52
+ class UserInfo(TypedDict):
53
+ id: str
54
+ age: int
55
+
56
+
57
+ def test_timedelta_roundtrip_via_dump_load() -> None:
58
+ td = datetime.timedelta(days=1, hours=2, minutes=3, seconds=4, microseconds=500)
59
+ dumped = dump_engine_object(td)
60
+ loaded = load_engine_object(datetime.timedelta, dumped)
61
+ assert isinstance(loaded, datetime.timedelta)
62
+ assert loaded == td
63
+
64
+
65
+ def test_ndarray_roundtrip_via_dump_load() -> None:
66
+ value: NDArray[np.float32] = np.array([1.0, 2.0, 3.0], dtype=np.float32)
67
+ dumped = dump_engine_object(value)
68
+ assert dumped == [1.0, 2.0, 3.0]
69
+ loaded = load_engine_object(NDArray[np.float32], dumped)
70
+ assert isinstance(loaded, np.ndarray)
71
+ assert loaded.dtype == np.float32
72
+ assert np.array_equal(loaded, value)
73
+
74
+
75
+ def test_nodes_kind_is_carried() -> None:
76
+ node = LocalNodes(label="User")
77
+ dumped = dump_engine_object(node)
78
+ # dumped should include discriminator
79
+ assert dumped.get("kind") == "Node"
80
+ # load back
81
+ loaded = load_engine_object(LocalNodes, dumped)
82
+ assert isinstance(loaded, LocalNodes)
83
+ # class-level attribute is preserved
84
+ assert getattr(loaded, "kind", None) == "Node"
85
+ assert loaded.label == "User"
86
+
87
+
88
+ def test_relationships_union_discriminator() -> None:
89
+ rel = LocalRelationships(
90
+ rel_type="LIKES",
91
+ source=LocalNodeFromFields(
92
+ label="User", fields=[LocalTargetFieldMapping("id")]
93
+ ),
94
+ target=LocalNodeFromFields(
95
+ label="Item", fields=[LocalTargetFieldMapping("id")]
96
+ ),
97
+ )
98
+ dumped = dump_engine_object(rel)
99
+ assert dumped.get("kind") == "Relationship"
100
+ loaded = load_engine_object(LocalNodes | LocalRelationships, dumped)
101
+ assert isinstance(loaded, LocalRelationships)
102
+ assert getattr(loaded, "kind", None) == "Relationship"
103
+ assert loaded.rel_type == "LIKES"
104
+ assert dataclasses.asdict(loaded.source) == {
105
+ "label": "User",
106
+ "fields": [{"source": "id", "target": None}],
107
+ }
108
+ assert dataclasses.asdict(loaded.target) == {
109
+ "label": "Item",
110
+ "fields": [{"source": "id", "target": None}],
111
+ }
112
+
113
+
114
+ def test_typed_dict_roundtrip_via_dump_load() -> None:
115
+ user: UserInfo = {"id": "u1", "age": 30}
116
+ dumped = dump_engine_object(user)
117
+ assert dumped == {"id": "u1", "age": 30}
118
+ loaded = load_engine_object(UserInfo, dumped)
119
+ assert loaded == user
120
+
121
+
122
+ def test_namedtuple_roundtrip_via_dump_load() -> None:
123
+ p = LocalPoint(1, 2)
124
+ dumped = dump_engine_object(p)
125
+ assert dumped == {"x": 1, "y": 2}
126
+ loaded = load_engine_object(LocalPoint, dumped)
127
+ assert isinstance(loaded, LocalPoint)
128
+ assert loaded == p
129
+
130
+
131
+ def test_dataclass_missing_fields_with_auto_defaults() -> None:
132
+ """Test that missing fields are automatically assigned safe default values."""
133
+
134
+ @dataclasses.dataclass
135
+ class TestClass:
136
+ required_field: str
137
+ optional_field: str | None # Should get None
138
+ list_field: list[str] # Should get []
139
+ dict_field: dict[str, int] # Should get {}
140
+ explicit_default: str = "default" # Should use explicit default
141
+
142
+ # Input missing optional_field, list_field, dict_field (but has explicit_default via class definition)
143
+ input_data = {"required_field": "test_value"}
144
+
145
+ loaded = load_engine_object(TestClass, input_data)
146
+
147
+ assert isinstance(loaded, TestClass)
148
+ assert loaded.required_field == "test_value"
149
+ assert loaded.optional_field is None # Auto-default for Optional
150
+ assert loaded.list_field == [] # Auto-default for list
151
+ assert loaded.dict_field == {} # Auto-default for dict
152
+ assert loaded.explicit_default == "default" # Explicit default from class
153
+
154
+
155
+ def test_namedtuple_missing_fields_with_auto_defaults() -> None:
156
+ """Test that missing fields in NamedTuple are automatically assigned safe default values."""
157
+ from typing import NamedTuple
158
+
159
+ class TestTuple(NamedTuple):
160
+ required_field: str
161
+ optional_field: str | None # Should get None
162
+ list_field: list[str] # Should get []
163
+ dict_field: dict[str, int] # Should get {}
164
+
165
+ # Input missing optional_field, list_field, dict_field
166
+ input_data = {"required_field": "test_value"}
167
+
168
+ loaded = load_engine_object(TestTuple, input_data)
169
+
170
+ assert isinstance(loaded, TestTuple)
171
+ assert loaded.required_field == "test_value"
172
+ assert loaded.optional_field is None # Auto-default for Optional
173
+ assert loaded.list_field == [] # Auto-default for list
174
+ assert loaded.dict_field == {} # Auto-default for dict
175
+
176
+
177
+ def test_dataclass_unsupported_type_still_fails() -> None:
178
+ """Test that fields with unsupported types still cause errors when missing."""
179
+
180
+ @dataclasses.dataclass
181
+ class TestClass:
182
+ required_field1: str
183
+ required_field2: int # No auto-default for int
184
+
185
+ # Input missing required_field2 which has no safe auto-default
186
+ input_data = {"required_field1": "test_value"}
187
+
188
+ # Should still raise an error because int has no safe auto-default
189
+ try:
190
+ load_engine_object(TestClass, input_data)
191
+ assert False, "Expected TypeError to be raised"
192
+ except TypeError:
193
+ pass # Expected behavior
194
+
195
+
196
+ def test_dump_vector_type_annotation_with_dim() -> None:
197
+ """Test dumping a vector type annotation with a specified dimension."""
198
+ expected_dump = {
199
+ "type": {
200
+ "kind": "Vector",
201
+ "element_type": {"kind": "Float32"},
202
+ "dimension": 3,
203
+ }
204
+ }
205
+ assert dump_engine_object(Vector[np.float32, Literal[3]]) == expected_dump
206
+
207
+
208
+ def test_dump_vector_type_annotation_no_dim() -> None:
209
+ """Test dumping a vector type annotation with no dimension."""
210
+ expected_dump_no_dim = {
211
+ "type": {
212
+ "kind": "Vector",
213
+ "element_type": {"kind": "Float64"},
214
+ "dimension": None,
215
+ }
216
+ }
217
+ assert dump_engine_object(Vector[np.float64]) == expected_dump_no_dim
218
+
219
+
220
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
221
+ def test_pydantic_unsupported_type_still_fails() -> None:
222
+ """Test that fields with unsupported types still cause errors when missing."""
223
+
224
+ class TestPydantic(pydantic.BaseModel):
225
+ required_field1: str
226
+ required_field2: int # No auto-default for int
227
+ optional_field: str | None
228
+ list_field: list[str]
229
+ dict_field: dict[str, int]
230
+ field_with_default: str = "default_value"
231
+
232
+ # Input missing required_field2 which has no safe auto-default
233
+ input_data = {"required_field1": "test_value"}
234
+
235
+ # Should still raise an error because int has no safe auto-default
236
+ with pytest.raises(pydantic.ValidationError):
237
+ load_engine_object(TestPydantic, input_data)
238
+
239
+ assert load_engine_object(
240
+ TestPydantic, {"required_field1": "test_value", "required_field2": 1}
241
+ ) == TestPydantic(
242
+ required_field1="test_value",
243
+ required_field2=1,
244
+ field_with_default="default_value",
245
+ optional_field=None,
246
+ list_field=[],
247
+ dict_field={},
248
+ )
249
+
250
+
251
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
252
+ def test_pydantic_field_descriptions() -> None:
253
+ """Test that Pydantic field descriptions are extracted and included in schema."""
254
+ from pydantic import BaseModel, Field
255
+
256
+ class UserWithDescriptions(BaseModel):
257
+ """A user model with field descriptions."""
258
+
259
+ name: str = Field(description="The user's full name")
260
+ age: int = Field(description="The user's age in years", ge=0, le=150)
261
+ email: str = Field(description="The user's email address")
262
+ is_active: bool = Field(
263
+ description="Whether the user account is active", default=True
264
+ )
265
+
266
+ # Test that field descriptions are extracted
267
+ encoded_schema = dump_engine_object(UserWithDescriptions)
268
+
269
+ # Check that the schema contains field descriptions
270
+ assert "fields" in encoded_schema["type"]
271
+ fields = encoded_schema["type"]["fields"]
272
+
273
+ # Find fields by name and check descriptions
274
+ field_descriptions = {field["name"]: field.get("description") for field in fields}
275
+
276
+ assert field_descriptions["name"] == "The user's full name"
277
+ assert field_descriptions["age"] == "The user's age in years"
278
+ assert field_descriptions["email"] == "The user's email address"
279
+ assert field_descriptions["is_active"] == "Whether the user account is active"
280
+
281
+
282
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
283
+ def test_pydantic_field_descriptions_without_field() -> None:
284
+ """Test that Pydantic models without field descriptions work correctly."""
285
+ from pydantic import BaseModel
286
+
287
+ class UserWithoutDescriptions(BaseModel):
288
+ """A user model without field descriptions."""
289
+
290
+ name: str
291
+ age: int
292
+ email: str
293
+
294
+ # Test that the schema works without descriptions
295
+ encoded_schema = dump_engine_object(UserWithoutDescriptions)
296
+
297
+ # Check that the schema contains fields but no descriptions
298
+ assert "fields" in encoded_schema["type"]
299
+ fields = encoded_schema["type"]["fields"]
300
+
301
+ # Verify no descriptions are present
302
+ for field in fields:
303
+ assert "description" not in field or field["description"] is None
304
+
305
+
306
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
307
+ def test_pydantic_mixed_descriptions() -> None:
308
+ """Test Pydantic model with some fields having descriptions and others not."""
309
+ from pydantic import BaseModel, Field
310
+
311
+ class MixedDescriptions(BaseModel):
312
+ """A model with mixed field descriptions."""
313
+
314
+ name: str = Field(description="The name field")
315
+ age: int # No description
316
+ email: str = Field(description="The email field")
317
+ active: bool # No description
318
+
319
+ # Test that only fields with descriptions have them in the schema
320
+ encoded_schema = dump_engine_object(MixedDescriptions)
321
+
322
+ assert "fields" in encoded_schema["type"]
323
+ fields = encoded_schema["type"]["fields"]
324
+
325
+ # Find fields by name and check descriptions
326
+ field_descriptions = {field["name"]: field.get("description") for field in fields}
327
+
328
+ assert field_descriptions["name"] == "The name field"
329
+ assert field_descriptions["age"] is None
330
+ assert field_descriptions["email"] == "The email field"
331
+ assert field_descriptions["active"] is None
@@ -8,9 +8,18 @@ import numpy as np
8
8
  import pytest
9
9
  from numpy.typing import NDArray
10
10
 
11
+ # Optional Pydantic support for testing
12
+ try:
13
+ from pydantic import BaseModel, Field
14
+
15
+ PYDANTIC_AVAILABLE = True
16
+ except ImportError:
17
+ BaseModel = None # type: ignore[misc,assignment]
18
+ Field = None # type: ignore[misc,assignment]
19
+ PYDANTIC_AVAILABLE = False
20
+
11
21
  import cocoindex
12
- from cocoindex.convert import (
13
- dump_engine_object,
22
+ from cocoindex.engine_value import (
14
23
  make_engine_value_encoder,
15
24
  make_engine_value_decoder,
16
25
  )
@@ -70,6 +79,29 @@ class CustomerNamedTuple(NamedTuple):
70
79
  tags: list[Tag] | None = None
71
80
 
72
81
 
82
+ # Pydantic model definitions (if available)
83
+ if PYDANTIC_AVAILABLE:
84
+
85
+ class OrderPydantic(BaseModel):
86
+ order_id: str
87
+ name: str
88
+ price: float
89
+ extra_field: str = "default_extra"
90
+
91
+ class TagPydantic(BaseModel):
92
+ name: str
93
+
94
+ class CustomerPydantic(BaseModel):
95
+ name: str
96
+ order: OrderPydantic
97
+ tags: list[TagPydantic] | None = None
98
+
99
+ class NestedStructPydantic(BaseModel):
100
+ customer: CustomerPydantic
101
+ orders: list[OrderPydantic]
102
+ count: int = 0
103
+
104
+
73
105
  def encode_engine_value(value: Any, type_hint: Type[Any] | str) -> Any:
74
106
  """
75
107
  Encode a Python value to an engine value.
@@ -973,30 +1005,6 @@ def test_decode_error_non_nullable_or_non_list_vector() -> None:
973
1005
  decoder("not a list")
974
1006
 
975
1007
 
976
- def test_dump_vector_type_annotation_with_dim() -> None:
977
- """Test dumping a vector type annotation with a specified dimension."""
978
- expected_dump = {
979
- "type": {
980
- "kind": "Vector",
981
- "element_type": {"kind": "Float32"},
982
- "dimension": 3,
983
- }
984
- }
985
- assert dump_engine_object(Float32VectorType) == expected_dump
986
-
987
-
988
- def test_dump_vector_type_annotation_no_dim() -> None:
989
- """Test dumping a vector type annotation with no dimension."""
990
- expected_dump_no_dim = {
991
- "type": {
992
- "kind": "Vector",
993
- "element_type": {"kind": "Float64"},
994
- "dimension": None,
995
- }
996
- }
997
- assert dump_engine_object(Float64VectorTypeNoDim) == expected_dump_no_dim
998
-
999
-
1000
1008
  def test_full_roundtrip_vector_numeric_types() -> None:
1001
1009
  """Test full roundtrip for numeric vector types using NDArray."""
1002
1010
  value_f32 = np.array([1.0, 2.0, 3.0], dtype=np.float32)
@@ -1566,3 +1574,119 @@ def test_auto_default_for_supported_and_unsupported_types() -> None:
1566
1574
  match=r"Field 'b' \(type <class 'int'>\) without default value is missing in input: ",
1567
1575
  ):
1568
1576
  build_engine_value_decoder(Base, UnsupportedField)
1577
+
1578
+
1579
+ # Pydantic model tests
1580
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1581
+ def test_pydantic_simple_struct() -> None:
1582
+ """Test basic Pydantic model encoding and decoding."""
1583
+ order = OrderPydantic(order_id="O1", name="item1", price=10.0)
1584
+ validate_full_roundtrip(order, OrderPydantic)
1585
+
1586
+
1587
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1588
+ def test_pydantic_struct_with_defaults() -> None:
1589
+ """Test Pydantic model with default values."""
1590
+ order = OrderPydantic(order_id="O1", name="item1", price=10.0)
1591
+ assert order.extra_field == "default_extra"
1592
+ validate_full_roundtrip(order, OrderPydantic)
1593
+
1594
+
1595
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1596
+ def test_pydantic_nested_struct() -> None:
1597
+ """Test nested Pydantic models."""
1598
+ order = OrderPydantic(order_id="O1", name="item1", price=10.0)
1599
+ customer = CustomerPydantic(name="Alice", order=order)
1600
+ validate_full_roundtrip(customer, CustomerPydantic)
1601
+
1602
+
1603
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1604
+ def test_pydantic_struct_with_list() -> None:
1605
+ """Test Pydantic model with list fields."""
1606
+ order = OrderPydantic(order_id="O1", name="item1", price=10.0)
1607
+ tags = [TagPydantic(name="vip"), TagPydantic(name="premium")]
1608
+ customer = CustomerPydantic(name="Alice", order=order, tags=tags)
1609
+ validate_full_roundtrip(customer, CustomerPydantic)
1610
+
1611
+
1612
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1613
+ def test_pydantic_complex_nested_struct() -> None:
1614
+ """Test complex nested Pydantic structure."""
1615
+ order1 = OrderPydantic(order_id="O1", name="item1", price=10.0)
1616
+ order2 = OrderPydantic(order_id="O2", name="item2", price=20.0)
1617
+ customer = CustomerPydantic(
1618
+ name="Alice", order=order1, tags=[TagPydantic(name="vip")]
1619
+ )
1620
+ nested = NestedStructPydantic(customer=customer, orders=[order1, order2], count=2)
1621
+ validate_full_roundtrip(nested, NestedStructPydantic)
1622
+
1623
+
1624
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1625
+ def test_pydantic_struct_to_dict_binding() -> None:
1626
+ """Test Pydantic model -> dict binding."""
1627
+ order = OrderPydantic(order_id="O1", name="item1", price=10.0, extra_field="custom")
1628
+ expected_dict = {
1629
+ "order_id": "O1",
1630
+ "name": "item1",
1631
+ "price": 10.0,
1632
+ "extra_field": "custom",
1633
+ }
1634
+
1635
+ validate_full_roundtrip(
1636
+ order,
1637
+ OrderPydantic,
1638
+ (expected_dict, Any),
1639
+ (expected_dict, dict),
1640
+ (expected_dict, dict[Any, Any]),
1641
+ (expected_dict, dict[str, Any]),
1642
+ )
1643
+
1644
+
1645
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1646
+ def test_make_engine_value_decoder_pydantic_struct() -> None:
1647
+ """Test engine value decoder for Pydantic models."""
1648
+ engine_val = ["O1", "item1", 10.0, "default_extra"]
1649
+ decoder = build_engine_value_decoder(OrderPydantic)
1650
+ result = decoder(engine_val)
1651
+
1652
+ assert isinstance(result, OrderPydantic)
1653
+ assert result.order_id == "O1"
1654
+ assert result.name == "item1"
1655
+ assert result.price == 10.0
1656
+ assert result.extra_field == "default_extra"
1657
+
1658
+
1659
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1660
+ def test_make_engine_value_decoder_pydantic_nested() -> None:
1661
+ """Test engine value decoder for nested Pydantic models."""
1662
+ engine_val = [
1663
+ "Alice",
1664
+ ["O1", "item1", 10.0, "default_extra"],
1665
+ [["vip"]],
1666
+ ]
1667
+ decoder = build_engine_value_decoder(CustomerPydantic)
1668
+ result = decoder(engine_val)
1669
+
1670
+ assert isinstance(result, CustomerPydantic)
1671
+ assert result.name == "Alice"
1672
+ assert isinstance(result.order, OrderPydantic)
1673
+ assert result.order.order_id == "O1"
1674
+ assert result.tags is not None
1675
+ assert len(result.tags) == 1
1676
+ assert isinstance(result.tags[0], TagPydantic)
1677
+ assert result.tags[0].name == "vip"
1678
+
1679
+
1680
+ @pytest.mark.skipif(not PYDANTIC_AVAILABLE, reason="Pydantic not available")
1681
+ def test_pydantic_mixed_with_dataclass() -> None:
1682
+ """Test mixing Pydantic models with dataclasses."""
1683
+
1684
+ # Create a dataclass that uses a Pydantic model
1685
+ @dataclass
1686
+ class MixedStruct:
1687
+ name: str
1688
+ pydantic_order: OrderPydantic
1689
+
1690
+ order = OrderPydantic(order_id="O1", name="item1", price=10.0)
1691
+ mixed = MixedStruct(name="test", pydantic_order=order)
1692
+ validate_full_roundtrip(mixed, MixedStruct)