tether-sdk 0.0.2__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.
tether/decorator.py ADDED
@@ -0,0 +1,280 @@
1
+ """@tool decorator introspection and ultra-compact JIT --help synthesis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any, Literal, get_args, get_origin, get_type_hints
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class ParamSpec:
13
+ name: str
14
+ flag: str
15
+ type_name: str
16
+ annotation: Any
17
+ required: bool
18
+ default: Any
19
+ is_variadic: bool = False
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class ToolSpec:
24
+ name: str
25
+ description: str
26
+ fn: Callable[..., Any]
27
+ params: list[ParamSpec]
28
+ accepts_stdin: bool
29
+ is_async: bool
30
+ custom_help: str | None = None
31
+ raw_dispatch: bool = False # If True, passes (args: list[str], stdin: str | None) directly
32
+
33
+ def index_line(self) -> str:
34
+ """Return a 1-line summary (strictly <= 12 tokens) for the static system prompt."""
35
+ short_desc = " ".join(self.description.strip().split())
36
+ line = f"- {self.name}: {short_desc}"
37
+ if len(line) > 44:
38
+ line = line[:41].rstrip() + "..."
39
+ return line
40
+
41
+ def help_text(self) -> str:
42
+ """Return ultra-compact --help output (target <= 60 tokens)."""
43
+ if self.custom_help:
44
+ return self.custom_help.strip()
45
+
46
+ parts: list[str] = [self.name]
47
+ for p in self.params:
48
+ if p.is_variadic:
49
+ parts.append(f"<{p.name}...>")
50
+ elif p.required:
51
+ parts.append(f"<{p.name}:{p.type_name}>")
52
+ else:
53
+ default_repr = repr(p.default) if isinstance(p.default, str) else str(p.default)
54
+ parts.append(f"[{p.flag} {p.type_name}={default_repr}]")
55
+ if self.accepts_stdin:
56
+ parts.append("[stdin]")
57
+
58
+ usage_line = " ".join(parts)
59
+ doc = (inspect.getdoc(self.fn) or self.description).strip().splitlines()[0]
60
+ if len(doc) > 100:
61
+ doc = doc[:97] + "..."
62
+ return f"Usage: {usage_line}\nDesc : {doc}"
63
+
64
+ async def invoke(self, args: list[str], stdin: str | None = None) -> str:
65
+ """Parse CLI arguments against ParamSpecs and invoke the underlying function."""
66
+ if self.raw_dispatch:
67
+ res = self.fn(args, stdin)
68
+ if inspect.isawaitable(res):
69
+ res = await res
70
+ return str(res) if res is not None else ""
71
+
72
+ if any(a in ("--help", "-h") for a in args):
73
+ return self.help_text()
74
+
75
+ parsed_kwargs = self._parse_args(args, stdin)
76
+ if isinstance(parsed_kwargs, str):
77
+ # Validation error string with self-healing --help
78
+ return f"Error: {parsed_kwargs}\n{self.help_text()}"
79
+
80
+ res = self.fn(**parsed_kwargs)
81
+ if inspect.isawaitable(res):
82
+ res = await res
83
+ return str(res) if res is not None else ""
84
+
85
+ def _parse_args(self, args: list[str], stdin: str | None) -> dict[str, Any] | str:
86
+ param_by_flag = {p.flag: p for p in self.params}
87
+ param_by_name = {p.name: p for p in self.params}
88
+ values: dict[str, Any] = {}
89
+ positionals: list[str] = []
90
+
91
+ i = 0
92
+ while i < len(args):
93
+ tok = args[i]
94
+ if tok.startswith("--"):
95
+ if "=" in tok:
96
+ flag, raw_val = tok.split("=", 1)
97
+ else:
98
+ flag = tok
99
+ raw_val = None
100
+ p = param_by_flag.get(flag) or param_by_name.get(flag.lstrip("-").replace("-", "_"))
101
+ if not p:
102
+ return f"unknown flag `{flag}`."
103
+ if p.type_name == "bool" and raw_val is None:
104
+ if i + 1 < len(args) and args[i + 1].lower() in ("true", "false", "1", "0"):
105
+ raw_val = args[i + 1]
106
+ i += 1
107
+ else:
108
+ values[p.name] = True
109
+ i += 1
110
+ continue
111
+ elif raw_val is None:
112
+ if i + 1 >= len(args):
113
+ return f"flag `{flag}` requires a value."
114
+ raw_val = args[i + 1]
115
+ i += 1
116
+ converted = _coerce_value(raw_val, p)
117
+ if isinstance(converted, tuple):
118
+ return str(converted[1])
119
+ values[p.name] = converted
120
+ else:
121
+ positionals.append(tok)
122
+ i += 1
123
+
124
+ # Map remaining positional arguments in order
125
+ unfilled = [p for p in self.params if p.name not in values]
126
+ pos_idx = 0
127
+ for p in unfilled:
128
+ if p.is_variadic:
129
+ rem = positionals[pos_idx:]
130
+ pos_idx = len(positionals)
131
+ values[p.name] = " ".join(rem)
132
+ elif pos_idx < len(positionals):
133
+ converted = _coerce_value(positionals[pos_idx], p)
134
+ if isinstance(converted, tuple):
135
+ return str(converted[1])
136
+ values[p.name] = converted
137
+ pos_idx += 1
138
+ elif not p.required:
139
+ values[p.name] = p.default
140
+ elif stdin is not None and len(self.params) == 1 and p.type_name == "str":
141
+ # Allow single required string param to be satisfied via stdin
142
+ values[p.name] = stdin
143
+ stdin = None
144
+ else:
145
+ return f"missing required argument `<{p.name}:{p.type_name}>`."
146
+
147
+ if pos_idx < len(positionals):
148
+ # If the last parameter is a string and not set by flag, join trailing unquoted words
149
+ if self.params and self.params[-1].type_name == "str":
150
+ last_p = self.params[-1]
151
+ extra = " ".join(positionals[pos_idx:])
152
+ values[last_p.name] = f"{values[last_p.name]} {extra}".strip()
153
+ else:
154
+ return f"unexpected positional arguments: {' '.join(positionals[pos_idx:])}"
155
+
156
+ if self.accepts_stdin:
157
+ values["stdin"] = stdin
158
+
159
+ return values
160
+
161
+
162
+ def _type_label(ann: Any) -> str:
163
+ origin = get_origin(ann)
164
+ if origin is Literal:
165
+ choices = "|".join(str(a) for a in get_args(ann))
166
+ return choices
167
+ if ann is int:
168
+ return "int"
169
+ if ann is float:
170
+ return "float"
171
+ if ann is bool:
172
+ return "bool"
173
+ return "str"
174
+
175
+
176
+ def _coerce_value(raw: str, p: ParamSpec) -> Any | tuple[Literal["err"], str]:
177
+ origin = get_origin(p.annotation)
178
+ if origin is Literal:
179
+ allowed = [str(a) for a in get_args(p.annotation)]
180
+ if raw not in allowed:
181
+ return ("err", f"`{p.name}` must be one of {'|'.join(allowed)} (got `{raw}`).")
182
+ return raw
183
+ if p.annotation is int:
184
+ try:
185
+ return int(raw)
186
+ except ValueError:
187
+ return ("err", f"`{p.name}` expects int (got `{raw}`).")
188
+ if p.annotation is float:
189
+ try:
190
+ return float(raw)
191
+ except ValueError:
192
+ return ("err", f"`{p.name}` expects float (got `{raw}`).")
193
+ if p.annotation is bool:
194
+ low = raw.lower()
195
+ if low in ("true", "1", "yes"):
196
+ return True
197
+ if low in ("false", "0", "no"):
198
+ return False
199
+ return ("err", f"`{p.name}` expects bool (got `{raw}`).")
200
+ return raw
201
+
202
+
203
+ def build_tool_spec(
204
+ fn: Callable[..., Any],
205
+ *,
206
+ name: str | None = None,
207
+ description: str | None = None,
208
+ custom_help: str | None = None,
209
+ raw_dispatch: bool = False,
210
+ ) -> ToolSpec:
211
+ """Introspect a Python function into a ToolSpec."""
212
+ tool_name = (name or fn.__name__).strip().replace("_", "-")
213
+ doc = inspect.getdoc(fn) or ""
214
+ first_line = doc.splitlines()[0].strip() if doc else f"Run {tool_name}"
215
+ tool_desc = description or first_line
216
+
217
+ sig = inspect.signature(fn)
218
+ try:
219
+ hints = get_type_hints(fn)
220
+ except Exception:
221
+ hints = {}
222
+
223
+ params: list[ParamSpec] = []
224
+ accepts_stdin = False
225
+
226
+ if not raw_dispatch:
227
+ for param_name, param in sig.parameters.items():
228
+ if param_name == "stdin":
229
+ accepts_stdin = True
230
+ continue
231
+ ann = hints.get(param_name, param.annotation)
232
+ if ann is inspect.Parameter.empty:
233
+ ann = str
234
+ is_var = param.kind == inspect.Parameter.VAR_POSITIONAL
235
+ required = param.default is inspect.Parameter.empty and not is_var
236
+ default_val = None if required else param.default
237
+ flag = f"--{param_name.replace('_', '-')}"
238
+ params.append(
239
+ ParamSpec(
240
+ name=param_name,
241
+ flag=flag,
242
+ type_name=_type_label(ann),
243
+ annotation=ann,
244
+ required=required,
245
+ default=default_val,
246
+ is_variadic=is_var,
247
+ )
248
+ )
249
+
250
+ spec = ToolSpec(
251
+ name=tool_name,
252
+ description=tool_desc,
253
+ fn=fn,
254
+ params=params,
255
+ accepts_stdin=accepts_stdin,
256
+ is_async=inspect.iscoroutinefunction(fn),
257
+ custom_help=custom_help,
258
+ raw_dispatch=raw_dispatch,
259
+ )
260
+ setattr(fn, "__tether_tool__", spec) # noqa: B010
261
+ return spec
262
+
263
+
264
+ def tool(
265
+ fn: Callable[..., Any] | None = None,
266
+ *,
267
+ name: str | None = None,
268
+ namespace: str | None = None,
269
+ description: str | None = None,
270
+ ) -> Any:
271
+ """Decorator to register a sync or async Python function as a Tether virtual CLI tool."""
272
+ effective_name = name or namespace
273
+
274
+ def decorator(target: Callable[..., Any | Awaitable[Any]]) -> Callable[..., Any]:
275
+ build_tool_spec(target, name=effective_name, description=description)
276
+ return target
277
+
278
+ if fn is not None:
279
+ return decorator(fn)
280
+ return decorator