adk-code-mode 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.
- adk_code_mode/__about__.py +4 -0
- adk_code_mode/__init__.py +22 -0
- adk_code_mode/_artifact_tools.py +152 -0
- adk_code_mode/callback.py +81 -0
- adk_code_mode/executor.py +607 -0
- adk_code_mode/output.py +97 -0
- adk_code_mode/py.typed +1 -0
- adk_code_mode/runtime/__init__.py +9 -0
- adk_code_mode/runtime/base.py +88 -0
- adk_code_mode/runtime/docker.py +331 -0
- adk_code_mode/runtime/protocol.py +193 -0
- adk_code_mode/tools/__init__.py +4 -0
- adk_code_mode/tools/catalog.py +95 -0
- adk_code_mode/tools/dispatcher.py +290 -0
- adk_code_mode/tools/namespacing.py +200 -0
- adk_code_mode/tools/normaliser.py +69 -0
- adk_code_mode/tools/stubs.py +446 -0
- adk_code_mode/workspace/__init__.py +8 -0
- adk_code_mode/workspace/files.py +44 -0
- adk_code_mode-0.1.0.dist-info/METADATA +351 -0
- adk_code_mode-0.1.0.dist-info/RECORD +23 -0
- adk_code_mode-0.1.0.dist-info/WHEEL +4 -0
- adk_code_mode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Generate Python stub modules for ADK tools.
|
|
5
|
+
|
|
6
|
+
Each stub is a real ``.py`` file the sandbox writes into ``/tools/``. The
|
|
7
|
+
function body is a single delegation into the host via the RPC client, so the
|
|
8
|
+
generated file is small and robust. Type hints come from the tool's JSON
|
|
9
|
+
Schema (``BaseTool._get_declaration().parameters_json_schema``).
|
|
10
|
+
|
|
11
|
+
``render_tool`` returns a structured ``RenderedTool``; ``render_tool_source``
|
|
12
|
+
turns one into the on-disk ``.py`` stub. The catalog renderer (in
|
|
13
|
+
``adk_code_mode.tools.catalog``) consumes the same ``RenderedTool`` to produce
|
|
14
|
+
the ``.pyi``-style block injected into the model's system prompt.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import keyword
|
|
20
|
+
import re
|
|
21
|
+
import textwrap
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from adk_code_mode.tools.namespacing import NamespacedTool, PythonNameCollisionError
|
|
26
|
+
|
|
27
|
+
_PRIMITIVES = {
|
|
28
|
+
"string": "str",
|
|
29
|
+
"integer": "int",
|
|
30
|
+
"number": "float",
|
|
31
|
+
"boolean": "bool",
|
|
32
|
+
"null": "None",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Target = Literal["stub", "catalog"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _schema_dict_from_declaration(declaration: Any) -> dict[str, Any]:
|
|
39
|
+
"""Return a JSON-Schema-shaped dict for a tool declaration.
|
|
40
|
+
|
|
41
|
+
ADK tools expose their schema one of two ways: ``parameters_json_schema``
|
|
42
|
+
(RestApiTool, MCP tools, anything built from a real JSON Schema) or
|
|
43
|
+
``parameters`` (FunctionTool, which builds a Gemini ``types.Schema`` from
|
|
44
|
+
the function signature). We normalise both to a plain dict.
|
|
45
|
+
"""
|
|
46
|
+
if declaration is None:
|
|
47
|
+
return {}
|
|
48
|
+
pjs = getattr(declaration, "parameters_json_schema", None)
|
|
49
|
+
if pjs:
|
|
50
|
+
return dict(pjs)
|
|
51
|
+
params = getattr(declaration, "parameters", None)
|
|
52
|
+
if params is None:
|
|
53
|
+
return {}
|
|
54
|
+
if hasattr(params, "model_dump"):
|
|
55
|
+
dumped = params.model_dump(mode="json", exclude_none=True)
|
|
56
|
+
return dumped if isinstance(dumped, dict) else {}
|
|
57
|
+
if isinstance(params, dict):
|
|
58
|
+
return dict(params)
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _schema_to_type(schema: Any) -> str:
|
|
63
|
+
"""Convert a JSON-Schema fragment to a Python type expression (PEP 604).
|
|
64
|
+
|
|
65
|
+
Best-effort. Anything we can't resolve cleanly falls back to ``Any``.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(schema, dict):
|
|
68
|
+
return "Any"
|
|
69
|
+
|
|
70
|
+
# Compositions.
|
|
71
|
+
if "anyOf" in schema or "oneOf" in schema:
|
|
72
|
+
variants = schema.get("anyOf") or schema.get("oneOf") or []
|
|
73
|
+
parts = [_schema_to_type(v) for v in variants]
|
|
74
|
+
parts = list(dict.fromkeys(parts))
|
|
75
|
+
if not parts:
|
|
76
|
+
return "Any"
|
|
77
|
+
if len(parts) == 1:
|
|
78
|
+
return parts[0]
|
|
79
|
+
return " | ".join(parts)
|
|
80
|
+
if "allOf" in schema:
|
|
81
|
+
merged: dict[str, Any] = {}
|
|
82
|
+
for part in schema["allOf"]:
|
|
83
|
+
if isinstance(part, dict):
|
|
84
|
+
merged.update(part)
|
|
85
|
+
if merged:
|
|
86
|
+
return _schema_to_type(merged)
|
|
87
|
+
return "Any"
|
|
88
|
+
|
|
89
|
+
if "enum" in schema and schema["enum"]:
|
|
90
|
+
lits: list[str] = []
|
|
91
|
+
for value in schema["enum"]:
|
|
92
|
+
if isinstance(value, str):
|
|
93
|
+
lits.append(repr(value))
|
|
94
|
+
elif isinstance(value, bool):
|
|
95
|
+
lits.append("True" if value else "False")
|
|
96
|
+
elif isinstance(value, (int, float)):
|
|
97
|
+
lits.append(repr(value))
|
|
98
|
+
elif value is None:
|
|
99
|
+
lits.append("None")
|
|
100
|
+
else:
|
|
101
|
+
return "Any"
|
|
102
|
+
return "Literal[" + ", ".join(lits) + "]"
|
|
103
|
+
|
|
104
|
+
ty = schema.get("type")
|
|
105
|
+
if isinstance(ty, str):
|
|
106
|
+
ty = ty.lower()
|
|
107
|
+
nullable = bool(schema.get("nullable"))
|
|
108
|
+
if isinstance(ty, list):
|
|
109
|
+
parts = [
|
|
110
|
+
_schema_to_type(
|
|
111
|
+
{
|
|
112
|
+
"type": t.lower() if isinstance(t, str) else t,
|
|
113
|
+
**{k: v for k, v in schema.items() if k != "type"},
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
for t in ty
|
|
117
|
+
]
|
|
118
|
+
parts = list(dict.fromkeys(parts))
|
|
119
|
+
result = parts[0] if len(parts) == 1 else " | ".join(parts)
|
|
120
|
+
elif ty == "array":
|
|
121
|
+
items = schema.get("items")
|
|
122
|
+
prefix_items = schema.get("prefixItems")
|
|
123
|
+
if prefix_items:
|
|
124
|
+
inner = ", ".join(_schema_to_type(p) for p in prefix_items)
|
|
125
|
+
result = f"tuple[{inner}]"
|
|
126
|
+
elif isinstance(items, dict):
|
|
127
|
+
result = f"list[{_schema_to_type(items)}]"
|
|
128
|
+
else:
|
|
129
|
+
result = "list[Any]"
|
|
130
|
+
elif ty == "object":
|
|
131
|
+
result = "dict[str, Any]"
|
|
132
|
+
elif isinstance(ty, str) and ty in _PRIMITIVES:
|
|
133
|
+
result = _PRIMITIVES[ty]
|
|
134
|
+
else:
|
|
135
|
+
result = "Any"
|
|
136
|
+
|
|
137
|
+
if nullable and result != "Any":
|
|
138
|
+
result = f"{result} | None"
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _python_default(schema: dict[str, Any]) -> str | None:
|
|
143
|
+
"""Return a Python source expression for the schema's default, or None."""
|
|
144
|
+
if "default" not in schema:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return repr(schema["default"])
|
|
148
|
+
except Exception:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _format_docstring(*, description: str, param_docs: list[tuple[str, str, str | None]]) -> str:
|
|
153
|
+
lines: list[str] = []
|
|
154
|
+
summary = (description or "").strip() or "Call the tool."
|
|
155
|
+
lines.extend(textwrap.wrap(summary, width=88) or [summary])
|
|
156
|
+
if param_docs:
|
|
157
|
+
lines.append("")
|
|
158
|
+
lines.append("Args:")
|
|
159
|
+
for name, desc, default_repr in param_docs:
|
|
160
|
+
desc = (desc or "").strip()
|
|
161
|
+
first, *rest = (desc or "").splitlines() or [""]
|
|
162
|
+
suffix = f" (default: {default_repr})" if default_repr is not None else ""
|
|
163
|
+
head = first + suffix
|
|
164
|
+
lines.append(f" {name}: {head}" if head else f" {name}:")
|
|
165
|
+
for extra in rest:
|
|
166
|
+
lines.append(f" {extra}")
|
|
167
|
+
body = "\n ".join(lines)
|
|
168
|
+
return f'"""{body}\n """'
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sanitise_param(raw: str) -> str:
|
|
172
|
+
cleaned = re.sub(r"[^a-zA-Z0-9_]", "_", raw).strip("_")
|
|
173
|
+
if not cleaned:
|
|
174
|
+
cleaned = "arg"
|
|
175
|
+
if cleaned[0].isdigit():
|
|
176
|
+
cleaned = f"_{cleaned}"
|
|
177
|
+
if keyword.iskeyword(cleaned):
|
|
178
|
+
cleaned = f"{cleaned}_"
|
|
179
|
+
return cleaned
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class RenderedParam:
|
|
184
|
+
"""One parameter as it appears in a generated tool function."""
|
|
185
|
+
|
|
186
|
+
py_name: str
|
|
187
|
+
raw_name: str
|
|
188
|
+
type_expr: str
|
|
189
|
+
is_required: bool
|
|
190
|
+
schema_default: str | None
|
|
191
|
+
"""``repr()`` of the schema's default value if present; otherwise ``None``."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class RenderedTool:
|
|
196
|
+
"""Structured tool ready for stub or catalog rendering.
|
|
197
|
+
|
|
198
|
+
Argument forwarding rules (Option A):
|
|
199
|
+
|
|
200
|
+
- Required, no schema default: ``name: T``; always forwarded.
|
|
201
|
+
- Required, schema default: ``name: T = <default>``; always forwarded.
|
|
202
|
+
- Optional (with or without schema default): ``name: T | None = _MISSING``
|
|
203
|
+
in the on-disk stub (forwarded only if the caller passed a value, so
|
|
204
|
+
the host-side tool's own default behaviour applies on omission); the
|
|
205
|
+
catalog renders this as ``name: T | None = ...`` since the sentinel is
|
|
206
|
+
an implementation detail. Schema default (when present) is surfaced in
|
|
207
|
+
the docstring rather than baked into the signature, since Python erases
|
|
208
|
+
"argument was not passed" once a real default lands in the parameter
|
|
209
|
+
slot.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
attribute: str
|
|
213
|
+
dotted_path: str
|
|
214
|
+
namespace: str | None
|
|
215
|
+
docstring: str
|
|
216
|
+
params: tuple[RenderedParam, ...]
|
|
217
|
+
|
|
218
|
+
def signature_for(self, target: Target) -> str:
|
|
219
|
+
"""Return the ``def name(...) -> Any:`` line for the requested target."""
|
|
220
|
+
return f"def {self.attribute}({_render_params(self.params, target=target)}) -> Any:"
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def needs_missing_sentinel(self) -> bool:
|
|
224
|
+
return any(not p.is_required for p in self.params)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _render_params(params: tuple[RenderedParam, ...], *, target: Target) -> str:
|
|
228
|
+
"""Render the keyword-only parameter list."""
|
|
229
|
+
if not params:
|
|
230
|
+
return ""
|
|
231
|
+
pieces: list[str] = ["*"]
|
|
232
|
+
optional_default = "_MISSING" if target == "stub" else "..."
|
|
233
|
+
for p in params:
|
|
234
|
+
if p.is_required:
|
|
235
|
+
if p.schema_default is None:
|
|
236
|
+
pieces.append(f"{p.py_name}: {p.type_expr}")
|
|
237
|
+
else:
|
|
238
|
+
pieces.append(f"{p.py_name}: {p.type_expr} = {p.schema_default}")
|
|
239
|
+
else:
|
|
240
|
+
pieces.append(f"{p.py_name}: {_ensure_optional_type(p.type_expr)} = {optional_default}")
|
|
241
|
+
return ", ".join(pieces)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ensure_optional_type(type_expr: str) -> str:
|
|
245
|
+
"""Return ``type_expr`` augmented with ``| None`` if not already present.
|
|
246
|
+
|
|
247
|
+
Avoids ``str | None | None`` for params whose schema was already
|
|
248
|
+
``nullable: true`` (which ``_schema_to_type`` rendered as ``str | None``).
|
|
249
|
+
"""
|
|
250
|
+
parts = [p.strip() for p in type_expr.split("|")]
|
|
251
|
+
seen: set[str] = set()
|
|
252
|
+
deduped: list[str] = []
|
|
253
|
+
for part in parts:
|
|
254
|
+
if part and part not in seen:
|
|
255
|
+
seen.add(part)
|
|
256
|
+
deduped.append(part)
|
|
257
|
+
if "None" not in seen:
|
|
258
|
+
deduped.append("None")
|
|
259
|
+
return " | ".join(deduped)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True)
|
|
263
|
+
class StubFile:
|
|
264
|
+
path: str
|
|
265
|
+
source: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def render_tool(nt: NamespacedTool) -> RenderedTool:
|
|
269
|
+
"""Build a ``RenderedTool`` for a normalised tool.
|
|
270
|
+
|
|
271
|
+
Captures everything stub and catalog rendering need: signature parts,
|
|
272
|
+
formatted docstring, dotted path, namespace.
|
|
273
|
+
"""
|
|
274
|
+
declaration = nt.resolved.tool._get_declaration()
|
|
275
|
+
schema: dict[str, Any] = _schema_dict_from_declaration(declaration)
|
|
276
|
+
|
|
277
|
+
props: dict[str, Any] = dict(schema.get("properties") or {})
|
|
278
|
+
required = set(schema.get("required") or [])
|
|
279
|
+
ordered = sorted(props.items(), key=lambda kv: (kv[0] not in required, kv[0]))
|
|
280
|
+
|
|
281
|
+
params: list[RenderedParam] = []
|
|
282
|
+
param_docs: list[tuple[str, str, str | None]] = []
|
|
283
|
+
|
|
284
|
+
seen_param_names: dict[str, str] = {}
|
|
285
|
+
|
|
286
|
+
for raw_name, subschema in ordered:
|
|
287
|
+
py_name = _sanitise_param(raw_name)
|
|
288
|
+
existing_raw_name = seen_param_names.get(py_name)
|
|
289
|
+
if existing_raw_name is not None:
|
|
290
|
+
raise PythonNameCollisionError(
|
|
291
|
+
f"Tool {nt.tool_name!r} has parameter names {existing_raw_name!r} and "
|
|
292
|
+
f"{raw_name!r} that both map to Python parameter {py_name!r}. Rename one "
|
|
293
|
+
"of the tool parameters so the generated stub signature is unambiguous."
|
|
294
|
+
)
|
|
295
|
+
seen_param_names[py_name] = raw_name
|
|
296
|
+
subschema = subschema if isinstance(subschema, dict) else {}
|
|
297
|
+
type_expr = _schema_to_type(subschema)
|
|
298
|
+
default = _python_default(subschema)
|
|
299
|
+
is_required = raw_name in required
|
|
300
|
+
params.append(
|
|
301
|
+
RenderedParam(
|
|
302
|
+
py_name=py_name,
|
|
303
|
+
raw_name=raw_name,
|
|
304
|
+
type_expr=type_expr,
|
|
305
|
+
is_required=is_required,
|
|
306
|
+
schema_default=default,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
doc_default = default if (not is_required and default is not None) else None
|
|
310
|
+
param_docs.append((py_name, str(subschema.get("description", "")), doc_default))
|
|
311
|
+
|
|
312
|
+
description = ""
|
|
313
|
+
if declaration is not None and declaration.description:
|
|
314
|
+
description = declaration.description
|
|
315
|
+
elif nt.resolved.tool.description:
|
|
316
|
+
description = nt.resolved.tool.description
|
|
317
|
+
docstring = _format_docstring(description=description, param_docs=param_docs)
|
|
318
|
+
|
|
319
|
+
return RenderedTool(
|
|
320
|
+
attribute=nt.attribute,
|
|
321
|
+
dotted_path=nt.dotted_path,
|
|
322
|
+
namespace=nt.namespace,
|
|
323
|
+
docstring=docstring,
|
|
324
|
+
params=tuple(params),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def render_tool_source(rt: RenderedTool) -> str:
|
|
329
|
+
"""Render the on-disk Python stub source for a tool."""
|
|
330
|
+
sig = rt.signature_for("stub")
|
|
331
|
+
lines: list[str] = [
|
|
332
|
+
"# Generated by adk-code-mode. Do not edit.",
|
|
333
|
+
"from __future__ import annotations",
|
|
334
|
+
"",
|
|
335
|
+
"from typing import Any, Literal",
|
|
336
|
+
"",
|
|
337
|
+
"from adk_code_mode_sandbox._rpc_client import call as _call",
|
|
338
|
+
"",
|
|
339
|
+
"",
|
|
340
|
+
]
|
|
341
|
+
if rt.needs_missing_sentinel:
|
|
342
|
+
lines.extend(["_MISSING = object()", "", ""])
|
|
343
|
+
lines.append(sig)
|
|
344
|
+
lines.append(f" {rt.docstring}")
|
|
345
|
+
|
|
346
|
+
unconditional = [p for p in rt.params if p.is_required]
|
|
347
|
+
optional = [p for p in rt.params if not p.is_required]
|
|
348
|
+
if unconditional:
|
|
349
|
+
lines.append(" _args: dict[str, Any] = {")
|
|
350
|
+
for p in unconditional:
|
|
351
|
+
lines.append(f" {p.raw_name!r}: {p.py_name},")
|
|
352
|
+
lines.append(" }")
|
|
353
|
+
else:
|
|
354
|
+
lines.append(" _args: dict[str, Any] = {}")
|
|
355
|
+
for p in optional:
|
|
356
|
+
lines.append(f" if {p.py_name} is not _MISSING:")
|
|
357
|
+
lines.append(f" _args[{p.raw_name!r}] = {p.py_name}")
|
|
358
|
+
lines.append(f" return _call({rt.dotted_path!r}, _args)")
|
|
359
|
+
lines.append("")
|
|
360
|
+
return "\n".join(lines)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def render_namespace_init(tools: list[NamespacedTool]) -> str:
|
|
364
|
+
"""Render ``__init__.py`` for a namespace package.
|
|
365
|
+
|
|
366
|
+
Re-exports each tool in the namespace so the model can write
|
|
367
|
+
``from tools.<namespace> import <name>``.
|
|
368
|
+
"""
|
|
369
|
+
sorted_tools = sorted(tools, key=lambda t: t.attribute)
|
|
370
|
+
imports = "\n".join(f"from .{t.attribute} import {t.attribute}" for t in sorted_tools)
|
|
371
|
+
all_list = ", ".join(repr(t.attribute) for t in sorted_tools)
|
|
372
|
+
header = "# Generated by adk-code-mode. Do not edit.\n"
|
|
373
|
+
return f"{header}from __future__ import annotations\n\n{imports}\n\n__all__ = [{all_list}]\n"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
_ROOT_MARKER = "# Generated by adk-code-mode. Do not edit.\n"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def render_root_init(top_level: list[NamespacedTool]) -> str:
|
|
380
|
+
"""Render the generated ``tools`` package ``__init__.py``.
|
|
381
|
+
|
|
382
|
+
Re-exports any top-level (non-namespaced) tools so the model can write
|
|
383
|
+
``from tools import <name>``. Namespaced tools are *not* re-exported
|
|
384
|
+
here — the model uses ``from tools.<namespace> import <name>``, which
|
|
385
|
+
only loads that namespace's stubs.
|
|
386
|
+
"""
|
|
387
|
+
if not top_level:
|
|
388
|
+
return _ROOT_MARKER
|
|
389
|
+
sorted_tools = sorted(top_level, key=lambda t: t.attribute)
|
|
390
|
+
imports = "\n".join(f"from .{t.attribute} import {t.attribute}" for t in sorted_tools)
|
|
391
|
+
all_list = ", ".join(repr(t.attribute) for t in sorted_tools)
|
|
392
|
+
return (
|
|
393
|
+
f"{_ROOT_MARKER}from __future__ import annotations\n\n{imports}\n\n__all__ = [{all_list}]\n"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def render_tree(namespaced: list[NamespacedTool]) -> list[StubFile]:
|
|
398
|
+
"""Render the full stub tree for a list of tools.
|
|
399
|
+
|
|
400
|
+
Files are returned with POSIX paths rooted at the generated ``tools``
|
|
401
|
+
package directory. The runtime mounts that package directory at ``/tools``
|
|
402
|
+
and adds its parent to ``sys.path`` so ``import tools`` resolves directly
|
|
403
|
+
to ``/tools/__init__.py``. The root ``__init__.py`` re-exports top-level tools (so
|
|
404
|
+
``from tools import <name>`` works) but does **not** re-export
|
|
405
|
+
namespaced tools — the model receives a tool catalog up-front and writes
|
|
406
|
+
``from tools.<namespace> import <name>`` directly, so eagerly re-exporting
|
|
407
|
+
every stub from the root would be wasteful at large surfaces.
|
|
408
|
+
"""
|
|
409
|
+
per_tool = sorted(namespaced, key=lambda t: t.dotted_path)
|
|
410
|
+
files: list[StubFile] = []
|
|
411
|
+
|
|
412
|
+
grouped: dict[str | None, list[NamespacedTool]] = {}
|
|
413
|
+
for nt in per_tool:
|
|
414
|
+
grouped.setdefault(nt.namespace, []).append(nt)
|
|
415
|
+
|
|
416
|
+
for nt in per_tool:
|
|
417
|
+
ns = nt.namespace
|
|
418
|
+
sub = "" if ns is None else f"{ns}/"
|
|
419
|
+
rendered = render_tool(nt)
|
|
420
|
+
files.append(StubFile(path=f"{sub}{nt.attribute}.py", source=render_tool_source(rendered)))
|
|
421
|
+
|
|
422
|
+
for ns, tools in grouped.items():
|
|
423
|
+
if ns is None:
|
|
424
|
+
continue
|
|
425
|
+
files.append(
|
|
426
|
+
StubFile(
|
|
427
|
+
path=f"{ns}/__init__.py",
|
|
428
|
+
source=render_namespace_init(tools),
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
top_level = grouped.get(None, [])
|
|
433
|
+
files.append(StubFile(path="__init__.py", source=render_root_init(top_level)))
|
|
434
|
+
return sorted(files, key=lambda f: f.path)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
__all__ = [
|
|
438
|
+
"RenderedParam",
|
|
439
|
+
"RenderedTool",
|
|
440
|
+
"StubFile",
|
|
441
|
+
"render_namespace_init",
|
|
442
|
+
"render_root_init",
|
|
443
|
+
"render_tool",
|
|
444
|
+
"render_tool_source",
|
|
445
|
+
"render_tree",
|
|
446
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Workspace helpers."""
|
|
5
|
+
|
|
6
|
+
from adk_code_mode.workspace.files import hash_bytes, hash_file, walk_workspace
|
|
7
|
+
|
|
8
|
+
__all__ = ["hash_bytes", "hash_file", "walk_workspace"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Workspace file helpers."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hash_bytes(data: bytes) -> str:
|
|
13
|
+
return hashlib.blake2s(data, digest_size=16).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hash_file(path: str) -> tuple[str, int]:
|
|
17
|
+
"""Return ``(blake2s_hex, size)`` for a file on disk."""
|
|
18
|
+
hasher = hashlib.blake2s(digest_size=16)
|
|
19
|
+
size = 0
|
|
20
|
+
with open(path, "rb") as fh:
|
|
21
|
+
while chunk := fh.read(65536):
|
|
22
|
+
hasher.update(chunk)
|
|
23
|
+
size += len(chunk)
|
|
24
|
+
return hasher.hexdigest(), size
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def walk_workspace(root: str) -> list[str]:
|
|
28
|
+
"""Return posix-relative paths of every file under ``root``, sorted.
|
|
29
|
+
|
|
30
|
+
Symlinks are skipped. Hidden files (starting with ``.``) are included.
|
|
31
|
+
"""
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
for dirpath, _dirnames, filenames in os.walk(root, followlinks=False):
|
|
34
|
+
for fn in filenames:
|
|
35
|
+
abs_path = os.path.join(dirpath, fn)
|
|
36
|
+
if os.path.islink(abs_path):
|
|
37
|
+
continue
|
|
38
|
+
rel = os.path.relpath(abs_path, root).replace(os.sep, "/")
|
|
39
|
+
out.append(rel)
|
|
40
|
+
out.sort()
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["hash_bytes", "hash_file", "walk_workspace"]
|