toolfuncs 0.7.0__tar.gz → 0.9.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.7.0 → toolfuncs-0.9.0}/PKG-INFO +24 -3
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/README.md +23 -2
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/docs/contract.md +7 -3
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/pyproject.toml +1 -1
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/__main__.py +17 -1
- toolfuncs-0.9.0/src/toolfuncs/_descriptions.py +52 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/_tools.py +11 -4
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/sdk.py +11 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/test_sdk_cli.py +40 -8
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/test_tools.py +60 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/uv.lock +1 -1
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/.github/workflows/release.yml +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/.gitignore +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/.python-version +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/__init__.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/_doctor.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/_errors.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/_import_path.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/_setup.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/agent_hooks.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/src/toolfuncs/py.typed +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/__init__.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/conftest.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/test_agent_hooks.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/test_doctor.py +0 -0
- {toolfuncs-0.7.0 → toolfuncs-0.9.0}/tests/test_import_path.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolfuncs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Executable typed Python function tools invoked by path or discovered through PATH
|
|
5
5
|
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
|
|
6
6
|
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
|
|
@@ -139,6 +139,27 @@ A toolfunc source must:
|
|
|
139
139
|
|
|
140
140
|
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.
|
|
141
141
|
|
|
142
|
+
Descriptions are static by default. A tool whose available domain surface changes at runtime may opt into a last-known catalog description with `dynamic = true`:
|
|
143
|
+
|
|
144
|
+
```toml
|
|
145
|
+
[tool.toolfuncs]
|
|
146
|
+
description = "Use connected services."
|
|
147
|
+
dynamic = true
|
|
148
|
+
cli_name = "connected-services"
|
|
149
|
+
python_name = "connected_services"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
After refreshing its own state, the tool publishes one current line without changing its source:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
toolsdk.publish_description(
|
|
156
|
+
"connected-services",
|
|
157
|
+
"Use connected services: Gmail, Google Drive, and Slack.",
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Toolfuncs stores the line at `${XDG_STATE_HOME:-~/.local/state}/toolfuncs/descriptions/connected-services`. Missing or invalid state uses the required static description. The CLI and Python identities always remain static.
|
|
162
|
+
|
|
142
163
|
Git requirements must use a full 40- or 64-hex commit object ID. Branches such as `@main`, tags, abbreviated hashes, and omitted revisions fail before uv resolves them. A tool that deliberately accepts floating-ref refresh and concurrency costs must say so in its source:
|
|
143
164
|
|
|
144
165
|
```python
|
|
@@ -177,7 +198,7 @@ Toolfuncs reads the current process's `PATH` from left to right. It considers on
|
|
|
177
198
|
|
|
178
199
|
The first executable filename claims a command name even when it is not a toolfunc. Therefore an ordinary executable earlier on `PATH` hides a same-named toolfunc later on `PATH`, matching what the shell actually executes. Repeated directories are scanned once, inaccessible directories are skipped, and discovered records are sorted by CLI name.
|
|
179
200
|
|
|
180
|
-
`toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `
|
|
201
|
+
`toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `cli`, `python`, `path`, and the effective `description`; `python` is the complete dynamic facade name, such as `toolfuncs.weather_report`. For `dynamic = true`, discovery reads the optional one-line state file and otherwise uses the static 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, executes its source, or accesses the network.
|
|
181
202
|
|
|
182
203
|
## Validate a tool while authoring
|
|
183
204
|
|
|
@@ -251,7 +272,7 @@ Available toolfuncs:
|
|
|
251
272
|
Python: `import toolfuncs as tools; help(tools.weather_report)`
|
|
252
273
|
```
|
|
253
274
|
|
|
254
|
-
The hook reads
|
|
275
|
+
The hook reads executable headers and PEP 723 metadata, plus the optional one-line state file for tools declaring `dynamic = true`. 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, resolve their dependencies, execute them, or access the network. The hook itself is harness infrastructure, not a toolfunc, and therefore does not carry the toolfuncs shebang or appear in the catalog.
|
|
255
276
|
|
|
256
277
|
## Development
|
|
257
278
|
|
|
@@ -121,6 +121,27 @@ A toolfunc source must:
|
|
|
121
121
|
|
|
122
122
|
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.
|
|
123
123
|
|
|
124
|
+
Descriptions are static by default. A tool whose available domain surface changes at runtime may opt into a last-known catalog description with `dynamic = true`:
|
|
125
|
+
|
|
126
|
+
```toml
|
|
127
|
+
[tool.toolfuncs]
|
|
128
|
+
description = "Use connected services."
|
|
129
|
+
dynamic = true
|
|
130
|
+
cli_name = "connected-services"
|
|
131
|
+
python_name = "connected_services"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
After refreshing its own state, the tool publishes one current line without changing its source:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
toolsdk.publish_description(
|
|
138
|
+
"connected-services",
|
|
139
|
+
"Use connected services: Gmail, Google Drive, and Slack.",
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Toolfuncs stores the line at `${XDG_STATE_HOME:-~/.local/state}/toolfuncs/descriptions/connected-services`. Missing or invalid state uses the required static description. The CLI and Python identities always remain static.
|
|
144
|
+
|
|
124
145
|
Git requirements must use a full 40- or 64-hex commit object ID. Branches such as `@main`, tags, abbreviated hashes, and omitted revisions fail before uv resolves them. A tool that deliberately accepts floating-ref refresh and concurrency costs must say so in its source:
|
|
125
146
|
|
|
126
147
|
```python
|
|
@@ -159,7 +180,7 @@ Toolfuncs reads the current process's `PATH` from left to right. It considers on
|
|
|
159
180
|
|
|
160
181
|
The first executable filename claims a command name even when it is not a toolfunc. Therefore an ordinary executable earlier on `PATH` hides a same-named toolfunc later on `PATH`, matching what the shell actually executes. Repeated directories are scanned once, inaccessible directories are skipped, and discovered records are sorted by CLI name.
|
|
161
182
|
|
|
162
|
-
`toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `
|
|
183
|
+
`toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `cli`, `python`, `path`, and the effective `description`; `python` is the complete dynamic facade name, such as `toolfuncs.weather_report`. For `dynamic = true`, discovery reads the optional one-line state file and otherwise uses the static 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, executes its source, or accesses the network.
|
|
163
184
|
|
|
164
185
|
## Validate a tool while authoring
|
|
165
186
|
|
|
@@ -233,7 +254,7 @@ Available toolfuncs:
|
|
|
233
254
|
Python: `import toolfuncs as tools; help(tools.weather_report)`
|
|
234
255
|
```
|
|
235
256
|
|
|
236
|
-
The hook reads
|
|
257
|
+
The hook reads executable headers and PEP 723 metadata, plus the optional one-line state file for tools declaring `dynamic = true`. 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, resolve their dependencies, execute them, or access the network. The hook itself is harness infrastructure, not a toolfunc, and therefore does not carry the toolfuncs shebang or appear in the catalog.
|
|
237
258
|
|
|
238
259
|
## Development
|
|
239
260
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# toolfuncs 0.
|
|
1
|
+
# toolfuncs 0.8 contract
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
@@ -21,10 +21,14 @@ 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
|
+
`dynamic` is an optional boolean and defaults to `false`. When it is `true`, catalog discovery may replace the static description with the valid one-line UTF-8 contents of `${XDG_STATE_HOME:-~/.local/state}/toolfuncs/descriptions/<cli_name>`. The static description remains required and is the fallback for missing, unreadable, undecodable, empty, or multiline state. The flag does not make either identity dynamic and does not describe the command graph.
|
|
25
|
+
|
|
24
26
|
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
27
|
|
|
26
28
|
`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
29
|
|
|
30
|
+
`toolsdk.publish_description(cli_name, description)` validates the ordinary CLI-name and one-line description rules and atomically replaces that CLI identity's dynamic description file. It returns the written path. Published descriptions are last-known state with no framework expiry; a tool overwrites the line when its domain inventory changes.
|
|
31
|
+
|
|
28
32
|
## Python interface
|
|
29
33
|
|
|
30
34
|
The standard Python interface is:
|
|
@@ -56,7 +60,7 @@ Discovery reads the current `PATH` from left to right. The first executable file
|
|
|
56
60
|
|
|
57
61
|
An exact-shebang source opts into validation. Discovery records an invalid opted-in source instead of raising and suppressing unrelated tools. The internal catalog contains sorted valid `Tool(cli_name, python_name, path, description)` records and sorted `InvalidTool(path, error)` records.
|
|
58
62
|
|
|
59
|
-
`toolfuncs list` emits one JSON object with `tools` and `invalid_tools`, prints a concise warning with invalid paths and errors to stderr
|
|
63
|
+
`toolfuncs list` emits one JSON object with `tools` and `invalid_tools`. Each valid output record contains `cli`, the complete `python` facade name such as `toolfuncs.session_history`, `path`, and `description`; these output labels are distinct from the validated metadata and internal `Tool(cli_name, python_name, path, description)` fields. The command prints a concise warning with invalid paths and errors to stderr and exits successfully even when invalid entries exist. The startup hook advertises valid tools and reports invalid tools separately. For a tool declaring `dynamic = true`, the record's description is the valid published line when present and the static description otherwise. Discovery and listing never import source, prepare dependencies, execute tool code, or access the network.
|
|
60
64
|
|
|
61
65
|
Direct invocation or Python lookup of an invalid tool fails with its validation error.
|
|
62
66
|
|
|
@@ -84,4 +88,4 @@ The public management commands are `toolfuncs doctor SOURCE`, `toolfuncs list`,
|
|
|
84
88
|
|
|
85
89
|
## Compatibility
|
|
86
90
|
|
|
87
|
-
Version 0.
|
|
91
|
+
Version 0.8 adds explicit opt-in last-known dynamic descriptions through `[tool.toolfuncs].dynamic` and `toolsdk.publish_description`. It retains the 0.7 identity, static-default discovery, runtime, and authoring contracts. Existing tools need no changes and incur no dynamic-description lookup unless they opt in.
|
|
@@ -18,6 +18,7 @@ from ._setup import setup
|
|
|
18
18
|
from ._tools import (
|
|
19
19
|
ALLOW_FLOATING_VCS_SHEBANG,
|
|
20
20
|
TOOL_SHEBANG,
|
|
21
|
+
Catalog,
|
|
21
22
|
InvalidTool,
|
|
22
23
|
_discover,
|
|
23
24
|
_load_prepared_source,
|
|
@@ -88,7 +89,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
88
89
|
if tokens:
|
|
89
90
|
raise SystemExit("toolfuncs list accepts no arguments")
|
|
90
91
|
catalog = _discover()
|
|
91
|
-
_print_json(
|
|
92
|
+
_print_json(_catalog_payload(catalog))
|
|
92
93
|
_warn_invalid_tools(catalog.invalid_tools)
|
|
93
94
|
return 0
|
|
94
95
|
if command == "doctor":
|
|
@@ -111,6 +112,21 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
111
112
|
return result if isinstance(result, int) else 0
|
|
112
113
|
|
|
113
114
|
|
|
115
|
+
def _catalog_payload(catalog: Catalog) -> dict[str, object]:
|
|
116
|
+
return {
|
|
117
|
+
"tools": [
|
|
118
|
+
{
|
|
119
|
+
"cli": tool.cli_name,
|
|
120
|
+
"python": f"toolfuncs.{tool.python_name}",
|
|
121
|
+
"path": tool.path,
|
|
122
|
+
"description": tool.description,
|
|
123
|
+
}
|
|
124
|
+
for tool in catalog.tools
|
|
125
|
+
],
|
|
126
|
+
"invalid_tools": [asdict(tool) for tool in catalog.invalid_tools],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
114
130
|
def _parse_setup(tokens: list[str]) -> Path | None:
|
|
115
131
|
if not tokens:
|
|
116
132
|
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Persistent one-line catalog descriptions for explicitly dynamic tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _normalize_description(value: str) -> str | None:
|
|
11
|
+
description = value.strip()
|
|
12
|
+
if not description or len(description.splitlines()) != 1:
|
|
13
|
+
return None
|
|
14
|
+
return description
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _description_path(cli_name: str) -> Path:
|
|
18
|
+
if raw_state_home := os.environ.get("XDG_STATE_HOME"):
|
|
19
|
+
state_home = Path(raw_state_home).expanduser()
|
|
20
|
+
else:
|
|
21
|
+
state_home = Path.home() / ".local" / "state"
|
|
22
|
+
return state_home / "toolfuncs" / "descriptions" / cli_name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _published_description(cli_name: str) -> str | None:
|
|
26
|
+
try:
|
|
27
|
+
value = _description_path(cli_name).read_text(encoding="utf-8")
|
|
28
|
+
except (OSError, UnicodeError):
|
|
29
|
+
return None
|
|
30
|
+
return _normalize_description(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _publish_description(cli_name: str, description: str) -> Path:
|
|
34
|
+
normalized = _normalize_description(description)
|
|
35
|
+
if normalized is None:
|
|
36
|
+
raise ValueError("description must contain exactly one nonempty line")
|
|
37
|
+
|
|
38
|
+
target = _description_path(cli_name)
|
|
39
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
40
|
+
descriptor, raw_temp = tempfile.mkstemp(prefix=f".{cli_name}.", dir=target.parent)
|
|
41
|
+
temp = Path(raw_temp)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
44
|
+
handle.write(f"{normalized}\n")
|
|
45
|
+
temp.replace(target)
|
|
46
|
+
except BaseException:
|
|
47
|
+
temp.unlink(missing_ok=True)
|
|
48
|
+
raise
|
|
49
|
+
return target
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = []
|
|
@@ -25,6 +25,7 @@ if sys.version_info >= (3, 11):
|
|
|
25
25
|
else:
|
|
26
26
|
import tomli as tomllib
|
|
27
27
|
|
|
28
|
+
from ._descriptions import _normalize_description, _published_description
|
|
28
29
|
from ._errors import DependencyError, InvalidToolError, ToolConflictError, ToolNotFoundError
|
|
29
30
|
|
|
30
31
|
TOOL_SHEBANG = "#!/usr/bin/env toolfuncs"
|
|
@@ -74,6 +75,7 @@ class _Metadata:
|
|
|
74
75
|
description: str
|
|
75
76
|
cli_name: str
|
|
76
77
|
python_name: str
|
|
78
|
+
dynamic: bool
|
|
77
79
|
|
|
78
80
|
|
|
79
81
|
@dataclass(frozen=True, slots=True)
|
|
@@ -237,7 +239,8 @@ def _inspect(source: Path, *, cli_name: str) -> Tool:
|
|
|
237
239
|
cli_name=cli_name,
|
|
238
240
|
python_name=python_name,
|
|
239
241
|
path=source,
|
|
240
|
-
description=metadata.
|
|
242
|
+
description=(_published_description(cli_name) if metadata.dynamic else None)
|
|
243
|
+
or metadata.description,
|
|
241
244
|
)
|
|
242
245
|
|
|
243
246
|
|
|
@@ -293,14 +296,14 @@ def _metadata(source: Path) -> _Metadata:
|
|
|
293
296
|
raw_config = raw_tool.get("toolfuncs") if isinstance(raw_tool, dict) else None
|
|
294
297
|
if not isinstance(raw_config, dict):
|
|
295
298
|
raise InvalidToolError(f"{source}: PEP 723 metadata must define [tool.toolfuncs]")
|
|
296
|
-
if unknown := sorted(set(raw_config) - {"cli_name", "description", "python_name"}):
|
|
299
|
+
if unknown := sorted(set(raw_config) - {"cli_name", "description", "dynamic", "python_name"}):
|
|
297
300
|
rendered = ", ".join(repr(field) for field in unknown)
|
|
298
301
|
raise InvalidToolError(f"{source}: unknown [tool.toolfuncs] fields: {rendered}")
|
|
299
302
|
raw_description = raw_config.get("description")
|
|
300
303
|
if not isinstance(raw_description, str) or not raw_description.strip():
|
|
301
304
|
raise InvalidToolError(f"{source}: [tool.toolfuncs].description must be a nonempty string")
|
|
302
|
-
description = raw_description
|
|
303
|
-
if
|
|
305
|
+
description = _normalize_description(raw_description)
|
|
306
|
+
if description is None:
|
|
304
307
|
raise InvalidToolError(
|
|
305
308
|
f"{source}: [tool.toolfuncs].description must contain exactly one line"
|
|
306
309
|
)
|
|
@@ -312,12 +315,16 @@ def _metadata(source: Path) -> _Metadata:
|
|
|
312
315
|
if not isinstance(raw_python_name, str) or not raw_python_name.strip():
|
|
313
316
|
raise InvalidToolError(f"{source}: [tool.toolfuncs].python_name must be a nonempty string")
|
|
314
317
|
python_name = raw_python_name
|
|
318
|
+
dynamic = raw_config.get("dynamic", False)
|
|
319
|
+
if not isinstance(dynamic, bool):
|
|
320
|
+
raise InvalidToolError(f"{source}: [tool.toolfuncs].dynamic must be a boolean")
|
|
315
321
|
return _Metadata(
|
|
316
322
|
tuple(dependencies),
|
|
317
323
|
requires_python,
|
|
318
324
|
description,
|
|
319
325
|
cli_name,
|
|
320
326
|
python_name,
|
|
327
|
+
dynamic,
|
|
321
328
|
)
|
|
322
329
|
|
|
323
330
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from collections.abc import Iterable
|
|
7
7
|
from functools import partial
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
from types import ModuleType
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -22,7 +23,9 @@ from cyclopts import (
|
|
|
22
23
|
)
|
|
23
24
|
from pydantic_core import to_jsonable_python
|
|
24
25
|
|
|
26
|
+
from ._descriptions import _publish_description
|
|
25
27
|
from ._errors import ToolDefinitionError
|
|
28
|
+
from ._tools import _python_name_for_cli_name
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
def json_result_action() -> ResultAction:
|
|
@@ -35,6 +38,13 @@ def json_result_action() -> ResultAction:
|
|
|
35
38
|
]
|
|
36
39
|
|
|
37
40
|
|
|
41
|
+
def publish_description(cli_name: str, description: str) -> Path:
|
|
42
|
+
"""Publish one tool's current one-line description for catalog discovery."""
|
|
43
|
+
|
|
44
|
+
_python_name_for_cli_name(cli_name)
|
|
45
|
+
return _publish_description(cli_name, description)
|
|
46
|
+
|
|
47
|
+
|
|
38
48
|
class App(CycloptsApp):
|
|
39
49
|
"""Cyclopts App with strict JSON output as its default result action.
|
|
40
50
|
|
|
@@ -72,6 +82,7 @@ __all__ = [
|
|
|
72
82
|
"ResultAction",
|
|
73
83
|
"Token",
|
|
74
84
|
"json_result_action",
|
|
85
|
+
"publish_description",
|
|
75
86
|
"to_jsonable_python",
|
|
76
87
|
"types",
|
|
77
88
|
"validators",
|
|
@@ -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.9.0\n"
|
|
38
38
|
assert completed.stderr == ""
|
|
39
39
|
|
|
40
40
|
|
|
@@ -52,7 +52,12 @@ def _avoid_installing_live_agent_hooks(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
52
52
|
monkeypatch.setattr(setup_impl, "_install_hooks", lambda _hook_wrapper: None)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
@pytest.fixture(autouse=True)
|
|
56
|
+
def _isolated_state_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
57
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _script(cli_name: str, description: str, body: str = "", *, dynamic: bool = False) -> str:
|
|
56
61
|
return (
|
|
57
62
|
f"{TOOL_SHEBANG}\n"
|
|
58
63
|
"# /// script\n"
|
|
@@ -60,6 +65,7 @@ def _script(cli_name: str, description: str, body: str = "") -> str:
|
|
|
60
65
|
"# dependencies = []\n"
|
|
61
66
|
"# [tool.toolfuncs]\n"
|
|
62
67
|
f"# description = {json.dumps(description)}\n"
|
|
68
|
+
f"# dynamic = {str(dynamic).lower()}\n"
|
|
63
69
|
f"# cli_name = {json.dumps(cli_name)}\n"
|
|
64
70
|
f"# python_name = {json.dumps(cli_name.replace('-', '_'))}\n"
|
|
65
71
|
"# ///\n"
|
|
@@ -289,10 +295,10 @@ def test_direct_command_dynamic_import_and_static_listing(tmp_path: Path) -> Non
|
|
|
289
295
|
assert imported.returncode == 0, imported.stderr
|
|
290
296
|
assert imported.stdout == "Hello, Ada.\n"
|
|
291
297
|
catalog = json.loads(listed.stdout)
|
|
292
|
-
record = next(item for item in catalog["tools"] if item["
|
|
298
|
+
record = next(item for item in catalog["tools"] if item["cli"] == "greet")
|
|
293
299
|
assert record == {
|
|
294
|
-
"
|
|
295
|
-
"
|
|
300
|
+
"cli": "greet",
|
|
301
|
+
"python": "toolfuncs.greet",
|
|
296
302
|
"path": str(source),
|
|
297
303
|
"description": "Greet one person",
|
|
298
304
|
}
|
|
@@ -338,8 +344,8 @@ def test_listing_does_not_import_source(tmp_path: Path) -> None:
|
|
|
338
344
|
assert json.loads(completed.stdout) == {
|
|
339
345
|
"tools": [
|
|
340
346
|
{
|
|
341
|
-
"
|
|
342
|
-
"
|
|
347
|
+
"cli": "unsafe",
|
|
348
|
+
"python": "toolfuncs.unsafe",
|
|
343
349
|
"path": str(bin_dir / "unsafe"),
|
|
344
350
|
"description": "Static description",
|
|
345
351
|
}
|
|
@@ -348,6 +354,32 @@ def test_listing_does_not_import_source(tmp_path: Path) -> None:
|
|
|
348
354
|
}
|
|
349
355
|
|
|
350
356
|
|
|
357
|
+
def test_listing_uses_published_description_without_importing_source(tmp_path: Path) -> None:
|
|
358
|
+
bin_dir = tmp_path / "bin"
|
|
359
|
+
bin_dir.mkdir()
|
|
360
|
+
_write_executable(
|
|
361
|
+
bin_dir / "dynamic-tool",
|
|
362
|
+
_script(
|
|
363
|
+
"dynamic-tool",
|
|
364
|
+
"Static fallback",
|
|
365
|
+
"raise RuntimeError('source was imported')\n",
|
|
366
|
+
dynamic=True,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
toolsdk.publish_description("dynamic-tool", "Current apps: Gmail and Slack.")
|
|
370
|
+
|
|
371
|
+
completed = run_python(
|
|
372
|
+
["-m", "toolfuncs", "list"],
|
|
373
|
+
cwd=tmp_path,
|
|
374
|
+
env={"PATH": str(bin_dir), "XDG_STATE_HOME": str(tmp_path / "state")},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
assert completed.returncode == 0, completed.stderr
|
|
378
|
+
assert json.loads(completed.stdout)["tools"][0]["description"] == (
|
|
379
|
+
"Current apps: Gmail and Slack."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
351
383
|
def test_listing_warns_about_invalid_tools_without_hiding_valid_tools(
|
|
352
384
|
tmp_path: Path,
|
|
353
385
|
) -> None:
|
|
@@ -364,7 +396,7 @@ def test_listing_warns_about_invalid_tools_without_hiding_valid_tools(
|
|
|
364
396
|
|
|
365
397
|
assert completed.returncode == 0
|
|
366
398
|
catalog = json.loads(completed.stdout)
|
|
367
|
-
assert [tool["
|
|
399
|
+
assert [tool["cli"] for tool in catalog["tools"]] == ["valid-tool"]
|
|
368
400
|
assert [Path(item["path"]).name for item in catalog["invalid_tools"]] == ["invalid_tool"]
|
|
369
401
|
assert "warning: found 1 invalid toolfunc:" in completed.stderr
|
|
370
402
|
assert "lowercase ASCII kebab-case" in completed.stderr
|
|
@@ -20,6 +20,7 @@ from toolfuncs._tools import (
|
|
|
20
20
|
_discover,
|
|
21
21
|
_load_python_name,
|
|
22
22
|
)
|
|
23
|
+
from toolfuncs.sdk import publish_description
|
|
23
24
|
|
|
24
25
|
from .conftest import run_python
|
|
25
26
|
|
|
@@ -34,6 +35,7 @@ def _script(
|
|
|
34
35
|
shebang: str = TOOL_SHEBANG,
|
|
35
36
|
declared_cli_name: str | None = None,
|
|
36
37
|
declared_python_name: str | None = None,
|
|
38
|
+
dynamic: bool = False,
|
|
37
39
|
) -> str:
|
|
38
40
|
return (
|
|
39
41
|
f"{shebang}\n"
|
|
@@ -42,6 +44,7 @@ def _script(
|
|
|
42
44
|
f"# dependencies = {json.dumps(dependencies or [])}\n"
|
|
43
45
|
"# [tool.toolfuncs]\n"
|
|
44
46
|
f"# description = {json.dumps(description)}\n"
|
|
47
|
+
f"# dynamic = {str(dynamic).lower()}\n"
|
|
45
48
|
f"# cli_name = {json.dumps(declared_cli_name or cli_name)}\n"
|
|
46
49
|
f"# python_name = "
|
|
47
50
|
f"{json.dumps(declared_python_name or cli_name.replace('-', '_'))}\n"
|
|
@@ -59,6 +62,11 @@ def _path(*directories: Path) -> str:
|
|
|
59
62
|
return os.pathsep.join(str(directory) for directory in directories)
|
|
60
63
|
|
|
61
64
|
|
|
65
|
+
@pytest.fixture(autouse=True)
|
|
66
|
+
def _isolated_state_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
67
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
68
|
+
|
|
69
|
+
|
|
62
70
|
def test_path_order_matches_shell_precedence(tmp_path: Path) -> None:
|
|
63
71
|
first = tmp_path / "first"
|
|
64
72
|
second = tmp_path / "second"
|
|
@@ -75,6 +83,58 @@ def test_path_order_matches_shell_precedence(tmp_path: Path) -> None:
|
|
|
75
83
|
assert tools["second-only"].path == second / "second-only"
|
|
76
84
|
|
|
77
85
|
|
|
86
|
+
def test_dynamic_description_is_an_explicit_last_known_override(tmp_path: Path) -> None:
|
|
87
|
+
bin_dir = tmp_path / "bin"
|
|
88
|
+
bin_dir.mkdir()
|
|
89
|
+
_write_tool(bin_dir / "dynamic-tool", _script("dynamic-tool", "Static", dynamic=True))
|
|
90
|
+
_write_tool(bin_dir / "static-tool", _script("static-tool", "Static"))
|
|
91
|
+
|
|
92
|
+
dynamic_path = publish_description("dynamic-tool", "Current apps: Gmail and Slack.")
|
|
93
|
+
static_path = publish_description("static-tool", "Must not be used.")
|
|
94
|
+
tools = {tool.cli_name: tool for tool in _discover(path=str(bin_dir)).tools}
|
|
95
|
+
|
|
96
|
+
assert dynamic_path.read_text(encoding="utf-8") == "Current apps: Gmail and Slack.\n"
|
|
97
|
+
assert static_path.read_text(encoding="utf-8") == "Must not be used.\n"
|
|
98
|
+
assert tools["dynamic-tool"].description == "Current apps: Gmail and Slack."
|
|
99
|
+
assert tools["static-tool"].description == "Static"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("published", ["", "first\nsecond\n", "invalid-utf8"])
|
|
103
|
+
def test_invalid_dynamic_description_state_falls_back_to_static(
|
|
104
|
+
tmp_path: Path, published: str
|
|
105
|
+
) -> None:
|
|
106
|
+
bin_dir = tmp_path / "bin"
|
|
107
|
+
bin_dir.mkdir()
|
|
108
|
+
_write_tool(bin_dir / "dynamic-tool", _script("dynamic-tool", "Static", dynamic=True))
|
|
109
|
+
description_path = tmp_path / "state" / "toolfuncs" / "descriptions" / "dynamic-tool"
|
|
110
|
+
description_path.parent.mkdir(parents=True)
|
|
111
|
+
if published == "invalid-utf8":
|
|
112
|
+
description_path.write_bytes(b"\xff")
|
|
113
|
+
else:
|
|
114
|
+
description_path.write_text(published, encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
assert _discover(path=str(bin_dir)).tools[0].description == "Static"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_publish_description_validates_name_and_one_line_text() -> None:
|
|
120
|
+
with pytest.raises(InvalidToolError, match="lowercase ASCII kebab-case"):
|
|
121
|
+
publish_description("not_valid", "Description")
|
|
122
|
+
with pytest.raises(ValueError, match="exactly one nonempty line"):
|
|
123
|
+
publish_description("valid-tool", "first\nsecond")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_dynamic_metadata_must_be_boolean(tmp_path: Path) -> None:
|
|
127
|
+
bin_dir = tmp_path / "bin"
|
|
128
|
+
bin_dir.mkdir()
|
|
129
|
+
source = _script("dynamic-tool", "Static").replace("# dynamic = false", '# dynamic = "yes"')
|
|
130
|
+
_write_tool(bin_dir / "dynamic-tool", source)
|
|
131
|
+
|
|
132
|
+
catalog = _discover(path=str(bin_dir))
|
|
133
|
+
|
|
134
|
+
assert len(catalog.invalid_tools) == 1
|
|
135
|
+
assert "dynamic must be a boolean" in catalog.invalid_tools[0].error
|
|
136
|
+
|
|
137
|
+
|
|
78
138
|
def test_ordinary_executable_shadows_later_toolfunc(tmp_path: Path) -> None:
|
|
79
139
|
first = tmp_path / "first"
|
|
80
140
|
second = tmp_path / "second"
|
|
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
|
|
File without changes
|