toolfuncs 0.3.3__tar.gz → 0.4.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.3.3 → toolfuncs-0.4.0}/PKG-INFO +30 -32
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/README.md +29 -31
- toolfuncs-0.4.0/docs/contract.md +81 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/pyproject.toml +1 -1
- toolfuncs-0.4.0/src/toolfuncs/__init__.py +38 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/__main__.py +37 -7
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/_tools.py +124 -40
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/agent_hooks.py +12 -10
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/sdk.py +1 -1
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/tests/conftest.py +1 -1
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/tests/test_agent_hooks.py +57 -12
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/tests/test_import_path.py +2 -1
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/tests/test_sdk_cli.py +133 -31
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/tests/test_tools.py +153 -42
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/uv.lock +1 -1
- toolfuncs-0.3.3/docs/contract.md +0 -84
- toolfuncs-0.3.3/src/toolfuncs/__init__.py +0 -82
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/.github/workflows/release.yml +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/.gitignore +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/.python-version +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/_errors.py +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/_import_path.py +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/_setup.py +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/src/toolfuncs/py.typed +0 -0
- {toolfuncs-0.3.3 → toolfuncs-0.4.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolfuncs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Executable typed Python function tools discovered through PATH
|
|
5
5
|
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
|
|
6
6
|
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
|
|
@@ -20,11 +20,11 @@ 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 filename is its command name and
|
|
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.
|
|
24
24
|
|
|
25
25
|
## Write a toolfunc
|
|
26
26
|
|
|
27
|
-
Create an extensionless file named `weather`:
|
|
27
|
+
Create an extensionless file named `weather-report`:
|
|
28
28
|
|
|
29
29
|
```python
|
|
30
30
|
#!/usr/bin/env toolfuncs
|
|
@@ -33,12 +33,14 @@ Create an extensionless file named `weather`:
|
|
|
33
33
|
# dependencies = ["weather-client"]
|
|
34
34
|
# [tool.toolfuncs]
|
|
35
35
|
# description = "Read current weather conditions."
|
|
36
|
+
# cli_name = "weather-report"
|
|
37
|
+
# python_name = "weather_report"
|
|
36
38
|
# ///
|
|
37
39
|
|
|
38
|
-
import toolfuncs
|
|
40
|
+
import toolfuncs.sdk as toolsdk
|
|
39
41
|
import weather_client
|
|
40
42
|
|
|
41
|
-
app =
|
|
43
|
+
app = toolsdk.App()
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
@app.command
|
|
@@ -49,8 +51,8 @@ def current(city: str) -> dict[str, object]:
|
|
|
49
51
|
Make it executable and place it in any existing `PATH` directory:
|
|
50
52
|
|
|
51
53
|
```console
|
|
52
|
-
chmod +x weather
|
|
53
|
-
mv weather ~/.local/bin/
|
|
54
|
+
chmod +x weather-report
|
|
55
|
+
mv weather-report ~/.local/bin/
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
That is the complete registration operation.
|
|
@@ -60,31 +62,25 @@ That is the complete registration operation.
|
|
|
60
62
|
The executable is the CLI:
|
|
61
63
|
|
|
62
64
|
```console
|
|
63
|
-
$ weather current London
|
|
65
|
+
$ weather-report current London
|
|
64
66
|
{"city": "London", "temperature": 18}
|
|
65
67
|
```
|
|
66
68
|
|
|
67
69
|
The same file is dynamically importable in a Python process that has `toolfuncs` installed and the same `PATH`:
|
|
68
70
|
|
|
69
71
|
```python
|
|
70
|
-
|
|
72
|
+
import toolfuncs as tools
|
|
71
73
|
|
|
72
|
-
conditions =
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
The explicit equivalent is useful when a tool name conflicts with a public package attribute:
|
|
76
|
-
|
|
77
|
-
```python
|
|
78
|
-
import toolfuncs
|
|
79
|
-
|
|
80
|
-
weather = toolfuncs.load("weather")
|
|
74
|
+
conditions = tools.weather_report.current("London")
|
|
81
75
|
```
|
|
82
76
|
|
|
83
77
|
`import_path()` is the narrower utility for a local script whose path is already
|
|
84
78
|
known:
|
|
85
79
|
|
|
86
80
|
```python
|
|
87
|
-
|
|
81
|
+
import toolfuncs as tools
|
|
82
|
+
|
|
83
|
+
module = tools.import_path("scripts/prepare_data.py")
|
|
88
84
|
```
|
|
89
85
|
|
|
90
86
|
It accepts one ordinary `.py` file or extensionless script and derives the
|
|
@@ -96,23 +92,23 @@ environment before import. A caller can replace that behavior with one callable
|
|
|
96
92
|
that accepts `list[str]` and returns `None`:
|
|
97
93
|
|
|
98
94
|
```python
|
|
99
|
-
module =
|
|
95
|
+
module = tools.import_path(
|
|
100
96
|
"scripts/prepare_data.py",
|
|
101
97
|
dependency_installer=my_installer,
|
|
102
98
|
)
|
|
103
99
|
```
|
|
104
100
|
|
|
105
|
-
Direct Python calls return ordinary Python objects and raise the original exceptions.
|
|
101
|
+
Direct Python calls return ordinary Python objects and raise the original exceptions. Dynamic tool attributes and `toolfuncs.import_path()` prepare declared requirements in the running Python environment before import. CLI calls use Cyclopts to parse annotated values and serialize successful results as strict JSON through `pydantic-core`.
|
|
106
102
|
|
|
107
103
|
## Source contract
|
|
108
104
|
|
|
109
105
|
A discoverable toolfunc must:
|
|
110
106
|
|
|
111
107
|
1. be one executable file in a directory on `PATH`;
|
|
112
|
-
2. have an extensionless
|
|
108
|
+
2. have an extensionless lowercase ASCII kebab-case filename;
|
|
113
109
|
3. start with one of the two exact toolfuncs shebangs described below;
|
|
114
|
-
4. contain exactly one PEP 723 `script` block with `dependencies
|
|
115
|
-
5. define a module-level `app =
|
|
110
|
+
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
|
+
5. define a module-level `app = toolsdk.App()` after `import toolfuncs.sdk as toolsdk`;
|
|
116
112
|
6. register each CLI-callable function explicitly with `@app.command`.
|
|
117
113
|
|
|
118
114
|
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.
|
|
@@ -127,7 +123,7 @@ The launcher consumes `--allow-floating-vcs`; the tool's Cyclopts app never sees
|
|
|
127
123
|
|
|
128
124
|
Only functions registered on `app` become CLI commands. Imported functions and `__all__` do not define the command surface. Function command and option spelling follows Cyclopts' Python-to-kebab-case projection.
|
|
129
125
|
|
|
130
|
-
No `if __name__ == "__main__":` block is required. The operating system passes the source path to the shebang interpreter, and toolfuncs imports the source under its
|
|
126
|
+
No `if __name__ == "__main__":` block is required. The operating system passes the source path to the shebang interpreter, and toolfuncs imports the source under its declared Python name before invoking its module-level app. Consequently, `weather-report ...` is the supported CLI while `python weather-report ...` merely defines the module and exits, or fails if its dependencies are not already installed.
|
|
131
127
|
|
|
132
128
|
## Package-backed implementations
|
|
133
129
|
|
|
@@ -140,6 +136,8 @@ A toolfunc remains one script even when most of its implementation lives in a pa
|
|
|
140
136
|
# dependencies = ["my-large-package"]
|
|
141
137
|
# [tool.toolfuncs]
|
|
142
138
|
# description = "Run the package's agent operation."
|
|
139
|
+
# cli_name = "package-operation"
|
|
140
|
+
# python_name = "package_operation"
|
|
143
141
|
# ///
|
|
144
142
|
|
|
145
143
|
from my_large_package.agent_tool import app, perform_operation
|
|
@@ -151,9 +149,9 @@ The imported `app` and functions are the original Python objects. Toolfuncs does
|
|
|
151
149
|
|
|
152
150
|
Toolfuncs reads the current process's `PATH` from left to right. It considers only executable files and symlinks, reads their first line, and parses PEP 723 metadata only when either exact toolfuncs shebang matches.
|
|
153
151
|
|
|
154
|
-
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 name.
|
|
152
|
+
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.
|
|
155
153
|
|
|
156
|
-
`toolfuncs list` returns
|
|
154
|
+
`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.
|
|
157
155
|
|
|
158
156
|
## Install the runtime
|
|
159
157
|
|
|
@@ -196,12 +194,12 @@ The installed hook runs at initial session start and at the session-start event
|
|
|
196
194
|
|
|
197
195
|
```text
|
|
198
196
|
Available toolfuncs:
|
|
199
|
-
- `
|
|
200
|
-
CLI: `weather --help`
|
|
201
|
-
Python: `
|
|
197
|
+
- `weather_report`: Read current weather conditions.
|
|
198
|
+
CLI: `weather-report --help`
|
|
199
|
+
Python: `import toolfuncs as tools; tools.weather_report`
|
|
202
200
|
```
|
|
203
201
|
|
|
204
|
-
The hook reads only executable headers and PEP 723 metadata. 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.
|
|
202
|
+
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.
|
|
205
203
|
|
|
206
204
|
## Development
|
|
207
205
|
|
|
@@ -213,7 +211,7 @@ uv run ruff check .
|
|
|
213
211
|
uv run basedpyright
|
|
214
212
|
```
|
|
215
213
|
|
|
216
|
-
The exact 0.
|
|
214
|
+
The exact 0.4 guarantees and non-goals are recorded in [the contract](docs/contract.md).
|
|
217
215
|
|
|
218
216
|
## Releasing
|
|
219
217
|
|
|
@@ -2,11 +2,11 @@
|
|
|
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 filename is its command name and
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Write a toolfunc
|
|
8
8
|
|
|
9
|
-
Create an extensionless file named `weather`:
|
|
9
|
+
Create an extensionless file named `weather-report`:
|
|
10
10
|
|
|
11
11
|
```python
|
|
12
12
|
#!/usr/bin/env toolfuncs
|
|
@@ -15,12 +15,14 @@ Create an extensionless file named `weather`:
|
|
|
15
15
|
# dependencies = ["weather-client"]
|
|
16
16
|
# [tool.toolfuncs]
|
|
17
17
|
# description = "Read current weather conditions."
|
|
18
|
+
# cli_name = "weather-report"
|
|
19
|
+
# python_name = "weather_report"
|
|
18
20
|
# ///
|
|
19
21
|
|
|
20
|
-
import toolfuncs
|
|
22
|
+
import toolfuncs.sdk as toolsdk
|
|
21
23
|
import weather_client
|
|
22
24
|
|
|
23
|
-
app =
|
|
25
|
+
app = toolsdk.App()
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
@app.command
|
|
@@ -31,8 +33,8 @@ def current(city: str) -> dict[str, object]:
|
|
|
31
33
|
Make it executable and place it in any existing `PATH` directory:
|
|
32
34
|
|
|
33
35
|
```console
|
|
34
|
-
chmod +x weather
|
|
35
|
-
mv weather ~/.local/bin/
|
|
36
|
+
chmod +x weather-report
|
|
37
|
+
mv weather-report ~/.local/bin/
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
That is the complete registration operation.
|
|
@@ -42,31 +44,25 @@ That is the complete registration operation.
|
|
|
42
44
|
The executable is the CLI:
|
|
43
45
|
|
|
44
46
|
```console
|
|
45
|
-
$ weather current London
|
|
47
|
+
$ weather-report current London
|
|
46
48
|
{"city": "London", "temperature": 18}
|
|
47
49
|
```
|
|
48
50
|
|
|
49
51
|
The same file is dynamically importable in a Python process that has `toolfuncs` installed and the same `PATH`:
|
|
50
52
|
|
|
51
53
|
```python
|
|
52
|
-
|
|
54
|
+
import toolfuncs as tools
|
|
53
55
|
|
|
54
|
-
conditions =
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
The explicit equivalent is useful when a tool name conflicts with a public package attribute:
|
|
58
|
-
|
|
59
|
-
```python
|
|
60
|
-
import toolfuncs
|
|
61
|
-
|
|
62
|
-
weather = toolfuncs.load("weather")
|
|
56
|
+
conditions = tools.weather_report.current("London")
|
|
63
57
|
```
|
|
64
58
|
|
|
65
59
|
`import_path()` is the narrower utility for a local script whose path is already
|
|
66
60
|
known:
|
|
67
61
|
|
|
68
62
|
```python
|
|
69
|
-
|
|
63
|
+
import toolfuncs as tools
|
|
64
|
+
|
|
65
|
+
module = tools.import_path("scripts/prepare_data.py")
|
|
70
66
|
```
|
|
71
67
|
|
|
72
68
|
It accepts one ordinary `.py` file or extensionless script and derives the
|
|
@@ -78,23 +74,23 @@ environment before import. A caller can replace that behavior with one callable
|
|
|
78
74
|
that accepts `list[str]` and returns `None`:
|
|
79
75
|
|
|
80
76
|
```python
|
|
81
|
-
module =
|
|
77
|
+
module = tools.import_path(
|
|
82
78
|
"scripts/prepare_data.py",
|
|
83
79
|
dependency_installer=my_installer,
|
|
84
80
|
)
|
|
85
81
|
```
|
|
86
82
|
|
|
87
|
-
Direct Python calls return ordinary Python objects and raise the original exceptions.
|
|
83
|
+
Direct Python calls return ordinary Python objects and raise the original exceptions. Dynamic tool attributes and `toolfuncs.import_path()` prepare declared requirements in the running Python environment before import. CLI calls use Cyclopts to parse annotated values and serialize successful results as strict JSON through `pydantic-core`.
|
|
88
84
|
|
|
89
85
|
## Source contract
|
|
90
86
|
|
|
91
87
|
A discoverable toolfunc must:
|
|
92
88
|
|
|
93
89
|
1. be one executable file in a directory on `PATH`;
|
|
94
|
-
2. have an extensionless
|
|
90
|
+
2. have an extensionless lowercase ASCII kebab-case filename;
|
|
95
91
|
3. start with one of the two exact toolfuncs shebangs described below;
|
|
96
|
-
4. contain exactly one PEP 723 `script` block with `dependencies
|
|
97
|
-
5. define a module-level `app =
|
|
92
|
+
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
|
+
5. define a module-level `app = toolsdk.App()` after `import toolfuncs.sdk as toolsdk`;
|
|
98
94
|
6. register each CLI-callable function explicitly with `@app.command`.
|
|
99
95
|
|
|
100
96
|
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.
|
|
@@ -109,7 +105,7 @@ The launcher consumes `--allow-floating-vcs`; the tool's Cyclopts app never sees
|
|
|
109
105
|
|
|
110
106
|
Only functions registered on `app` become CLI commands. Imported functions and `__all__` do not define the command surface. Function command and option spelling follows Cyclopts' Python-to-kebab-case projection.
|
|
111
107
|
|
|
112
|
-
No `if __name__ == "__main__":` block is required. The operating system passes the source path to the shebang interpreter, and toolfuncs imports the source under its
|
|
108
|
+
No `if __name__ == "__main__":` block is required. The operating system passes the source path to the shebang interpreter, and toolfuncs imports the source under its declared Python name before invoking its module-level app. Consequently, `weather-report ...` is the supported CLI while `python weather-report ...` merely defines the module and exits, or fails if its dependencies are not already installed.
|
|
113
109
|
|
|
114
110
|
## Package-backed implementations
|
|
115
111
|
|
|
@@ -122,6 +118,8 @@ A toolfunc remains one script even when most of its implementation lives in a pa
|
|
|
122
118
|
# dependencies = ["my-large-package"]
|
|
123
119
|
# [tool.toolfuncs]
|
|
124
120
|
# description = "Run the package's agent operation."
|
|
121
|
+
# cli_name = "package-operation"
|
|
122
|
+
# python_name = "package_operation"
|
|
125
123
|
# ///
|
|
126
124
|
|
|
127
125
|
from my_large_package.agent_tool import app, perform_operation
|
|
@@ -133,9 +131,9 @@ The imported `app` and functions are the original Python objects. Toolfuncs does
|
|
|
133
131
|
|
|
134
132
|
Toolfuncs reads the current process's `PATH` from left to right. It considers only executable files and symlinks, reads their first line, and parses PEP 723 metadata only when either exact toolfuncs shebang matches.
|
|
135
133
|
|
|
136
|
-
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 name.
|
|
134
|
+
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.
|
|
137
135
|
|
|
138
|
-
`toolfuncs list` returns
|
|
136
|
+
`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.
|
|
139
137
|
|
|
140
138
|
## Install the runtime
|
|
141
139
|
|
|
@@ -178,12 +176,12 @@ The installed hook runs at initial session start and at the session-start event
|
|
|
178
176
|
|
|
179
177
|
```text
|
|
180
178
|
Available toolfuncs:
|
|
181
|
-
- `
|
|
182
|
-
CLI: `weather --help`
|
|
183
|
-
Python: `
|
|
179
|
+
- `weather_report`: Read current weather conditions.
|
|
180
|
+
CLI: `weather-report --help`
|
|
181
|
+
Python: `import toolfuncs as tools; tools.weather_report`
|
|
184
182
|
```
|
|
185
183
|
|
|
186
|
-
The hook reads only executable headers and PEP 723 metadata. 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.
|
|
184
|
+
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.
|
|
187
185
|
|
|
188
186
|
## Development
|
|
189
187
|
|
|
@@ -195,7 +193,7 @@ uv run ruff check .
|
|
|
195
193
|
uv run basedpyright
|
|
196
194
|
```
|
|
197
195
|
|
|
198
|
-
The exact 0.
|
|
196
|
+
The exact 0.4 guarantees and non-goals are recorded in [the contract](docs/contract.md).
|
|
199
197
|
|
|
200
198
|
## Releasing
|
|
201
199
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# toolfuncs 0.4 contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Toolfuncs projects one explicitly registered set of typed Python functions into ordinary in-process Python calls and a strict-JSON command line. It is a source convention and runtime, not a transport protocol, package format, registry, plugin system, or filesystem scope.
|
|
6
|
+
|
|
7
|
+
## Tool identity and source
|
|
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`.
|
|
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.
|
|
12
|
+
|
|
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
|
+
|
|
15
|
+
```toml
|
|
16
|
+
[tool.toolfuncs]
|
|
17
|
+
description = "Describe the tool in one line."
|
|
18
|
+
cli_name = "session-history"
|
|
19
|
+
python_name = "session_history"
|
|
20
|
+
```
|
|
21
|
+
|
|
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
|
+
|
|
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.
|
|
25
|
+
|
|
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
|
+
|
|
28
|
+
## Python interface
|
|
29
|
+
|
|
30
|
+
The standard Python interface is:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import toolfuncs as tools
|
|
34
|
+
|
|
35
|
+
hits = tools.session_history.search("Cyclopts")
|
|
36
|
+
module = tools.import_path("scripts/prepare_data.py")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Dynamic attribute lookup maps the requested snake-case Python name to its kebab-case PATH command, validates the source and its declared identities, prepares its PEP 723 requirements in the current interpreter, and imports the source under the Python name. A loaded name retains one canonical source and module object. A different source or pre-existing Python module using the same name is a clear conflict rather than an implicit replacement.
|
|
40
|
+
|
|
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
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
## Dependencies
|
|
46
|
+
|
|
47
|
+
For CLI execution, the managed wrapper passes the source to `uvx --with-requirements` and starts toolfuncs inside the resulting cached environment. The runtime then imports the prepared source without installing its requirements again. For direct Python composition, dynamic attribute loading installs requirements into the running interpreter with pip before import. The current interpreter must satisfy `requires-python`, and preparation and loading are serialized within a process.
|
|
48
|
+
|
|
49
|
+
The `toolfuncs` distribution may appear in `dependencies`, but it is redundant because the shebang has already started the runtime. A script may import any installed implementation package; that package is not itself a toolfunc.
|
|
50
|
+
|
|
51
|
+
Git requirements are immutable by default. A direct Git URL must name a full 40- or 64-hex commit object ID. A branch, tag, abbreviated hash, or omitted revision fails before dependency resolution unless the source explicitly uses the floating-VCS shebang. An adjacent `uv lock --script` lock is not consumed by the toolfuncs launch path.
|
|
52
|
+
|
|
53
|
+
## Discovery
|
|
54
|
+
|
|
55
|
+
Discovery reads the current `PATH` from left to right. The first executable filename claims that command before its shebang is inspected, so an earlier ordinary executable hides a later same-named toolfunc exactly as the shell does. Repeated resolved directories are visited once, and missing or unreadable directories are skipped.
|
|
56
|
+
|
|
57
|
+
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
|
+
|
|
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.
|
|
60
|
+
|
|
61
|
+
Direct invocation or Python lookup of an invalid tool fails with its validation error.
|
|
62
|
+
|
|
63
|
+
## CLI execution
|
|
64
|
+
|
|
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.
|
|
66
|
+
|
|
67
|
+
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
|
+
|
|
69
|
+
Successful default CLI operations emit one strict JSON value. CLI parsing, validation, dependency, or execution failures exit nonzero. Direct Python calls return original objects and raise original exceptions.
|
|
70
|
+
|
|
71
|
+
## Setup and management
|
|
72
|
+
|
|
73
|
+
`uvx toolfuncs setup` installs one managed uvx wrapper in `~/.local/bin` or an explicit `--bin-dir`, plus the agent-catalog hook wrapper. The destination must already resolve from `PATH`. Setup is idempotent, refuses to overwrite unmanaged commands, and never modifies shell profiles.
|
|
74
|
+
|
|
75
|
+
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
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
## Compatibility
|
|
80
|
+
|
|
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.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Typed Python tools with matching command-line interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import types as _types
|
|
6
|
+
|
|
7
|
+
from ._errors import InvalidToolError as _InvalidToolError
|
|
8
|
+
from ._errors import ToolNotFoundError as _ToolNotFoundError
|
|
9
|
+
from ._import_path import import_path
|
|
10
|
+
from ._tools import _cli_name_for_python_name, _discover, _load_python_name
|
|
11
|
+
|
|
12
|
+
__all__ = ["import_path"]
|
|
13
|
+
|
|
14
|
+
_PUBLIC_NAMES = frozenset({"import_path", "sdk"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(python_name: str) -> _types.ModuleType:
|
|
18
|
+
"""Dynamically expose PATH tools by their declared Python names."""
|
|
19
|
+
|
|
20
|
+
if python_name.startswith("_") or python_name in _PUBLIC_NAMES:
|
|
21
|
+
raise AttributeError(python_name)
|
|
22
|
+
try:
|
|
23
|
+
_cli_name_for_python_name(python_name)
|
|
24
|
+
except _InvalidToolError:
|
|
25
|
+
raise AttributeError(python_name) from None
|
|
26
|
+
try:
|
|
27
|
+
module = _load_python_name(python_name)
|
|
28
|
+
except _ToolNotFoundError:
|
|
29
|
+
raise AttributeError(f"module {__name__!r} has no attribute {python_name!r}") from None
|
|
30
|
+
globals()[python_name] = module
|
|
31
|
+
return module
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __dir__() -> list[str]:
|
|
35
|
+
"""Include valid effective PATH tools in interactive completion."""
|
|
36
|
+
|
|
37
|
+
catalog = _discover()
|
|
38
|
+
return sorted(_PUBLIC_NAMES | {tool.python_name for tool in catalog.tools})
|
|
@@ -4,22 +4,24 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import sys
|
|
7
|
-
from collections.abc import Sequence
|
|
7
|
+
from collections.abc import Iterator, Sequence
|
|
8
|
+
from contextlib import contextmanager
|
|
8
9
|
from dataclasses import asdict
|
|
10
|
+
from importlib.metadata import version
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import Any
|
|
11
13
|
|
|
12
14
|
from pydantic_core import to_jsonable_python
|
|
13
15
|
|
|
14
|
-
from . import __version__
|
|
15
16
|
from ._setup import setup
|
|
16
17
|
from ._tools import (
|
|
17
18
|
ALLOW_FLOATING_VCS_SHEBANG,
|
|
18
19
|
TOOL_SHEBANG,
|
|
20
|
+
InvalidTool,
|
|
21
|
+
_discover,
|
|
19
22
|
_load_prepared_source,
|
|
20
23
|
_load_source,
|
|
21
24
|
_validate_source_dependencies,
|
|
22
|
-
discover,
|
|
23
25
|
)
|
|
24
26
|
from .sdk import _run
|
|
25
27
|
|
|
@@ -37,6 +39,18 @@ Floating Git revisions require the explicit shebang:
|
|
|
37
39
|
"""
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
@contextmanager
|
|
43
|
+
def _tool_program_name(source: Path) -> Iterator[None]:
|
|
44
|
+
"""Expose the source executable as the invoked app's program name."""
|
|
45
|
+
|
|
46
|
+
previous = sys.argv[0]
|
|
47
|
+
sys.argv[0] = source.name
|
|
48
|
+
try:
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
sys.argv[0] = previous
|
|
52
|
+
|
|
53
|
+
|
|
40
54
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
41
55
|
"""Run a source supplied by its shebang or dispatch a management command."""
|
|
42
56
|
|
|
@@ -47,7 +61,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
47
61
|
if tokens[0] in {"-V", "--version"}:
|
|
48
62
|
if len(tokens) != 1:
|
|
49
63
|
raise SystemExit("toolfuncs --version accepts no arguments")
|
|
50
|
-
print(
|
|
64
|
+
print(version("toolfuncs"))
|
|
51
65
|
return 0
|
|
52
66
|
|
|
53
67
|
command = tokens.pop(0)
|
|
@@ -55,7 +69,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
55
69
|
if not tokens:
|
|
56
70
|
raise SystemExit("toolfuncs internal error: missing prepared source")
|
|
57
71
|
source = Path(tokens.pop(0))
|
|
58
|
-
|
|
72
|
+
module = _load_prepared_source(source)
|
|
73
|
+
with _tool_program_name(source):
|
|
74
|
+
result = _run(module, tokens)
|
|
59
75
|
return result if isinstance(result, int) else 0
|
|
60
76
|
if command == "__validate-source":
|
|
61
77
|
if len(tokens) != 1:
|
|
@@ -65,7 +81,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
65
81
|
if command == "list":
|
|
66
82
|
if tokens:
|
|
67
83
|
raise SystemExit("toolfuncs list accepts no arguments")
|
|
68
|
-
|
|
84
|
+
catalog = _discover()
|
|
85
|
+
_print_json(asdict(catalog))
|
|
86
|
+
_warn_invalid_tools(catalog.invalid_tools)
|
|
69
87
|
return 0
|
|
70
88
|
if command == "setup":
|
|
71
89
|
destination = _parse_setup(tokens)
|
|
@@ -75,7 +93,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
75
93
|
source = Path(command)
|
|
76
94
|
if not source.is_file():
|
|
77
95
|
raise SystemExit(f"unknown toolfuncs command or missing source: {command}")
|
|
78
|
-
|
|
96
|
+
module = _load_source(source)
|
|
97
|
+
with _tool_program_name(source):
|
|
98
|
+
result = _run(module, tokens)
|
|
79
99
|
return result if isinstance(result, int) else 0
|
|
80
100
|
|
|
81
101
|
|
|
@@ -91,5 +111,15 @@ def _print_json(value: Any) -> None:
|
|
|
91
111
|
print(json.dumps(value, default=to_jsonable_python, allow_nan=False))
|
|
92
112
|
|
|
93
113
|
|
|
114
|
+
def _warn_invalid_tools(invalid_tools: tuple[InvalidTool, ...]) -> None:
|
|
115
|
+
if not invalid_tools:
|
|
116
|
+
return
|
|
117
|
+
count = len(invalid_tools)
|
|
118
|
+
noun = "toolfunc" if count == 1 else "toolfuncs"
|
|
119
|
+
print(f"warning: found {count} invalid {noun}:", file=sys.stderr)
|
|
120
|
+
for invalid in invalid_tools:
|
|
121
|
+
print(f"- {invalid.path}: {invalid.error}", file=sys.stderr)
|
|
122
|
+
|
|
123
|
+
|
|
94
124
|
if __name__ == "__main__":
|
|
95
125
|
raise SystemExit(main())
|