"""Tests for schema derivation from typed function signatures.""" from __future__ import annotations import logging from typing import Annotated, Any, Literal import pytest from omnigent_client.tools._schema import build_function_schema from pydantic import BaseModel, Field # ─── primitives ───────────────────────────────────────────────────── def test_schema_primitive_str_param() -> None: """A single ``str`` param produces a string-typed required property.""" def fn(text: str) -> str: """Echo the text.""" return text result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["type"] == "object" assert schema["properties"]["text"]["type"] == "string" assert "text" in schema["required"] def test_schema_primitive_int_with_default() -> None: """A param with a default is NOT marked required (in non-strict).""" def fn(count: int = 5) -> int: """Return the count.""" return count result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["properties"]["count"]["type"] == "integer" # Default value should be reflected in the schema. assert schema["properties"]["count"].get("default") == 5 # Non-strict: defaulted params are not required. assert "count" not in schema.get("required", []) def test_schema_multiple_primitive_params() -> None: """Multiple params with mixed types build correct properties.""" def fn(name: str, age: int, active: bool = True) -> str: """Format a record.""" return f"{name} {age} {active}" result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["properties"]["name"]["type"] == "string" assert schema["properties"]["age"]["type"] == "integer" assert schema["properties"]["active"]["type"] == "boolean" assert sorted(schema["required"]) == ["age", "name"] # ─── pydantic models ───────────────────────────────────────────────── class _Person(BaseModel): """A person record (test fixture).""" name: str age: int email: str | None = None def test_schema_pydantic_model_param() -> None: """A ``BaseModel`` param produces a ``$ref`` into ``$defs``.""" def fn(person: _Person) -> str: """Format the person.""" return person.name result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema # The person property should reference the model's schema. person_schema = schema["properties"]["person"] assert "$ref" in person_schema or "type" in person_schema # The Person model definition should appear in $defs. assert "$defs" in schema assert "_Person" in schema["$defs"] person_def = schema["$defs"]["_Person"] assert person_def["type"] == "object" assert "name" in person_def["properties"] assert "age" in person_def["properties"] assert "email" in person_def["properties"] # ─── Annotated / Field / Literal / Optional ────────────────────────── def test_schema_annotated_string_description() -> None: """``Annotated[str, "desc"]`` populates the description.""" def fn(name: Annotated[str, "the user's name"]) -> str: """Greet.""" return name result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["properties"]["name"]["description"] == "the user's name" def test_schema_annotated_field_description() -> None: """``Annotated[T, Field(description=...)]`` populates the description.""" def fn(value: Annotated[int, Field(description="numeric input")]) -> int: """Process.""" return value result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["properties"]["value"]["description"] == "numeric input" def test_schema_literal_becomes_enum() -> None: """``Literal["a", "b"]`` produces an enum schema.""" def fn(mode: Literal["read", "write", "append"]) -> None: """Operate.""" result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema mode = schema["properties"]["mode"] # Could be {enum: [...]} or {const: "..."} depending on Pydantic # version; canonical form is "enum" for >1 value. assert mode["enum"] == ["read", "write", "append"] def test_schema_optional_str() -> None: """``Optional[str] = None`` is nullable with default null.""" def fn(label: str | None = None) -> None: """Maybe label.""" result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema label = schema["properties"]["label"] # Pydantic represents Optional via anyOf with a null variant, OR # via a `type` list including "null". Either is acceptable as long # as the schema permits null. if "anyOf" in label: types_in_union = {variant.get("type") for variant in label["anyOf"]} assert "null" in types_in_union elif isinstance(label.get("type"), list): assert "null" in label["type"] else: # Pydantic 2.x sometimes inlines as anyOf — fail loud if neither path. pytest.fail(f"Optional[str] schema should permit null but got {label!r}") def test_schema_str_or_none_pep604() -> None: """``str | None`` (PEP 604 union) is handled identically to Optional.""" def fn(label: str | None = None) -> None: """Maybe label.""" result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema label = schema["properties"]["label"] if "anyOf" in label: types_in_union = {variant.get("type") for variant in label["anyOf"]} assert "null" in types_in_union elif isinstance(label.get("type"), list): assert "null" in label["type"] # ─── docstring ─────────────────────────────────────────────────────── def test_schema_description_from_docstring() -> None: """The function-level description comes from the docstring.""" def fn(x: int) -> int: """The first paragraph of the docstring.""" return x result = build_function_schema(fn, strict=False) assert result.description == "The first paragraph of the docstring." def test_schema_param_description_from_docstring_args() -> None: """``Args:`` entries populate the per-property descriptions.""" def fn(text: str, count: int) -> str: """ Process input. Args: text: The text to process. count: Number of times to repeat. """ return text * count result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["properties"]["text"]["description"] == "The text to process." assert schema["properties"]["count"]["description"] == "Number of times to repeat." def test_schema_annotated_overrides_docstring() -> None: """``Annotated[..., Field(description=...)]`` wins over docstring.""" def fn(x: Annotated[int, Field(description="annotated wins")]) -> int: """ Process. Args: x: docstring loses. """ return x result = build_function_schema(fn, strict=False) assert result.parameters_json_schema["properties"]["x"]["description"] == "annotated wins" # ─── return type ───────────────────────────────────────────────────── def test_schema_return_annotation_captured() -> None: """The return annotation is exposed on the result.""" def fn(x: int) -> str: """Stringify.""" return str(x) result = build_function_schema(fn, strict=False) assert result.return_annotation is str def test_schema_no_return_annotation_is_none() -> None: """Missing return annotation produces ``None``.""" def fn(x: int): """Process.""" return x result = build_function_schema(fn, strict=False) assert result.return_annotation is None # ─── strict mode ───────────────────────────────────────────────────── def test_schema_strict_mode_default_true() -> None: """``strict=True`` (the default) adds ``additionalProperties: false``.""" def fn(x: int) -> int: """Process.""" return x result = build_function_schema(fn) # default strict=True assert result.parameters_json_schema["additionalProperties"] is False def test_schema_strict_false_does_not_force_additional_properties() -> None: """``strict=False`` leaves ``additionalProperties`` unset.""" def fn(x: int) -> int: """Process.""" return x result = build_function_schema(fn, strict=False) assert "additionalProperties" not in result.parameters_json_schema # ─── permissive types ──────────────────────────────────────────────── def test_schema_warns_on_any_param(caplog: pytest.LogCaptureFixture) -> None: """``Any`` parameter logs an INFO warning.""" def fn(payload: Any) -> str: """Process.""" return str(payload) with caplog.at_level(logging.INFO, logger="omnigent_client.tools._schema"): build_function_schema(fn, strict=False) # Find the warning by content — must name function and parameter. warning_messages = [r.message for r in caplog.records] assert any( "fn" in msg and "payload" in msg and "permissive" in msg.lower() for msg in warning_messages ), f"Expected an Any-warning naming 'fn' and 'payload', got: {warning_messages}" def test_schema_warns_on_object_param(caplog: pytest.LogCaptureFixture) -> None: """``object`` parameter logs an INFO warning.""" def fn(thing: object) -> str: """Process.""" return str(thing) with caplog.at_level(logging.INFO, logger="omnigent_client.tools._schema"): build_function_schema(fn, strict=False) warning_messages = [r.message for r in caplog.records] assert any( "fn" in msg and "thing" in msg and "permissive" in msg.lower() for msg in warning_messages ) def test_schema_warns_on_missing_annotation( caplog: pytest.LogCaptureFixture, ) -> None: """Missing annotation (resolves to Any) logs an INFO warning.""" def fn(x): # type: ignore[no-untyped-def] """Process.""" return x with caplog.at_level(logging.INFO, logger="omnigent_client.tools._schema"): build_function_schema(fn, strict=False) warning_messages = [r.message for r in caplog.records] assert any( "fn" in msg and "x" in msg and "permissive" in msg.lower() for msg in warning_messages ) def test_schema_no_warning_for_concrete_type( caplog: pytest.LogCaptureFixture, ) -> None: """Concrete annotations do NOT log a warning.""" def fn(x: int) -> int: """Process.""" return x with caplog.at_level(logging.INFO, logger="omnigent_client.tools._schema"): build_function_schema(fn, strict=False) warning_messages = [r.message for r in caplog.records] assert not any("permissive" in msg.lower() for msg in warning_messages), ( f"Concrete int param should not produce a permissive-type warning, got: {warning_messages}" ) # ─── zero-arg tools ────────────────────────────────────────────────── def test_schema_zero_arg_tool() -> None: """A function with no parameters produces an empty-object schema.""" def fn() -> str: """Return a constant.""" return "constant" result = build_function_schema(fn, strict=False) schema = result.parameters_json_schema assert schema["type"] == "object" assert schema["properties"] == {} assert schema.get("required", []) == [] def test_schema_zero_arg_tool_strict_mode() -> None: """A zero-arg tool in strict mode still has additionalProperties false.""" def fn() -> str: """Return a constant.""" return "constant" result = build_function_schema(fn, strict=True) schema = result.parameters_json_schema assert schema["additionalProperties"] is False