toolfuncs 0.3.4__tar.gz → 0.5.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.3.4 → toolfuncs-0.5.0}/PKG-INFO +30 -32
  2. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/README.md +29 -31
  3. toolfuncs-0.5.0/docs/contract.md +81 -0
  4. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/pyproject.toml +1 -1
  5. toolfuncs-0.5.0/src/toolfuncs/__init__.py +38 -0
  6. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/__main__.py +17 -4
  7. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/_tools.py +124 -40
  8. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/agent_hooks.py +12 -10
  9. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/sdk.py +1 -1
  10. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/tests/conftest.py +1 -1
  11. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/tests/test_agent_hooks.py +57 -12
  12. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/tests/test_import_path.py +2 -1
  13. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/tests/test_sdk_cli.py +90 -35
  14. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/tests/test_tools.py +153 -42
  15. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/uv.lock +1 -1
  16. toolfuncs-0.3.4/docs/contract.md +0 -84
  17. toolfuncs-0.3.4/src/toolfuncs/__init__.py +0 -82
  18. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/.github/workflows/release.yml +0 -0
  19. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/.gitignore +0 -0
  20. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/.python-version +0 -0
  21. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/_errors.py +0 -0
  22. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/_import_path.py +0 -0
  23. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/_setup.py +0 -0
  24. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/src/toolfuncs/py.typed +0 -0
  25. {toolfuncs-0.3.4 → toolfuncs-0.5.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: toolfuncs
3
- Version: 0.3.4
3
+ Version: 0.5.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 dynamic Python import name. 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 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 = toolfuncs.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
- from toolfuncs import weather
72
+ import toolfuncs as tools
71
73
 
72
- conditions = weather.current("London")
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
- module = toolfuncs.import_path("scripts/prepare_data.py")
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 = toolfuncs.import_path(
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. `toolfuncs.load()` 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`.
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 filename that is a valid non-keyword Python identifier;
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` and a one-line `[tool.toolfuncs].description`;
115
- 5. define a module-level `app = toolfuncs.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 filename before invoking its module-level app. Consequently, `weather ...` is the supported CLI while `python weather ...` merely defines the module and exits, or fails if its dependencies are not already installed.
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 static JSON records containing `name`, `path`, and `description`. It never imports a tool, prepares its dependencies, or executes its source.
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
- - `weather`: Read current weather conditions.
200
- CLI: `weather --help`
201
- Python: `from toolfuncs import weather`
197
+ - `weather_report`: Read current weather conditions.
198
+ CLI: `weather-report --help`
199
+ Python: `import toolfuncs as tools; help(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.2 guarantees and non-goals are recorded in [the contract](docs/contract.md).
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 dynamic Python import name. 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 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 = toolfuncs.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
- from toolfuncs import weather
54
+ import toolfuncs as tools
53
55
 
54
- conditions = weather.current("London")
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
- module = toolfuncs.import_path("scripts/prepare_data.py")
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 = toolfuncs.import_path(
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. `toolfuncs.load()` 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`.
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 filename that is a valid non-keyword Python identifier;
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` and a one-line `[tool.toolfuncs].description`;
97
- 5. define a module-level `app = toolfuncs.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 filename before invoking its module-level app. Consequently, `weather ...` is the supported CLI while `python weather ...` merely defines the module and exits, or fails if its dependencies are not already installed.
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 static JSON records containing `name`, `path`, and `description`. It never imports a tool, prepares its dependencies, or executes its source.
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
- - `weather`: Read current weather conditions.
182
- CLI: `weather --help`
183
- Python: `from toolfuncs import weather`
179
+ - `weather_report`: Read current weather conditions.
180
+ CLI: `weather-report --help`
181
+ Python: `import toolfuncs as tools; help(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.2 guarantees and non-goals are recorded in [the contract](docs/contract.md).
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "toolfuncs"
3
- version = "0.3.4"
3
+ version = "0.5.0"
4
4
  description = "Executable typed Python function tools discovered through PATH"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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})
@@ -7,20 +7,21 @@ import sys
7
7
  from collections.abc import Iterator, Sequence
8
8
  from contextlib import contextmanager
9
9
  from dataclasses import asdict
10
+ from importlib.metadata import version
10
11
  from pathlib import Path
11
12
  from typing import Any
12
13
 
13
14
  from pydantic_core import to_jsonable_python
14
15
 
15
- from . import __version__
16
16
  from ._setup import setup
17
17
  from ._tools import (
18
18
  ALLOW_FLOATING_VCS_SHEBANG,
19
19
  TOOL_SHEBANG,
20
+ InvalidTool,
21
+ _discover,
20
22
  _load_prepared_source,
21
23
  _load_source,
22
24
  _validate_source_dependencies,
23
- discover,
24
25
  )
25
26
  from .sdk import _run
26
27
 
@@ -60,7 +61,7 @@ def main(argv: Sequence[str] | None = None) -> int:
60
61
  if tokens[0] in {"-V", "--version"}:
61
62
  if len(tokens) != 1:
62
63
  raise SystemExit("toolfuncs --version accepts no arguments")
63
- print(__version__)
64
+ print(version("toolfuncs"))
64
65
  return 0
65
66
 
66
67
  command = tokens.pop(0)
@@ -80,7 +81,9 @@ def main(argv: Sequence[str] | None = None) -> int:
80
81
  if command == "list":
81
82
  if tokens:
82
83
  raise SystemExit("toolfuncs list accepts no arguments")
83
- _print_json([asdict(tool) for tool in discover()])
84
+ catalog = _discover()
85
+ _print_json(asdict(catalog))
86
+ _warn_invalid_tools(catalog.invalid_tools)
84
87
  return 0
85
88
  if command == "setup":
86
89
  destination = _parse_setup(tokens)
@@ -108,5 +111,15 @@ def _print_json(value: Any) -> None:
108
111
  print(json.dumps(value, default=to_jsonable_python, allow_nan=False))
109
112
 
110
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
+
111
124
  if __name__ == "__main__":
112
125
  raise SystemExit(main())