Skip to content

Commit 380ae91

Browse files
Create test suite generator (requires per-exercise templates) (#1857)
* Add test generator script * add .flake8 file to handle line length discrepency with black * add requirements file for generator script * Run bin/generate_tests.py in Travis HOUSEKEEPING job to ensure template changes have been applied * add --check flag to generator script and refactor CI calls Co-authored-by: Michael Morehouse <[email protected]>
1 parent a1a3df1 commit 380ae91

File tree

5 files changed

+170
-5
lines changed

5 files changed

+170
-5
lines changed

.flake8

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[flake8]
2+
max-line-length = 80
3+
exclude = .git, *venv*
4+
select = B950
5+
ignore = E501

.travis.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ matrix:
1515
dist: trusty
1616
- env: JOB=HOUSEKEEPING
1717
python: 3.7
18-
install: ./bin/fetch-configlet
18+
install:
19+
- ./bin/fetch-configlet
20+
- git clone https://github.com/exercism/problem-specifications spec
21+
- pip install -r requirements-generator.txt
1922
before_script: ./bin/check-readmes.sh
2023
script:
24+
- bin/generate_tests.py -p spec --check
2125
# May provide more useful output than configlet fmt
2226
# if JSON is not valid or items are incomplete
2327
- ./bin/configlet lint .

bin/check-readmes.sh

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ get_timestamp()
99
ret=0
1010
for exercise in $(ls -d exercises/*/); do
1111
meta_dir="${exercise}.meta"
12-
if [ -d "$meta_dir" ]; then
13-
meta_timestamp="$(get_timestamp "$meta_dir")"
12+
hints_file="${meta_dir}/HINTS.md"
13+
if [ -f "$hints_file" ]; then
14+
hints_timestamp="$(get_timestamp "$hints_file")"
1415
readme_timestamp="$(get_timestamp "${exercise}README.md")"
15-
if [ "$meta_timestamp" -gt "$readme_timestamp" ]; then
16+
if [ "$hints_timestamp" -gt "$readme_timestamp" ]; then
1617
ret=1
17-
echo "$(basename "$exercise"): .meta/ contents newer than README. Please regenerate it with configlet."
18+
echo "$(basename "$exercise"): .meta/HINTS.md contents newer than README. Please regenerate README with configlet."
1819
fi
1920
fi
2021
done

bin/generate_tests.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3.7
2+
"""
3+
Generates exercise test suites using an exercise's canonical-data.json
4+
(found in problem-specifications) and $exercise/.meta/template.j2.
5+
If either does not exist, generation will not be attempted.
6+
7+
Usage:
8+
generate_tests.py Generates tests for all exercises
9+
generate_tests.py two-fer Generates tests for two-fer exercise
10+
generate_tests.py t* Generates tests for all exercises matching t*
11+
12+
generate_tests.py --check Checks if test files are out of sync with templates
13+
generate_tests.py --check two-fer Checks if two-fer test file is out of sync with template
14+
"""
15+
import argparse
16+
import json
17+
import logging
18+
import os
19+
import re
20+
import sys
21+
from glob import glob
22+
from itertools import repeat
23+
from string import punctuation, whitespace
24+
from subprocess import CalledProcessError, check_call
25+
26+
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
27+
28+
VERSION = '0.1.0'
29+
30+
DEFAULT_SPEC_LOCATION = os.path.join('..', 'problem-specifications')
31+
RGX_WORDS = re.compile(r'[-_\s]|(?=[A-Z])')
32+
33+
logging.basicConfig()
34+
logger = logging.getLogger('generator')
35+
logger.setLevel(logging.WARN)
36+
37+
38+
def replace_all(string, chars, rep):
39+
"""
40+
Replace any char in chars with rep, reduce runs and strip terminal ends.
41+
"""
42+
trans = str.maketrans(dict(zip(chars, repeat(rep))))
43+
return re.sub("{0}+".format(re.escape(rep)), rep,
44+
string.translate(trans)).strip(rep)
45+
46+
47+
def to_snake(string):
48+
"""
49+
Convert pretty much anything to to_snake.
50+
"""
51+
clean = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", string)
52+
clean = re.sub("([a-z0-9])([A-Z])", r"\1_\2", clean).lower()
53+
return replace_all(clean, whitespace + punctuation, "_")
54+
55+
56+
def camel_case(string):
57+
"""
58+
Convert pretty much anything to CamelCase.
59+
"""
60+
return ''.join(w.title() for w in to_snake(string).split('_'))
61+
62+
63+
def load_canonical(exercise, spec_path):
64+
"""
65+
Loads the canonical data for an exercise as a nested dictionary
66+
"""
67+
full_path = os.path.join(
68+
spec_path, 'exercises', exercise, 'canonical-data.json'
69+
)
70+
with open(full_path) as f:
71+
return json.load(f)
72+
73+
74+
def format_file(path):
75+
"""
76+
Runs black auto-formatter on file at path
77+
"""
78+
check_call(['black', '-q', path])
79+
80+
81+
def compare_existing(rendered, tests_path):
82+
"""
83+
Returns true if contents of file at tests_path match rendered
84+
"""
85+
if not os.path.isfile(tests_path):
86+
return False
87+
with open(tests_path) as f:
88+
current = f.read()
89+
return rendered == current
90+
91+
92+
def generate_exercise(env, spec_path, exercise, check=False):
93+
"""
94+
Renders test suite for exercise and if check is:
95+
True: verifies that current tests file matches rendered
96+
False: saves rendered to tests file
97+
"""
98+
slug = os.path.basename(exercise)
99+
try:
100+
spec = load_canonical(slug, spec_path)
101+
template_path = os.path.join(slug, '.meta', 'template.j2')
102+
try:
103+
template = env.get_template(template_path)
104+
tests_path = os.path.join(
105+
exercise, f'{to_snake(slug)}_test.py'
106+
)
107+
rendered = template.render(**spec)
108+
if check:
109+
if not compare_existing(rendered, tests_path):
110+
logger.error(f'{slug}: check failed; tests must be regenerated with bin/generate_tests.py')
111+
sys.exit(1)
112+
else:
113+
with open(tests_path, 'w') as f:
114+
f.write(rendered)
115+
format_file(tests_path)
116+
print(f'{slug} generated at {tests_path}')
117+
except TemplateNotFound:
118+
logger.info(f'{slug}: no template found; skipping')
119+
except FileNotFoundError:
120+
logger.info(f'{slug}: no canonical data found; skipping')
121+
122+
123+
def generate(exercise_glob, spec_path=DEFAULT_SPEC_LOCATION, check=False, **kwargs):
124+
"""
125+
Primary entry point. Generates test files for all exercises matching exercise_glob
126+
"""
127+
loader = FileSystemLoader('exercises')
128+
env = Environment(loader=loader, keep_trailing_newline=True)
129+
env.filters['to_snake'] = to_snake
130+
env.filters['camel_case'] = camel_case
131+
for exercise in glob(os.path.join('exercises', exercise_glob)):
132+
generate_exercise(env, spec_path, exercise, check)
133+
134+
135+
if __name__ == '__main__':
136+
parser = argparse.ArgumentParser()
137+
parser.add_argument(
138+
'exercise_glob', nargs='?', default='*', metavar='EXERCISE'
139+
)
140+
parser.add_argument(
141+
'--version', action='version',
142+
version='%(prog)s {} for Python {}'.format(
143+
VERSION, sys.version.split("\n")[0],
144+
)
145+
)
146+
parser.add_argument('-v', '--verbose', action='store_true')
147+
parser.add_argument('-p', '--spec-path', default=DEFAULT_SPEC_LOCATION)
148+
parser.add_argument('--check', action='store_true')
149+
opts = parser.parse_args()
150+
if opts.verbose:
151+
logger.setLevel(logging.INFO)
152+
generate(**opts.__dict__)

requirements-generator.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
black==19.3b0
2+
flake8==3.7.8
3+
Jinja2==2.10.1

0 commit comments

Comments
 (0)