Files
pydantic--pydantic-ai/tests/test_function_signature.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

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]'