toolfuncs 0.4.0__tar.gz → 0.6.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.
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/PKG-INFO +37 -3
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/README.md +36 -2
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/docs/contract.md +10 -4
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/pyproject.toml +1 -1
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/__main__.py +8 -0
- toolfuncs-0.6.0/src/toolfuncs/_doctor.py +181 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/agent_hooks.py +1 -1
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/tests/test_agent_hooks.py +3 -3
- toolfuncs-0.6.0/tests/test_doctor.py +185 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/tests/test_sdk_cli.py +1 -1
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/uv.lock +1 -1
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/.github/workflows/release.yml +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/.gitignore +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/.python-version +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/__init__.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/_errors.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/_import_path.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/_setup.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/_tools.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/py.typed +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/src/toolfuncs/sdk.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/tests/__init__.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/tests/conftest.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/tests/test_import_path.py +0 -0
- {toolfuncs-0.4.0 → toolfuncs-0.6.0}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolfuncs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Executable typed Python function tools discovered through PATH
|
|
5
5
|
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
|
|
6
6
|
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
|
|
@@ -45,6 +45,14 @@ app = toolsdk.App()
|
|
|
45
45
|
|
|
46
46
|
@app.command
|
|
47
47
|
def current(city: str) -> dict[str, object]:
|
|
48
|
+
"""Read current conditions for one city.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
city:
|
|
53
|
+
City whose current conditions should be read.
|
|
54
|
+
"""
|
|
55
|
+
|
|
48
56
|
return weather_client.current(city)
|
|
49
57
|
```
|
|
50
58
|
|
|
@@ -109,7 +117,8 @@ A discoverable toolfunc must:
|
|
|
109
117
|
3. start with one of the two exact toolfuncs shebangs described below;
|
|
110
118
|
4. contain exactly one PEP 723 `script` block with `dependencies`, a one-line description, and required `cli_name` and `python_name` documentation that exactly matches the filename-derived identities;
|
|
111
119
|
5. define a module-level `app = toolsdk.App()` after `import toolfuncs.sdk as toolsdk`;
|
|
112
|
-
6.
|
|
120
|
+
6. give the module and every registered operation a docstring, annotate every operation parameter and return value, and provide effective Cyclopts help for every visible CLI argument;
|
|
121
|
+
7. register each CLI-callable function explicitly with `@app.command`.
|
|
113
122
|
|
|
114
123
|
The PEP 723 dependency list contains the packages needed in addition to the running toolfuncs environment. Listing `toolfuncs` itself is accepted but normally redundant because the shebang has already started the toolfuncs runtime before dependencies are prepared.
|
|
115
124
|
|
|
@@ -153,6 +162,30 @@ The first executable filename claims a command name even when it is not a toolfu
|
|
|
153
162
|
|
|
154
163
|
`toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `cli_name`, `python_name`, `path`, and `description`. An opted-in but invalid tool is reported with its path and validation error without hiding unrelated valid tools; the command also prints a concise warning to stderr and exits successfully. Listing never imports a tool, prepares its dependencies, or executes its source.
|
|
155
164
|
|
|
165
|
+
## Validate a tool while authoring
|
|
166
|
+
|
|
167
|
+
Run the doctor against one explicit source before installing or committing it:
|
|
168
|
+
|
|
169
|
+
```console
|
|
170
|
+
toolfuncs doctor ./weather-report
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The doctor validates the static source contract, prepares the declared dependencies, loads the source, and inspects the actual Cyclopts command graph. It requires a module docstring, at least one registered operation, a docstring and complete annotations for every operation, effective help text for every visible parsed CLI argument, and successful root and command help generation. Aliases are inspected once through their underlying command.
|
|
174
|
+
|
|
175
|
+
The result is always one strict-JSON tagged union. A valid source exits zero:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{"status": "success", "path": "/path/to/weather-report", "commands": ["current"]}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Expected authoring failures return `status: "error"`, list every independently detectable operation error, and exit nonzero without using an exception as the result:
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{"status": "error", "path": "/path/to/weather-report", "errors": ["current: CLI argument '--city' must have help text"]}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Loading is intentional: the registered command graph and Cyclopts' effective parameter help can be constructed dynamically and should not be approximated with a second source parser. Run the doctor in a suitable disposable environment when dependency isolation matters.
|
|
188
|
+
|
|
156
189
|
## Install the runtime
|
|
157
190
|
|
|
158
191
|
```console
|
|
@@ -182,6 +215,7 @@ Setup does not discover, copy, register, link, synchronize, or remove tools. The
|
|
|
182
215
|
## Management commands
|
|
183
216
|
|
|
184
217
|
```console
|
|
218
|
+
toolfuncs doctor SOURCE
|
|
185
219
|
toolfuncs list
|
|
186
220
|
toolfuncs setup [--bin-dir DIRECTORY]
|
|
187
221
|
```
|
|
@@ -196,7 +230,7 @@ The installed hook runs at initial session start and at the session-start event
|
|
|
196
230
|
Available toolfuncs:
|
|
197
231
|
- `weather_report`: Read current weather conditions.
|
|
198
232
|
CLI: `weather-report --help`
|
|
199
|
-
Python: `import toolfuncs as tools; tools.weather_report`
|
|
233
|
+
Python: `import toolfuncs as tools; help(tools.weather_report)`
|
|
200
234
|
```
|
|
201
235
|
|
|
202
236
|
The hook reads only executable headers and PEP 723 metadata. It advertises valid tools and lists invalid tool paths and errors separately; one invalid tool does not suppress the others. It does not import tools or resolve their dependencies. The hook itself is harness infrastructure, not a toolfunc, and therefore does not carry the toolfuncs shebang or appear in the catalog.
|
|
@@ -27,6 +27,14 @@ app = toolsdk.App()
|
|
|
27
27
|
|
|
28
28
|
@app.command
|
|
29
29
|
def current(city: str) -> dict[str, object]:
|
|
30
|
+
"""Read current conditions for one city.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
city:
|
|
35
|
+
City whose current conditions should be read.
|
|
36
|
+
"""
|
|
37
|
+
|
|
30
38
|
return weather_client.current(city)
|
|
31
39
|
```
|
|
32
40
|
|
|
@@ -91,7 +99,8 @@ A discoverable toolfunc must:
|
|
|
91
99
|
3. start with one of the two exact toolfuncs shebangs described below;
|
|
92
100
|
4. contain exactly one PEP 723 `script` block with `dependencies`, a one-line description, and required `cli_name` and `python_name` documentation that exactly matches the filename-derived identities;
|
|
93
101
|
5. define a module-level `app = toolsdk.App()` after `import toolfuncs.sdk as toolsdk`;
|
|
94
|
-
6.
|
|
102
|
+
6. give the module and every registered operation a docstring, annotate every operation parameter and return value, and provide effective Cyclopts help for every visible CLI argument;
|
|
103
|
+
7. register each CLI-callable function explicitly with `@app.command`.
|
|
95
104
|
|
|
96
105
|
The PEP 723 dependency list contains the packages needed in addition to the running toolfuncs environment. Listing `toolfuncs` itself is accepted but normally redundant because the shebang has already started the toolfuncs runtime before dependencies are prepared.
|
|
97
106
|
|
|
@@ -135,6 +144,30 @@ The first executable filename claims a command name even when it is not a toolfu
|
|
|
135
144
|
|
|
136
145
|
`toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `cli_name`, `python_name`, `path`, and `description`. An opted-in but invalid tool is reported with its path and validation error without hiding unrelated valid tools; the command also prints a concise warning to stderr and exits successfully. Listing never imports a tool, prepares its dependencies, or executes its source.
|
|
137
146
|
|
|
147
|
+
## Validate a tool while authoring
|
|
148
|
+
|
|
149
|
+
Run the doctor against one explicit source before installing or committing it:
|
|
150
|
+
|
|
151
|
+
```console
|
|
152
|
+
toolfuncs doctor ./weather-report
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The doctor validates the static source contract, prepares the declared dependencies, loads the source, and inspects the actual Cyclopts command graph. It requires a module docstring, at least one registered operation, a docstring and complete annotations for every operation, effective help text for every visible parsed CLI argument, and successful root and command help generation. Aliases are inspected once through their underlying command.
|
|
156
|
+
|
|
157
|
+
The result is always one strict-JSON tagged union. A valid source exits zero:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{"status": "success", "path": "/path/to/weather-report", "commands": ["current"]}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Expected authoring failures return `status: "error"`, list every independently detectable operation error, and exit nonzero without using an exception as the result:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{"status": "error", "path": "/path/to/weather-report", "errors": ["current: CLI argument '--city' must have help text"]}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Loading is intentional: the registered command graph and Cyclopts' effective parameter help can be constructed dynamically and should not be approximated with a second source parser. Run the doctor in a suitable disposable environment when dependency isolation matters.
|
|
170
|
+
|
|
138
171
|
## Install the runtime
|
|
139
172
|
|
|
140
173
|
```console
|
|
@@ -164,6 +197,7 @@ Setup does not discover, copy, register, link, synchronize, or remove tools. The
|
|
|
164
197
|
## Management commands
|
|
165
198
|
|
|
166
199
|
```console
|
|
200
|
+
toolfuncs doctor SOURCE
|
|
167
201
|
toolfuncs list
|
|
168
202
|
toolfuncs setup [--bin-dir DIRECTORY]
|
|
169
203
|
```
|
|
@@ -178,7 +212,7 @@ The installed hook runs at initial session start and at the session-start event
|
|
|
178
212
|
Available toolfuncs:
|
|
179
213
|
- `weather_report`: Read current weather conditions.
|
|
180
214
|
CLI: `weather-report --help`
|
|
181
|
-
Python: `import toolfuncs as tools; tools.weather_report`
|
|
215
|
+
Python: `import toolfuncs as tools; help(tools.weather_report)`
|
|
182
216
|
```
|
|
183
217
|
|
|
184
218
|
The hook reads only executable headers and PEP 723 metadata. It advertises valid tools and lists invalid tool paths and errors separately; one invalid tool does not suppress the others. It does not import tools or resolve their dependencies. The hook itself is harness infrastructure, not a toolfunc, and therefore does not carry the toolfuncs shebang or appear in the catalog.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# toolfuncs 0.
|
|
1
|
+
# toolfuncs 0.6 contract
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
@@ -21,7 +21,7 @@ python_name = "session_history"
|
|
|
21
21
|
|
|
22
22
|
The declared names do not override runtime identity. `cli_name` must equal the PATH entry's filename, and `python_name` must equal the mechanically derived Python name. A source reached through a symlink therefore documents the symlink basename rather than its target basename. One source has one supported CLI identity; aliases are not part of the contract.
|
|
23
23
|
|
|
24
|
-
The source imports the authoring API as `import toolfuncs.sdk as toolsdk`, defines a module-level `app`, normally `app = toolsdk.App()`, and registers every CLI-callable operation explicitly. Function signatures, annotations, defaults, docstrings, return values, side effects, and exceptions define the operations. `__all__` retains its ordinary Python meaning and does not register commands.
|
|
24
|
+
The source imports the authoring API as `import toolfuncs.sdk as toolsdk`, defines a module-level `app`, normally `app = toolsdk.App()`, and registers every CLI-callable operation explicitly. The module and every registered operation have docstrings. Every operation parameter and return value is annotated, and every visible parsed CLI argument has effective Cyclopts help from its function docstring or richer parameter metadata. Function signatures, annotations, defaults, docstrings, return values, side effects, and exceptions define the operations. `__all__` retains its ordinary Python meaning and does not register commands.
|
|
25
25
|
|
|
26
26
|
`toolsdk.App` supplies strict JSON serialization as its default result action. An explicit `result_action=` replaces that default unchanged. Advanced builder values such as `Parameter`, `Group`, validators, and result helpers live under `toolfuncs.sdk`, not the package root.
|
|
27
27
|
|
|
@@ -60,6 +60,12 @@ An exact-shebang source opts into validation. Discovery records an invalid opted
|
|
|
60
60
|
|
|
61
61
|
Direct invocation or Python lookup of an invalid tool fails with its validation error.
|
|
62
62
|
|
|
63
|
+
## Authoring validation
|
|
64
|
+
|
|
65
|
+
`toolfuncs doctor SOURCE` validates one explicit source, including a source that is not yet on `PATH`. The source must still be executable and satisfy the ordinary filename, shebang, metadata, identity, and dependency policies. Doctor prepares its dependencies, imports the source, resolves its actual Cyclopts command graph, and validates the module docstring, registered operations, operation docstrings and annotations, effective help for every visible parsed CLI argument, and root and command help generation. Aliases are deduplicated through the underlying command object, and nested commands are inspected recursively.
|
|
66
|
+
|
|
67
|
+
The command emits one strict-JSON tagged union. `DoctorSuccess` has `status: "success"`, the absolute source path, and the resolved command paths; it exits zero. `DoctorFailure` has `status: "error"`, the absolute source path, and all independently detectable errors; it exits nonzero. A source validation, dependency, or import failure prevents runtime inspection and therefore produces one loading error. Expected authoring failures are result values rather than exceptions; unexpected failures in the doctor itself retain ordinary exception behavior.
|
|
68
|
+
|
|
63
69
|
## CLI execution
|
|
64
70
|
|
|
65
71
|
When the operating system executes a tool, `/usr/bin/env` resolves the managed `toolfuncs` wrapper on `PATH` and passes it the PATH-visible source path followed by the user's arguments. The wrapper validates dependency policy, starts one uv-prepared environment, and invokes the runtime. Toolfuncs validates identity metadata, imports the source under `python_name`, obtains its module-level app, preserves the exact CLI filename as the Cyclopts program name, and invokes the app with the remaining arguments.
|
|
@@ -74,8 +80,8 @@ Successful default CLI operations emit one strict JSON value. CLI parsing, valid
|
|
|
74
80
|
|
|
75
81
|
Setup does not discover, install, copy, link, register, synchronize, or remove tool sources. Adding and removing tools are ordinary filesystem operations in directories already on `PATH`.
|
|
76
82
|
|
|
77
|
-
The public management commands are `toolfuncs
|
|
83
|
+
The public management commands are `toolfuncs doctor SOURCE`, `toolfuncs list`, and `toolfuncs setup [--bin-dir DIRECTORY]`. Tools are invoked directly by their own CLI names. There is no universal name dispatcher, `run`, `exec`, `describe`, or `sync` command.
|
|
78
84
|
|
|
79
85
|
## Compatibility
|
|
80
86
|
|
|
81
|
-
Version 0.4 intentionally does not accept the former underscore executable names, missing identity declarations, root-level builder exports, public `load`, or public discovery records. All controlled tools must migrate with the runtime.
|
|
87
|
+
Version 0.6 retains the 0.4 runtime and identity contract and adds the authoring doctor. It intentionally does not accept the former underscore executable names, missing identity declarations, root-level builder exports, public `load`, or public discovery records. All controlled tools must migrate with the runtime.
|
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from pydantic_core import to_jsonable_python
|
|
15
15
|
|
|
16
|
+
from ._doctor import doctor
|
|
16
17
|
from ._setup import setup
|
|
17
18
|
from ._tools import (
|
|
18
19
|
ALLOW_FLOATING_VCS_SHEBANG,
|
|
@@ -28,6 +29,7 @@ from .sdk import _run
|
|
|
28
29
|
_HELP = f"""\
|
|
29
30
|
Usage:
|
|
30
31
|
toolfuncs --version
|
|
32
|
+
toolfuncs doctor SOURCE
|
|
31
33
|
toolfuncs list
|
|
32
34
|
toolfuncs setup [--bin-dir DIRECTORY]
|
|
33
35
|
|
|
@@ -85,6 +87,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
85
87
|
_print_json(asdict(catalog))
|
|
86
88
|
_warn_invalid_tools(catalog.invalid_tools)
|
|
87
89
|
return 0
|
|
90
|
+
if command == "doctor":
|
|
91
|
+
if len(tokens) != 1:
|
|
92
|
+
raise SystemExit("usage: toolfuncs doctor SOURCE")
|
|
93
|
+
result = doctor(tokens[0])
|
|
94
|
+
_print_json(asdict(result))
|
|
95
|
+
return 0 if result.status == "success" else 1
|
|
88
96
|
if command == "setup":
|
|
89
97
|
destination = _parse_setup(tokens)
|
|
90
98
|
_print_json(asdict(setup(destination)))
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Validate the runtime interface of one toolfunc source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from cyclopts import App
|
|
16
|
+
|
|
17
|
+
from ._tools import _load_source
|
|
18
|
+
from .sdk import _application
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class DoctorSuccess:
|
|
23
|
+
"""Successful validation of one toolfunc source."""
|
|
24
|
+
|
|
25
|
+
status: Literal["success"] = field(default="success", init=False)
|
|
26
|
+
path: Path
|
|
27
|
+
commands: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DoctorFailure:
|
|
32
|
+
"""Expected validation failures for one toolfunc source."""
|
|
33
|
+
|
|
34
|
+
status: Literal["error"] = field(default="error", init=False)
|
|
35
|
+
path: Path
|
|
36
|
+
errors: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DoctorResult = DoctorSuccess | DoctorFailure
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def doctor(source: str | os.PathLike[str]) -> DoctorResult:
|
|
43
|
+
"""Validate one toolfunc source and its registered runtime interface."""
|
|
44
|
+
|
|
45
|
+
path = _absolute_path(source)
|
|
46
|
+
if path.is_file() and not os.access(path, os.X_OK):
|
|
47
|
+
return DoctorFailure(path=path, errors=("source must be executable",))
|
|
48
|
+
try:
|
|
49
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
50
|
+
module = _load_source(path)
|
|
51
|
+
except (Exception, SystemExit) as exc:
|
|
52
|
+
return DoctorFailure(path=path, errors=(f"could not load source: {_exception_text(exc)}",))
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
app = _application(module)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
return DoctorFailure(path=path, errors=(f"invalid application: {_exception_text(exc)}",))
|
|
58
|
+
|
|
59
|
+
errors: list[str] = []
|
|
60
|
+
if not inspect.getdoc(module):
|
|
61
|
+
errors.append("module must have a docstring")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
command_apps = tuple(_command_apps(app))
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
errors.append(f"could not resolve command graph: {_exception_text(exc)}")
|
|
67
|
+
return DoctorFailure(path=path, errors=tuple(errors))
|
|
68
|
+
commands = tuple(
|
|
69
|
+
_command_name(chain) for chain, command_app in command_apps if command_app.default_command
|
|
70
|
+
)
|
|
71
|
+
operations = _unique_operations(command_apps)
|
|
72
|
+
if not operations:
|
|
73
|
+
errors.append("application must register at least one operation")
|
|
74
|
+
|
|
75
|
+
for command, command_app, operation in operations:
|
|
76
|
+
_inspect_operation(command, command_app, operation, errors)
|
|
77
|
+
|
|
78
|
+
for chain, _ in command_apps:
|
|
79
|
+
try:
|
|
80
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
81
|
+
app.help_print(chain)
|
|
82
|
+
except (Exception, SystemExit) as exc:
|
|
83
|
+
errors.append(
|
|
84
|
+
f"{_command_name(chain)}: could not generate help: {_exception_text(exc)}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if errors:
|
|
88
|
+
return DoctorFailure(path=path, errors=tuple(errors))
|
|
89
|
+
return DoctorSuccess(path=path, commands=commands)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _absolute_path(source: str | os.PathLike[str]) -> Path:
|
|
93
|
+
raw = os.fspath(source)
|
|
94
|
+
if isinstance(raw, bytes):
|
|
95
|
+
raise TypeError("source must be a string path, not bytes")
|
|
96
|
+
return Path(os.path.abspath(Path(raw).expanduser()))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _command_apps(root: App) -> Iterator[tuple[tuple[str, ...], App]]:
|
|
100
|
+
pending: deque[tuple[tuple[str, ...], App]] = deque([((), root)])
|
|
101
|
+
seen: set[int] = set()
|
|
102
|
+
while pending:
|
|
103
|
+
chain, app = pending.popleft()
|
|
104
|
+
if id(app) in seen:
|
|
105
|
+
continue
|
|
106
|
+
seen.add(id(app))
|
|
107
|
+
yield chain, app
|
|
108
|
+
|
|
109
|
+
builtins = {*app.help_flags, *app.version_flags}
|
|
110
|
+
for name, child in app.resolved_commands().items():
|
|
111
|
+
if name not in builtins:
|
|
112
|
+
pending.append(((*chain, name), child))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _unique_operations(
|
|
116
|
+
command_apps: tuple[tuple[tuple[str, ...], App], ...],
|
|
117
|
+
) -> tuple[tuple[str, App, Callable[..., Any]], ...]:
|
|
118
|
+
operations: list[tuple[str, App, Callable[..., Any]]] = []
|
|
119
|
+
seen: set[int] = set()
|
|
120
|
+
for chain, app in command_apps:
|
|
121
|
+
operation = app.default_command
|
|
122
|
+
if operation is None or id(operation) in seen:
|
|
123
|
+
continue
|
|
124
|
+
seen.add(id(operation))
|
|
125
|
+
operations.append((_command_name(chain), app, operation))
|
|
126
|
+
return tuple(operations)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _inspect_operation(
|
|
130
|
+
command: str,
|
|
131
|
+
app: App,
|
|
132
|
+
operation: Callable[..., Any],
|
|
133
|
+
errors: list[str],
|
|
134
|
+
) -> None:
|
|
135
|
+
if not inspect.getdoc(operation):
|
|
136
|
+
errors.append(f"{command}: operation must have a docstring")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
signature = inspect.signature(operation)
|
|
140
|
+
except (TypeError, ValueError) as exc:
|
|
141
|
+
errors.append(f"{command}: could not inspect signature: {_exception_text(exc)}")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
for parameter in signature.parameters.values():
|
|
145
|
+
if parameter.annotation is inspect.Parameter.empty:
|
|
146
|
+
errors.append(f"{command}: parameter {parameter.name!r} must have a type annotation")
|
|
147
|
+
if signature.return_annotation is inspect.Signature.empty:
|
|
148
|
+
errors.append(f"{command}: return value must have a type annotation")
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
arguments = app.assemble_argument_collection(parse_docstring=True)
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
errors.append(f"{command}: could not inspect CLI arguments: {_exception_text(exc)}")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
for argument in arguments:
|
|
157
|
+
if argument.show and argument.parse and not argument.parameter.help:
|
|
158
|
+
errors.append(
|
|
159
|
+
f"{command}: CLI argument {_argument_name(argument)!r} must have help text"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _command_name(chain: tuple[str, ...]) -> str:
|
|
164
|
+
return " ".join(chain) if chain else "<default>"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _argument_name(argument: Any) -> str:
|
|
168
|
+
names = tuple(argument.names)
|
|
169
|
+
if names:
|
|
170
|
+
return names[0]
|
|
171
|
+
if argument.keys:
|
|
172
|
+
return ".".join(argument.keys)
|
|
173
|
+
return argument.field_info.name
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _exception_text(exc: BaseException) -> str:
|
|
177
|
+
message = str(exc)
|
|
178
|
+
return f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = ["DoctorFailure", "DoctorResult", "DoctorSuccess", "doctor"]
|
|
@@ -25,7 +25,7 @@ def catalog_context(catalog: Catalog) -> str | None:
|
|
|
25
25
|
(
|
|
26
26
|
f"- `{tool.python_name}`: {tool.description}",
|
|
27
27
|
f" CLI: `{tool.cli_name} --help`",
|
|
28
|
-
f" Python: `import toolfuncs as tools; tools.{tool.python_name}`",
|
|
28
|
+
f" Python: `import toolfuncs as tools; help(tools.{tool.python_name})`",
|
|
29
29
|
)
|
|
30
30
|
)
|
|
31
31
|
if catalog.invalid_tools:
|
|
@@ -52,10 +52,10 @@ def test_catalog_context_shows_cli_and_python_usage() -> None:
|
|
|
52
52
|
"Available toolfuncs:\n"
|
|
53
53
|
"- `session_history`: Search prior sessions.\n"
|
|
54
54
|
" CLI: `session-history --help`\n"
|
|
55
|
-
" Python: `import toolfuncs as tools; tools.session_history`\n"
|
|
55
|
+
" Python: `import toolfuncs as tools; help(tools.session_history)`\n"
|
|
56
56
|
"- `screenpipe`: Search recorded activity.\n"
|
|
57
57
|
" CLI: `screenpipe --help`\n"
|
|
58
|
-
" Python: `import toolfuncs as tools; tools.screenpipe`\n"
|
|
58
|
+
" Python: `import toolfuncs as tools; help(tools.screenpipe)`\n"
|
|
59
59
|
"</toolfuncs>"
|
|
60
60
|
)
|
|
61
61
|
|
|
@@ -107,7 +107,7 @@ def test_session_start_handler_advertises_startup_and_post_compaction(
|
|
|
107
107
|
assert isinstance(result, shared.outputs.AddContext)
|
|
108
108
|
assert "`weather`" in result.text
|
|
109
109
|
assert "CLI: `weather --help`" in result.text
|
|
110
|
-
assert "Python: `import toolfuncs as tools; tools.weather`" in result.text
|
|
110
|
+
assert "Python: `import toolfuncs as tools; help(tools.weather)`" in result.text
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def test_session_start_handler_emits_nothing_for_an_empty_path(
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from toolfuncs._tools import TOOL_SHEBANG
|
|
8
|
+
|
|
9
|
+
from .conftest import run_python
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _script(cli_name: str, body: str) -> str:
|
|
13
|
+
return (
|
|
14
|
+
f"{TOOL_SHEBANG}\n"
|
|
15
|
+
"# /// script\n"
|
|
16
|
+
'# requires-python = ">=3.10"\n'
|
|
17
|
+
"# dependencies = []\n"
|
|
18
|
+
"# [tool.toolfuncs]\n"
|
|
19
|
+
'# description = "Doctor fixture."\n'
|
|
20
|
+
f"# cli_name = {json.dumps(cli_name)}\n"
|
|
21
|
+
f"# python_name = {json.dumps(cli_name.replace('-', '_'))}\n"
|
|
22
|
+
"# ///\n"
|
|
23
|
+
f"{body}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_executable(path: Path, content: str) -> None:
|
|
28
|
+
path.write_text(content, encoding="utf-8")
|
|
29
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_doctor_reports_a_valid_runtime_interface(tmp_path: Path) -> None:
|
|
33
|
+
source = tmp_path / "renderer"
|
|
34
|
+
_write_executable(
|
|
35
|
+
source,
|
|
36
|
+
_script(
|
|
37
|
+
"renderer",
|
|
38
|
+
'\n"""Render values."""\n\n'
|
|
39
|
+
"from pathlib import Path\n\n"
|
|
40
|
+
"import toolfuncs.sdk as toolsdk\n\n"
|
|
41
|
+
"app = toolsdk.App()\n\n"
|
|
42
|
+
"@app.command(alias='show')\n"
|
|
43
|
+
"def render(value: str, *, output: Path | None = None) -> dict[str, object]:\n"
|
|
44
|
+
' """Render one value.\n\n'
|
|
45
|
+
" Parameters\n"
|
|
46
|
+
" ----------\n"
|
|
47
|
+
" value:\n"
|
|
48
|
+
" Value to render.\n"
|
|
49
|
+
" output:\n"
|
|
50
|
+
" Optional output path.\n"
|
|
51
|
+
' """\n\n'
|
|
52
|
+
" return {'value': value, 'output': output}\n",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
completed = run_python(["-m", "toolfuncs", "doctor", str(source)], cwd=tmp_path)
|
|
57
|
+
|
|
58
|
+
assert completed.returncode == 0, completed.stderr
|
|
59
|
+
assert json.loads(completed.stdout) == {
|
|
60
|
+
"status": "success",
|
|
61
|
+
"path": str(source),
|
|
62
|
+
"commands": ["render"],
|
|
63
|
+
}
|
|
64
|
+
assert completed.stderr == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_doctor_aggregates_operation_and_argument_errors(tmp_path: Path) -> None:
|
|
68
|
+
source = tmp_path / "broken"
|
|
69
|
+
_write_executable(
|
|
70
|
+
source,
|
|
71
|
+
_script(
|
|
72
|
+
"broken",
|
|
73
|
+
"\nimport toolfuncs.sdk as toolsdk\n\n"
|
|
74
|
+
"app = toolsdk.App()\n\n"
|
|
75
|
+
"@app.command\n"
|
|
76
|
+
"def first(value, *, quiet: bool):\n"
|
|
77
|
+
" return value\n\n"
|
|
78
|
+
"@app.command\n"
|
|
79
|
+
"def second(path: str) -> str:\n"
|
|
80
|
+
' """Read a path."""\n'
|
|
81
|
+
" return path\n",
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
completed = run_python(["-m", "toolfuncs", "doctor", str(source)], cwd=tmp_path)
|
|
86
|
+
|
|
87
|
+
assert completed.returncode == 1
|
|
88
|
+
payload = json.loads(completed.stdout)
|
|
89
|
+
assert payload["status"] == "error"
|
|
90
|
+
assert payload["path"] == str(source)
|
|
91
|
+
assert payload["errors"] == [
|
|
92
|
+
"module must have a docstring",
|
|
93
|
+
"first: operation must have a docstring",
|
|
94
|
+
"first: parameter 'value' must have a type annotation",
|
|
95
|
+
"first: return value must have a type annotation",
|
|
96
|
+
"first: CLI argument '--value' must have help text",
|
|
97
|
+
"first: CLI argument '--quiet' must have help text",
|
|
98
|
+
"second: CLI argument '--path' must have help text",
|
|
99
|
+
]
|
|
100
|
+
assert completed.stderr == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_doctor_accepts_effective_parameter_help_from_cyclopts(tmp_path: Path) -> None:
|
|
104
|
+
source = tmp_path / "annotated-help"
|
|
105
|
+
_write_executable(
|
|
106
|
+
source,
|
|
107
|
+
_script(
|
|
108
|
+
"annotated-help",
|
|
109
|
+
'\n"""Use explicit Cyclopts help."""\n\n'
|
|
110
|
+
"from typing import Annotated\n\n"
|
|
111
|
+
"import toolfuncs.sdk as toolsdk\n\n"
|
|
112
|
+
"app = toolsdk.App()\n\n"
|
|
113
|
+
"@app.command\n"
|
|
114
|
+
"def run(value: Annotated[str, toolsdk.Parameter(help='Value to use.')]) -> str:\n"
|
|
115
|
+
' """Use one value."""\n'
|
|
116
|
+
" return value\n",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
completed = run_python(["-m", "toolfuncs", "doctor", str(source)], cwd=tmp_path)
|
|
121
|
+
|
|
122
|
+
assert completed.returncode == 0, completed.stderr
|
|
123
|
+
assert json.loads(completed.stdout)["status"] == "success"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_doctor_inspects_nested_commands_once_through_aliases(tmp_path: Path) -> None:
|
|
127
|
+
source = tmp_path / "nested"
|
|
128
|
+
_write_executable(
|
|
129
|
+
source,
|
|
130
|
+
_script(
|
|
131
|
+
"nested",
|
|
132
|
+
'\n"""Run nested commands."""\n\n'
|
|
133
|
+
"import toolfuncs.sdk as toolsdk\n\n"
|
|
134
|
+
"app = toolsdk.App()\n"
|
|
135
|
+
"group = toolsdk.App(name='group')\n"
|
|
136
|
+
"app.command(group)\n\n"
|
|
137
|
+
"@group.command(alias='do')\n"
|
|
138
|
+
"def act(value: str) -> str:\n"
|
|
139
|
+
' """Act on one value.\n\n'
|
|
140
|
+
" Parameters\n"
|
|
141
|
+
" ----------\n"
|
|
142
|
+
" value:\n"
|
|
143
|
+
" Value to act on.\n"
|
|
144
|
+
' """\n\n'
|
|
145
|
+
" return value\n",
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
completed = run_python(["-m", "toolfuncs", "doctor", str(source)], cwd=tmp_path)
|
|
150
|
+
|
|
151
|
+
assert completed.returncode == 0, completed.stderr
|
|
152
|
+
assert json.loads(completed.stdout)["commands"] == ["group act"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_doctor_requires_an_executable_source(tmp_path: Path) -> None:
|
|
156
|
+
source = tmp_path / "not-executable"
|
|
157
|
+
source.write_text(_script("not-executable", '\n"""Not executable."""\n'), encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
completed = run_python(["-m", "toolfuncs", "doctor", str(source)], cwd=tmp_path)
|
|
160
|
+
|
|
161
|
+
assert completed.returncode == 1
|
|
162
|
+
assert json.loads(completed.stdout)["errors"] == ["source must be executable"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_doctor_returns_a_tagged_failure_when_loading_fails(tmp_path: Path) -> None:
|
|
166
|
+
source = tmp_path / "load-failure"
|
|
167
|
+
_write_executable(
|
|
168
|
+
source,
|
|
169
|
+
_script(
|
|
170
|
+
"load-failure",
|
|
171
|
+
'\n"""Fail while loading."""\n\n'
|
|
172
|
+
'print("import noise")\n'
|
|
173
|
+
'raise RuntimeError("broken import")\n',
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
completed = run_python(["-m", "toolfuncs", "doctor", str(source)], cwd=tmp_path)
|
|
178
|
+
|
|
179
|
+
assert completed.returncode == 1
|
|
180
|
+
assert json.loads(completed.stdout) == {
|
|
181
|
+
"status": "error",
|
|
182
|
+
"path": str(source),
|
|
183
|
+
"errors": ["could not load source: RuntimeError: broken import"],
|
|
184
|
+
}
|
|
185
|
+
assert completed.stderr == ""
|
|
@@ -34,7 +34,7 @@ def test_cli_prints_installed_version(tmp_path: Path, flag: str) -> None:
|
|
|
34
34
|
completed = run_python(["-m", "toolfuncs", flag], cwd=tmp_path)
|
|
35
35
|
|
|
36
36
|
assert completed.returncode == 0
|
|
37
|
-
assert completed.stdout == "0.
|
|
37
|
+
assert completed.stdout == "0.6.0\n"
|
|
38
38
|
assert completed.stderr == ""
|
|
39
39
|
|
|
40
40
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|