toolfuncs 0.5.0__tar.gz → 0.7.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 (25) hide show
  1. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/PKG-INFO +69 -17
  2. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/README.md +67 -15
  3. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/docs/contract.md +14 -8
  4. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/pyproject.toml +2 -2
  5. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/__main__.py +13 -1
  6. toolfuncs-0.7.0/src/toolfuncs/_doctor.py +181 -0
  7. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/_import_path.py +8 -4
  8. toolfuncs-0.7.0/tests/test_doctor.py +185 -0
  9. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/tests/test_import_path.py +24 -0
  10. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/tests/test_sdk_cli.py +35 -1
  11. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/uv.lock +1 -1
  12. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/.github/workflows/release.yml +0 -0
  13. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/.gitignore +0 -0
  14. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/.python-version +0 -0
  15. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/__init__.py +0 -0
  16. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/_errors.py +0 -0
  17. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/_setup.py +0 -0
  18. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/_tools.py +0 -0
  19. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/agent_hooks.py +0 -0
  20. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/py.typed +0 -0
  21. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/src/toolfuncs/sdk.py +0 -0
  22. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/tests/__init__.py +0 -0
  23. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/tests/conftest.py +0 -0
  24. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/tests/test_agent_hooks.py +0 -0
  25. {toolfuncs-0.5.0 → toolfuncs-0.7.0}/tests/test_tools.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: toolfuncs
3
- Version: 0.5.0
4
- Summary: Executable typed Python function tools discovered through PATH
3
+ Version: 0.7.0
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
7
7
  Author-email: Nima Shoghi <nima@boltz.bio>
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  `toolfuncs` turns typed Python functions into tools that have matching Python and command-line interfaces.
22
22
 
23
- A toolfunc is one executable Python script on `PATH`. Its lowercase kebab-case filename is its command name, and its Python import name is the same spelling with hyphens replaced by underscores. Its implementation may import any packages declared in its PEP 723 requirements, but packages and projects are not themselves discoverable toolfuncs.
23
+ A toolfunc is one executable Python script. Its lowercase kebab-case filename is its command name, and its Python import name is the same spelling with hyphens replaced by underscores. A known source can be called or imported directly from any path. Putting it on `PATH` additionally makes its filename a shell command and exposes it to discovery and dynamic Python lookup. Its implementation may import any packages declared in its PEP 723 requirements, but packages and projects are not themselves toolfuncs.
24
24
 
25
25
  ## Write a toolfunc
26
26
 
@@ -45,28 +45,55 @@ 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
 
51
- Make it executable and place it in any existing `PATH` directory:
59
+ Make it executable:
52
60
 
53
61
  ```console
54
62
  chmod +x weather-report
55
- mv weather-report ~/.local/bin/
56
63
  ```
57
64
 
58
- That is the complete registration operation.
65
+ No registration operation is required. Move it to an existing `PATH` directory only when it should be globally discoverable:
66
+
67
+ ```console
68
+ mv weather-report ~/.local/bin/
69
+ ```
59
70
 
60
71
  ## Call it
61
72
 
62
- The executable is the CLI:
73
+ A known source can be run explicitly without adding it to `PATH`:
74
+
75
+ ```console
76
+ $ toolfuncs ./weather-report current London
77
+ {"city": "London", "temperature": 18}
78
+ ```
79
+
80
+ When the source is on `PATH`, its filename is also the CLI:
63
81
 
64
82
  ```console
65
83
  $ weather-report current London
66
84
  {"city": "London", "temperature": 18}
67
85
  ```
68
86
 
69
- The same file is dynamically importable in a Python process that has `toolfuncs` installed and the same `PATH`:
87
+ A known source is importable by path:
88
+
89
+ ```python
90
+ import toolfuncs as tools
91
+
92
+ weather_report = tools.import_path("./weather-report")
93
+ conditions = weather_report.current("London")
94
+ ```
95
+
96
+ When the source is on `PATH`, the same file is also dynamically importable by name in a Python process with that `PATH`:
70
97
 
71
98
  ```python
72
99
  import toolfuncs as tools
@@ -74,8 +101,7 @@ import toolfuncs as tools
74
101
  conditions = tools.weather_report.current("London")
75
102
  ```
76
103
 
77
- `import_path()` is the narrower utility for a local script whose path is already
78
- known:
104
+ `import_path()` also works for an ordinary local Python script whose path is already known:
79
105
 
80
106
  ```python
81
107
  import toolfuncs as tools
@@ -83,8 +109,7 @@ import toolfuncs as tools
83
109
  module = tools.import_path("scripts/prepare_data.py")
84
110
  ```
85
111
 
86
- It accepts one ordinary `.py` file or extensionless script and derives the
87
- module name from its filename. It does not require a shebang, executable bit,
112
+ It accepts one ordinary `.py` file or extensionless script and derives the module name from its filename. An extensionless lowercase kebab-case filename is mapped to its snake-case Python name. It does not require a shebang, executable bit,
88
113
  `[tool.toolfuncs]` metadata, or `App`, and it does not import packages, projects,
89
114
  distributions, or URLs. When an optional PEP 723 block exists, the default
90
115
  installer installs all declared requirements together into the running Python
@@ -102,14 +127,15 @@ Direct Python calls return ordinary Python objects and raise the original except
102
127
 
103
128
  ## Source contract
104
129
 
105
- A discoverable toolfunc must:
130
+ A toolfunc source must:
106
131
 
107
- 1. be one executable file in a directory on `PATH`;
132
+ 1. be one executable file;
108
133
  2. have an extensionless lowercase ASCII kebab-case filename;
109
134
  3. start with one of the two exact toolfuncs shebangs described below;
110
135
  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
136
  5. define a module-level `app = toolsdk.App()` after `import toolfuncs.sdk as toolsdk`;
112
- 6. register each CLI-callable function explicitly with `@app.command`.
137
+ 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;
138
+ 7. register each CLI-callable function explicitly with `@app.command`.
113
139
 
114
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.
115
141
 
@@ -153,6 +179,30 @@ The first executable filename claims a command name even when it is not a toolfu
153
179
 
154
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.
155
181
 
182
+ ## Validate a tool while authoring
183
+
184
+ Run the doctor against one explicit source before installing or committing it:
185
+
186
+ ```console
187
+ toolfuncs doctor ./weather-report
188
+ ```
189
+
190
+ 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.
191
+
192
+ The result is always one strict-JSON tagged union. A valid source exits zero:
193
+
194
+ ```json
195
+ {"status": "success", "path": "/path/to/weather-report", "commands": ["current"]}
196
+ ```
197
+
198
+ Expected authoring failures return `status: "error"`, list every independently detectable operation error, and exit nonzero without using an exception as the result:
199
+
200
+ ```json
201
+ {"status": "error", "path": "/path/to/weather-report", "errors": ["current: CLI argument '--city' must have help text"]}
202
+ ```
203
+
204
+ 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.
205
+
156
206
  ## Install the runtime
157
207
 
158
208
  ```console
@@ -182,11 +232,13 @@ Setup does not discover, copy, register, link, synchronize, or remove tools. The
182
232
  ## Management commands
183
233
 
184
234
  ```console
235
+ toolfuncs SOURCE [ARGS...]
236
+ toolfuncs doctor SOURCE
185
237
  toolfuncs list
186
238
  toolfuncs setup [--bin-dir DIRECTORY]
187
239
  ```
188
240
 
189
- The source-path argument supplied automatically by `#!/usr/bin/env toolfuncs` is runtime plumbing rather than another user-facing command. Tools are invoked directly by their own names.
241
+ `toolfuncs SOURCE [ARGS...]` runs a known source directly. This is the explicit-source interface, not a name dispatcher: `toolfuncs weather-report` resolves `weather-report` as a source path rather than searching `PATH` for that tool name. A toolfunc on `PATH` can instead be invoked directly by its own filename.
190
242
 
191
243
  ## Agent integration
192
244
 
@@ -211,7 +263,7 @@ uv run ruff check .
211
263
  uv run basedpyright
212
264
  ```
213
265
 
214
- The exact 0.4 guarantees and non-goals are recorded in [the contract](docs/contract.md).
266
+ The exact guarantees and non-goals are recorded in [the contract](docs/contract.md).
215
267
 
216
268
  ## Releasing
217
269
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  `toolfuncs` turns typed Python functions into tools that have matching Python and command-line interfaces.
4
4
 
5
- A toolfunc is one executable Python script on `PATH`. Its lowercase kebab-case filename is its command name, and its Python import name is the same spelling with hyphens replaced by underscores. Its implementation may import any packages declared in its PEP 723 requirements, but packages and projects are not themselves discoverable toolfuncs.
5
+ A toolfunc is one executable Python script. Its lowercase kebab-case filename is its command name, and its Python import name is the same spelling with hyphens replaced by underscores. A known source can be called or imported directly from any path. Putting it on `PATH` additionally makes its filename a shell command and exposes it to discovery and dynamic Python lookup. Its implementation may import any packages declared in its PEP 723 requirements, but packages and projects are not themselves toolfuncs.
6
6
 
7
7
  ## Write a toolfunc
8
8
 
@@ -27,28 +27,55 @@ 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
 
33
- Make it executable and place it in any existing `PATH` directory:
41
+ Make it executable:
34
42
 
35
43
  ```console
36
44
  chmod +x weather-report
37
- mv weather-report ~/.local/bin/
38
45
  ```
39
46
 
40
- That is the complete registration operation.
47
+ No registration operation is required. Move it to an existing `PATH` directory only when it should be globally discoverable:
48
+
49
+ ```console
50
+ mv weather-report ~/.local/bin/
51
+ ```
41
52
 
42
53
  ## Call it
43
54
 
44
- The executable is the CLI:
55
+ A known source can be run explicitly without adding it to `PATH`:
56
+
57
+ ```console
58
+ $ toolfuncs ./weather-report current London
59
+ {"city": "London", "temperature": 18}
60
+ ```
61
+
62
+ When the source is on `PATH`, its filename is also the CLI:
45
63
 
46
64
  ```console
47
65
  $ weather-report current London
48
66
  {"city": "London", "temperature": 18}
49
67
  ```
50
68
 
51
- The same file is dynamically importable in a Python process that has `toolfuncs` installed and the same `PATH`:
69
+ A known source is importable by path:
70
+
71
+ ```python
72
+ import toolfuncs as tools
73
+
74
+ weather_report = tools.import_path("./weather-report")
75
+ conditions = weather_report.current("London")
76
+ ```
77
+
78
+ When the source is on `PATH`, the same file is also dynamically importable by name in a Python process with that `PATH`:
52
79
 
53
80
  ```python
54
81
  import toolfuncs as tools
@@ -56,8 +83,7 @@ import toolfuncs as tools
56
83
  conditions = tools.weather_report.current("London")
57
84
  ```
58
85
 
59
- `import_path()` is the narrower utility for a local script whose path is already
60
- known:
86
+ `import_path()` also works for an ordinary local Python script whose path is already known:
61
87
 
62
88
  ```python
63
89
  import toolfuncs as tools
@@ -65,8 +91,7 @@ import toolfuncs as tools
65
91
  module = tools.import_path("scripts/prepare_data.py")
66
92
  ```
67
93
 
68
- It accepts one ordinary `.py` file or extensionless script and derives the
69
- module name from its filename. It does not require a shebang, executable bit,
94
+ It accepts one ordinary `.py` file or extensionless script and derives the module name from its filename. An extensionless lowercase kebab-case filename is mapped to its snake-case Python name. It does not require a shebang, executable bit,
70
95
  `[tool.toolfuncs]` metadata, or `App`, and it does not import packages, projects,
71
96
  distributions, or URLs. When an optional PEP 723 block exists, the default
72
97
  installer installs all declared requirements together into the running Python
@@ -84,14 +109,15 @@ Direct Python calls return ordinary Python objects and raise the original except
84
109
 
85
110
  ## Source contract
86
111
 
87
- A discoverable toolfunc must:
112
+ A toolfunc source must:
88
113
 
89
- 1. be one executable file in a directory on `PATH`;
114
+ 1. be one executable file;
90
115
  2. have an extensionless lowercase ASCII kebab-case filename;
91
116
  3. start with one of the two exact toolfuncs shebangs described below;
92
117
  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
118
  5. define a module-level `app = toolsdk.App()` after `import toolfuncs.sdk as toolsdk`;
94
- 6. register each CLI-callable function explicitly with `@app.command`.
119
+ 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;
120
+ 7. register each CLI-callable function explicitly with `@app.command`.
95
121
 
96
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.
97
123
 
@@ -135,6 +161,30 @@ The first executable filename claims a command name even when it is not a toolfu
135
161
 
136
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.
137
163
 
164
+ ## Validate a tool while authoring
165
+
166
+ Run the doctor against one explicit source before installing or committing it:
167
+
168
+ ```console
169
+ toolfuncs doctor ./weather-report
170
+ ```
171
+
172
+ 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.
173
+
174
+ The result is always one strict-JSON tagged union. A valid source exits zero:
175
+
176
+ ```json
177
+ {"status": "success", "path": "/path/to/weather-report", "commands": ["current"]}
178
+ ```
179
+
180
+ Expected authoring failures return `status: "error"`, list every independently detectable operation error, and exit nonzero without using an exception as the result:
181
+
182
+ ```json
183
+ {"status": "error", "path": "/path/to/weather-report", "errors": ["current: CLI argument '--city' must have help text"]}
184
+ ```
185
+
186
+ 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.
187
+
138
188
  ## Install the runtime
139
189
 
140
190
  ```console
@@ -164,11 +214,13 @@ Setup does not discover, copy, register, link, synchronize, or remove tools. The
164
214
  ## Management commands
165
215
 
166
216
  ```console
217
+ toolfuncs SOURCE [ARGS...]
218
+ toolfuncs doctor SOURCE
167
219
  toolfuncs list
168
220
  toolfuncs setup [--bin-dir DIRECTORY]
169
221
  ```
170
222
 
171
- The source-path argument supplied automatically by `#!/usr/bin/env toolfuncs` is runtime plumbing rather than another user-facing command. Tools are invoked directly by their own names.
223
+ `toolfuncs SOURCE [ARGS...]` runs a known source directly. This is the explicit-source interface, not a name dispatcher: `toolfuncs weather-report` resolves `weather-report` as a source path rather than searching `PATH` for that tool name. A toolfunc on `PATH` can instead be invoked directly by its own filename.
172
224
 
173
225
  ## Agent integration
174
226
 
@@ -193,7 +245,7 @@ uv run ruff check .
193
245
  uv run basedpyright
194
246
  ```
195
247
 
196
- The exact 0.4 guarantees and non-goals are recorded in [the contract](docs/contract.md).
248
+ The exact guarantees and non-goals are recorded in [the contract](docs/contract.md).
197
249
 
198
250
  ## Releasing
199
251
 
@@ -1,4 +1,4 @@
1
- # toolfuncs 0.4 contract
1
+ # toolfuncs 0.7 contract
2
2
 
3
3
  ## Purpose
4
4
 
@@ -6,9 +6,9 @@ Toolfuncs projects one explicitly registered set of typed Python functions into
6
6
 
7
7
  ## Tool identity and source
8
8
 
9
- A toolfunc is one executable regular file or symlink in a directory on `PATH`. Its first line is exactly `#!/usr/bin/env toolfuncs`, or the explicit floating-VCS form `#!/usr/bin/env -S toolfuncs --allow-floating-vcs`.
9
+ A toolfunc is one executable regular file or symlink. Its first line is exactly `#!/usr/bin/env toolfuncs`, or the explicit floating-VCS form `#!/usr/bin/env -S toolfuncs --allow-floating-vcs`.
10
10
 
11
- The PATH-visible extensionless filename is the CLI command and must use lowercase ASCII kebab-case: a letter followed by letters or digits, with optional single hyphens separating further nonempty letter-or-digit segments. The Python name is the CLI name with every hyphen replaced by an underscore. A Python name that conflicts with `toolfuncs.sdk` or `toolfuncs.import_path` is invalid.
11
+ The extensionless filename is the CLI command and must use lowercase ASCII kebab-case: a letter followed by letters or digits, with optional single hyphens separating further nonempty letter-or-digit segments. The Python name is the CLI name with every hyphen replaced by an underscore. A Python name that conflicts with `toolfuncs.sdk` or `toolfuncs.import_path` is invalid.
12
12
 
13
13
  The source contains exactly one PEP 723 `script` block. `dependencies` is a list of valid Python requirement strings and `requires-python` is optional. `[tool.toolfuncs]` contains exactly three required documentation fields:
14
14
 
@@ -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
 
@@ -40,7 +40,7 @@ Dynamic attribute lookup maps the requested snake-case Python name to its kebab-
40
40
 
41
41
  The package root publicly exposes only dynamic tools and `import_path`. There is no public `load`, discovery, setup, record, error, or builder API at the root. `import toolfuncs.NAME` is not promised because discovered tools are dynamic package attributes rather than installed submodules.
42
42
 
43
- `import_path` remains separate. It imports one already-known local `.py` file or extensionless script, derives its module name from that source filename, optionally prepares ordinary PEP 723 dependencies, and does not require the toolfuncs shebang, identity metadata, executable permission, or a module-level app.
43
+ `import_path` remains separate. It imports one already-known local `.py` file or extensionless script, derives its module name from that source filename, maps a valid extensionless kebab-case filename to snake case, optionally prepares ordinary PEP 723 dependencies, and does not require the toolfuncs shebang, identity metadata, executable permission, or a module-level app.
44
44
 
45
45
  ## Dependencies
46
46
 
@@ -60,9 +60,15 @@ 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
- 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.
71
+ `toolfuncs SOURCE [ARGS...]` runs a known source without requiring that source to be on `PATH`. It resolves `SOURCE` as a filesystem path and does not search `PATH` for a tool name. When the operating system executes a tool that is on `PATH`, `/usr/bin/env` resolves the managed `toolfuncs` wrapper and passes it the source path followed by the user's arguments. In either form, 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.
66
72
 
67
73
  The source is imported rather than executed as `__main__`. A main guard is neither required nor used. Plain `python SOURCE ...` does not invoke the CLI and does not prepare PEP 723 dependencies.
68
74
 
@@ -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 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.
83
+ The public management commands are `toolfuncs doctor SOURCE`, `toolfuncs list`, and `toolfuncs setup [--bin-dir DIRECTORY]`. The explicit-source execution form is `toolfuncs SOURCE [ARGS...]`; it is not a universal name dispatcher. Tools on `PATH` are invoked directly by their own CLI names. There is no `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.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.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "toolfuncs"
3
- version = "0.5.0"
4
- description = "Executable typed Python function tools discovered through PATH"
3
+ version = "0.7.0"
4
+ description = "Executable typed Python function tools invoked by path or discovered through PATH"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Nima Shoghi", email = "nima@boltz.bio" }
@@ -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,12 +29,17 @@ from .sdk import _run
28
29
  _HELP = f"""\
29
30
  Usage:
30
31
  toolfuncs --version
32
+ toolfuncs SOURCE [ARGS...]
33
+ toolfuncs doctor SOURCE
31
34
  toolfuncs list
32
35
  toolfuncs setup [--bin-dir DIRECTORY]
33
36
 
34
- A toolfunc is an executable Python script on PATH whose first line is exactly:
37
+ A toolfunc is an executable Python script whose first line is exactly:
35
38
  {TOOL_SHEBANG}
36
39
 
40
+ Run a known source with `toolfuncs SOURCE [ARGS...]`. Put it on PATH to invoke
41
+ it by filename and make it available to discovery and dynamic Python lookup.
42
+
37
43
  Floating Git revisions require the explicit shebang:
38
44
  {ALLOW_FLOATING_VCS_SHEBANG}
39
45
  """
@@ -85,6 +91,12 @@ def main(argv: Sequence[str] | None = None) -> int:
85
91
  _print_json(asdict(catalog))
86
92
  _warn_invalid_tools(catalog.invalid_tools)
87
93
  return 0
94
+ if command == "doctor":
95
+ if len(tokens) != 1:
96
+ raise SystemExit("usage: toolfuncs doctor SOURCE")
97
+ result = doctor(tokens[0])
98
+ _print_json(asdict(result))
99
+ return 0 if result.status == "success" else 1
88
100
  if command == "setup":
89
101
  destination = _parse_setup(tokens)
90
102
  _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"]
@@ -37,6 +37,7 @@ _PEP723_BLOCK = re.compile(
37
37
  r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)[ \t]*$[\r\n]"
38
38
  r"(?P<content>(?:^#(?:| .*)$[\r\n]?)+)^# ///[ \t]*$"
39
39
  )
40
+ _EXTENSIONLESS_MODULE_NAME = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z")
40
41
  _IMPORT_LOCK = threading.RLock()
41
42
 
42
43
 
@@ -87,10 +88,11 @@ def import_path(
87
88
  """Import one local Python script.
88
89
 
89
90
  ``source`` may be an ordinary ``.py`` file or an extensionless script. Its
90
- filename determines the module name. When the source contains PEP 723
91
- metadata, its requirements are installed as one list before import. Pass a
92
- custom ``dependency_installer`` to replace the default current-interpreter
93
- pip installation.
91
+ filename determines the module name; valid extensionless kebab-case names
92
+ map to snake case. When the source contains PEP 723 metadata, its
93
+ requirements are installed as one list before import. Pass a custom
94
+ ``dependency_installer`` to replace the default current-interpreter pip
95
+ installation.
94
96
  """
95
97
 
96
98
  path = _source_path(source)
@@ -152,6 +154,8 @@ def _module_name(source: Path) -> str:
152
154
  name = source.stem
153
155
  elif not source.suffix:
154
156
  name = source.name
157
+ if _EXTENSIONLESS_MODULE_NAME.fullmatch(name):
158
+ name = name.replace("-", "_")
155
159
  else:
156
160
  raise ValueError(f"Python script must be extensionless or end in '.py': {source}")
157
161
  if not name.isidentifier() or keyword.iskeyword(name):
@@ -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 == ""
@@ -43,6 +43,30 @@ def test_import_path_accepts_extensionless_script(
43
43
  assert imported.VALUE == 43
44
44
 
45
45
 
46
+ def test_import_path_maps_extensionless_kebab_case_to_snake_case(
47
+ tmp_path: Path,
48
+ unique_name: str,
49
+ ) -> None:
50
+ source = tmp_path / f"{unique_name}-job"
51
+ source.write_text("VALUE = 44\n", encoding="utf-8")
52
+
53
+ imported = import_path(source)
54
+
55
+ assert imported.__name__ == f"{unique_name}_job"
56
+ assert imported.VALUE == 44
57
+
58
+
59
+ def test_import_path_rejects_malformed_extensionless_kebab_case(
60
+ tmp_path: Path,
61
+ unique_name: str,
62
+ ) -> None:
63
+ source = tmp_path / f"{unique_name}--job"
64
+ source.write_text("VALUE = 45\n", encoding="utf-8")
65
+
66
+ with pytest.raises(ValueError, match="valid Python module name"):
67
+ import_path(source)
68
+
69
+
46
70
  def test_pep723_dependencies_are_passed_as_one_list_before_import(
47
71
  tmp_path: Path,
48
72
  unique_name: str,
@@ -34,7 +34,16 @@ 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.5.0\n"
37
+ assert completed.stdout == "0.7.0\n"
38
+ assert completed.stderr == ""
39
+
40
+
41
+ def test_cli_help_advertises_explicit_source_execution(tmp_path: Path) -> None:
42
+ completed = run_python(["-m", "toolfuncs", "--help"], cwd=tmp_path)
43
+
44
+ assert completed.returncode == 0
45
+ assert "toolfuncs SOURCE [ARGS...]" in completed.stdout
46
+ assert "Run a known source" in completed.stdout
38
47
  assert completed.stderr == ""
39
48
 
40
49
 
@@ -106,6 +115,31 @@ def test_runtime_requires_a_module_level_app(tmp_path: Path) -> None:
106
115
  assert "must define 'app'" in completed.stderr
107
116
 
108
117
 
118
+ def test_explicit_source_runs_without_being_on_path(tmp_path: Path) -> None:
119
+ source = tmp_path / "local-tool"
120
+ _write_executable(
121
+ source,
122
+ _script(
123
+ "local-tool",
124
+ "Local tool",
125
+ "import toolfuncs.sdk as toolsdk\n\n"
126
+ "app = toolsdk.App()\n\n"
127
+ "@app.command\n"
128
+ "def ping() -> str:\n"
129
+ " return 'pong'\n",
130
+ ),
131
+ )
132
+
133
+ completed = run_python(
134
+ ["-m", "toolfuncs", str(source), "ping"],
135
+ cwd=tmp_path,
136
+ env={"PATH": os.defpath},
137
+ )
138
+
139
+ assert completed.returncode == 0, completed.stderr
140
+ assert json.loads(completed.stdout) == "pong"
141
+
142
+
109
143
  @pytest.mark.parametrize("prepared", [False, True])
110
144
  @pytest.mark.parametrize(
111
145
  ("app_definition", "expected_name"),
@@ -601,7 +601,7 @@ wheels = [
601
601
 
602
602
  [[package]]
603
603
  name = "toolfuncs"
604
- version = "0.5.0"
604
+ version = "0.7.0"
605
605
  source = { editable = "." }
606
606
  dependencies = [
607
607
  { name = "cyclopts" },
File without changes
File without changes
File without changes
File without changes
File without changes