toolschema 1.0.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,289 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+
9
+ class ValidationIssueKind(str, Enum):
10
+ REQUIRED = "required"
11
+ TYPE = "type"
12
+ ENUM = "enum"
13
+ CONSTRAINT = "constraint"
14
+ ADDITIONAL_PROPERTY = "additional_property"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ValidationIssue:
19
+ message: str
20
+ path: tuple[str | int, ...] = ()
21
+ kind: ValidationIssueKind = ValidationIssueKind.TYPE
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ValidationSuccess:
26
+ value: dict[str, Any]
27
+ issues: None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ValidationFailure:
32
+ value: None = None
33
+ issues: tuple[ValidationIssue, ...] = ()
34
+
35
+
36
+ ValidationResult = ValidationSuccess | ValidationFailure
37
+
38
+
39
+ def _issue(
40
+ message: str,
41
+ *,
42
+ path: tuple[str | int, ...] = (),
43
+ kind: ValidationIssueKind = ValidationIssueKind.TYPE,
44
+ ) -> ValidationIssue:
45
+ return ValidationIssue(message=message, path=path, kind=kind)
46
+
47
+
48
+ def _type_name(value: Any) -> str:
49
+ if value is None:
50
+ return "null"
51
+ if isinstance(value, bool):
52
+ return "boolean"
53
+ if isinstance(value, int) and not isinstance(value, bool):
54
+ return "integer"
55
+ if isinstance(value, float):
56
+ return "number"
57
+ if isinstance(value, str):
58
+ return "string"
59
+ if isinstance(value, list):
60
+ return "array"
61
+ if isinstance(value, dict):
62
+ return "object"
63
+ return type(value).__name__
64
+
65
+
66
+ def _matches_type(value: Any, expected: str) -> bool:
67
+ if expected == "null":
68
+ return value is None
69
+ if expected == "boolean":
70
+ return isinstance(value, bool)
71
+ if expected == "integer":
72
+ return isinstance(value, int) and not isinstance(value, bool)
73
+ if expected == "number":
74
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
75
+ if expected == "string":
76
+ return isinstance(value, str)
77
+ if expected == "array":
78
+ return isinstance(value, list)
79
+ if expected == "object":
80
+ return isinstance(value, dict)
81
+ return True
82
+
83
+
84
+ def _validate_constraints(
85
+ value: Any, schema: dict[str, Any], path: tuple[str | int, ...]
86
+ ) -> list[ValidationIssue]:
87
+ issues: list[ValidationIssue] = []
88
+
89
+ if isinstance(value, str):
90
+ min_length = schema.get("minLength")
91
+ max_length = schema.get("maxLength")
92
+ pattern = schema.get("pattern")
93
+ if min_length is not None and len(value) < min_length:
94
+ issues.append(
95
+ _issue(
96
+ f"String too short (minLength {min_length})",
97
+ path=path,
98
+ kind=ValidationIssueKind.CONSTRAINT,
99
+ )
100
+ )
101
+ if max_length is not None and len(value) > max_length:
102
+ issues.append(
103
+ _issue(
104
+ f"String too long (maxLength {max_length})",
105
+ path=path,
106
+ kind=ValidationIssueKind.CONSTRAINT,
107
+ )
108
+ )
109
+ if pattern is not None and re.search(pattern, value) is None:
110
+ issues.append(
111
+ _issue(
112
+ f"String does not match pattern {pattern!r}",
113
+ path=path,
114
+ kind=ValidationIssueKind.CONSTRAINT,
115
+ )
116
+ )
117
+
118
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
119
+ minimum = schema.get("minimum")
120
+ maximum = schema.get("maximum")
121
+ exclusive_minimum = schema.get("exclusiveMinimum")
122
+ exclusive_maximum = schema.get("exclusiveMaximum")
123
+ if minimum is not None and value < minimum:
124
+ issues.append(
125
+ _issue(
126
+ f"Value must be >= {minimum}", path=path, kind=ValidationIssueKind.CONSTRAINT
127
+ )
128
+ )
129
+ if maximum is not None and value > maximum:
130
+ issues.append(
131
+ _issue(
132
+ f"Value must be <= {maximum}", path=path, kind=ValidationIssueKind.CONSTRAINT
133
+ )
134
+ )
135
+ if exclusive_minimum is not None and value <= exclusive_minimum:
136
+ issues.append(
137
+ _issue(
138
+ f"Value must be > {exclusive_minimum}",
139
+ path=path,
140
+ kind=ValidationIssueKind.CONSTRAINT,
141
+ )
142
+ )
143
+ if exclusive_maximum is not None and value >= exclusive_maximum:
144
+ issues.append(
145
+ _issue(
146
+ f"Value must be < {exclusive_maximum}",
147
+ path=path,
148
+ kind=ValidationIssueKind.CONSTRAINT,
149
+ )
150
+ )
151
+
152
+ return issues
153
+
154
+
155
+ def _validate_value(
156
+ value: Any, schema: dict[str, Any], path: tuple[str | int, ...] = ()
157
+ ) -> list[ValidationIssue]:
158
+ if "anyOf" in schema:
159
+ branch_issues = [_validate_value(value, branch, path) for branch in schema["anyOf"]]
160
+ if any(not branch for branch in branch_issues):
161
+ return []
162
+ return [
163
+ _issue(
164
+ f"Value {_type_name(value)!r} does not match anyOf",
165
+ path=path,
166
+ kind=ValidationIssueKind.TYPE,
167
+ )
168
+ ]
169
+
170
+ if "enum" in schema and value not in schema["enum"]:
171
+ return [
172
+ _issue(
173
+ f"Value {value!r} is not in enum {schema['enum']!r}",
174
+ path=path,
175
+ kind=ValidationIssueKind.ENUM,
176
+ )
177
+ ]
178
+
179
+ json_type = schema.get("type")
180
+ if json_type and not _matches_type(value, json_type):
181
+ return [
182
+ _issue(
183
+ f"Expected {json_type}, got {_type_name(value)}",
184
+ path=path,
185
+ kind=ValidationIssueKind.TYPE,
186
+ )
187
+ ]
188
+
189
+ issues = _validate_constraints(value, schema, path)
190
+
191
+ if json_type == "array" and "items" in schema and isinstance(value, list):
192
+ for index, item in enumerate(value):
193
+ issues.extend(_validate_value(item, schema["items"], path + (index,)))
194
+
195
+ if json_type == "object" and isinstance(value, dict):
196
+ properties = schema.get("properties", {})
197
+ required = set(schema.get("required", []))
198
+
199
+ if schema.get("additionalProperties") is False:
200
+ extra = set(value) - set(properties)
201
+ for key in sorted(extra):
202
+ issues.append(
203
+ _issue(
204
+ f"Additional property {key!r} is not allowed",
205
+ path=path + (key,),
206
+ kind=ValidationIssueKind.ADDITIONAL_PROPERTY,
207
+ )
208
+ )
209
+
210
+ for key in sorted(required):
211
+ if key not in value:
212
+ prop_schema = properties.get(key, {})
213
+ if "default" not in prop_schema:
214
+ issues.append(
215
+ _issue(
216
+ f"Missing required property {key!r}",
217
+ path=path + (key,),
218
+ kind=ValidationIssueKind.REQUIRED,
219
+ )
220
+ )
221
+
222
+ for key, prop_schema in properties.items():
223
+ if key in value:
224
+ issues.extend(_validate_value(value[key], prop_schema, path + (key,)))
225
+
226
+ additional = schema.get("additionalProperties")
227
+ if isinstance(additional, dict):
228
+ for key, item in value.items():
229
+ if key not in properties:
230
+ issues.extend(_validate_value(item, additional, path + (key,)))
231
+
232
+ return issues
233
+
234
+
235
+ def validate_arguments(args: Any, parameters_schema: dict[str, Any]) -> ValidationResult:
236
+ """Validate tool arguments against a JSON Schema parameters object."""
237
+ if not isinstance(args, dict):
238
+ return ValidationFailure(
239
+ issues=(
240
+ _issue(
241
+ f"Arguments must be an object, got {_type_name(args)}",
242
+ kind=ValidationIssueKind.TYPE,
243
+ ),
244
+ )
245
+ )
246
+
247
+ if parameters_schema.get("type") != "object":
248
+ issues = _validate_value(args, parameters_schema)
249
+ return ValidationFailure(issues=tuple(issues)) if issues else ValidationSuccess(value=args)
250
+
251
+ properties = parameters_schema.get("properties", {})
252
+ required = set(parameters_schema.get("required", []))
253
+ issues: list[ValidationIssue] = []
254
+
255
+ for key in sorted(required):
256
+ if key not in args:
257
+ prop_schema = properties.get(key, {})
258
+ if "default" not in prop_schema:
259
+ issues.append(
260
+ _issue(
261
+ f"Missing required property {key!r}",
262
+ path=(key,),
263
+ kind=ValidationIssueKind.REQUIRED,
264
+ )
265
+ )
266
+
267
+ normalized = dict(args)
268
+ for key, prop_schema in properties.items():
269
+ if key not in normalized and isinstance(prop_schema, dict) and "default" in prop_schema:
270
+ normalized[key] = prop_schema["default"]
271
+
272
+ if parameters_schema.get("additionalProperties") is False:
273
+ extra = set(normalized) - set(properties)
274
+ for key in sorted(extra):
275
+ issues.append(
276
+ _issue(
277
+ f"Additional property {key!r} is not allowed",
278
+ path=(key,),
279
+ kind=ValidationIssueKind.ADDITIONAL_PROPERTY,
280
+ )
281
+ )
282
+
283
+ for key, prop_schema in properties.items():
284
+ if key in normalized:
285
+ issues.extend(_validate_value(normalized[key], prop_schema, (key,)))
286
+
287
+ if issues:
288
+ return ValidationFailure(issues=tuple(issues))
289
+ return ValidationSuccess(value=normalized)
@@ -0,0 +1,6 @@
1
+ from toolschema.adapters.anthropic import to_anthropic
2
+ from toolschema.adapters.gemini import to_gemini
3
+ from toolschema.adapters.mcp import to_mcp
4
+ from toolschema.adapters.openai import to_openai
5
+
6
+ __all__ = ["to_anthropic", "to_gemini", "to_mcp", "to_openai"]
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+
7
+ def _resolve_ref(ref: str, defs: dict[str, Any]) -> dict[str, Any]:
8
+ if not ref.startswith("#/$defs/"):
9
+ raise ValueError(f"Unsupported $ref format: {ref!r}")
10
+ key = ref.removeprefix("#/$defs/")
11
+ if key not in defs:
12
+ raise ValueError(f"Unresolved $ref: {ref!r}")
13
+ return copy.deepcopy(defs[key])
14
+
15
+
16
+ def _inline_node(node: Any, defs: dict[str, Any], resolving: set[str]) -> Any:
17
+ if isinstance(node, dict):
18
+ if "$ref" in node:
19
+ ref = node["$ref"]
20
+ key = ref.removeprefix("#/$defs/")
21
+ if key in resolving:
22
+ raise ValueError(f"Circular $ref detected: {ref!r}")
23
+ resolving = resolving | {key}
24
+ resolved = _inline_node(_resolve_ref(ref, defs), defs, resolving)
25
+ extras = {k: v for k, v in node.items() if k != "$ref"}
26
+ if extras:
27
+ if not isinstance(resolved, dict):
28
+ return resolved
29
+ return {**resolved, **extras}
30
+ return resolved
31
+
32
+ return {k: _inline_node(v, defs, resolving) for k, v in node.items()}
33
+
34
+ if isinstance(node, list):
35
+ return [_inline_node(item, defs, resolving) for item in node]
36
+
37
+ return node
38
+
39
+
40
+ def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
41
+ """Flatten JSON Schema $ref pointers using local $defs."""
42
+ cloned = copy.deepcopy(schema)
43
+ defs = cloned.pop("$defs", {})
44
+ if not defs:
45
+ return cloned
46
+ return _inline_node(cloned, defs, set())
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+ from toolschema._ir import ToolDefinition
7
+ from toolschema._schema_utils import strip_canonical_meta
8
+
9
+ _CONSTRAINT_KEYS = frozenset(
10
+ {
11
+ "minLength",
12
+ "maxLength",
13
+ "minimum",
14
+ "maximum",
15
+ "exclusiveMinimum",
16
+ "exclusiveMaximum",
17
+ "pattern",
18
+ }
19
+ )
20
+
21
+
22
+ def _constraint_to_description(prop: dict[str, Any]) -> str | None:
23
+ parts: list[str] = []
24
+ if "minLength" in prop:
25
+ parts.append(f"min length {prop['minLength']}")
26
+ if "maxLength" in prop:
27
+ parts.append(f"max length {prop['maxLength']}")
28
+ if "minimum" in prop:
29
+ parts.append(f"minimum {prop['minimum']}")
30
+ if "maximum" in prop:
31
+ parts.append(f"maximum {prop['maximum']}")
32
+ if "exclusiveMinimum" in prop:
33
+ parts.append(f"exclusive minimum {prop['exclusiveMinimum']}")
34
+ if "exclusiveMaximum" in prop:
35
+ parts.append(f"exclusive maximum {prop['exclusiveMaximum']}")
36
+ if "pattern" in prop:
37
+ parts.append(f"pattern {prop['pattern']!r}")
38
+ return "; ".join(parts) if parts else None
39
+
40
+
41
+ def _adapt_property(prop: dict[str, Any]) -> dict[str, Any]:
42
+ result = copy.deepcopy(prop)
43
+ constraint_desc = _constraint_to_description(result)
44
+ if constraint_desc:
45
+ existing = result.get("description", "")
46
+ suffix = f" ({constraint_desc})"
47
+ result["description"] = f"{existing}{suffix}" if existing else constraint_desc
48
+ for key in _CONSTRAINT_KEYS:
49
+ result.pop(key, None)
50
+ return result
51
+
52
+
53
+ def _adapt_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
54
+ result = strip_canonical_meta(schema)
55
+ properties = result.get("properties")
56
+ if isinstance(properties, dict):
57
+ result["properties"] = {k: _adapt_property(v) for k, v in properties.items()}
58
+ return result
59
+
60
+
61
+ def to_anthropic(tool: ToolDefinition) -> dict[str, Any]:
62
+ """Convert ToolDefinition to Anthropic tool format."""
63
+ return {
64
+ "name": tool.name,
65
+ "description": tool.description,
66
+ "input_schema": _adapt_input_schema(tool.parameters),
67
+ }
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+ from toolschema._ir import ToolDefinition
7
+
8
+ _TYPE_MAP = {
9
+ "string": "STRING",
10
+ "integer": "INTEGER",
11
+ "number": "NUMBER",
12
+ "boolean": "BOOLEAN",
13
+ "array": "ARRAY",
14
+ "object": "OBJECT",
15
+ "null": "NULL",
16
+ }
17
+
18
+
19
+ def _strip_canonical_meta(schema: dict[str, Any]) -> dict[str, Any]:
20
+ result = copy.deepcopy(schema)
21
+ result.pop("$schema", None)
22
+ return result
23
+
24
+
25
+ def _convert_type(schema: dict[str, Any]) -> dict[str, Any]:
26
+ result: dict[str, Any] = {}
27
+ for key, value in schema.items():
28
+ if key == "type" and isinstance(value, str):
29
+ result["type"] = _TYPE_MAP.get(value, value.upper())
30
+ elif key == "properties" and isinstance(value, dict):
31
+ result["properties"] = {k: _convert_schema(v) for k, v in value.items()}
32
+ elif key == "items" and isinstance(value, dict):
33
+ result["items"] = _convert_schema(value)
34
+ elif key == "additionalProperties" and isinstance(value, dict):
35
+ result["additionalProperties"] = _convert_schema(value)
36
+ elif key == "anyOf" and isinstance(value, list):
37
+ result["anyOf"] = [_convert_schema(item) for item in value]
38
+ else:
39
+ result[key] = value
40
+ return result
41
+
42
+
43
+ def _convert_schema(schema: dict[str, Any]) -> dict[str, Any]:
44
+ return _convert_type(copy.deepcopy(schema))
45
+
46
+
47
+ def to_gemini(tool: ToolDefinition) -> dict[str, Any]:
48
+ """Convert ToolDefinition to Gemini FunctionDeclaration format."""
49
+ parameters = _convert_schema(_strip_canonical_meta(tool.parameters))
50
+ if "type" not in parameters:
51
+ parameters["type"] = "OBJECT"
52
+
53
+ result: dict[str, Any] = {
54
+ "name": tool.name,
55
+ "description": tool.description,
56
+ "parameters": parameters,
57
+ }
58
+ return result
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from toolschema._ir import ToolDefinition
6
+ from toolschema._schema_utils import strip_canonical_meta
7
+ from toolschema.adapters._inline_refs import inline_refs as resolve_inline_refs
8
+
9
+
10
+ def to_mcp(tool: ToolDefinition, *, inline_refs: bool = True) -> dict[str, Any]:
11
+ """Convert ToolDefinition to MCP tools/list format."""
12
+ input_schema = strip_canonical_meta(tool.parameters)
13
+ if inline_refs:
14
+ input_schema = resolve_inline_refs(input_schema)
15
+
16
+ result: dict[str, Any] = {
17
+ "name": tool.name,
18
+ "description": tool.description,
19
+ "inputSchema": input_schema,
20
+ }
21
+
22
+ if tool.output is not None:
23
+ output_schema = strip_canonical_meta(tool.output)
24
+ if inline_refs:
25
+ output_schema = resolve_inline_refs(output_schema)
26
+ result["outputSchema"] = output_schema
27
+
28
+ return result
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+ from toolschema._ir import ToolDefinition
7
+ from toolschema._schema_utils import strip_canonical_meta
8
+
9
+
10
+ def _apply_strict(parameters: dict[str, Any]) -> dict[str, Any]:
11
+ params = copy.deepcopy(parameters)
12
+ params["additionalProperties"] = False
13
+ properties = params.get("properties", {})
14
+ if properties:
15
+ params["required"] = list(properties.keys())
16
+ return params
17
+
18
+
19
+ def to_openai(tool: ToolDefinition, *, strict: bool = False) -> dict[str, Any]:
20
+ """Convert ToolDefinition to OpenAI function-calling tool format."""
21
+ parameters = strip_canonical_meta(tool.parameters)
22
+ if strict:
23
+ parameters = _apply_strict(parameters)
24
+
25
+ return {
26
+ "type": "function",
27
+ "function": {
28
+ "name": tool.name,
29
+ "description": tool.description,
30
+ "parameters": parameters,
31
+ },
32
+ }