Files
wehub-resource-sync 97e91a83f3
Ruff / Ruff (push) Has been cancelled
Test / Core Tests (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.10) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.11) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.12) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.13) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.9) (push) Has been cancelled
Test / Full Coverage (Python 3.11) (push) Has been cancelled
Test / Core Provider Tests (OpenAI) (push) Has been cancelled
Test / Core Provider Tests (Anthropic) (push) Has been cancelled
Test / Core Provider Tests (Google) (push) Has been cancelled
Test / Core Provider Tests (Other) (push) Has been cancelled
Test / Anthropic Tests (push) Has been cancelled
Test / Gemini Tests (push) Has been cancelled
Test / Google GenAI Tests (push) Has been cancelled
Test / Vertex AI Tests (push) Has been cancelled
Test / OpenAI Tests (push) Has been cancelled
Test / Writer Tests (push) Has been cancelled
Test / Auto Client Tests (push) Has been cancelled
ty / type-check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:36:38 +08:00

1200 lines
42 KiB
Python

import os
from collections.abc import AsyncGenerator, Generator, Iterable
from copy import deepcopy
from enum import Enum
from typing import Any, Literal, Optional, Protocol, TypeVar, Union, cast
import pytest
from jiter import from_json
from pydantic import BaseModel, Field, ValidationError
import instructor
from instructor.dsl.partial import Partial, PartialLiteralMixin, _make_field_optional
from openai import AsyncOpenAI, OpenAI
T_Model = TypeVar("T_Model", bound=BaseModel)
class _PartialModelApi(Protocol[T_Model]):
@classmethod
def get_partial_model(cls) -> type[T_Model]: ...
@classmethod
def model_from_chunks(
cls, json_chunks: Iterable[Any], **kwargs: Any
) -> Generator[T_Model, None, None]: ...
@classmethod
def model_from_chunks_async(
cls, json_chunks: AsyncGenerator[str, None], **kwargs: Any
) -> AsyncGenerator[T_Model, None]: ...
def _partial_api(model: type[T_Model]) -> type[_PartialModelApi[T_Model]]:
"""Expose the classmethods dynamically added by ``Partial[T]``."""
return cast(type[_PartialModelApi[T_Model]], model)
models = ["gpt-4o-mini"]
modes = [
instructor.Mode.TOOLS,
]
class SampleNestedPartial(BaseModel):
b: int
class SamplePartial(BaseModel):
a: int
b: SampleNestedPartial
class NestedA(BaseModel):
a: str
b: Optional[str]
class NestedB(BaseModel):
c: str
d: str
e: list[Union[str, int]]
f: str
class UnionWithNested(BaseModel):
a: list[Union[NestedA, NestedB]]
b: list[NestedA]
c: NestedB
class PEP604UnionField(BaseModel):
value: str | int
def test_partial():
partial = Partial[SamplePartial]
assert partial.model_json_schema() == {
"$defs": {
"PartialSampleNestedPartial": {
"properties": {"b": {"title": "B", "type": "integer"}},
"required": ["b"],
"title": "PartialSampleNestedPartial",
"type": "object",
}
},
"properties": {
"a": {"title": "A", "type": "integer"},
"b": {"$ref": "#/$defs/PartialSampleNestedPartial"},
},
"required": ["a", "b"],
"title": "PartialSamplePartial",
"type": "object",
}, "Wrapped model JSON schema has changed"
assert _partial_api(partial).get_partial_model().model_json_schema() == {
"$defs": {
"PartialSampleNestedPartial": {
"properties": {
"b": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": None,
"title": "B",
}
},
"title": "PartialSampleNestedPartial",
"type": "object",
}
},
"properties": {
"a": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": None,
"title": "A",
},
"b": {
"anyOf": [
{"$ref": "#/$defs/PartialSampleNestedPartial"},
{"type": "null"},
],
"default": {},
},
},
"title": "PartialSamplePartial",
"type": "object",
}, "Partial model JSON schema has changed"
partial_chunks = ["\n", "\t", " ", "\x00", '{"a": 42, "b": {"b": 1}}']
expected_sync_models = [
# First model has default values (nested models show their fields as None)
{"a": None, "b": {"b": None}},
{"a": None, "b": {"b": None}},
{"a": None, "b": {"b": None}},
{"a": None, "b": {"b": None}},
# Last model has all fields populated from JSON
{"a": 42, "b": {"b": 1}},
]
expected_async_models = [
{"a": None, "b": {"b": None}},
{"a": None, "b": {"b": None}},
{"a": None, "b": {"b": None}},
{"a": None, "b": {"b": None}},
{"a": 42, "b": {"b": 1}},
]
def test_partial_with_whitespace():
partial = Partial[SamplePartial]
# Get the actual models from chunks - must provide complete data for final validation
models = list(_partial_api(partial).model_from_chunks(partial_chunks))
assert len(models) == len(expected_sync_models)
for i, model in enumerate(models):
assert model.model_dump() == expected_sync_models[i]
@pytest.mark.asyncio
async def test_async_partial_with_whitespace():
partial = Partial[SamplePartial]
# Handle any leading whitespace from the model - must provide complete data for final validation
async def async_generator():
for chunk in partial_chunks:
yield chunk
i = 0
async for model in _partial_api(partial).model_from_chunks_async(async_generator()):
# Expected behavior: When whitespace chunks are processed, we should always get a model
assert model.model_dump() == expected_async_models[i]
i += 1
assert i == len(expected_async_models)
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
def test_summary_extraction():
class Summary(BaseModel):
summary: str = Field(description="A detailed summary")
client = OpenAI()
client = instructor.from_openai(client, mode=instructor.Mode.TOOLS)
extraction_stream = client.chat.completions.create_partial(
model="gpt-4o",
response_model=Summary,
messages=[
{"role": "system", "content": "You summarize text"},
{"role": "user", "content": "Summarize: Mary had a little lamb"},
],
stream=True,
)
# Collect all streaming updates and verify final result
final_summary = None
chunk_count = 0
for extraction in extraction_stream:
final_summary = extraction.summary
chunk_count += 1
# Verify we got streaming updates and a valid final summary
assert chunk_count > 0
assert final_summary is not None
assert len(final_summary) > 0
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
@pytest.mark.asyncio
async def test_summary_extraction_async():
class Summary(BaseModel):
summary: str = Field(description="A detailed summary")
client = AsyncOpenAI()
client = instructor.from_openai(client, mode=instructor.Mode.TOOLS)
extraction_stream = client.chat.completions.create_partial(
model="gpt-4o",
response_model=Summary,
messages=[
{"role": "system", "content": "You summarize text"},
{"role": "user", "content": "Summarize: Mary had a little lamb"},
],
stream=True,
)
# Collect all streaming updates and verify final result
final_summary = None
chunk_count = 0
async for extraction in extraction_stream:
final_summary = extraction.summary
chunk_count += 1
# Verify we got streaming updates and a valid final summary
assert chunk_count > 0
assert final_summary is not None
assert len(final_summary) > 0
def test_union_with_nested():
partial = Partial[UnionWithNested]
_partial_api(partial).get_partial_model().model_validate_json(
'{"a": [{"b": "b"}, {"d": "d"}], "b": [{"b": "b"}], "c": {"d": "d"}, "e": [1, "a"]}'
)
def test_partial_streaming_with_pep604_union_field():
partial = Partial[PEP604UnionField]
models = list(_partial_api(partial).model_from_chunks(['{"value": ', "1}"]))
assert models[-1].model_dump() == {"value": 1}
def test_partial_with_default_factory():
"""Test that Partial works with fields that have default_factory.
This test ensures that when making fields optional, the default_factory
is properly cleared to avoid Pydantic validation errors about having
both default and default_factory set.
"""
class ModelWithDefaultFactory(BaseModel):
items: list[str] = Field(default_factory=list)
tags: dict[str, str] = Field(default_factory=dict)
name: str
# This should not raise a validation error about both default and default_factory
partial = Partial[ModelWithDefaultFactory]
partial_model = _partial_api(partial).get_partial_model()
# Verify we can instantiate and validate
# In Partial models, all fields are made Optional with default=None
instance = partial_model()
assert instance.items is None
assert instance.tags is None
assert instance.name is None
# Test with partial data
instance2 = partial_model.model_validate({"items": ["a", "b"]})
assert instance2.items == ["a", "b"]
assert instance2.tags is None
assert instance2.name is None
class TestMakeFieldOptionalWorksWithPydanticV2:
"""Tests proving that _make_field_optional with deepcopy works correctly in Pydantic v2.
These tests refute the claim that deepcopy + setting default = None doesn't work
in Pydantic v2. The implementation is correct and fields are properly made optional.
See: https://github.com/instructor-ai/instructor/issues/XXXX
"""
def test_deepcopy_approach_makes_field_optional(self):
"""Verify that deepcopy + default = None makes fields optional in Pydantic v2."""
class Original(BaseModel):
name: str # Required field
field = Original.model_fields["name"]
assert field.is_required() is True, "Original field should be required"
# This is what _make_field_optional does
tmp = deepcopy(field)
tmp.default = None
tmp.annotation = Optional[str]
assert tmp.is_required() is False, "Modified field should not be required"
assert tmp.default is None, "Default should be None"
def test_make_field_optional_function_works(self):
"""Verify _make_field_optional correctly transforms required fields."""
class TestModel(BaseModel):
name: str
age: int
for field_name, field_info in TestModel.model_fields.items():
assert field_info.is_required() is True, f"{field_name} should be required"
annotation, new_field = _make_field_optional(field_info)
assert new_field.is_required() is False, (
f"{field_name} should be optional after transformation"
)
assert new_field.default is None, f"{field_name} should have None default"
def test_partial_model_validates_empty_dict(self):
"""Verify Partial models can validate empty dicts (all fields None)."""
class MyModel(BaseModel):
name: str
age: int
status: str
PartialModel = Partial[MyModel]
TruePartial = _partial_api(PartialModel).get_partial_model()
# This should NOT raise ValidationError
result = TruePartial.model_validate({})
assert result.name is None
assert result.age is None
assert result.status is None
def test_partial_validates_incremental_streaming_data(self):
"""Verify Partial models correctly handle incremental streaming data."""
class MyModel(BaseModel):
name: str
age: int
PartialModel = Partial[MyModel]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Simulate streaming JSON chunks
streaming_states = [
("{}", None, None),
('{"name": "Jo', "Jo", None), # Partial string
('{"name": "John"}', "John", None),
('{"name": "John", "age": 25}', "John", 25),
]
for json_str, expected_name, expected_age in streaming_states:
obj = from_json(json_str.encode(), partial_mode="trailing-strings")
result = TruePartial.model_validate(obj)
assert result.name == expected_name, f"Failed for {json_str}"
assert result.age == expected_age, f"Failed for {json_str}"
def test_partial_with_all_field_types(self):
"""Verify _make_field_optional works with various field types."""
class ComplexModel(BaseModel):
string_field: str
int_field: int
float_field: float
bool_field: bool
list_field: list[str]
optional_field: Optional[str]
PartialModel = Partial[ComplexModel]
TruePartial = _partial_api(PartialModel).get_partial_model()
# All fields should validate with empty dict
result = TruePartial.model_validate({})
assert result.string_field is None
assert result.int_field is None
assert result.float_field is None
assert result.bool_field is None
assert result.list_field is None
assert result.optional_field is None
class TestLiteralTypeStreaming:
"""Tests for Literal type handling during streaming.
Without PartialLiteralMixin: uses partial_mode='trailing-strings', which keeps
incomplete strings and causes validation errors for Literal/Enum fields.
With PartialLiteralMixin: uses partial_mode='on', which drops incomplete strings
so fields become None.
"""
def test_literal_without_mixin_fails_on_incomplete_string(self):
"""Without PartialLiteralMixin, incomplete Literal strings cause validation errors."""
class ModelWithLiteral(BaseModel):
status: Literal["active", "inactive"]
PartialModel = Partial[ModelWithLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
# With partial_mode="trailing-strings", incomplete strings are kept
partial_json = b'{"status": "act'
obj = from_json(partial_json, partial_mode="trailing-strings")
# obj is {"status": "act"} - a partial string that fails Literal validation
with pytest.raises(ValidationError):
TruePartial.model_validate(obj)
def test_literal_with_mixin_incomplete_string_becomes_none(self):
"""With PartialLiteralMixin, incomplete Literal strings are dropped."""
class ModelWithLiteral(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
PartialModel = Partial[ModelWithLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
# With partial_mode="on" (enabled by PartialLiteralMixin), incomplete strings are dropped
partial_json = b'{"status": "act'
obj = from_json(partial_json, partial_mode="on")
# obj is {} because the incomplete string was dropped
result = TruePartial.model_validate(obj)
assert result.status is None
def test_literal_accepts_valid_complete_value(self):
"""Literal fields should accept valid complete values."""
class ModelWithLiteral(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
PartialModel = Partial[ModelWithLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"status": "active"})
assert result.status == "active"
result = TruePartial.model_validate({"status": "inactive"})
assert result.status == "inactive"
def test_literal_with_missing_field_is_none(self):
"""Literal fields should be None when not present in data."""
class ModelWithLiteral(BaseModel, PartialLiteralMixin):
name: str
status: Literal["active", "inactive"]
PartialModel = Partial[ModelWithLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"name": "John"})
assert result.name == "John"
assert result.status is None
def test_literal_default_is_available_during_streaming(self):
"""Literal fields with explicit defaults should be present in partial results."""
class Person(BaseModel):
type: Literal["Person"] = "Person"
name: str
age: int
PartialModel = Partial[Person]
results = list(_partial_api(PartialModel).model_from_chunks(['{"name": "Joh']))
assert len(results) == 1
assert results[0].type == "Person"
assert results[0].name == "Joh"
assert results[0].age is None
def test_literal_rejects_complete_invalid_value(self):
"""Complete but invalid Literal values should fail validation."""
class ModelWithLiteral(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
PartialModel = Partial[ModelWithLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
# "xyz" is a complete string but not a valid Literal value
with pytest.raises(ValidationError):
TruePartial.model_validate({"status": "xyz"})
class TestPartialStreamingWithComplexTypes:
"""Tests for streaming with complex Pydantic types using PartialLiteralMixin.
With PartialLiteralMixin, partial_mode='on' is used, so incomplete values are dropped.
"""
def test_enum_incomplete_string_becomes_none(self):
"""With PartialLiteralMixin, incomplete Enum strings are dropped."""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class ModelWithEnum(BaseModel, PartialLiteralMixin):
status: Status
PartialModel = Partial[ModelWithEnum]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Incomplete string is dropped with partial_mode="on"
obj = from_json(b'{"status": "act', partial_mode="on")
result = TruePartial.model_validate(obj)
assert result.status is None
def test_enum_accepts_valid_complete_value(self):
"""Enum fields should accept valid complete values."""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class ModelWithEnum(BaseModel, PartialLiteralMixin):
status: Status
PartialModel = Partial[ModelWithEnum]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"status": "active"})
assert result.status == Status.ACTIVE
def test_optional_literal_incomplete_string_becomes_none(self):
"""With PartialLiteralMixin, incomplete Optional[Literal] strings are dropped."""
class ModelWithOptionalLiteral(BaseModel, PartialLiteralMixin):
status: Optional[Literal["on", "off"]] = None
PartialModel = Partial[ModelWithOptionalLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
obj = from_json(b'{"status": "o', partial_mode="on")
result = TruePartial.model_validate(obj)
assert result.status is None
def test_optional_literal_accepts_valid_value(self):
"""Optional[Literal] should accept valid complete values."""
class ModelWithOptionalLiteral(BaseModel, PartialLiteralMixin):
status: Optional[Literal["on", "off"]] = None
PartialModel = Partial[ModelWithOptionalLiteral]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"status": "on"})
assert result.status == "on"
def test_union_literal_incomplete_string_becomes_none(self):
"""With PartialLiteralMixin, incomplete Union[Literal, int] strings are dropped."""
class ModelWithUnion(BaseModel, PartialLiteralMixin):
value: Union[Literal["yes", "no"], int]
PartialModel = Partial[ModelWithUnion]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Incomplete string is dropped
obj = from_json(b'{"value": "ye', partial_mode="on")
result = TruePartial.model_validate(obj)
assert result.value is None
def test_union_literal_accepts_valid_values(self):
"""Union[Literal, int] should accept both valid Literal and int."""
class ModelWithUnion(BaseModel, PartialLiteralMixin):
value: Union[Literal["yes", "no"], int]
PartialModel = Partial[ModelWithUnion]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"value": "yes"})
assert result.value == "yes"
result = TruePartial.model_validate({"value": 42})
assert result.value == 42
def test_partial_model_supports_pep604_union_annotations(self):
class MyResponse(BaseModel):
value: str | int
PartialModel = Partial[MyResponse]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"value": "hello"})
assert result.value == "hello"
result = TruePartial.model_validate({"value": 42})
assert result.value == 42
def test_union_of_literals_matches_all_branches(self):
"""Union[Literal, Literal] should match values from all branches."""
class ModelWithUnionLiterals(BaseModel, PartialLiteralMixin):
value: Union[Literal["a", "b"], Literal["x", "y"]]
PartialModel = Partial[ModelWithUnionLiterals]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Both branches should work
assert TruePartial.model_validate({"value": "a"}).value == "a"
assert TruePartial.model_validate({"value": "b"}).value == "b"
assert TruePartial.model_validate({"value": "x"}).value == "x"
assert TruePartial.model_validate({"value": "y"}).value == "y"
def test_list_literal_incomplete_item_dropped(self):
"""With PartialLiteralMixin, incomplete list items are dropped."""
class ModelWithLiteralList(BaseModel, PartialLiteralMixin):
tags: list[Literal["admin", "user", "guest"]]
PartialModel = Partial[ModelWithLiteralList]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Incomplete list item is dropped
obj = from_json(b'{"tags": ["admin", "us', partial_mode="on")
result = TruePartial.model_validate(obj)
assert result.tags == ["admin"]
def test_list_literal_accepts_valid_items(self):
"""list[Literal] should accept valid complete items."""
class ModelWithLiteralList(BaseModel, PartialLiteralMixin):
tags: list[Literal["admin", "user", "guest"]]
PartialModel = Partial[ModelWithLiteralList]
TruePartial = _partial_api(PartialModel).get_partial_model()
result = TruePartial.model_validate({"tags": ["admin", "user"]})
assert result.tags == ["admin", "user"]
class TestDiscriminatedUnionPartial:
"""Tests for discriminated unions with Partial streaming.
KNOWN LIMITATION: Discriminated unions don't work with Partial because:
- Partial makes all fields Optional
- Pydantic requires discriminator fields to be strictly Literal, not Optional[Literal]
Workaround: Use Union without the discriminator parameter.
"""
def test_discriminated_union_not_compatible_with_partial(self):
"""Discriminated unions fail with Partial (known limitation)."""
class Cat(BaseModel):
pet_type: Literal["cat"]
meows: int
class Dog(BaseModel):
pet_type: Literal["dog"]
barks: int
class PetContainer(BaseModel):
pet: Union[Cat, Dog] = Field(discriminator="pet_type")
# Fails because Partial makes pet_type Optional, but discriminators must be Literal
from pydantic import PydanticUserError
PartialModel = Partial[PetContainer]
with pytest.raises(PydanticUserError):
_partial_api(PartialModel).get_partial_model()
def test_union_without_discriminator_works(self):
"""Union without discriminator works with Partial streaming."""
class Cat(BaseModel):
pet_type: Literal["cat"]
meows: int
class Dog(BaseModel):
pet_type: Literal["dog"]
barks: int
class PetContainerNoDiscriminator(BaseModel):
pet: Union[Cat, Dog] # No discriminator - works with Partial
PartialModel = Partial[PetContainerNoDiscriminator]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Complete value works
result = TruePartial.model_validate({"pet": {"pet_type": "cat", "meows": 5}})
assert result.pet is not None
assert result.pet.pet_type == "cat"
def test_single_value_literal_incomplete_string(self):
"""Single-value Literals with incomplete strings become None."""
class Cat(BaseModel):
pet_type: Literal["cat"]
PartialModel = Partial[Cat]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Incomplete string is dropped
obj = from_json(b'{"pet_type": "ca', partial_mode="on")
result = TruePartial.model_validate(obj)
assert result.pet_type is None
# Complete value works
result = TruePartial.model_validate({"pet_type": "cat"})
assert result.pet_type == "cat"
class TestModelValidatorsDuringStreaming:
"""Tests for model validators during partial streaming.
Model validators are automatically wrapped to skip during streaming
(when context={"partial_streaming": True} is passed) and only run
when validating without that context (final validation).
"""
def test_model_validator_skipped_during_streaming(self):
"""Model validators should be skipped when streaming context is passed."""
from pydantic import model_validator
class ModelWithValidator(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
priority: Literal["high", "low"]
@model_validator(mode="after")
def validate_relationships(self):
# This would fail during streaming without wrapping
if self.status is not None and self.priority is None:
raise ValueError("If status is set, priority must also be set!")
return self
PartialModel = Partial[ModelWithValidator]
# With completeness-based validation, incomplete JSON skips all validation
# by using model_construct() instead of model_validate()
chunks = ['{"status": "act'] # Incomplete JSON
results = list(_partial_api(PartialModel).model_from_chunks(chunks))
# Incomplete JSON - no validation runs, partial value stored
assert results[0].status == "act"
assert results[0].priority is None
def test_model_validator_runs_when_complete(self):
"""Model validators should run when all fields are complete."""
from pydantic import model_validator
class ModelWithValidator(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
priority: Literal["high", "low"]
@model_validator(mode="after")
def validate_relationships(self):
if self.status == "active" and self.priority == "low":
raise ValueError("Active status requires high priority!")
return self
PartialModel = Partial[ModelWithValidator]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Valid complete data
result = TruePartial.model_validate({"status": "active", "priority": "high"})
assert result.status == "active"
assert result.priority == "high"
# Invalid complete data should fail
with pytest.raises(ValidationError):
TruePartial.model_validate({"status": "active", "priority": "low"})
def test_multiple_model_validators(self):
"""Multiple model validators should all be wrapped and run when complete."""
from pydantic import model_validator
validator_calls = []
class ModelWithMultipleValidators(BaseModel, PartialLiteralMixin):
a: Literal["x", "y"]
b: Literal["1", "2"]
@model_validator(mode="after")
def validator_one(self):
validator_calls.append("one")
return self
@model_validator(mode="after")
def validator_two(self):
validator_calls.append("two")
return self
PartialModel = Partial[ModelWithMultipleValidators]
# During streaming with incomplete JSON, validators should be skipped
# because model_construct() is used instead of model_validate()
validator_calls.clear()
chunks = ['{"a": "x'] # Incomplete JSON
list(_partial_api(PartialModel).model_from_chunks(chunks))
assert validator_calls == []
# Complete JSON - validators run during model_validate
validator_calls.clear()
chunks = ['{"a": "x", "b": "1"}'] # Complete JSON
list(_partial_api(PartialModel).model_from_chunks(chunks))
assert "one" in validator_calls
assert "two" in validator_calls
def test_validators_run_without_streaming_context(self):
"""Validators should run when no streaming context is passed (final validation)."""
from pydantic import model_validator
class ModelWithValidator(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
priority: Literal["high", "low"]
@model_validator(mode="after")
def validate_relationships(self):
if self.status == "active" and self.priority == "low":
raise ValueError("Active requires high priority!")
return self
PartialModel = Partial[ModelWithValidator]
TruePartial = _partial_api(PartialModel).get_partial_model()
# Without streaming context, validators run even with incomplete data
# This is the final validation scenario
with pytest.raises(ValidationError):
TruePartial.model_validate({"status": "active", "priority": "low"})
# Valid complete data passes
result = TruePartial.model_validate({"status": "active", "priority": "high"})
assert result.status == "active"
assert result.priority == "high"
class TestFinalValidationAfterStreaming:
"""Tests for final validation after streaming completes.
When streaming ends, the final object is validated against the original
model to enforce required fields and run validators without streaming context.
"""
def test_final_validation_catches_missing_required_fields(self):
"""Final validation should fail if required fields are missing."""
class ModelWithRequired(BaseModel):
name: str # Required
age: int # Required
nickname: Optional[str] = None # Optional
PartialModel = Partial[ModelWithRequired]
# Simulate streaming that doesn't provide all required fields
chunks = ['{"name": "John"}'] # Missing 'age'
with pytest.raises(ValidationError) as exc_info:
list(_partial_api(PartialModel).model_from_chunks(iter(chunks)))
# Should fail because 'age' is required but missing
assert "age" in str(exc_info.value)
def test_final_validation_passes_with_all_required_fields(self):
"""Final validation should pass when all required fields are present."""
class ModelWithRequired(BaseModel):
name: str
age: int
PartialModel = Partial[ModelWithRequired]
# Simulate streaming that provides all required fields
chunks = ['{"name": "John", "age": 30}']
results = list(_partial_api(PartialModel).model_from_chunks(iter(chunks)))
assert len(results) > 0
final = results[-1]
assert final.name == "John"
assert final.age == 30
def test_final_validation_runs_model_validators(self):
"""Final validation should run model validators without streaming context."""
from pydantic import model_validator
class ModelWithValidator(BaseModel, PartialLiteralMixin):
status: Literal["active", "inactive"]
priority: Literal["high", "low"]
@model_validator(mode="after")
def check_consistency(self):
if self.status == "active" and self.priority == "low":
raise ValueError("Active tasks must have high priority")
return self
PartialModel = Partial[ModelWithValidator]
# This should fail final validation due to the model validator
chunks = ['{"status": "active", "priority": "low"}']
with pytest.raises(ValidationError) as exc_info:
list(_partial_api(PartialModel).model_from_chunks(iter(chunks)))
assert "Active tasks must have high priority" in str(exc_info.value)
def test_streaming_yields_partial_objects_before_final_validation(self):
"""Streaming should yield partial objects even if final validation will fail."""
class ModelWithRequired(BaseModel):
name: str
age: int
PartialModel = Partial[ModelWithRequired]
# Stream with incomplete JSON first, then complete JSON
# First chunk is incomplete, yields partial object
# Second chunk completes the JSON with all required fields
chunks = ['{"name": "Jo', 'hn", "age": 25}']
partial_objects = []
for obj in _partial_api(PartialModel).model_from_chunks(iter(chunks)):
partial_objects.append(obj)
# Should have yielded partial objects during streaming
assert len(partial_objects) >= 1
# First partial object has incomplete name
assert partial_objects[0].name == "Jo"
# Final object is fully validated
assert partial_objects[-1].name == "John"
assert partial_objects[-1].age == 25
def test_original_model_reference_is_stored(self):
"""Partial model should store reference to original model."""
class OriginalModel(BaseModel):
name: str
PartialModel = Partial[OriginalModel]
assert hasattr(PartialModel, "_original_model")
assert PartialModel._original_model is OriginalModel
@pytest.mark.asyncio
async def test_async_final_validation_catches_missing_required_fields(self):
"""Async streaming should also do final validation."""
class ModelWithRequired(BaseModel):
name: str
age: int
PartialModel = Partial[ModelWithRequired]
async def async_chunks():
yield '{"name": "John"}' # Missing 'age'
with pytest.raises(ValidationError) as exc_info:
async for _ in _partial_api(PartialModel).model_from_chunks_async(
async_chunks()
):
pass
assert "age" in str(exc_info.value)
class TestRecursiveModels:
"""Test that Partial handles self-referential models without infinite recursion."""
def test_basic_recursive_model(self):
"""Partial should work with basic recursive models."""
class TreeNode(BaseModel):
value: str
children: Optional[list["TreeNode"]] = None
TreeNode.model_rebuild()
# Should not raise RecursionError
PartialTreeNode = Partial[TreeNode]
TruePartial = _partial_api(PartialTreeNode).get_partial_model()
# Can validate partial data
result = TruePartial.model_validate({"value": "root"})
assert result.value == "root"
assert result.children is None
def test_nested_recursive_model(self):
"""Partial should work with nested children."""
class TreeNode(BaseModel):
value: str
children: Optional[list["TreeNode"]] = None
TreeNode.model_rebuild()
PartialTreeNode = Partial[TreeNode]
TruePartial = _partial_api(PartialTreeNode).get_partial_model()
# Validate with nested structure
data = {
"value": "root",
"children": [
{"value": "child1"},
{"value": "child2", "children": [{"value": "grandchild"}]},
],
}
result = TruePartial.model_validate(data)
assert result.value == "root"
assert len(result.children) == 2
assert result.children[0].value == "child1"
assert result.children[1].children[0].value == "grandchild"
def test_mutually_recursive_models(self):
"""Partial should handle mutually recursive models."""
class Person(BaseModel):
name: str
employer: Optional["Company"] = None
class Company(BaseModel):
name: str
employees: Optional[list[Person]] = None
Person.model_rebuild()
Company.model_rebuild()
# Both should work without RecursionError
PartialPerson = Partial[Person]
PartialCompany = Partial[Company]
assert PartialPerson is not None
assert PartialCompany is not None
# Validate partial data
person_partial = _partial_api(PartialPerson).get_partial_model()
result = person_partial.model_validate({"name": "Alice"})
assert result.name == "Alice"
def test_direct_self_reference(self):
"""Partial should handle direct self-reference (linked list style)."""
class LinkedNode(BaseModel):
value: int
next: Optional["LinkedNode"] = None
LinkedNode.model_rebuild()
# Should not raise RecursionError
PartialLinked = Partial[LinkedNode]
TruePartial = _partial_api(PartialLinked).get_partial_model()
# Validate chain
data = {"value": 1, "next": {"value": 2, "next": {"value": 3}}}
result = TruePartial.model_validate(data)
assert result.value == 1
assert result.next.value == 2
assert result.next.next.value == 3
def test_complex_recursive_with_validators(self):
"""Complex recursive model with validators, multiple self-refs, and nested types."""
from typing import Literal
from pydantic import model_validator, field_validator
from enum import Enum
class NodeType(Enum):
FOLDER = "folder"
FILE = "file"
SYMLINK = "symlink"
class Permission(BaseModel):
user: str
level: Literal["read", "write", "admin"]
class FileSystemNode(BaseModel):
name: str
node_type: NodeType
size_bytes: Optional[int] = None
children: Optional[list["FileSystemNode"]] = None
parent: Optional["FileSystemNode"] = None
symlink_target: Optional["FileSystemNode"] = None
permissions: Optional[list[Permission]] = None
metadata: Optional[dict[str, str]] = None
@field_validator("name")
@classmethod
def validate_name(cls, v):
if v and "/" in v:
raise ValueError("Name cannot contain /")
return v
@model_validator(mode="after")
def validate_node_consistency(self):
# Folders must have no size, files must have size
if self.node_type == NodeType.FOLDER and self.size_bytes is not None:
raise ValueError("Folders cannot have size_bytes")
if self.node_type == NodeType.FILE and self.children:
raise ValueError("Files cannot have children")
if self.node_type == NodeType.SYMLINK and not self.symlink_target:
raise ValueError("Symlinks must have a target")
return self
FileSystemNode.model_rebuild()
# Should not raise RecursionError
PartialFS = Partial[FileSystemNode]
TruePartial = _partial_api(PartialFS).get_partial_model()
# Complex nested structure
data = {
"name": "root",
"node_type": "folder",
"permissions": [{"user": "admin", "level": "admin"}],
"metadata": {"created": "2024-01-01"},
"children": [
{
"name": "documents",
"node_type": "folder",
"children": [
{
"name": "report.pdf",
"node_type": "file",
"size_bytes": 1024,
"permissions": [{"user": "alice", "level": "read"}],
},
{
"name": "data",
"node_type": "folder",
"children": [
{
"name": "archive.zip",
"node_type": "file",
"size_bytes": 2048,
}
],
},
],
},
{
"name": "shortcut",
"node_type": "symlink",
"symlink_target": {
"name": "target_file",
"node_type": "file",
"size_bytes": 512,
},
},
],
}
result = TruePartial.model_validate(data)
assert result.name == "root"
assert result.node_type == NodeType.FOLDER
assert len(result.children) == 2
assert result.children[0].name == "documents"
assert len(result.children[0].children) == 2
assert result.children[0].children[0].name == "report.pdf"
assert result.children[0].children[0].size_bytes == 1024
assert result.children[0].children[1].children[0].name == "archive.zip"
assert result.children[1].symlink_target.name == "target_file"
assert result.permissions[0].level == "admin"
def test_recursive_with_union_types(self):
"""Recursive model with Union types containing self-references."""
from typing import Union
class TextBlock(BaseModel):
text: str
class Container(BaseModel):
title: str
content: list[Union[TextBlock, "Container"]]
Container.model_rebuild()
PartialContainer = Partial[Container]
TruePartial = _partial_api(PartialContainer).get_partial_model()
data = {
"title": "Chapter 1",
"content": [
{"text": "Introduction paragraph"},
{
"title": "Section 1.1",
"content": [
{"text": "Section text"},
{
"title": "Subsection 1.1.1",
"content": [{"text": "Deep nested text"}],
},
],
},
{"text": "Closing paragraph"},
],
}
result = TruePartial.model_validate(data)
assert result.title == "Chapter 1"
assert len(result.content) == 3
assert result.content[0].text == "Introduction paragraph"
assert result.content[1].title == "Section 1.1"
assert result.content[1].content[1].title == "Subsection 1.1.1"