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 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())
@@ -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
+ ]