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
439 lines
13 KiB
Python
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"
|
|
}
|