9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
1020 lines
36 KiB
Python
1020 lines
36 KiB
Python
from __future__ import annotations as _annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import contextvars
|
|
import functools
|
|
import importlib
|
|
import os
|
|
import sys
|
|
import threading
|
|
from collections.abc import AsyncIterator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from importlib.metadata import distributions
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import pydantic_ai._utils as utils_module
|
|
from pydantic_ai import UserError
|
|
from pydantic_ai._utils import (
|
|
UNSET,
|
|
PeekableAsyncStream,
|
|
check_object_json_schema,
|
|
dataclasses_no_defaults_repr,
|
|
group_by_temporal,
|
|
is_async_callable,
|
|
merge_json_schema_defs,
|
|
run_in_executor,
|
|
strip_markdown_fences,
|
|
using_thread_executor,
|
|
)
|
|
|
|
from ._inline_snapshot import snapshot
|
|
from .models.mock_async_stream import MockAsyncStream
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'interval,expected',
|
|
[
|
|
(None, snapshot([[1], [2], [3]])),
|
|
(0, snapshot([[1], [2], [3]])),
|
|
(0.02, snapshot([[1], [2], [3]])),
|
|
(0.04, snapshot([[1, 2], [3]])),
|
|
(0.1, snapshot([[1, 2, 3]])),
|
|
],
|
|
)
|
|
async def test_group_by_temporal(interval: float | None, expected: list[list[int]]):
|
|
async def yield_groups() -> AsyncIterator[int]:
|
|
yield 1
|
|
await asyncio.sleep(0.02)
|
|
yield 2
|
|
await asyncio.sleep(0.02)
|
|
yield 3
|
|
await asyncio.sleep(0.02)
|
|
|
|
async with group_by_temporal(yield_groups(), soft_max_interval=interval) as groups_iter:
|
|
groups: list[list[int]] = [g async for g in groups_iter]
|
|
assert groups == expected
|
|
|
|
|
|
async def test_group_by_temporal_first_window_starts_on_first_item():
|
|
"""The debounce window must start when the first item arrives, not when iteration begins.
|
|
|
|
Regression test for #5946. A slow first item — e.g. the latency before a model's first
|
|
streamed token — used to have its window measured from iteration start, so the window
|
|
elapsed before the item arrived and it was emitted in a group of its own. Here the first
|
|
item is delayed well past the window, yet must still group with a second item that arrives
|
|
within the window of the first: correct output is `[[1, 2]]`, the bug produced `[[1], [2]]`.
|
|
"""
|
|
interval = 0.05
|
|
|
|
async def yield_groups() -> AsyncIterator[int]:
|
|
await asyncio.sleep(interval * 3) # first item arrives long after iteration started
|
|
yield 1
|
|
await asyncio.sleep(interval / 5) # second item arrives well within the first item's window
|
|
yield 2
|
|
|
|
async with group_by_temporal(yield_groups(), soft_max_interval=interval) as groups_iter:
|
|
groups: list[list[int]] = [g async for g in groups_iter]
|
|
assert groups == [[1, 2]]
|
|
|
|
|
|
def test_check_object_json_schema():
|
|
object_schema = {'type': 'object', 'properties': {'a': {'type': 'string'}}}
|
|
assert check_object_json_schema(object_schema) == object_schema
|
|
|
|
assert check_object_json_schema(
|
|
{
|
|
'$defs': {
|
|
'JsonModel': {
|
|
'properties': {
|
|
'type': {'title': 'Type', 'type': 'string'},
|
|
'items': {'anyOf': [{'type': 'string'}, {'type': 'null'}]},
|
|
},
|
|
'required': ['type', 'items'],
|
|
'title': 'JsonModel',
|
|
'type': 'object',
|
|
}
|
|
},
|
|
'$ref': '#/$defs/JsonModel',
|
|
}
|
|
) == {
|
|
'properties': {
|
|
'items': {'anyOf': [{'type': 'string'}, {'type': 'null'}]},
|
|
'type': {'title': 'Type', 'type': 'string'},
|
|
},
|
|
'required': ['type', 'items'],
|
|
'title': 'JsonModel',
|
|
'type': 'object',
|
|
}
|
|
|
|
# Can't remove the recursive ref here:
|
|
assert check_object_json_schema(
|
|
{
|
|
'$defs': {
|
|
'JsonModel': {
|
|
'properties': {
|
|
'type': {'title': 'Type', 'type': 'string'},
|
|
'items': {'anyOf': [{'$ref': '#/$defs/JsonModel'}, {'type': 'null'}]},
|
|
},
|
|
'required': ['type', 'items'],
|
|
'title': 'JsonModel',
|
|
'type': 'object',
|
|
}
|
|
},
|
|
'$ref': '#/$defs/JsonModel',
|
|
}
|
|
) == {
|
|
'$defs': {
|
|
'JsonModel': {
|
|
'properties': {
|
|
'items': {'anyOf': [{'$ref': '#/$defs/JsonModel'}, {'type': 'null'}]},
|
|
'type': {'title': 'Type', 'type': 'string'},
|
|
},
|
|
'required': ['type', 'items'],
|
|
'title': 'JsonModel',
|
|
'type': 'object',
|
|
}
|
|
},
|
|
'$ref': '#/$defs/JsonModel',
|
|
}
|
|
|
|
array_schema = {'type': 'array', 'items': {'type': 'string'}}
|
|
with pytest.raises(UserError, match=r'^Schema must be an object$'):
|
|
check_object_json_schema(array_schema)
|
|
|
|
|
|
@pytest.mark.parametrize('peek_first', [True, False])
|
|
@pytest.mark.anyio
|
|
async def test_peekable_async_stream(peek_first: bool):
|
|
async_stream = MockAsyncStream(iter([1, 2, 3]))
|
|
peekable_async_stream: PeekableAsyncStream[int, MockAsyncStream[int]] = PeekableAsyncStream(async_stream)
|
|
|
|
items: list[int] = []
|
|
|
|
# We need to both peek before starting the stream, and not, to achieve full coverage
|
|
if peek_first:
|
|
assert not await peekable_async_stream.is_exhausted()
|
|
assert await peekable_async_stream.peek() == 1
|
|
|
|
async for item in peekable_async_stream:
|
|
items.append(item)
|
|
|
|
# The next line is included mostly for the sake of achieving coverage
|
|
assert await peekable_async_stream.peek() == (item + 1 if item < 3 else UNSET)
|
|
|
|
assert await peekable_async_stream.is_exhausted()
|
|
assert await peekable_async_stream.peek() is UNSET
|
|
assert items == [1, 2, 3]
|
|
|
|
|
|
async def test_peekable_async_stream_aclose_before_iteration():
|
|
class AsyncIteratorNoClose:
|
|
def __aiter__(self) -> AsyncIteratorNoClose:
|
|
return self # pragma: no cover
|
|
|
|
async def __anext__(self) -> int:
|
|
raise StopAsyncIteration # pragma: no cover
|
|
|
|
peekable_async_stream: PeekableAsyncStream[int, AsyncIteratorNoClose] = PeekableAsyncStream(AsyncIteratorNoClose())
|
|
await peekable_async_stream.aclose()
|
|
|
|
assert await peekable_async_stream.is_exhausted()
|
|
|
|
|
|
def test_run_until_complete_cleans_up_own_task_on_interrupt():
|
|
"""A `KeyboardInterrupt` during `run_until_complete` must drive our own coroutine's cleanup
|
|
(closing model streams and HTTP connections via its `async with`/`finally` blocks) and leave no
|
|
pending task, without cancelling other tasks on the caller-owned loop.
|
|
|
|
This is a unit test rather than a public-API/VCR test because it requires a real interrupt to
|
|
arrive mid-`run_until_complete` while the coroutine is suspended, which can't be triggered
|
|
reliably through the public API; we simulate the interrupt by patching the loop.
|
|
"""
|
|
cleaned: list[str] = []
|
|
|
|
async def coro() -> None:
|
|
try:
|
|
await asyncio.Event().wait() # suspends forever
|
|
finally:
|
|
cleaned.append('cleaned')
|
|
|
|
loop = utils_module.get_event_loop()
|
|
|
|
# An unrelated task on the (caller-owned) loop that must survive: the reporter's `all_tasks()`
|
|
# sledgehammer would cancel this, ours must not.
|
|
async def bystander() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
bystander_task = loop.create_task(bystander())
|
|
tasks_before = asyncio.all_tasks(loop)
|
|
|
|
real_run_until_complete = loop.run_until_complete
|
|
calls = 0
|
|
|
|
def interrupt_once(future: Any) -> Any:
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
# Let our task (and the bystander) start and suspend, then simulate Ctrl-C reaching the
|
|
# caller of `run_until_complete`.
|
|
loop.call_soon(loop.stop)
|
|
loop.run_forever()
|
|
raise KeyboardInterrupt
|
|
return real_run_until_complete(future)
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setattr(loop, 'run_until_complete', interrupt_once)
|
|
with pytest.raises(KeyboardInterrupt):
|
|
utils_module.run_until_complete(coro())
|
|
|
|
assert cleaned == ['cleaned'] # our coroutine's cleanup ran
|
|
assert not bystander_task.cancelled() # the unrelated task was left alone
|
|
assert asyncio.all_tasks(loop) == tasks_before # our task didn't leak, nothing else was touched
|
|
|
|
bystander_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
loop.run_until_complete(bystander_task)
|
|
|
|
|
|
def test_package_versions(capsys: pytest.CaptureFixture[str]):
|
|
if os.getenv('CI'):
|
|
with capsys.disabled(): # pragma: lax no cover
|
|
print('\npackage versions:')
|
|
packages = sorted((package.metadata['Name'], package.version) for package in distributions())
|
|
for name, version in packages:
|
|
print(f'{name:30} {version}')
|
|
|
|
|
|
async def test_run_in_executor_with_contextvars() -> None:
|
|
ctx_var = contextvars.ContextVar('test_var', default='default')
|
|
ctx_var.set('original_value')
|
|
|
|
result = await run_in_executor(ctx_var.get)
|
|
assert result == ctx_var.get()
|
|
|
|
ctx_var.set('new_value')
|
|
result = await run_in_executor(ctx_var.get)
|
|
assert result == ctx_var.get()
|
|
|
|
# show that the old version did not work
|
|
old_result = asyncio.get_running_loop().run_in_executor(None, ctx_var.get)
|
|
assert old_result != ctx_var.get()
|
|
|
|
|
|
async def test_run_in_executor_with_disable_threads() -> None:
|
|
from pydantic_ai._utils import disable_threads
|
|
|
|
calls: list[str] = []
|
|
|
|
def sync_func() -> str:
|
|
calls.append('called')
|
|
return 'result'
|
|
|
|
# Without disable_threads, should use threading
|
|
result = await run_in_executor(sync_func)
|
|
assert result == 'result'
|
|
assert calls == ['called']
|
|
|
|
# With disable_threads enabled, should execute directly
|
|
calls.clear()
|
|
with disable_threads():
|
|
result = await run_in_executor(sync_func)
|
|
assert result == 'result'
|
|
assert calls == ['called']
|
|
|
|
|
|
async def test_run_in_executor_with_custom_executor() -> None:
|
|
main_thread = threading.current_thread()
|
|
|
|
def sync_func() -> threading.Thread:
|
|
return threading.current_thread()
|
|
|
|
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='custom-pool')
|
|
try:
|
|
with using_thread_executor(executor):
|
|
result = await run_in_executor(sync_func)
|
|
assert result is not main_thread
|
|
assert result.name.startswith('custom-pool')
|
|
finally:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
async def test_run_in_executor_custom_executor_preserves_context_vars() -> None:
|
|
ctx_var = contextvars.ContextVar('test_var', default='default')
|
|
ctx_var.set('custom_value')
|
|
|
|
executor = ThreadPoolExecutor(max_workers=2)
|
|
try:
|
|
with using_thread_executor(executor):
|
|
result = await run_in_executor(ctx_var.get)
|
|
assert result == 'custom_value'
|
|
finally:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
async def test_disable_threads_takes_priority_over_custom_executor() -> None:
|
|
from pydantic_ai._utils import disable_threads
|
|
|
|
main_thread = threading.current_thread()
|
|
|
|
def check_thread() -> threading.Thread:
|
|
return threading.current_thread()
|
|
|
|
executor = ThreadPoolExecutor(max_workers=2)
|
|
try:
|
|
with using_thread_executor(executor):
|
|
with disable_threads():
|
|
result = await run_in_executor(check_thread)
|
|
assert result is main_thread
|
|
finally:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
async def test_disable_threads_defaults_false_on_non_emscripten(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(sys, 'platform', 'linux')
|
|
importlib.reload(utils_module)
|
|
try:
|
|
main_thread = threading.current_thread()
|
|
|
|
def check_thread() -> threading.Thread:
|
|
return threading.current_thread()
|
|
|
|
result = await utils_module.run_in_executor(check_thread)
|
|
assert result is not main_thread
|
|
finally:
|
|
importlib.reload(utils_module)
|
|
|
|
|
|
async def test_run_in_executor_runs_inline_by_default_on_emscripten(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(sys, 'platform', 'emscripten')
|
|
importlib.reload(utils_module)
|
|
try:
|
|
main_thread = threading.current_thread()
|
|
|
|
def check_thread() -> threading.Thread:
|
|
return threading.current_thread()
|
|
|
|
result = await utils_module.run_in_executor(check_thread)
|
|
assert result is main_thread
|
|
finally:
|
|
monkeypatch.setattr(sys, 'platform', 'linux')
|
|
importlib.reload(utils_module)
|
|
|
|
|
|
def test_is_async_callable():
|
|
def sync_func(): ... # pragma: no branch
|
|
|
|
assert is_async_callable(sync_func) is False
|
|
|
|
async def async_func(): ... # pragma: no branch
|
|
|
|
assert is_async_callable(async_func) is True
|
|
|
|
class AsyncCallable:
|
|
async def __call__(self): ... # pragma: no branch
|
|
|
|
partial_async_callable = functools.partial(AsyncCallable())
|
|
assert is_async_callable(partial_async_callable) is True
|
|
|
|
|
|
def test_merge_json_schema_defs():
|
|
foo_bar_schema = {
|
|
'$defs': {
|
|
'Bar': {
|
|
'description': 'Bar description',
|
|
'properties': {'bar': {'type': 'string'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
'Foo': {
|
|
'description': 'Foo description',
|
|
'properties': {'foo': {'type': 'string'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
},
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo'}, 'bar': {'$ref': '#/$defs/Bar'}},
|
|
'required': ['foo', 'bar'],
|
|
'type': 'object',
|
|
'title': 'FooBar',
|
|
}
|
|
|
|
foo_bar_baz_schema = {
|
|
'$defs': {
|
|
'Baz': {
|
|
'description': 'Baz description',
|
|
'properties': {'baz': {'type': 'string'}},
|
|
'required': ['baz'],
|
|
'title': 'Baz',
|
|
'type': 'object',
|
|
},
|
|
'Foo': {
|
|
'description': 'Foo description. Note that this is different from the Foo in foo_bar_schema!',
|
|
'properties': {'foo': {'type': 'int'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Bar': {
|
|
'description': 'Bar description',
|
|
'properties': {'bar': {'type': 'string'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
},
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo'}, 'baz': {'$ref': '#/$defs/Baz'}, 'bar': {'$ref': '#/$defs/Bar'}},
|
|
'required': ['foo', 'baz', 'bar'],
|
|
'type': 'object',
|
|
'title': 'FooBarBaz',
|
|
}
|
|
|
|
# A schema with no title that will cause numeric suffixes
|
|
no_title_schema = {
|
|
'$defs': {
|
|
'Foo': {
|
|
'description': 'Another different Foo',
|
|
'properties': {'foo': {'type': 'boolean'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Bar': {
|
|
'description': 'Another different Bar',
|
|
'properties': {'bar': {'type': 'number'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
},
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo'}, 'bar': {'$ref': '#/$defs/Bar'}},
|
|
'required': ['foo', 'bar'],
|
|
'type': 'object',
|
|
}
|
|
|
|
# Another schema with no title that will cause more numeric suffixes
|
|
another_no_title_schema = {
|
|
'$defs': {
|
|
'Foo': {
|
|
'description': 'Yet another different Foo',
|
|
'properties': {'foo': {'type': 'array'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Bar': {
|
|
'description': 'Yet another different Bar',
|
|
'properties': {'bar': {'type': 'object'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
},
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo'}, 'bar': {'$ref': '#/$defs/Bar'}},
|
|
'required': ['foo', 'bar'],
|
|
'type': 'object',
|
|
}
|
|
|
|
# Schema with nested properties, array items, prefixItems, and anyOf/oneOf
|
|
complex_schema = {
|
|
'$defs': {
|
|
'Nested': {
|
|
'description': 'A nested type',
|
|
'properties': {'nested': {'type': 'string'}},
|
|
'required': ['nested'],
|
|
'title': 'Nested',
|
|
'type': 'object',
|
|
},
|
|
'ArrayItem': {
|
|
'description': 'An array item type',
|
|
'properties': {'item': {'type': 'string'}},
|
|
'required': ['item'],
|
|
'title': 'ArrayItem',
|
|
'type': 'object',
|
|
},
|
|
'UnionType': {
|
|
'description': 'A union type',
|
|
'properties': {'union': {'type': 'string'}},
|
|
'required': ['union'],
|
|
'title': 'UnionType',
|
|
'type': 'object',
|
|
},
|
|
},
|
|
'properties': {
|
|
'nested_props': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'deep_nested': {'$ref': '#/$defs/Nested'},
|
|
},
|
|
},
|
|
'array_with_items': {
|
|
'type': 'array',
|
|
'items': {'$ref': '#/$defs/ArrayItem'},
|
|
},
|
|
'array_with_prefix': {
|
|
'type': 'array',
|
|
'prefixItems': [
|
|
{'$ref': '#/$defs/ArrayItem'},
|
|
{'$ref': '#/$defs/Nested'},
|
|
],
|
|
},
|
|
'union_anyOf': {
|
|
'anyOf': [
|
|
{'$ref': '#/$defs/UnionType'},
|
|
{'$ref': '#/$defs/Nested'},
|
|
],
|
|
},
|
|
'union_oneOf': {
|
|
'oneOf': [
|
|
{'$ref': '#/$defs/UnionType'},
|
|
{'$ref': '#/$defs/ArrayItem'},
|
|
],
|
|
},
|
|
},
|
|
'type': 'object',
|
|
'title': 'ComplexSchema',
|
|
}
|
|
|
|
schemas = [foo_bar_schema, foo_bar_baz_schema, no_title_schema, another_no_title_schema, complex_schema]
|
|
rewritten_schemas, all_defs = merge_json_schema_defs(schemas)
|
|
assert all_defs == snapshot(
|
|
{
|
|
'Bar': {
|
|
'description': 'Bar description',
|
|
'properties': {'bar': {'type': 'string'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
'Foo': {
|
|
'description': 'Foo description',
|
|
'properties': {'foo': {'type': 'string'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Baz': {
|
|
'description': 'Baz description',
|
|
'properties': {'baz': {'type': 'string'}},
|
|
'required': ['baz'],
|
|
'title': 'Baz',
|
|
'type': 'object',
|
|
},
|
|
'FooBarBaz_Foo_1': {
|
|
'description': 'Foo description. Note that this is different from the Foo in foo_bar_schema!',
|
|
'properties': {'foo': {'type': 'int'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Foo_1': {
|
|
'description': 'Another different Foo',
|
|
'properties': {'foo': {'type': 'boolean'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Bar_1': {
|
|
'description': 'Another different Bar',
|
|
'properties': {'bar': {'type': 'number'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
'Foo_2': {
|
|
'description': 'Yet another different Foo',
|
|
'properties': {'foo': {'type': 'array'}},
|
|
'required': ['foo'],
|
|
'title': 'Foo',
|
|
'type': 'object',
|
|
},
|
|
'Bar_2': {
|
|
'description': 'Yet another different Bar',
|
|
'properties': {'bar': {'type': 'object'}},
|
|
'required': ['bar'],
|
|
'title': 'Bar',
|
|
'type': 'object',
|
|
},
|
|
'Nested': {
|
|
'description': 'A nested type',
|
|
'properties': {'nested': {'type': 'string'}},
|
|
'required': ['nested'],
|
|
'title': 'Nested',
|
|
'type': 'object',
|
|
},
|
|
'ArrayItem': {
|
|
'description': 'An array item type',
|
|
'properties': {'item': {'type': 'string'}},
|
|
'required': ['item'],
|
|
'title': 'ArrayItem',
|
|
'type': 'object',
|
|
},
|
|
'UnionType': {
|
|
'description': 'A union type',
|
|
'properties': {'union': {'type': 'string'}},
|
|
'required': ['union'],
|
|
'title': 'UnionType',
|
|
'type': 'object',
|
|
},
|
|
}
|
|
)
|
|
assert rewritten_schemas == snapshot(
|
|
[
|
|
{
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo'}, 'bar': {'$ref': '#/$defs/Bar'}},
|
|
'required': ['foo', 'bar'],
|
|
'type': 'object',
|
|
'title': 'FooBar',
|
|
},
|
|
{
|
|
'properties': {
|
|
'foo': {'$ref': '#/$defs/FooBarBaz_Foo_1'},
|
|
'baz': {'$ref': '#/$defs/Baz'},
|
|
'bar': {'$ref': '#/$defs/Bar'},
|
|
},
|
|
'required': ['foo', 'baz', 'bar'],
|
|
'type': 'object',
|
|
'title': 'FooBarBaz',
|
|
},
|
|
{
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo_1'}, 'bar': {'$ref': '#/$defs/Bar_1'}},
|
|
'required': ['foo', 'bar'],
|
|
'type': 'object',
|
|
},
|
|
{
|
|
'properties': {'foo': {'$ref': '#/$defs/Foo_2'}, 'bar': {'$ref': '#/$defs/Bar_2'}},
|
|
'required': ['foo', 'bar'],
|
|
'type': 'object',
|
|
},
|
|
{
|
|
'properties': {
|
|
'nested_props': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'deep_nested': {'$ref': '#/$defs/Nested'},
|
|
},
|
|
},
|
|
'array_with_items': {
|
|
'type': 'array',
|
|
'items': {'$ref': '#/$defs/ArrayItem'},
|
|
},
|
|
'array_with_prefix': {
|
|
'type': 'array',
|
|
'prefixItems': [
|
|
{'$ref': '#/$defs/ArrayItem'},
|
|
{'$ref': '#/$defs/Nested'},
|
|
],
|
|
},
|
|
'union_anyOf': {
|
|
'anyOf': [
|
|
{'$ref': '#/$defs/UnionType'},
|
|
{'$ref': '#/$defs/Nested'},
|
|
],
|
|
},
|
|
'union_oneOf': {
|
|
'oneOf': [
|
|
{'$ref': '#/$defs/UnionType'},
|
|
{'$ref': '#/$defs/ArrayItem'},
|
|
],
|
|
},
|
|
},
|
|
'type': 'object',
|
|
'title': 'ComplexSchema',
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
def test_merge_json_schema_defs_internal_refs_in_renamed_defs():
|
|
"""When defs are renamed due to collisions, internal $refs within those defs must also be updated."""
|
|
schema_a = {
|
|
'$defs': {
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'string'}}},
|
|
'Outer': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/Inner'}, 'extra_a': {'type': 'string'}},
|
|
},
|
|
},
|
|
'properties': {'outer': {'$ref': '#/$defs/Outer'}},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
}
|
|
schema_b = {
|
|
'$defs': {
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
|
'Outer': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/Inner'}, 'extra_b': {'type': 'number'}},
|
|
},
|
|
},
|
|
'properties': {'outer': {'$ref': '#/$defs/Outer'}},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
}
|
|
|
|
rewritten_schemas, all_defs = merge_json_schema_defs([schema_a, schema_b])
|
|
|
|
# SchemaB's Outer was renamed to SchemaB_Outer_1, and its internal $ref to Inner
|
|
# must now point to SchemaB_Inner_1 (not the original Inner from SchemaA)
|
|
assert all_defs == snapshot(
|
|
{
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'string'}}},
|
|
'Outer': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/Inner'}, 'extra_a': {'type': 'string'}},
|
|
},
|
|
'SchemaB_Inner_1': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
|
'SchemaB_Outer_1': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/SchemaB_Inner_1'}, 'extra_b': {'type': 'number'}},
|
|
},
|
|
}
|
|
)
|
|
assert rewritten_schemas == snapshot(
|
|
[
|
|
{
|
|
'properties': {'outer': {'$ref': '#/$defs/Outer'}},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
},
|
|
{
|
|
'properties': {'outer': {'$ref': '#/$defs/SchemaB_Outer_1'}},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
def test_merge_json_schema_defs_non_renamed_def_refs_renamed_def():
|
|
"""A non-renamed def that references a renamed def must also have its $refs updated."""
|
|
schema_a = {
|
|
'$defs': {
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'string'}}},
|
|
},
|
|
'properties': {'inner': {'$ref': '#/$defs/Inner'}},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
}
|
|
schema_b = {
|
|
'$defs': {
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
|
'Wrapper': {'type': 'object', 'properties': {'inner': {'$ref': '#/$defs/Inner'}}},
|
|
},
|
|
'properties': {'wrapper': {'$ref': '#/$defs/Wrapper'}},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
}
|
|
|
|
rewritten_schemas, all_defs = merge_json_schema_defs([schema_a, schema_b])
|
|
|
|
# Wrapper is new (no collision), but its $ref to Inner must be rewritten
|
|
# to SchemaB_Inner_1 because SchemaB's Inner was renamed
|
|
assert all_defs == snapshot(
|
|
{
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'string'}}},
|
|
'SchemaB_Inner_1': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
|
'Wrapper': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/SchemaB_Inner_1'}},
|
|
},
|
|
}
|
|
)
|
|
assert rewritten_schemas == snapshot(
|
|
[
|
|
{
|
|
'properties': {'inner': {'$ref': '#/$defs/Inner'}},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
},
|
|
{
|
|
'properties': {'wrapper': {'$ref': '#/$defs/Wrapper'}},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
def test_merge_json_schema_defs_additional_properties_allof_not():
|
|
"""$refs under additionalProperties, allOf, and not must be rewritten during merge."""
|
|
schema_a = {
|
|
'$defs': {
|
|
'Value': {'type': 'object', 'properties': {'v': {'type': 'string'}}},
|
|
'Base': {'type': 'object', 'properties': {'b': {'type': 'string'}}},
|
|
'Excluded': {'type': 'object', 'properties': {'e': {'type': 'string'}}},
|
|
},
|
|
'properties': {
|
|
'map': {'type': 'object', 'additionalProperties': {'$ref': '#/$defs/Value'}},
|
|
'composed': {'allOf': [{'$ref': '#/$defs/Base'}, {'$ref': '#/$defs/Value'}]},
|
|
'excluded': {'not': {'$ref': '#/$defs/Excluded'}},
|
|
},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
}
|
|
schema_b = {
|
|
'$defs': {
|
|
'Value': {'type': 'object', 'properties': {'v': {'type': 'integer'}}},
|
|
'Base': {'type': 'object', 'properties': {'b': {'type': 'integer'}}},
|
|
'Excluded': {'type': 'object', 'properties': {'e': {'type': 'integer'}}},
|
|
},
|
|
'properties': {
|
|
'map': {'type': 'object', 'additionalProperties': {'$ref': '#/$defs/Value'}},
|
|
'composed': {'allOf': [{'$ref': '#/$defs/Base'}, {'$ref': '#/$defs/Value'}]},
|
|
'excluded': {'not': {'$ref': '#/$defs/Excluded'}},
|
|
},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
}
|
|
|
|
rewritten_schemas, _ = merge_json_schema_defs([schema_a, schema_b])
|
|
|
|
# SchemaB's refs should all be rewritten to the renamed defs
|
|
assert rewritten_schemas[1] == snapshot(
|
|
{
|
|
'properties': {
|
|
'map': {'type': 'object', 'additionalProperties': {'$ref': '#/$defs/SchemaB_Value_1'}},
|
|
'composed': {'allOf': [{'$ref': '#/$defs/SchemaB_Base_1'}, {'$ref': '#/$defs/SchemaB_Value_1'}]},
|
|
'excluded': {'not': {'$ref': '#/$defs/SchemaB_Excluded_1'}},
|
|
},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
}
|
|
)
|
|
|
|
|
|
def test_merge_json_schema_defs_structurally_equal_with_different_ref_targets():
|
|
"""Defs that are structurally equal but whose $refs resolve to different types need separate copies."""
|
|
schema_a = {
|
|
'$defs': {
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'string'}}},
|
|
'Wrapper': {'type': 'object', 'properties': {'inner': {'$ref': '#/$defs/Inner'}}},
|
|
},
|
|
'properties': {'wrapper': {'$ref': '#/$defs/Wrapper'}},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
}
|
|
schema_b = {
|
|
'$defs': {
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
|
'Wrapper': {'type': 'object', 'properties': {'inner': {'$ref': '#/$defs/Inner'}}},
|
|
},
|
|
'properties': {'wrapper': {'$ref': '#/$defs/Wrapper'}},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
}
|
|
|
|
rewritten_schemas, all_defs = merge_json_schema_defs([schema_a, schema_b])
|
|
|
|
# Both Wrappers are structurally identical ({$ref: Inner}), but their Inner
|
|
# defs differ, so SchemaB needs its own Wrapper copy with updated refs.
|
|
assert all_defs == snapshot(
|
|
{
|
|
'Inner': {'type': 'object', 'properties': {'x': {'type': 'string'}}},
|
|
'Wrapper': {'type': 'object', 'properties': {'inner': {'$ref': '#/$defs/Inner'}}},
|
|
'SchemaB_Inner_1': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
|
'SchemaB_Wrapper_1': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/SchemaB_Inner_1'}},
|
|
},
|
|
}
|
|
)
|
|
assert rewritten_schemas == snapshot(
|
|
[
|
|
{
|
|
'properties': {'wrapper': {'$ref': '#/$defs/Wrapper'}},
|
|
'type': 'object',
|
|
'title': 'SchemaA',
|
|
},
|
|
{
|
|
'properties': {'wrapper': {'$ref': '#/$defs/SchemaB_Wrapper_1'}},
|
|
'type': 'object',
|
|
'title': 'SchemaB',
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
def test_strip_markdown_fences():
|
|
assert strip_markdown_fences('{"foo": "bar"}') == '{"foo": "bar"}'
|
|
assert strip_markdown_fences('```json\n{"foo": "bar"}\n```') == '{"foo": "bar"}'
|
|
assert strip_markdown_fences('```json\r\n{"foo": "bar"}\r\n```') == '{"foo": "bar"}'
|
|
assert strip_markdown_fences('```json\n{\n "foo": "bar"\n}') == '{\n "foo": "bar"\n}'
|
|
assert (
|
|
strip_markdown_fences('{"foo": "```json\\n{"foo": "bar"}\\n```"}')
|
|
== '{"foo": "```json\\n{"foo": "bar"}\\n```"}'
|
|
)
|
|
assert (
|
|
strip_markdown_fences('Here is some beautiful JSON:\n\n```\n{"foo": "bar"}\n``` Nice right?')
|
|
== '{"foo": "bar"}'
|
|
)
|
|
assert strip_markdown_fences('No JSON to be found') == 'No JSON to be found'
|
|
# Content after closing fence with braces should not be captured (issue #4397)
|
|
assert strip_markdown_fences('```json\n{"a": 1}\n```\nContext: {"b": 2}') == '{"a": 1}'
|
|
assert (
|
|
strip_markdown_fences('```json\n{"result": "pass"}\n```\nThis matches schema {"type": "object"}')
|
|
== '{"result": "pass"}'
|
|
)
|
|
# Nested JSON objects should still be fully captured
|
|
assert strip_markdown_fences('```json\n{"nested": {"key": "value"}}\n```') == '{"nested": {"key": "value"}}'
|
|
assert strip_markdown_fences('```json\n{"a": {"b": {"c": 1}}}\n```') == '{"a": {"b": {"c": 1}}}'
|
|
|
|
|
|
class _AmbiguousBool:
|
|
"""Mimics the result of a numpy array comparison: its truth value is ambiguous."""
|
|
|
|
def __bool__(self) -> bool:
|
|
raise ValueError('The truth value of an array with more than one element is ambiguous.')
|
|
|
|
|
|
class _ArrayLike:
|
|
"""Mimics a numpy array: `!=` returns a value whose `bool()` raises, instead of a plain bool."""
|
|
|
|
def __ne__(self, other: object) -> Any:
|
|
return _AmbiguousBool()
|
|
|
|
def __repr__(self) -> str:
|
|
return 'ArrayLike()'
|
|
|
|
|
|
@dataclass(repr=False)
|
|
class _HasRequiredField:
|
|
content: Any
|
|
|
|
__repr__ = dataclasses_no_defaults_repr
|
|
|
|
|
|
@dataclass(repr=False)
|
|
class _HasDefaultField:
|
|
content: Any = None
|
|
|
|
__repr__ = dataclasses_no_defaults_repr
|
|
|
|
|
|
class _CountingIntListFactory:
|
|
"""A `default_factory` that records how many times it is called, to prove `repr()` never calls it."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
|
|
def __call__(self) -> list[int]:
|
|
self.calls += 1
|
|
return []
|
|
|
|
|
|
_items_factory = _CountingIntListFactory()
|
|
|
|
|
|
@dataclass(repr=False)
|
|
class _HasMixedFields:
|
|
required: int
|
|
flag: bool = False
|
|
items: list[int] = field(default_factory=_items_factory)
|
|
|
|
__repr__ = dataclasses_no_defaults_repr
|
|
|
|
|
|
def test_dataclasses_no_defaults_repr_non_bool_ne():
|
|
"""repr() must not raise when a field holds a value whose `!=` returns a non-bool (e.g. a numpy array).
|
|
|
|
Regression test for #6415: `repr()` of message parts crashed with `ValueError` when a field
|
|
such as `ToolReturnPart.content` held a numpy array. Covers both branches of the helper: a
|
|
required field (no default) and a field with an explicit default that holds such a value.
|
|
|
|
This is a plain unit test rather than a public-API/VCR test because it exercises the pure
|
|
`dataclasses_no_defaults_repr` helper in memory and makes no model or network requests.
|
|
"""
|
|
# Required field: value is always shown, and the ambiguous `!=` result must not be evaluated.
|
|
assert repr(_HasRequiredField(content=_ArrayLike())) == '_HasRequiredField(content=ArrayLike())'
|
|
# Explicit-default field holding the same value: the guarded comparison falls back to showing it.
|
|
assert repr(_HasDefaultField(content=_ArrayLike())) == '_HasDefaultField(content=ArrayLike())'
|
|
|
|
|
|
def test_dataclasses_no_defaults_repr_omits_defaults():
|
|
"""Fields equal to an explicit default are omitted; differing and factory-backed fields are shown.
|
|
|
|
Also asserts `repr()` never calls the `default_factory`: some factories are impure (e.g. `uuid7()`,
|
|
`now_utc()`), so materializing them during `repr()` would consume randomness/time or mutate state.
|
|
|
|
This is a plain unit test rather than a public-API/VCR test because it exercises the pure
|
|
`dataclasses_no_defaults_repr` helper in memory and makes no model or network requests.
|
|
"""
|
|
# `flag` equals its default and is omitted; `items` has only a `default_factory` so it is always shown.
|
|
instance = _HasMixedFields(required=1)
|
|
_items_factory.calls = 0 # reset the count incurred while constructing the instance above
|
|
assert repr(instance) == '_HasMixedFields(required=1, items=[])'
|
|
assert _items_factory.calls == 0 # repr must not invoke the default_factory
|
|
|
|
# `flag` differs from its default and is shown.
|
|
instance = _HasMixedFields(required=1, flag=True)
|
|
_items_factory.calls = 0
|
|
assert repr(instance) == '_HasMixedFields(required=1, flag=True, items=[])'
|
|
assert _items_factory.calls == 0
|