diff --git a/bin/generate_tests b/bin/generate_tests new file mode 100755 index 00000000..3dcda67f --- /dev/null +++ b/bin/generate_tests @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 + +"""Test generator v1.""" + +import argparse +import csv +import datetime +import io +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 filter_tocsv(data: list) -> str: + """Filter `tocsv` that CSV encodes an object.""" + formatted_data = [] + for datum in data: + if isinstance(datum, (int, float, str)): + formatted_data.append(datum) + elif isinstance(datum, list): + formatted_data.append("[" + filter_tocsv(datum) + "]") + else: + raise ValueError(f"Unsuported data type {type(datum)} in filter_tocsv()") + buff = io.StringIO() + writer = csv.writer(buff, dialect="unix") + writer.writerow(formatted_data) + buff.seek(0) + got = buff.read().removesuffix("\n") + return got + + +def jinja_env(exercise: pathlib.Path) -> jinja2.Environment: + """Return a configured Jinja env with filters added.""" + env = jinja2.Environment(loader=jinja2.FileSystemLoader(exercise / ".meta")) + # Shell quoting + env.filters["quote"] = shlex.quote + # JSON formatting, default to compact form (`jq -c`). + env.filters["tojson"] = filter_tojson + env.filters["tocsv"] = filter_tocsv + # String escaping, ANSI-C style. + env.filters["repr"] = repr + # Return a dict with only specified keys kepts. + 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 bool_to_str(obj): + """Convert boolean values to strings.""" + if isinstance(obj, dict): + return {key: bool_to_str(val) for key, val in obj.items()} + if isinstance(obj, list): + return [bool_to_str(val) for val in obj] + if obj is True: + return "true" + if obj is False: + return "false" + return obj + + +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) + for case in cases: + case["expected"] = bool_to_str(case["expected"]) + + 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} + """ + ).strip() + data = { + "cases": cases, + "header": header, + "solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0] + } + + # Render the template. + for out_file in ["create_test_table.sql", "data.csv"]: + base = out_file.split(".")[0] + if not (exercise / ".meta" / f"template.{base}.j2").exists(): + continue + try: + template = jinja_env(exercise).get_template(f"template.{base}.j2") + out = template.render(data).strip("\n") + except jinja2.exceptions.TemplateAssertionError as e: + e.add_note(f"Error rendering template.{base}.j2 for {exercise.name}") + raise + + # Write the test file. + (exercise / out_file).write_text(out + "\n") + + +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() diff --git a/exercises/practice/anagram/.meta/template.create_test_table.j2 b/exercises/practice/anagram/.meta/template.create_test_table.j2 new file mode 100644 index 00000000..8ec0abeb --- /dev/null +++ b/exercises/practice/anagram/.meta/template.create_test_table.j2 @@ -0,0 +1,34 @@ +DROP TABLE IF EXISTS tests; + +CREATE TABLE IF NOT EXISTS tests ( + -- uuid and description are taken from the test.toml file + uuid TEXT PRIMARY KEY, + description TEXT NOT NULL, + -- The following section is needed by the online test-runner + status TEXT DEFAULT 'fail', + message TEXT, + output TEXT, + test_code TEXT, + task_id INTEGER DEFAULT NULL, + -- Here are columns for the actual tests + subject TEXT NOT NULL, + candidates TEXT NOT NULL, -- json array of strings + expected TEXT NOT NULL +); + +INSERT INTO + tests (uuid, description, subject, candidates, expected) +VALUES +{%- for case in cases %} + ( + '{{ case["uuid"] }}', + '{{ case["description"] }}', + '{{ case["input"]["subject"] }}', + '[{{ case["input"]["candidates"] | tocsv }}]', + '[{{ case["expected"] | tocsv }}]' +{%- if loop.last %} + ); +{%- else %} + ), +{%- endif %} +{%- endfor %} diff --git a/exercises/practice/anagram/.meta/template.data.j2 b/exercises/practice/anagram/.meta/template.data.j2 new file mode 100644 index 00000000..258974bf --- /dev/null +++ b/exercises/practice/anagram/.meta/template.data.j2 @@ -0,0 +1,3 @@ +{%- for case in cases -%} +{{ [case["input"]["subject"], case["input"]["candidates"]] | tocsv }},"" +{% endfor %}