Files
567-labs--instructor/tests/coverage/test_dsl_partial_coverage.py
wehub-resource-sync 97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:36:38 +08:00

439 lines
13 KiB
Python

from collections.abc import AsyncGenerator, Callable, Generator, Iterable
from enum import Enum
from typing import Any, Optional, Protocol, Union, cast
import typing
import pytest
from pydantic import BaseModel
from instructor.v2.dsl.json_tracker import JsonCompleteness
from instructor.v2.dsl.partial import Partial, PartialBase
import instructor.v2.dsl.partial as partial_module
class State(str, Enum):
READY = "ready"
PENDING = "pending"
class Item(BaseModel):
number: int
state: State
class Envelope(BaseModel):
optional_note: Optional[str]
metadata: dict[str, Union[str, int]]
featured: Item
items: list[Item]
class LooseEnvelope(BaseModel):
metadata: dict
values: typing.List # noqa: UP006
counts: list[int]
class EnvelopeStream(Protocol):
@classmethod
def model_from_chunks(
cls, json_chunks: Iterable[Any], **kwargs: Any
) -> Generator[Envelope, None, None]: ...
@classmethod
def model_from_chunks_async(
cls, json_chunks: AsyncGenerator[Any, None], **kwargs: Any
) -> AsyncGenerator[Envelope, None]: ...
@classmethod
def from_streaming_response(
cls,
completion: Iterable[Any],
stream_extractor: Optional[
Callable[[Iterable[Any]], Generator[str, None, None]]
],
) -> Generator[Envelope, None, None]: ...
@classmethod
def from_streaming_response_async(
cls,
completion: AsyncGenerator[Any, None],
stream_extractor: Optional[
Callable[[AsyncGenerator[Any, None]], AsyncGenerator[str, None]]
],
) -> AsyncGenerator[Envelope, None]: ...
_build_partial_object = cast(
Callable[[Any, type[BaseModel], JsonCompleteness, str], Any],
vars(partial_module)["_build_partial_object"],
)
_build_partial_list = cast(
Callable[
[list[Any], Optional[type[BaseModel]], str, JsonCompleteness, str], list[Any]
],
vars(partial_module)["_build_partial_list"],
)
class TextChunk:
def __init__(self, value: str) -> None:
self.value = value
def __str__(self) -> str:
return self.value
class UnprintableChunk:
def __str__(self) -> str:
raise ValueError("cannot render chunk")
def test_partial_builder_validates_complete_nested_values_and_keeps_open_values() -> (
None
):
tracker = JsonCompleteness()
tracker.analyze(
'{"optional_note":null,"metadata":{"source":"stream","attempt":2},'
'"featured":{"number":"7","state":"ready"},'
'"items":[{"number":"8","state":"pending"},'
'{"number":"9","state":"rea'
)
result = _build_partial_object(
{
"optional_note": None,
"metadata": {"source": "stream", "attempt": 2},
"featured": {"number": "7", "state": "ready"},
"items": [
{"number": "8", "state": "pending"},
{"number": "9", "state": "rea"},
],
},
Envelope,
tracker,
"",
)
assert result.optional_note is None
assert result.metadata == {"source": "stream", "attempt": 2}
assert result.featured == Item(number=7, state=State.READY)
assert result.items[0] == Item(number=8, state=State.PENDING)
assert result.items[1] == {"number": "9", "state": "rea"}
def test_partial_builder_recurses_into_open_nested_model_and_handles_scalars() -> None:
tracker = JsonCompleteness()
tracker.analyze('{"featured":{"number":"12","state":"pen')
result = _build_partial_object(
{"featured": {"number": "12", "state": "pen"}}, Envelope, tracker, ""
)
assert result.featured.number == "12"
assert result.featured.state == "pen"
assert result.optional_note is None
assert result.metadata is None
assert result.items is None
assert _build_partial_object(None, Envelope, tracker, "") is None
assert _build_partial_object("still streaming", Envelope, tracker, "") == (
"still streaming"
)
def test_partial_list_preserves_untyped_and_unknown_fields() -> None:
tracker = JsonCompleteness()
tracker.analyze('[{"number":"3","state":"ready"}]')
raw_item = {"number": "3", "state": "ready"}
assert _build_partial_list([raw_item], None, "items", tracker, "") == [raw_item]
assert _build_partial_list([raw_item], Envelope, "missing", tracker, "") == [
raw_item
]
assert _build_partial_list([raw_item], Envelope, "metadata", tracker, "") == [
raw_item
]
def test_partial_builder_preserves_bare_containers_and_complete_scalar_items() -> None:
tracker = JsonCompleteness()
tracker.analyze('{"metadata":{"source":"stream"},"values":["raw"],"counts":[1]}')
result = _build_partial_object(
{"metadata": {"source": "stream"}, "values": ["raw"], "counts": [1]},
LooseEnvelope,
tracker,
"",
)
assert result.metadata == {"source": "stream"}
assert result.values == ["raw"]
assert result.counts == [1]
def test_sync_stream_accepts_string_like_chunks_and_skips_bad_chunks() -> None:
partial_envelope = cast(type[EnvelopeStream], Partial[Envelope])
values = list(
partial_envelope.model_from_chunks(
[
None,
UnprintableChunk(),
'{"optional_note":"live","metadata":{"source":"sync"},',
TextChunk(
'"featured":{"number":1,"state":"ready"},'
'"items":[{"number":2,"state":"pending"}]}'
),
]
)
)
assert len(values) == 2
assert values[0].optional_note == "live"
assert values[-1] == Envelope(
optional_note="live",
metadata={"source": "sync"},
featured=Item(number=1, state=State.READY),
items=[Item(number=2, state=State.PENDING)],
)
def test_sync_partial_stream_handles_empty_incomplete_and_unbound_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
partial_envelope = cast(type[EnvelopeStream], Partial[Envelope])
assert list(partial_envelope.model_from_chunks([])) == []
incomplete = list(partial_envelope.model_from_chunks(['{"optional_note":"open']))
assert len(incomplete) == 1
assert incomplete[0].optional_note == "open"
monkeypatch.setattr(partial_envelope, "_original_model", None)
complete = list(
partial_envelope.model_from_chunks(
[
'{"optional_note":"complete","metadata":{},'
'"featured":{"number":1,"state":"ready"},"items":[]}'
]
)
)
assert len(complete) == 1
assert complete[0].optional_note == "complete"
@pytest.mark.asyncio
async def test_async_stream_accepts_string_like_chunks_and_skips_bad_chunks() -> None:
async def chunks() -> AsyncGenerator[Any, None]:
yield None
yield UnprintableChunk()
yield '{"optional_note":"live","metadata":{"source":"async"},'
yield TextChunk(
'"featured":{"number":4,"state":"pending"},'
'"items":[{"number":5,"state":"ready"}]}'
)
partial_envelope = cast(type[EnvelopeStream], Partial[Envelope])
values = [
value async for value in partial_envelope.model_from_chunks_async(chunks())
]
assert len(values) == 2
assert values[0].metadata == {"source": "async"}
assert values[-1] == Envelope(
optional_note="live",
metadata={"source": "async"},
featured=Item(number=4, state=State.PENDING),
items=[Item(number=5, state=State.READY)],
)
@pytest.mark.asyncio
async def test_async_partial_stream_handles_empty_incomplete_and_unbound_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def chunks(*values: str) -> AsyncGenerator[str, None]:
for value in values:
yield value
partial_envelope = cast(type[EnvelopeStream], Partial[Envelope])
assert [
value async for value in partial_envelope.model_from_chunks_async(chunks())
] == []
incomplete = [
value
async for value in partial_envelope.model_from_chunks_async(
chunks('{"optional_note":"open')
)
]
assert len(incomplete) == 1
assert incomplete[0].optional_note == "open"
monkeypatch.setattr(partial_envelope, "_original_model", None)
complete = [
value
async for value in partial_envelope.model_from_chunks_async(
chunks(
'{"optional_note":"complete","metadata":{},'
'"featured":{"number":1,"state":"ready"},"items":[]}'
)
)
]
assert len(complete) == 1
assert complete[0].optional_note == "complete"
def test_sync_streaming_response_requires_and_uses_an_extractor() -> None:
partial_envelope = cast(type[EnvelopeStream], Partial[Envelope])
with pytest.raises(ValueError, match="stream_extractor is required"):
list(partial_envelope.from_streaming_response([], None))
def extractor(completion: Iterable[str]) -> Generator[str, None, None]:
yield from completion
values = list(
partial_envelope.from_streaming_response(
[
'{"optional_note":"extracted","metadata":{},'
'"featured":{"number":1,"state":"ready"},"items":[]}'
],
extractor,
)
)
assert values == [
Envelope(
optional_note="extracted",
metadata={},
featured=Item(number=1, state=State.READY),
items=[],
)
]
@pytest.mark.asyncio
async def test_async_streaming_response_requires_and_uses_an_extractor() -> None:
partial_envelope = cast(type[EnvelopeStream], Partial[Envelope])
async def empty() -> AsyncGenerator[str, None]:
if False:
yield ""
with pytest.raises(ValueError, match="stream_extractor is required"):
[
value
async for value in partial_envelope.from_streaming_response_async(
empty(), None
)
]
async def completion() -> AsyncGenerator[str, None]:
yield (
'{"optional_note":"extracted","metadata":{},'
'"featured":{"number":6,"state":"pending"},"items":[]}'
)
async def extractor(source: AsyncGenerator[str, None]) -> AsyncGenerator[str, None]:
async for chunk in source:
yield chunk
values = [
value
async for value in partial_envelope.from_streaming_response_async(
completion(), extractor
)
]
assert values == [
Envelope(
optional_note="extracted",
metadata={},
featured=Item(number=6, state=State.PENDING),
items=[],
)
]
def test_sync_json_extractor_delegates_and_rejects_a_missing_extractor() -> None:
def extractor(completion: Iterable[str]) -> Generator[str, None, None]:
for chunk in completion:
yield chunk.removeprefix("payload:")
assert list(
PartialBase.extract_json(["payload:{", 'payload:"ok":true}'], extractor)
) == ["{", '"ok":true}']
with pytest.raises(ValueError, match="stream_extractor is required"):
list(PartialBase.extract_json([], None))
@pytest.mark.asyncio
async def test_async_json_extractor_delegates_and_rejects_a_missing_extractor() -> None:
async def completion() -> AsyncGenerator[str, None]:
yield "payload:{"
yield 'payload:"ok":true}'
async def extractor(source: AsyncGenerator[str, None]) -> AsyncGenerator[str, None]:
async for chunk in source:
yield chunk.removeprefix("payload:")
assert [
chunk async for chunk in PartialBase.extract_json_async(completion(), extractor)
] == ["{", '"ok":true}']
with pytest.raises(ValueError, match="stream_extractor is required"):
[chunk async for chunk in PartialBase.extract_json_async(completion(), None)]
@pytest.mark.asyncio
async def test_async_responses_extractor_ignores_unhandled_events() -> None:
from openai.types.responses import (
ResponseFunctionCallArgumentsDeltaEvent,
ResponseReasoningSummaryTextDeltaEvent,
)
from instructor.v2.core.mode import Mode
async def events() -> AsyncGenerator[Any, None]:
yield ResponseReasoningSummaryTextDeltaEvent(
delta="thinking",
item_id="reasoning-1",
output_index=0,
sequence_number=1,
summary_index=0,
type="response.reasoning_summary_text.delta",
)
yield object()
yield ResponseFunctionCallArgumentsDeltaEvent(
delta='{"ok":true}',
item_id="call-1",
output_index=1,
sequence_number=2,
type="response.function_call_arguments.delta",
)
assert [
chunk
async for chunk in PartialBase.extract_json_async(
events(), Mode.RESPONSES_TOOLS
)
] == ['{"ok":true}']
def test_partial_rejects_direct_use_and_wraps_a_recursive_model() -> None:
with pytest.raises(TypeError, match="Cannot instantiate abstract Partial class"):
cast(Callable[[], object], Partial)()
with pytest.raises(TypeError, match="Cannot subclass .*Partial"):
type("InvalidPartial", (cast(type, Partial),), {})
class RecursiveNode(BaseModel):
child: "RecursiveNode"
RecursiveNode.model_rebuild()
schema = Partial[RecursiveNode].model_json_schema()
assert schema["$defs"]["PartialRecursiveNode"]["properties"]["child"] == {
"$ref": "#/$defs/RecursiveNode"
}
assert schema["$defs"]["RecursiveNode"]["properties"]["child"] == {
"$ref": "#/$defs/RecursiveNode"
}