Skip to content
9 changes: 9 additions & 0 deletions sdk/python/aleo/codegen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""aleo.codegen — build-time ABI→Python emitter.

Turns an ``aleo-abi`` JSON description of a program into a module of frozen
dataclasses with ``to_plaintext()`` encoders and ``from_plaintext()``
decoders. Build-time only: nothing in the ``aleo`` runtime imports this
package, and generated modules import only :mod:`aleo.codegen.runtime`.

Usage: ``python -m aleo.codegen --abi abi.json --out generated.py``.
"""
49 changes: 49 additions & 0 deletions sdk/python/aleo/codegen/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""CLI: python -m aleo.codegen --abi abi.json --out generated.py [--config cfg.json]

Config mode drives multiple programs from one JSON file
(``{"programs": [{"abi": "...", "out": "..."}]}``); paths inside a config
resolve relative to the config file's own location.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from ._emit import emit_module


def _generate(abi_path: Path, out_path: Path) -> None:
abi = json.loads(abi_path.read_text())
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(emit_module(abi))
print(f"generated {len(abi.get('structs', []))} structs, "
f"{len(abi.get('records', []))} records, "
f"{len(abi.get('mappings', []))} mapping decoders -> {out_path}")


def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="aleo.codegen")
p.add_argument("--abi", type=Path, help="path to ABI JSON")
p.add_argument("--out", type=Path, help="output .py path")
p.add_argument("--config", type=Path, help="config JSON with a programs list")
args = p.parse_args(argv)
try:
if args.config:
cfg = json.loads(args.config.read_text())
base = args.config.parent
for entry in cfg["programs"]:
_generate((base / entry["abi"]).resolve(), (base / entry["out"]).resolve())
elif args.abi and args.out:
_generate(args.abi, args.out)
else:
p.error("provide --abi and --out, or --config")
except (OSError, json.JSONDecodeError, ValueError, KeyError) as exc:
print(f"aleo.codegen: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
255 changes: 255 additions & 0 deletions sdk/python/aleo/codegen/_emit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownLambdaType=false
"""ABI JSON → Python source emitter.

Build-time only; the emitted code imports :mod:`aleo.codegen.runtime` for
parsing and formatting. The ABI shape this consumes is the ``aleo-abi``
output: struct = ``{path: [Name], fields: [{name, ty}]}``, record fields add
``mode``, mapping = ``{name, key: ty, value: ty}``, and ``ty`` is either
``{"Primitive": ...}`` or ``{"Struct": {"path": [...], "program": ...}}``.
"""
from __future__ import annotations

import keyword
import re
from dataclasses import dataclass, field
from typing import Any, Callable

_PROGRAM_ID_RE = re.compile(r"[a-zA-Z0-9_.]+")


def _ident(name: Any, context: str) -> str:
"""Validate an ABI name before interpolating it into emitted source.

ABI JSON is external input; a name that is not a plain Python identifier
(or that shadows a keyword or the synthetic ``_nonce`` record field) must
fail at generation time with a pointer to the offender, never become a
SyntaxError — or executable code — in the generated module.
"""
if (
not isinstance(name, str)
or not name.isidentifier()
or keyword.iskeyword(name)
or name == "_nonce"
):
raise ValueError(f"Invalid identifier in ABI {context}: {name!r}")
return name


@dataclass(frozen=True)
class PyType:
"""How one ABI type appears in emitted Python.

``annotation`` is the type annotation; ``encode_expr``/``decode_expr``
map a value expression to the encoding/decoding expression emitted into
``to_plaintext``/``from_decoded`` bodies.
"""

annotation: str
encode_expr: Callable[[str], str]
# Only nested structs decode; primitives pass through as parsed.
decode_expr: Callable[[str], str] = field(default=lambda e: e)


def resolve_ty(ty: Any) -> PyType:
"""Map an ABI ``ty`` tree to its emitted-Python representation."""
if isinstance(ty, dict) and "Primitive" in ty:
prim = ty["Primitive"]
if isinstance(prim, dict):
width = prim.get("UInt") or prim.get("Int")
if width is None:
raise ValueError(f"Unsupported primitive: {prim!r}")
suffix = width.lower()
return PyType("int", lambda e, s=suffix: f"fmt_int({e}, '{s}')")
if prim == "Boolean":
return PyType("bool", lambda e: f"fmt_bool({e})")
if prim == "Address":
return PyType("str", lambda e: f"fmt_address({e})")
if prim in ("Field", "Group", "Scalar"):
suffix = prim.lower()
return PyType("str", lambda e, s=suffix: f"fmt_fieldlike({e}, '{s}')")
raise ValueError(f"Unsupported primitive: {prim!r}")
if isinstance(ty, dict) and "Struct" in ty:
name = _ident(ty["Struct"]["path"][-1], "struct reference")
return PyType(
name,
lambda e: f"{e}.to_plaintext()",
lambda e, n=name: f"{n}.from_decoded({e})",
)
raise ValueError(f"Unsupported ABI type: {ty!r}")


def emit_struct(struct: dict[str, Any]) -> str:
"""Emit one struct as a frozen dataclass with encode/decode methods."""
name = _ident(struct["path"][-1], "struct name")
fields = [(_ident(f["name"], f"field of {name}"), resolve_ty(f["ty"]))
for f in struct["fields"]]
lines: list[str] = ["@dataclass(frozen=True)", f"class {name}:"]
for fname, pt in fields:
lines.append(f" {fname}: {pt.annotation}")

# to_plaintext — emitted as a parts list + join (readable generated code).
lines += ["", " def to_plaintext(self) -> str:", " parts = ["]
for fname, pt in fields:
enc = pt.encode_expr("self." + fname)
lines.append(f" \"{fname}: \" + {enc},")
lines += [
" ]",
" return \"{ \" + \", \".join(parts) + \" }\"",
]

# from_decoded / from_plaintext. Subscript expressions are precomputed
# outside the f-string (no backslashes in f-string expressions on 3.10).
kwarg_parts: list[str] = []
for fname, pt in fields:
subscript = "d['" + fname + "']"
kwarg_parts.append(f"{fname}={pt.decode_expr(subscript)}")
kwargs = ", ".join(kwarg_parts)
lines += [
"",
" @classmethod",
" def from_decoded(cls, d: dict) -> \"" + name + "\":",
f" return cls({kwargs})",
"",
" @classmethod",
" def from_plaintext(cls, text: str) -> \"" + name + "\":",
" return cls.from_decoded(parse_plaintext(text))",
"",
]
return "\n".join(lines) + "\n"


# ── Records, mappings, module assembly ───────────────────────────────────────

_HEADER = "# Generated by aleo.codegen — DO NOT EDIT.\n"
_IMPORTS = (
"from dataclasses import dataclass\n"
"from typing import Any, Callable, Optional\n"
"from aleo.codegen.runtime import (parse_plaintext, fmt_int, fmt_bool,"
" fmt_fieldlike, fmt_address)\n\n"
)


def _struct_deps(struct: dict[str, Any]) -> set[str]:
deps: set[str] = set()
for f in struct["fields"]:
ty = f["ty"]
if isinstance(ty, dict) and "Struct" in ty:
deps.add(ty["Struct"]["path"][-1])
return deps


def _toposort(structs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Order structs so nested struct classes are defined before use."""
by_name = {s["path"][-1]: s for s in structs}
done: list[dict[str, Any]] = []
seen: set[str] = set()

def visit(name: str) -> None:
if name in seen or name not in by_name:
return
seen.add(name)
for dep in _struct_deps(by_name[name]):
visit(dep)
done.append(by_name[name])

for s in structs:
visit(s["path"][-1])
return done


def emit_record(record: dict[str, Any]) -> str:
"""Emit one record as a decode-only frozen dataclass.

Records are produced by scanners and never hand-constructed, so no
``to_plaintext`` is emitted. The scanner's ``_nonce`` rides along as an
optional extra field.
"""
name = _ident(record["path"][-1], "record name")
fields = [(_ident(f["name"], f"field of {name}"), resolve_ty(f["ty"]))
for f in record["fields"]]
lines = ["@dataclass(frozen=True)", f"class {name}:"]
for fname, pt in fields:
lines.append(f" {fname}: {pt.annotation}")
lines.append(" _nonce: Optional[str] = None")
kwarg_parts: list[str] = []
for fname, pt in fields:
subscript = "d['" + fname + "']"
kwarg_parts.append(f"{fname}={pt.decode_expr(subscript)}")
kwargs = ", ".join(kwarg_parts)
lines += [
"",
" @classmethod",
f" def from_decoded(cls, d: dict) -> \"{name}\":",
f" return cls({kwargs}, _nonce=d.get('_nonce'))",
"",
" @classmethod",
f" def from_plaintext(cls, text: str) -> \"{name}\":",
" return cls.from_decoded(parse_plaintext(text))",
"",
]
return "\n".join(lines) + "\n"


def _check_struct_refs(abi: dict[str, Any]) -> None:
"""Every struct reference must resolve to a struct defined in THIS ABI.

A cross-program or missing reference would otherwise emit a call to a
class that is never generated (NameError at import/decode time), and two
structs sharing a terminal name would silently collapse to one class.
"""
program = abi["program"]
structs = abi.get("structs", [])
names = [s["path"][-1] for s in structs]
dupes = {n for n in names if names.count(n) > 1}
if dupes:
raise ValueError(f"Duplicate struct names in ABI: {sorted(dupes)}")
local = set(names)

def check(ty: Any, context: str) -> None:
if isinstance(ty, dict) and "Struct" in ty:
ref = ty["Struct"]
name, prog = ref["path"][-1], ref.get("program", program)
if name not in local or prog != program:
raise ValueError(
f"Unresolvable struct reference {name!r} (program {prog!r}) "
f"in {context}: cross-program and undefined structs are not "
"supported — the generated class would not exist."
)

for s in structs:
for f in s["fields"]:
check(f["ty"], f"struct {s['path'][-1]}")
for r in abi.get("records", []):
for f in r["fields"]:
check(f["ty"], f"record {r['path'][-1]}")
for m in abi.get("mappings", []):
check(m["value"], f"mapping {m['name']}")


def emit_module(abi: dict[str, Any]) -> str:
"""Emit a complete generated module for one program's ABI."""
program = abi["program"]
if not isinstance(program, str) or not _PROGRAM_ID_RE.fullmatch(program):
raise ValueError(f"Invalid program id in ABI: {program!r}")
_check_struct_refs(abi)
parts = [_HEADER, _IMPORTS, f"PROGRAM_ID = \"{program}\"\n\n"]
for s in _toposort(abi.get("structs", [])):
parts.append(emit_struct(s))
parts.append("\n")
for r in abi.get("records", []):
parts.append(emit_record(r))
parts.append("\n")
dec_entries: list[str] = []
for m in abi.get("mappings", []):
v = m["value"]
if isinstance(v, dict) and "Struct" in v:
dec_entries.append(f" \"{m['name']}\": {v['Struct']['path'][-1]}.from_plaintext,")
else:
dec_entries.append(f" \"{m['name']}\": parse_plaintext,")
parts.append("MAPPING_VALUE_DECODERS: dict[str, Callable[[str], Any]] = {\n"
+ "\n".join(dec_entries) + "\n}\n")
# The full ABI rides along (like the TS bindings' PROGRAM_ABI) so callers
# can recover what the classes drop — e.g. mapping KEY types for
# formatting read keys — without re-reading the pinned JSON at runtime.
parts.append(f"\nABI: dict = {abi!r}\n")
return "".join(parts)
Loading
Loading