2019-07-24 09:49:01 -04:00
|
|
|
#!/usr/bin/env python3.7
|
|
|
|
|
"""
|
|
|
|
|
Generates exercise test suites using an exercise's canonical-data.json
|
|
|
|
|
(found in problem-specifications) and $exercise/.meta/template.j2.
|
|
|
|
|
If either does not exist, generation will not be attempted.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
generate_tests.py Generates tests for all exercises
|
|
|
|
|
generate_tests.py two-fer Generates tests for two-fer exercise
|
|
|
|
|
generate_tests.py t* Generates tests for all exercises matching t*
|
|
|
|
|
|
|
|
|
|
generate_tests.py --check Checks if test files are out of sync with templates
|
|
|
|
|
generate_tests.py --check two-fer Checks if two-fer test file is out of sync with template
|
|
|
|
|
"""
|
|
|
|
|
import argparse
|
2019-11-06 13:39:53 -05:00
|
|
|
import difflib
|
2019-07-24 10:54:59 -04:00
|
|
|
import filecmp
|
2019-10-29 16:44:59 -04:00
|
|
|
import importlib.util
|
2019-07-24 09:49:01 -04:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
2019-10-03 16:23:25 -05:00
|
|
|
import posixpath
|
2019-07-24 09:49:01 -04:00
|
|
|
import re
|
2019-07-24 10:54:59 -04:00
|
|
|
import shutil
|
2019-07-24 09:49:01 -04:00
|
|
|
import sys
|
|
|
|
|
from glob import glob
|
|
|
|
|
from itertools import repeat
|
|
|
|
|
from string import punctuation, whitespace
|
2019-10-29 11:23:21 -03:00
|
|
|
from subprocess import check_call
|
2019-07-24 12:35:16 -04:00
|
|
|
from tempfile import NamedTemporaryFile
|
2019-11-13 15:00:17 +00:00
|
|
|
from textwrap import wrap
|
2019-07-24 09:49:01 -04:00
|
|
|
|
2019-08-07 09:45:02 -04:00
|
|
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, UndefinedError
|
2019-07-24 09:49:01 -04:00
|
|
|
|
2019-10-29 16:44:59 -04:00
|
|
|
VERSION = "0.2.0"
|
2019-07-24 09:49:01 -04:00
|
|
|
|
2019-10-29 16:44:59 -04:00
|
|
|
DEFAULT_SPEC_LOCATION = os.path.join("..", "problem-specifications")
|
|
|
|
|
RGX_WORDS = re.compile(r"[-_\s]|(?=[A-Z])")
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
logging.basicConfig()
|
2019-10-29 16:44:59 -04:00
|
|
|
logger = logging.getLogger("generator")
|
2019-07-24 09:49:01 -04:00
|
|
|
logger.setLevel(logging.WARN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def replace_all(string, chars, rep):
|
|
|
|
|
"""
|
|
|
|
|
Replace any char in chars with rep, reduce runs and strip terminal ends.
|
|
|
|
|
"""
|
|
|
|
|
trans = str.maketrans(dict(zip(chars, repeat(rep))))
|
2019-10-29 16:44:59 -04:00
|
|
|
return re.sub("{0}+".format(re.escape(rep)), rep, string.translate(trans)).strip(
|
|
|
|
|
rep
|
|
|
|
|
)
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_snake(string):
|
|
|
|
|
"""
|
|
|
|
|
Convert pretty much anything to to_snake.
|
|
|
|
|
"""
|
|
|
|
|
clean = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", string)
|
|
|
|
|
clean = re.sub("([a-z0-9])([A-Z])", r"\1_\2", clean).lower()
|
|
|
|
|
return replace_all(clean, whitespace + punctuation, "_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def camel_case(string):
|
|
|
|
|
"""
|
|
|
|
|
Convert pretty much anything to CamelCase.
|
|
|
|
|
"""
|
2019-10-29 16:44:59 -04:00
|
|
|
return "".join(w.title() for w in to_snake(string).split("_"))
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
|
2019-11-13 15:00:17 +00:00
|
|
|
def wrap_overlong(string, width=70):
|
|
|
|
|
"""
|
|
|
|
|
Break an overly long string literal into escaped lines.
|
|
|
|
|
"""
|
|
|
|
|
return ["{0!r} \\".format(w) for w in wrap(string, width)]
|
|
|
|
|
|
|
|
|
|
|
2019-07-24 16:05:08 -04:00
|
|
|
def get_tested_properties(spec):
|
|
|
|
|
"""
|
|
|
|
|
Get set of tested properties from spec. Include nested cases.
|
|
|
|
|
"""
|
|
|
|
|
props = set()
|
|
|
|
|
for case in spec["cases"]:
|
|
|
|
|
if "property" in case:
|
|
|
|
|
props.add(case["property"])
|
|
|
|
|
if "cases" in case:
|
|
|
|
|
props.update(get_tested_properties(case))
|
|
|
|
|
return sorted(props)
|
|
|
|
|
|
|
|
|
|
|
2019-07-29 23:42:12 -05:00
|
|
|
def error_case(case):
|
|
|
|
|
return (
|
2019-10-29 16:44:59 -04:00
|
|
|
"expected" in case
|
|
|
|
|
and isinstance(case["expected"], dict)
|
|
|
|
|
and "error" in case["expected"]
|
2019-07-29 23:42:12 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2019-08-06 08:35:53 -04:00
|
|
|
def has_error_case(cases):
|
|
|
|
|
cases = cases[:]
|
|
|
|
|
while cases:
|
|
|
|
|
case = cases.pop(0)
|
|
|
|
|
if error_case(case):
|
|
|
|
|
return True
|
|
|
|
|
cases.extend(case.get("cases", []))
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2019-10-22 10:50:01 -04:00
|
|
|
def regex_replace(s, find, repl):
|
|
|
|
|
return re.sub(find, repl, s)
|
|
|
|
|
|
|
|
|
|
|
2019-07-24 09:49:01 -04:00
|
|
|
def load_canonical(exercise, spec_path):
|
|
|
|
|
"""
|
|
|
|
|
Loads the canonical data for an exercise as a nested dictionary
|
|
|
|
|
"""
|
2019-10-29 16:44:59 -04:00
|
|
|
full_path = os.path.join(spec_path, "exercises", exercise, "canonical-data.json")
|
2019-07-24 09:49:01 -04:00
|
|
|
with open(full_path) as f:
|
2019-07-24 16:05:08 -04:00
|
|
|
spec = json.load(f)
|
|
|
|
|
spec["properties"] = get_tested_properties(spec)
|
|
|
|
|
return spec
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
|
2019-08-06 14:41:00 -04:00
|
|
|
def load_additional_tests(exercise):
|
|
|
|
|
"""
|
|
|
|
|
Loads additional tests from .meta/additional_tests.json
|
|
|
|
|
"""
|
2019-10-29 16:44:59 -04:00
|
|
|
full_path = os.path.join("exercises", exercise, ".meta", "additional_tests.json")
|
2019-08-06 14:41:00 -04:00
|
|
|
try:
|
|
|
|
|
with open(full_path) as f:
|
|
|
|
|
data = json.load(f)
|
|
|
|
|
return data.get("cases", [])
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
2019-07-24 09:49:01 -04:00
|
|
|
def format_file(path):
|
|
|
|
|
"""
|
|
|
|
|
Runs black auto-formatter on file at path
|
|
|
|
|
"""
|
2019-10-29 16:44:59 -04:00
|
|
|
check_call(["black", "-q", path])
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_exercise(env, spec_path, exercise, check=False):
|
|
|
|
|
"""
|
|
|
|
|
Renders test suite for exercise and if check is:
|
|
|
|
|
True: verifies that current tests file matches rendered
|
|
|
|
|
False: saves rendered to tests file
|
|
|
|
|
"""
|
|
|
|
|
slug = os.path.basename(exercise)
|
2019-10-29 16:44:59 -04:00
|
|
|
meta_dir = os.path.join(exercise, ".meta")
|
|
|
|
|
plugins_module = None
|
|
|
|
|
plugins_name = "plugins"
|
|
|
|
|
plugins_source = os.path.join(meta_dir, f"{plugins_name}.py")
|
2019-07-24 09:49:01 -04:00
|
|
|
try:
|
2019-10-29 16:44:59 -04:00
|
|
|
if os.path.isfile(plugins_source):
|
|
|
|
|
plugins_spec = importlib.util.spec_from_file_location(
|
|
|
|
|
plugins_name, plugins_source
|
|
|
|
|
)
|
|
|
|
|
plugins_module = importlib.util.module_from_spec(plugins_spec)
|
|
|
|
|
sys.modules[plugins_name] = plugins_module
|
|
|
|
|
plugins_spec.loader.exec_module(plugins_module)
|
2019-07-24 09:49:01 -04:00
|
|
|
spec = load_canonical(slug, spec_path)
|
2019-08-06 14:41:00 -04:00
|
|
|
additional_tests = load_additional_tests(slug)
|
2019-08-06 14:45:35 -04:00
|
|
|
spec["additional_cases"] = additional_tests
|
2019-10-29 16:44:59 -04:00
|
|
|
template_path = posixpath.join(slug, ".meta", "template.j2")
|
2019-08-07 09:45:02 -04:00
|
|
|
template = env.get_template(template_path)
|
2019-10-29 16:44:59 -04:00
|
|
|
tests_path = os.path.join(exercise, f"{to_snake(slug)}_test.py")
|
2019-08-07 09:45:02 -04:00
|
|
|
spec["has_error_case"] = has_error_case(spec["cases"])
|
2019-10-29 16:44:59 -04:00
|
|
|
if plugins_module is not None:
|
|
|
|
|
spec[plugins_name] = plugins_module
|
2019-11-06 13:39:53 -05:00
|
|
|
logger.debug(f"{slug}: attempting render")
|
2019-08-07 09:45:02 -04:00
|
|
|
rendered = template.render(**spec)
|
2019-10-29 16:44:59 -04:00
|
|
|
with NamedTemporaryFile("w", delete=False) as tmp:
|
2019-11-06 13:39:53 -05:00
|
|
|
logger.debug(f"{slug}: writing render to tmp file {tmp.name}")
|
2019-08-07 09:45:02 -04:00
|
|
|
tmp.write(rendered)
|
2019-10-15 18:47:37 +01:00
|
|
|
try:
|
2019-11-06 13:39:53 -05:00
|
|
|
logger.debug(f"{slug}: formatting tmp file {tmp.name}")
|
2019-10-15 18:47:37 +01:00
|
|
|
format_file(tmp.name)
|
|
|
|
|
except FileNotFoundError as e:
|
2019-10-29 16:44:59 -04:00
|
|
|
logger.error(f"{slug}: the black utility must be installed")
|
2019-10-15 18:47:37 +01:00
|
|
|
return False
|
|
|
|
|
|
2019-08-07 09:45:02 -04:00
|
|
|
if check:
|
|
|
|
|
try:
|
2019-11-06 13:39:53 -05:00
|
|
|
check_ok = True
|
|
|
|
|
if not os.path.isfile(tmp.name):
|
|
|
|
|
logger.debug(f"{slug}: tmp file {tmp.name} not found")
|
|
|
|
|
check_ok = False
|
|
|
|
|
if not os.path.isfile(tests_path):
|
|
|
|
|
logger.debug(f"{slug}: tests file {tests_path} not found")
|
|
|
|
|
check_ok = False
|
|
|
|
|
if check_ok and not filecmp.cmp(tmp.name, tests_path):
|
|
|
|
|
with open(tests_path) as f:
|
|
|
|
|
current_lines = f.readlines()
|
|
|
|
|
with open(tmp.name) as f:
|
|
|
|
|
rendered_lines = f.readlines()
|
|
|
|
|
diff = difflib.unified_diff(
|
|
|
|
|
current_lines,
|
|
|
|
|
rendered_lines,
|
|
|
|
|
fromfile=f"[current] {os.path.basename(tests_path)}",
|
|
|
|
|
tofile=f"[generated] {tmp.name}",
|
|
|
|
|
)
|
|
|
|
|
logger.debug(f"{slug}: ##### DIFF START #####")
|
|
|
|
|
for line in diff:
|
|
|
|
|
logger.debug(line.strip())
|
|
|
|
|
logger.debug(f"{slug}: ##### DIFF END #####")
|
|
|
|
|
check_ok = False
|
|
|
|
|
if not check_ok:
|
2019-10-29 16:44:59 -04:00
|
|
|
logger.error(
|
|
|
|
|
f"{slug}: check failed; tests must be regenerated with bin/generate_tests.py"
|
|
|
|
|
)
|
2019-08-07 09:45:02 -04:00
|
|
|
return False
|
2019-11-06 13:39:53 -05:00
|
|
|
logger.debug(f"{slug}: check passed")
|
2019-08-07 09:45:02 -04:00
|
|
|
finally:
|
2019-11-06 13:39:53 -05:00
|
|
|
logger.debug(f"{slug}: removing tmp file {tmp.name}")
|
2019-08-07 09:45:02 -04:00
|
|
|
os.remove(tmp.name)
|
|
|
|
|
else:
|
2019-11-06 13:39:53 -05:00
|
|
|
logger.debug(f"{slug}: moving tmp file {tmp.name}->{tests_path}")
|
2019-08-07 09:45:02 -04:00
|
|
|
shutil.move(tmp.name, tests_path)
|
2019-10-29 16:44:59 -04:00
|
|
|
print(f"{slug} generated at {tests_path}")
|
|
|
|
|
except (TypeError, UndefinedError, SyntaxError) as e:
|
2019-08-07 09:45:02 -04:00
|
|
|
logger.debug(str(e))
|
2019-10-29 16:44:59 -04:00
|
|
|
logger.error(f"{slug}: generation failed")
|
2019-08-07 09:45:02 -04:00
|
|
|
return False
|
|
|
|
|
except TemplateNotFound as e:
|
|
|
|
|
logger.debug(str(e))
|
2019-10-29 16:44:59 -04:00
|
|
|
logger.info(f"{slug}: no template found; skipping")
|
2019-07-24 16:05:08 -04:00
|
|
|
except FileNotFoundError as e:
|
|
|
|
|
logger.debug(str(e))
|
2019-10-29 16:44:59 -04:00
|
|
|
logger.info(f"{slug}: no canonical data found; skipping")
|
2019-08-07 09:45:02 -04:00
|
|
|
return True
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
|
2019-08-07 09:45:02 -04:00
|
|
|
def generate(
|
2019-10-29 16:44:59 -04:00
|
|
|
exercise_glob,
|
|
|
|
|
spec_path=DEFAULT_SPEC_LOCATION,
|
|
|
|
|
stop_on_failure=False,
|
|
|
|
|
check=False,
|
|
|
|
|
**kwargs,
|
2019-08-07 09:45:02 -04:00
|
|
|
):
|
2019-07-24 09:49:01 -04:00
|
|
|
"""
|
|
|
|
|
Primary entry point. Generates test files for all exercises matching exercise_glob
|
|
|
|
|
"""
|
2019-10-15 18:47:37 +01:00
|
|
|
# black must be installed or all test files will error
|
|
|
|
|
if not shutil.which("black"):
|
|
|
|
|
logger.error("the black utility must be installed")
|
|
|
|
|
sys.exit(1)
|
2019-10-29 16:44:59 -04:00
|
|
|
loader = FileSystemLoader(["config", "exercises"])
|
2019-07-24 09:49:01 -04:00
|
|
|
env = Environment(loader=loader, keep_trailing_newline=True)
|
2019-10-29 16:44:59 -04:00
|
|
|
env.filters["to_snake"] = to_snake
|
|
|
|
|
env.filters["camel_case"] = camel_case
|
2019-11-13 15:00:17 +00:00
|
|
|
env.filters["wrap_overlong"] = wrap_overlong
|
2019-10-29 16:44:59 -04:00
|
|
|
env.filters["regex_replace"] = regex_replace
|
|
|
|
|
env.tests["error_case"] = error_case
|
2019-08-07 09:45:02 -04:00
|
|
|
result = True
|
2019-11-06 14:38:57 -05:00
|
|
|
for exercise in sorted(glob(os.path.join("exercises", exercise_glob))):
|
2019-08-07 09:45:02 -04:00
|
|
|
if not generate_exercise(env, spec_path, exercise, check):
|
|
|
|
|
result = False
|
|
|
|
|
if stop_on_failure:
|
|
|
|
|
break
|
|
|
|
|
if not result:
|
|
|
|
|
sys.exit(1)
|
2019-07-24 09:49:01 -04:00
|
|
|
|
|
|
|
|
|
2019-10-29 16:44:59 -04:00
|
|
|
if __name__ == "__main__":
|
2019-07-24 09:49:01 -04:00
|
|
|
parser = argparse.ArgumentParser()
|
2019-10-29 16:44:59 -04:00
|
|
|
parser.add_argument("exercise_glob", nargs="?", default="*", metavar="EXERCISE")
|
2019-07-24 09:49:01 -04:00
|
|
|
parser.add_argument(
|
2019-10-29 16:44:59 -04:00
|
|
|
"--version",
|
|
|
|
|
action="version",
|
|
|
|
|
version="%(prog)s {} for Python {}".format(VERSION, sys.version.split("\n")[0]),
|
2019-07-24 09:49:01 -04:00
|
|
|
)
|
2019-10-29 16:44:59 -04:00
|
|
|
parser.add_argument("-v", "--verbose", action="store_true")
|
2019-08-08 12:49:02 -04:00
|
|
|
parser.add_argument(
|
2019-10-29 16:44:59 -04:00
|
|
|
"-p",
|
|
|
|
|
"--spec-path",
|
2019-08-08 12:49:02 -04:00
|
|
|
default=DEFAULT_SPEC_LOCATION,
|
|
|
|
|
help=(
|
2019-10-29 16:44:59 -04:00
|
|
|
"path to clone of exercism/problem-specifications " "(default: %(default)s)"
|
|
|
|
|
),
|
2019-08-08 12:49:02 -04:00
|
|
|
)
|
2019-10-29 16:44:59 -04:00
|
|
|
parser.add_argument("--stop-on-failure", action="store_true")
|
2019-08-08 12:49:02 -04:00
|
|
|
parser.add_argument(
|
2019-10-29 16:44:59 -04:00
|
|
|
"--check",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="check if tests are up-to-date, but do not modify test files",
|
2019-08-08 12:49:02 -04:00
|
|
|
)
|
2019-07-24 09:49:01 -04:00
|
|
|
opts = parser.parse_args()
|
|
|
|
|
if opts.verbose:
|
2019-07-24 16:05:08 -04:00
|
|
|
logger.setLevel(logging.DEBUG)
|
2019-07-24 09:49:01 -04:00
|
|
|
generate(**opts.__dict__)
|