From 13589181d5006b69244f358e9d0719f8073bd588 Mon Sep 17 00:00:00 2001 From: Takuma IMAMURA Date: Fri, 19 Jun 2026 16:16:47 +0900 Subject: [PATCH] feat: implement PyReflect - pyreflect CLI: A transpiler for reflective programming in Python, which converts a template JSON to executable Python nodes. - pyreflect module: A Python library, which provides a template parser, validator, transpiler, and auxiliary functions. - Example templates: quine, mutual_quine and trinity_quine. - README.md: Documentation Signed-off-by: Takuma IMAMURA --- .github/workflows/ruff.yml | 33 ++++++ .github/workflows/spdx.yml | 33 ++++++ .gitignore | 7 ++ README.md | 136 +++++++++++++++++++++++- examples/mutual_quine/template.json | 10 ++ examples/quine/template.json | 6 ++ examples/trinity_quine/template.json | 14 +++ pyproject.toml | 31 ++++++ src/pyreflect/__init__.py | 51 +++++++++ src/pyreflect/cli.py | 81 +++++++++++++++ src/pyreflect/template.py | 78 ++++++++++++++ src/pyreflect/transpiler.py | 149 +++++++++++++++++++++++++++ 12 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ruff.yml create mode 100644 .github/workflows/spdx.yml create mode 100644 examples/mutual_quine/template.json create mode 100644 examples/quine/template.json create mode 100644 examples/trinity_quine/template.json create mode 100644 pyproject.toml create mode 100644 src/pyreflect/__init__.py create mode 100644 src/pyreflect/cli.py create mode 100644 src/pyreflect/template.py create mode 100644 src/pyreflect/transpiler.py diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..ad58399 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,33 @@ +name: Ruff + +on: + pull_request: + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/ruff.yml' + push: + branches: + - main + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/ruff.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + ruff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: "3.12" + - name: Run ruff check + run: uv run ruff check + - name: Run ruff format --check + run: uv run ruff format --check diff --git a/.github/workflows/spdx.yml b/.github/workflows/spdx.yml new file mode 100644 index 0000000..ffed79a --- /dev/null +++ b/.github/workflows/spdx.yml @@ -0,0 +1,33 @@ +name: SPDX Check + +on: + pull_request: + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/spdx.yml' + push: + branches: + - main + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/spdx.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-spdx-headers: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: enarx/spdx@d4020ee98e3101dd487c5184f27c6a6fb4f88709 # master + with: + licenses: MIT diff --git a/.gitignore b/.gitignore index 83972fa..d5230b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +# Transpiler output +manifest.json +node__*.py + +# System +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/README.md b/README.md index f967d4e..95bfc64 100644 --- a/README.md +++ b/README.md @@ -1 +1,135 @@ -# PyReflect \ No newline at end of file +# PyReflect + +![SemVer](https://img.shields.io/badge/PyReflect-0.1.0-white) +![Python Version](https://img.shields.io/badge/Python-3.12-blue) +[![License](https://img.shields.io/badge/License-MIT-red)](/LICENSE) + +A transpiler for mutually-referential reflective programming in Python. It is available as a library (`pyreflect`) and a command-line tool (`pyreflect`). + +## Overview + +PyReflect is a transpiler that makes *mutually-referential* programs — programs made of several nodes — possible. Every node can reference the source code of itself and of the other nodes, without relying on any external source (e.g. file, stdin, registry). + +Suppose node A and node B each want to reference the other's code. The naïve approach is to embed A's code inside B and B's code inside A. But then A's code has changed, so the copy of A embedded in B must be updated; that changes B's code, so the copy of B embedded in A must be updated; that changes A's code again, *ad infinitum*. In general this "fixed point" problem has no solution: for A to contain B we need $\mathsf{len}\left(A\right) > \mathsf{len}\left(B\right)$, and for B to contain A we need $\mathsf{len}\left(B\right) > \mathsf{len}\left(A\right)$ — a contradiction. + +This infinite regress can be resolved by **Kleene's second recursion theorem**. Instead of embedding the code, each node embeds a *code generator* together with its input data. The generator can reconstruct the exact code of every node (itself and its peers) from the embedded input data — derived intrinsically, never fetched from any external source. + +## Install + +```bash +uv pip install git+https://github.com/acompany-develop/PyReflect +``` + +This exposes the `pyreflect` command and the importable `pyreflect` package. + +## Usage + +### Library API + +```py +from pyreflect import parse_template, transpile + +with open(path, encoding="utf-8") as f: + # Read + text = f.read() + # Parse + template = parse_template(text) + # Transpile + nodes = transpile(template) +``` + +### Transpiler CLI + +```bash +# Input from file +pyreflect TEMPLATE.json OUTPUT_DIR + +# Input from stdin +cat TEMPLATE.json | pyreflect - OUTPUT_DIR +``` + +It writes one file per node (`node_.py`) with a `manifest.json` mapping node-id to filename. + +## Transpiler details + +The `pyreflect` transpiler converts a template into N standalone Python programs. A template is a JSON list of node objects: + +```json + [ {"node-id": "__NODE1", "code": ""}, + {"node-id": "__NODE2", "code": ""}, + ... ] +``` + +Each node has a `node-id` and a `code` body that may reference any subset of the node-ids (itself included). + +The transpiler does two things: + +1. rewrites every node-id token in a code body into `__pyreflect_render__("")`; +2. wraps each body with an identical framework, including the definition of the `__pyreflect_render__` function. + +The `__pyreflect_render__` function reconstructs the exact source code of the specified node from data embedded in the emitted source code without any external source of information. + +The framework exposes the following API to each body: + +```plaintext +__pyreflect_SELF__ this node's id +__pyreflect_DATA__ Base64-encoded template +__pyreflect_self_id__() this node's id +__pyreflect_node_ids__() all node ids, in template order +__pyreflect_render__(target) exact source code (string) of the target node +``` + +A body must not redefine the reserved names of the form `__pyreflect_*__`. + +## Example code + +### quine + +Single node that prints its own code. + +```bash +# Transpile +pyreflect examples/quine/template.json examples/quine/ + +# Run Node: Display its own code +uv run examples/quine/node___NODE.py + +# Verify +cat examples/quine/node___NODE.py +``` + +### mutual_quine + +Two nodes, each of which prints the other's *SHA-256 digest*. The peer value is obtained intrinsically — node 1 reconstructs node 2's source from its own embedded data and hashes it, and vice versa — so node 1's self hash equals the value node 2 reports as its expected peer reference, and vice versa. + +```bash +# Transpile +pyreflect examples/mutual_quine/template.json examples/mutual_quine/ + +# Run Node 1: Display the SHA-256 digest of Node 2 +uv run examples/mutual_quine/node___NODE1.py +# Run Node 2: Display the SHA-256 digest of Node 1 +uv run examples/mutual_quine/node___NODE2.py + +# Verify +sha256sum examples/mutual_quine/*.py +``` + +### trinity_quine + +A variant of `mutual_quine` with three nodes wired into a cycle: node 1 prints node 2's digest, node 2 prints node 3's, node 3 prints node 1's (1 → 2 → 3 → 1). It demonstrates that the transpiler handles arbitrary n-node reference graphs, not just the symmetric two-node case. + +```bash +# Transpile +pyreflect examples/trinity_quine/template.json examples/trinity_quine/ + +# Run Node 1: Display the SHA-256 digest of Node 2 +uv run examples/trinity_quine/node___NODE1.py +# Run Node 2: Display the SHA-256 digest of Node 3 +uv run examples/trinity_quine/node___NODE2.py +# Run Node 3: Display the SHA-256 digest of Node 1 +uv run examples/trinity_quine/node___NODE3.py + +# Verify +sha256sum examples/trinity_quine/*.py +``` diff --git a/examples/mutual_quine/template.json b/examples/mutual_quine/template.json new file mode 100644 index 0000000..5f553e8 --- /dev/null +++ b/examples/mutual_quine/template.json @@ -0,0 +1,10 @@ +[ + { + "node-id": "__NODE1", + "code": "# ========== BODY ========== \nimport hashlib\nprint(hashlib.sha256(__NODE2.encode('utf-8')).hexdigest())" + }, + { + "node-id": "__NODE2", + "code": "# ========== BODY ========== \nimport hashlib\nprint(hashlib.sha256(__NODE1.encode('utf-8')).hexdigest())" + } +] diff --git a/examples/quine/template.json b/examples/quine/template.json new file mode 100644 index 0000000..7158170 --- /dev/null +++ b/examples/quine/template.json @@ -0,0 +1,6 @@ +[ + { + "node-id": "__NODE", + "code": "# ========== BODY ========== \nprint(__NODE)" + } +] diff --git a/examples/trinity_quine/template.json b/examples/trinity_quine/template.json new file mode 100644 index 0000000..32d9166 --- /dev/null +++ b/examples/trinity_quine/template.json @@ -0,0 +1,14 @@ +[ + { + "node-id": "__NODE1", + "code": "# ========== BODY ========== \nimport hashlib\nprint(hashlib.sha256(__NODE2.encode('utf-8')).hexdigest())" + }, + { + "node-id": "__NODE2", + "code": "# ========== BODY ========== \nimport hashlib\nprint(hashlib.sha256(__NODE3.encode('utf-8')).hexdigest())" + }, + { + "node-id": "__NODE3", + "code": "# ========== BODY ========== \nimport hashlib\nprint(hashlib.sha256(__NODE1.encode('utf-8')).hexdigest())" + } +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cb5a721 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "PyReflect" +version = "0.1.0" +readme = "README.md" +requires-python = ">= 3.12" +license = "MIT" +dependencies = [] + +[project.scripts] +pyreflect = "pyreflect.cli:main" + +[build-system] +requires = ["hatchling>=1.30"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pyreflect"] + +[tool.ruff] +target-version = "py312" + +[dependency-groups] +dev = ["ruff>=0.15.4"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "PL", "SIM", "TC", "W"] +ignore = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/src/pyreflect/__init__.py b/src/pyreflect/__init__.py new file mode 100644 index 0000000..2bb41f7 --- /dev/null +++ b/src/pyreflect/__init__.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MIT + +"""PyReflect -- a general mutual-reference transpiler. + +Turns a template describing N nodes into N standalone Python programs, each able +to reconstruct the exact source of every node (itself included) from data +embedded in itself. + +Public API:: + + from pyreflect import load_template, parse_template, transpile + + template = load_template("template.json") + nodes = transpile(template) # {node_id: source_code} +""" + +__version__ = "0.1.0" + +from .template import ( + Node, + Template, + node_ids, + parse_template, + validate_template, +) +from .transpiler import ( + FRAMEWORK, + HEADER, + build_blob, + build_bodies, + render_node, + rewrite_placeholders, + transpile, +) + +__all__ = [ + "__version__", + "Node", + "Template", + "load_template", + "parse_template", + "validate_template", + "node_ids", + "transpile", + "build_blob", + "build_bodies", + "rewrite_placeholders", + "render_node", + "FRAMEWORK", + "HEADER", +] diff --git a/src/pyreflect/cli.py b/src/pyreflect/cli.py new file mode 100644 index 0000000..a91c008 --- /dev/null +++ b/src/pyreflect/cli.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MIT + +"""Command-line entry point for PyReflect.""" + +import argparse +import json +import os +import re +import sys + +from . import __version__ +from .template import Template, node_ids, parse_template +from .transpiler import transpile + + +def read_file(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def load_template(path: str) -> Template: + text = sys.stdin.read() if path == "-" else read_file(path) + return parse_template(text) + + +def node_filename(node_id: str) -> str: + return "node_" + re.sub(r"[^A-Za-z0-9_]", "_", node_id) + ".py" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pyreflect", + description="Transpile a template into standalone Python nodes.", + ) + parser.add_argument( + "template", + metavar="TEMPLATE.json", + help="template JSON file, or '-' to read from stdin", + ) + parser.add_argument( + "outdir", + metavar="OUTPUT_DIR", + help="directory to write node_.py files and manifest.json into", + ) + parser.add_argument( + "--version", + action="version", + version="%(prog)s " + __version__, + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + template = load_template(args.template) + nodes = transpile(template) + ids = node_ids(template) + manifest = {nid: node_filename(nid) for nid in ids} + + os.makedirs(args.outdir, exist_ok=True) + for nid, filename in manifest.items(): + with open(os.path.join(args.outdir, filename), "w", encoding="utf-8") as f: + f.write(nodes[nid]) + with open( + os.path.join(args.outdir, "manifest.json"), "w", encoding="utf-8" + ) as f: + json.dump(manifest, f, indent=2) + except (ValueError, OSError) as exc: + sys.stderr.write("pyreflect: %s\n" % exc) + return 1 + + print("generated %d node(s) in %s:" % (len(manifest), os.path.abspath(args.outdir))) + for nid, filename in manifest.items(): + print(" %-14s -> %s" % (nid, filename)) + print(" manifest -> manifest.json") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pyreflect/template.py b/src/pyreflect/template.py new file mode 100644 index 0000000..a5ec962 --- /dev/null +++ b/src/pyreflect/template.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT + +"""Template parsing and validation for PyReflect.""" + +import json +from typing import Required, TypedDict + + +class Node(TypedDict): + """A single template entry.""" + + node_id: Required[str] + code: Required[str] + + +# Internal canonical representation keeps the JSON key names ("node-id"), +# since the transpiler and manifest format are defined in terms of them. +Template = list[dict] + + +def parse_template(text: str) -> Template: + """Decode and validate a template from its JSON text. + + Args: + text: str JSON text encoding a list of node objects. + + Returns: + The validated template (a list of ``{"node-id", "code"}`` dicts). + + Raises: + ValueError: if the JSON is invalid or the template is malformed. + """ + return validate_template(json.loads(text)) + + +def validate_template[T](template: T) -> Template: + """Validate a decoded template JSON object, returning it unchanged. + + Args: + template: T The decoded JSON object to validate (expected to be a list of + node objects). + + Returns: + The same object, validated and typed as ``Template``. + + Raises: + ValueError: if the structure is malformed or node-ids collide. + """ + if not isinstance(template, list): + raise ValueError("template must be a JSON list") + + for entry in template: + if not isinstance(entry, dict): + raise ValueError("each template entry must be an object") + if "node-id" not in entry or "code" not in entry: + raise ValueError("each template entry needs 'node-id' and 'code'") + if not isinstance(entry["node-id"], str): + raise ValueError("'node-id' must be a string") + if not isinstance(entry["code"], str): + raise ValueError("'code' must be a string") + + ids = [entry["node-id"] for entry in template] + if len(set(ids)) != len(ids): + raise ValueError("duplicate node-id in template") + + return template + + +def node_ids(template: Template) -> list[str]: + """List the node ids of a template. + + Args: + template: Template The validated template. + + Returns: + The node ids, in the order they appear in the template. + """ + return [entry["node-id"] for entry in template] diff --git a/src/pyreflect/transpiler.py b/src/pyreflect/transpiler.py new file mode 100644 index 0000000..0330484 --- /dev/null +++ b/src/pyreflect/transpiler.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: MIT + +"""Core mutual-reference transpiler for PyReflect.""" + +import base64 +import json +import re + +from .template import Template, validate_template +from .template import node_ids as _template_node_ids + +# Embedded framework (byte-identical in every node; written literally AND stored +# in the data blob from this one source value). +FRAMEWORK = '''\ +# ========== PyReflect FRAMEWORK ========== + +def __pyreflect_blob__(): + return json.loads(base64.b64decode(__pyreflect_DATA__).decode("utf-8")) + +def __pyreflect_render__(target): + """Exact source code (str) of node `target`, reconstructed from embedded data.""" + d = __pyreflect_blob__() + return (d["header"] + + "__pyreflect_SELF__ = " + repr(target) + "\\n" + + "__pyreflect_DATA__ = " + repr(__pyreflect_DATA__) + "\\n" + + "\\n" + + d["framework"] + "\\n" + + d["bodies"][target]) + +def __pyreflect_node_ids__(): + return list(__pyreflect_blob__()["nodes"]) + +def __pyreflect_self_id__(): + return __pyreflect_SELF__''' + +HEADER = ( + "#!/usr/bin/env python3\n" + "# auto-generated mutually-referential attestation node -- do not edit\n" + "import base64, hashlib, json\n" + "\n" +) + + +def render_node(target: str, blob: str, header: str, framework: str, body: str) -> str: + """Lay out the source of one node file. + + This expression is mirrored verbatim inside ``FRAMEWORK`` (the + ``__pyreflect_render__`` function); the two MUST stay structurally identical. + + Args: + target: str The node-id this file is for. + blob: str The base64-encoded data blob embedded in the node. + header: str The shared file header. + framework: str The embedded framework source. + body: str The rewritten node body. + + Returns: + The complete source code of the node file. + """ + return ( + header + + "__pyreflect_SELF__ = " + + repr(target) + + "\n" + + "__pyreflect_DATA__ = " + + repr(blob) + + "\n" + + "\n" + + framework + + "\n" + + body + ) + + +def rewrite_placeholders(code: str, ids) -> str: + """Rewrite each bare node-id token in ``code`` into a ``__pyreflect_render__`` call. + + Args: + code: str A node body, possibly containing bare node-id tokens. + ids: Iterable[str] The node-ids to rewrite, matched on identifier boundaries. + + Returns: + The rewritten body, terminated by a single trailing newline. + """ + out = code + for nid in ids: + pat = r"(? dict[str, str]: + """Rewrite every node body in a template. + + Args: + template: Template The validated template. + + Returns: + A ``{node_id: rewritten_code}`` mapping. + """ + ids = _template_node_ids(template) + return { + entry["node-id"]: rewrite_placeholders(entry["code"], ids) for entry in template + } + + +def build_blob(template: Template, bodies: dict[str, str] | None = None) -> str: + """Build the base64-encoded data blob embedded in every emitted node. + + Args: + template: Template The validated template. + bodies: dict[str, str] | None Pre-rewritten bodies; rebuilt from + ``template`` when ``None``. + + Returns: + The base64-encoded JSON blob (ASCII str) embedded as ``__pyreflect_DATA__``. + """ + if bodies is None: + bodies = build_bodies(template) + blob_obj = { + "nodes": _template_node_ids(template), + "bodies": bodies, + "header": HEADER, + "framework": FRAMEWORK, + } + return base64.b64encode( + json.dumps(blob_obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).decode("ascii") + + +def transpile(template: Template) -> dict[str, str]: + """Transpile a template into standalone Python nodes. + + Args: + template: Template The template to transpile (it is validated). + + Returns: + A ``{node_id: source_code}`` mapping, one entry per node. + + Raises: + ValueError: if the template is malformed or node-ids collide. + """ + template = validate_template(template) + ids = _template_node_ids(template) + bodies = build_bodies(template) + blob = build_blob(template, bodies) + + nodes = {nid: render_node(nid, blob, HEADER, FRAMEWORK, bodies[nid]) for nid in ids} + return nodes