Skip to content

Commit c0d8217

Browse files
committed
fixes #92
1 parent 80e3f8c commit c0d8217

3 files changed

Lines changed: 124 additions & 39 deletions

File tree

nbs/01_funccall.ipynb

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -330,31 +330,65 @@
330330
"outputs": [],
331331
"source": [
332332
"#| export\n",
333-
"def _handle_type(t, defs):\n",
333+
"def handle_type(t, defs):\n",
334334
" \"Convert a type annotation to JSON Schema\"\n",
335+
" if t is empty: raise TypeError(\"Missing type annotation\")\n",
336+
" if t in (object, Any): raise TypeError(f\"Can't make a schema for {t!r}\")\n",
335337
" ot = ifnone(get_origin(t), t)\n",
336338
" if t is NoneType: return {'type': 'null'}\n",
337-
" if ot in (Union, UnionType): return {\"anyOf\": [_handle_type(arg, defs) for arg in get_args(t)]}\n",
339+
" if ot in (Union, UnionType): return union_schema(t, defs)\n",
338340
" if t in custom_types: return {'type': 'string', 'format': t.__name__}\n",
339341
" if ot is dict:\n",
340342
" args = get_args(t)\n",
341-
" return {'type': 'object', 'additionalProperties': _handle_type(args[1], defs)} if args else {'type': 'object'}\n",
343+
" return {'type': 'object', 'additionalProperties': handle_type(args[1], defs)} if args else {'type': 'object'}\n",
342344
" if ot is tuple:\n",
343345
" args = get_args(t)\n",
344346
" if not args: return {'type': 'array', 'items': {}}\n",
345-
" if args[-1] is Ellipsis: return {'type': 'array', 'items': _handle_type(args[0], defs)}\n",
346-
" prefix = [_handle_type(a, defs) for a in args]\n",
347+
" if args[-1] is Ellipsis: return {'type': 'array', 'items': handle_type(args[0], defs)}\n",
348+
" prefix = [handle_type(a, defs) for a in args]\n",
347349
" items = prefix[0] if all(p == prefix[0] for p in prefix) else {'anyOf': prefix}\n",
348350
" return {'type': 'array', 'prefixItems': prefix, 'items': items, 'minItems': len(args), 'maxItems': len(args)}\n",
349351
" if ot in (list, set):\n",
350352
" args = get_args(t)\n",
351-
" schema = {'type': 'array', 'items': _handle_type(args[0], defs) if args else {'type': 'string'}}\n",
353+
" schema = {'type': 'array', 'items': handle_type(args[0], defs) if args else {'type': 'string'}}\n",
352354
" if ot is set: schema['uniqueItems'] = True\n",
353355
" return schema\n",
354356
" if isinstance(t, type) and not issubclass(t, (int, float, str, bool)) and t.__module__ != 'builtins' or inspect.isfunction(t):\n",
355357
" defs[t.__name__] = _get_nested_schema(t)\n",
356358
" return {'$ref': f'#/$defs/{t.__name__}'}\n",
357-
" return {'type': _type_str(t)}"
359+
" return {'type': _type_str(t)}\n",
360+
"\n",
361+
"def _schemable(t, defs=None):\n",
362+
" \"JSON Schema for type `t`, or None if `t` has no schema representation\"\n",
363+
" try: return handle_type(t, defs)\n",
364+
" except TypeError: return None\n",
365+
"\n",
366+
"def union_schema(t, defs=None):\n",
367+
" \"Schema for union type `t`: its schema-representable members, unwrapped if only one, `anyOf` otherwise\"\n",
368+
" args = list(filter(None, (_schemable(a, defs) for a in get_args(t))))\n",
369+
" if not args: raise TypeError(f\"No schema-representable member in {t!r}\")\n",
370+
" return args[0] if len(args)==1 else {\"anyOf\": args}"
371+
]
372+
},
373+
{
374+
"cell_type": "markdown",
375+
"id": "c320ab26",
376+
"metadata": {},
377+
"source": [
378+
"Not every Python type has a JSON Schema representation: bare `object` and `Any` say nothing a model could act on, and a missing annotation is treated the same way; `handle_type` raises `TypeError` for them all. `union_schema` builds a union's schema from just its schema-representable members, unwrapping when only one remains, so an annotation like `str|object` (meaning \"pass a string; other objects accepted at runtime\") collapses to a plain string schema:\n"
379+
]
380+
},
381+
{
382+
"cell_type": "code",
383+
"execution_count": null,
384+
"id": "7999acf7",
385+
"metadata": {},
386+
"outputs": [],
387+
"source": [
388+
"test_eq(union_schema(str|object), {'type': 'string'})\n",
389+
"test_eq(union_schema(int|str), {'anyOf': [{'type': 'integer'}, {'type': 'string'}]})\n",
390+
"test_fail(lambda: union_schema(object|Any), contains=\"No schema-representable member\")\n",
391+
"test_is(_schemable(object), None)\n"
358392
]
359393
},
360394
{
@@ -375,7 +409,7 @@
375409
}
376410
],
377411
"source": [
378-
"_handle_type(int, None), _handle_type(Path, None)"
412+
"handle_type(int, None), handle_type(Path, None)"
379413
]
380414
},
381415
{
@@ -411,7 +445,28 @@
411445
],
412446
"source": [
413447
"# gemini expect `items` to be defined for arrays\n",
414-
"_handle_type(list, None), _handle_type(tuple[str], None), _handle_type(set[str], None)"
448+
"handle_type(list, None), handle_type(tuple[str], None), handle_type(set[str], None)"
449+
]
450+
},
451+
{
452+
"cell_type": "markdown",
453+
"id": "9cae3817",
454+
"metadata": {},
455+
"source": [
456+
"Tool parameters must be fully annotated: a missing annotation, or a type with no schema representation (bare `object` or `Any`), raises rather than producing a junk schema. Unions are filtered to just their schema-representable members, so `str|object` means \"a string, but any object accepted at runtime\", and collapses to a plain string schema. A union with no representable member raises:"
457+
]
458+
},
459+
{
460+
"cell_type": "code",
461+
"execution_count": null,
462+
"id": "dc4231d0",
463+
"metadata": {},
464+
"outputs": [],
465+
"source": [
466+
"test_fail(lambda: handle_type(object, None), contains=\"Can't make a schema\")\n",
467+
"test_eq(handle_type(str|object, None), {'type': 'string'})\n",
468+
"test_eq(handle_type(int|str, None), {'anyOf': [{'type': 'integer'}, {'type': 'string'}]})\n",
469+
"test_fail(lambda: handle_type(object|Any, None), contains=\"No schema-representable member\")"
415470
]
416471
},
417472
{
@@ -433,7 +488,7 @@
433488
}
434489
],
435490
"source": [
436-
"_handle_type(dict, None), _handle_type(dict[str,str], None)"
491+
"handle_type(dict, None), handle_type(dict[str,str], None)"
437492
]
438493
},
439494
{
@@ -501,8 +556,8 @@
501556
"metadata": {},
502557
"outputs": [],
503558
"source": [
504-
"test_eq(_handle_type(list, {}), {'type': 'array', 'items': {'type': 'string'}})\n",
505-
"test_eq(_handle_type(set, {}), {'type': 'array', 'items': {'type': 'string'}, 'uniqueItems': True})"
559+
"test_eq(handle_type(list, {}), {'type': 'array', 'items': {'type': 'string'}})\n",
560+
"test_eq(handle_type(set, {}), {'type': 'array', 'items': {'type': 'string'}, 'uniqueItems': True})"
506561
]
507562
},
508563
{
@@ -518,7 +573,8 @@
518573
" p = _param(obj, evalable=evalable)\n",
519574
" props[name] = p\n",
520575
" if obj.default is empty: req[name] = True\n",
521-
" p.update(_handle_type(obj.anno, defs))\n",
576+
" try: p.update(handle_type(obj.anno, defs))\n",
577+
" except TypeError as e: raise TypeError(f\"Parameter {name!r}: {e}\") from None\n",
522578
" if 'anyOf' in p: p.pop('type', None)"
523579
]
524580
},
@@ -556,23 +612,23 @@
556612
"source": [
557613
"# Test primitive types\n",
558614
"defs = {}\n",
559-
"assert _handle_type(int, defs) == {'type': 'integer'}\n",
560-
"assert _handle_type(str, defs) == {'type': 'string'}\n",
561-
"assert _handle_type(bool, defs) == {'type': 'boolean'}\n",
562-
"assert _handle_type(float, defs) == {'type': 'number'}\n",
615+
"assert handle_type(int, defs) == {'type': 'integer'}\n",
616+
"assert handle_type(str, defs) == {'type': 'string'}\n",
617+
"assert handle_type(bool, defs) == {'type': 'boolean'}\n",
618+
"assert handle_type(float, defs) == {'type': 'number'}\n",
563619
"\n",
564620
"# Test custom class\n",
565621
"class TestClass:\n",
566622
" def __init__(self, x: int, y: int): store_attr()\n",
567623
"\n",
568-
"result = _handle_type(TestClass, defs)\n",
624+
"result = handle_type(TestClass, defs)\n",
569625
"assert result == {'$ref': '#/$defs/TestClass'}\n",
570626
"assert 'TestClass' in defs\n",
571627
"assert defs['TestClass']['type'] == 'object'\n",
572628
"assert 'properties' in defs['TestClass']\n",
573629
"\n",
574630
"# tuple[int, ...] should produce array with items, not prefixItems\n",
575-
"test_eq(_handle_type(tuple[int, ...], {}), {'type': 'array', 'items': {'type': 'integer'}})"
631+
"test_eq(handle_type(tuple[int, ...], {}), {'type': 'array', 'items': {'type': 'integer'}})"
576632
]
577633
},
578634
{
@@ -583,10 +639,10 @@
583639
"outputs": [],
584640
"source": [
585641
"# Test primitive types in containers\n",
586-
"test_eq(_handle_type(list[int], defs), {'type': 'array', 'items': {'type': 'integer'}})\n",
587-
"test_eq(_handle_type(tuple[str], defs), {'type': 'array', 'prefixItems': [{'type': 'string'}], 'items': {'type': 'string'}, 'minItems': 1, 'maxItems': 1})\n",
588-
"test_eq(_handle_type(set[str], defs), dict(type='array', items={'type': 'string'}, uniqueItems=True))\n",
589-
"test_eq(_handle_type(dict[str,bool], defs), {'type': 'object', 'additionalProperties': {'type': 'boolean'}})"
642+
"test_eq(handle_type(list[int], defs), {'type': 'array', 'items': {'type': 'integer'}})\n",
643+
"test_eq(handle_type(tuple[str], defs), {'type': 'array', 'prefixItems': [{'type': 'string'}], 'items': {'type': 'string'}, 'minItems': 1, 'maxItems': 1})\n",
644+
"test_eq(handle_type(set[str], defs), dict(type='array', items={'type': 'string'}, uniqueItems=True))\n",
645+
"test_eq(handle_type(dict[str,bool], defs), {'type': 'object', 'additionalProperties': {'type': 'boolean'}})"
590646
]
591647
},
592648
{
@@ -596,13 +652,13 @@
596652
"metadata": {},
597653
"outputs": [],
598654
"source": [
599-
"result = _handle_type(list[TestClass], defs)\n",
655+
"result = handle_type(list[TestClass], defs)\n",
600656
"assert result == {'type': 'array', 'items': {'$ref': '#/$defs/TestClass'}}\n",
601657
"assert 'TestClass' in defs\n",
602658
"\n",
603659
"# Test complex nested structure\n",
604660
"ComplexType = dict[str, list[TestClass]]\n",
605-
"result = _handle_type(dict[str, list[TestClass]], defs)\n",
661+
"result = handle_type(dict[str, list[TestClass]], defs)\n",
606662
"assert result == {'type': 'object', 'additionalProperties': {'type': 'array', 'items': {'$ref': '#/$defs/TestClass'}}}"
607663
]
608664
},
@@ -726,10 +782,10 @@
726782
"outputs": [],
727783
"source": [
728784
"def f(\n",
729-
" o:object, # the o\n",
785+
" o:dict, # the o\n",
730786
" q:tuple[int,str],\n",
731787
" p:str|list[str] = 'a',\n",
732-
"): \"object function\""
788+
"): \"dict function\""
733789
]
734790
},
735791
{
@@ -773,6 +829,19 @@
773829
"s"
774830
]
775831
},
832+
{
833+
"cell_type": "code",
834+
"execution_count": null,
835+
"id": "6037a5ed",
836+
"metadata": {},
837+
"outputs": [],
838+
"source": [
839+
"def _noanno(x, y:int=0):\n",
840+
" \"Docs\"\n",
841+
" return x\n",
842+
"test_fail(lambda: get_schema(_noanno), contains=\"Parameter 'x': Missing type annotation\")"
843+
]
844+
},
776845
{
777846
"cell_type": "code",
778847
"execution_count": null,
@@ -1086,7 +1155,7 @@
10861155
" \"A conversation between two speakers\"\n",
10871156
" def __init__(\n",
10881157
" self,\n",
1089-
" turns:dict[str,object], # dictionary of topics and the Turns of the conversation\n",
1158+
" turns:dict[str,Turn], # dictionary of topics and the Turns of the conversation\n",
10901159
" ): store_attr()\n",
10911160
"\n",
10921161
"get_schema(DictConversation)"

toolslm/_modidx.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,27 @@
1818
'toolslm.funccall._ann_outer': ('funccall.html#_ann_outer', 'toolslm/funccall.py'),
1919
'toolslm.funccall._copy_loc': ('funccall.html#_copy_loc', 'toolslm/funccall.py'),
2020
'toolslm.funccall._get_nested_schema': ('funccall.html#_get_nested_schema', 'toolslm/funccall.py'),
21-
'toolslm.funccall._handle_type': ('funccall.html#_handle_type', 'toolslm/funccall.py'),
2221
'toolslm.funccall._nm_map': ('funccall.html#_nm_map', 'toolslm/funccall.py'),
2322
'toolslm.funccall._norm_nm': ('funccall.html#_norm_nm', 'toolslm/funccall.py'),
2423
'toolslm.funccall._param': ('funccall.html#_param', 'toolslm/funccall.py'),
2524
'toolslm.funccall._process_property': ('funccall.html#_process_property', 'toolslm/funccall.py'),
2625
'toolslm.funccall._py_nm': ('funccall.html#_py_nm', 'toolslm/funccall.py'),
2726
'toolslm.funccall._run': ('funccall.html#_run', 'toolslm/funccall.py'),
27+
'toolslm.funccall._schemable': ('funccall.html#_schemable', 'toolslm/funccall.py'),
2828
'toolslm.funccall._type_str': ('funccall.html#_type_str', 'toolslm/funccall.py'),
2929
'toolslm.funccall.call_func': ('funccall.html#call_func', 'toolslm/funccall.py'),
3030
'toolslm.funccall.call_func_async': ('funccall.html#call_func_async', 'toolslm/funccall.py'),
3131
'toolslm.funccall.coerce_inputs': ('funccall.html#coerce_inputs', 'toolslm/funccall.py'),
3232
'toolslm.funccall.get_schema': ('funccall.html#get_schema', 'toolslm/funccall.py'),
3333
'toolslm.funccall.get_schema_nm': ('funccall.html#get_schema_nm', 'toolslm/funccall.py'),
34+
'toolslm.funccall.handle_type': ('funccall.html#handle_type', 'toolslm/funccall.py'),
3435
'toolslm.funccall.minipy': ('funccall.html#minipy', 'toolslm/funccall.py'),
3536
'toolslm.funccall.mk_ns': ('funccall.html#mk_ns', 'toolslm/funccall.py'),
3637
'toolslm.funccall.mk_param': ('funccall.html#mk_param', 'toolslm/funccall.py'),
3738
'toolslm.funccall.mk_tool': ('funccall.html#mk_tool', 'toolslm/funccall.py'),
3839
'toolslm.funccall.resolve_nm': ('funccall.html#resolve_nm', 'toolslm/funccall.py'),
39-
'toolslm.funccall.schema2sig': ('funccall.html#schema2sig', 'toolslm/funccall.py')},
40+
'toolslm.funccall.schema2sig': ('funccall.html#schema2sig', 'toolslm/funccall.py'),
41+
'toolslm.funccall.union_schema': ('funccall.html#union_schema', 'toolslm/funccall.py')},
4042
'toolslm.inspecttools': { 'toolslm.inspecttools.SymbolNotFound': ( 'inspecttools.html#symbolnotfound',
4143
'toolslm/inspecttools.py'),
4244
'toolslm.inspecttools.SymbolNotFound.__repr__': ( 'inspecttools.html#symbolnotfound.__repr__',

toolslm/funccall.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_funccall.ipynb.
44

55
# %% auto #0
6-
__all__ = ['empty', 'custom_types', 'type_map', 'get_schema', 'minipy', 'mk_ns', 'coerce_inputs', 'resolve_nm', 'get_schema_nm',
7-
'call_func', 'call_func_async', 'mk_param', 'schema2sig', 'mk_tool']
6+
__all__ = ['empty', 'custom_types', 'type_map', 'handle_type', 'union_schema', 'get_schema', 'minipy', 'mk_ns', 'coerce_inputs',
7+
'resolve_nm', 'get_schema_nm', 'call_func', 'call_func_async', 'mk_param', 'schema2sig', 'mk_tool']
88

99
# %% ../nbs/01_funccall.ipynb #e5ad6b86
1010
import inspect, ast, keyword
@@ -47,39 +47,53 @@ def _type_str(t):
4747
return base
4848

4949
# %% ../nbs/01_funccall.ipynb #c141588b
50-
def _handle_type(t, defs):
50+
def handle_type(t, defs):
5151
"Convert a type annotation to JSON Schema"
52+
if t is empty: raise TypeError("Missing type annotation")
53+
if t in (object, Any): raise TypeError(f"Can't make a schema for {t!r}")
5254
ot = ifnone(get_origin(t), t)
5355
if t is NoneType: return {'type': 'null'}
54-
if ot in (Union, UnionType): return {"anyOf": [_handle_type(arg, defs) for arg in get_args(t)]}
56+
if ot in (Union, UnionType): return union_schema(t, defs)
5557
if t in custom_types: return {'type': 'string', 'format': t.__name__}
5658
if ot is dict:
5759
args = get_args(t)
58-
return {'type': 'object', 'additionalProperties': _handle_type(args[1], defs)} if args else {'type': 'object'}
60+
return {'type': 'object', 'additionalProperties': handle_type(args[1], defs)} if args else {'type': 'object'}
5961
if ot is tuple:
6062
args = get_args(t)
6163
if not args: return {'type': 'array', 'items': {}}
62-
if args[-1] is Ellipsis: return {'type': 'array', 'items': _handle_type(args[0], defs)}
63-
prefix = [_handle_type(a, defs) for a in args]
64+
if args[-1] is Ellipsis: return {'type': 'array', 'items': handle_type(args[0], defs)}
65+
prefix = [handle_type(a, defs) for a in args]
6466
items = prefix[0] if all(p == prefix[0] for p in prefix) else {'anyOf': prefix}
6567
return {'type': 'array', 'prefixItems': prefix, 'items': items, 'minItems': len(args), 'maxItems': len(args)}
6668
if ot in (list, set):
6769
args = get_args(t)
68-
schema = {'type': 'array', 'items': _handle_type(args[0], defs) if args else {'type': 'string'}}
70+
schema = {'type': 'array', 'items': handle_type(args[0], defs) if args else {'type': 'string'}}
6971
if ot is set: schema['uniqueItems'] = True
7072
return schema
7173
if isinstance(t, type) and not issubclass(t, (int, float, str, bool)) and t.__module__ != 'builtins' or inspect.isfunction(t):
7274
defs[t.__name__] = _get_nested_schema(t)
7375
return {'$ref': f'#/$defs/{t.__name__}'}
7476
return {'type': _type_str(t)}
7577

78+
def _schemable(t, defs=None):
79+
"JSON Schema for type `t`, or None if `t` has no schema representation"
80+
try: return handle_type(t, defs)
81+
except TypeError: return None
82+
83+
def union_schema(t, defs=None):
84+
"Schema for union type `t`: its schema-representable members, unwrapped if only one, `anyOf` otherwise"
85+
args = list(filter(None, (_schemable(a, defs) for a in get_args(t))))
86+
if not args: raise TypeError(f"No schema-representable member in {t!r}")
87+
return args[0] if len(args)==1 else {"anyOf": args}
88+
7689
# %% ../nbs/01_funccall.ipynb #e0840bf5
7790
def _process_property(name, obj, props, req, defs, evalable=False):
7891
"Process a single property of the schema"
7992
p = _param(obj, evalable=evalable)
8093
props[name] = p
8194
if obj.default is empty: req[name] = True
82-
p.update(_handle_type(obj.anno, defs))
95+
try: p.update(handle_type(obj.anno, defs))
96+
except TypeError as e: raise TypeError(f"Parameter {name!r}: {e}") from None
8397
if 'anyOf' in p: p.pop('type', None)
8498

8599
# %% ../nbs/01_funccall.ipynb #38b0f97e

0 commit comments

Comments
 (0)