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
Harness Compat / harness compat (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
1538 lines
50 KiB
Python
1538 lines
50 KiB
Python
"""Tests for function signature generation and deduplication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typing
|
|
from enum import Enum
|
|
from typing import Optional, Union
|
|
|
|
import pytest
|
|
import typing_extensions
|
|
from pydantic import BaseModel, RootModel
|
|
|
|
from pydantic_ai._run_context import RunContext
|
|
from pydantic_ai.function_signature import (
|
|
FunctionParam,
|
|
FunctionSignature,
|
|
GenericTypeExpr,
|
|
SimpleTypeExpr,
|
|
TypeFieldSignature,
|
|
TypeSignature,
|
|
UnionTypeExpr,
|
|
)
|
|
from pydantic_ai.tools import Tool, ToolDefinition
|
|
|
|
from ._inline_snapshot import snapshot
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
# Module-level types for tests that need get_type_hints() resolution
|
|
class _Color(str, Enum):
|
|
RED = 'red'
|
|
GREEN = 'green'
|
|
|
|
|
|
class _ConfigWithEnum(BaseModel):
|
|
name: str
|
|
color: _Color
|
|
|
|
|
|
class _SearchParams(typing_extensions.TypedDict):
|
|
query: str
|
|
limit: int
|
|
|
|
|
|
def test_render_function_signature_with_no_params():
|
|
sig = FunctionSignature(name='ping', params={}, return_type=SimpleTypeExpr('None'))
|
|
assert sig.render('...', name='ping') == 'def ping() -> None:\n ...'
|
|
# render() can override is_async
|
|
assert sig.render('...', name='ping', is_async=True) == 'async def ping() -> None:\n ...'
|
|
|
|
|
|
def test_get_conflicting_type_names_substring_names():
|
|
"""Marking 'User' for prefix must not affect 'UserMeta' in the same signature."""
|
|
user1 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
user2 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'id': TypeFieldSignature(name='id', type=SimpleTypeExpr('int'), required=True, description=None),
|
|
},
|
|
)
|
|
user_meta = TypeSignature(
|
|
name='UserMeta',
|
|
fields={
|
|
'role': TypeFieldSignature(name='role', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
|
|
sig1 = FunctionSignature(
|
|
name='tool_a',
|
|
params={'user': FunctionParam(name='user', type=user1, default=None)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user1],
|
|
)
|
|
sig2 = FunctionSignature(
|
|
name='tool_b',
|
|
params={
|
|
'user': FunctionParam(name='user', type=user2, default=None),
|
|
'meta': FunctionParam(name='meta', type=user_meta, default=None),
|
|
},
|
|
return_type=user_meta,
|
|
referenced_types=[user2, user_meta],
|
|
)
|
|
prefixed = FunctionSignature.get_conflicting_type_names([sig1, sig2])
|
|
|
|
# 'User' needs a prefix, 'UserMeta' does not
|
|
assert prefixed == frozenset({'User'})
|
|
assert user_meta.name == 'UserMeta'
|
|
# render() sets the context — prefixed types resolve correctly
|
|
rendered = sig2.render('...', name='tool_b', conflicting_type_names=prefixed)
|
|
assert 'user: tool_b_User' in rendered
|
|
assert 'meta: UserMeta' in rendered
|
|
|
|
|
|
def test_render_definition_with_conflicting_types():
|
|
"""render_definition applies tool-name prefix for conflicting types."""
|
|
user = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
# Without conflict info, renders as plain name
|
|
plain = user.render_definition()
|
|
assert plain.startswith('class User(TypedDict):')
|
|
|
|
# With conflict info, renders with prefix
|
|
prefixed = user.render_definition(owner_name='tool_a', conflicting_type_names=frozenset({'User'}))
|
|
assert prefixed.startswith('class tool_a_User(TypedDict):')
|
|
|
|
|
|
def test_render_type_definitions_with_conflicts():
|
|
"""render_type_definitions renders unique types with prefixes for conflicts."""
|
|
user1 = TypeSignature(
|
|
name='User',
|
|
fields={'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True)},
|
|
)
|
|
user2 = TypeSignature(
|
|
name='User',
|
|
fields={'id': TypeFieldSignature(name='id', type=SimpleTypeExpr('int'), required=True)},
|
|
)
|
|
address = TypeSignature(
|
|
name='Address',
|
|
fields={'city': TypeFieldSignature(name='city', type=SimpleTypeExpr('str'), required=True)},
|
|
)
|
|
|
|
sig1 = FunctionSignature(
|
|
name='get_user',
|
|
params={'user': FunctionParam(name='user', type=user1)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user1, address],
|
|
)
|
|
sig2 = FunctionSignature(
|
|
name='find_user',
|
|
params={'user': FunctionParam(name='user', type=user2)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user2],
|
|
)
|
|
conflicting = FunctionSignature.get_conflicting_type_names([sig1, sig2])
|
|
assert conflicting == frozenset({'User'})
|
|
|
|
rendered = FunctionSignature.render_type_definitions([sig1, sig2], conflicting)
|
|
# Should have 3 definitions: prefixed User for each sig, plus Address
|
|
assert len(rendered) == 3
|
|
assert any('class get_user_User(TypedDict):' in r for r in rendered)
|
|
assert any('class find_user_User(TypedDict):' in r for r in rendered)
|
|
assert any('class Address(TypedDict):' in r for r in rendered)
|
|
|
|
|
|
def test_render_type_definitions_empty():
|
|
"""render_type_definitions returns empty list when no referenced types."""
|
|
sig = FunctionSignature(
|
|
name='simple',
|
|
params={'x': FunctionParam(name='x', type=SimpleTypeExpr('int'))},
|
|
return_type=SimpleTypeExpr('str'),
|
|
)
|
|
assert FunctionSignature.render_type_definitions([sig], frozenset()) == []
|
|
|
|
|
|
def test_dedup_identical_types_unified():
|
|
"""Identical TypeSignatures are unified to the same object instance."""
|
|
user1 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
user2 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
|
|
sig1 = FunctionSignature(
|
|
name='tool_a',
|
|
params={'user': FunctionParam(name='user', type=user1, default=None)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user1],
|
|
)
|
|
sig2 = FunctionSignature(
|
|
name='tool_b',
|
|
params={'user': FunctionParam(name='user', type=user2, default=None)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user2],
|
|
)
|
|
FunctionSignature.get_conflicting_type_names([sig1, sig2])
|
|
|
|
# Both sigs keep the type, but unified to the same canonical instance
|
|
assert len(sig2.referenced_types) == 1
|
|
assert sig2.referenced_types[0] is user1
|
|
# sig2's param should now point to the canonical (sig1's) TypeSignature
|
|
assert sig2.params['user'].type is user1
|
|
|
|
# collect_unique_referenced_types emits the definition only once
|
|
defs = FunctionSignature.collect_unique_referenced_types([sig1, sig2])
|
|
assert len(defs) == 1
|
|
|
|
|
|
def test_dedup_replaces_nested_generic_and_union_refs_with_canonical():
|
|
user1 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
user2 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
wrapper = TypeSignature(
|
|
name='Wrapper',
|
|
fields={
|
|
'users': TypeFieldSignature(
|
|
name='users',
|
|
type=GenericTypeExpr(base='list', args=[user2]),
|
|
required=True,
|
|
description=None,
|
|
),
|
|
'maybe_user': TypeFieldSignature(
|
|
name='maybe_user',
|
|
type=UnionTypeExpr(members=[user2, SimpleTypeExpr('None')]),
|
|
required=False,
|
|
description=None,
|
|
),
|
|
},
|
|
)
|
|
|
|
sig1 = FunctionSignature(
|
|
name='tool_a',
|
|
params={'user': FunctionParam(name='user', type=user1, default=None)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user1],
|
|
)
|
|
sig2 = FunctionSignature(
|
|
name='tool_b',
|
|
params={
|
|
'wrapper': FunctionParam(name='wrapper', type=wrapper, default=None),
|
|
'tags': FunctionParam(
|
|
name='tags', type=GenericTypeExpr(base='list', args=[SimpleTypeExpr('str')]), default=None
|
|
),
|
|
'label': FunctionParam(
|
|
name='label', type=UnionTypeExpr(members=[SimpleTypeExpr('str'), SimpleTypeExpr('None')]), default=None
|
|
),
|
|
},
|
|
return_type=GenericTypeExpr(base='list', args=[user2]),
|
|
referenced_types=[user2, wrapper],
|
|
)
|
|
|
|
FunctionSignature.get_conflicting_type_names([sig1, sig2])
|
|
|
|
users_expr = wrapper.fields['users'].type
|
|
maybe_user_expr = wrapper.fields['maybe_user'].type
|
|
return_expr = sig2.return_type
|
|
|
|
assert isinstance(users_expr, GenericTypeExpr)
|
|
assert isinstance(maybe_user_expr, UnionTypeExpr)
|
|
assert isinstance(return_expr, GenericTypeExpr)
|
|
assert users_expr.args[0] is user1
|
|
assert maybe_user_expr.members[0] is user1
|
|
assert return_expr.args[0] is user1
|
|
|
|
|
|
def test_dedup_mixed_identical_and_conflicting_from_schemas():
|
|
"""Two identical User $defs are unified; a third different User is renamed.
|
|
|
|
Tests the full pipeline: JSON schema → schema_to_signature → dedup → render.
|
|
"""
|
|
# tool_a and tool_b both have a User $def with {name: str}
|
|
user_v1_def = {
|
|
'type': 'object',
|
|
'properties': {'name': {'type': 'string'}},
|
|
'required': ['name'],
|
|
}
|
|
# tool_c has a User $def with {id: int} — same name, different structure
|
|
user_v2_def = {
|
|
'type': 'object',
|
|
'properties': {'id': {'type': 'integer'}},
|
|
'required': ['id'],
|
|
}
|
|
|
|
sig1 = FunctionSignature.from_schema(
|
|
name='tool_a',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'user': {'$ref': '#/$defs/User'}},
|
|
'required': ['user'],
|
|
'$defs': {'User': user_v1_def},
|
|
},
|
|
)
|
|
sig2 = FunctionSignature.from_schema(
|
|
name='tool_b',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'user': {'$ref': '#/$defs/User'}},
|
|
'required': ['user'],
|
|
'$defs': {'User': user_v1_def},
|
|
},
|
|
)
|
|
sig3 = FunctionSignature.from_schema(
|
|
name='tool_c',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'user': {'$ref': '#/$defs/User'}},
|
|
'required': ['user'],
|
|
'$defs': {'User': user_v2_def},
|
|
},
|
|
)
|
|
|
|
prefixed = FunctionSignature.get_conflicting_type_names([sig1, sig2, sig3])
|
|
|
|
# sig1 and sig2 share the same canonical User instance
|
|
assert len(sig1.referenced_types) == 1
|
|
assert len(sig2.referenced_types) == 1
|
|
assert sig2.referenced_types[0] is sig1.referenced_types[0]
|
|
assert sig2.params['user'].type is sig1.referenced_types[0]
|
|
|
|
# 'User' needs a prefix
|
|
assert prefixed == frozenset({'User'})
|
|
# render() sets the context — prefixed types resolve correctly
|
|
rendered = sig3.render('...', name='tool_c', conflicting_type_names=prefixed)
|
|
assert 'user: tool_c_User' in rendered
|
|
|
|
# collect_unique_referenced_types returns exactly 2 unique types
|
|
unique_types = FunctionSignature.collect_unique_referenced_types([sig1, sig2, sig3])
|
|
assert len(unique_types) == 2
|
|
# First type (User) doesn't need prefix
|
|
assert unique_types[0].render_definition() == snapshot("""\
|
|
class User(TypedDict):
|
|
name: str""")
|
|
# Second type needs prefix — render_definition() uses context from render()
|
|
assert 'tool_c_User' in sig3.render('...', name='tool_c', conflicting_type_names=prefixed)
|
|
|
|
|
|
def test_dedup_composite_type_expr_rename_propagates():
|
|
"""Renaming propagates through GenericTypeExpr references like list[User]."""
|
|
user1 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
user2 = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'id': TypeFieldSignature(name='id', type=SimpleTypeExpr('int'), required=True, description=None),
|
|
},
|
|
)
|
|
|
|
sig1 = FunctionSignature(
|
|
name='tool_a',
|
|
params={'user': FunctionParam(name='user', type=user1, default=None)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user1],
|
|
)
|
|
# sig2 references user2 via list[User]
|
|
sig2 = FunctionSignature(
|
|
name='tool_b',
|
|
params={'users': FunctionParam(name='users', type=GenericTypeExpr(base='list', args=[user2]), default=None)},
|
|
return_type=SimpleTypeExpr('Any'),
|
|
referenced_types=[user2],
|
|
)
|
|
prefixed = FunctionSignature.get_conflicting_type_names([sig1, sig2])
|
|
|
|
# 'User' needs a prefix
|
|
assert prefixed == frozenset({'User'})
|
|
# render() sets the context — prefixed types resolve correctly
|
|
rendered = sig2.render('...', name='tool_b', conflicting_type_names=prefixed)
|
|
assert 'users: list[tool_b_User]' in rendered
|
|
|
|
|
|
def test_render_type_signature():
|
|
"""TypeSignature renders a valid TypedDict class definition."""
|
|
ts = TypeSignature(
|
|
name='User',
|
|
fields={
|
|
'name': TypeFieldSignature(name='name', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
'age': TypeFieldSignature(name='age', type=SimpleTypeExpr('int'), required=False, description='The age'),
|
|
},
|
|
)
|
|
assert str(ts) == snapshot('User')
|
|
|
|
|
|
def test_render_type_signature_empty():
|
|
"""Empty TypeSignature renders with pass."""
|
|
ts = TypeSignature(name='Empty')
|
|
assert str(ts) == 'Empty'
|
|
assert ts.render_definition() == 'class Empty(TypedDict):\n pass'
|
|
|
|
|
|
def test_render_generic_type_expr():
|
|
"""GenericTypeExpr renders correctly."""
|
|
user = TypeSignature(name='User')
|
|
expr = GenericTypeExpr(base='list', args=[user])
|
|
assert str(expr) == 'list[User]'
|
|
|
|
dict_expr = GenericTypeExpr(base='dict', args=[SimpleTypeExpr('str'), GenericTypeExpr(base='list', args=[user])])
|
|
assert str(dict_expr) == 'dict[str, list[User]]'
|
|
|
|
|
|
def test_render_union_type_expr():
|
|
"""UnionTypeExpr renders correctly."""
|
|
user = TypeSignature(name='User')
|
|
expr = UnionTypeExpr(members=[user, SimpleTypeExpr('None')])
|
|
assert str(expr) == 'User | None'
|
|
|
|
|
|
def test_render_function_param():
|
|
"""FunctionParam renders correct parameter strings."""
|
|
p1 = FunctionParam(name='x', type=SimpleTypeExpr('int'), default=None)
|
|
assert str(p1) == 'x: int'
|
|
|
|
p2 = FunctionParam(name='y', type=SimpleTypeExpr('str'), default="'hello'")
|
|
assert str(p2) == "y: str = 'hello'"
|
|
|
|
user = TypeSignature(name='User')
|
|
p3 = FunctionParam(name='user', type=user, default=None)
|
|
assert str(p3) == 'user: User'
|
|
|
|
|
|
def test_structurally_equal():
|
|
"""TypeSignature.structurally_equal compares fields structurally."""
|
|
ts1 = TypeSignature(
|
|
name='A',
|
|
fields={
|
|
'x': TypeFieldSignature(name='x', type=SimpleTypeExpr('int'), required=True, description='desc1'),
|
|
},
|
|
)
|
|
ts2 = TypeSignature(
|
|
name='B',
|
|
fields={
|
|
'x': TypeFieldSignature(name='x', type=SimpleTypeExpr('int'), required=True, description='different desc'),
|
|
},
|
|
)
|
|
# Same fields, different names and descriptions — structurally equal
|
|
assert ts1.structurally_equal(ts2)
|
|
|
|
ts3 = TypeSignature(
|
|
name='C',
|
|
fields={
|
|
'x': TypeFieldSignature(name='x', type=SimpleTypeExpr('str'), required=True, description=None),
|
|
},
|
|
)
|
|
# Different field type — not equal
|
|
assert not ts1.structurally_equal(ts3)
|
|
|
|
|
|
def test_schema_nested_object_name_with_digit_prefix():
|
|
"""Tool names starting with digits get valid PascalCase TypedDict names with leading underscore."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='123_tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'config': {
|
|
'type': 'object',
|
|
'properties': {'key': {'type': 'string'}},
|
|
'required': ['key'],
|
|
},
|
|
},
|
|
'required': ['config'],
|
|
},
|
|
)
|
|
# The nested object should get a name like _123Tool + Config
|
|
assert any('_123Tool' in rt.name for rt in sig.referenced_types)
|
|
|
|
|
|
def test_schema_hyphenated_tool_name():
|
|
"""Hyphenated tool names (e.g. from MCP) produce valid PascalCase TypedDict names."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='my-tool-name',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'data': {
|
|
'type': 'object',
|
|
'properties': {'value': {'type': 'integer'}},
|
|
'required': ['value'],
|
|
},
|
|
},
|
|
'required': ['data'],
|
|
},
|
|
)
|
|
assert any('MyToolName' in rt.name for rt in sig.referenced_types)
|
|
|
|
|
|
def test_tool_def_function_signature_from_schema():
|
|
"""Tool.tool_def.function_signature is generated from the JSON schema."""
|
|
|
|
def my_tool(x: int, y: str = 'hello') -> bool:
|
|
"""A test tool."""
|
|
return True # pragma: no cover
|
|
|
|
tool = Tool(my_tool)
|
|
tool_def = tool.tool_def
|
|
|
|
sig = tool_def.function_signature
|
|
assert sig is not None
|
|
assert 'x' in sig.params
|
|
assert 'y' in sig.params
|
|
|
|
|
|
def test_tool_def_has_schema_based_signature():
|
|
"""Tool.tool_def.function_signature is computed from schema + return_schema."""
|
|
|
|
def my_tool(x: int, y: str = 'hello') -> bool:
|
|
"""A test tool."""
|
|
return True # pragma: no cover
|
|
|
|
tool = Tool(my_tool)
|
|
sig = tool.tool_def.function_signature
|
|
assert sig is not None
|
|
assert 'x' in sig.params
|
|
assert 'y' in sig.params
|
|
assert str(sig.params['x'].type) == 'int'
|
|
assert str(sig.params['y'].type) == 'str'
|
|
|
|
|
|
def test_tool_definition_function_signature_computed_from_schema():
|
|
"""ToolDefinition without explicit function_signature computes it from JSON schema."""
|
|
|
|
td = ToolDefinition(
|
|
name='test_tool',
|
|
parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}, 'required': ['x']},
|
|
description='A test tool',
|
|
)
|
|
sig = td.function_signature
|
|
assert sig is not None
|
|
assert 'x' in sig.params
|
|
|
|
|
|
def test_tool_definition_schema_based_function_signature():
|
|
"""ToolDefinition.function_signature generates a schema-based signature."""
|
|
|
|
td = ToolDefinition(
|
|
name='schema_tool',
|
|
parameters_json_schema={'type': 'object', 'properties': {'q': {'type': 'string'}}, 'required': ['q']},
|
|
description='A schema-based tool',
|
|
)
|
|
sig = td.function_signature
|
|
assert sig is not None
|
|
assert 'q' in sig.params
|
|
assert str(sig.params['q'].type) == 'str'
|
|
|
|
|
|
def test_tool_from_schema_function_signature_uses_schema():
|
|
async def handler(**kwargs: typing.Any) -> typing.Any: # pragma: no cover
|
|
return kwargs
|
|
|
|
tool = Tool.from_schema(
|
|
handler,
|
|
name='search',
|
|
description='Search documents',
|
|
json_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'query': {'type': 'string'},
|
|
'limit': {'type': 'integer'},
|
|
},
|
|
'required': ['query'],
|
|
},
|
|
)
|
|
assert tool.tool_def.render_signature('...') == snapshot('''\
|
|
def search(*, query: str, limit: int | None = None) -> Any:
|
|
"""Search documents"""
|
|
...\
|
|
''')
|
|
|
|
|
|
# =============================================================================
|
|
# Function signature edge cases
|
|
# =============================================================================
|
|
|
|
|
|
def test_function_signature_special_params():
|
|
"""RunContext skipped, unannotated params become Any via schema path."""
|
|
|
|
def with_ctx(ctx: RunContext, x: int) -> int:
|
|
return x # pragma: no cover
|
|
|
|
td = Tool(with_ctx, takes_ctx=True).tool_def
|
|
assert td.render_signature('...') == snapshot("""\
|
|
def with_ctx(*, x: int) -> int:
|
|
...\
|
|
""")
|
|
|
|
def no_annot(x): # pyright: ignore[reportUnknownParameterType,reportMissingParameterType]
|
|
return x # pragma: no cover # pyright: ignore[reportUnknownVariableType]
|
|
|
|
td = Tool(no_annot).tool_def # pyright: ignore[reportUnknownArgumentType]
|
|
assert td.render_signature('...') == snapshot("""\
|
|
def no_annot(*, x: Any) -> Any:
|
|
...\
|
|
""")
|
|
|
|
|
|
def test_from_function_kwargs_and_varargs():
|
|
"""Schema path skips **kwargs and wraps *args as list[T]."""
|
|
|
|
def with_kwargs(x: int, **kwargs: str) -> str:
|
|
return '' # pragma: no cover
|
|
|
|
sig = Tool(with_kwargs).tool_def.function_signature
|
|
assert sig is not None
|
|
assert 'kwargs' not in sig.params
|
|
assert 'x' in sig.params
|
|
|
|
def with_args(*args: int) -> str:
|
|
return '' # pragma: no cover
|
|
|
|
sig = Tool(with_args).tool_def.function_signature
|
|
assert sig is not None
|
|
assert 'args' in sig.params
|
|
assert str(sig.params['args'].type) == 'list[int]'
|
|
|
|
|
|
class _UserInfo(BaseModel):
|
|
name: str
|
|
|
|
|
|
class _IntList(RootModel[list[int]]):
|
|
pass
|
|
|
|
|
|
class _TreeNode(BaseModel):
|
|
value: int
|
|
children: list[_TreeNode] = []
|
|
|
|
|
|
def test_function_signature_union_and_model_types():
|
|
"""Unions, optionals, and model types render correct signatures via schema path."""
|
|
|
|
def complex_func(
|
|
a: Union[int, str], # noqa: UP007 — testing Union[] code path
|
|
b: int | str,
|
|
c: Optional[int] = None, # noqa: UP045 — testing Optional[] code path
|
|
d: _UserInfo | None = None,
|
|
) -> _UserInfo: ... # pragma: no cover
|
|
|
|
td = Tool(complex_func).tool_def
|
|
assert td.render_signature('...') == snapshot("""\
|
|
def complex_func(*, a: int | str, b: int | str, c: int | None = None, d: _UserInfo | None = None) -> _UserInfo:
|
|
...\
|
|
""")
|
|
assert td.function_signature is not None
|
|
assert [t.render_definition() for t in td.function_signature.referenced_types] == snapshot(
|
|
[
|
|
"""\
|
|
class _UserInfo(TypedDict):
|
|
name: str\
|
|
"""
|
|
]
|
|
)
|
|
|
|
|
|
def test_type_signature_docstring_and_structural_equality():
|
|
"""Docstring rendering and structural equality with different required."""
|
|
ts = TypeSignature(name='Documented', description='A documented empty type')
|
|
assert str(ts) == snapshot('Documented')
|
|
|
|
ts_a = TypeSignature(
|
|
name='A',
|
|
fields={'x': TypeFieldSignature(name='x', type=SimpleTypeExpr('int'), required=True, description=None)},
|
|
)
|
|
ts_b = TypeSignature(
|
|
name='B',
|
|
fields={'x': TypeFieldSignature(name='x', type=SimpleTypeExpr('int'), required=False, description=None)},
|
|
)
|
|
assert not ts_a.structurally_equal(ts_b)
|
|
|
|
|
|
# =============================================================================
|
|
# Schema signature edge cases
|
|
# =============================================================================
|
|
|
|
|
|
def test_schema_signature_const_enum():
|
|
"""const and enum paths in schema_to_type_expr produce Literal types."""
|
|
# const value
|
|
sig_const = FunctionSignature.from_schema(
|
|
name='tool_const',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'mode': {'const': 'fast'}},
|
|
'required': ['mode'],
|
|
},
|
|
)
|
|
assert sig_const.render('...', name='tool_const') == snapshot("""\
|
|
def tool_const(*, mode: Literal['fast']) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# enum values
|
|
sig_enum = FunctionSignature.from_schema(
|
|
name='tool_enum',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'color': {'enum': ['red', 'green', 'blue']}},
|
|
'required': ['color'],
|
|
},
|
|
)
|
|
assert sig_enum.render('...', name='tool_enum') == snapshot("""\
|
|
def tool_enum(*, color: Literal['red', 'green', 'blue']) -> Any:
|
|
...\
|
|
""")
|
|
|
|
|
|
def test_collect_unique_referenced_types_empty():
|
|
"""Empty input returns empty list."""
|
|
assert FunctionSignature.collect_unique_referenced_types([]) == []
|
|
|
|
sig = FunctionSignature(name='test', params={}, return_type=SimpleTypeExpr('Any'), referenced_types=[])
|
|
assert FunctionSignature.collect_unique_referenced_types([sig]) == []
|
|
|
|
|
|
def test_schema_signature_union_ref_allof():
|
|
"""oneOf, allOf, $ref variants produce correct signatures."""
|
|
sig_oneof = FunctionSignature.from_schema(
|
|
name='my_tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'value': {'oneOf': [{'type': 'string'}, {'type': 'integer'}]}},
|
|
'required': ['value'],
|
|
},
|
|
)
|
|
assert sig_oneof.render('...', name='my_tool') == snapshot("""\
|
|
def my_tool(*, value: str | int) -> Any:
|
|
...\
|
|
""")
|
|
|
|
sig_allof_single = FunctionSignature.from_schema(
|
|
name='tool2',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'allOf': [{'type': 'string'}]}},
|
|
'required': ['x'],
|
|
},
|
|
)
|
|
assert sig_allof_single.render('...', name='tool2') == snapshot("""\
|
|
def tool2(*, x: str) -> Any:
|
|
...\
|
|
""")
|
|
|
|
sig_allof_multi = FunctionSignature.from_schema(
|
|
name='tool3',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'allOf': [{'type': 'string'}, {'type': 'integer'}]}},
|
|
'required': ['x'],
|
|
},
|
|
)
|
|
assert sig_allof_multi.render('...', name='tool3') == snapshot("""\
|
|
def tool3(*, x: Any) -> Any:
|
|
...\
|
|
""")
|
|
|
|
sig_ref = FunctionSignature.from_schema(
|
|
name='tool4',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'user': {'$ref': '#/$defs/User'}},
|
|
'required': ['user'],
|
|
'$defs': {'User': {'type': 'object', 'properties': {'name': {'type': 'string'}}, 'required': ['name']}},
|
|
},
|
|
)
|
|
assert sig_ref.render('...', name='tool4') == snapshot("""\
|
|
def tool4(*, user: User) -> Any:
|
|
...\
|
|
""")
|
|
assert [t.render_definition() for t in sig_ref.referenced_types] == snapshot(
|
|
[
|
|
"""\
|
|
class User(TypedDict):
|
|
name: str\
|
|
"""
|
|
]
|
|
)
|
|
|
|
sig_ref_nonobj = FunctionSignature.from_schema(
|
|
name='tool5',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'$ref': '#/$defs/StringAlias'}},
|
|
'required': ['x'],
|
|
'$defs': {'StringAlias': {'type': 'string'}},
|
|
},
|
|
)
|
|
assert sig_ref_nonobj.render('...', name='tool5') == snapshot("""\
|
|
def tool5(*, x: StringAlias) -> Any:
|
|
...\
|
|
""")
|
|
|
|
|
|
def test_schema_signature_array_object_typelist():
|
|
"""Arrays, objects, additionalProperties, and type lists."""
|
|
# Tuple array
|
|
sig = FunctionSignature.from_schema(
|
|
name='t1',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'coords': {'type': 'array', 'items': [{'type': 'number'}, {'type': 'number'}]}},
|
|
'required': ['coords'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t1') == snapshot("""\
|
|
def t1(*, coords: tuple[float, float]) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Empty array
|
|
sig = FunctionSignature.from_schema(
|
|
name='t2',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'data': {'type': 'array'}},
|
|
'required': ['data'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t2') == snapshot("""\
|
|
def t2(*, data: list[Any]) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# additionalProperties: true
|
|
sig = FunctionSignature.from_schema(
|
|
name='t3',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'meta': {'type': 'object', 'additionalProperties': True}},
|
|
'required': ['meta'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t3') == snapshot("""\
|
|
def t3(*, meta: dict[str, Any]) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Typed additionalProperties
|
|
sig = FunctionSignature.from_schema(
|
|
name='t4',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'tags': {'type': 'object', 'additionalProperties': {'type': 'string'}}},
|
|
'required': ['tags'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t4') == snapshot("""\
|
|
def t4(*, tags: dict[str, str]) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Type list ['string', 'null']
|
|
sig = FunctionSignature.from_schema(
|
|
name='t5',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'name': {'type': ['string', 'null']}},
|
|
'required': ['name'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t5') == snapshot("""\
|
|
def t5(*, name: str | None) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Type list multi
|
|
sig = FunctionSignature.from_schema(
|
|
name='t6',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'value': {'type': ['string', 'integer', 'boolean']}},
|
|
'required': ['value'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t6') == snapshot("""\
|
|
def t6(*, value: str | int | bool) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Object type list with null
|
|
sig = FunctionSignature.from_schema(
|
|
name='t7',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'config': {
|
|
'type': ['object', 'null'],
|
|
'properties': {'enabled': {'type': 'boolean'}},
|
|
'required': ['enabled'],
|
|
},
|
|
},
|
|
'required': ['config'],
|
|
},
|
|
)
|
|
assert sig.render('...', name='t7') == snapshot("""\
|
|
def t7(*, config: T7Config | None) -> Any:
|
|
...\
|
|
""")
|
|
assert [t.render_definition() for t in sig.referenced_types] == snapshot(
|
|
[
|
|
"""\
|
|
class T7Config(TypedDict):
|
|
enabled: bool\
|
|
"""
|
|
]
|
|
)
|
|
|
|
|
|
def test_schema_signature_optional_params_and_return():
|
|
"""Optional params, return schema edge cases, anyOf dedup."""
|
|
# Optional already nullable
|
|
sig = FunctionSignature.from_schema(
|
|
name='t1',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'type': ['string', 'null']}},
|
|
},
|
|
)
|
|
assert sig.render('...', name='t1') == snapshot("""\
|
|
def t1(*, x: str | None = None) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Optional not nullable → adds | None
|
|
sig = FunctionSignature.from_schema(
|
|
name='t2',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'type': 'string'}},
|
|
},
|
|
)
|
|
assert sig.render('...', name='t2') == snapshot("""\
|
|
def t2(*, x: str | None = None) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Optional with explicit default preserves default value
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_default',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'type': 'integer', 'default': 7}},
|
|
},
|
|
)
|
|
assert sig.render('...', name='t_default') == snapshot("""\
|
|
def t_default(*, x: int = 7) -> Any:
|
|
...\
|
|
""")
|
|
|
|
# Unresolvable return_schema keeps return type as Any
|
|
sig3 = FunctionSignature.from_schema(
|
|
name='t3',
|
|
parameters_schema={'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']},
|
|
return_schema={},
|
|
)
|
|
assert sig3.render('...', name='t3', description='A tool') == snapshot('''\
|
|
def t3(*, x: str) -> Any:
|
|
"""A tool"""
|
|
...\
|
|
''')
|
|
|
|
# Return schema with $defs
|
|
sig4 = FunctionSignature.from_schema(
|
|
name='t4',
|
|
parameters_schema={'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']},
|
|
return_schema={
|
|
'$ref': '#/$defs/Result',
|
|
'$defs': {'Result': {'type': 'object', 'properties': {'v': {'type': 'integer'}}, 'required': ['v']}},
|
|
},
|
|
)
|
|
assert sig4.render('...', name='t4') == snapshot("""\
|
|
def t4(*, x: str) -> Result:
|
|
...\
|
|
""")
|
|
assert [t.render_definition() for t in sig4.referenced_types] == snapshot(
|
|
[
|
|
"""\
|
|
class Result(TypedDict):
|
|
v: int\
|
|
"""
|
|
]
|
|
)
|
|
|
|
# anyOf with duplicates → deduplicated
|
|
sig5 = FunctionSignature.from_schema(
|
|
name='t5',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'anyOf': [{'type': 'string'}, {'type': 'string'}, {'type': 'null'}]}},
|
|
'required': ['x'],
|
|
},
|
|
)
|
|
assert sig5.render('...', name='t5') == snapshot("""\
|
|
def t5(*, x: str | None) -> Any:
|
|
...\
|
|
""")
|
|
|
|
|
|
# =============================================================================
|
|
# Additional coverage tests
|
|
# =============================================================================
|
|
|
|
|
|
def test_tool_definition_eq_non_tool():
|
|
"""ToolDefinition does not equal non-ToolDefinition objects."""
|
|
td = ToolDefinition(name='t')
|
|
assert td != 'not a tool'
|
|
|
|
|
|
def test_function_signature_literal_annotation():
|
|
"""Literal type annotations render via schema path."""
|
|
ns: dict[str, object] = {'typing': typing}
|
|
exec("def func(x: typing.Literal['a', 'b']) -> None: ...", ns)
|
|
td = Tool(ns['func']).tool_def # pyright: ignore[reportArgumentType]
|
|
assert td.render_signature('...') == snapshot("""\
|
|
def func(*, x: Literal['a', 'b']) -> None:
|
|
...\
|
|
""")
|
|
|
|
|
|
def test_function_with_bare_generic_annotation():
|
|
"""Bare generic (e.g. `typing.List` without type args) renders as list[Any] via schema path."""
|
|
# Create a function with bare generic annotation (typing.List, not list)
|
|
# via a helper module that doesn't use `from __future__ import annotations`
|
|
import types as t
|
|
|
|
mod = t.ModuleType('_test_bare_generic')
|
|
mod.__dict__['List'] = getattr(typing, 'List') # typing.List (has origin but no args)
|
|
exec(
|
|
compile('def func(items: List) -> None: ...', '<test>', 'exec'),
|
|
mod.__dict__,
|
|
)
|
|
td = Tool(mod.func).tool_def
|
|
assert 'items: list[Any]' in td.render_signature('...')
|
|
|
|
|
|
def test_function_signature_nameerror_for_unresolvable_forward_ref():
|
|
"""Functions with unresolvable forward refs raise NameError via the schema path."""
|
|
ns: dict[str, object] = {}
|
|
exec(
|
|
"def func_with_fwd_ref(x: 'NonexistentType') -> None: ...",
|
|
ns,
|
|
)
|
|
func = ns['func_with_fwd_ref']
|
|
with pytest.raises(NameError, match='NonexistentType'):
|
|
Tool(func) # pyright: ignore[reportArgumentType]
|
|
|
|
|
|
def test_schema_bare_object():
|
|
"""Bare object type (no properties, no additionalProperties) renders as dict."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_bare',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'data': {'type': 'object'}},
|
|
'required': ['data'],
|
|
},
|
|
)
|
|
assert 'dict[str, Any]' in sig.render('...', name='t_bare')
|
|
|
|
|
|
def test_schema_object_type_list_no_null():
|
|
"""Object in type list without null renders without union."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_obj_list',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'config': {
|
|
'type': ['object'],
|
|
'properties': {'x': {'type': 'string'}},
|
|
'required': ['x'],
|
|
},
|
|
},
|
|
'required': ['config'],
|
|
},
|
|
)
|
|
rendered = sig.render('...', name='t_obj_list')
|
|
assert 'None' not in rendered
|
|
assert 'TObjListConfig' in rendered
|
|
|
|
|
|
def test_schema_single_type_after_filtering():
|
|
"""Single-element type list renders as plain type."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_single',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'type': ['string']}},
|
|
'required': ['x'],
|
|
},
|
|
)
|
|
rendered = sig.render('...', name='t_single')
|
|
assert 'str' in rendered
|
|
# Should not be a union
|
|
assert '|' not in rendered
|
|
|
|
|
|
def test_schema_single_anyof_member():
|
|
"""Single-member anyOf returns the type directly, not a union."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_anyof_single',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'anyOf': [{'type': 'string'}]}},
|
|
'required': ['x'],
|
|
},
|
|
)
|
|
rendered = sig.render('...', name='t_anyof_single')
|
|
assert 'str' in rendered
|
|
assert '|' not in rendered
|
|
|
|
|
|
def test_schema_defs_already_processed():
|
|
"""Second call with same $defs name finds it already processed."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool_shared',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'a': {'$ref': '#/$defs/Shared'},
|
|
'b': {'$ref': '#/$defs/Shared'},
|
|
},
|
|
'required': ['a', 'b'],
|
|
'$defs': {
|
|
'Shared': {
|
|
'type': 'object',
|
|
'properties': {'v': {'type': 'integer'}},
|
|
'required': ['v'],
|
|
}
|
|
},
|
|
},
|
|
)
|
|
# Both params reference the same TypeSignature
|
|
assert str(sig.params['a'].type) == 'Shared'
|
|
assert str(sig.params['b'].type) == 'Shared'
|
|
# Only one referenced type
|
|
assert len(sig.referenced_types) == 1
|
|
|
|
|
|
def test_schema_return_type_dedup():
|
|
"""Param and return schemas sharing a $defs type produce one definition."""
|
|
shared_def = {
|
|
'type': 'object',
|
|
'properties': {'id': {'type': 'integer'}},
|
|
'required': ['id'],
|
|
}
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool_dedup',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'item': {'$ref': '#/$defs/Item'}},
|
|
'required': ['item'],
|
|
'$defs': {'Item': shared_def},
|
|
},
|
|
return_schema={
|
|
'$ref': '#/$defs/Item',
|
|
'$defs': {'Item': shared_def},
|
|
},
|
|
)
|
|
# Both param and return reference Item
|
|
assert str(sig.params['item'].type) == 'Item'
|
|
assert str(sig.return_type) == 'Item'
|
|
|
|
|
|
def test_schema_object_type_name_collision():
|
|
"""Two properties generating the same path-based type name — second is reused."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool_collision',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'data': {
|
|
'type': 'object',
|
|
'properties': {'x': {'type': 'string'}},
|
|
'required': ['x'],
|
|
},
|
|
},
|
|
'required': ['data'],
|
|
},
|
|
)
|
|
# Should have a TypedDict for the nested object
|
|
assert len(sig.referenced_types) == 1
|
|
assert sig.referenced_types[0].name == 'ToolCollisionData'
|
|
|
|
|
|
def test_function_signature_root_model():
|
|
"""RootModel parameter renders as list type via schema path."""
|
|
|
|
def func_with_root_model(x: _IntList, y: int) -> None: ... # pragma: no cover
|
|
|
|
td = Tool(func_with_root_model).tool_def
|
|
assert td.render_signature('...') == snapshot("""\
|
|
def func_with_root_model(*, x: _IntList, y: int) -> None:
|
|
...\
|
|
""")
|
|
assert td.function_signature is not None
|
|
assert td.function_signature.referenced_types == []
|
|
|
|
|
|
def test_function_signature_recursive_model():
|
|
"""Recursive BaseModel with $ref in schema produces correct TypedDict via schema path."""
|
|
|
|
def func_with_tree(x: _TreeNode, label: str = '') -> None: ... # pragma: no cover
|
|
|
|
td = Tool(func_with_tree).tool_def
|
|
assert td.render_signature('...') == snapshot("""\
|
|
def func_with_tree(*, x: _TreeNode, label: str = '') -> None:
|
|
...\
|
|
""")
|
|
sig = td.function_signature
|
|
assert sig is not None
|
|
assert len(sig.referenced_types) == 1
|
|
assert sig.referenced_types[0].name == '_TreeNode'
|
|
assert 'value' in sig.referenced_types[0].fields
|
|
assert 'children' in sig.referenced_types[0].fields
|
|
|
|
|
|
def test_schema_cross_referencing_defs():
|
|
"""$ref in a def property lazily resolves another def not yet processed."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'item': {'$ref': '#/$defs/Container'}},
|
|
'required': ['item'],
|
|
'$defs': {
|
|
'Container': {
|
|
'type': 'object',
|
|
'properties': {'inner': {'$ref': '#/$defs/Inner'}},
|
|
'required': ['inner'],
|
|
},
|
|
'Inner': {
|
|
'type': 'object',
|
|
'properties': {'value': {'type': 'string'}},
|
|
'required': ['value'],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
assert sig.render('...', name='tool') == snapshot("""\
|
|
def tool(*, item: Container) -> Any:
|
|
...\
|
|
""")
|
|
# Both Container and Inner should be resolved as TypeSignatures
|
|
type_names = {t.name for t in sig.referenced_types}
|
|
assert type_names == {'Container', 'Inner'}
|
|
# Container's 'inner' field references the Inner TypeSignature
|
|
container = next(t for t in sig.referenced_types if t.name == 'Container')
|
|
assert str(container.fields['inner'].type) == 'Inner'
|
|
|
|
|
|
def test_schema_inline_object_reuses_existing_typename():
|
|
"""Inline object property reuses a $defs type when path-based names collide."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'data': {
|
|
'type': 'object',
|
|
'properties': {'x': {'type': 'string'}},
|
|
'required': ['x'],
|
|
},
|
|
},
|
|
'required': ['data'],
|
|
'$defs': {
|
|
# This $def's name matches the path-based typename for property 'data'
|
|
# _path_to_typename('tool', 'data') == 'ToolData'
|
|
'ToolData': {
|
|
'type': 'object',
|
|
'properties': {'y': {'type': 'integer'}},
|
|
'required': ['y'],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
# The $defs ToolData was registered first; the inline 'data' property reuses it
|
|
assert str(sig.params['data'].type) == 'ToolData'
|
|
|
|
|
|
def test_schema_additional_properties_false():
|
|
"""additionalProperties: false falls through to dict[str, Any]."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_ap_false',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'meta': {'type': 'object', 'additionalProperties': False},
|
|
},
|
|
'required': ['meta'],
|
|
},
|
|
)
|
|
assert 'dict[str, Any]' in sig.render('...', name='t_ap_false')
|
|
|
|
|
|
def test_schema_empty_type_list():
|
|
"""Empty type list produces Any."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='t_empty',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'x': {'type': []}},
|
|
'required': ['x'],
|
|
},
|
|
)
|
|
assert 'Any' in sig.render('...', name='t_empty')
|
|
|
|
|
|
def test_function_with_typed_dict_param():
|
|
"""TypedDict parameters render correctly via schema path."""
|
|
|
|
def search(params: _SearchParams, verbose: bool = False) -> str: ... # pragma: no cover
|
|
|
|
td = Tool(search).tool_def
|
|
assert 'params: _SearchParams' in td.render_signature('...')
|
|
assert td.function_signature is not None
|
|
assert any(rt.name == '_SearchParams' for rt in td.function_signature.referenced_types)
|
|
|
|
|
|
def test_schema_optional_with_anyof_null():
|
|
"""Optional schema params with anyOf containing null are correctly handled."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'name': {'type': 'string'},
|
|
'tag': {'anyOf': [{'type': 'string'}, {'type': 'null'}]},
|
|
},
|
|
'required': ['name'],
|
|
},
|
|
)
|
|
rendered = sig.render('...', name='tool')
|
|
assert 'tag: str | None' in rendered
|
|
|
|
|
|
def test_schema_optional_with_oneof_null():
|
|
"""Optional schema params with oneOf containing null are correctly handled."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'name': {'type': 'string'},
|
|
'tag': {'oneOf': [{'type': 'string'}, {'type': 'null'}]},
|
|
},
|
|
'required': ['name'],
|
|
},
|
|
)
|
|
rendered = sig.render('...', name='tool')
|
|
assert 'tag: str | None' in rendered
|
|
|
|
|
|
def test_function_with_enum_field_in_model():
|
|
"""Model with an enum field skips non-object $defs (enums) via schema path."""
|
|
|
|
def configure(cfg: _ConfigWithEnum, dry_run: bool = False) -> str:
|
|
return cfg.name # pragma: no cover
|
|
|
|
td = Tool(configure).tool_def
|
|
assert 'cfg: _ConfigWithEnum' in td.render_signature('...')
|
|
# _ConfigWithEnum should be a TypedDict, but _Color (enum) should not
|
|
assert td.function_signature is not None
|
|
type_names = {rt.name for rt in td.function_signature.referenced_types}
|
|
assert '_ConfigWithEnum' in type_names
|
|
assert '_Color' not in type_names
|
|
|
|
|
|
def test_schema_with_described_optional_fields():
|
|
"""Schema with descriptions and optional fields renders NotRequired and field descriptions."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='create_user',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {
|
|
'name': {'type': 'string', 'description': 'The user name'},
|
|
'bio': {'type': 'string', 'description': 'A short bio.\nCan be multiple lines.'},
|
|
},
|
|
'required': ['name'],
|
|
'$defs': {
|
|
'Address': {
|
|
'type': 'object',
|
|
'description': 'A mailing address',
|
|
'properties': {
|
|
'street': {'type': 'string'},
|
|
'city': {'type': 'string'},
|
|
},
|
|
'required': ['street'],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
# Check that optional field 'bio' is rendered with description
|
|
rendered = sig.render('...', name='create_user')
|
|
assert 'bio: str' in rendered
|
|
|
|
# Check that Address type has description in its definition
|
|
addr = next(t for t in sig.referenced_types if t.name == 'Address')
|
|
defn = addr.render_definition()
|
|
assert 'A mailing address' in defn
|
|
# city is not required → should render as NotRequired
|
|
assert 'NotRequired' in defn
|
|
|
|
|
|
def test_render_type_field_with_multiline_description():
|
|
"""TypeFieldSignature renders multi-line descriptions correctly."""
|
|
field = TypeFieldSignature(
|
|
name='notes',
|
|
type=SimpleTypeExpr('str'),
|
|
required=True,
|
|
description='First line.\nSecond line.',
|
|
)
|
|
rendered = str(field)
|
|
assert '"""' in rendered
|
|
assert 'First line.' in rendered
|
|
assert 'Second line.' in rendered
|
|
|
|
|
|
def test_render_empty_type_signature():
|
|
"""Empty TypeSignature without description renders with pass."""
|
|
ts = TypeSignature(name='Empty')
|
|
assert ts.render_definition() == 'class Empty(TypedDict):\n pass'
|
|
|
|
# Empty with description renders docstring but no pass
|
|
ts_with_desc = TypeSignature(name='Marker', description='A marker type')
|
|
assert ts_with_desc.render_definition() == 'class Marker(TypedDict):\n """A marker type"""'
|
|
|
|
|
|
# Boolean JSON Schema nodes (`True`/`False` are valid schemas per spec, and surface from
|
|
# e.g. MCP tool schemas). Each test below exercises a distinct walker entry point that would
|
|
# otherwise index into a bool. See `_normalize_schema_node`.
|
|
|
|
|
|
def test_boolean_property_schema_renders_as_any():
|
|
"""A property whose schema is the bare boolean `True` renders as `Any` instead of crashing."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='passthrough',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'anything': True},
|
|
'required': ['anything'],
|
|
},
|
|
)
|
|
assert str(sig.params['anything'].type) == 'Any'
|
|
|
|
|
|
def test_boolean_array_items_render_as_list_any():
|
|
"""`items: True` (any element allowed) recurses with a bool and renders as `list[Any]`."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='collect',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'arr': {'type': 'array', 'items': True}},
|
|
'required': ['arr'],
|
|
},
|
|
)
|
|
assert str(sig.params['arr'].type) == 'list[Any]'
|
|
|
|
|
|
def test_boolean_allof_member_renders_as_any():
|
|
"""A single-member `allOf: [True]` recurses into `_schema_to_type_expr` with a bool."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='allof_tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'value': {'allOf': [True]}},
|
|
'required': ['value'],
|
|
},
|
|
)
|
|
assert str(sig.params['value'].type) == 'Any'
|
|
|
|
|
|
def test_boolean_anyof_member_renders():
|
|
"""A bool member inside `anyOf` is inspected (`.get('type')`) before recursion and must not crash."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='anyof_tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'value': {'anyOf': [True, {'type': 'null'}]}},
|
|
'required': ['value'],
|
|
},
|
|
)
|
|
assert str(sig.params['value'].type) == 'Any | None'
|
|
|
|
|
|
def test_boolean_nested_object_property_renders_as_any():
|
|
"""A bare-boolean property inside a NESTED object hits the `_build_type_signature` walker."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='outer',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'nested': {'type': 'object', 'properties': {'x': True}}},
|
|
'required': ['nested'],
|
|
},
|
|
)
|
|
nested = next(t for t in sig.referenced_types if 'x' in t.fields)
|
|
assert str(nested.fields['x'].type) == 'Any'
|
|
|
|
|
|
def test_boolean_def_schema_does_not_crash():
|
|
"""A bare-boolean `$defs` entry (referenced via `$ref`) must not crash `_process_schema_defs`."""
|
|
sig = FunctionSignature.from_schema(
|
|
name='defs_tool',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'value': {'$ref': '#/$defs/Anything'}},
|
|
'required': ['value'],
|
|
'$defs': {'Anything': True},
|
|
},
|
|
)
|
|
assert 'value' in sig.params
|
|
|
|
|
|
def test_boolean_additional_properties_render_as_dict_any():
|
|
"""`additionalProperties: True`/`False` on an object render as `dict[str, Any]`.
|
|
|
|
Unlike the cases above these never crashed (the object branch reads the value directly),
|
|
but the assertion documents the rendered shape and guards against regressions.
|
|
"""
|
|
for value in (True, False):
|
|
sig = FunctionSignature.from_schema(
|
|
name='cfg',
|
|
parameters_schema={
|
|
'type': 'object',
|
|
'properties': {'meta': {'type': 'object', 'additionalProperties': value}},
|
|
'required': ['meta'],
|
|
},
|
|
)
|
|
assert str(sig.params['meta'].type) == 'dict[str, Any]'
|