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.
@@ -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"]