htbp-sdk 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.
htbp/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """htbp — HTBP (HTTP Tool Bridge Protocol) server SDK.
2
+
3
+ Expose functions as self-describing HTTP tool endpoints, FastAPI-style::
4
+
5
+ from htbp import Htbp
6
+
7
+ app = Htbp(title="Math tools")
8
+
9
+ @app.tool()
10
+ def add(a: int, b: int = 0) -> int:
11
+ \"\"\"Add two integers.\"\"\"
12
+ return a + b
13
+
14
+ # uvicorn main:app
15
+ """
16
+
17
+ from .app import Htbp
18
+ from .errors import (
19
+ BadRequestError,
20
+ HtbpError,
21
+ MethodNotAllowedError,
22
+ NotFoundError,
23
+ UpstreamError,
24
+ )
25
+ from .schema import ToolContext
26
+ from .text_help import build_text_help
27
+
28
+ __all__ = [
29
+ "Htbp",
30
+ "ToolContext",
31
+ "HtbpError",
32
+ "NotFoundError",
33
+ "BadRequestError",
34
+ "MethodNotAllowedError",
35
+ "UpstreamError",
36
+ "build_text_help",
37
+ ]
htbp/app.py ADDED
@@ -0,0 +1,402 @@
1
+ """The Htbp application: FastAPI-style tool registration on a self-describing
2
+ HTBP tree, served as a pure-ASGI app — mirror of the TypeScript SDK's
3
+ core/app.ts dispatch."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import inspect
9
+ import json
10
+ from typing import Any, Callable
11
+ from urllib.parse import quote, unquote
12
+
13
+ from .describe import describe_directory, describe_tool
14
+ from .errors import BadRequestError, HtbpError, MethodNotAllowedError, NotFoundError
15
+ from .http import (
16
+ CORS_HEADERS,
17
+ MAX_JSON_BYTES,
18
+ constant_time_equal,
19
+ dump_json,
20
+ error_body,
21
+ etag_of,
22
+ get_bearer_token,
23
+ if_none_match_matches,
24
+ )
25
+ from .schema import CompiledTool, ToolContext, compile_function, output_json_schema
26
+ from .text_help import build_text_help
27
+ from .tree import (
28
+ EFFECTS,
29
+ DirectoryNode,
30
+ RemoteNode,
31
+ ToolNode,
32
+ add_child,
33
+ ensure_directory,
34
+ path_segments,
35
+ resolve,
36
+ )
37
+
38
+
39
+ class Htbp:
40
+ def __init__(
41
+ self,
42
+ title: str,
43
+ description: str | None = None,
44
+ *,
45
+ auth: dict[str, str] | None = None,
46
+ skill: str | None = None,
47
+ cors: bool = True,
48
+ base_path: str | None = None,
49
+ ) -> None:
50
+ if auth is not None and "bearer_token" not in auth:
51
+ raise ValueError("auth must be {'bearer_token': '...'}.")
52
+ self._auth = auth
53
+ self._cors = cors
54
+ self._base_path = _normalize_base_path(base_path)
55
+ self.root = DirectoryNode(id="", title=title, description=description, skill=skill)
56
+
57
+ # -- registration -----------------------------------------------------
58
+
59
+ def tool(
60
+ self,
61
+ fn: Callable[..., Any] | None = None,
62
+ *,
63
+ name: str | None = None,
64
+ description: str | None = None,
65
+ effect: str = "unknown",
66
+ example: Any = None,
67
+ skill: str | None = None,
68
+ ):
69
+ """Decorator: register a plain function as an HTBP tool.
70
+
71
+ Usable bare (``@app.tool``) or with options (``@app.tool(...)``).
72
+ """
73
+ if effect not in EFFECTS:
74
+ raise ValueError(f"effect must be one of {EFFECTS}.")
75
+
76
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
77
+ compiled = compile_function(func)
78
+ tool_name = name or func.__name__
79
+ doc = inspect.getdoc(func)
80
+ tool_description = description or (doc.split("\n\n")[0].strip() if doc else None)
81
+ return_annotation = inspect.signature(func).return_annotation
82
+ add_child(
83
+ self.root,
84
+ ToolNode(
85
+ id=tool_name,
86
+ title=tool_name,
87
+ description=tool_description,
88
+ skill=skill,
89
+ effect=effect,
90
+ example=example,
91
+ input_schema=compiled.json_schema,
92
+ output_schema=output_json_schema(return_annotation),
93
+ validate=compiled.validate,
94
+ call=_make_caller(func, compiled),
95
+ ),
96
+ )
97
+ return func
98
+
99
+ if fn is not None:
100
+ return decorator(fn)
101
+ return decorator
102
+
103
+ def mount(self, path: str, sub: "Htbp") -> "Htbp":
104
+ """Graft a sub-app's tree under ``path`` (cascading)."""
105
+ if sub is self:
106
+ raise ValueError("Cannot mount an app into itself.")
107
+ segments = path_segments(path)
108
+ parent = ensure_directory(self.root, segments[:-1])
109
+ child = DirectoryNode(
110
+ id=segments[-1],
111
+ title=sub.root.title,
112
+ description=sub.root.description,
113
+ skill=sub.root.skill,
114
+ children=sub.root.children,
115
+ )
116
+ add_child(parent, child)
117
+ return self
118
+
119
+ def remote(
120
+ self,
121
+ path: str,
122
+ help_url: str,
123
+ *,
124
+ headers: dict[str, str] | None = None,
125
+ title: str | None = None,
126
+ description: str | None = None,
127
+ allow_insecure: bool = False,
128
+ ) -> "Htbp":
129
+ segments = path_segments(path)
130
+ parent = ensure_directory(self.root, segments[:-1])
131
+ add_child(
132
+ parent,
133
+ RemoteNode(
134
+ id=segments[-1],
135
+ title=title or segments[-1],
136
+ description=description,
137
+ help_url=help_url,
138
+ headers=headers,
139
+ allow_insecure=allow_insecure,
140
+ ),
141
+ )
142
+ return self
143
+
144
+ # -- ASGI -------------------------------------------------------------
145
+
146
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
147
+ if scope["type"] == "lifespan":
148
+ await _lifespan(receive, send)
149
+ return
150
+ if scope["type"] != "http":
151
+ raise RuntimeError(f"Unsupported ASGI scope type '{scope['type']}'.")
152
+ try:
153
+ status, headers, body = await self._dispatch(scope, receive)
154
+ except HtbpError as error:
155
+ status, headers, body = self._error_response(error)
156
+ except Exception as error: # noqa: BLE001 - handler errors become 500 envelopes
157
+ status, headers, body = self._error_response(HtbpError(500, "internal_error", str(error)))
158
+ if self._cors:
159
+ for key, value in CORS_HEADERS.items():
160
+ headers.setdefault(key, value)
161
+ await _send_response(send, status, headers, body)
162
+
163
+ def _error_response(self, error: HtbpError) -> tuple[int, dict[str, str], bytes]:
164
+ headers = {"Content-Type": "application/json; charset=utf-8"}
165
+ if error.status == 401:
166
+ headers["WWW-Authenticate"] = "Bearer"
167
+ return error.status, headers, error_body(error.code, error.message, error.details).encode("utf-8")
168
+
169
+ async def _dispatch(self, scope: dict[str, Any], receive: Any) -> tuple[int, dict[str, str], bytes]:
170
+ method = scope["method"]
171
+ headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}
172
+
173
+ if method == "OPTIONS" and self._cors:
174
+ return 204, {**CORS_HEADERS, "Access-Control-Max-Age": "86400"}, b""
175
+
176
+ self._authenticate(headers)
177
+
178
+ segments, base = self._request_segments(scope)
179
+ control = None
180
+ if segments and segments[-1] in ("~help", "~skill"):
181
+ control = segments.pop()
182
+
183
+ found = resolve(self.root, segments)
184
+ if found is None:
185
+ raise NotFoundError(f"No HTBP resource at '/{'/'.join(segments)}'.")
186
+ node, sub = found
187
+ resource_path = _resource_path(base, segments)
188
+
189
+ if control == "~skill":
190
+ return await self._serve_skill(method, headers, node, sub, resource_path)
191
+ if control == "~help" or (method == "GET" and not segments):
192
+ return await self._serve_help(method, headers, node, sub, resource_path)
193
+
194
+ if method == "POST":
195
+ return await self._serve_call(scope, receive, headers, node, sub, resource_path)
196
+ if method == "GET":
197
+ raise MethodNotAllowedError(f"GET is not supported here; fetch '{resource_path}/~help' for usage.")
198
+ raise MethodNotAllowedError(f"Method {method} is not supported.")
199
+
200
+ def _authenticate(self, headers: dict[str, str]) -> None:
201
+ if self._auth is None:
202
+ return
203
+ token = get_bearer_token(headers.get("authorization"))
204
+ if not token or not constant_time_equal(token, self._auth["bearer_token"]):
205
+ raise HtbpError(401, "unauthorized", "A valid bearer token is required.")
206
+
207
+ def _request_segments(self, scope: dict[str, Any]) -> tuple[list[str], str]:
208
+ # root_path covers being mounted inside FastAPI/Starlette; an explicit
209
+ # base_path is for serving under a prefix without a mounting frame.
210
+ root_path = scope.get("root_path", "") or ""
211
+ base = _join_base(root_path, self._base_path)
212
+ path = scope["path"]
213
+ rest = path
214
+ if self._base_path:
215
+ inner = path[len(root_path):] if root_path and path.startswith(root_path) else path
216
+ if inner != self._base_path and not inner.startswith(self._base_path + "/"):
217
+ raise NotFoundError(f"No HTBP resource at '{path}'.")
218
+ rest = inner[len(self._base_path):]
219
+ elif root_path and path.startswith(root_path):
220
+ rest = path[len(root_path):]
221
+ segments = [unquote(segment) for segment in rest.split("/") if segment]
222
+ return segments, base
223
+
224
+ async def _serve_help(
225
+ self, method: str, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
226
+ ) -> tuple[int, dict[str, str], bytes]:
227
+ if method != "GET":
228
+ raise MethodNotAllowedError("~help only supports GET.")
229
+ has_skill = False
230
+ if isinstance(node, DirectoryNode):
231
+ payload = describe_directory(node)
232
+ await self._enrich_remote_children(node, payload)
233
+ has_skill = node.skill is not None
234
+ elif isinstance(node, ToolNode):
235
+ if sub:
236
+ raise NotFoundError(f"No HTBP resource at '{resource_path}'.")
237
+ payload = describe_tool(node)
238
+ has_skill = node.skill is not None
239
+ else:
240
+ from .remote import remote_describe
241
+
242
+ payload = await remote_describe(node, sub)
243
+
244
+ auth_label = "bearer" if self._auth else "none"
245
+ cache_control = (
246
+ "no-store"
247
+ if payload.get("cachable") is False
248
+ else f"{'private' if self._auth else 'public'}, max-age=300"
249
+ )
250
+ accept = headers.get("accept", "")
251
+ if _prefers_text(accept):
252
+ body = build_text_help(payload, resource_path, auth_label, has_skill)
253
+ content_type = "text/plain; charset=utf-8"
254
+ else:
255
+ body = dump_json(payload) + "\n"
256
+ content_type = "application/json; charset=utf-8"
257
+ if cache_control == "no-store":
258
+ return 200, {"Content-Type": content_type, "Cache-Control": cache_control}, body.encode("utf-8")
259
+ return _cached_document(body, content_type, cache_control, headers.get("if-none-match"))
260
+
261
+ async def _enrich_remote_children(self, node: DirectoryNode, payload: dict[str, Any]) -> None:
262
+ remotes = [child for child in node.children.values() if isinstance(child, RemoteNode) and not child.description]
263
+ if not remotes:
264
+ return
265
+ from .remote import fetch_remote_summary
266
+
267
+ refs = {ref["name"]: ref for ref in payload.get("resources", [])}
268
+ summaries = await asyncio.gather(*(fetch_remote_summary(child) for child in remotes))
269
+ for child, summary in zip(remotes, summaries):
270
+ ref = refs.get(child.id)
271
+ if ref is None or summary is None:
272
+ continue
273
+ enriched = summary.get("description") or (f"Remote: {summary['title']}" if summary.get("title") else None)
274
+ if enriched:
275
+ ref["description"] = enriched
276
+
277
+ async def _serve_skill(
278
+ self, method: str, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
279
+ ) -> tuple[int, dict[str, str], bytes]:
280
+ if method != "GET":
281
+ raise MethodNotAllowedError("~skill only supports GET.")
282
+ skill = None if isinstance(node, RemoteNode) or sub else getattr(node, "skill", None)
283
+ if skill is None:
284
+ raise NotFoundError(f"No skill document at '{resource_path}/~skill'.")
285
+ cache_control = f"{'private' if self._auth else 'public'}, max-age=300"
286
+ return _cached_document(skill, "text/markdown; charset=utf-8", cache_control, headers.get("if-none-match"))
287
+
288
+ async def _serve_call(
289
+ self, scope: dict[str, Any], receive: Any, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
290
+ ) -> tuple[int, dict[str, str], bytes]:
291
+ if isinstance(node, DirectoryNode):
292
+ raise HtbpError(405, "not_callable", f"'{resource_path}' is a directory; descend to an end-path resource.")
293
+ input_value = await _read_json_body(receive)
294
+ if isinstance(node, RemoteNode):
295
+ from .remote import remote_call
296
+
297
+ result = await remote_call(node, sub, input_value)
298
+ return _json_response({"resource": resource_path, "result": result})
299
+ if sub:
300
+ raise NotFoundError(f"No HTBP resource at '{resource_path}'.")
301
+ ok, value = node.validate(input_value)
302
+ if not ok:
303
+ raise BadRequestError("Input validation failed.", value)
304
+ ctx = ToolContext(path=resource_path, headers=headers, scope=scope)
305
+ result = await node.call(value, ctx)
306
+ return _json_response({"resource": resource_path, "result": result})
307
+
308
+
309
+ def _make_caller(fn: Callable[..., Any], compiled: CompiledTool):
310
+ is_async = inspect.iscoroutinefunction(fn)
311
+
312
+ async def call(value: Any, ctx: ToolContext) -> Any:
313
+ kwargs = compiled.bind(value, ctx)
314
+ if is_async:
315
+ return await fn(**kwargs)
316
+ return await asyncio.to_thread(fn, **kwargs)
317
+
318
+ return call
319
+
320
+
321
+ def _prefers_text(accept: str) -> bool:
322
+ value = accept.lower()
323
+ if "application/json" in value:
324
+ return False
325
+ return "text/plain" in value or "text/markdown" in value
326
+
327
+
328
+ def _normalize_base_path(base_path: str | None) -> str:
329
+ if not base_path or base_path == "/":
330
+ return ""
331
+ trimmed = base_path.rstrip("/")
332
+ return trimmed if trimmed.startswith("/") else f"/{trimmed}"
333
+
334
+
335
+ def _join_base(root_path: str, base_path: str) -> str:
336
+ combined = f"{root_path.rstrip('/')}{base_path}"
337
+ return combined if combined else ""
338
+
339
+
340
+ def _resource_path(base: str, segments: list[str]) -> str:
341
+ joined = "/".join(quote(segment, safe="") for segment in segments)
342
+ if not joined:
343
+ return base or "/"
344
+ return f"{base}/{joined}"
345
+
346
+
347
+ def _cached_document(
348
+ body: str, content_type: str, cache_control: str, if_none_match: str | None
349
+ ) -> tuple[int, dict[str, str], bytes]:
350
+ etag = etag_of(body)
351
+ headers = {"ETag": etag, "Cache-Control": cache_control}
352
+ if if_none_match and if_none_match_matches(if_none_match, etag):
353
+ return 304, headers, b""
354
+ return 200, {**headers, "Content-Type": content_type}, body.encode("utf-8")
355
+
356
+
357
+ def _json_response(data: Any) -> tuple[int, dict[str, str], bytes]:
358
+ return 200, {"Content-Type": "application/json; charset=utf-8"}, dump_json(data).encode("utf-8")
359
+
360
+
361
+ async def _read_json_body(receive: Any):
362
+ chunks: list[bytes] = []
363
+ total = 0
364
+ while True:
365
+ message = await receive()
366
+ if message["type"] != "http.request":
367
+ raise BadRequestError("Unexpected ASGI message while reading the body.")
368
+ chunk = message.get("body", b"")
369
+ total += len(chunk)
370
+ if total > MAX_JSON_BYTES:
371
+ raise BadRequestError("Request body exceeds the maximum size.")
372
+ chunks.append(chunk)
373
+ if not message.get("more_body", False):
374
+ break
375
+ body = b"".join(chunks).decode("utf-8", errors="replace")
376
+ if not body.strip():
377
+ return {}
378
+ try:
379
+ return json.loads(body)
380
+ except json.JSONDecodeError:
381
+ raise BadRequestError("Request body is not valid JSON.") from None
382
+
383
+
384
+ async def _lifespan(receive: Any, send: Any) -> None:
385
+ while True:
386
+ message = await receive()
387
+ if message["type"] == "lifespan.startup":
388
+ await send({"type": "lifespan.startup.complete"})
389
+ elif message["type"] == "lifespan.shutdown":
390
+ await send({"type": "lifespan.shutdown.complete"})
391
+ return
392
+
393
+
394
+ async def _send_response(send: Any, status: int, headers: dict[str, str], body: bytes) -> None:
395
+ await send(
396
+ {
397
+ "type": "http.response.start",
398
+ "status": status,
399
+ "headers": [(key.encode("latin-1"), value.encode("latin-1")) for key, value in headers.items()],
400
+ }
401
+ )
402
+ await send({"type": "http.response.body", "body": body})
htbp/describe.py ADDED
@@ -0,0 +1,55 @@
1
+ """Node -> help payload projection (mirror of core/describe.ts).
2
+
3
+ Payload dicts preserve the TypeScript key order so serialized output matches
4
+ across the two SDKs. Tool leaves are emitted as kind ``http`` for wire
5
+ compatibility with tool-bridge's remote-node sanitizer."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from .tree import AppNode, DirectoryNode, RemoteNode, ToolNode
13
+
14
+
15
+ def describe_directory(node: DirectoryNode) -> dict[str, Any]:
16
+ resources = []
17
+ for child in node.children.values():
18
+ ref: dict[str, Any] = {
19
+ "name": child.id,
20
+ # INVARIANT: relative path, so the subtree stays mountable anywhere.
21
+ "path": f"./{quote(child.id, safe='')}",
22
+ }
23
+ description = _child_description(child)
24
+ if description is not None:
25
+ ref["description"] = description
26
+ resources.append(ref)
27
+ payload: dict[str, Any] = {"htbp": "draft", "kind": "directory", "title": node.title}
28
+ if node.description is not None:
29
+ payload["description"] = node.description
30
+ payload["resources"] = resources
31
+ return payload
32
+
33
+
34
+ def _child_description(child: AppNode) -> str | None:
35
+ if child.description:
36
+ return child.description
37
+ if isinstance(child, RemoteNode):
38
+ return "Remote HTBP server."
39
+ return None
40
+
41
+
42
+ def describe_tool(node: ToolNode) -> dict[str, Any]:
43
+ endpoint: dict[str, Any] = {"method": "POST"}
44
+ if node.input_schema is not None:
45
+ endpoint["inputSchema"] = node.input_schema
46
+ if node.output_schema is not None:
47
+ endpoint["outputSchema"] = node.output_schema
48
+ if node.example is not None:
49
+ endpoint["example"] = node.example
50
+ endpoint["effect"] = node.effect
51
+ payload: dict[str, Any] = {"htbp": "draft", "kind": "http", "title": node.title}
52
+ if node.description is not None:
53
+ payload["description"] = node.description
54
+ payload["endpoint"] = endpoint
55
+ return payload
htbp/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Error hierarchy mapped to the HTBP error envelope
2
+ ``{"error":{"code","message","details?"}}`` with an HTTP status."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+
9
+ class HtbpError(Exception):
10
+ def __init__(self, status: int, code: str, message: str, details: Any = None) -> None:
11
+ super().__init__(message)
12
+ self.status = status
13
+ self.code = code
14
+ self.message = message
15
+ self.details = details
16
+
17
+
18
+ class NotFoundError(HtbpError):
19
+ def __init__(self, message: str) -> None:
20
+ super().__init__(404, "not_found", message)
21
+
22
+
23
+ class MethodNotAllowedError(HtbpError):
24
+ def __init__(self, message: str) -> None:
25
+ super().__init__(405, "method_not_allowed", message)
26
+
27
+
28
+ class BadRequestError(HtbpError):
29
+ def __init__(self, message: str, details: Any = None) -> None:
30
+ super().__init__(400, "bad_request", message, details)
31
+
32
+
33
+ class UpstreamError(HtbpError):
34
+ def __init__(self, message: str, details: Any = None) -> None:
35
+ super().__init__(502, "upstream_error", message, details)
htbp/http.py ADDED
@@ -0,0 +1,55 @@
1
+ """HTTP plumbing shared by the ASGI handler and the remote client — mirror of
2
+ the TypeScript SDK's core/http.ts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import hmac
8
+ import json as _json
9
+ import re
10
+ from typing import Any
11
+
12
+ MAX_JSON_BYTES = 1_000_000
13
+ REMOTE_FETCH_TIMEOUT_S = 5.0
14
+
15
+ CORS_HEADERS = {
16
+ "Access-Control-Allow-Origin": "*",
17
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
18
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, If-None-Match",
19
+ }
20
+
21
+
22
+ def dump_json(data: Any) -> str:
23
+ # Matches JSON.stringify(data, null, 2): 2-space indent, ": " separator,
24
+ # no ASCII escaping of non-ASCII characters.
25
+ return _json.dumps(data, indent=2, ensure_ascii=False)
26
+
27
+
28
+ def error_body(code: str, message: str, details: Any = None) -> str:
29
+ error: dict[str, Any] = {"code": code, "message": message}
30
+ if details is not None:
31
+ error["details"] = details
32
+ return dump_json({"error": error})
33
+
34
+
35
+ def etag_of(body: str) -> str:
36
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
37
+ return f'"{digest[:16]}"'
38
+
39
+
40
+ def if_none_match_matches(header: str, etag: str) -> bool:
41
+ candidates = [candidate.strip() for candidate in header.split(",")]
42
+ return any(candidate == etag or candidate == "*" for candidate in candidates)
43
+
44
+
45
+ def constant_time_equal(a: str, b: str) -> bool:
46
+ hash_a = hashlib.sha256(a.encode("utf-8")).hexdigest()
47
+ hash_b = hashlib.sha256(b.encode("utf-8")).hexdigest()
48
+ return hmac.compare_digest(hash_a, hash_b)
49
+
50
+
51
+ def get_bearer_token(authorization: str | None) -> str | None:
52
+ if not authorization:
53
+ return None
54
+ match = re.match(r"^Bearer\s+(.+)$", authorization.strip(), re.IGNORECASE)
55
+ return match.group(1) if match else None
htbp/remote.py ADDED
@@ -0,0 +1,188 @@
1
+ """Remote HTBP node: pass through ``~help`` for any sub-path and proxy calls,
2
+ never forwarding local credentials — mirror of the TypeScript SDK's
3
+ core/remote.ts. Requires the ``htbp-sdk[remote]`` extra (httpx)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ from typing import Any
10
+ from urllib.parse import quote, urlsplit
11
+
12
+ from .errors import HtbpError, NotFoundError, UpstreamError
13
+ from .http import MAX_JSON_BYTES, REMOTE_FETCH_TIMEOUT_S
14
+ from .tree import EFFECTS, RemoteNode
15
+
16
+ _VALID_KINDS = ("directory", "http", "remote")
17
+
18
+
19
+ def _httpx():
20
+ try:
21
+ import httpx
22
+ except ImportError as error: # pragma: no cover
23
+ raise RuntimeError(
24
+ "Remote nodes require httpx; install the extra: pip install 'htbp-sdk[remote]'."
25
+ ) from error
26
+ return httpx
27
+
28
+
29
+ def remote_base_url(help_url: str) -> str:
30
+ return re.sub(r"/?~help/?$", "", help_url).rstrip("/")
31
+
32
+
33
+ def _require_secure_url(node: RemoteNode, raw: str) -> str:
34
+ parts = urlsplit(raw)
35
+ if parts.scheme not in ("http", "https"):
36
+ raise UpstreamError(f"Remote URL '{raw}' must be http(s).")
37
+ host = parts.hostname or ""
38
+ is_local = host in ("localhost", "127.0.0.1", "::1")
39
+ if parts.scheme != "https" and not (node.allow_insecure or is_local):
40
+ raise UpstreamError(f"Remote URL '{raw}' must use https (set allow_insecure for development).")
41
+ return raw
42
+
43
+
44
+ def _remote_target(node: RemoteNode, sub: list[str]) -> str:
45
+ for segment in sub:
46
+ if segment in (".", "..") or not segment:
47
+ raise NotFoundError(f"Invalid remote sub-path segment '{segment}'.")
48
+ base = remote_base_url(node.help_url)
49
+ suffix = "/".join(quote(segment, safe="") for segment in sub)
50
+ return _require_secure_url(node, f"{base}/{suffix}" if suffix else base)
51
+
52
+
53
+ def _remote_headers(node: RemoteNode, extra: dict[str, str]) -> dict[str, str]:
54
+ # Only the per-remote configured headers ever go upstream; the inbound
55
+ # Authorization header is deliberately never forwarded.
56
+ headers = dict(node.headers or {})
57
+ headers.update(extra)
58
+ return headers
59
+
60
+
61
+ async def _bounded_request(method: str, url: str, headers: dict[str, str], content: str | None, timeout: float) -> tuple[int, str]:
62
+ httpx = _httpx()
63
+ async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
64
+ response = await client.request(method, url, headers=headers, content=content)
65
+ if 300 <= response.status_code < 400:
66
+ raise UpstreamError(f"Remote '{url}' responded with a redirect ({response.status_code}); redirects are not followed.")
67
+ body = response.text
68
+ if len(body.encode("utf-8")) > MAX_JSON_BYTES:
69
+ raise UpstreamError("Remote response body exceeded the maximum size.")
70
+ return response.status_code, body
71
+
72
+
73
+ async def remote_describe(node: RemoteNode, sub: list[str]) -> dict[str, Any]:
74
+ target = _remote_target(node, sub)
75
+ help_url = f"{target.rstrip('/')}/~help"
76
+ try:
77
+ status, body = await _bounded_request(
78
+ "GET", help_url, _remote_headers(node, {"Accept": "application/json"}), None, REMOTE_FETCH_TIMEOUT_S
79
+ )
80
+ except HtbpError:
81
+ raise
82
+ except Exception as error:
83
+ raise UpstreamError(f"Failed to fetch remote help: {error}") from error
84
+ if status == 404:
85
+ raise NotFoundError(f"Remote resource '/{'/'.join(sub)}' not found.")
86
+ if status != 200:
87
+ raise UpstreamError(f"Remote help returned {status}.", body[:2000] or None)
88
+ try:
89
+ payload = parse_help_payload(body)
90
+ except Exception as error:
91
+ raise UpstreamError(f"Invalid remote help payload: {error}") from error
92
+ if not sub:
93
+ payload["kind"] = "remote"
94
+ payload["title"] = node.title or payload["title"]
95
+ if node.description is not None:
96
+ payload["description"] = node.description
97
+ return payload
98
+
99
+
100
+ async def remote_call(node: RemoteNode, sub: list[str], input_value: Any) -> Any:
101
+ target = _remote_target(node, sub)
102
+ try:
103
+ status, body = await _bounded_request(
104
+ "POST",
105
+ target,
106
+ _remote_headers(node, {"Content-Type": "application/json", "Accept": "application/json"}),
107
+ json.dumps(input_value if input_value is not None else {}),
108
+ REMOTE_FETCH_TIMEOUT_S,
109
+ )
110
+ except HtbpError:
111
+ raise
112
+ except Exception as error:
113
+ raise UpstreamError(f"Remote call failed: {error}") from error
114
+ try:
115
+ parsed = json.loads(body) if body else None
116
+ except json.JSONDecodeError:
117
+ raise UpstreamError(f"Remote call returned non-JSON (status {status}).") from None
118
+ if status != 200:
119
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
120
+ error = parsed["error"]
121
+ if isinstance(error.get("code"), str) and isinstance(error.get("message"), str):
122
+ raise HtbpError(status, error["code"], error["message"], error.get("details"))
123
+ raise UpstreamError(f"Remote call returned {status}.", parsed)
124
+ if isinstance(parsed, dict) and "result" in parsed and "resource" in parsed:
125
+ return parsed["result"]
126
+ return parsed
127
+
128
+
129
+ async def fetch_remote_summary(node: RemoteNode, timeout: float = 1.5) -> dict[str, Any] | None:
130
+ """Best-effort title/description for directory listings; None on failure."""
131
+ try:
132
+ help_url = f"{remote_base_url(node.help_url)}/~help"
133
+ _require_secure_url(node, help_url)
134
+ status, body = await _bounded_request(
135
+ "GET", help_url, _remote_headers(node, {"Accept": "application/json"}), None, timeout
136
+ )
137
+ if status != 200:
138
+ return None
139
+ payload = parse_help_payload(body)
140
+ return {"title": payload.get("title"), "description": payload.get("description")}
141
+ except Exception:
142
+ return None
143
+
144
+
145
+ def parse_help_payload(body: str) -> dict[str, Any]:
146
+ """Tolerantly validate an untrusted JSON help payload into our shape."""
147
+ parsed = json.loads(body)
148
+ if not isinstance(parsed, dict):
149
+ raise ValueError("Remote help payload is not a JSON object.")
150
+ kind = parsed.get("kind") if parsed.get("kind") in _VALID_KINDS else "directory"
151
+ payload: dict[str, Any] = {
152
+ "htbp": "draft",
153
+ "kind": kind,
154
+ "title": parsed["title"] if isinstance(parsed.get("title"), str) else "Remote HTBP server",
155
+ }
156
+ if isinstance(parsed.get("description"), str):
157
+ payload["description"] = parsed["description"]
158
+ if isinstance(parsed.get("cachable"), bool):
159
+ payload["cachable"] = parsed["cachable"]
160
+ resources = _parse_resources(parsed.get("resources"))
161
+ if resources is not None:
162
+ payload["resources"] = resources
163
+ if isinstance(parsed.get("endpoint"), dict):
164
+ payload["endpoint"] = _sanitize_endpoint(parsed["endpoint"])
165
+ return payload
166
+
167
+
168
+ def _parse_resources(value: Any) -> list[dict[str, Any]] | None:
169
+ if not isinstance(value, list):
170
+ return None
171
+ refs = []
172
+ for item in value:
173
+ if isinstance(item, dict) and isinstance(item.get("name"), str) and isinstance(item.get("path"), str):
174
+ ref: dict[str, Any] = {"name": item["name"], "path": item["path"]}
175
+ if isinstance(item.get("description"), str):
176
+ ref["description"] = item["description"]
177
+ refs.append(ref)
178
+ return refs
179
+
180
+
181
+ def _sanitize_endpoint(value: dict[str, Any]) -> dict[str, Any]:
182
+ endpoint: dict[str, Any] = {"method": value["method"] if isinstance(value.get("method"), str) else "POST"}
183
+ for key in ("inputSchema", "outputSchema", "example"):
184
+ if key in value:
185
+ endpoint[key] = value[key]
186
+ if value.get("effect") in EFFECTS:
187
+ endpoint["effect"] = value["effect"]
188
+ return endpoint
htbp/schema.py ADDED
@@ -0,0 +1,122 @@
1
+ """Function signature -> pydantic model -> JSON Schema, FastAPI-style.
2
+
3
+ Rules:
4
+ - Every regular parameter becomes an input field; type hints map to pydantic
5
+ field types (missing hints default to Any), defaults make fields optional.
6
+ - A parameter annotated with ``ToolContext`` is injected at call time and
7
+ excluded from the schema.
8
+ - Exactly one non-context parameter annotated with a ``BaseModel`` subclass
9
+ makes that model the input schema directly (the "body model" style).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import inspect
15
+ from dataclasses import dataclass
16
+ from typing import Any, get_type_hints
17
+
18
+ from pydantic import BaseModel, ValidationError, create_model
19
+
20
+
21
+ @dataclass
22
+ class ToolContext:
23
+ """Request context injected into ToolContext-annotated parameters."""
24
+
25
+ path: str
26
+ headers: dict[str, str]
27
+ scope: dict[str, Any]
28
+
29
+
30
+ @dataclass
31
+ class CompiledTool:
32
+ json_schema: dict[str, Any] | None
33
+ validate: Any # (input) -> (ok, value_or_issues)
34
+ bind: Any # (validated_value, ctx) -> kwargs dict for fn
35
+
36
+
37
+ def compile_function(fn: Any) -> CompiledTool:
38
+ signature = inspect.signature(fn)
39
+ try:
40
+ hints = get_type_hints(fn)
41
+ except Exception:
42
+ hints = {}
43
+
44
+ ctx_params: list[str] = []
45
+ field_params: list[inspect.Parameter] = []
46
+ for param in signature.parameters.values():
47
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
48
+ raise TypeError(f"Tool '{fn.__name__}' cannot use *args/**kwargs parameters.")
49
+ annotation = hints.get(param.name, param.annotation)
50
+ if annotation is ToolContext:
51
+ ctx_params.append(param.name)
52
+ else:
53
+ field_params.append(param)
54
+
55
+ body_model = _single_body_model(field_params, hints)
56
+ if body_model is not None:
57
+ model, body_param = body_model
58
+ return CompiledTool(
59
+ json_schema=model.model_json_schema(),
60
+ validate=_model_validator(model),
61
+ bind=lambda value, ctx: {body_param: value, **{name: ctx for name in ctx_params}},
62
+ )
63
+
64
+ fields: dict[str, Any] = {}
65
+ for param in field_params:
66
+ annotation = hints.get(param.name, param.annotation)
67
+ if annotation is inspect.Parameter.empty:
68
+ annotation = Any
69
+ default = ... if param.default is inspect.Parameter.empty else param.default
70
+ fields[param.name] = (annotation, default)
71
+
72
+ if not fields:
73
+ return CompiledTool(
74
+ json_schema=None,
75
+ validate=lambda value: (True, value),
76
+ bind=lambda value, ctx: {name: ctx for name in ctx_params},
77
+ )
78
+
79
+ model = create_model(f"{_camel(fn.__name__)}Input", **fields)
80
+ field_names = list(fields)
81
+ return CompiledTool(
82
+ json_schema=model.model_json_schema(),
83
+ validate=_model_validator(model),
84
+ bind=lambda value, ctx: {
85
+ **{name: getattr(value, name) for name in field_names},
86
+ **{name: ctx for name in ctx_params},
87
+ },
88
+ )
89
+
90
+
91
+ def _single_body_model(
92
+ params: list[inspect.Parameter], hints: dict[str, Any]
93
+ ) -> tuple[type[BaseModel], str] | None:
94
+ if len(params) != 1:
95
+ return None
96
+ annotation = hints.get(params[0].name, params[0].annotation)
97
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
98
+ return annotation, params[0].name
99
+ return None
100
+
101
+
102
+ def _model_validator(model: type[BaseModel]):
103
+ def validate(value: Any) -> tuple[bool, Any]:
104
+ if not isinstance(value, dict):
105
+ return False, [{"type": "model_type", "msg": "Input must be a JSON object."}]
106
+ try:
107
+ return True, model.model_validate(value)
108
+ except ValidationError as error:
109
+ return False, error.errors(include_url=False)
110
+
111
+ return validate
112
+
113
+
114
+ def output_json_schema(annotation: Any) -> dict[str, Any] | None:
115
+ """Documentation-only output schema from a BaseModel return annotation."""
116
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
117
+ return annotation.model_json_schema()
118
+ return None
119
+
120
+
121
+ def _camel(name: str) -> str:
122
+ return "".join(part.capitalize() for part in name.split("_"))
htbp/text_help.py ADDED
@@ -0,0 +1,74 @@
1
+ """Text/plain HTBP DSL rendering of a JSON help payload — mirror of the
2
+ TypeScript SDK's protocol/text-help.ts (RFC-0001 §8)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any
8
+
9
+ MAX_SHAPE_LENGTH = 120
10
+ _SIMPLE_TYPES = {"string", "integer", "number", "boolean", "object", "array"}
11
+
12
+
13
+ def one_line(value: str) -> str:
14
+ return re.sub(r"\s+", " ", value).strip()
15
+
16
+
17
+ def build_text_help(payload: dict[str, Any], resource_path: str, auth: str, has_skill: bool) -> str:
18
+ lines = [
19
+ "htbp draft",
20
+ f"resource {resource_path}",
21
+ f"title {one_line(payload['title'])}",
22
+ ]
23
+ if payload.get("description"):
24
+ lines.append(f"summary {one_line(payload['description'])}")
25
+ if has_skill:
26
+ lines.append("skill ./~skill")
27
+ lines.append(f"auth {auth}")
28
+ lines.append("")
29
+
30
+ endpoint = payload.get("endpoint")
31
+ if endpoint:
32
+ lines.append(f"cmd call {endpoint['method']} {resource_path}")
33
+ lines.append(f" body application/json {compact_shape(endpoint.get('inputSchema'))}")
34
+ lines.append(f" auth {auth}")
35
+ lines.append(f" effect {endpoint.get('effect') or 'unknown'}")
36
+ lines.append(" returns 200 application/json")
37
+
38
+ for resource in payload.get("resources") or []:
39
+ lines.append(f"link child {join_relative(resource_path, resource['path'])}/~help")
40
+ if resource.get("description"):
41
+ lines.append(f" note {one_line(resource['description'])}")
42
+
43
+ return "\n".join(lines).rstrip() + "\n"
44
+
45
+
46
+ def compact_shape(schema: Any) -> str:
47
+ """Render a JSON Schema as the RFC §8.6 compact body shape, e.g.
48
+ ``{"a":integer,"b":integer?}``; fall back to ``object`` when it would not
49
+ stay compact."""
50
+ if not isinstance(schema, dict) or schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
51
+ return "object"
52
+ required = schema.get("required") if isinstance(schema.get("required"), list) else []
53
+ fields = []
54
+ for name, prop in schema["properties"].items():
55
+ optional = "" if name in required else "?"
56
+ fields.append(f'"{name}":{_shape_type(prop)}{optional}')
57
+ shape = "{" + ",".join(fields) + "}"
58
+ return "object" if len(shape) > MAX_SHAPE_LENGTH else shape
59
+
60
+
61
+ def _shape_type(prop: Any) -> str:
62
+ if isinstance(prop, dict) and prop.get("type") in _SIMPLE_TYPES:
63
+ return prop["type"]
64
+ return "json"
65
+
66
+
67
+ def join_relative(base: str, relative: str) -> str:
68
+ prefix = "" if base == "/" else base.rstrip("/")
69
+ if relative.startswith("./"):
70
+ return f"{prefix}/{relative[2:]}"
71
+ if relative.startswith("../"):
72
+ parent = "/".join(prefix.split("/")[:-1])
73
+ return f"{parent}/{relative[3:]}"
74
+ return f"{prefix}/{relative}"
htbp/tree.py ADDED
@@ -0,0 +1,112 @@
1
+ """In-memory app tree and path resolution — line-for-line mirror of the
2
+ TypeScript SDK's core/tree.ts (itself a reduction of tool-bridge's
3
+ registry.ts)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Awaitable, Callable, Union
10
+
11
+ Effect = str # 'read' | 'write' | 'delete' | 'external' | 'unknown'
12
+ EFFECTS = ("read", "write", "delete", "external", "unknown")
13
+
14
+ # validate(input) -> (ok, value_or_issues)
15
+ Validator = Callable[[Any], tuple[bool, Any]]
16
+ Handler = Callable[..., Any]
17
+
18
+
19
+ @dataclass
20
+ class DirectoryNode:
21
+ id: str
22
+ title: str
23
+ description: str | None = None
24
+ skill: str | None = None
25
+ children: dict[str, "AppNode"] = field(default_factory=dict)
26
+
27
+ kind = "directory"
28
+
29
+
30
+ @dataclass
31
+ class ToolNode:
32
+ id: str
33
+ title: str
34
+ validate: Validator
35
+ call: Callable[[Any, Any], Awaitable[Any]] # (validated_input, ctx) -> result
36
+ description: str | None = None
37
+ skill: str | None = None
38
+ effect: Effect = "unknown"
39
+ example: Any = None
40
+ input_schema: Any = None
41
+ output_schema: Any = None
42
+
43
+ kind = "tool"
44
+
45
+
46
+ @dataclass
47
+ class RemoteNode:
48
+ id: str
49
+ title: str
50
+ help_url: str
51
+ description: str | None = None
52
+ headers: dict[str, str] | None = None
53
+ allow_insecure: bool = False
54
+
55
+ kind = "remote"
56
+
57
+
58
+ AppNode = Union[DirectoryNode, ToolNode, RemoteNode]
59
+
60
+ _SEGMENT_PATTERN = re.compile(r"^[^/?#]+$")
61
+
62
+
63
+ def assert_valid_segment(segment: str, context: str) -> None:
64
+ if not _SEGMENT_PATTERN.match(segment) or segment in (".", "..") or segment.startswith("~"):
65
+ raise ValueError(f"{context}: '{segment}' is not a valid path segment.")
66
+
67
+
68
+ def add_child(parent: DirectoryNode, node: AppNode) -> None:
69
+ assert_valid_segment(node.id, "Node id")
70
+ if node.id in parent.children:
71
+ raise ValueError(f"Duplicate sibling id '{node.id}'.")
72
+ parent.children[node.id] = node
73
+
74
+
75
+ def path_segments(path: str) -> list[str]:
76
+ segments = [segment for segment in path.split("/") if segment]
77
+ if not segments:
78
+ raise ValueError(f"Mount path '{path}' must contain at least one segment.")
79
+ for segment in segments:
80
+ assert_valid_segment(segment, f"Mount path '{path}'")
81
+ return segments
82
+
83
+
84
+ def ensure_directory(root: DirectoryNode, segments: list[str]) -> DirectoryNode:
85
+ current = root
86
+ for segment in segments:
87
+ existing = current.children.get(segment)
88
+ if existing is not None:
89
+ if not isinstance(existing, DirectoryNode):
90
+ raise ValueError(f"Cannot create directory '{segment}': a {existing.kind} node already uses that id.")
91
+ current = existing
92
+ else:
93
+ created = DirectoryNode(id=segment, title=segment)
94
+ current.children[segment] = created
95
+ current = created
96
+ return current
97
+
98
+
99
+ def resolve(root: DirectoryNode, segments: list[str]) -> tuple[AppNode, list[str]] | None:
100
+ """Walk directory children by URL segments; a tool/remote node stops
101
+ descent and the leftover segments become its sub-path."""
102
+ current: AppNode = root
103
+ index = 0
104
+ while index < len(segments):
105
+ if not isinstance(current, DirectoryNode):
106
+ break
107
+ nxt = current.children.get(segments[index])
108
+ if nxt is None:
109
+ return None
110
+ current = nxt
111
+ index += 1
112
+ return current, segments[index:]
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: htbp-sdk
3
+ Version: 0.1.0
4
+ Summary: HTBP (HTTP Tool Bridge Protocol) server SDK: expose functions as self-describing HTTP tool endpoints, FastAPI-style.
5
+ Project-URL: Repository, https://github.com/TokenRollAI/http-tool-bridge
6
+ Author: TokenRoll AI
7
+ License-Expression: MIT
8
+ Keywords: agent,asgi,htbp,http,sdk,tool
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: pydantic>=2.7
11
+ Provides-Extra: remote
12
+ Requires-Dist: httpx>=0.27; extra == 'remote'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # htbp-sdk
16
+
17
+ HTBP (HTTP Tool Bridge Protocol) server SDK for Python. Expose functions as
18
+ self-describing HTTP tool endpoints, FastAPI-style. Pure ASGI — runs under
19
+ uvicorn/hypercorn and mounts inside FastAPI or Starlette.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ pip install htbp-sdk # import name: htbp
25
+ pip install 'htbp-sdk[remote]' # + httpx, needed only for app.remote()
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```python
31
+ from htbp import Htbp
32
+
33
+ app = Htbp(title="Math tools")
34
+
35
+ @app.tool(effect="read")
36
+ def add(a: int, b: int = 0) -> int:
37
+ """Add two integers."""
38
+ return a + b
39
+ ```
40
+
41
+ ```sh
42
+ uvicorn main:app
43
+ ```
44
+
45
+ Input schemas are derived from type hints via pydantic (FastAPI-style); a
46
+ single parameter annotated with a `BaseModel` subclass uses that model as the
47
+ body schema directly. Async functions are awaited; sync functions run in a
48
+ thread. A `ToolContext`-annotated parameter receives request context.
49
+
50
+ Every node answers `GET {path}/~help` (JSON by default, RFC-0001 text DSL with
51
+ `Accept: text/plain`); tools are invoked with `POST {path}` and respond with
52
+ `{"resource", "result"}`.
53
+
54
+ ## Cascading
55
+
56
+ ```python
57
+ docs = Htbp(title="Docs tools")
58
+
59
+ @docs.tool()
60
+ def search(q: str) -> dict:
61
+ return {"hits": [q]}
62
+
63
+ app.mount("/docs", docs) # local nesting
64
+ app.remote("/ext", "https://other.example.com/~help") # federate a remote HTBP server
65
+ ```
66
+
67
+ Remote nodes pass through `~help` for any sub-path and proxy calls; the inbound
68
+ `Authorization` header is never forwarded upstream.
69
+
70
+ Mount inside FastAPI:
71
+
72
+ ```python
73
+ fastapi_app.mount("/tools", app)
74
+ ```
75
+
76
+ ## Options
77
+
78
+ ```python
79
+ Htbp(
80
+ title="My tools",
81
+ description="...",
82
+ auth={"bearer_token": "..."}, # 401 + WWW-Authenticate without it
83
+ skill="# Markdown served at /~skill",
84
+ base_path="/htbp", # serve under a path prefix
85
+ cors=False, # CORS is on by default
86
+ )
87
+ ```
@@ -0,0 +1,12 @@
1
+ htbp/__init__.py,sha256=ghRZBHn8lYI0S6cyTDPhJSJOJBtd4tRqmsQqELENgJI,737
2
+ htbp/app.py,sha256=kbrOfBuAqxNYB0zEu4YELvOIx5Nz2DmCHVz-FLZRSeQ,15911
3
+ htbp/describe.py,sha256=w0klUShz53Iknoi96KWN58XwdZtgv2UVyFRrGx7qS6k,1984
4
+ htbp/errors.py,sha256=EMRfwh7dj38vhbM9MlAPQtDyZB5NlwOVNtQ12zsBro4,1066
5
+ htbp/http.py,sha256=NVlM8VOjzx1C2DbdO2hNKx6Du9Xde7rMjI3mv1C5xUc,1748
6
+ htbp/remote.py,sha256=2SVYWg_MGowlZ7yKiaO98qb_-BxLCMrLMMmyS0Zwq9c,7776
7
+ htbp/schema.py,sha256=74qIwz-gJ8SC3VEkfkaJjlZnwQBpQBDjWRJPNvTFT_U,4169
8
+ htbp/text_help.py,sha256=r3q246yWKAIkvpKRhuEvTkEDCsZjc090rpOs2prdJZw,2747
9
+ htbp/tree.py,sha256=EY526r7Hx8ZVWni0NhUOABlaTe-Ik_hMmUzWqYDdSzU,3383
10
+ htbp_sdk-0.1.0.dist-info/METADATA,sha256=29ZIdBn-MX6Psi35oXRqh_Kg3TUSNGeuh8n-gbnHuCM,2382
11
+ htbp_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ htbp_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any