From 901ae863150076159ef2435152d29ff5c783aad0 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Sun, 28 Jun 2026 13:23:58 -0700 Subject: [PATCH] Copy the Python/Jinja2 test generator from `jq` --- bin/generate_tests | 171 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100755 bin/generate_tests diff --git a/bin/generate_tests b/bin/generate_tests new file mode 100755 index 00000000..a7efd276 --- /dev/null +++ b/bin/generate_tests @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +"""Test generator v1.""" + +import argparse +import datetime +import json +import os +import pathlib +import re +import shlex +import subprocess +import sys +import textwrap +import tomllib + +import jinja2 + + +def problem_spec_dir() -> pathlib.Path: + """Detect and return the problem specs.""" + cache_dir = os.getenv("XDG_CACHE_HOME", os.getenv("HOME") + "/.cache") + specs = pathlib.Path(cache_dir) / "exercism/configlet/problem-specifications" + if specs.exists(): + return specs + cur = pathlib.Path(os.getcwd()) + for i in cur.parents: + if i.name == "problem-specifications": + return i + raise LookupError("Could not find problem specs") + + +def flatten_cases(cases: list[dict]) -> list[tuple[list[str], dict]]: + """Recursive flatten test cases, returning individual cases with parent descriptions.""" + for case_or_group in cases: + if "cases" in case_or_group: + for groups, child_case in flatten_cases(case_or_group["cases"]): + yield ([case_or_group["description"]] + groups, child_case) + else: + yield ([], case_or_group) + + +def get_cases(specs: pathlib.Path, exercise: pathlib.Path) -> list[dict]: + """Return flattened, filtered cases with additional metadata attached.""" + canonical_path = specs / "exercises" / exercise.name / "canonical-data.json" + with open(canonical_path, "r", encoding="utf-8") as f: + canonical = json.load(f) + with open(exercise / ".meta" / "tests.toml", "rb") as f: + tests = tomllib.load(f) + + reimplemented = { + test["reimplements"] + for test in tests.values() + if test.get("include", True) and "reimplements" in test + } + cases = [] + for groups, case in flatten_cases(canonical["cases"]): + # Filter out test cases with include=false or not listed. + if case["uuid"] not in tests or case["uuid"] in reimplemented: + continue + if not tests[case["uuid"]].get("include", True): + continue + # Add metadata. + case["descriptions"] = groups + [case["description"]] + case["expect_error"] = isinstance(case["expected"], dict) and "error" in case["expected"] + if case["expect_error"]: + case["expect_error_msg"] = case["expected"]["error"] + cases.append(case) + return cases + + +def filter_tojson(data, separators=(',', ':'), indent=None) -> str: + """Filter `tojson` that JSON encodes a string with flexible settings.""" + return json.dumps(data, separators=separators, indent=indent) + + +def jinja_env(exercise: pathlib.Path) -> jinja2.Environment: + """Return a configured Jinja env with filters added.""" + env = jinja2.Environment(loader=jinja2.FileSystemLoader(exercise / ".meta")) + env.filters["quote"] = shlex.quote + env.filters["tojson"] = filter_tojson + env.filters["repr"] = repr + env.filters["camel_to_snake"] = lambda x: re.sub(r"([a-z])([A-Z])", (lambda m: f"{m.group(1)}_{m.group(2).lower()}"), x) + env.filters["format_list"] = lambda x: shlex.quote( + "[" + ",".join(f'"{i}"' if isinstance(i, str) else str(i) for i in x) + "]" + ) + return env + + +def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None: + """Generate and write test file for a given spec and exercise.""" + cases = get_cases(specs, exercise) + + timestamp = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0).isoformat() + header = textwrap.dedent(f"""\ + #!/usr/bin/env bats + load bats-extra + + # generated on {timestamp} + # local version: 2.0.0.0""" + ) + data = { + "cases": list(enumerate(cases)), + "header": header, + "solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0] + } + + # Render the template. + out = jinja_env(exercise).get_template("template.j2").render(data) + + # Check for changes or the lack thereof. + test_file = exercise / json.loads((exercise / ".meta/config.json").read_text())["files"]["test"][0] + if test_file.exists(): + old_content = [i for i in test_file.read_text().splitlines() if "generated on" not in i] + new_content = [i for i in out.splitlines() if "generated on" not in i] + if old_content == new_content: + return + + # Write the test file. + test_file.write_text(out) + + +def argparser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument( + "--no-pull", + action="store_false", + dest="pull", + help="Do not run `git pull` on the problem specs repo", + ) + parser.add_argument( + "exercises", + nargs="*", + help="exercises to generate tests; if none supplied, generate all" + ) + return parser + + +def main(): + """Main entrypoint.""" + specs = problem_spec_dir() + args = argparser().parse_args() + if args.pull: + subprocess.check_call(["git", "pull"], cwd=specs) + exercises = args.exercises + # Generate all exercises with templates if none are specified as args. + if not exercises: + exercises = [ + i.parent.parent + for i in pathlib.Path("exercises/practice").glob("*/.meta/template.j2") + ] + else: + # Turn strings to paths and make them relative to the practice exercises. + out = [] + practice = pathlib.Path("exercises/practice") + for exercise in exercises: + path = pathlib.Path(exercise) + if not path.is_relative_to(practice): + path = practice / path + out.append(path) + exercises = out + + for exercise in exercises: + exercise_path = pathlib.Path(exercise) + if not exercise_path.exists(): + raise ValueError(f"Exercise {exercise_path} does not exist") + generate(specs, exercise_path) + + +if __name__ == "__main__": + main()