Skip to content

Commit b6fa5ce

Browse files
committed
Copy the Python/Jinja2 test generator from jq
1 parent e6432a9 commit b6fa5ce

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

bin/generate_tests

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

0 commit comments

Comments
 (0)