3tears-tool-schema 0.55.0__tar.gz
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.
- 3tears_tool_schema-0.55.0/.gitignore +250 -0
- 3tears_tool_schema-0.55.0/LICENSE +21 -0
- 3tears_tool_schema-0.55.0/PKG-INFO +130 -0
- 3tears_tool_schema-0.55.0/README.md +113 -0
- 3tears_tool_schema-0.55.0/pyproject.toml +32 -0
- 3tears_tool_schema-0.55.0/src/threetears/tool_schema/__init__.py +28 -0
- 3tears_tool_schema-0.55.0/src/threetears/tool_schema/py.typed +0 -0
- 3tears_tool_schema-0.55.0/src/threetears/tool_schema/self_contained.py +275 -0
- 3tears_tool_schema-0.55.0/tests/test_self_contained.py +370 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
# Anchored: these name top-level build output. Unanchored, `lib/` matches at ANY depth --
|
|
18
|
+
# it swallowed a vendored `.../pako/lib/` tree, and hatchling reads this file with its own
|
|
19
|
+
# matcher that does NOT honour `!` re-inclusion, so the miss reached built artifacts.
|
|
20
|
+
/lib/
|
|
21
|
+
/lib64/
|
|
22
|
+
parts/
|
|
23
|
+
sdist/
|
|
24
|
+
var/
|
|
25
|
+
wheels/
|
|
26
|
+
share/python-wheels/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.installed.cfg
|
|
29
|
+
*.egg
|
|
30
|
+
MANIFEST
|
|
31
|
+
|
|
32
|
+
# PyInstaller
|
|
33
|
+
# Usually these files are written by a python script from a template
|
|
34
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
35
|
+
*.manifest
|
|
36
|
+
*.spec
|
|
37
|
+
|
|
38
|
+
# Installer logs
|
|
39
|
+
pip-log.txt
|
|
40
|
+
pip-delete-this-directory.txt
|
|
41
|
+
|
|
42
|
+
# Unit test / coverage reports
|
|
43
|
+
htmlcov/
|
|
44
|
+
.tox/
|
|
45
|
+
.nox/
|
|
46
|
+
.coverage
|
|
47
|
+
.coverage.*
|
|
48
|
+
.cache
|
|
49
|
+
nosetests.xml
|
|
50
|
+
coverage.xml
|
|
51
|
+
*.cover
|
|
52
|
+
*.py.cover
|
|
53
|
+
.hypothesis/
|
|
54
|
+
.pytest_cache/
|
|
55
|
+
cover/
|
|
56
|
+
|
|
57
|
+
# Translations
|
|
58
|
+
*.mo
|
|
59
|
+
*.pot
|
|
60
|
+
|
|
61
|
+
# Django stuff:
|
|
62
|
+
*.log
|
|
63
|
+
local_settings.py
|
|
64
|
+
db.sqlite3
|
|
65
|
+
db.sqlite3-journal
|
|
66
|
+
|
|
67
|
+
# Flask stuff:
|
|
68
|
+
instance/
|
|
69
|
+
.webassets-cache
|
|
70
|
+
|
|
71
|
+
# Scrapy stuff:
|
|
72
|
+
.scrapy
|
|
73
|
+
|
|
74
|
+
# Sphinx documentation
|
|
75
|
+
docs/_build/
|
|
76
|
+
|
|
77
|
+
# PyBuilder
|
|
78
|
+
.pybuilder/
|
|
79
|
+
target/
|
|
80
|
+
|
|
81
|
+
# Jupyter Notebook
|
|
82
|
+
.ipynb_checkpoints
|
|
83
|
+
|
|
84
|
+
# IPython
|
|
85
|
+
profile_default/
|
|
86
|
+
ipython_config.py
|
|
87
|
+
|
|
88
|
+
# pyenv
|
|
89
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
90
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
91
|
+
# .python-version
|
|
92
|
+
|
|
93
|
+
# pipenv
|
|
94
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
95
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
96
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
97
|
+
# install all needed dependencies.
|
|
98
|
+
#Pipfile.lock
|
|
99
|
+
|
|
100
|
+
# UV
|
|
101
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
102
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
103
|
+
# commonly ignored for libraries.
|
|
104
|
+
#uv.lock
|
|
105
|
+
|
|
106
|
+
# poetry
|
|
107
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
108
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
109
|
+
# commonly ignored for libraries.
|
|
110
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
111
|
+
#poetry.lock
|
|
112
|
+
#poetry.toml
|
|
113
|
+
|
|
114
|
+
# pdm
|
|
115
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
116
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
117
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
118
|
+
#pdm.lock
|
|
119
|
+
#pdm.toml
|
|
120
|
+
.pdm-python
|
|
121
|
+
.pdm-build/
|
|
122
|
+
|
|
123
|
+
# pixi
|
|
124
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
125
|
+
#pixi.lock
|
|
126
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
127
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
128
|
+
.pixi
|
|
129
|
+
|
|
130
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
131
|
+
__pypackages__/
|
|
132
|
+
|
|
133
|
+
# Celery stuff
|
|
134
|
+
celerybeat-schedule
|
|
135
|
+
celerybeat.pid
|
|
136
|
+
|
|
137
|
+
# SageMath parsed files
|
|
138
|
+
*.sage.py
|
|
139
|
+
|
|
140
|
+
# Environments
|
|
141
|
+
.env
|
|
142
|
+
.envrc
|
|
143
|
+
.venv
|
|
144
|
+
env/
|
|
145
|
+
venv/
|
|
146
|
+
ENV/
|
|
147
|
+
env.bak/
|
|
148
|
+
venv.bak/
|
|
149
|
+
|
|
150
|
+
# Spyder project settings
|
|
151
|
+
.spyderproject
|
|
152
|
+
.spyproject
|
|
153
|
+
|
|
154
|
+
# Rope project settings
|
|
155
|
+
.ropeproject
|
|
156
|
+
|
|
157
|
+
# mkdocs documentation
|
|
158
|
+
/site
|
|
159
|
+
|
|
160
|
+
# mypy
|
|
161
|
+
.mypy_cache/
|
|
162
|
+
.dmypy.json
|
|
163
|
+
dmypy.json
|
|
164
|
+
|
|
165
|
+
# Pyre type checker
|
|
166
|
+
.pyre/
|
|
167
|
+
|
|
168
|
+
# pytype static type analyzer
|
|
169
|
+
.pytype/
|
|
170
|
+
|
|
171
|
+
# Cython debug symbols
|
|
172
|
+
cython_debug/
|
|
173
|
+
|
|
174
|
+
# PyCharm
|
|
175
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
176
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
177
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
178
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
179
|
+
#.idea/
|
|
180
|
+
|
|
181
|
+
# Abstra
|
|
182
|
+
# Abstra is an AI-powered process automation framework.
|
|
183
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
184
|
+
# Learn more at https://abstra.io/docs
|
|
185
|
+
.abstra/
|
|
186
|
+
|
|
187
|
+
# Visual Studio Code
|
|
188
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
189
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
190
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
191
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
192
|
+
# .vscode/
|
|
193
|
+
|
|
194
|
+
# Ruff stuff:
|
|
195
|
+
.ruff_cache/
|
|
196
|
+
|
|
197
|
+
# PyPI configuration file
|
|
198
|
+
.pypirc
|
|
199
|
+
|
|
200
|
+
# Cursor
|
|
201
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
202
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
203
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
204
|
+
.cursorignore
|
|
205
|
+
.cursorindexingignore
|
|
206
|
+
|
|
207
|
+
# Marimo
|
|
208
|
+
marimo/_static/
|
|
209
|
+
marimo/_lsp/
|
|
210
|
+
__marimo__/
|
|
211
|
+
|
|
212
|
+
# Claude Code local state
|
|
213
|
+
# .claude/* rather than .claude/ so the one file below can be re-included:
|
|
214
|
+
# git never descends into an excluded DIRECTORY, so a negation inside one is
|
|
215
|
+
# silently dead. Excluding the contents instead leaves the directory readable.
|
|
216
|
+
.claude/*
|
|
217
|
+
# Prawduct install reference. Committed on purpose: it is what enables the
|
|
218
|
+
# plugin for anyone who clones this repo. Without it the governance hooks run
|
|
219
|
+
# only on a machine that already has prawduct installed, and a new developer
|
|
220
|
+
# gets none of them.
|
|
221
|
+
!.claude/settings.json
|
|
222
|
+
|
|
223
|
+
# prawduct session evidence (local governance artifacts, never shipped)
|
|
224
|
+
.prawduct/
|
|
225
|
+
|
|
226
|
+
# macOS folder metadata
|
|
227
|
+
.DS_Store
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Prawduct session files
|
|
231
|
+
.claude/settings.local.json
|
|
232
|
+
.prawduct/.bug-inbox
|
|
233
|
+
.prawduct/.critic-active
|
|
234
|
+
.prawduct/.critic-findings.json
|
|
235
|
+
.prawduct/.critic-partials/
|
|
236
|
+
.prawduct/.critic-partials-archive/
|
|
237
|
+
.prawduct/.governance-ledger.jsonl
|
|
238
|
+
.prawduct/.handoff-notes.md
|
|
239
|
+
.prawduct/.test-evidence.json
|
|
240
|
+
.prawduct/.pr-reviews/
|
|
241
|
+
.prawduct/.session-base-tree
|
|
242
|
+
.prawduct/.session-git-baseline
|
|
243
|
+
.prawduct/.session-handoff.md
|
|
244
|
+
.prawduct/.session-reflected
|
|
245
|
+
.prawduct/.session-start
|
|
246
|
+
.prawduct/.subagent-briefing.md
|
|
247
|
+
.prawduct/.gates-waived
|
|
248
|
+
.prawduct/.advisories.json
|
|
249
|
+
.prawduct/.work-model-index.json
|
|
250
|
+
.prawduct/reflections.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Pace
|
|
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.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: 3tears-tool-schema
|
|
3
|
+
Version: 0.55.0
|
|
4
|
+
Summary: Dependency-free helpers that make a tool's JSON Schema self-contained for models and validators
|
|
5
|
+
Project-URL: Repository, https://github.com/pacepace/3tears
|
|
6
|
+
Author: pace
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# 3tears-tool-schema
|
|
19
|
+
|
|
20
|
+
Dependency-free helpers for the JSON Schema a tool advertises for its arguments.
|
|
21
|
+
|
|
22
|
+
A tool's arguments reach a model as a JSON Schema: pydantic's `model_json_schema()`, an MCP
|
|
23
|
+
server's `inputSchema`, a LangChain tool's `args_schema`. Pydantic writes every nested model as a
|
|
24
|
+
`$ref` into the schema's `$defs`, and every optional field as `anyOf: [X, {"type": "null"}]`.
|
|
25
|
+
Anything that reads only a property's own `type` gets both wrong. A model is shown a string where
|
|
26
|
+
the tool wants a list of objects, and an input normaliser leaves `"[]"` as a string where the tool
|
|
27
|
+
wants a list.
|
|
28
|
+
|
|
29
|
+
This package has **no dependencies**, so any tool host can take it without inheriting a framework:
|
|
30
|
+
a LangGraph app, an MCP server, a model adapter, a validator.
|
|
31
|
+
|
|
32
|
+
## `self_contained_input_schema`
|
|
33
|
+
|
|
34
|
+
One schema per tool, with no references left in it.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pydantic import BaseModel
|
|
38
|
+
from threetears.tool_schema import self_contained_input_schema
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Shot(BaseModel):
|
|
42
|
+
prompt: str
|
|
43
|
+
seconds: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Storyboard(BaseModel):
|
|
47
|
+
shots: list[Shot]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
schema = self_contained_input_schema(Storyboard.model_json_schema(), tool_name="storyboard")
|
|
51
|
+
# {"type": "object",
|
|
52
|
+
# "properties": {"shots": {"type": "array",
|
|
53
|
+
# "items": {"type": "object",
|
|
54
|
+
# "properties": {"prompt": {"type": "string"},
|
|
55
|
+
# "seconds": {"type": "integer"}},
|
|
56
|
+
# "required": ["prompt"]}}},
|
|
57
|
+
# "required": ["shots"]}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
What it does:
|
|
61
|
+
|
|
62
|
+
- Inlines every `$ref` into the schema's own `$defs` or `definitions`, through `items`, unions and
|
|
63
|
+
nested properties. Each level keeps its `required` list and descriptions, and a field's own
|
|
64
|
+
description wins over its model's.
|
|
65
|
+
- Collapses an optional union (`anyOf: [X, null]`) to `X` at every depth, dropping the
|
|
66
|
+
`default: null` that would contradict `X`.
|
|
67
|
+
- Keeps a union of two or more real members whole, rather than choosing one.
|
|
68
|
+
- Leaves an untyped (`Any`) field untyped.
|
|
69
|
+
- Expands a recursive model until it recurs. The point of recursion keeps the definition's type and
|
|
70
|
+
reads `"A Node: the same shape as the Node that contains it."`, rather than being cut to `{}`.
|
|
71
|
+
- Drops titles and the root description; the tool's description travels beside its schema.
|
|
72
|
+
- Always returns `type: "object"`, `properties` and `required`.
|
|
73
|
+
|
|
74
|
+
A `$ref` to anything but the schema's own definitions raises `ValueError` naming the tool and the
|
|
75
|
+
reference. A reference left in place would point at nothing the reader has.
|
|
76
|
+
|
|
77
|
+
### In an MCP server
|
|
78
|
+
|
|
79
|
+
List a tool whose arguments are a pydantic model, with an `inputSchema` any client can read:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from mcp.types import Tool
|
|
83
|
+
from threetears.tool_schema import self_contained_input_schema
|
|
84
|
+
|
|
85
|
+
tool = Tool(
|
|
86
|
+
name="storyboard",
|
|
87
|
+
description="Plan the shots for a scene.",
|
|
88
|
+
inputSchema=self_contained_input_schema(Storyboard.model_json_schema(), tool_name="storyboard"),
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### In a LangGraph app
|
|
93
|
+
|
|
94
|
+
Hand a model provider that takes raw tool specs the same self-contained shape:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from langchain_core.tools import StructuredTool
|
|
98
|
+
from threetears.tool_schema import self_contained_input_schema
|
|
99
|
+
|
|
100
|
+
tool = StructuredTool.from_function(plan, name="storyboard", args_schema=Storyboard)
|
|
101
|
+
spec = {
|
|
102
|
+
"name": tool.name,
|
|
103
|
+
"description": tool.description,
|
|
104
|
+
"input_schema": self_contained_input_schema(Storyboard.model_json_schema(), tool_name=tool.name),
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## `declared_type`
|
|
109
|
+
|
|
110
|
+
The one JSON type a property declares, read through the shapes pydantic writes. Use it to coerce
|
|
111
|
+
loose input toward the declared type.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from threetears.tool_schema import declared_type
|
|
115
|
+
|
|
116
|
+
schema = Storyboard.model_json_schema()
|
|
117
|
+
declared_type(schema["properties"]["shots"], schema) # "array"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It reads through:
|
|
121
|
+
|
|
122
|
+
- a nullable type list, such as `["array", "null"]`;
|
|
123
|
+
- an optional union;
|
|
124
|
+
- a `$ref` into the schema's definitions.
|
|
125
|
+
|
|
126
|
+
It answers `None` for a union of two or more real types, and for a reference it cannot follow. It
|
|
127
|
+
never raises, so code that must not fail on a schema it only half understands can call it.
|
|
128
|
+
|
|
129
|
+
In 3tears, `3tears-models` shows every bound tool to a Claude subscription model through
|
|
130
|
+
`self_contained_input_schema`, and `3tears-agent-tools` coerces tool input with `declared_type`.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# 3tears-tool-schema
|
|
2
|
+
|
|
3
|
+
Dependency-free helpers for the JSON Schema a tool advertises for its arguments.
|
|
4
|
+
|
|
5
|
+
A tool's arguments reach a model as a JSON Schema: pydantic's `model_json_schema()`, an MCP
|
|
6
|
+
server's `inputSchema`, a LangChain tool's `args_schema`. Pydantic writes every nested model as a
|
|
7
|
+
`$ref` into the schema's `$defs`, and every optional field as `anyOf: [X, {"type": "null"}]`.
|
|
8
|
+
Anything that reads only a property's own `type` gets both wrong. A model is shown a string where
|
|
9
|
+
the tool wants a list of objects, and an input normaliser leaves `"[]"` as a string where the tool
|
|
10
|
+
wants a list.
|
|
11
|
+
|
|
12
|
+
This package has **no dependencies**, so any tool host can take it without inheriting a framework:
|
|
13
|
+
a LangGraph app, an MCP server, a model adapter, a validator.
|
|
14
|
+
|
|
15
|
+
## `self_contained_input_schema`
|
|
16
|
+
|
|
17
|
+
One schema per tool, with no references left in it.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
from threetears.tool_schema import self_contained_input_schema
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Shot(BaseModel):
|
|
25
|
+
prompt: str
|
|
26
|
+
seconds: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Storyboard(BaseModel):
|
|
30
|
+
shots: list[Shot]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
schema = self_contained_input_schema(Storyboard.model_json_schema(), tool_name="storyboard")
|
|
34
|
+
# {"type": "object",
|
|
35
|
+
# "properties": {"shots": {"type": "array",
|
|
36
|
+
# "items": {"type": "object",
|
|
37
|
+
# "properties": {"prompt": {"type": "string"},
|
|
38
|
+
# "seconds": {"type": "integer"}},
|
|
39
|
+
# "required": ["prompt"]}}},
|
|
40
|
+
# "required": ["shots"]}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
What it does:
|
|
44
|
+
|
|
45
|
+
- Inlines every `$ref` into the schema's own `$defs` or `definitions`, through `items`, unions and
|
|
46
|
+
nested properties. Each level keeps its `required` list and descriptions, and a field's own
|
|
47
|
+
description wins over its model's.
|
|
48
|
+
- Collapses an optional union (`anyOf: [X, null]`) to `X` at every depth, dropping the
|
|
49
|
+
`default: null` that would contradict `X`.
|
|
50
|
+
- Keeps a union of two or more real members whole, rather than choosing one.
|
|
51
|
+
- Leaves an untyped (`Any`) field untyped.
|
|
52
|
+
- Expands a recursive model until it recurs. The point of recursion keeps the definition's type and
|
|
53
|
+
reads `"A Node: the same shape as the Node that contains it."`, rather than being cut to `{}`.
|
|
54
|
+
- Drops titles and the root description; the tool's description travels beside its schema.
|
|
55
|
+
- Always returns `type: "object"`, `properties` and `required`.
|
|
56
|
+
|
|
57
|
+
A `$ref` to anything but the schema's own definitions raises `ValueError` naming the tool and the
|
|
58
|
+
reference. A reference left in place would point at nothing the reader has.
|
|
59
|
+
|
|
60
|
+
### In an MCP server
|
|
61
|
+
|
|
62
|
+
List a tool whose arguments are a pydantic model, with an `inputSchema` any client can read:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from mcp.types import Tool
|
|
66
|
+
from threetears.tool_schema import self_contained_input_schema
|
|
67
|
+
|
|
68
|
+
tool = Tool(
|
|
69
|
+
name="storyboard",
|
|
70
|
+
description="Plan the shots for a scene.",
|
|
71
|
+
inputSchema=self_contained_input_schema(Storyboard.model_json_schema(), tool_name="storyboard"),
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### In a LangGraph app
|
|
76
|
+
|
|
77
|
+
Hand a model provider that takes raw tool specs the same self-contained shape:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from langchain_core.tools import StructuredTool
|
|
81
|
+
from threetears.tool_schema import self_contained_input_schema
|
|
82
|
+
|
|
83
|
+
tool = StructuredTool.from_function(plan, name="storyboard", args_schema=Storyboard)
|
|
84
|
+
spec = {
|
|
85
|
+
"name": tool.name,
|
|
86
|
+
"description": tool.description,
|
|
87
|
+
"input_schema": self_contained_input_schema(Storyboard.model_json_schema(), tool_name=tool.name),
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## `declared_type`
|
|
92
|
+
|
|
93
|
+
The one JSON type a property declares, read through the shapes pydantic writes. Use it to coerce
|
|
94
|
+
loose input toward the declared type.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from threetears.tool_schema import declared_type
|
|
98
|
+
|
|
99
|
+
schema = Storyboard.model_json_schema()
|
|
100
|
+
declared_type(schema["properties"]["shots"], schema) # "array"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
It reads through:
|
|
104
|
+
|
|
105
|
+
- a nullable type list, such as `["array", "null"]`;
|
|
106
|
+
- an optional union;
|
|
107
|
+
- a `$ref` into the schema's definitions.
|
|
108
|
+
|
|
109
|
+
It answers `None` for a union of two or more real types, and for a reference it cannot follow. It
|
|
110
|
+
never raises, so code that must not fail on a schema it only half understands can call it.
|
|
111
|
+
|
|
112
|
+
In 3tears, `3tears-models` shows every bound tool to a Claude subscription model through
|
|
113
|
+
`self_contained_input_schema`, and `3tears-agent-tools` coerces tool input with `declared_type`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "3tears-tool-schema"
|
|
7
|
+
version = "0.55.0"
|
|
8
|
+
description = "Dependency-free helpers that make a tool's JSON Schema self-contained for models and validators"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
authors = [{name = "pace"}]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
# dependency-free by design, enforced by the contract-purity check in
|
|
23
|
+
# tests/enforcement/: every tool host needs this -- a model adapter, a tool
|
|
24
|
+
# framework, an agent SDK, an MCP server -- and none of them should inherit
|
|
25
|
+
# another's dependency closure to read a JSON Schema.
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/pacepace/3tears"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/threetears"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""dependency-free helpers for the JSON Schema a tool advertises for its arguments.
|
|
2
|
+
|
|
3
|
+
:func:`self_contained_input_schema` inlines every ``$ref`` and collapses every optional union, so a
|
|
4
|
+
model or validator handed one schema per tool sees nested models as the objects they are.
|
|
5
|
+
:func:`declared_type` reads the one JSON type a property declares through optional unions and
|
|
6
|
+
references. This package depends on nothing, so every tool host -- a model adapter, a tool
|
|
7
|
+
framework, an MCP server -- can take it without inheriting another's dependency closure; purity is
|
|
8
|
+
enforced by the contract-purity check in the workspace's ``tests/enforcement/``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Version derived from package metadata so the metadata is the single source of truth -- a release
|
|
12
|
+
# that bumps pyproject cannot leave a stale runtime ``__version__`` behind. The fallback keeps the
|
|
13
|
+
# import working from a source tree that was never installed. ``importlib.metadata`` is stdlib, so
|
|
14
|
+
# the dependency-free floor is untouched.
|
|
15
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as _version
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = _version("3tears-tool-schema")
|
|
20
|
+
except _PackageNotFoundError: # pragma: no cover - dev fallback
|
|
21
|
+
__version__ = "unknown"
|
|
22
|
+
|
|
23
|
+
from threetears.tool_schema.self_contained import declared_type, self_contained_input_schema
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"declared_type",
|
|
27
|
+
"self_contained_input_schema",
|
|
28
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""a tool's JSON Schema made self-contained, and the one type a property declares.
|
|
2
|
+
|
|
3
|
+
A tool advertises its arguments as a JSON Schema -- pydantic's ``model_json_schema()``, an MCP
|
|
4
|
+
server's ``inputSchema``, a LangChain tool's ``args_schema`` -- and pydantic writes every nested
|
|
5
|
+
model as a ``$ref`` into the schema's ``$defs`` and every optional field as
|
|
6
|
+
``anyOf: [X, {"type": "null"}]``. A consumer that reads only a property's own ``type`` then sees a
|
|
7
|
+
nested model as nothing and an optional list as nothing: a model is shown a string field where the
|
|
8
|
+
tool wants a list of objects, and an input normaliser leaves a JSON-encoded list as a string.
|
|
9
|
+
|
|
10
|
+
:func:`self_contained_input_schema` resolves all of that into one schema with no references, for a
|
|
11
|
+
model or validator that is handed a single schema per tool. :func:`declared_type` answers the
|
|
12
|
+
narrower question a normaliser asks of one property: which single JSON type does it declare.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"declared_type",
|
|
22
|
+
"self_contained_input_schema",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
#: JSON Schema keywords whose value is one subschema.
|
|
26
|
+
_SUBSCHEMA_KEYWORDS = frozenset(
|
|
27
|
+
{"items", "additionalProperties", "not", "contains", "if", "then", "else", "propertyNames", "unevaluatedItems"}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
#: JSON Schema keywords whose value is a list of subschemas.
|
|
31
|
+
_SUBSCHEMA_LIST_KEYWORDS = frozenset({"anyOf", "oneOf", "allOf", "prefixItems"})
|
|
32
|
+
|
|
33
|
+
#: JSON Schema keywords whose value maps names to subschemas. The names are data, not keywords.
|
|
34
|
+
_SUBSCHEMA_MAP_KEYWORDS = frozenset({"properties", "patternProperties", "dependentSchemas", "$defs", "definitions"})
|
|
35
|
+
|
|
36
|
+
#: Where a local definition lives, for each spelling of it: ``$defs`` (JSON Schema 2019-09 on,
|
|
37
|
+
#: pydantic 2) and ``definitions`` (draft 7, pydantic 1).
|
|
38
|
+
_DEFINITION_PREFIXES = ("#/$defs/", "#/definitions/")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _definitions(schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
42
|
+
"""the schema's local definitions, both spellings merged.
|
|
43
|
+
|
|
44
|
+
:param schema: the root schema
|
|
45
|
+
:ptype schema: Mapping[str, Any]
|
|
46
|
+
:return: definition name to definition
|
|
47
|
+
:rtype: dict[str, Any]
|
|
48
|
+
"""
|
|
49
|
+
return {**(schema.get("definitions") or {}), **(schema.get("$defs") or {})}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _local_definition_name(ref: str) -> str | None:
|
|
53
|
+
"""the name of the local definition ``ref`` points at, or ``None`` when it points elsewhere.
|
|
54
|
+
|
|
55
|
+
:param ref: a ``$ref`` value
|
|
56
|
+
:ptype ref: str
|
|
57
|
+
:return: the definition's name, JSON-pointer escapes decoded, or ``None``
|
|
58
|
+
:rtype: str | None
|
|
59
|
+
"""
|
|
60
|
+
prefix = next((p for p in _DEFINITION_PREFIXES if ref.startswith(p)), None)
|
|
61
|
+
name: str | None = None
|
|
62
|
+
if prefix is not None and "/" not in ref[len(prefix) :]:
|
|
63
|
+
name = ref[len(prefix) :].replace("~1", "/").replace("~0", "~")
|
|
64
|
+
return name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _walk_subschemas(node: Mapping[str, Any], transform: Callable[[Any], Any]) -> dict[str, Any]:
|
|
68
|
+
"""``node`` with ``transform`` applied to every subschema it holds, and everything else copied.
|
|
69
|
+
|
|
70
|
+
Only schema-bearing keywords are walked: a ``default``, ``enum``, ``const`` or ``examples`` value
|
|
71
|
+
is data, and a dict inside one must not be read as a schema.
|
|
72
|
+
|
|
73
|
+
:param node: one schema object
|
|
74
|
+
:ptype node: Mapping[str, Any]
|
|
75
|
+
:param transform: applied to each immediate subschema
|
|
76
|
+
:ptype transform: Callable[[Any], Any]
|
|
77
|
+
:return: a new schema object; ``node`` is not mutated
|
|
78
|
+
:rtype: dict[str, Any]
|
|
79
|
+
"""
|
|
80
|
+
walked: dict[str, Any] = {}
|
|
81
|
+
for key, value in node.items():
|
|
82
|
+
if key in _SUBSCHEMA_KEYWORDS and isinstance(value, Mapping):
|
|
83
|
+
walked[key] = transform(value)
|
|
84
|
+
elif key in _SUBSCHEMA_LIST_KEYWORDS and isinstance(value, list):
|
|
85
|
+
walked[key] = [transform(item) for item in value]
|
|
86
|
+
elif key in _SUBSCHEMA_MAP_KEYWORDS and isinstance(value, Mapping):
|
|
87
|
+
walked[key] = {name: transform(item) for name, item in value.items()}
|
|
88
|
+
else:
|
|
89
|
+
walked[key] = value
|
|
90
|
+
return walked
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _collapse_optional(node: Any) -> Any:
|
|
94
|
+
"""``node`` with every ``X | None`` union collapsed to ``X``, at every depth.
|
|
95
|
+
|
|
96
|
+
Pydantic renders an optional field as ``anyOf: [X, {"type": "null"}]`` with no top-level
|
|
97
|
+
``type``. A union with exactly one non-null member becomes that member, carrying the field's
|
|
98
|
+
own keywords over it -- the field's description is the more specific one -- minus the
|
|
99
|
+
``default: null`` that would contradict the member's type. A union of two or more real members
|
|
100
|
+
is left whole: choosing one would drop the others.
|
|
101
|
+
|
|
102
|
+
:param node: a schema, or any value inside one
|
|
103
|
+
:ptype node: Any
|
|
104
|
+
:return: the schema with optional unions collapsed; ``node`` is not mutated
|
|
105
|
+
:rtype: Any
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(node, Mapping):
|
|
108
|
+
return node
|
|
109
|
+
result = _walk_subschemas(node, _collapse_optional)
|
|
110
|
+
union_key = "anyOf" if "anyOf" in result else "oneOf" if "oneOf" in result else None
|
|
111
|
+
if union_key is not None and "type" not in result:
|
|
112
|
+
members = result[union_key]
|
|
113
|
+
real = [m for m in members if not (isinstance(m, Mapping) and m.get("type") == "null")]
|
|
114
|
+
if len(real) == 1 and len(real) < len(members) and isinstance(real[0], Mapping):
|
|
115
|
+
field = {k: v for k, v in result.items() if k != union_key and not (k == "default" and v is None)}
|
|
116
|
+
result = {**real[0], **field}
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _inline_refs(
|
|
121
|
+
node: Any,
|
|
122
|
+
definitions: dict[str, Any],
|
|
123
|
+
tool_name: str,
|
|
124
|
+
expanding: tuple[str, ...] = (),
|
|
125
|
+
) -> Any:
|
|
126
|
+
"""``node`` with every ``$ref`` replaced by the definition it names, and titles removed.
|
|
127
|
+
|
|
128
|
+
A reference's sibling keywords override the definition's -- pydantic writes a field's own
|
|
129
|
+
description beside the ``$ref``, and it is the more specific one. Titles go because they are
|
|
130
|
+
noise to a model, and LangChain's own conversion for the provider APIs removes them too.
|
|
131
|
+
|
|
132
|
+
A recursive model cannot be inlined completely: its schema is infinite. It is expanded until a
|
|
133
|
+
definition recurs inside its own expansion, and at that point the schema says in words what
|
|
134
|
+
the value is -- the same shape as the enclosing one -- keeping the definition's ``type``. A
|
|
135
|
+
bounded expansion rather than a refusal, so a tool with a tree-shaped argument stays usable;
|
|
136
|
+
described rather than cut to ``{}``, so a model is not shown "anything" where the tool requires
|
|
137
|
+
a particular shape.
|
|
138
|
+
|
|
139
|
+
:param node: a schema, or any value inside one
|
|
140
|
+
:ptype node: Any
|
|
141
|
+
:param definitions: the root schema's ``$defs`` and ``definitions``, merged
|
|
142
|
+
:ptype definitions: dict[str, Any]
|
|
143
|
+
:param tool_name: the tool whose schema this is, for errors
|
|
144
|
+
:ptype tool_name: str
|
|
145
|
+
:param expanding: the definitions being expanded on the path to ``node``, outermost first
|
|
146
|
+
:ptype expanding: tuple[str, ...]
|
|
147
|
+
:return: the inlined schema; ``node`` is not mutated
|
|
148
|
+
:rtype: Any
|
|
149
|
+
:raises ValueError: when a ``$ref`` names no local definition
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(node, Mapping):
|
|
152
|
+
return node
|
|
153
|
+
siblings = _walk_subschemas(
|
|
154
|
+
{k: v for k, v in node.items() if k not in ("$ref", "title", "$defs", "definitions")},
|
|
155
|
+
lambda child: _inline_refs(child, definitions, tool_name, expanding),
|
|
156
|
+
)
|
|
157
|
+
ref = node.get("$ref")
|
|
158
|
+
result: Any = siblings
|
|
159
|
+
if isinstance(ref, str):
|
|
160
|
+
name = _local_definition_name(ref)
|
|
161
|
+
if name is None:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"tool {tool_name!r}: cannot make its input schema self-contained: $ref {ref!r} does "
|
|
164
|
+
"not name a definition in the schema's own $defs, so it cannot be inlined"
|
|
165
|
+
)
|
|
166
|
+
if name not in definitions:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"tool {tool_name!r}: cannot make its input schema self-contained: $ref {ref!r} names "
|
|
169
|
+
"a definition the schema does not carry"
|
|
170
|
+
)
|
|
171
|
+
definition = definitions[name]
|
|
172
|
+
if name in expanding:
|
|
173
|
+
note = f"A {name}: the same shape as the {name} that contains it."
|
|
174
|
+
field_description = siblings.pop("description", None)
|
|
175
|
+
recursion: dict[str, Any] = {"description": f"{field_description} {note}" if field_description else note}
|
|
176
|
+
if isinstance(definition, Mapping) and "type" in definition:
|
|
177
|
+
recursion = {"type": definition["type"], **recursion}
|
|
178
|
+
result = {**recursion, **siblings}
|
|
179
|
+
else:
|
|
180
|
+
expanded = _inline_refs(definition, definitions, tool_name, (*expanding, name))
|
|
181
|
+
result = {**expanded, **siblings} if isinstance(expanded, Mapping) else expanded
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def self_contained_input_schema(schema: Mapping[str, Any], *, tool_name: str) -> dict[str, Any]:
|
|
186
|
+
"""a tool's input schema with every reference inlined, as one ``type: object`` schema.
|
|
187
|
+
|
|
188
|
+
For a consumer handed one schema per tool and no shared definitions -- a model's tool listing,
|
|
189
|
+
an MCP client, a validator given the properties alone. Every ``$ref`` into the schema's own
|
|
190
|
+
``$defs`` / ``definitions`` is inlined, through ``items``, unions and nested properties, with
|
|
191
|
+
each level's ``required`` list and descriptions kept; a field's description wins over its
|
|
192
|
+
model's. Every optional union (``anyOf: [X, null]``) collapses to ``X`` at every depth; a union
|
|
193
|
+
of two or more real members is kept whole; an untyped field stays untyped. A recursive model is
|
|
194
|
+
expanded until it recurs, and the point of recursion reads
|
|
195
|
+
``"A Node: the same shape as the Node that contains it."`` with the definition's type. Titles
|
|
196
|
+
and the root description are dropped -- the tool's own description travels beside its schema.
|
|
197
|
+
|
|
198
|
+
The result always has ``type: "object"``, ``properties`` and ``required``, so a builder that
|
|
199
|
+
would otherwise mark every property required uses it as it stands.
|
|
200
|
+
|
|
201
|
+
:param schema: the tool's input JSON Schema -- pydantic's ``model_json_schema()``, an MCP
|
|
202
|
+
``inputSchema``, a LangChain ``args_schema`` or ``tool_call_schema``; not mutated
|
|
203
|
+
:ptype schema: Mapping[str, Any]
|
|
204
|
+
:param tool_name: the tool's name, for errors
|
|
205
|
+
:ptype tool_name: str
|
|
206
|
+
:return: the self-contained schema
|
|
207
|
+
:rtype: dict[str, Any]
|
|
208
|
+
:raises ValueError: when a ``$ref`` points anywhere but the schema's own definitions, or names
|
|
209
|
+
one it does not carry -- a reference left in place would point at nothing the reader has
|
|
210
|
+
"""
|
|
211
|
+
collapsed = _collapse_optional(schema)
|
|
212
|
+
root = _inline_refs(collapsed, _definitions(collapsed), tool_name)
|
|
213
|
+
root.pop("description", None)
|
|
214
|
+
return {
|
|
215
|
+
**root,
|
|
216
|
+
"type": "object",
|
|
217
|
+
"properties": root.get("properties") or {},
|
|
218
|
+
"required": list(root.get("required") or []),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def declared_type(prop: Any, schema: Mapping[str, Any]) -> str | None:
|
|
223
|
+
"""the one JSON Schema type ``prop`` declares, read through the shapes pydantic writes.
|
|
224
|
+
|
|
225
|
+
A property's own ``type`` answers when it is a string, or a list naming one type besides
|
|
226
|
+
``null``. An optional field has no ``type`` -- pydantic writes ``anyOf: [X, {"type": "null"}]``
|
|
227
|
+
-- and answers with ``X``'s. A nested model is a ``$ref`` into the schema's own definitions and
|
|
228
|
+
answers with the definition's. A union of two or more real types answers ``None``: the value
|
|
229
|
+
may be any of them, and treating it as one would choose for the caller. So does a reference
|
|
230
|
+
that points elsewhere or names nothing, and one already being followed.
|
|
231
|
+
|
|
232
|
+
Never raises: it is for code that must not fail on a schema it half understands, such as a
|
|
233
|
+
normaliser that coerces a JSON-encoded string into the list a property declares.
|
|
234
|
+
|
|
235
|
+
:param prop: one property's schema
|
|
236
|
+
:ptype prop: Any
|
|
237
|
+
:param schema: the whole schema, whose definitions a reference names
|
|
238
|
+
:ptype schema: Mapping[str, Any]
|
|
239
|
+
:return: the declared type, or ``None`` when there is no single one
|
|
240
|
+
:rtype: str | None
|
|
241
|
+
"""
|
|
242
|
+
return _declared_type(prop, schema, frozenset())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _declared_type(prop: Any, schema: Mapping[str, Any], following: frozenset[str]) -> str | None:
|
|
246
|
+
""":func:`declared_type`, carrying the references already followed so a cycle ends.
|
|
247
|
+
|
|
248
|
+
:param prop: one property's schema
|
|
249
|
+
:ptype prop: Any
|
|
250
|
+
:param schema: the whole schema
|
|
251
|
+
:ptype schema: Mapping[str, Any]
|
|
252
|
+
:param following: references already followed on this path
|
|
253
|
+
:ptype following: frozenset[str]
|
|
254
|
+
:return: the declared type, or ``None``
|
|
255
|
+
:rtype: str | None
|
|
256
|
+
"""
|
|
257
|
+
result: str | None = None
|
|
258
|
+
if not isinstance(prop, Mapping):
|
|
259
|
+
return result
|
|
260
|
+
declared = prop.get("type")
|
|
261
|
+
ref = prop.get("$ref")
|
|
262
|
+
members = prop.get("anyOf") or prop.get("oneOf")
|
|
263
|
+
if isinstance(declared, str):
|
|
264
|
+
result = declared
|
|
265
|
+
elif isinstance(declared, list):
|
|
266
|
+
real_types = [t for t in declared if t != "null"]
|
|
267
|
+
result = real_types[0] if len(real_types) == 1 and isinstance(real_types[0], str) else None
|
|
268
|
+
elif isinstance(ref, str) and ref not in following:
|
|
269
|
+
name = _local_definition_name(ref)
|
|
270
|
+
if name is not None:
|
|
271
|
+
result = _declared_type(_definitions(schema).get(name), schema, following | {ref})
|
|
272
|
+
elif isinstance(members, list):
|
|
273
|
+
real = [m for m in members if not (isinstance(m, Mapping) and m.get("type") == "null")]
|
|
274
|
+
result = _declared_type(real[0], schema, following) if len(real) == 1 else None
|
|
275
|
+
return result
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""a tool's JSON Schema made self-contained, and the one type a property declares.
|
|
2
|
+
|
|
3
|
+
The schema below is exactly what pydantic 2 renders for a storyboard tool's arguments -- nested
|
|
4
|
+
models as ``$ref`` into ``$defs``, optional fields as ``anyOf`` with ``null``, a two-model union, an
|
|
5
|
+
untyped field and a recursive outline -- written out so this dependency-free package is tested
|
|
6
|
+
without pydantic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from threetears.tool_schema import declared_type, self_contained_input_schema
|
|
17
|
+
|
|
18
|
+
_STORYBOARD: dict[str, Any] = {
|
|
19
|
+
"$defs": {
|
|
20
|
+
"Close": {
|
|
21
|
+
"description": "A close-up.",
|
|
22
|
+
"properties": {"subject": {"title": "Subject", "type": "string"}},
|
|
23
|
+
"required": ["subject"],
|
|
24
|
+
"title": "Close",
|
|
25
|
+
"type": "object",
|
|
26
|
+
},
|
|
27
|
+
"Lighting": {
|
|
28
|
+
"description": "How a scene is lit.",
|
|
29
|
+
"properties": {"key": {"description": "the key light", "title": "Key", "type": "string"}},
|
|
30
|
+
"required": ["key"],
|
|
31
|
+
"title": "Lighting",
|
|
32
|
+
"type": "object",
|
|
33
|
+
},
|
|
34
|
+
"Node": {
|
|
35
|
+
"description": "An outline node.",
|
|
36
|
+
"properties": {
|
|
37
|
+
"label": {"description": "the node's text", "title": "Label", "type": "string"},
|
|
38
|
+
"children": {
|
|
39
|
+
"description": "the nodes under this one",
|
|
40
|
+
"items": {"$ref": "#/$defs/Node"},
|
|
41
|
+
"title": "Children",
|
|
42
|
+
"type": "array",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
"required": ["label"],
|
|
46
|
+
"title": "Node",
|
|
47
|
+
"type": "object",
|
|
48
|
+
},
|
|
49
|
+
"Scene": {
|
|
50
|
+
"description": "Where a scene happens.",
|
|
51
|
+
"properties": {
|
|
52
|
+
"location": {"description": "the place", "title": "Location", "type": "string"},
|
|
53
|
+
"lighting": {
|
|
54
|
+
"anyOf": [{"$ref": "#/$defs/Lighting"}, {"type": "null"}],
|
|
55
|
+
"default": None,
|
|
56
|
+
"description": "the lighting, when it matters",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"required": ["location"],
|
|
60
|
+
"title": "Scene",
|
|
61
|
+
"type": "object",
|
|
62
|
+
},
|
|
63
|
+
"Shot": {
|
|
64
|
+
"description": "One camera shot.",
|
|
65
|
+
"properties": {
|
|
66
|
+
"prompt": {"description": "what the shot shows", "title": "Prompt", "type": "string"},
|
|
67
|
+
"seconds": {
|
|
68
|
+
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
|
69
|
+
"default": None,
|
|
70
|
+
"description": "how long it runs",
|
|
71
|
+
"title": "Seconds",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"required": ["prompt"],
|
|
75
|
+
"title": "Shot",
|
|
76
|
+
"type": "object",
|
|
77
|
+
},
|
|
78
|
+
"Wide": {
|
|
79
|
+
"description": "A wide shot.",
|
|
80
|
+
"properties": {"landscape": {"title": "Landscape", "type": "string"}},
|
|
81
|
+
"required": ["landscape"],
|
|
82
|
+
"title": "Wide",
|
|
83
|
+
"type": "object",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
"properties": {
|
|
87
|
+
"shots": {
|
|
88
|
+
"description": "the shots, in order",
|
|
89
|
+
"items": {"$ref": "#/$defs/Shot"},
|
|
90
|
+
"title": "Shots",
|
|
91
|
+
"type": "array",
|
|
92
|
+
},
|
|
93
|
+
"scene": {"$ref": "#/$defs/Scene", "description": "the scene they belong to"},
|
|
94
|
+
"framing": {
|
|
95
|
+
"anyOf": [{"$ref": "#/$defs/Close"}, {"$ref": "#/$defs/Wide"}],
|
|
96
|
+
"description": "the framing",
|
|
97
|
+
"title": "Framing",
|
|
98
|
+
},
|
|
99
|
+
"anything": {"default": None, "description": "any value at all", "title": "Anything"},
|
|
100
|
+
"outline": {
|
|
101
|
+
"anyOf": [{"$ref": "#/$defs/Node"}, {"type": "null"}],
|
|
102
|
+
"default": None,
|
|
103
|
+
"description": "the outline, if there is one",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"required": ["shots", "scene", "framing"],
|
|
107
|
+
"title": "StoryboardInput",
|
|
108
|
+
"type": "object",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_SHOT = {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"description": "One camera shot.",
|
|
115
|
+
"properties": {
|
|
116
|
+
"prompt": {"type": "string", "description": "what the shot shows"},
|
|
117
|
+
"seconds": {"type": "integer", "description": "how long it runs"},
|
|
118
|
+
},
|
|
119
|
+
"required": ["prompt"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _storyboard() -> dict[str, Any]:
|
|
124
|
+
"""the storyboard schema, self-contained.
|
|
125
|
+
|
|
126
|
+
:return: the result
|
|
127
|
+
:rtype: dict[str, Any]
|
|
128
|
+
"""
|
|
129
|
+
return self_contained_input_schema(_STORYBOARD, tool_name="storyboard")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestSelfContainedInputSchema:
|
|
133
|
+
def test_a_list_of_models_is_an_array_of_objects(self) -> None:
|
|
134
|
+
"""``list[Shot]`` is an array of Shot objects, each keeping its required list.
|
|
135
|
+
|
|
136
|
+
:return: none
|
|
137
|
+
:rtype: None
|
|
138
|
+
"""
|
|
139
|
+
assert _storyboard()["properties"]["shots"] == {
|
|
140
|
+
"type": "array",
|
|
141
|
+
"description": "the shots, in order",
|
|
142
|
+
"items": _SHOT,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def test_a_nested_sub_object_keeps_its_properties_required_list_and_field_description(self) -> None:
|
|
146
|
+
"""a sub-object is the object, and the field's description wins over the model's.
|
|
147
|
+
|
|
148
|
+
:return: none
|
|
149
|
+
:rtype: None
|
|
150
|
+
"""
|
|
151
|
+
scene = _storyboard()["properties"]["scene"]
|
|
152
|
+
assert scene["type"] == "object"
|
|
153
|
+
assert scene["description"] == "the scene they belong to"
|
|
154
|
+
assert scene["required"] == ["location"]
|
|
155
|
+
assert scene["properties"]["location"] == {"type": "string", "description": "the place"}
|
|
156
|
+
|
|
157
|
+
def test_an_optional_nested_model_is_unwrapped_to_the_object(self) -> None:
|
|
158
|
+
"""``Lighting | None`` collapses to the Lighting object, at any depth.
|
|
159
|
+
|
|
160
|
+
:return: none
|
|
161
|
+
:rtype: None
|
|
162
|
+
"""
|
|
163
|
+
assert _storyboard()["properties"]["scene"]["properties"]["lighting"] == {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"description": "the lighting, when it matters",
|
|
166
|
+
"properties": {"key": {"type": "string", "description": "the key light"}},
|
|
167
|
+
"required": ["key"],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def test_a_union_of_models_keeps_every_member(self) -> None:
|
|
171
|
+
"""``Close | Wide`` stays a union of both, never the first alone.
|
|
172
|
+
|
|
173
|
+
:return: none
|
|
174
|
+
:rtype: None
|
|
175
|
+
"""
|
|
176
|
+
framing = _storyboard()["properties"]["framing"]
|
|
177
|
+
assert framing["description"] == "the framing"
|
|
178
|
+
assert [sorted(member["properties"]) for member in framing["anyOf"]] == [["subject"], ["landscape"]]
|
|
179
|
+
|
|
180
|
+
def test_an_untyped_field_is_not_forced_to_a_string(self) -> None:
|
|
181
|
+
"""``Any`` stays untyped.
|
|
182
|
+
|
|
183
|
+
:return: none
|
|
184
|
+
:rtype: None
|
|
185
|
+
"""
|
|
186
|
+
assert _storyboard()["properties"]["anything"] == {"default": None, "description": "any value at all"}
|
|
187
|
+
|
|
188
|
+
def test_no_reference_is_left_dangling(self) -> None:
|
|
189
|
+
"""nothing in the result points anywhere.
|
|
190
|
+
|
|
191
|
+
:return: none
|
|
192
|
+
:rtype: None
|
|
193
|
+
"""
|
|
194
|
+
result = _storyboard()
|
|
195
|
+
assert "$ref" not in repr(result)
|
|
196
|
+
assert "$defs" not in result
|
|
197
|
+
|
|
198
|
+
def test_the_top_level_is_an_object_with_the_models_required_list(self) -> None:
|
|
199
|
+
"""``type``, ``properties`` and ``required`` are always there.
|
|
200
|
+
|
|
201
|
+
:return: none
|
|
202
|
+
:rtype: None
|
|
203
|
+
"""
|
|
204
|
+
result = _storyboard()
|
|
205
|
+
assert result["type"] == "object"
|
|
206
|
+
assert result["required"] == ["shots", "scene", "framing"]
|
|
207
|
+
assert "title" not in result
|
|
208
|
+
assert "description" not in result
|
|
209
|
+
|
|
210
|
+
def test_a_recursive_model_is_expanded_once_and_its_recursion_named(self) -> None:
|
|
211
|
+
"""the point of recursion keeps its type and says in words what it is.
|
|
212
|
+
|
|
213
|
+
:return: none
|
|
214
|
+
:rtype: None
|
|
215
|
+
"""
|
|
216
|
+
outline = _storyboard()["properties"]["outline"]
|
|
217
|
+
assert outline["type"] == "object"
|
|
218
|
+
assert outline["description"] == "the outline, if there is one"
|
|
219
|
+
children = outline["properties"]["children"]
|
|
220
|
+
assert children["type"] == "array"
|
|
221
|
+
assert children["description"] == "the nodes under this one"
|
|
222
|
+
assert children["items"] == {
|
|
223
|
+
"type": "object",
|
|
224
|
+
"description": "A Node: the same shape as the Node that contains it.",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def test_the_draft_7_definitions_spelling_is_inlined_too(self) -> None:
|
|
228
|
+
"""pydantic 1 and draft-7 schemas keep definitions under ``definitions``.
|
|
229
|
+
|
|
230
|
+
:return: none
|
|
231
|
+
:rtype: None
|
|
232
|
+
"""
|
|
233
|
+
schema = {
|
|
234
|
+
"type": "object",
|
|
235
|
+
"properties": {"shot": {"$ref": "#/definitions/Shot"}},
|
|
236
|
+
"definitions": {"Shot": {"type": "object", "properties": {"prompt": {"type": "string"}}}},
|
|
237
|
+
}
|
|
238
|
+
assert self_contained_input_schema(schema, tool_name="t")["properties"]["shot"] == {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {"prompt": {"type": "string"}},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def test_a_schema_with_no_properties_is_still_an_object(self) -> None:
|
|
244
|
+
"""an argument-less tool gets empty ``properties`` and ``required``.
|
|
245
|
+
|
|
246
|
+
:return: none
|
|
247
|
+
:rtype: None
|
|
248
|
+
"""
|
|
249
|
+
assert self_contained_input_schema({}, tool_name="t") == {"type": "object", "properties": {}, "required": []}
|
|
250
|
+
|
|
251
|
+
def test_the_input_is_not_mutated(self) -> None:
|
|
252
|
+
"""callers keep the schema they passed.
|
|
253
|
+
|
|
254
|
+
:return: none
|
|
255
|
+
:rtype: None
|
|
256
|
+
"""
|
|
257
|
+
before = copy.deepcopy(_STORYBOARD)
|
|
258
|
+
_storyboard()
|
|
259
|
+
assert _STORYBOARD == before
|
|
260
|
+
|
|
261
|
+
def test_a_reference_outside_the_schema_is_refused_naming_the_tool(self) -> None:
|
|
262
|
+
"""a foreign reference cannot be inlined, and a left one points at nothing.
|
|
263
|
+
|
|
264
|
+
:return: none
|
|
265
|
+
:rtype: None
|
|
266
|
+
"""
|
|
267
|
+
schema = {"type": "object", "properties": {"shot": {"$ref": "https://example.com/shot.json"}}}
|
|
268
|
+
with pytest.raises(ValueError, match=r"storyboard.*https://example.com/shot.json"):
|
|
269
|
+
self_contained_input_schema(schema, tool_name="storyboard")
|
|
270
|
+
|
|
271
|
+
def test_a_reference_to_a_missing_definition_is_refused_naming_the_tool(self) -> None:
|
|
272
|
+
"""a local reference to a definition the schema lacks is refused too.
|
|
273
|
+
|
|
274
|
+
:return: none
|
|
275
|
+
:rtype: None
|
|
276
|
+
"""
|
|
277
|
+
schema = {"type": "object", "properties": {"shot": {"$ref": "#/$defs/Missing"}}}
|
|
278
|
+
with pytest.raises(ValueError, match=r"storyboard.*#/\$defs/Missing"):
|
|
279
|
+
self_contained_input_schema(schema, tool_name="storyboard")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TestDeclaredType:
|
|
283
|
+
def test_a_plain_type(self) -> None:
|
|
284
|
+
"""a property's own ``type`` answers.
|
|
285
|
+
|
|
286
|
+
:return: none
|
|
287
|
+
:rtype: None
|
|
288
|
+
"""
|
|
289
|
+
assert declared_type(_STORYBOARD["properties"]["shots"], _STORYBOARD) == "array"
|
|
290
|
+
|
|
291
|
+
def test_an_optional_field_answers_with_its_member(self) -> None:
|
|
292
|
+
"""``anyOf: [integer, null]`` declares integer.
|
|
293
|
+
|
|
294
|
+
:return: none
|
|
295
|
+
:rtype: None
|
|
296
|
+
"""
|
|
297
|
+
shot = _STORYBOARD["$defs"]["Shot"]
|
|
298
|
+
assert declared_type(shot["properties"]["seconds"], _STORYBOARD) == "integer"
|
|
299
|
+
|
|
300
|
+
def test_a_nullable_type_list_answers_with_the_real_type(self) -> None:
|
|
301
|
+
"""``["array", "null"]`` declares array.
|
|
302
|
+
|
|
303
|
+
:return: none
|
|
304
|
+
:rtype: None
|
|
305
|
+
"""
|
|
306
|
+
assert declared_type({"type": ["array", "null"]}, {}) == "array"
|
|
307
|
+
|
|
308
|
+
def test_a_nested_model_answers_with_its_definition(self) -> None:
|
|
309
|
+
"""a ``$ref`` is followed into the definitions.
|
|
310
|
+
|
|
311
|
+
:return: none
|
|
312
|
+
:rtype: None
|
|
313
|
+
"""
|
|
314
|
+
assert declared_type(_STORYBOARD["properties"]["scene"], _STORYBOARD) == "object"
|
|
315
|
+
|
|
316
|
+
def test_an_optional_nested_model_answers_with_its_definition(self) -> None:
|
|
317
|
+
"""an optional ``$ref`` is followed too.
|
|
318
|
+
|
|
319
|
+
:return: none
|
|
320
|
+
:rtype: None
|
|
321
|
+
"""
|
|
322
|
+
assert declared_type(_STORYBOARD["properties"]["outline"], _STORYBOARD) == "object"
|
|
323
|
+
|
|
324
|
+
def test_a_union_of_real_types_declares_no_single_type(self) -> None:
|
|
325
|
+
"""``Close | Wide`` could be either.
|
|
326
|
+
|
|
327
|
+
:return: none
|
|
328
|
+
:rtype: None
|
|
329
|
+
"""
|
|
330
|
+
assert declared_type(_STORYBOARD["properties"]["framing"], _STORYBOARD) is None
|
|
331
|
+
|
|
332
|
+
def test_an_untyped_field_declares_nothing(self) -> None:
|
|
333
|
+
"""``Any`` declares no type.
|
|
334
|
+
|
|
335
|
+
:return: none
|
|
336
|
+
:rtype: None
|
|
337
|
+
"""
|
|
338
|
+
assert declared_type(_STORYBOARD["properties"]["anything"], _STORYBOARD) is None
|
|
339
|
+
|
|
340
|
+
def test_a_reference_it_cannot_follow_declares_nothing_and_does_not_raise(self) -> None:
|
|
341
|
+
"""a missing or foreign reference answers ``None``.
|
|
342
|
+
|
|
343
|
+
:return: none
|
|
344
|
+
:rtype: None
|
|
345
|
+
"""
|
|
346
|
+
assert declared_type({"$ref": "#/$defs/Missing"}, {}) is None
|
|
347
|
+
assert declared_type({"$ref": "https://example.com/x.json"}, {}) is None
|
|
348
|
+
|
|
349
|
+
def test_a_reference_cycle_ends(self) -> None:
|
|
350
|
+
"""a definition that is only a reference to itself answers ``None`` rather than looping.
|
|
351
|
+
|
|
352
|
+
:return: none
|
|
353
|
+
:rtype: None
|
|
354
|
+
"""
|
|
355
|
+
schema = {"$defs": {"Loop": {"$ref": "#/$defs/Loop"}}}
|
|
356
|
+
assert declared_type({"$ref": "#/$defs/Loop"}, schema) is None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class TestPublicSurface:
|
|
360
|
+
def test_every_exported_name_is_importable(self) -> None:
|
|
361
|
+
"""``__all__`` names what the package exports, and each resolves.
|
|
362
|
+
|
|
363
|
+
:return: none
|
|
364
|
+
:rtype: None
|
|
365
|
+
"""
|
|
366
|
+
import threetears.tool_schema as tool_schema
|
|
367
|
+
|
|
368
|
+
assert tool_schema.__all__
|
|
369
|
+
for name in tool_schema.__all__:
|
|
370
|
+
assert callable(getattr(tool_schema, name))
|