Skip to content

Commit 4c5a18a

Browse files
authored
Merge pull request #8 from comfuture/enhance-doc-meta
Enhance `Doc` in `Annotation` and implement its tests
2 parents 9f7659a + 9a283cf commit 4c5a18a

File tree

6 files changed

+123
-61
lines changed

6 files changed

+123
-61
lines changed

README.md

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ pip install function-schema
2020

2121
```python
2222
from typing import Annotated, Optional
23+
from function_schema import Doc
2324
import enum
2425

2526
def get_weather(
26-
city: Annotated[str, "The city to get the weather for"],
27+
city: Annotated[str, Doc("The city to get the weather for")],
2728
unit: Annotated[
2829
Optional[str],
29-
"The unit to return the temperature in",
30+
Doc("The unit to return the temperature in"),
3031
enum.Enum("Unit", "celcius fahrenheit")
3132
] = "celcius",
3233
) -> str:
@@ -37,10 +38,7 @@ def get_weather(
3738
Function description is taken from the docstring.
3839
Type hinting with `typing.Annotated` for annotate additional information about the parameters and return type.
3940

40-
- type can be `typing.Union`, `typing.Optional`. (`T | None` for python 3.10+)
41-
- string value of `Annotated` is used as a description
42-
- enum value of `Annotated` is used as an enum schema
43-
41+
Then you can generate a schema for this function:
4442
```python
4543
import json
4644
from function_schema import get_function_schema
@@ -89,20 +87,55 @@ schema = get_function_schema(get_weather, "claude")
8987

9088
Please refer to the [Claude tool use](https://docs.anthropic.com/claude/docs/tool-use) documentation for more information.
9189

92-
### Literal types can be used as Enum
90+
You can use any type hinting supported by python for the first argument of `Annotated`. including:
91+
`typing.Literal`, `typing.Optional`, `typing.Union`, and `T | None` for python 3.10+.
92+
`Doc` class or plain string in `Annotated` is used for describe the parameter.
93+
`Doc` metadata is the [PEP propose](https://peps.python.org/pep-0727/) for standardizing the metadata in type hints.
94+
currently, implemented in `typing-extensions` module. Also `function_schema.Doc` is provided for compatibility.
95+
96+
Enumeratable candidates can be defined with `enum.Enum` in the argument of `Annotated`.
97+
98+
```python
99+
import enum
100+
101+
class AnimalType(enum.Enum):
102+
dog = enum.auto()
103+
cat = enum.auto()
104+
105+
def get_animal(
106+
animal: Annotated[str, Doc("The animal to get"), AnimalType],
107+
) -> str:
108+
"""Returns the animal."""
109+
return f"Animal is {animal.value}"
110+
```
111+
In this example, each name of `AnimalType` enums(`dog`, `cat`) is used as an enum schema.
112+
In shorthand, you can use `typing.Literal` as the type will do the same thing.
113+
114+
```python
115+
def get_animal(
116+
animal: Annotated[Literal["dog", "cat"], Doc("The animal to get")],
117+
) -> str:
118+
"""Returns the animal."""
119+
return f"Animal is {animal}"
120+
```
121+
122+
123+
### Plain String in Annotated
124+
125+
The string value of `Annotated` is used as a description for convenience.
93126

94127
```python
95128
def get_weather(
96-
city: Annotated[str, "The city to get the weather for"],
97-
unit: Annotated[
98-
Optional[Literal["celcius", "fahrenheit"]], # <- Literal type represents Enum
99-
"The unit to return the temperature in",
100-
] = "celcius",
129+
city: Annotated[str, "The city to get the weather for"], # <- string value of Annotated is used as a description
130+
unit: Annotated[Optional[str], "The unit to return the temperature in"] = "celcius",
101131
) -> str:
102132
"""Returns the weather for the given city."""
103133
return f"Weather for {city} is 20°C"
104134
```
105-
The schema will be generated as the same as the previous example.
135+
136+
But this would create a predefined meaning for any plain string inside of `Annotated`,
137+
and any tool that was using plain strings in them for any other purpose, which is currently allowed, would now be invalid.
138+
Please refer to the [PEP 0727, Plain String in Annotated](https://peps.python.org/pep-0727/#plain-string-in-annotated) for more information.
106139

107140
### Usage with OpenAI API
108141

@@ -163,7 +196,7 @@ response = client.beta.tools.messages.create(
163196
### CLI usage
164197

165198
```sh
166-
function_schema mymodule.py my_function
199+
function_schema mymodule.py my_function | jq
167200
```
168201

169202
## License

function_schema/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
from .core import get_function_schema
55

6-
__version__ = "0.4.0"
6+
__version__ = "0.4.1"
77
__all__ = (
88
"get_function_schema",
99
"__version__",

function_schema/core.py

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import enum
2-
import typing
32
import inspect
43
import platform
54
import packaging.version
5+
from typing import Annotated, Optional, Union, Callable, Literal, Any, get_args, get_origin
66

77
current_version = packaging.version.parse(platform.python_version())
88
py_310 = packaging.version.parse("3.10")
99

1010
if current_version >= py_310:
1111
from types import UnionType
1212
else:
13-
UnionType = typing.Union # type: ignore
13+
UnionType = Union # type: ignore
1414

1515
try:
1616
from typing import Doc
@@ -22,29 +22,23 @@ class Doc:
2222
def __init__(self, documentation: str, /):
2323
self.documentation = documentation
2424

25-
__all__ = ("get_function_schema", "guess_type", "Doc")
25+
__all__ = ("get_function_schema", "guess_type", "Doc", "Annotated")
2626

27-
def is_doc_meta(obj):
27+
28+
def is_doc_meta(obj: Annotated[Any, Doc("The object to be checked.")]) -> Annotated[bool, Doc("True if the object is a documentation object, False otherwise.")]:
2829
"""
2930
Check if the given object is a documentation object.
30-
Parameters:
31-
obj (object): The object to be checked.
32-
Returns:
33-
bool: True if the object is a documentation object, False otherwise.
3431
3532
Example:
3633
>>> is_doc_meta(Doc("This is a documentation object"))
3734
True
3835
"""
3936
return getattr(obj, '__class__') == Doc and hasattr(obj, 'documentation')
4037

41-
def unwrap_doc(obj: typing.Union[Doc, str]):
38+
39+
def unwrap_doc(obj: Annotated[Union[Doc, str], Doc("The object to get the documentation string from.")]) -> Annotated[str, Doc("The documentation string.")]:
4240
"""
4341
Get the documentation string from the given object.
44-
Parameters:
45-
obj (Doc | str): The object to get the documentation string from.
46-
Returns:
47-
str: The documentation string.
4842
4943
Example:
5044
>>> unwrap_doc(Doc("This is a documentation object"))
@@ -58,12 +52,12 @@ def unwrap_doc(obj: typing.Union[Doc, str]):
5852

5953

6054
def get_function_schema(
61-
func: typing.Annotated[typing.Callable, "The function to get the schema for"],
62-
format: typing.Annotated[
63-
typing.Optional[typing.Literal["openai", "claude"]],
64-
"The format of the schema to return",
55+
func: Annotated[Callable, Doc("The function to get the schema for")],
56+
format: Annotated[
57+
Optional[Literal["openai", "claude"]],
58+
Doc("The format of the schema to return"),
6559
] = "openai",
66-
) -> typing.Annotated[dict[str, typing.Any], "The JSON schema for the given function"]:
60+
) -> Annotated[dict[str, Any], Doc("The JSON schema for the given function")]:
6761
"""
6862
Returns a JSON schema for the given function.
6963
@@ -76,10 +70,10 @@ def get_function_schema(
7670
>>> from typing import Annotated, Optional
7771
>>> import enum
7872
>>> def get_weather(
79-
... city: Annotated[str, "The city to get the weather for"],
73+
... city: Annotated[str, Doc("The city to get the weather for")],
8074
... unit: Annotated[
8175
... Optional[str],
82-
... "The unit to return the temperature in",
76+
... Doc("The unit to return the temperature in"),
8377
... enum.Enum("Unit", "celcius fahrenheit")
8478
... ] = "celcius",
8579
... ) -> str:
@@ -115,8 +109,8 @@ def get_function_schema(
115109
"required": [],
116110
}
117111
for name, param in params.items():
118-
param_args = typing.get_args(param.annotation)
119-
is_annotated = typing.get_origin(param.annotation) is typing.Annotated
112+
param_args = get_args(param.annotation)
113+
is_annotated = get_origin(param.annotation) is Annotated
120114

121115
enum_ = None
122116
default_value = inspect._empty
@@ -126,10 +120,17 @@ def get_function_schema(
126120
(T, *_) = param_args
127121

128122
# find description in param_args tuple
129-
description = next(
130-
(unwrap_doc(arg) for arg in param_args if isinstance(arg, (Doc, str))),
131-
f"The {name} parameter",
132-
)
123+
try:
124+
description = next(
125+
unwrap_doc(arg)
126+
for arg in param_args if isinstance(arg, Doc)
127+
)
128+
except StopIteration:
129+
try:
130+
description = next(
131+
arg for arg in param_args if isinstance(arg, str))
132+
except StopIteration:
133+
description = "The {name} parameter"
133134

134135
# find enum in param_args tuple
135136
enum_ = next(
@@ -139,13 +140,13 @@ def get_function_schema(
139140
if isinstance(arg, type) and issubclass(arg, enum.Enum)
140141
),
141142
# use typing.Literal as enum if no enum found
142-
typing.get_origin(T) is typing.Literal and typing.get_args(T) or None,
143+
get_origin(T) is Literal and get_args(T) or None,
143144
)
144145
else:
145146
T = param.annotation
146147
description = f"The {name} parameter"
147-
if typing.get_origin(T) is typing.Literal:
148-
enum_ = typing.get_args(T)
148+
if get_origin(T) is Literal:
149+
enum_ = get_args(T)
149150

150151
# find default value for param
151152
if param.default is not inspect._empty:
@@ -157,20 +158,21 @@ def get_function_schema(
157158
}
158159

159160
if enum_ is not None:
160-
schema["properties"][name]["enum"] = [t for t in enum_ if t is not None]
161+
schema["properties"][name]["enum"] = [
162+
t for t in enum_ if t is not None]
161163

162164
if default_value is not inspect._empty:
163165
schema["properties"][name]["default"] = default_value
164166

165167
if (
166-
typing.get_origin(T) is not typing.Literal
168+
get_origin(T) is not Literal
167169
and not isinstance(None, T)
168170
and default_value is inspect._empty
169171
):
170172
schema["required"].append(name)
171173

172-
if typing.get_origin(T) is typing.Literal:
173-
if all(typing.get_args(T)):
174+
if get_origin(T) is Literal:
175+
if all(get_args(T)):
174176
schema["required"].append(name)
175177

176178
parms_key = "input_schema" if format == "claude" else "parameters"
@@ -185,24 +187,25 @@ def get_function_schema(
185187

186188

187189
def guess_type(
188-
T: typing.Annotated[type, "The type to guess the JSON schema type for"],
189-
) -> typing.Annotated[
190-
typing.Union[str, list[str]], "str | list of str that representing JSON schema type"
190+
T: Annotated[type, Doc("The type to guess the JSON schema type for")],
191+
) -> Annotated[
192+
Union[str, list[str]], Doc(
193+
"str | list of str that representing JSON schema type")
191194
]:
192195
"""Guesses the JSON schema type for the given python type."""
193196

194197
# special case
195-
if T is typing.Any:
198+
if T is Any:
196199
return {}
197200

198-
origin = typing.get_origin(T)
201+
origin = get_origin(T)
199202

200-
if origin is typing.Annotated:
201-
return guess_type(typing.get_args(T)[0])
203+
if origin is Annotated:
204+
return guess_type(get_args(T)[0])
202205

203206
# hacking around typing modules, `typing.Union` and `types.UnitonType`
204-
if origin in [typing.Union, UnionType]:
205-
union_types = [t for t in typing.get_args(T) if t is not type(None)]
207+
if origin in [Union, UnionType]:
208+
union_types = [t for t in get_args(T) if t is not type(None)]
206209
_types = [
207210
guess_type(union_type)
208211
for union_type in union_types
@@ -217,8 +220,8 @@ def guess_type(
217220
return _types[0]
218221
return _types
219222

220-
if origin is typing.Literal:
221-
type_args = typing.Union[tuple(type(arg) for arg in typing.get_args(T))]
223+
if origin is Literal:
224+
type_args = Union[tuple(type(arg) for arg in get_args(T))]
222225
return guess_type(type_args)
223226
elif origin is list or origin is tuple:
224227
return "array"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "function-schema"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
requires-python = ">= 3.9"
55
description = "A small utility to generate JSON schemas for python functions."
66
readme = "README.md"

test/test_pep_0727_doc.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,29 @@ def func1(a: Annotated[str, Enum("Candidates", "a b c"), Doc("A string parameter
3131
assert schema["parameters"]["properties"]["a"]["description"] == "A string parameter", "parameter a should have a description"
3232
assert schema["parameters"]["properties"]["a"]["enum"] == [
3333
"a", "b", "c"], "parameter a should have enum values"
34+
35+
36+
def test_multiple_docs_in_annotation():
37+
"""Test a function with annotations with multiple Doc"""
38+
def func1(a: Annotated[int, Doc("An integer parameter"), Doc("A number")]):
39+
"""My function"""
40+
...
41+
42+
schema = get_function_schema(func1)
43+
assert schema["name"] == "func1", "Function name should be func1"
44+
assert schema["description"] == "My function", "Function description should be there"
45+
assert schema["parameters"]["properties"]["a"]["type"] == "number", "parameter a should be an integer"
46+
assert schema["parameters"]["properties"]["a"]["description"] == "An integer parameter", "first description should be used"
47+
48+
49+
def test_mixed_docs_in_annotation():
50+
"""Test a function with annotations with mixed Doc and strings"""
51+
def func1(a: Annotated[int, "An integer parameter", Doc("A number")]):
52+
"""My function"""
53+
...
54+
55+
schema = get_function_schema(func1)
56+
assert schema["name"] == "func1", "Function name should be func1"
57+
assert schema["description"] == "My function", "Function description should be there"
58+
assert schema["parameters"]["properties"]["a"]["type"] == "number", "parameter a should be an integer"
59+
assert schema["parameters"]["properties"]["a"]["description"] == "A number", "`Doc` should be used rather than string"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)