Skip to main content

polytools

PyPI Python CI License: MIT Zero dependencies

Cross-provider LLM tool schema generation from Python type hints.

Write your tool once. Export to OpenAI, Anthropic, Gemini, and MCP — no framework lock-in, no third-party dependencies.

from polytools import tool

@tool
def search_web(query: str, max_results: int = 5) -> list[str]:
    """Search the web and return relevant URLs.

    Args:
        query: The search query string.
        max_results: Maximum number of results to return.
    """
    ...

search_web.to_openai()     # → OpenAI function calling format
search_web.to_anthropic()  # → Anthropic tools format
search_web.to_gemini()     # → Gemini FunctionDeclaration format
search_web.to_mcp()        # → MCP JSON-RPC tool definition
search_web.to_all()        # → all four at once

Why polytools?

Every LLM provider uses a different JSON format for tool/function definitions. Today you either hand-write four separate schemas, lock into a framework that weighs megabytes, or wrestle Pydantic into every project.

polytools is a single decorator — pure Python stdlib, zero external dependencies — that reads your type hints and docstring once and outputs whatever format you need.

Installation

pip install polytools

Requires Python 3.9+. No other dependencies, ever.

Quick Start

from polytools import tool
from typing import Optional, Literal

@tool
def send_email(
    recipient: str,
    subject: str,
    body: str,
    cc: Optional[str] = None,
    priority: Literal["low", "normal", "high"] = "normal",
) -> bool:
    """Send an email message.

    Args:
        recipient: Email address of the primary recipient.
        subject: Subject line of the email.
        body: Full text body of the email.
        cc: Optional CC email address.
        priority: Message priority level.
    """
    ...

OpenAI

import openai

client = openai.OpenAI()
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Send an email to alice@example.com"}],
    tools=[send_email.to_openai()],
)
Schema output
{
  "type": "function",
  "function": {
    "name": "send_email",
    "description": "Send an email message.",
    "parameters": {
      "type": "object",
      "properties": {
        "recipient": {"type": "string", "description": "Email address of the primary recipient."},
        "subject":   {"type": "string", "description": "Subject line of the email."},
        "body":      {"type": "string", "description": "Full text body of the email."},
        "cc":        {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Optional CC email address."},
        "priority":  {"type": "string", "enum": ["low", "normal", "high"], "description": "Message priority level."}
      },
      "required": ["recipient", "subject", "body"]
    }
  }
}

Anthropic

import anthropic

client = anthropic.Anthropic()
response = client.messages.create(
    model="claude-sonnet-5",
    messages=[{"role": "user", "content": "Send an email to alice@example.com"}],
    tools=[send_email.to_anthropic()],
)
Schema output
{
  "name": "send_email",
  "description": "Send an email message.",
  "input_schema": {
    "type": "object",
    "properties": {
      "recipient": {"type": "string", "description": "Email address of the primary recipient."},
      "subject":   {"type": "string", "description": "Subject line of the email."},
      "body":      {"type": "string", "description": "Full text body of the email."},
      "cc":        {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Optional CC email address."},
      "priority":  {"type": "string", "enum": ["low", "normal", "high"], "description": "Message priority level."}
    },
    "required": ["recipient", "subject", "body"]
  }
}

Gemini

import google.generativeai as genai

model = genai.GenerativeModel(
    model_name="gemini-1.5-pro",
    tools=[{"function_declarations": [send_email.to_gemini()]}],
)
Schema output
{
  "name": "send_email",
  "description": "Send an email message.",
  "parameters": {
    "type": "OBJECT",
    "properties": {
      "recipient": {"type": "STRING", "description": "Email address of the primary recipient."},
      "subject":   {"type": "STRING", "description": "Subject line of the email."},
      "body":      {"type": "STRING", "description": "Full text body of the email."},
      "cc":        {"type": "STRING", "description": "Optional CC email address."},
      "priority":  {"type": "STRING", "enum": ["low", "normal", "high"], "description": "Message priority level."}
    },
    "required": ["recipient", "subject", "body"]
  }
}

MCP

# In your MCP server's tools/list handler:
tools = [send_email.to_mcp()]
Schema output
{
  "name": "send_email",
  "description": "Send an email message.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "recipient": {"type": "string", "description": "Email address of the primary recipient."},
      "subject":   {"type": "string", "description": "Subject line of the email."},
      "body":      {"type": "string", "description": "Full text body of the email."},
      "cc":        {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Optional CC email address."},
      "priority":  {"type": "string", "enum": ["low", "normal", "high"], "description": "Message priority level."}
    },
    "required": ["recipient", "subject", "body"]
  }
}

Invoking from an LLM response

import json

# OpenAI
tool_call = response.choices[0].message.tool_calls[0]
result = send_email.call(json.loads(tool_call.function.arguments))

# Anthropic
block = response.content[0]  # ToolUseBlock
result = send_email.call(block.input)

Supported Type Annotations

Python type JSON Schema output
str {"type": "string"}
int {"type": "integer"}
float {"type": "number"}
bool {"type": "boolean"}
bytes {"type": "string", "format": "byte"}
None / type(None) {"type": "null"}
list / List[T] / list[T] {"type": "array", "items": {...}}
dict / Dict[K, V] / dict[K, V] {"type": "object", "additionalProperties": {...}}
tuple[T, ...] {"type": "array", "items": {...}}
tuple[T1, T2, T3] {"type": "array", "prefixItems": [...]}
set[T] / frozenset[T] {"type": "array", "uniqueItems": true, "items": {...}}
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).

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)
{
  "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:

Google (recommended)

def f(x: int, y: str = "hello") -> bool:
    """Summary line.

    Args:
        x: Description of x.
        y: Description of y.
    """

NumPy

def f(x: int, y: str = "hello") -> bool:
    """Summary line.

    Parameters
    ----------
    x : int
        Description of x.
    y : str, optional
        Description of y.
    """

reStructuredText

def f(x: int, y: str = "hello") -> bool:
    """Summary line.

    :param x: Description of x.
    :param y: Description of y.
    """

API Reference

@tool

Decorator that wraps a Python function and exposes provider schema methods. Can be used with or without parentheses:

@tool
def my_func(...): ...

@tool()
def my_func(...): ...

Tool methods

Method Returns Description
.to_openai() dict OpenAI Chat Completions tools list entry
.to_anthropic() dict Anthropic Messages API tools list entry
.to_gemini() dict Gemini function_declarations list entry
.to_mcp() dict MCP tools/list response entry
.to_all() dict[str, dict] All four, keyed by provider name
.call(args: dict) Any Invoke the function with an LLM argument dict

Tool attributes

Attribute Type Description
__wrapped__ Callable The original unwrapped function
__name__ str Preserved from the original function
__doc__ str Preserved from the original function

Notes on Gemini

Gemini's API does not support anyOf in tool schemas. Optional[T] parameters are handled by omitting them from the required list rather than encoding nullability in the schema type. Union types are resolved to the first non-null type.

Contributing

See CONTRIBUTING.md.

License

MIT — see LICENSE.


Part of the aenealabs AI agent toolkit.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

polytools-0.2.1.tar.gz (29.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

polytools-0.2.1-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

Details for the file polytools-0.2.1.tar.gz.

File metadata

  • Download URL: polytools-0.2.1.tar.gz
  • Upload date:
  • Size: 29.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for polytools-0.2.1.tar.gz
Algorithm Hash digest
SHA256 7ebdc2d31a714365c6afef1d6780b2a27c243c5d8e9b18f330762901691d54b9
MD5 0483e701c9fc6a4b9a88dae8f3a39965
BLAKE2b-256 c8fcbcde289fd091dab62d30603022b5286ccfe59f6bc0064fdba9e133b9c739

See more details on using hashes here.

File details

Details for the file polytools-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: polytools-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 22.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for polytools-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a51ac0d8ea7f1f7d104c024f2ed6c7d5a967d9968b3f7e5b72ac59a8a3b990be
MD5 187d871cac1a09d6ce50eec2b9ae7684
BLAKE2b-256 855f5745866eda16908105577a291cc6b24c4bdb2ac4e1a098a923c72464ac0a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page