toolfuncs 0.1.0__py3-none-any.whl
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/__init__.py +89 -0
- toolfuncs/__main__.py +79 -0
- toolfuncs/_discovery.py +147 -0
- toolfuncs/_errors.py +50 -0
- toolfuncs/_importer.py +609 -0
- toolfuncs/_metadata.py +234 -0
- toolfuncs/_redaction.py +18 -0
- toolfuncs/_shims.py +88 -0
- toolfuncs/py.typed +0 -0
- toolfuncs/sdk.py +86 -0
- toolfuncs-0.1.0.dist-info/METADATA +209 -0
- toolfuncs-0.1.0.dist-info/RECORD +14 -0
- toolfuncs-0.1.0.dist-info/WHEEL +4 -0
- toolfuncs-0.1.0.dist-info/entry_points.txt +2 -0
toolfuncs/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Function-first Python tools with matching import and CLI interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
|
|
7
|
+
from ._discovery import Tool, ToolKind, discover_tools, load_tool, resolve_tool
|
|
8
|
+
from ._errors import (
|
|
9
|
+
DependencyMetadataError,
|
|
10
|
+
ImportConflictError,
|
|
11
|
+
ImportNameError,
|
|
12
|
+
InvalidSourceError,
|
|
13
|
+
ShimConflictError,
|
|
14
|
+
ToolDefinitionError,
|
|
15
|
+
ToolfuncsError,
|
|
16
|
+
ToolMetadataError,
|
|
17
|
+
ToolNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from ._importer import import_path
|
|
20
|
+
from ._shims import SyncResult, sync
|
|
21
|
+
from .sdk import (
|
|
22
|
+
UNSET,
|
|
23
|
+
App,
|
|
24
|
+
Group,
|
|
25
|
+
Parameter,
|
|
26
|
+
ResultAction,
|
|
27
|
+
Token,
|
|
28
|
+
application,
|
|
29
|
+
json_result_action,
|
|
30
|
+
run,
|
|
31
|
+
to_jsonable_python,
|
|
32
|
+
types,
|
|
33
|
+
validators,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"UNSET",
|
|
40
|
+
"App",
|
|
41
|
+
"DependencyMetadataError",
|
|
42
|
+
"Group",
|
|
43
|
+
"ImportConflictError",
|
|
44
|
+
"ImportNameError",
|
|
45
|
+
"InvalidSourceError",
|
|
46
|
+
"Parameter",
|
|
47
|
+
"ResultAction",
|
|
48
|
+
"ShimConflictError",
|
|
49
|
+
"SyncResult",
|
|
50
|
+
"Token",
|
|
51
|
+
"Tool",
|
|
52
|
+
"ToolDefinitionError",
|
|
53
|
+
"ToolKind",
|
|
54
|
+
"ToolMetadataError",
|
|
55
|
+
"ToolNotFoundError",
|
|
56
|
+
"ToolfuncsError",
|
|
57
|
+
"application",
|
|
58
|
+
"discover_tools",
|
|
59
|
+
"import_path",
|
|
60
|
+
"json_result_action",
|
|
61
|
+
"load_tool",
|
|
62
|
+
"resolve_tool",
|
|
63
|
+
"run",
|
|
64
|
+
"sync",
|
|
65
|
+
"to_jsonable_python",
|
|
66
|
+
"types",
|
|
67
|
+
"validators",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
_PUBLIC_API = frozenset(__all__)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def __getattr__(name: str) -> ModuleType:
|
|
74
|
+
"""Dynamically expose scoped tools for ``from toolfuncs import name``."""
|
|
75
|
+
|
|
76
|
+
if name.startswith("_"):
|
|
77
|
+
raise AttributeError(name)
|
|
78
|
+
try:
|
|
79
|
+
module = load_tool(name)
|
|
80
|
+
except ToolNotFoundError:
|
|
81
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
82
|
+
globals()[name] = module
|
|
83
|
+
return module
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def __dir__() -> list[str]:
|
|
87
|
+
"""Include effective scoped tools in interactive completion."""
|
|
88
|
+
|
|
89
|
+
return sorted(_PUBLIC_API | {tool.name for tool in discover_tools()})
|
toolfuncs/__main__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Management and universal dispatch CLI for toolfuncs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic_core import to_jsonable_python
|
|
13
|
+
|
|
14
|
+
from ._discovery import discover_tools, load_tool, resolve_tool
|
|
15
|
+
from ._shims import sync
|
|
16
|
+
from .sdk import run as run_module
|
|
17
|
+
|
|
18
|
+
_HELP = """\
|
|
19
|
+
Usage:
|
|
20
|
+
toolfuncs TOOL [ARGS...]
|
|
21
|
+
toolfuncs run TOOL [ARGS...]
|
|
22
|
+
toolfuncs list
|
|
23
|
+
toolfuncs describe TOOL
|
|
24
|
+
toolfuncs sync [--bin-dir DIRECTORY]
|
|
25
|
+
|
|
26
|
+
Project tools in the nearest .agents/tools directory shadow tools in
|
|
27
|
+
~/.agents/tools. TOOL is dispatched to the typed functions registered on its
|
|
28
|
+
module-level app. Use `toolfuncs TOOL --help` for the generated function CLI.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
33
|
+
"""Dispatch the management command or one scoped function-first tool."""
|
|
34
|
+
|
|
35
|
+
tokens = list(sys.argv[1:] if argv is None else argv)
|
|
36
|
+
if not tokens or tokens[0] in {"-h", "--help", "help"}:
|
|
37
|
+
print(_HELP, end="")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
command = tokens.pop(0)
|
|
41
|
+
if command == "list":
|
|
42
|
+
if tokens:
|
|
43
|
+
raise SystemExit("toolfuncs list accepts no arguments")
|
|
44
|
+
_print_json([asdict(tool) for tool in discover_tools()])
|
|
45
|
+
return 0
|
|
46
|
+
if command == "describe":
|
|
47
|
+
if len(tokens) != 1:
|
|
48
|
+
raise SystemExit("usage: toolfuncs describe TOOL")
|
|
49
|
+
tool = resolve_tool(tokens[0])
|
|
50
|
+
_print_json(asdict(tool))
|
|
51
|
+
return 0
|
|
52
|
+
if command == "sync":
|
|
53
|
+
bin_dir = _parse_sync(tokens)
|
|
54
|
+
_print_json(asdict(sync(bin_dir)))
|
|
55
|
+
return 0
|
|
56
|
+
if command == "run":
|
|
57
|
+
if not tokens:
|
|
58
|
+
raise SystemExit("usage: toolfuncs run TOOL [ARGS...]")
|
|
59
|
+
command = tokens.pop(0)
|
|
60
|
+
|
|
61
|
+
module = load_tool(command)
|
|
62
|
+
result = run_module(tokens, module=module)
|
|
63
|
+
return result if isinstance(result, int) else 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_sync(tokens: list[str]) -> Path:
|
|
67
|
+
if not tokens:
|
|
68
|
+
return Path.home() / ".local" / "bin"
|
|
69
|
+
if len(tokens) == 2 and tokens[0] == "--bin-dir":
|
|
70
|
+
return Path(tokens[1])
|
|
71
|
+
raise SystemExit("usage: toolfuncs sync [--bin-dir DIRECTORY]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _print_json(value: Any) -> None:
|
|
75
|
+
print(json.dumps(value, default=to_jsonable_python, allow_nan=False))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(main())
|
toolfuncs/_discovery.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Scoped discovery for project and user tool directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from ._errors import InvalidSourceError, ToolNotFoundError
|
|
12
|
+
from ._importer import import_path
|
|
13
|
+
from ._metadata import inline_tool_metadata, project_tool_metadata
|
|
14
|
+
|
|
15
|
+
Scope = Literal["project", "user"]
|
|
16
|
+
ToolKind = Literal["script", "package"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Tool:
|
|
21
|
+
"""One effective tool source found in a discovery scope."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
path: Path
|
|
25
|
+
scope: Scope
|
|
26
|
+
kind: ToolKind
|
|
27
|
+
description: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_INITIAL_CWD = Path.cwd().resolve()
|
|
31
|
+
_INITIAL_HOME = Path.home().resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def discover_tools(
|
|
35
|
+
*, cwd: str | Path | None = None, home: str | Path | None = None
|
|
36
|
+
) -> tuple[Tool, ...]:
|
|
37
|
+
"""Return effective tools, with the nearest project scope shadowing user scope."""
|
|
38
|
+
|
|
39
|
+
working_directory = Path(cwd).expanduser().resolve() if cwd is not None else _INITIAL_CWD
|
|
40
|
+
home_directory = Path(home).expanduser().resolve() if home is not None else _INITIAL_HOME
|
|
41
|
+
user = _scan_root(home_directory / ".agents" / "tools", scope="user")
|
|
42
|
+
project_root = _nearest_project_tools_root(working_directory, home=home_directory)
|
|
43
|
+
project = _scan_root(project_root, scope="project") if project_root is not None else {}
|
|
44
|
+
effective = {**user, **project}
|
|
45
|
+
return tuple(effective[name] for name in sorted(effective))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_tool(
|
|
49
|
+
name: str,
|
|
50
|
+
*,
|
|
51
|
+
cwd: str | Path | None = None,
|
|
52
|
+
home: str | Path | None = None,
|
|
53
|
+
) -> Tool:
|
|
54
|
+
"""Resolve one effective scoped tool by its Python identifier."""
|
|
55
|
+
|
|
56
|
+
_validate_tool_name(name)
|
|
57
|
+
for tool in discover_tools(cwd=cwd, home=home):
|
|
58
|
+
if tool.name == name:
|
|
59
|
+
return tool
|
|
60
|
+
roots = _discovery_roots(cwd=cwd, home=home)
|
|
61
|
+
rendered = ", ".join(str(root) for root in roots)
|
|
62
|
+
raise ToolNotFoundError(f"no tool named {name!r} in scoped roots: {rendered}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_tool(
|
|
66
|
+
name: str,
|
|
67
|
+
*,
|
|
68
|
+
cwd: str | Path | None = None,
|
|
69
|
+
home: str | Path | None = None,
|
|
70
|
+
reload: bool = False,
|
|
71
|
+
) -> ModuleType:
|
|
72
|
+
"""Resolve and import one effective tool module."""
|
|
73
|
+
|
|
74
|
+
tool = resolve_tool(name, cwd=cwd, home=home)
|
|
75
|
+
return import_path(tool.path, import_name=tool.name, reload=reload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _nearest_project_tools_root(cwd: Path, *, home: Path) -> Path | None:
|
|
79
|
+
current = cwd if cwd.is_dir() else cwd.parent
|
|
80
|
+
for directory in (current, *current.parents):
|
|
81
|
+
if directory == home:
|
|
82
|
+
break
|
|
83
|
+
candidate = directory / ".agents" / "tools"
|
|
84
|
+
if candidate.is_dir():
|
|
85
|
+
return candidate
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _discovery_roots(
|
|
90
|
+
*, cwd: str | Path | None = None, home: str | Path | None = None
|
|
91
|
+
) -> tuple[Path, ...]:
|
|
92
|
+
working_directory = Path(cwd).expanduser().resolve() if cwd is not None else _INITIAL_CWD
|
|
93
|
+
home_directory = Path(home).expanduser().resolve() if home is not None else _INITIAL_HOME
|
|
94
|
+
project = _nearest_project_tools_root(working_directory, home=home_directory)
|
|
95
|
+
roots = (home_directory / ".agents" / "tools",)
|
|
96
|
+
return (project, *roots) if project is not None else roots
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _scan_root(root: Path, *, scope: Scope) -> dict[str, Tool]:
|
|
100
|
+
if not root.is_dir():
|
|
101
|
+
return {}
|
|
102
|
+
tools: dict[str, Tool] = {}
|
|
103
|
+
for entry in sorted(root.iterdir(), key=lambda path: path.name):
|
|
104
|
+
if entry.name.startswith((".", "_")):
|
|
105
|
+
continue
|
|
106
|
+
if entry.is_file() and entry.suffix == ".py":
|
|
107
|
+
name = entry.stem
|
|
108
|
+
kind: ToolKind = "script"
|
|
109
|
+
elif entry.is_dir():
|
|
110
|
+
name = entry.name
|
|
111
|
+
kind = "package"
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
_validate_tool_name(name, path=entry)
|
|
115
|
+
if kind == "script":
|
|
116
|
+
metadata = inline_tool_metadata(entry)
|
|
117
|
+
else:
|
|
118
|
+
initializer = entry / "src" / name / "__init__.py"
|
|
119
|
+
if not initializer.is_file():
|
|
120
|
+
raise InvalidSourceError(
|
|
121
|
+
f"packaged tool {entry} must define src/{name}/__init__.py"
|
|
122
|
+
)
|
|
123
|
+
metadata = project_tool_metadata(entry, tool_name=name)
|
|
124
|
+
if previous := tools.get(name):
|
|
125
|
+
raise InvalidSourceError(
|
|
126
|
+
f"tool root {root} defines {name!r} more than once: {previous.path} and {entry}"
|
|
127
|
+
)
|
|
128
|
+
tools[name] = Tool(
|
|
129
|
+
name=name,
|
|
130
|
+
path=entry,
|
|
131
|
+
scope=scope,
|
|
132
|
+
kind=kind,
|
|
133
|
+
description=metadata.description,
|
|
134
|
+
)
|
|
135
|
+
return tools
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _validate_tool_name(name: str, *, path: Path | None = None) -> None:
|
|
139
|
+
if name.isidentifier() and not keyword.iskeyword(name):
|
|
140
|
+
return
|
|
141
|
+
context = f" at {path}" if path is not None else ""
|
|
142
|
+
raise InvalidSourceError(
|
|
143
|
+
f"tool name {name!r}{context} is not a valid non-keyword Python identifier"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["Scope", "Tool", "ToolKind", "discover_tools", "load_tool", "resolve_tool"]
|
toolfuncs/_errors.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Public exception hierarchy for toolfuncs."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ToolfuncsError(Exception):
|
|
5
|
+
"""Base class for toolfuncs failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidSourceError(ToolfuncsError, ValueError):
|
|
9
|
+
"""A source cannot be interpreted as a supported Python tool."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DependencyMetadataError(InvalidSourceError):
|
|
13
|
+
"""Inline or project dependency metadata is invalid or ambiguous."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolMetadataError(InvalidSourceError):
|
|
17
|
+
"""Static metadata for a discoverable tool is missing or invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ImportNameError(InvalidSourceError):
|
|
21
|
+
"""A Python import name is invalid or cannot be inferred."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImportConflictError(ToolfuncsError, ImportError):
|
|
25
|
+
"""A source or import name conflicts with an earlier path import."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolNotFoundError(ToolfuncsError, ImportError):
|
|
29
|
+
"""No effective scoped tool has the requested name."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolDefinitionError(ToolfuncsError):
|
|
33
|
+
"""A discovered module does not define a valid function-first tool."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ShimConflictError(ToolfuncsError):
|
|
37
|
+
"""A shim target exists but is not managed by toolfuncs."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DependencyMetadataError",
|
|
42
|
+
"ImportConflictError",
|
|
43
|
+
"ImportNameError",
|
|
44
|
+
"InvalidSourceError",
|
|
45
|
+
"ShimConflictError",
|
|
46
|
+
"ToolDefinitionError",
|
|
47
|
+
"ToolMetadataError",
|
|
48
|
+
"ToolNotFoundError",
|
|
49
|
+
"ToolfuncsError",
|
|
50
|
+
]
|