Skip to content

Commit fe29831

Browse files
authored
generator: Add support for exercise plugins (exercism#2097)
* generator: Add support for exercise plugins * use importlib for importing plugins * handle plugin SyntaxError * group SyntaxError with TypeError, UndefinedError
1 parent f9cc6e9 commit fe29831

1 file changed

Lines changed: 67 additions & 58 deletions

File tree

bin/generate_tests.py

Lines changed: 67 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515
import argparse
1616
import filecmp
17+
import importlib.util
1718
import json
1819
import logging
1920
import os
@@ -29,13 +30,13 @@
2930

3031
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, UndefinedError
3132

32-
VERSION = '0.1.1'
33+
VERSION = "0.2.0"
3334

34-
DEFAULT_SPEC_LOCATION = os.path.join('..', 'problem-specifications')
35-
RGX_WORDS = re.compile(r'[-_\s]|(?=[A-Z])')
35+
DEFAULT_SPEC_LOCATION = os.path.join("..", "problem-specifications")
36+
RGX_WORDS = re.compile(r"[-_\s]|(?=[A-Z])")
3637

3738
logging.basicConfig()
38-
logger = logging.getLogger('generator')
39+
logger = logging.getLogger("generator")
3940
logger.setLevel(logging.WARN)
4041

4142

@@ -44,8 +45,9 @@ def replace_all(string, chars, rep):
4445
Replace any char in chars with rep, reduce runs and strip terminal ends.
4546
"""
4647
trans = str.maketrans(dict(zip(chars, repeat(rep))))
47-
return re.sub("{0}+".format(re.escape(rep)), rep,
48-
string.translate(trans)).strip(rep)
48+
return re.sub("{0}+".format(re.escape(rep)), rep, string.translate(trans)).strip(
49+
rep
50+
)
4951

5052

5153
def to_snake(string):
@@ -61,7 +63,7 @@ def camel_case(string):
6163
"""
6264
Convert pretty much anything to CamelCase.
6365
"""
64-
return ''.join(w.title() for w in to_snake(string).split('_'))
66+
return "".join(w.title() for w in to_snake(string).split("_"))
6567

6668

6769
def get_tested_properties(spec):
@@ -79,9 +81,9 @@ def get_tested_properties(spec):
7981

8082
def error_case(case):
8183
return (
82-
"expected" in case and
83-
isinstance(case["expected"], dict) and
84-
"error" in case["expected"]
84+
"expected" in case
85+
and isinstance(case["expected"], dict)
86+
and "error" in case["expected"]
8587
)
8688

8789

@@ -103,9 +105,7 @@ def load_canonical(exercise, spec_path):
103105
"""
104106
Loads the canonical data for an exercise as a nested dictionary
105107
"""
106-
full_path = os.path.join(
107-
spec_path, 'exercises', exercise, 'canonical-data.json'
108-
)
108+
full_path = os.path.join(spec_path, "exercises", exercise, "canonical-data.json")
109109
with open(full_path) as f:
110110
spec = json.load(f)
111111
spec["properties"] = get_tested_properties(spec)
@@ -116,7 +116,7 @@ def load_additional_tests(exercise):
116116
"""
117117
Loads additional tests from .meta/additional_tests.json
118118
"""
119-
full_path = os.path.join('exercises', exercise, '.meta', 'additional_tests.json')
119+
full_path = os.path.join("exercises", exercise, ".meta", "additional_tests.json")
120120
try:
121121
with open(full_path) as f:
122122
data = json.load(f)
@@ -129,7 +129,7 @@ def format_file(path):
129129
"""
130130
Runs black auto-formatter on file at path
131131
"""
132-
check_call(['black', '-q', path])
132+
check_call(["black", "-q", path])
133133

134134

135135
def compare_existing(rendered, tests_path):
@@ -150,54 +150,69 @@ def generate_exercise(env, spec_path, exercise, check=False):
150150
False: saves rendered to tests file
151151
"""
152152
slug = os.path.basename(exercise)
153+
meta_dir = os.path.join(exercise, ".meta")
154+
plugins_module = None
155+
plugins_name = "plugins"
156+
plugins_source = os.path.join(meta_dir, f"{plugins_name}.py")
153157
try:
158+
if os.path.isfile(plugins_source):
159+
plugins_spec = importlib.util.spec_from_file_location(
160+
plugins_name, plugins_source
161+
)
162+
plugins_module = importlib.util.module_from_spec(plugins_spec)
163+
sys.modules[plugins_name] = plugins_module
164+
plugins_spec.loader.exec_module(plugins_module)
154165
spec = load_canonical(slug, spec_path)
155166
additional_tests = load_additional_tests(slug)
156167
spec["additional_cases"] = additional_tests
157-
template_path = posixpath.join(slug, '.meta', 'template.j2')
168+
template_path = posixpath.join(slug, ".meta", "template.j2")
158169
template = env.get_template(template_path)
159-
tests_path = os.path.join(
160-
exercise, f'{to_snake(slug)}_test.py'
161-
)
170+
tests_path = os.path.join(exercise, f"{to_snake(slug)}_test.py")
162171
spec["has_error_case"] = has_error_case(spec["cases"])
163-
logger.info(f'{slug}: attempting render')
172+
if plugins_module is not None:
173+
spec[plugins_name] = plugins_module
174+
logger.info(f"{slug}: attempting render")
164175
rendered = template.render(**spec)
165-
with NamedTemporaryFile('w', delete=False) as tmp:
176+
with NamedTemporaryFile("w", delete=False) as tmp:
166177
tmp.write(rendered)
167178
try:
168-
logger.debug(f'{slug}: formatting tmp file')
179+
logger.debug(f"{slug}: formatting tmp file")
169180
format_file(tmp.name)
170181
except FileNotFoundError as e:
171-
logger.error(f'{slug}: the black utility must be installed')
182+
logger.error(f"{slug}: the black utility must be installed")
172183
return False
173184

174185
if check:
175186
try:
176187
if not filecmp.cmp(tmp.name, tests_path):
177-
logger.error(f'{slug}: check failed; tests must be regenerated with bin/generate_tests.py')
188+
logger.error(
189+
f"{slug}: check failed; tests must be regenerated with bin/generate_tests.py"
190+
)
178191
return False
179192
finally:
180193
os.remove(tmp.name)
181194
else:
182195
shutil.move(tmp.name, tests_path)
183-
print(f'{slug} generated at {tests_path}')
184-
except (TypeError, UndefinedError) as e:
196+
print(f"{slug} generated at {tests_path}")
197+
except (TypeError, UndefinedError, SyntaxError) as e:
185198
logger.debug(str(e))
186-
logger.error(f'{slug}: generation failed')
199+
logger.error(f"{slug}: generation failed")
187200
return False
188201
except TemplateNotFound as e:
189202
logger.debug(str(e))
190-
logger.info(f'{slug}: no template found; skipping')
203+
logger.info(f"{slug}: no template found; skipping")
191204
except FileNotFoundError as e:
192205
logger.debug(str(e))
193-
logger.info(f'{slug}: no canonical data found; skipping')
206+
logger.info(f"{slug}: no canonical data found; skipping")
194207
return True
195208

196209

197210
def generate(
198-
exercise_glob, spec_path=DEFAULT_SPEC_LOCATION,
199-
stop_on_failure=False, check=False,
200-
**kwargs
211+
exercise_glob,
212+
spec_path=DEFAULT_SPEC_LOCATION,
213+
stop_on_failure=False,
214+
check=False,
215+
**kwargs,
201216
):
202217
"""
203218
Primary entry point. Generates test files for all exercises matching exercise_glob
@@ -206,14 +221,14 @@ def generate(
206221
if not shutil.which("black"):
207222
logger.error("the black utility must be installed")
208223
sys.exit(1)
209-
loader = FileSystemLoader(['config', 'exercises'])
224+
loader = FileSystemLoader(["config", "exercises"])
210225
env = Environment(loader=loader, keep_trailing_newline=True)
211-
env.filters['to_snake'] = to_snake
212-
env.filters['camel_case'] = camel_case
213-
env.filters['regex_replace'] = regex_replace
214-
env.tests['error_case'] = error_case
226+
env.filters["to_snake"] = to_snake
227+
env.filters["camel_case"] = camel_case
228+
env.filters["regex_replace"] = regex_replace
229+
env.tests["error_case"] = error_case
215230
result = True
216-
for exercise in glob(os.path.join('exercises', exercise_glob)):
231+
for exercise in glob(os.path.join("exercises", exercise_glob)):
217232
if not generate_exercise(env, spec_path, exercise, check):
218233
result = False
219234
if stop_on_failure:
@@ -222,34 +237,28 @@ def generate(
222237
sys.exit(1)
223238

224239

225-
if __name__ == '__main__':
240+
if __name__ == "__main__":
226241
parser = argparse.ArgumentParser()
242+
parser.add_argument("exercise_glob", nargs="?", default="*", metavar="EXERCISE")
227243
parser.add_argument(
228-
'exercise_glob', nargs='?', default='*', metavar='EXERCISE'
229-
)
230-
parser.add_argument(
231-
'--version', action='version',
232-
version='%(prog)s {} for Python {}'.format(
233-
VERSION, sys.version.split("\n")[0],
234-
)
244+
"--version",
245+
action="version",
246+
version="%(prog)s {} for Python {}".format(VERSION, sys.version.split("\n")[0]),
235247
)
236-
parser.add_argument('-v', '--verbose', action='store_true')
248+
parser.add_argument("-v", "--verbose", action="store_true")
237249
parser.add_argument(
238-
'-p', '--spec-path',
250+
"-p",
251+
"--spec-path",
239252
default=DEFAULT_SPEC_LOCATION,
240253
help=(
241-
'path to clone of exercism/problem-specifications '
242-
'(default: %(default)s)'
243-
)
244-
)
245-
parser.add_argument(
246-
'--stop-on-failure',
247-
action='store_true'
254+
"path to clone of exercism/problem-specifications " "(default: %(default)s)"
255+
),
248256
)
257+
parser.add_argument("--stop-on-failure", action="store_true")
249258
parser.add_argument(
250-
'--check',
251-
action='store_true',
252-
help='check if tests are up-to-date, but do not modify test files'
259+
"--check",
260+
action="store_true",
261+
help="check if tests are up-to-date, but do not modify test files",
253262
)
254263
opts = parser.parse_args()
255264
if opts.verbose:

0 commit comments

Comments
 (0)