Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <value 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()
```

<details>
<summary>Schema output (parameters)</summary>

```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"]
}
```
</details>

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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/polytools/_providers/_gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 133 additions & 11 deletions src/polytools/_schema.py
Original file line number Diff line number Diff line change
@@ -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
---------------------
Expand All @@ -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: <value 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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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 {}

Expand All @@ -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
Expand All @@ -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

# ------------------------------------------------------------------
Expand All @@ -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

# ------------------------------------------------------------------
Expand All @@ -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
Expand All @@ -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
# ------------------------------------------------------------------
Expand Down
Loading
Loading