From 313dc886a2934d743d1d0819bc83f9cf27a3c146 Mon Sep 17 00:00:00 2001 From: Takuma IMAMURA Date: Wed, 29 Jul 2026 17:10:03 +0900 Subject: [PATCH 1/2] ci: add concurrency to ruff workflow Add a concurrency block to .github/workflows/ruff.yml to group runs by workflow and ref and cancel any in-progress runs. Signed-off-by: Takuma IMAMURA --- .github/workflows/ruff.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index ad58399..4bf38ae 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -15,6 +15,10 @@ on: - '.github/workflows/ruff.yml' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read From 3dc33e2e3d84a825068155e563a9ced2f8eff4a6 Mon Sep 17 00:00:00 2001 From: Takuma IMAMURA Date: Wed, 29 Jul 2026 19:28:53 +0900 Subject: [PATCH 2/2] ci(test): add example tests Signed-off-by: Takuma IMAMURA --- .github/workflows/test.yaml | 39 +++++++++++++++ examples/test_examples.py | 98 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yaml create mode 100644 examples/test_examples.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..a3dce7a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,39 @@ +name: Test + +on: + pull_request: + paths: + - '**/*.py' + - 'examples/**' + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/test.yaml' + push: + branches: + - main + paths: + - '**/*.py' + - 'examples/**' + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/test.yaml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + 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 example transpile tests + run: uv run pytest examples/ -v diff --git a/examples/test_examples.py b/examples/test_examples.py new file mode 100644 index 0000000..25811fb --- /dev/null +++ b/examples/test_examples.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: MIT + +"""Transpile every template in examples/, execute the emitted nodes, and +compare what they print against the expected values. + +- ``quine`` the node prints its own exact source code; +- ``mutual_quine`` each node prints the SHA-256 of the other node's source; +- ``trinity_quine`` each node prints the SHA-256 of the next node's source, + in a 3-cycle. + +Run with: ``uv run pytest examples/`` +""" + +import hashlib +import subprocess +import sys +from pathlib import Path + +import pytest + +from pyreflect import parse_template, transpile +from pyreflect.cli import node_filename + +EXAMPLES_DIR = Path(__file__).resolve().parent +EXAMPLE_NAMES = sorted(p.parent.name for p in EXAMPLES_DIR.glob("*/template.json")) + +EXPECTED_NODE_IDS = { + "quine": ["__NODE"], + "mutual_quine": ["__NODE1", "__NODE2"], + "trinity_quine": ["__NODE1", "__NODE2", "__NODE3"], +} + +# Which peer each node hashes, as written in the template bodies. +HASH_TARGETS = { + "mutual_quine": {"__NODE1": "__NODE2", "__NODE2": "__NODE1"}, + "trinity_quine": { + "__NODE1": "__NODE2", + "__NODE2": "__NODE3", + "__NODE3": "__NODE1", + }, +} + + +@pytest.fixture(scope="session") +def transpiled(tmp_path_factory) -> dict[str, tuple[dict[str, str], Path]]: + """Transpile every example once and write the emitted nodes to a temporary + directory; return ``{example_name: (nodes, outdir)}``.""" + result = {} + for name in EXAMPLE_NAMES: + text = (EXAMPLES_DIR / name / "template.json").read_text(encoding="utf-8") + nodes = transpile(parse_template(text)) + outdir = tmp_path_factory.mktemp(name) + for nid, src in nodes.items(): + (outdir / node_filename(nid)).write_text(src, encoding="utf-8") + result[name] = (nodes, outdir) + return result + + +def run_node(outdir: Path, node_id: str) -> str: + """Execute one emitted node as a standalone program; return its stdout.""" + proc = subprocess.run( + [sys.executable, str(outdir / node_filename(node_id))], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout + + +def test_examples_discovered(): + assert sorted(EXPECTED_NODE_IDS) == EXAMPLE_NAMES + + +@pytest.mark.parametrize("name", EXAMPLE_NAMES) +def test_transpile_emits_expected_nodes(name, transpiled): + nodes, _ = transpiled[name] + assert list(nodes) == EXPECTED_NODE_IDS[name] + + +def test_quine_prints_its_own_source(transpiled): + nodes, outdir = transpiled["quine"] + stdout = run_node(outdir, "__NODE") + # The body is ``print()``, so stdout is the emitted source + # plus the newline appended by print. + assert stdout == nodes["__NODE"] + "\n" + + +@pytest.mark.parametrize("name", sorted(HASH_TARGETS)) +def test_hash_quines_print_peer_digest(name, transpiled): + """Each node's printed digest equals the actual SHA-256 of the peer file.""" + nodes, outdir = transpiled[name] + for nid, target in HASH_TARGETS[name].items(): + actual_source = (outdir / node_filename(target)).read_text(encoding="utf-8") + assert actual_source == nodes[target] + expected = hashlib.sha256(actual_source.encode("utf-8")).hexdigest() + assert run_node(outdir, nid) == expected + "\n" diff --git a/pyproject.toml b/pyproject.toml index cb5a721..77d23eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ packages = ["src/pyreflect"] target-version = "py312" [dependency-groups] -dev = ["ruff>=0.15.4"] +dev = ["pytest >= 9.0.2", "ruff>=0.15.4"] [tool.ruff.lint] select = ["B", "E", "F", "I", "PL", "SIM", "TC", "W"]