Skip to content

Commit e71f1c2

Browse files
author
Nijat Khanbabayev
committed
Add more tests, test struct with pipe and literal
Signed-off-by: Nijat Khanbabayev <nijat.khanbabayev@cubistsystematic.com>
1 parent b9cc4f5 commit e71f1c2

2 files changed

Lines changed: 384 additions & 0 deletions

File tree

csp/tests/impl/test_struct.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import enum
22
import json
3+
import os
34
import pickle
5+
import sys
46
import typing
57
import unittest
68
from datetime import date, datetime, time, timedelta
@@ -17,6 +19,8 @@
1719
from csp.impl.types.typing_utils import FastList
1820
from csp.typing import Numpy1DArray
1921

22+
USE_PYDANTIC = os.environ.get("CSP_PYDANTIC", True)
23+
2024

2125
class MyEnum(csp.Enum):
2226
A = 1
@@ -3903,6 +3907,286 @@ class DataPoint(csp.Struct):
39033907
self.assertNotIn("_last_updated", json_data)
39043908
self.assertNotIn("_source", json_data["data"])
39053909

3910+
def test_literal_types_validation(self):
3911+
"""Test that Literal type annotations correctly validate input values in CSP Structs"""
3912+
3913+
# Define a simple class with various Literal types
3914+
class StructWithLiterals(csp.Struct):
3915+
# String literals
3916+
color: Literal["red", "green", "blue"]
3917+
# Integer literals
3918+
size: Literal[1, 2, 3]
3919+
# Mixed type literals
3920+
status: Literal["on", "off", 0, 1, True, False]
3921+
# Optional literal with default
3922+
mode: Optional[Literal["fast", "slow"]] = "fast"
3923+
3924+
# Test valid assignments
3925+
s1 = StructWithLiterals(color="red", size=2, status="on")
3926+
self.assertEqual(s1.color, "red")
3927+
self.assertEqual(s1.size, 2)
3928+
self.assertEqual(s1.status, "on")
3929+
self.assertEqual(s1.mode, "fast") # Default value
3930+
3931+
# Test another valid instance with different values
3932+
s2 = StructWithLiterals(color="blue", size=1, status=True, mode="slow")
3933+
self.assertEqual(s2.color, "blue")
3934+
self.assertEqual(s2.size, 1)
3935+
self.assertEqual(s2.status, True)
3936+
self.assertEqual(s2.mode, "slow")
3937+
3938+
# Test direct assignment
3939+
s3 = StructWithLiterals(color="green", size=3, status=0)
3940+
s3.color = "blue"
3941+
s3.size = 2
3942+
s3.status = False
3943+
self.assertEqual(s3.color, "blue")
3944+
self.assertEqual(s3.size, 2)
3945+
self.assertEqual(s3.status, False)
3946+
3947+
# This should fail! But old csp type checking doesnt catch
3948+
StructWithLiterals(color="yellow", size=1, status="on") # Invalid color
3949+
3950+
# This should fail! But old csp type checking doesnt catch
3951+
StructWithLiterals(color="red", size=4, status="on") # Invalid size
3952+
3953+
# This should fail! But old csp type checking doesnt catch
3954+
StructWithLiterals(color="red", size=1, status="standby") # Invalid status
3955+
3956+
# Test with Pydantic validation
3957+
if USE_PYDANTIC:
3958+
# Test valid values
3959+
result = TypeAdapter(StructWithLiterals).validate_python({"color": "green", "size": 3, "status": 0})
3960+
self.assertEqual(result.color, "green")
3961+
self.assertEqual(result.size, 3)
3962+
self.assertEqual(result.status, 0)
3963+
3964+
# Test invalid color with Pydantic validation
3965+
with self.assertRaises(ValidationError) as exc_info:
3966+
TypeAdapter(StructWithLiterals).validate_python({"color": "yellow", "size": 1, "status": "on"})
3967+
self.assertIn("1 validation error for", str(exc_info.exception))
3968+
self.assertIn("color", str(exc_info.exception))
3969+
3970+
# Test invalid size with Pydantic validation
3971+
with self.assertRaises(ValidationError) as exc_info:
3972+
TypeAdapter(StructWithLiterals).validate_python({"color": "red", "size": 4, "status": "on"})
3973+
self.assertIn("1 validation error for", str(exc_info.exception))
3974+
self.assertIn("size", str(exc_info.exception))
3975+
3976+
# Test invalid status with Pydantic validation
3977+
with self.assertRaises(ValidationError) as exc_info:
3978+
TypeAdapter(StructWithLiterals).validate_python({"color": "red", "size": 1, "status": "standby"})
3979+
self.assertIn("1 validation error for", str(exc_info.exception))
3980+
self.assertIn("status", str(exc_info.exception))
3981+
3982+
# Test invalid mode with Pydantic validation
3983+
with self.assertRaises(ValidationError) as exc_info:
3984+
TypeAdapter(StructWithLiterals).validate_python(
3985+
{"color": "red", "size": 1, "status": "on", "mode": "medium"}
3986+
)
3987+
self.assertIn("1 validation error for", str(exc_info.exception))
3988+
self.assertIn("mode", str(exc_info.exception))
3989+
# Test serialization and deserialization preserves literal values
3990+
result = TypeAdapter(StructWithLiterals).validate_python({"color": "green", "size": 3, "status": 0})
3991+
json_data = TypeAdapter(StructWithLiterals).dump_json(result)
3992+
restored = TypeAdapter(StructWithLiterals).validate_json(json_data)
3993+
self.assertEqual(restored.color, "green")
3994+
self.assertEqual(restored.size, 3)
3995+
self.assertEqual(restored.status, 0)
3996+
3997+
def test_literal_in_complex_structures(self):
3998+
"""Test Literal type annotations in more complex structures with nesting and containers"""
3999+
4000+
# Define a class using Literal in collection types and nested structs
4001+
class Configuration(csp.Struct):
4002+
mode: Literal["debug", "production", "test"]
4003+
4004+
class ItemType(csp.Enum):
4005+
WEAPON = 1
4006+
ARMOR = 2
4007+
POTION = 3
4008+
4009+
class Item(csp.Struct):
4010+
name: str
4011+
type: ItemType
4012+
rarity: Literal["common", "uncommon", "rare", "epic", "legendary"]
4013+
4014+
class Character(csp.Struct):
4015+
name: str
4016+
# Literal in list
4017+
classes: List[Literal["warrior", "mage", "rogue"]]
4018+
# Literal in dictionary values
4019+
attributes: Dict[str, Literal[1, 2, 3, 4, 5]]
4020+
# Nested struct with literal
4021+
config: Configuration
4022+
# List of nested structs with literals
4023+
inventory: List[Item]
4024+
4025+
# Create valid instance with various literal usages
4026+
character = Character(
4027+
name="Test Character",
4028+
classes=["warrior", "mage"],
4029+
attributes={"strength": 5, "intelligence": 3, "dexterity": 4},
4030+
config=Configuration(mode="debug"),
4031+
inventory=[
4032+
Item(name="Sword", type=ItemType.WEAPON, rarity="common"),
4033+
Item(name="Health Potion", type=ItemType.POTION, rarity="rare"),
4034+
],
4035+
)
4036+
4037+
# Test data is correctly set
4038+
self.assertEqual(character.name, "Test Character")
4039+
self.assertEqual(character.classes, ["warrior", "mage"])
4040+
self.assertEqual(character.attributes, {"strength": 5, "intelligence": 3, "dexterity": 4})
4041+
self.assertEqual(character.config.mode, "debug")
4042+
self.assertEqual(len(character.inventory), 2)
4043+
self.assertEqual(character.inventory[0].rarity, "common")
4044+
self.assertEqual(character.inventory[1].rarity, "rare")
4045+
4046+
# This should fail! But default csp struct type checking doesnt catch
4047+
Configuration(mode="invalid")
4048+
4049+
# This should fail! But default csp struct type checking doesnt catch
4050+
Item(name="Bad Item", type=ItemType.ARMOR, rarity="unknown")
4051+
4052+
# This should fail! But we dont check on mutation
4053+
character.classes.append("paladin") # Invalid class
4054+
4055+
# This should fail! But we dont check on mutation
4056+
character.attributes["wisdom"] = 6 # Value out of range
4057+
4058+
if USE_PYDANTIC:
4059+
# Test valid nested data
4060+
data = {
4061+
"name": "Pydantic Character",
4062+
"classes": ["rogue", "warrior"],
4063+
"attributes": {"strength": 2, "wisdom": 4},
4064+
"config": {"mode": "production"},
4065+
"inventory": [{"name": "Shield", "type": ItemType.ARMOR, "rarity": "uncommon"}],
4066+
}
4067+
result = TypeAdapter(Character).validate_python(data)
4068+
self.assertEqual(result.name, "Pydantic Character")
4069+
self.assertEqual(result.classes, ["rogue", "warrior"])
4070+
self.assertEqual(result.config.mode, "production")
4071+
self.assertEqual(result.inventory[0].rarity, "uncommon")
4072+
4073+
# Test invalid literal in nested structure
4074+
invalid_data = data.copy()
4075+
invalid_data["config"] = {"mode": "invalid_mode"}
4076+
with self.assertRaises(ValidationError) as exc_info:
4077+
TypeAdapter(Character).validate_python(invalid_data)
4078+
4079+
# Test serialization/deserialization round trip
4080+
round_trip = TypeAdapter(Character).validate_python(TypeAdapter(Character).dump_python(result))
4081+
self.assertEqual(round_trip.name, result.name)
4082+
self.assertEqual(round_trip.classes, result.classes)
4083+
self.assertEqual(round_trip.config.mode, result.config.mode)
4084+
self.assertEqual(round_trip.inventory[0].rarity, result.inventory[0].rarity)
4085+
4086+
def test_pipe_operator_types(self):
4087+
"""Test using the pipe operator for union types in Python 3.10+"""
4088+
if sys.version_info >= (3, 10): # Only run on Python 3.10+
4089+
# Define a class using various pipe operator combinations
4090+
class PipeTypesConfig(csp.Struct):
4091+
# Basic primitive types with pipe
4092+
id_field: str | int
4093+
# Pipe with None (similar to Optional)
4094+
description: str | None = None
4095+
# Multiple types with pipe
4096+
value: str | int | float | bool
4097+
# Container with pipe
4098+
tags: List[str] | Dict[str, str] | None = None
4099+
# Pipe with literal for comparison
4100+
status: Literal["active", "inactive"] | None = "active"
4101+
4102+
# Test with string ID
4103+
p1 = PipeTypesConfig(id_field="abc123", value="test_value")
4104+
self.assertEqual(p1.id_field, "abc123")
4105+
self.assertIsNone(p1.description)
4106+
self.assertEqual(p1.value, "test_value")
4107+
self.assertIsNone(p1.tags)
4108+
self.assertEqual(p1.status, "active")
4109+
4110+
# Test with integer ID
4111+
p2 = PipeTypesConfig(id_field=42, value=3.14, description="A config")
4112+
self.assertEqual(p2.id_field, 42)
4113+
self.assertEqual(p2.description, "A config")
4114+
self.assertEqual(p2.value, 3.14)
4115+
4116+
# Test with boolean value and list tags
4117+
p3 = PipeTypesConfig(id_field=99, value=True, tags=["tag1", "tag2"])
4118+
self.assertEqual(p3.id_field, 99)
4119+
self.assertEqual(p3.value, True)
4120+
self.assertEqual(p3.tags, ["tag1", "tag2"])
4121+
4122+
# Test with dict tags
4123+
p4 = PipeTypesConfig(id_field="xyz", value=42, tags={"key1": "val1", "key2": "val2"})
4124+
self.assertEqual(p4.id_field, "xyz")
4125+
self.assertEqual(p4.value, 42)
4126+
self.assertEqual(p4.tags, {"key1": "val1", "key2": "val2"})
4127+
4128+
# Test direct assignment
4129+
p5 = PipeTypesConfig(id_field="test", value=1)
4130+
p5.id_field = 100
4131+
p5.value = False
4132+
p5.tags = ["new", "tags"]
4133+
p5.description = "Updated"
4134+
self.assertEqual(p5.id_field, 100)
4135+
self.assertEqual(p5.value, False)
4136+
self.assertEqual(p5.tags, ["new", "tags"])
4137+
self.assertEqual(p5.description, "Updated")
4138+
4139+
# Test Pydantic validation if available
4140+
if USE_PYDANTIC:
4141+
# Test all valid types
4142+
valid_cases = [
4143+
{"id_field": "string_id", "value": "string_value"},
4144+
{"id_field": 42, "value": 123},
4145+
{"id_field": "mixed", "value": 3.14},
4146+
{"id_field": 999, "value": True},
4147+
{"id_field": "with_desc", "value": 1, "description": "Description"},
4148+
{"id_field": "with_tags", "value": 1, "tags": ["a", "b", "c"]},
4149+
{"id_field": "with_dict", "value": 1, "tags": {"a": "A", "b": "B"}},
4150+
]
4151+
4152+
for case in valid_cases:
4153+
result = TypeAdapter(PipeTypesConfig).validate_python(case)
4154+
self.assertEqual(result.id_field, case["id_field"])
4155+
self.assertEqual(result.value, case["value"])
4156+
4157+
# Test invalid values
4158+
invalid_cases = [
4159+
{"id_field": 3.14, "value": 1}, # Float for id_field
4160+
{"id_field": None, "value": 1}, # None for required id_field
4161+
{"id_field": "test", "value": {}}, # Dict for value
4162+
{"id_field": "test", "value": None}, # None for required value
4163+
{"id_field": "test", "value": 1, "status": "unknown"}, # Invalid literal
4164+
]
4165+
4166+
for case in invalid_cases:
4167+
with self.assertRaises(ValidationError):
4168+
TypeAdapter(PipeTypesConfig).validate_python(case)
4169+
4170+
# Test serialization/deserialization
4171+
original = PipeTypesConfig(
4172+
id_field="test_id",
4173+
value=42,
4174+
description="Test description",
4175+
tags=["tag1", "tag2"],
4176+
status="inactive",
4177+
)
4178+
4179+
# Convert to JSON and back
4180+
json_data = TypeAdapter(PipeTypesConfig).dump_json(original)
4181+
restored = TypeAdapter(PipeTypesConfig).validate_json(json_data)
4182+
4183+
# Verify data integrity
4184+
self.assertEqual(restored.id_field, original.id_field)
4185+
self.assertEqual(restored.value, original.value)
4186+
self.assertEqual(restored.description, original.description)
4187+
self.assertEqual(restored.tags, original.tags)
4188+
self.assertEqual(restored.status, original.status)
4189+
39064190

39074191
if __name__ == "__main__":
39084192
unittest.main()

0 commit comments

Comments
 (0)