Skip to content

Commit a217d56

Browse files
authored
Copy the Python/Jinja2 test generator from jq (#786)
1 parent 93db4cc commit a217d56

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

bin/generate_tests

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python3
2+
3+
"""Test generator v1."""
4+
5+
import argparse
6+
import datetime
7+
import json
8+
import os
9+
import pathlib
10+
import re
11+
import shlex
12+
import subprocess
13+
import sys
14+
import textwrap
15+
import tomllib
16+
17+
import jinja2
18+
19+
20+
def problem_spec_dir() -> pathlib.Path:
21+
"""Detect and return the problem specs."""
22+
cache_dir = os.getenv("XDG_CACHE_HOME", os.getenv("HOME") + "/.cache")
23+
specs = pathlib.Path(cache_dir) / "exercism/configlet/problem-specifications"
24+
if specs.exists():
25+
return specs
26+
cur = pathlib.Path(os.getcwd())
27+
for i in cur.parents:
28+
if i.name == "problem-specifications":
29+
return i
30+
raise LookupError("Could not find problem specs")
31+
32+
33+
def flatten_cases(cases: list[dict]) -> list[tuple[list[str], dict]]:
34+
"""Recursive flatten test cases, returning individual cases with parent descriptions."""
35+
for case_or_group in cases:
36+
if "cases" in case_or_group:
37+
for groups, child_case in flatten_cases(case_or_group["cases"]):
38+
yield ([case_or_group["description"]] + groups, child_case)
39+
else:
40+
yield ([], case_or_group)
41+
42+
43+
def get_cases(specs: pathlib.Path, exercise: pathlib.Path) -> list[dict]:
44+
"""Return flattened, filtered cases with additional metadata attached."""
45+
canonical_path = specs / "exercises" / exercise.name / "canonical-data.json"
46+
with open(canonical_path, "r", encoding="utf-8") as f:
47+
canonical = json.load(f)
48+
with open(exercise / ".meta" / "tests.toml", "rb") as f:
49+
tests = tomllib.load(f)
50+
51+
reimplemented = {
52+
test["reimplements"]
53+
for test in tests.values()
54+
if test.get("include", True) and "reimplements" in test
55+
}
56+
cases = []
57+
for groups, case in flatten_cases(canonical["cases"]):
58+
# Filter out test cases with include=false or not listed.
59+
if case["uuid"] not in tests or case["uuid"] in reimplemented:
60+
continue
61+
if not tests[case["uuid"]].get("include", True):
62+
continue
63+
# Add metadata.
64+
case["descriptions"] = groups + [case["description"]]
65+
case["expect_error"] = isinstance(case["expected"], dict) and "error" in case["expected"]
66+
if case["expect_error"]:
67+
case["expect_error_msg"] = case["expected"]["error"]
68+
cases.append(case)
69+
return cases
70+
71+
72+
def filter_tojson(data, separators=(',', ':'), indent=None) -> str:
73+
"""Filter `tojson` that JSON encodes a string with flexible settings."""
74+
return json.dumps(data, separators=separators, indent=indent)
75+
76+
77+
def jinja_env(exercise: pathlib.Path) -> jinja2.Environment:
78+
"""Return a configured Jinja env with filters added."""
79+
env = jinja2.Environment(loader=jinja2.FileSystemLoader(exercise / ".meta"))
80+
env.filters["quote"] = shlex.quote
81+
env.filters["tojson"] = filter_tojson
82+
env.filters["repr"] = repr
83+
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)
84+
env.filters["format_list"] = lambda x: shlex.quote(
85+
"[" + ",".join(f'"{i}"' if isinstance(i, str) else str(i) for i in x) + "]"
86+
)
87+
return env
88+
89+
90+
def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None:
91+
"""Generate and write test file for a given spec and exercise."""
92+
cases = get_cases(specs, exercise)
93+
94+
timestamp = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0).isoformat()
95+
header = textwrap.dedent(f"""\
96+
#!/usr/bin/env bats
97+
load bats-extra
98+
99+
# generated on {timestamp}
100+
# local version: 2.0.0.0"""
101+
)
102+
data = {
103+
"cases": list(enumerate(cases)),
104+
"header": header,
105+
"solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0]
106+
}
107+
108+
# Render the template.
109+
out = jinja_env(exercise).get_template("template.j2").render(data)
110+
111+
# Check for changes or the lack thereof.
112+
test_file = exercise / json.loads((exercise / ".meta/config.json").read_text())["files"]["test"][0]
113+
if test_file.exists():
114+
old_content = [i for i in test_file.read_text().splitlines() if "generated on" not in i]
115+
new_content = [i for i in out.splitlines() if "generated on" not in i]
116+
if old_content == new_content:
117+
return
118+
119+
# Write the test file.
120+
test_file.write_text(out)
121+
122+
123+
def argparser() -> argparse.ArgumentParser:
124+
parser = argparse.ArgumentParser()
125+
parser.add_argument(
126+
"--no-pull",
127+
action="store_false",
128+
dest="pull",
129+
help="Do not run `git pull` on the problem specs repo",
130+
)
131+
parser.add_argument(
132+
"exercises",
133+
nargs="*",
134+
help="exercises to generate tests; if none supplied, generate all"
135+
)
136+
return parser
137+
138+
139+
def main():
140+
"""Main entrypoint."""
141+
specs = problem_spec_dir()
142+
args = argparser().parse_args()
143+
if args.pull:
144+
subprocess.check_call(["git", "pull"], cwd=specs)
145+
exercises = args.exercises
146+
# Generate all exercises with templates if none are specified as args.
147+
if not exercises:
148+
exercises = [
149+
i.parent.parent
150+
for i in pathlib.Path("exercises/practice").glob("*/.meta/template.j2")
151+
]
152+
else:
153+
# Turn strings to paths and make them relative to the practice exercises.
154+
out = []
155+
practice = pathlib.Path("exercises/practice")
156+
for exercise in exercises:
157+
path = pathlib.Path(exercise)
158+
if not path.is_relative_to(practice):
159+
path = practice / path
160+
out.append(path)
161+
exercises = out
162+
163+
for exercise in exercises:
164+
exercise_path = pathlib.Path(exercise)
165+
if not exercise_path.exists():
166+
raise ValueError(f"Exercise {exercise_path} does not exist")
167+
generate(specs, exercise_path)
168+
169+
170+
if __name__ == "__main__":
171+
main()

0 commit comments

Comments
 (0)