coloph-toolset 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """Python tool declarations and canonical argument validation."""
2
+
3
+ from ._decorator import (
4
+ DeclarationError,
5
+ Tool,
6
+ ToolParam,
7
+ annotation_with_description,
8
+ signature_with_tool_params,
9
+ tool,
10
+ tool_for,
11
+ )
12
+
13
+ __all__ = [
14
+ "DeclarationError",
15
+ "Tool",
16
+ "ToolParam",
17
+ "annotation_with_description",
18
+ "signature_with_tool_params",
19
+ "tool",
20
+ "tool_for",
21
+ ]
@@ -0,0 +1,66 @@
1
+ """Schema-backed argument contract for tool declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from copy import deepcopy
7
+ from typing import Any
8
+
9
+ from ._decorator import Tool, signature_with_tool_params
10
+
11
+
12
+ def tool_argument_model(
13
+ tool: Tool,
14
+ *,
15
+ include_hidden: bool = False,
16
+ name: str | None = None,
17
+ descriptions: dict[str, str] | None = None,
18
+ ) -> Any:
19
+ """Build the argument contract from one canonical tool signature."""
20
+ from pydantic import ConfigDict, Field, TypeAdapter, ValidationError, create_model
21
+ from pydantic.errors import PydanticInvalidForJsonSchema, PydanticUserError
22
+ from pydantic_core import SchemaError
23
+
24
+ params = tool.params
25
+ hidden = () if include_hidden else tool.model_hidden_args
26
+ public_names = {param.name for param in params if param.name not in hidden}
27
+ tool_params = {param.name: param for param in params}
28
+ overrides = descriptions or {}
29
+ unknown = set(overrides) - set(tool_params)
30
+ if unknown or any(not isinstance(value, str) for value in overrides.values()):
31
+ raise TypeError(f"invalid description overrides: {sorted(unknown)}")
32
+ fields: dict[str, Any] = {}
33
+ try:
34
+ for parameter in list(signature_with_tool_params(tool).parameters.values())[1:]:
35
+ tool_param = tool_params[parameter.name]
36
+ default = ... if parameter.default is inspect.Parameter.empty else deepcopy(parameter.default)
37
+ if default is not ...:
38
+ TypeAdapter(tool_param.annotation).validate_python(deepcopy(default))
39
+ if parameter.name not in public_names:
40
+ continue
41
+ fields[parameter.name] = (
42
+ tool_param.annotation,
43
+ Field(
44
+ default=default,
45
+ description=overrides.get(parameter.name, tool_param.description),
46
+ ),
47
+ )
48
+ model = create_model(
49
+ name or f"{tool.fn.__name__}_arguments",
50
+ __config__=ConfigDict(
51
+ extra="forbid",
52
+ validate_default=True,
53
+ protected_namespaces=(),
54
+ revalidate_instances="always",
55
+ ),
56
+ **fields,
57
+ )
58
+ model.model_json_schema()
59
+ return model
60
+ except (
61
+ ValidationError,
62
+ SchemaError,
63
+ PydanticUserError,
64
+ PydanticInvalidForJsonSchema,
65
+ ) as exc:
66
+ raise TypeError(f"{tool.fn.__qualname__}: invalid argument contract: {exc}") from exc
@@ -0,0 +1,543 @@
1
+ """The `@tool` decorator + its lazy introspection model.
2
+
3
+ A `@tool`-decorated function carries a `Tool` description on `fn.tool`. The
4
+ function signature + per-parameter annotations + docstring are the single
5
+ source of truth. Introspection is LAZY and PER-PARAMETER so that return
6
+ annotations (which may reference TYPE_CHECKING-only names) are never evaluated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import __future__
11
+
12
+ import inspect
13
+ import sys
14
+ import types
15
+ import typing
16
+ from dataclasses import dataclass
17
+ from functools import cached_property
18
+ from typing import Annotated, Any, Callable, Literal, Union, get_args, get_origin
19
+
20
+ _MISSING = inspect.Parameter.empty
21
+ _NONE_TYPE = type(None)
22
+
23
+ _SCALAR_TYPES = (bool, int, float, str)
24
+
25
+ DeclarationError = TypeError
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ToolParam:
30
+ """One named parameter of a tool function (excluding `ctx`)."""
31
+
32
+ name: str
33
+ annotation: Any
34
+ type: type # bool/int/float/str base scalar
35
+ default: Any # _MISSING if required
36
+ description: str
37
+ choices: tuple[Any, ...] | None # all-str or all-int, from Literal[...]
38
+ repeated: bool # list[T] params -> repeatable flag
39
+ nullable: bool
40
+ metadata: tuple[Any, ...]
41
+
42
+ @property
43
+ def required(self) -> bool:
44
+ return self.default is _MISSING
45
+
46
+
47
+ @dataclass
48
+ class Tool:
49
+ """A tool description constructed by the `@tool` decorator."""
50
+
51
+ fn: Callable[..., Any]
52
+ model_hidden_args: tuple[str, ...] = ()
53
+ metadata_types: tuple[type, ...] = ()
54
+
55
+ def __post_init__(self) -> None:
56
+ original = inspect.unwrap(self.fn)
57
+ if not inspect.isfunction(original):
58
+ raise TypeError("a tool must be a Python function")
59
+ if any(
60
+ predicate(function)
61
+ for predicate in (
62
+ inspect.iscoroutinefunction,
63
+ inspect.isasyncgenfunction,
64
+ inspect.isgeneratorfunction,
65
+ )
66
+ for function in (self.fn, original)
67
+ ):
68
+ raise TypeError("async and generator tools are not supported in this release")
69
+ if any(not isinstance(name, str) for name in self.model_hidden_args):
70
+ raise TypeError("model_hidden_args must contain parameter names")
71
+ if len(set(self.model_hidden_args)) != len(self.model_hidden_args):
72
+ raise TypeError("model_hidden_args must not contain duplicates")
73
+ if any(not isinstance(marker, type) for marker in self.metadata_types):
74
+ raise TypeError("metadata_types must contain marker types")
75
+ _validate_signature(self)
76
+
77
+ @cached_property
78
+ def params(self) -> tuple[ToolParam, ...]:
79
+ return _compute_params(self)
80
+
81
+ @cached_property
82
+ def summary(self) -> str:
83
+ doc = inspect.getdoc(self.fn) or ""
84
+ return doc.split("\n", 1)[0].strip()
85
+
86
+ @cached_property
87
+ def description(self) -> str:
88
+ return (inspect.getdoc(self.fn) or "").strip()
89
+
90
+ def argument_model(
91
+ self,
92
+ *,
93
+ include_hidden: bool = False,
94
+ name: str | None = None,
95
+ descriptions: dict[str, str] | None = None,
96
+ ) -> Any:
97
+ from ._argument_model import tool_argument_model
98
+
99
+ return tool_argument_model(
100
+ self,
101
+ include_hidden=include_hidden,
102
+ name=name,
103
+ descriptions=descriptions,
104
+ )
105
+
106
+ def validate_arguments(self, arguments: object, *, include_hidden: bool = False) -> dict[str, Any]:
107
+ model = self.argument_model(include_hidden=include_hidden)
108
+ parsed = model.model_validate(arguments)
109
+ result: dict[str, Any] = parsed.model_dump()
110
+ return result
111
+
112
+ def json_schema(self, *, include_hidden: bool = False) -> dict[str, Any]:
113
+ schema: dict[str, Any] = self.argument_model(include_hidden=include_hidden).model_json_schema()
114
+ return schema
115
+
116
+
117
+ def annotation_with_description(fn: Callable[..., Any], p: inspect.Parameter, description: str) -> Any:
118
+ """Return `p.annotation` with its user-facing description localized."""
119
+ resolved = _resolve_annotation(fn, p)
120
+ if not description:
121
+ return resolved
122
+ return _replace_annotation_description(resolved, description)
123
+
124
+
125
+ def tool(
126
+ *,
127
+ model_hidden_args: tuple[str, ...] = (),
128
+ metadata_types: tuple[type, ...] = (),
129
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
130
+ """Attach a `Tool` description to `fn` as `fn.tool`.
131
+
132
+ `model_hidden_args` keeps operator-only parameters in the CLI/API contract
133
+ while omitting them from native model schemas; the tool body must provide a
134
+ safe default or runtime resolver for each hidden argument.
135
+ `metadata_types` names application-owned `Annotated` markers that are
136
+ retained for adapters but excluded from validation and JSON Schema.
137
+ """
138
+
139
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
140
+ if hasattr(fn, "tool"):
141
+ raise TypeError(f"{fn.__qualname__}: function already has a tool declaration")
142
+ setattr(fn, "tool", Tool(fn, tuple(model_hidden_args), tuple(metadata_types)))
143
+ return fn
144
+
145
+ return decorator
146
+
147
+
148
+ def tool_for(fn: Callable[..., Any]) -> Tool:
149
+ """Return the `Tool` attached to `fn` by `@tool`, failing loudly otherwise."""
150
+ attached = getattr(fn, "tool", None)
151
+ if not isinstance(attached, Tool):
152
+ raise TypeError("{function} is not a @tool-decorated function".format(function=fn.__qualname__))
153
+ return attached
154
+
155
+
156
+ def signature_with_tool_params(tool: Tool) -> inspect.Signature:
157
+ """Return the callable signature with localized tool parameters."""
158
+ sig = _function_signature(tool.fn)
159
+ existing = {param.name: param for param in sig.parameters.values()}
160
+ projected: list[inspect.Parameter] = []
161
+ for param in tool.params:
162
+ original = existing.get(param.name)
163
+ if original is None:
164
+ raise TypeError(
165
+ "no callable parameter backs synthetic tool parameter {parameter!r}".format(parameter=param.name)
166
+ )
167
+ projected.append(original.replace(annotation=annotation_with_description(tool.fn, original, param.description)))
168
+ context = list(sig.parameters.values())[:1]
169
+ if not context:
170
+ raise TypeError(
171
+ "@tool {function!r} must take a context parameter as its first argument".format(
172
+ function=tool.fn.__qualname__
173
+ )
174
+ )
175
+ return sig.replace(parameters=[*context, *projected])
176
+
177
+
178
+ # ----------------------------------------------------------------------
179
+ # Lazy per-parameter introspection
180
+ # ----------------------------------------------------------------------
181
+
182
+
183
+ def _function_signature(fn: Callable[..., Any]) -> inspect.Signature:
184
+ """Read parameters without evaluating unrelated deferred annotations."""
185
+ original = inspect.unwrap(fn)
186
+ string_annotations = original.__code__.co_flags & __future__.annotations.compiler_flag
187
+ if sys.version_info >= (3, 14) and not string_annotations and getattr(original, "__annotate__", None):
188
+ from annotationlib import Format
189
+
190
+ return inspect.signature(fn, annotation_format=Format.STRING)
191
+ return inspect.signature(fn)
192
+
193
+
194
+ def _validate_signature(tool: Tool) -> None:
195
+ sig_params = list(_function_signature(tool.fn).parameters.values())
196
+ if not sig_params:
197
+ raise TypeError(
198
+ "@tool {function!r} must take a context parameter as its first argument".format(
199
+ function=tool.fn.__qualname__
200
+ )
201
+ )
202
+ context = sig_params[0]
203
+ if context.name != "ctx":
204
+ raise TypeError(
205
+ "@tool {function!r} first parameter must be named 'ctx', got {actual!r}".format(
206
+ function=tool.fn.__qualname__, actual=context.name
207
+ )
208
+ )
209
+ if (
210
+ context.kind not in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
211
+ or context.default is not _MISSING
212
+ ):
213
+ raise TypeError("the injected context must be a required positional parameter")
214
+ by_name = {parameter.name: parameter for parameter in sig_params[1:]}
215
+ for parameter in by_name.values():
216
+ if parameter.kind not in (
217
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
218
+ inspect.Parameter.KEYWORD_ONLY,
219
+ ):
220
+ raise TypeError(
221
+ "@tool {function!r} param {param!r} must be positional-or-keyword or "
222
+ "keyword-only (no *args/**kwargs), got {kind}".format(
223
+ function=tool.fn.__qualname__,
224
+ param=parameter.name,
225
+ kind=parameter.kind.name,
226
+ )
227
+ )
228
+ for name in tool.model_hidden_args:
229
+ if name not in by_name:
230
+ raise TypeError(f"model_hidden_args names unknown parameter {name!r}")
231
+ if by_name[name].default is _MISSING:
232
+ raise TypeError(f"model_hidden_args names required parameter {name!r}")
233
+
234
+
235
+ def _compute_params(tool: Tool) -> tuple[ToolParam, ...]:
236
+ fn = tool.fn
237
+ sig = _function_signature(fn)
238
+ sig_params = list(sig.parameters.values())
239
+ if not sig_params:
240
+ raise TypeError(
241
+ "@tool {function!r} must take a context parameter as its first argument".format(function=fn.__qualname__)
242
+ )
243
+ if sig_params[0].name != "ctx":
244
+ raise TypeError(
245
+ "@tool {function!r} first parameter must be named 'ctx', got {actual!r}".format(
246
+ function=fn.__qualname__,
247
+ actual=sig_params[0].name,
248
+ )
249
+ )
250
+
251
+ params: list[ToolParam] = []
252
+ for p in sig_params[1:]:
253
+ if p.kind not in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
254
+ raise TypeError(
255
+ "@tool {function!r} param {param!r} must be positional-or-keyword or keyword-only "
256
+ "(no *args/**kwargs), got {kind}".format(
257
+ function=fn.__qualname__,
258
+ param=p.name,
259
+ kind=p.kind.name,
260
+ )
261
+ )
262
+ resolved = _resolve_annotation(fn, p)
263
+ base, choices, repeated, nullable, description, metadata = _describe_annotation(
264
+ fn, p.name, resolved, tool.metadata_types
265
+ )
266
+ params.append(
267
+ ToolParam(
268
+ name=p.name,
269
+ annotation=_validation_annotation(resolved, tool.metadata_types),
270
+ type=base,
271
+ default=p.default if p.default is not _MISSING else _MISSING,
272
+ description=description,
273
+ choices=choices,
274
+ repeated=repeated,
275
+ nullable=nullable,
276
+ metadata=metadata,
277
+ )
278
+ )
279
+ return tuple(params)
280
+
281
+
282
+ def _resolve_annotation(fn: Callable[..., Any], p: inspect.Parameter) -> Any:
283
+ ann = p.annotation
284
+ if ann is inspect.Parameter.empty:
285
+ raise TypeError(
286
+ "@tool {function!r} param {param!r} must be annotated".format(function=fn.__qualname__, param=p.name)
287
+ )
288
+ if not isinstance(ann, str):
289
+ return ann
290
+ # ``inspect.signature`` follows ``functools.wraps`` chains, so evaluate a
291
+ # preserved string annotation in the same original function's globals.
292
+ # A transport-neutral return adapter may live in another module.
293
+ annotation_owner = inspect.unwrap(fn)
294
+ eval_ns = {**vars(typing), **annotation_owner.__globals__}
295
+ try:
296
+ return eval(ann, eval_ns)
297
+ except (NameError, AttributeError, SyntaxError, TypeError, ValueError) as exc:
298
+ # EXPECTED_EXCEPTION: annotation eval failure re-raised as a clear TypeError naming tool+param.
299
+ raise TypeError(
300
+ "@tool {function!r} param {param!r}: could not evaluate annotation {annotation!r}: {error}".format(
301
+ function=fn.__qualname__,
302
+ param=p.name,
303
+ annotation=ann,
304
+ error=exc,
305
+ )
306
+ ) from exc
307
+
308
+
309
+ def _describe_annotation(
310
+ fn: Callable[..., Any],
311
+ param_name: str,
312
+ resolved: Any,
313
+ metadata_types: tuple[type, ...],
314
+ ) -> tuple[type, tuple[Any, ...] | None, bool, bool, str, tuple[Any, ...]]:
315
+ """Return the original Coloph parameter shape plus its generic metadata."""
316
+ state: dict[str, Any] = {"description": "", "metadata": []}
317
+
318
+ def consume(metadata: tuple[Any, ...]) -> None:
319
+ from pydantic.fields import FieldInfo
320
+
321
+ for meta in metadata:
322
+ if isinstance(meta, str):
323
+ if state["description"]:
324
+ raise TypeError(
325
+ "@tool {function!r} param {param!r}: duplicate description metadata".format(
326
+ function=fn.__qualname__,
327
+ param=param_name,
328
+ )
329
+ )
330
+ state["description"] = meta
331
+ elif isinstance(meta, FieldInfo):
332
+ if meta.description:
333
+ if state["description"]:
334
+ raise TypeError(
335
+ f"@tool {fn.__qualname__!r} param {param_name!r}: duplicate description metadata"
336
+ )
337
+ state["description"] = meta.description
338
+ _validate_constraint_metadata(fn, param_name, meta)
339
+ elif type(meta) in metadata_types:
340
+ state["metadata"].append(meta)
341
+ else:
342
+ _validate_constraint_metadata(fn, param_name, meta)
343
+
344
+ # Peel outer Annotated / Optional wrappers, accumulating metadata.
345
+ current = resolved
346
+ nullable = False
347
+ while True:
348
+ if hasattr(current, "__metadata__"):
349
+ consume(current.__metadata__)
350
+ current = get_args(current)[0]
351
+ continue
352
+ origin = get_origin(current)
353
+ if origin is Union or origin is types.UnionType:
354
+ non_none = [a for a in get_args(current) if a is not _NONE_TYPE]
355
+ if len(non_none) == 1:
356
+ nullable = True
357
+ current = non_none[0]
358
+ continue
359
+ raise TypeError(
360
+ "@tool {function!r} param {param!r}: ambiguous union annotation {annotation!r}".format(
361
+ function=fn.__qualname__,
362
+ param=param_name,
363
+ annotation=resolved,
364
+ )
365
+ )
366
+ break
367
+
368
+ repeated = False
369
+ choices: tuple[Any, ...] | None = None
370
+ origin = get_origin(current)
371
+ if origin is list:
372
+ repeated = True
373
+ element = get_args(current)[0]
374
+ if hasattr(element, "__metadata__"):
375
+ consume(element.__metadata__)
376
+ element = get_args(element)[0]
377
+ element_base = get_args(element)[0] if hasattr(element, "__metadata__") else element
378
+ if element_base not in (str, int):
379
+ raise TypeError(
380
+ "@tool {function!r} param {param!r}: list element must be str or int, got {element!r}".format(
381
+ function=fn.__qualname__,
382
+ param=param_name,
383
+ element=element_base,
384
+ )
385
+ )
386
+ base: type = element_base
387
+ elif get_origin(current) is Literal:
388
+ base, choices = _literal_base_and_choices(fn, param_name, get_args(current))
389
+ elif isinstance(current, type) and current in _SCALAR_TYPES:
390
+ base = current
391
+ else:
392
+ raise TypeError(
393
+ "@tool {function!r} param {param!r}: unsupported annotation {annotation!r} "
394
+ "(expected bool/int/float/str, Literal, or list[str|int])".format(
395
+ function=fn.__qualname__,
396
+ param=param_name,
397
+ annotation=resolved,
398
+ )
399
+ )
400
+
401
+ _validate_constraints_apply(fn, param_name, resolved, base, choices, repeated)
402
+ return base, choices, repeated, nullable, state["description"], tuple(state["metadata"])
403
+
404
+
405
+ def _validate_constraint_metadata(fn: Callable[..., Any], param_name: str, meta: Any) -> None:
406
+ from annotated_types import Ge, Gt, Le, Lt, MaxLen, MinLen, MultipleOf
407
+ from pydantic import Field, Strict
408
+ from pydantic.fields import FieldInfo
409
+
410
+ if isinstance(meta, (Ge, Gt, Le, Lt, MaxLen, MinLen, MultipleOf, Strict)):
411
+ return
412
+ if isinstance(meta, FieldInfo):
413
+ defaults = Field()
414
+ allowed = {"description", "title", "examples", "metadata"}
415
+ changed = {
416
+ key
417
+ for key in FieldInfo.__slots__
418
+ if not key.startswith("_") and key not in allowed and getattr(meta, key) != getattr(defaults, key)
419
+ }
420
+ if changed:
421
+ raise TypeError(
422
+ f"@tool {fn.__qualname__!r} param {param_name!r}: unsupported Field options {sorted(changed)}"
423
+ )
424
+ for nested in meta.metadata:
425
+ _validate_constraint_metadata(fn, param_name, nested)
426
+ return
427
+ pattern_type = type(Field(pattern="").metadata[0])
428
+ if type(meta) is pattern_type and set(vars(meta)) == {"pattern"}:
429
+ return
430
+ raise TypeError(f"@tool {fn.__qualname__!r} param {param_name!r}: unsupported metadata {meta!r}")
431
+
432
+
433
+ def _iter_constraint_metadata(annotation: Any) -> list[Any]:
434
+ result: list[Any] = []
435
+ if hasattr(annotation, "__metadata__"):
436
+ result.extend(meta for meta in annotation.__metadata__ if not isinstance(meta, str))
437
+ result.extend(_iter_constraint_metadata(get_args(annotation)[0]))
438
+ return result
439
+ origin = get_origin(annotation)
440
+ if origin in (Union, types.UnionType, list):
441
+ for argument in get_args(annotation):
442
+ if argument is not _NONE_TYPE:
443
+ result.extend(_iter_constraint_metadata(argument))
444
+ return result
445
+
446
+
447
+ def _validate_constraints_apply(
448
+ fn: Callable[..., Any],
449
+ param_name: str,
450
+ resolved: Any,
451
+ base: type,
452
+ choices: tuple[Any, ...] | None,
453
+ repeated: bool,
454
+ ) -> None:
455
+ from annotated_types import Ge, Gt, Le, Lt, MaxLen, MinLen, MultipleOf
456
+ from pydantic import Field, Strict
457
+ from pydantic.fields import FieldInfo
458
+
459
+ numeric = (Ge, Gt, Le, Lt, MultipleOf)
460
+ length = (MinLen, MaxLen)
461
+ pattern_type = type(Field(pattern="").metadata[0])
462
+ metadata = _iter_constraint_metadata(resolved)
463
+ expanded: list[Any] = []
464
+ for item in metadata:
465
+ expanded.extend(item.metadata if isinstance(item, FieldInfo) else (item,))
466
+ for item in expanded:
467
+ if isinstance(item, numeric):
468
+ valid = not repeated and base in (int, float) and choices is None
469
+ elif isinstance(item, length):
470
+ valid = repeated or (base is str and choices is None)
471
+ elif isinstance(item, Strict):
472
+ valid = repeated or choices is None
473
+ elif type(item) is pattern_type:
474
+ valid = base is str and not repeated and choices is None
475
+ else:
476
+ continue
477
+ if not valid:
478
+ raise TypeError(
479
+ f"@tool {fn.__qualname__!r} param {param_name!r}: constraint {item!r} does not apply to this type"
480
+ )
481
+
482
+
483
+ def _validation_annotation(annotation: Any, metadata_types: tuple[type, ...]) -> Any:
484
+ """Remove application markers while keeping the copied annotation contract."""
485
+ if hasattr(annotation, "__metadata__"):
486
+ base = _validation_annotation(get_args(annotation)[0], metadata_types)
487
+ metadata = tuple(meta for meta in annotation.__metadata__ if type(meta) not in metadata_types)
488
+ return Annotated[(base, *metadata)] if metadata else base
489
+ origin = get_origin(annotation)
490
+ if origin is list:
491
+ return list.__class_getitem__(_validation_annotation(get_args(annotation)[0], metadata_types))
492
+ if origin in (Union, types.UnionType):
493
+ arguments = [_validation_annotation(arg, metadata_types) for arg in get_args(annotation)]
494
+ combined = arguments[0]
495
+ for argument in arguments[1:]:
496
+ combined = combined | argument
497
+ return combined
498
+ return annotation
499
+
500
+
501
+ def _replace_annotation_description(annotation: Any, description: str) -> Any:
502
+ return Annotated[(_without_annotation_descriptions(annotation), description)]
503
+
504
+
505
+ def _without_annotation_descriptions(annotation: Any) -> Any:
506
+ if hasattr(annotation, "__metadata__"):
507
+ from pydantic.fields import FieldInfo
508
+
509
+ base = _without_annotation_descriptions(get_args(annotation)[0])
510
+ metadata = [
511
+ FieldInfo.merge_field_infos(meta, description=None) if isinstance(meta, FieldInfo) else meta
512
+ for meta in annotation.__metadata__
513
+ if not isinstance(meta, str)
514
+ ]
515
+ return Annotated[(base, *metadata)] if metadata else base
516
+
517
+ origin = get_origin(annotation)
518
+ if origin is list:
519
+ return list.__class_getitem__(_without_annotation_descriptions(get_args(annotation)[0]))
520
+ if origin in (Union, types.UnionType):
521
+ arguments = [_without_annotation_descriptions(item) for item in get_args(annotation)]
522
+ combined = arguments[0]
523
+ for argument in arguments[1:]:
524
+ combined = combined | argument
525
+ return combined
526
+ return annotation
527
+
528
+
529
+ def _literal_base_and_choices(
530
+ fn: Callable[..., Any], param_name: str, values: tuple[Any, ...]
531
+ ) -> tuple[type, tuple[Any, ...]]:
532
+ """Literal[...] → (base type, choices). All-str or all-int, never mixed."""
533
+ if all(isinstance(v, str) for v in values):
534
+ return str, tuple(values)
535
+ if all(isinstance(v, int) and not isinstance(v, bool) for v in values):
536
+ return int, tuple(values)
537
+ raise TypeError(
538
+ "@tool {function!r} param {param!r}: Literal values must be all-str or all-int, got {values!r}".format(
539
+ function=fn.__qualname__,
540
+ param=param_name,
541
+ values=values,
542
+ )
543
+ )
File without changes
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: coloph-toolset
3
+ Version: 0.1.0
4
+ Summary: Declare Python tools once, export their schemas, and validate their arguments.
5
+ Project-URL: Repository, https://github.com/golergka/coloph-toolset
6
+ Project-URL: Issues, https://github.com/golergka/coloph-toolset/issues
7
+ Project-URL: Changelog, https://github.com/golergka/coloph-toolset/blob/main/CHANGELOG.md
8
+ Author: golergka
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: annotated-types<1,>=0.7
16
+ Requires-Dist: pydantic<3,>=2.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # coloph-toolset
20
+
21
+ Declare Python tools once. Export their JSON Schema and validate arguments before your application calls the function.
22
+
23
+ Version 0.1 provides declarations and validation. It does not manage execution, transactions, authorization, or model providers.
24
+ Later releases add catalogs and adapters. The [roadmap](docs/roadmap.md) describes that sequence.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ uv add coloph-toolset
30
+ ```
31
+
32
+ The package requires Python 3.11 or later and Pydantic 2.12 or later within major version 2.
33
+ No Coloph installation, database, credentials, HTTP framework, or agent framework is required.
34
+
35
+ ## Declare and validate
36
+
37
+ ```python
38
+ from typing import Annotated
39
+ from pydantic import Field, ValidationError
40
+ from coloph_toolset import tool, tool_for
41
+
42
+
43
+ @tool()
44
+ def shipping_quote(
45
+ ctx,
46
+ quantity: Annotated[int, "Number of parcels", Field(gt=0)],
47
+ destination: Annotated[str | None, "Country code, or null for collection"],
48
+ insured: Annotated[bool, "Include insurance"] = False,
49
+ ) -> dict[str, object]:
50
+ return {"quantity": quantity, "destination": destination, "insured": insured}
51
+
52
+
53
+ declaration = tool_for(shipping_quote)
54
+ schema = declaration.json_schema()
55
+ arguments = declaration.validate_arguments({"quantity": "2", "destination": None})
56
+ result = shipping_quote(None, **arguments)
57
+
58
+ try:
59
+ declaration.validate_arguments({"quantity": 0, "destination": None})
60
+ except ValidationError as error:
61
+ print(error.errors(include_url=False))
62
+ ```
63
+
64
+ Validation never calls the function. Calling the Python function directly does not apply validation.
65
+ The application owns execution and must use validated arguments at its dispatch boundary.
66
+
67
+ ## Context and restricted arguments
68
+
69
+ The declaration expects a required first parameter named `ctx`.
70
+ Its type is application-owned and its annotation is not evaluated. Context never appears in the argument schema.
71
+
72
+ ```python
73
+ @tool(model_hidden_args=("internal",))
74
+ def inspect_order(ctx, order: str, internal: bool = False):
75
+ return ctx.lookup(order, internal=internal)
76
+
77
+
78
+ public = tool_for(inspect_order)
79
+ public.validate_arguments({"order": "demo"})
80
+ # An external "internal" argument raises ValidationError.
81
+ operator_schema = public.json_schema(include_hidden=True)
82
+ ```
83
+
84
+ Hidden arguments require valid defaults. Only trusted application code can select `include_hidden=True`.
85
+ Argument projection is not an authorization system.
86
+
87
+ ## Contracts and examples
88
+
89
+ - [Argument contract and API](docs/contracts.md)
90
+ - [Standalone shipping-quote project](examples/01-tool-declarations/README.md)
91
+ - [Development and release procedure](CONTRIBUTING.md)
92
+ - [Changes](CHANGELOG.md)
93
+
94
+ Every milestone adds a project under `examples/`. CI runs all preserved examples against the current library.
95
+
96
+ ## Development
97
+
98
+ ```sh
99
+ uv sync --locked
100
+ uv run pytest
101
+ uv run mypy
102
+ uv run python -m ruff check .
103
+ uv run python -m ruff format --check .
104
+ uv build
105
+ uv run python scripts/smoke_wheel.py
106
+ ```
107
+
108
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ coloph_toolset/__init__.py,sha256=uuKjdJdANO-t_rc-5fTu10Ve0qgxp1_zzIs2c4kIzTI,396
2
+ coloph_toolset/_argument_model.py,sha256=KmLAbG5hUYI0zL5pxSRcsMDkMxCRwoydu8k9XJKyjOg,2498
3
+ coloph_toolset/_decorator.py,sha256=9i_sUSlcb1-36biWZOUmEfcZWHHGkChPaSSVKxtndIk,21583
4
+ coloph_toolset/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ coloph_toolset-0.1.0.dist-info/METADATA,sha256=OP3_SJJrQUbkcfaaiqcANJ1wFv0VXLgjXfR7Z2727ts,3625
6
+ coloph_toolset-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ coloph_toolset-0.1.0.dist-info/licenses/LICENSE,sha256=tQJK1QL_5QBTjOTmN-fL-EeYNmQEX5yPG4PPAC4io0A,1065
8
+ coloph_toolset-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 golergka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.