diff --git a/CHANGELOG.md b/CHANGELOG.md index 597e10a..b8b0986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.2.0] - 2026-06-30 + +### Added +- **Structured input types** — parameters typed as a `dataclass`, `TypedDict`, `NamedTuple`, or `Enum` now generate proper nested JSON Schema objects (or typed enums), across all four providers. Nesting is recursive and self-referential types are guarded against infinite recursion. +- New tests covering enums, dataclasses, TypedDicts, NamedTuples, nested combinations, and provider integration + +### Fixed +- Gemini formatter now preserves `required` lists on nested object schemas + +### Changed +- Dropped the author email from package metadata (the GitHub no-reply address was a dead mailto link); author is now name-only + ## [0.1.1] - 2026-06-30 ### Added @@ -37,6 +49,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Zero external dependencies — pure Python stdlib (3.9+) - 91 unit tests covering schema generation, docstring parsing, and all four providers -[Unreleased]: https://github.com/aenealabs/polytools/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/aenealabs/polytools/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/aenealabs/polytools/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/aenealabs/polytools/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/aenealabs/polytools/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 8e51170..babcd0b 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,73 @@ result = send_email.call(block.input) | `Optional[T]` | `{"anyOf": [{...}, {"type": "null"}]}` | | `Union[T1, T2]` | `{"anyOf": [{...}, {...}]}` | | `Literal["a", "b"]` | `{"type": "string", "enum": ["a", "b"]}` | +| `Enum` subclass | `{"type": , "enum": [...]}` | +| `dataclass` | `{"type": "object", "properties": {...}, "required": [...]}` | +| `TypedDict` | `{"type": "object", "properties": {...}, "required": [...]}` | +| `NamedTuple` | `{"type": "object", "properties": {...}, "required": [...]}` | | `Any` | `{}` (no constraints) | | Unannotated | `{}` (no constraints) | Nested types are fully supported: `list[dict[str, Optional[int]]]` works as expected. +## Structured inputs + +Parameters typed as a `dataclass`, `TypedDict`, `NamedTuple`, or `Enum` become proper nested JSON Schema objects — no Pydantic required. A field is marked `required` unless it has a default (or is a `total=False` / `NotRequired` TypedDict key). + +```python +from dataclasses import dataclass +from enum import Enum +from polytools import tool + +class Priority(Enum): + LOW = "low" + HIGH = "high" + +@dataclass +class Ticket: + title: str + priority: Priority + assignee: str = "" + +@tool +def create_ticket(ticket: Ticket) -> str: + """Open a support ticket. + + Args: + ticket: The ticket to create. + """ + ... + +create_ticket.to_openai() +``` + +
+Schema output (parameters) + +```json +{ + "type": "object", + "properties": { + "ticket": { + "type": "object", + "description": "The ticket to create.", + "properties": { + "title": {"type": "string"}, + "priority": {"type": "string", "enum": ["low", "high"]}, + "assignee": {"type": "string"} + }, + "required": ["title", "priority"] + } + }, + "required": ["ticket"] +} +``` +
+ +Nesting is recursive (a dataclass field that is itself a dataclass, `list[SomeDataclass]`, `Optional[SomeTypedDict]`, `dict[str, SomeEnum]`, …). Self-referential types are guarded and collapse to a bare `{"type": "object"}`. + +> **Note:** `.call(args)` passes the LLM's argument dict straight to your function; it does **not** reconstruct dataclass/TypedDict instances from that dict. Schema generation and argument coercion are separate concerns — coercion may land in a future release. + ## Docstring Styles Parameter descriptions are parsed automatically from Google, NumPy, and reStructuredText docstrings: diff --git a/pyproject.toml b/pyproject.toml index e4f1c4e..1bb5041 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "polytools" -version = "0.1.1" +version = "0.2.0" description = "Cross-provider LLM tool schema generation from Python type hints. Zero dependencies." readme = "README.md" requires-python = ">=3.9" diff --git a/src/polytools/_providers/_gemini.py b/src/polytools/_providers/_gemini.py index 97b4355..562ba7f 100644 --- a/src/polytools/_providers/_gemini.py +++ b/src/polytools/_providers/_gemini.py @@ -93,6 +93,10 @@ def _convert_schema(schema: dict) -> dict: elif key == "additionalProperties" and isinstance(value, dict): result["additionalProperties"] = _convert_schema(value) + elif key == "required" and isinstance(value, list): + # Preserve required lists on nested object schemas. + result["required"] = value + elif key in ("description", "format", "minItems", "maxItems", "uniqueItems", "minimum", "maximum"): result[key] = value diff --git a/src/polytools/_schema.py b/src/polytools/_schema.py index adcc9af..9a2641e 100644 --- a/src/polytools/_schema.py +++ b/src/polytools/_schema.py @@ -1,7 +1,8 @@ """ Python type annotation → JSON Schema conversion. -Pure Python stdlib only (typing, inspect). No third-party dependencies. +Pure Python stdlib only (typing, inspect, dataclasses, enum). No third-party +dependencies. Supported annotations --------------------- @@ -11,16 +12,24 @@ Optionals : Optional[T] → anyOf: [T_schema, {type: null}] Unions : Union[T1, T2, ...] → anyOf: [...] Literals : Literal["a", "b"] → {type: string, enum: [...]} +Enums : enum.Enum subclass → {type: , enum: [...]} +Structured : dataclass / TypedDict / NamedTuple + → {type: object, properties: {...}, required: [...]} Any : Any → {} (no constraints) Unknown : falls back to {type: object} + +Nested structured types are resolved recursively (e.g. a dataclass field that +is itself a dataclass, or ``list[SomeDataclass]``). Self-referential types are +guarded against infinite recursion and collapse to ``{type: object}``. """ from __future__ import annotations +import dataclasses +import enum import inspect -import sys import typing -from typing import Any, Union +from typing import Any, Union, get_type_hints # --------------------------------------------------------------------------- # Compatibility helpers @@ -63,6 +72,96 @@ def _get_args(tp: Any) -> tuple: } +# --------------------------------------------------------------------------- +# Structured-type detection (dataclass / TypedDict / NamedTuple / Enum) +# --------------------------------------------------------------------------- + +def _is_typeddict(tp: Any) -> bool: + is_td = getattr(typing, "is_typeddict", None) # Python 3.10+ + if is_td is not None: + try: + if is_td(tp): + return True + except Exception: # pragma: no cover - defensive + pass + # Fallback for 3.9: TypedDict classes subclass dict and carry __total__. + return ( + isinstance(tp, type) + and issubclass(tp, dict) + and hasattr(tp, "__annotations__") + and hasattr(tp, "__total__") + ) + + +def _is_namedtuple(tp: Any) -> bool: + return isinstance(tp, type) and issubclass(tp, tuple) and hasattr(tp, "_fields") + + +def _resolve_hints(cls: Any) -> dict: + """typing.get_type_hints with a graceful fallback to raw annotations.""" + try: + return get_type_hints(cls) + except Exception: + return dict(getattr(cls, "__annotations__", {})) + + +def _enum_to_schema(cls: type) -> dict: + """Map an ``enum.Enum`` subclass to an enum schema, typed by its values.""" + values = [member.value for member in cls] + if all(isinstance(v, str) for v in values): + return {"type": "string", "enum": values} + if all(isinstance(v, bool) for v in values): + return {"type": "boolean", "enum": values} + if all(isinstance(v, int) for v in values): + return {"type": "integer", "enum": values} + if all(isinstance(v, float) for v in values): + return {"type": "number", "enum": values} + return {"enum": values} # mixed value types → untyped enum + + +def _dataclass_fields(cls: type) -> list: + hints = _resolve_hints(cls) + fields = [] + for f in dataclasses.fields(cls): + annotation = hints.get(f.name, f.type) + has_default = ( + f.default is not dataclasses.MISSING + or f.default_factory is not dataclasses.MISSING # type: ignore[misc] + ) + fields.append((f.name, annotation, not has_default)) + return fields + + +def _typeddict_fields(td: type) -> list: + hints = _resolve_hints(td) + required_keys = getattr(td, "__required_keys__", None) + if required_keys is None: + total = getattr(td, "__total__", True) + required_keys = set(hints) if total else set() + return [(key, hints.get(key, Any), key in required_keys) for key in hints] + + +def _namedtuple_fields(nt: type) -> list: + hints = _resolve_hints(nt) + defaults = getattr(nt, "_field_defaults", {}) + return [(name, hints.get(name, Any), name not in defaults) for name in nt._fields] + + +def _object_schema(cls: type, fields: list, seen: frozenset) -> dict: + """Build an object schema from ``(name, annotation, required)`` triples.""" + child_seen = seen | {cls} + properties: dict[str, dict] = {} + required: list[str] = [] + for name, annotation, is_required in fields: + properties[name] = _to_schema(annotation, child_seen) + if is_required: + required.append(name) + schema: dict = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return schema + + # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- @@ -74,8 +173,13 @@ def annotation_to_schema(annotation: Any) -> dict: which in JSON Schema means "no constraints" (any value is valid). This function is deliberately non-recursive on exceptions: if a nested - annotation cannot be resolved, that subtree falls back to ``{}``. + annotation cannot be resolved, that subtree falls back to ``{}`` or + ``{"type": "object"}``. """ + return _to_schema(annotation, frozenset()) + + +def _to_schema(annotation: Any, seen: frozenset) -> dict: if annotation is inspect.Parameter.empty: return {} @@ -99,11 +203,11 @@ def annotation_to_schema(annotation: Any) -> dict: if len(non_none) == 1 and has_none: # Optional[X] → anyOf: [X_schema, null] - inner = annotation_to_schema(non_none[0]) + inner = _to_schema(non_none[0], seen) return {"anyOf": [inner, {"type": "null"}]} # Union[X, Y, …] - return {"anyOf": [annotation_to_schema(a) for a in args]} + return {"anyOf": [_to_schema(a, seen) for a in args]} # ------------------------------------------------------------------ # Literal @@ -127,7 +231,7 @@ def annotation_to_schema(annotation: Any) -> dict: if origin is list: schema: dict = {"type": "array"} if args: - schema["items"] = annotation_to_schema(args[0]) + schema["items"] = _to_schema(args[0], seen) return schema # ------------------------------------------------------------------ @@ -136,7 +240,7 @@ def annotation_to_schema(annotation: Any) -> dict: if origin is dict: schema = {"type": "object"} if len(args) == 2: - schema["additionalProperties"] = annotation_to_schema(args[1]) + schema["additionalProperties"] = _to_schema(args[1], seen) return schema # ------------------------------------------------------------------ @@ -147,10 +251,10 @@ def annotation_to_schema(annotation: Any) -> dict: if args: if len(args) == 2 and args[1] is Ellipsis: # Tuple[T, ...] — homogeneous, variable length - schema["items"] = annotation_to_schema(args[0]) + schema["items"] = _to_schema(args[0], seen) else: # Tuple[T1, T2, T3] — heterogeneous, fixed length - schema["prefixItems"] = [annotation_to_schema(a) for a in args] + schema["prefixItems"] = [_to_schema(a, seen) for a in args] schema["minItems"] = len(args) schema["maxItems"] = len(args) return schema @@ -161,9 +265,27 @@ def annotation_to_schema(annotation: Any) -> dict: if origin in (set, frozenset): schema = {"type": "array", "uniqueItems": True} if args: - schema["items"] = annotation_to_schema(args[0]) + schema["items"] = _to_schema(args[0], seen) return schema + # ------------------------------------------------------------------ + # Structured class types: Enum, TypedDict, NamedTuple, dataclass + # ------------------------------------------------------------------ + if isinstance(annotation, type): + if issubclass(annotation, enum.Enum): + return _enum_to_schema(annotation) + + # Guard against self-referential structures. + if annotation in seen: + return {"type": "object"} + + if _is_typeddict(annotation): + return _object_schema(annotation, _typeddict_fields(annotation), seen) + if _is_namedtuple(annotation): + return _object_schema(annotation, _namedtuple_fields(annotation), seen) + if dataclasses.is_dataclass(annotation): + return _object_schema(annotation, _dataclass_fields(annotation), seen) + # ------------------------------------------------------------------ # Fallback: treat unknown types as object # ------------------------------------------------------------------ diff --git a/tests/test_structured.py b/tests/test_structured.py new file mode 100644 index 0000000..7eff293 --- /dev/null +++ b/tests/test_structured.py @@ -0,0 +1,197 @@ +"""Structured-input support: Enum, dataclass, TypedDict, NamedTuple → object schemas.""" + +import enum +import unittest +from dataclasses import dataclass, field +from typing import Dict, List, NamedTuple, Optional, TypedDict + +from polytools import tool +from polytools._schema import annotation_to_schema + + +# ---- Fixtures (module level so forward refs resolve) -------------------- +class Color(enum.Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +class Priority(enum.IntEnum): + LOW = 1 + HIGH = 2 + + +@dataclass +class Address: + street: str + city: str + zip_code: str = "" + + +@dataclass +class Person: + name: str + age: int + address: Address + nicknames: List[str] = field(default_factory=list) + + +class Movie(TypedDict): + title: str + year: int + + +class PartialConfig(TypedDict, total=False): + verbose: str + retries: int + + +class Coord(NamedTuple): + x: int + y: int + label: str = "origin" + + +@dataclass +class Node: + value: int + next: Optional["Node"] = None # self-referential + + +# ---- Enums -------------------------------------------------------------- +class TestEnum(unittest.TestCase): + def test_string_enum(self): + self.assertEqual( + annotation_to_schema(Color), + {"type": "string", "enum": ["red", "green", "blue"]}, + ) + + def test_int_enum(self): + self.assertEqual( + annotation_to_schema(Priority), + {"type": "integer", "enum": [1, 2]}, + ) + + +# ---- Dataclasses -------------------------------------------------------- +class TestDataclass(unittest.TestCase): + def test_flat_dataclass(self): + schema = annotation_to_schema(Address) + self.assertEqual(schema["type"], "object") + self.assertEqual(set(schema["properties"]), {"street", "city", "zip_code"}) + self.assertEqual(schema["properties"]["street"], {"type": "string"}) + # zip_code has a default → not required + self.assertEqual(schema["required"], ["street", "city"]) + + def test_nested_dataclass(self): + schema = annotation_to_schema(Person) + props = schema["properties"] + self.assertEqual(props["address"]["type"], "object") + self.assertEqual(props["address"]["properties"]["city"], {"type": "string"}) + self.assertEqual(props["nicknames"], {"type": "array", "items": {"type": "string"}}) + # nicknames has default_factory → not required + self.assertEqual(schema["required"], ["name", "age", "address"]) + + def test_self_referential_does_not_recurse_infinitely(self): + schema = annotation_to_schema(Node) + self.assertEqual(schema["type"], "object") + # The recursive 'next' field collapses to a bare object. + self.assertIn("next", schema["properties"]) + self.assertEqual(schema["properties"]["value"], {"type": "integer"}) + + +# ---- TypedDict ---------------------------------------------------------- +class TestTypedDict(unittest.TestCase): + def test_total_typeddict_all_required(self): + schema = annotation_to_schema(Movie) + self.assertEqual(schema["type"], "object") + self.assertEqual(set(schema["properties"]), {"title", "year"}) + self.assertEqual(sorted(schema["required"]), ["title", "year"]) + + def test_partial_typeddict_none_required(self): + schema = annotation_to_schema(PartialConfig) + self.assertEqual(set(schema["properties"]), {"verbose", "retries"}) + self.assertNotIn("required", schema) + + +# ---- NamedTuple --------------------------------------------------------- +class TestNamedTuple(unittest.TestCase): + def test_namedtuple_as_object(self): + schema = annotation_to_schema(Coord) + self.assertEqual(schema["type"], "object") + self.assertEqual(schema["properties"]["x"], {"type": "integer"}) + # label has a default → not required + self.assertEqual(schema["required"], ["x", "y"]) + + +# ---- Nested combinations ------------------------------------------------ +class TestNested(unittest.TestCase): + def test_list_of_dataclass(self): + schema = annotation_to_schema(List[Address]) + self.assertEqual(schema["type"], "array") + self.assertEqual(schema["items"]["type"], "object") + self.assertIn("street", schema["items"]["properties"]) + + def test_optional_dataclass(self): + schema = annotation_to_schema(Optional[Address]) + self.assertEqual(schema["anyOf"][0]["type"], "object") + self.assertEqual(schema["anyOf"][1], {"type": "null"}) + + def test_dict_of_enum(self): + schema = annotation_to_schema(Dict[str, Color]) + self.assertEqual(schema["type"], "object") + self.assertEqual( + schema["additionalProperties"], {"type": "string", "enum": ["red", "green", "blue"]} + ) + + +# ---- Provider integration ---------------------------------------------- +class TestProviders(unittest.TestCase): + def setUp(self): + @tool + def create_user(person: Person, color: Color) -> bool: + """Create a user. + + Args: + person: The person to create. + color: Favorite color. + """ + return True + + self.tool = create_user + + def test_openai_embeds_nested_object(self): + params = self.tool.to_openai()["function"]["parameters"] + person = params["properties"]["person"] + self.assertEqual(person["type"], "object") + self.assertEqual(person["properties"]["address"]["type"], "object") + self.assertEqual(person["required"], ["name", "age", "address"]) + self.assertEqual(params["properties"]["color"]["enum"], ["red", "green", "blue"]) + self.assertEqual(params["required"], ["person", "color"]) + + def test_anthropic_and_mcp_pass_nested_through(self): + for schema, key in ( + (self.tool.to_anthropic(), "input_schema"), + (self.tool.to_mcp(), "inputSchema"), + ): + props = schema[key]["properties"] + self.assertEqual(props["person"]["type"], "object") + self.assertEqual(props["person"]["required"], ["name", "age", "address"]) + + def test_gemini_uppercases_and_preserves_nested_required(self): + gparams = self.tool.to_gemini()["parameters"] + person = gparams["properties"]["person"] + self.assertEqual(person["type"], "OBJECT") + self.assertEqual(person["properties"]["address"]["type"], "OBJECT") + self.assertEqual(person["properties"]["address"]["properties"]["street"]["type"], "STRING") + # The nested-required fix: required survives Gemini conversion. + self.assertEqual(person["required"], ["name", "age", "address"]) + self.assertEqual(gparams["properties"]["color"]["type"], "STRING") + + def test_description_still_merged_on_structured_param(self): + person = self.tool.to_openai()["function"]["parameters"]["properties"]["person"] + self.assertEqual(person["description"], "The person to create.") + + +if __name__ == "__main__": + unittest.main()