toolfuncs 0.7.0__tar.gz → 0.8.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.
Files changed (26) hide show
  1. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/PKG-INFO +24 -3
  2. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/README.md +23 -2
  3. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/docs/contract.md +7 -3
  4. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/pyproject.toml +1 -1
  5. toolfuncs-0.8.0/src/toolfuncs/_descriptions.py +52 -0
  6. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/_tools.py +11 -4
  7. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/sdk.py +11 -0
  8. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/tests/test_sdk_cli.py +34 -2
  9. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/tests/test_tools.py +60 -0
  10. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/uv.lock +1 -1
  11. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/.github/workflows/release.yml +0 -0
  12. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/.gitignore +0 -0
  13. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/.python-version +0 -0
  14. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/__init__.py +0 -0
  15. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/__main__.py +0 -0
  16. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/_doctor.py +0 -0
  17. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/_errors.py +0 -0
  18. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/_import_path.py +0 -0
  19. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/_setup.py +0 -0
  20. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/agent_hooks.py +0 -0
  21. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/src/toolfuncs/py.typed +0 -0
  22. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/tests/__init__.py +0 -0
  23. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/tests/conftest.py +0 -0
  24. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/tests/test_agent_hooks.py +0 -0
  25. {toolfuncs-0.7.0 → toolfuncs-0.8.0}/tests/test_doctor.py +0 -0
  26. {toolfuncs-0.7.0 → toolfuncs-0.8.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.7.0
3
+ Version: 0.8.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 `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.
201
+ `toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `cli_name`, `python_name`, `path`, and the effective `description`. 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 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.
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 `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.
183
+ `toolfuncs list` returns one JSON object with `tools` and `invalid_tools`. Valid records contain `cli_name`, `python_name`, `path`, and the effective `description`. 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 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.
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.7 contract
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, and exits successfully even when invalid entries exist. The startup hook advertises valid tools and reports invalid tools separately. Discovery and listing never import source, prepare dependencies, or execute tool code.
63
+ `toolfuncs list` emits one JSON object with `tools` and `invalid_tools`, 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.7 adds public explicit-source CLI execution and maps extensionless kebab-case names to snake case in `import_path`. It retains the 0.6 runtime, identity, discovery, and authoring contracts. 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.
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "toolfuncs"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Executable typed Python function tools invoked by path or discovered through PATH"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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.description,
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.strip()
303
- if len(description.splitlines()) != 1:
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.7.0\n"
37
+ assert completed.stdout == "0.8.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
- def _script(cli_name: str, description: str, body: str = "") -> str:
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"
@@ -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:
@@ -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"
@@ -601,7 +601,7 @@ wheels = [
601
601
 
602
602
  [[package]]
603
603
  name = "toolfuncs"
604
- version = "0.7.0"
604
+ version = "0.8.0"
605
605
  source = { editable = "." }
606
606
  dependencies = [
607
607
  { name = "cyclopts" },
File without changes
File without changes
File without changes
File without changes