From a10eca6d0083e269aab483505d9d87a55d524d84 Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Sat, 29 Sep 2018 22:19:39 +0100 Subject: [PATCH 01/11] [WIP] Very rough pass. --- generators/generate.py | 125 +++++++++++++++++++++++++++++++ generators/templates/__macros.j2 | 48 ++++++++++++ generators/templates/_default.j2 | 21 ++++++ requirements-generate.txt | 2 + 4 files changed, 196 insertions(+) create mode 100644 generators/generate.py create mode 100644 generators/templates/__macros.j2 create mode 100644 generators/templates/_default.j2 create mode 100644 requirements-generate.txt diff --git a/generators/generate.py b/generators/generate.py new file mode 100644 index 0000000000..89ddab2398 --- /dev/null +++ b/generators/generate.py @@ -0,0 +1,125 @@ +#!/usr/bin/python +import json +import os +import re +import logging +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +from pathlib import Path +from string import whitespace, punctuation +from itertools import repeat + +# 3rd party imports +from jinja2 import Environment, PackageLoader, select_autoescape +from yapf.yapflib.yapf_api import FormatCode + +logging.basicConfig(format='%(levelname)s: %(message)s') +logger = logging.getLogger(__name__) + +BADCHARS = str.maketrans(dict(zip(punctuation + whitespace, repeat("_")))) + +def _module_name(name): + return name.replace("-", "_") + +def _test_class_name(name): + return "".join("".join(word.title() for word in name.split("-")).split()) + +def _test_name(name): + return re.sub("_+", "_", name.lower().translate(BADCHARS)).strip("_") + +def _func_name(name): + return re.sub(r"([a-z][A-Z]+[a-z])", lambda m: m.group(1)[0] + "_" + m.group(1)[1:], name).lower() + +def _is_error_case(case): + expect = case.get("expected") + return expect and isinstance(expect, dict) and "error" in expect + +def _sanitize_data(data): + data["PY_MODULE"] = _module_name(data["exercise"]) + data["PY_FROM_IMPORTS"] = [] + + def _sanitize_case(case): + if "description" in case: + case["PY_TEST_NAME"] = _test_name(case["description"]) + if "property" in case: + func_name = case["PY_TEST_FUNC"] = _func_name(case["property"]) + if func_name not in data["PY_FROM_IMPORTS"]: + data["PY_FROM_IMPORTS"].append(func_name) + is_error = False + if "expected" in case: + is_error = case["PY_TEST_IS_ERROR"] = _is_error_case(case) + return is_error + + for case in data["cases"]: + # grouped tests + if "cases" in case: + case["PY_TEST_CLASS"] = _test_class_name(case["description"]) + case["PY_TEST_CLASS_HAS_ERROR"] = False + for grouped_case in case["cases"]: + if _sanitize_case(grouped_case) and not case["PY_TEST_CLASS_HAS_ERROR"]: + case["PY_TEST_CLASS_HAS_ERROR"] = True + case["PY_TEST_CLASS_NEEDS_SETUP"] = case["PY_TEST_CLASS_HAS_ERROR"] + # ungrouped tests + else: + if "PY_TEST_CLASS" not in data: + data["PY_TEST_CLASS"] = _test_class_name(data["exercise"]) + data["PY_TEST_CLASS_HAS_ERROR"] = False + if _sanitize_case(case) and not data.get("PY_BASE_TEST_CLASS_HAS_ERROR"): + data["PY_TEST_CLASS_HAS_ERROR"] = True + if "PY_TEST_CLASS" in data: + data["PY_TEST_CLASS_NEEDS_SETUP"] = data.get("PY_TEST_CLASS_HAS_ERROR", False) + return data + +def main(): + parser = ArgumentParser( + formatter_class=ArgumentDefaultsHelpFormatter + ) + parser.add_argument('-v', '--verbose', action='count') + parser.add_argument( + '-p', '--spec-path', default='../problem-specifications', type=Path + ) + parser.add_argument( + '-c', '--config', default='./config.json', type=Path + ) + parser.add_argument('exercises', nargs='*') + + args = parser.parse_args() + + # validate the config file + if not args.config.is_file(): + parser.error("config must be an extant file, got: {0.config}".format(args)) + elif args.config.suffix != ".json": + parser.error("config must be a *.json file, got: {0.config}".format(args)) + + # validate the spec path + if not args.spec_path.is_dir(): + parser.error("spec-path must be a directory, got: {0.spec_path}".format(args)) + + # set verbosity + if args.verbose: + logger.setLevel(logging.DEBUG if args.verbose > 1 else logging.INFO) + + # build the Jinja2 template environment + env = Environment( + loader=PackageLoader(__name__, 'templates'), + autoescape=select_autoescape([]) + ) + + # get the (possibly limited) list of exercises to generate + config = json.loads(args.config.read_text()) + exercises = {e["slug"] for e in config["exercises"] if not e.get("deprecated")} + if args.exercises: + exercises = set(args.exercises).intersection(exercises) + + for exercise in sorted(exercises): + canonical = args.spec_path.joinpath("exercises", exercise, "canonical-data.json") + if not canonical.is_file(): + logger.warning("No canonical data found for {0}: skipping".format(exercise)) + continue + data = _sanitize_data(json.loads(canonical.read_text())) + + template = env.select_template(["{0}.j2".format(exercise), "_default.j2"]) + rendered = template.render(**data) + print(FormatCode(template.render(**data), style_config="facebook")[0]) + +if __name__ == '__main__': + main() diff --git a/generators/templates/__macros.j2 b/generators/templates/__macros.j2 new file mode 100644 index 0000000000..5ab5d0332d --- /dev/null +++ b/generators/templates/__macros.j2 @@ -0,0 +1,48 @@ +{% macro import(modname, imports=[]) %} +{% if imports|length > 0 %} +from {{ modname }} import {{ imports|join(", ") }} +{% else %} +import {{ modname }} +{% endif %} +{% endmacro %} + +{% macro add_test(class_name, setup=False, error=False, cases=[]) %} +class {{ class_name }}Test(unittest.TestCase): + {%- if setup %} + {{ add_setup(error=error)|indent(4) }} + {% endif -%} + {%- for case in cases %} + def test_{{ case.PY_TEST_NAME }}(self): + {%- if not case.PY_IS_ERROR %} + {{- add_assert_equal(case.PY_TEST_FUNC, case.input, case.expected)|indent(8) }} + {% else %} + {{- add_assert_raises(case.PY_TEST_FUNC, case.input, case.expected.error)|indent(8) }} + {% endif %} + {% endfor %} +{% endmacro %} + +{% macro add_setup(error=False) %} +# Utility functions +def setUp(self): +{%- if error %} + try: + self.assertRaisesRegex + except AttributeError: + self.assertRaisesRegex = self.assertRaisesRegexp +{%- endif -%} +{% endmacro %} + +{% macro add_assert_equal(func, input, expect) %} +{% if not input %} +self.assertEqual({{ func }}(), "{{ expect }}") +{% elif input|length == 1 %} +self.assertEqual({{ func }}("{{ input.phrase }}"), "{{ expect }}") +{% else %} +FIXME +{% endif %} +{% endmacro %} + +{% macro add_assert_raises(func, input, error) %} +self.assertRaisesRegex({{ func }}(), "{{ error }}") +{% endmacro %} + diff --git a/generators/templates/_default.j2 b/generators/templates/_default.j2 new file mode 100644 index 0000000000..ddf9fb5d35 --- /dev/null +++ b/generators/templates/_default.j2 @@ -0,0 +1,21 @@ +{% import '__macros.j2' as macros %} +import unittest + +{{ macros.import(PY_MODULE, PY_FROM_IMPORTS) }} + +# Tests adapted from `problem-specifications//canonical-data.json` @ {{ version }} + +{% if PY_TEST_CLASS is defined %} +{{ macros.add_test(PY_TEST_CLASS, + setup=PY_TEST_CLASS_NEEDS_SETUP, + error=PY_TEST_CLASS_HAS_ERROR, + cases=cases|rejectattr(cases.cases)) }} +{% endif %} +{% for case in cases if case.cases %} +{{ macros.add_test(case.PY_TEST_CLASS, + setup=case.PY_TEST_CLASS_NEEDS_SETUP, + error=case.PY_TEST_CLASS_HAS_ERROR, + cases=case.cases) }} +{% endfor %} +if __name__ == '__main__': + unittest.main() diff --git a/requirements-generate.txt b/requirements-generate.txt new file mode 100644 index 0000000000..95154dfb87 --- /dev/null +++ b/requirements-generate.txt @@ -0,0 +1,2 @@ +Jinja2==2.10 +yapf==0.24.0 From cf254d3f5575293aa45f6fc116bcc8ffc091ef5f Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Tue, 2 Oct 2018 18:42:41 +0100 Subject: [PATCH 02/11] First (arguably) functional pass. --- generators/generate.py | 239 +++++++++++++++++++++---------- generators/templates/__macros.j2 | 57 ++++---- generators/templates/_default.j2 | 21 +-- 3 files changed, 195 insertions(+), 122 deletions(-) diff --git a/generators/generate.py b/generators/generate.py index 89ddab2398..c5ace49202 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -1,125 +1,206 @@ #!/usr/bin/python +""" +Generates exercise_test.py files for tests with canonical data. +""" import json -import os import re import logging -from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +from argparse import ArgumentParser from pathlib import Path -from string import whitespace, punctuation from itertools import repeat +from string import punctuation, whitespace -# 3rd party imports -from jinja2 import Environment, PackageLoader, select_autoescape -from yapf.yapflib.yapf_api import FormatCode +# 3rd party imports: +from jinja2 import Environment, PackageLoader, select_autoescape, TemplateError +from yapf.yapflib.yapf_api import FormatFile, style -logging.basicConfig(format='%(levelname)s: %(message)s') -logger = logging.getLogger(__name__) +logging.basicConfig(format="%(levelname)s: %(message)s") +LOGGER = logging.getLogger(__name__) -BADCHARS = str.maketrans(dict(zip(punctuation + whitespace, repeat("_")))) -def _module_name(name): - return name.replace("-", "_") +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)))) + return re.sub("{0}+".format(re.escape(rep)), rep, + string.translate(trans)).strip(rep) -def _test_class_name(name): - return "".join("".join(word.title() for word in name.split("-")).split()) -def _test_name(name): - return re.sub("_+", "_", name.lower().translate(BADCHARS)).strip("_") +def to_snake(string): + """ + Convert pretty much anything to snake_case. + """ + 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 _func_name(name): - return re.sub(r"([a-z][A-Z]+[a-z])", lambda m: m.group(1)[0] + "_" + m.group(1)[1:], name).lower() -def _is_error_case(case): +def to_camel(string): + """ + Convert pretty much anything to CamelCase. + """ + return "".join(w.title() for w in to_snake(string).split("_")) + + +def is_error(case): + """ + Determines if the given case is an error. + """ expect = case.get("expected") return expect and isinstance(expect, dict) and "error" in expect -def _sanitize_data(data): - data["PY_MODULE"] = _module_name(data["exercise"]) - data["PY_FROM_IMPORTS"] = [] - - def _sanitize_case(case): - if "description" in case: - case["PY_TEST_NAME"] = _test_name(case["description"]) - if "property" in case: - func_name = case["PY_TEST_FUNC"] = _func_name(case["property"]) - if func_name not in data["PY_FROM_IMPORTS"]: - data["PY_FROM_IMPORTS"].append(func_name) - is_error = False - if "expected" in case: - is_error = case["PY_TEST_IS_ERROR"] = _is_error_case(case) - return is_error - - for case in data["cases"]: - # grouped tests - if "cases" in case: - case["PY_TEST_CLASS"] = _test_class_name(case["description"]) - case["PY_TEST_CLASS_HAS_ERROR"] = False - for grouped_case in case["cases"]: - if _sanitize_case(grouped_case) and not case["PY_TEST_CLASS_HAS_ERROR"]: - case["PY_TEST_CLASS_HAS_ERROR"] = True - case["PY_TEST_CLASS_NEEDS_SETUP"] = case["PY_TEST_CLASS_HAS_ERROR"] - # ungrouped tests - else: - if "PY_TEST_CLASS" not in data: - data["PY_TEST_CLASS"] = _test_class_name(data["exercise"]) - data["PY_TEST_CLASS_HAS_ERROR"] = False - if _sanitize_case(case) and not data.get("PY_BASE_TEST_CLASS_HAS_ERROR"): - data["PY_TEST_CLASS_HAS_ERROR"] = True - if "PY_TEST_CLASS" in data: - data["PY_TEST_CLASS_NEEDS_SETUP"] = data.get("PY_TEST_CLASS_HAS_ERROR", False) - return data + +def has_error(data): + """ + Check if any case in the data is an error. + """ + if "cases" in data: + for case in data["cases"]: + if has_error(case): + return True + return False + return is_error(data) + + +def get_properties(data): + """ + Get the list of the unique property values accessed in all tests. + """ + + def _get_properties(obj): + if "cases" in obj: + for case in obj["cases"]: + yield from get_properties(case) + return + yield obj["property"] + + return sorted(set(_get_properties(data))) + + +def format_input(case): + """ + Format the case's input values for use in a function call. + """ + raw = case["input"] + if len(raw) == 1: + return repr(list(raw.values())[0]) + if any(k.isdigit() for k in raw): + return repr(raw) + return ", ".join("{0}={1!r}".format(*i) for i in raw.items()) + + +def format_expect(case): + """ + Format the case's expected values for use in a function call. + """ + if is_error(case): + return repr(case["expected"]["error"]) + return repr(case["expected"]) + def main(): - parser = ArgumentParser( - formatter_class=ArgumentDefaultsHelpFormatter - ) - parser.add_argument('-v', '--verbose', action='count') + """ + Provide the CLI. + """ + parser = ArgumentParser() + parser.add_argument("-v", "--verbose", action="count") parser.add_argument( - '-p', '--spec-path', default='../problem-specifications', type=Path + "-p", + "--spec-path", + type=Path, + default="../problem-specifications", + help="path to the problem specifications directory: (%(default)s)", ) parser.add_argument( - '-c', '--config', default='./config.json', type=Path - ) - parser.add_argument('exercises', nargs='*') - + "-c", + "--config", + type=Path, + default="./config.json", + help="path to the Python track config.json file: (%(default)s)") + parser.add_argument( + "-o", + "--output", + type=Path, + default="./exercises", + help="path to the output directory: (%(default)s)") + parser.add_argument( + "-s", + "--style", + default=style.DEFAULT_STYLE, + help="yapf autoreformat style to use: (%(default)s)") + parser.add_argument("exercises", nargs="*") + args = parser.parse_args() # validate the config file if not args.config.is_file(): - parser.error("config must be an extant file, got: {0.config}".format(args)) + parser.error( + "config must be an extant file, got: {0.config}".format(args)) elif args.config.suffix != ".json": - parser.error("config must be a *.json file, got: {0.config}".format(args)) + parser.error( + "config must be a *.json file, got: {0.config}".format(args)) # validate the spec path if not args.spec_path.is_dir(): - parser.error("spec-path must be a directory, got: {0.spec_path}".format(args)) + parser.error( + "spec-path must be a directory, got: {0.spec_path}".format(args)) # set verbosity if args.verbose: - logger.setLevel(logging.DEBUG if args.verbose > 1 else logging.INFO) + LOGGER.setLevel(logging.DEBUG if args.verbose > 1 else logging.INFO) # build the Jinja2 template environment env = Environment( - loader=PackageLoader(__name__, 'templates'), - autoescape=select_autoescape([]) - ) + loader=PackageLoader(__name__, "templates"), + autoescape=select_autoescape([])) + env.filters["to_snake"] = to_snake + env.filters["to_camel"] = to_camel + env.filters["is_error"] = is_error + env.filters["format_input"] = format_input + env.filters["format_expect"] = format_expect # get the (possibly limited) list of exercises to generate config = json.loads(args.config.read_text()) - exercises = {e["slug"] for e in config["exercises"] if not e.get("deprecated")} + exercises = { + e["slug"] + for e in config["exercises"] if not e.get("deprecated") + } if args.exercises: exercises = set(args.exercises).intersection(exercises) for exercise in sorted(exercises): - canonical = args.spec_path.joinpath("exercises", exercise, "canonical-data.json") + canonical = args.spec_path.joinpath("exercises", exercise, + "canonical-data.json") if not canonical.is_file(): - logger.warning("No canonical data found for {0}: skipping".format(exercise)) + LOGGER.warning("No canonical data found for %s: skipping", + exercise) + continue + data = json.loads(canonical.read_text(), object_pairs_hook=dict) + data["has_error"] = has_error(data) + data["properties"] = get_properties(data) + template = env.select_template( + ["{0}.j2".format(exercise), "_default.j2"]) + try: + code = template.render(data=data) + out_dir = args.output.joinpath(exercise) + if not out_dir.exists(): + out_dir.mkdir(parents=True) + out_file = out_dir.joinpath("{0}_test.py".format( + to_snake(exercise))) + out_file.write_text(code) + try: + FormatFile( + str(out_file), + style_config=args.style, + in_place=True, + verify=True) + except SyntaxError: + LOGGER.exception("Unable to reformat %s: skipping", exercise) + except TemplateError: + LOGGER.exception("Unable to render %s: skipping", exercise) continue - data = _sanitize_data(json.loads(canonical.read_text())) - - template = env.select_template(["{0}.j2".format(exercise), "_default.j2"]) - rendered = template.render(**data) - print(FormatCode(template.render(**data), style_config="facebook")[0]) - -if __name__ == '__main__': + + +if __name__ == "__main__": main() diff --git a/generators/templates/__macros.j2 b/generators/templates/__macros.j2 index 5ab5d0332d..b19306213a 100644 --- a/generators/templates/__macros.j2 +++ b/generators/templates/__macros.j2 @@ -1,24 +1,31 @@ -{% macro import(modname, imports=[]) %} -{% if imports|length > 0 %} -from {{ modname }} import {{ imports|join(", ") }} -{% else %} -import {{ modname }} +{% macro make_import(data) %} +{%- if data.properties | length > 0 %} +from {{ data.exercise | to_snake }} import {{ data.properties | map("to_snake") | join(", ") }} +{%- else %} +import {{ data.exercise | to_snake }} {% endif %} {% endmacro %} -{% macro add_test(class_name, setup=False, error=False, cases=[]) %} -class {{ class_name }}Test(unittest.TestCase): - {%- if setup %} - {{ add_setup(error=error)|indent(4) }} - {% endif -%} - {%- for case in cases %} - def test_{{ case.PY_TEST_NAME }}(self): - {%- if not case.PY_IS_ERROR %} - {{- add_assert_equal(case.PY_TEST_FUNC, case.input, case.expected)|indent(8) }} - {% else %} - {{- add_assert_raises(case.PY_TEST_FUNC, case.input, case.expected.error)|indent(8) }} +{% macro make_tests(data) %} +class {{ data.exercise | to_camel }}Test(unittest.TestCase): + {%- if data.has_error %} + {{ add_setup(error=data.has_error) | indent(4) }} + {% endif %} + {%- for case in data.cases recursive %} + {%- if case.cases %} + {%- if loop.nextitem is defined %} + {{ "#" * loop.depth }} {{ case.description }} {% endif %} - {% endfor %} + {{- loop(case.cases) }} + {%- else %} + def test_{{ case.description | to_snake }}(self): + {%- if case | is_error %} + {{- add_assert_raises(case) | indent(12) }} + {%- else %} + {{- add_assert_equal(case) | indent(12) }} + {% endif %} + {%- endif %} + {%- endfor %} {% endmacro %} {% macro add_setup(error=False) %} @@ -32,17 +39,15 @@ def setUp(self): {%- endif -%} {% endmacro %} -{% macro add_assert_equal(func, input, expect) %} -{% if not input %} -self.assertEqual({{ func }}(), "{{ expect }}") -{% elif input|length == 1 %} -self.assertEqual({{ func }}("{{ input.phrase }}"), "{{ expect }}") -{% else %} -FIXME +{% macro add_assert_equal(case) %} +{%- if not case.input %} +self.assertEqual({{ case.property | to_snake }}(), {{ case | format_expect }}) +{%- else %} +self.assertEqual({{ case.property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) {% endif %} {% endmacro %} -{% macro add_assert_raises(func, input, error) %} -self.assertRaisesRegex({{ func }}(), "{{ error }}") +{% macro add_assert_raises(case) %} +self.assertRaisesRegex({{ case.property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) {% endmacro %} diff --git a/generators/templates/_default.j2 b/generators/templates/_default.j2 index ddf9fb5d35..2d98a3e572 100644 --- a/generators/templates/_default.j2 +++ b/generators/templates/_default.j2 @@ -1,21 +1,8 @@ -{% import '__macros.j2' as macros %} +{% from '__macros.j2' import make_import, make_tests %} import unittest +{{ make_import(data) }} -{{ macros.import(PY_MODULE, PY_FROM_IMPORTS) }} - -# Tests adapted from `problem-specifications//canonical-data.json` @ {{ version }} - -{% if PY_TEST_CLASS is defined %} -{{ macros.add_test(PY_TEST_CLASS, - setup=PY_TEST_CLASS_NEEDS_SETUP, - error=PY_TEST_CLASS_HAS_ERROR, - cases=cases|rejectattr(cases.cases)) }} -{% endif %} -{% for case in cases if case.cases %} -{{ macros.add_test(case.PY_TEST_CLASS, - setup=case.PY_TEST_CLASS_NEEDS_SETUP, - error=case.PY_TEST_CLASS_HAS_ERROR, - cases=case.cases) }} -{% endfor %} +# Tests adapted from `problem-specifications//canonical-data.json` @ {{ data.version }} +{{ make_tests(data) }} if __name__ == '__main__': unittest.main() From b4711266f24019099c7d87de378af91cdf2c7d2b Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Mon, 8 Oct 2018 18:38:09 +0100 Subject: [PATCH 03/11] fix for py2.7 --- generators/generate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/generators/generate.py b/generators/generate.py index c5ace49202..b3e6654224 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -71,7 +71,8 @@ def get_properties(data): def _get_properties(obj): if "cases" in obj: for case in obj["cases"]: - yield from get_properties(case) + for prop in get_properties(case): + yield prop return yield obj["property"] From 989bc33c7bdabeb589d66511d7395170e38ba3e1 Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Mon, 8 Oct 2018 21:38:27 +0100 Subject: [PATCH 04/11] fixing requested cmccandless changes --- generators/generate.py | 4 ++-- generators/templates/_default.j2 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/generators/generate.py b/generators/generate.py index b3e6654224..22de92bcb2 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -100,7 +100,7 @@ def format_expect(case): return repr(case["expected"]) -def main(): +def main(args=None): """ Provide the CLI. """ @@ -132,7 +132,7 @@ def main(): help="yapf autoreformat style to use: (%(default)s)") parser.add_argument("exercises", nargs="*") - args = parser.parse_args() + args = parser.parse_args(args) # validate the config file if not args.config.is_file(): diff --git a/generators/templates/_default.j2 b/generators/templates/_default.j2 index 2d98a3e572..102301f06a 100644 --- a/generators/templates/_default.j2 +++ b/generators/templates/_default.j2 @@ -2,7 +2,7 @@ import unittest {{ make_import(data) }} -# Tests adapted from `problem-specifications//canonical-data.json` @ {{ data.version }} +# Tests adapted from `problem-specifications//canonical-data.json` @ v{{ data.version }} {{ make_tests(data) }} if __name__ == '__main__': unittest.main() From db3cf96a452c83f4a9a44bf7b2bc385ab6a42dc9 Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Mon, 15 Oct 2018 18:26:05 +0100 Subject: [PATCH 05/11] Exceptions, Input, and Expect fixes Fix whitespace formatting for exception handling. Handles a few different types of canonical input and expect kinds, but this will need additional thought. Should expand the number of tests that pass after auto-generation. --- generators/generate.py | 54 ++++++++++++++++++++++---------- generators/templates/__macros.j2 | 53 +++++++++++++++++-------------- generators/templates/_default.j2 | 6 +++- 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/generators/generate.py b/generators/generate.py index 22de92bcb2..dc4309978b 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -6,6 +6,7 @@ import re import logging from argparse import ArgumentParser +from collections import OrderedDict from pathlib import Path from itertools import repeat from string import punctuation, whitespace @@ -79,16 +80,26 @@ def _get_properties(obj): return sorted(set(_get_properties(data))) -def format_input(case): +def format_input(case, n_args=None): """ Format the case's input values for use in a function call. + + By default converts all arguments to positional form. + + If n_args is provided, convert only the first n argss to positional form. """ - raw = case["input"] - if len(raw) == 1: - return repr(list(raw.values())[0]) - if any(k.isdigit() for k in raw): - return repr(raw) - return ", ".join("{0}={1!r}".format(*i) for i in raw.items()) + raw = list(case["input"].items()) + n_args = len(raw) if n_args is None else n_args + result = [] + for _ in range(n_args): + arg = raw.pop(0)[1] + if isinstance(arg, dict): + result.extend(map(repr, arg.values())) + else: + result.append(repr(arg)) + while raw: + result.append("{0}={1!s}".format(raw.pop(0))) + return ", ".join(result) def format_expect(case): @@ -97,6 +108,9 @@ def format_expect(case): """ if is_error(case): return repr(case["expected"]["error"]) + expected = case["expected"] + if isinstance(expected, dict): + return "{" + ", ".join("{0!r}: {1!r}".format(*i) for i in expected.items()) + "}" return repr(case["expected"]) @@ -125,6 +139,11 @@ def main(args=None): type=Path, default="./exercises", help="path to the output directory: (%(default)s)") + parser.add_argument( + "-n", + "--no-autoformat", + action="store_true", + help="disable yapf autoreformat") parser.add_argument( "-s", "--style", @@ -150,7 +169,7 @@ def main(args=None): # set verbosity if args.verbose: LOGGER.setLevel(logging.DEBUG if args.verbose > 1 else logging.INFO) - + # build the Jinja2 template environment env = Environment( loader=PackageLoader(__name__, "templates"), @@ -177,7 +196,7 @@ def main(args=None): LOGGER.warning("No canonical data found for %s: skipping", exercise) continue - data = json.loads(canonical.read_text(), object_pairs_hook=dict) + data = json.loads(canonical.read_text(), object_pairs_hook=OrderedDict) data["has_error"] = has_error(data) data["properties"] = get_properties(data) template = env.select_template( @@ -190,14 +209,15 @@ def main(args=None): out_file = out_dir.joinpath("{0}_test.py".format( to_snake(exercise))) out_file.write_text(code) - try: - FormatFile( - str(out_file), - style_config=args.style, - in_place=True, - verify=True) - except SyntaxError: - LOGGER.exception("Unable to reformat %s: skipping", exercise) + if not args.no_autoformat: + try: + FormatFile( + str(out_file), + style_config=args.style, + in_place=True, + verify=True) + except SyntaxError: + LOGGER.exception("Unable to reformat %s: skipping", exercise) except TemplateError: LOGGER.exception("Unable to render %s: skipping", exercise) continue diff --git a/generators/templates/__macros.j2 b/generators/templates/__macros.j2 index b19306213a..94cfc7e9ef 100644 --- a/generators/templates/__macros.j2 +++ b/generators/templates/__macros.j2 @@ -1,31 +1,31 @@ {% macro make_import(data) %} -{%- if data.properties | length > 0 %} +{% if data.properties | length > 0 %} from {{ data.exercise | to_snake }} import {{ data.properties | map("to_snake") | join(", ") }} -{%- else %} +{% else %} import {{ data.exercise | to_snake }} {% endif %} {% endmacro %} {% macro make_tests(data) %} class {{ data.exercise | to_camel }}Test(unittest.TestCase): - {%- if data.has_error %} - {{ add_setup(error=data.has_error) | indent(4) }} - {% endif %} - {%- for case in data.cases recursive %} - {%- if case.cases %} - {%- if loop.nextitem is defined %} - {{ "#" * loop.depth }} {{ case.description }} - {% endif %} - {{- loop(case.cases) }} - {%- else %} + {% for case in data.cases recursive %} + {% if case.cases %} + {#% if loop.nextitem is defined %#} + {#{ "#" * loop.depth }} {{ case.description }#} + {#% endif %#} + {{ loop(case.cases) }} + {% else %} def test_{{ case.description | to_snake }}(self): - {%- if case | is_error %} + {% if case | is_error %} {{- add_assert_raises(case) | indent(12) }} - {%- else %} + {% else %} {{- add_assert_equal(case) | indent(12) }} {% endif %} - {%- endif %} - {%- endfor %} + {% endif %} + {% endfor %} + {% if data.has_error %} + {{ add_setup(error=data.has_error) | indent(8) }} + {% endif %} {% endmacro %} {% macro add_setup(error=False) %} @@ -36,18 +36,23 @@ def setUp(self): self.assertRaisesRegex except AttributeError: self.assertRaisesRegex = self.assertRaisesRegexp -{%- endif -%} +{% endif %} +{% if error %} +def assertRaisesWithMessage(self, exception): + return self.assertRaisesRegex(exception, r".+") +{% endif %} {% endmacro %} -{% macro add_assert_equal(case) %} -{%- if not case.input %} -self.assertEqual({{ case.property | to_snake }}(), {{ case | format_expect }}) -{%- else %} -self.assertEqual({{ case.property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) +{% macro add_assert_equal(case) -%} +{% if not case.input -%} + self.assertEqual({{ case.property | to_snake }}(), {{ case | format_expect }}) +{% else -%} + self.assertEqual({{ case.property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) {% endif %} {% endmacro %} -{% macro add_assert_raises(case) %} -self.assertRaisesRegex({{ case.property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) +{% macro add_assert_raises(case) -%} + with self.assertRaisesWithMessage(ValueError): + {{ case.property | to_snake }}({{ case | format_input }}) {% endmacro %} diff --git a/generators/templates/_default.j2 b/generators/templates/_default.j2 index 102301f06a..72526ab75b 100644 --- a/generators/templates/_default.j2 +++ b/generators/templates/_default.j2 @@ -1,8 +1,12 @@ -{% from '__macros.j2' import make_import, make_tests %} +{%- from '__macros.j2' import make_import, make_tests -%} import unittest + {{ make_import(data) }} + # Tests adapted from `problem-specifications//canonical-data.json` @ v{{ data.version }} + {{ make_tests(data) }} + if __name__ == '__main__': unittest.main() From c999dc0b4c36394be7b82fdbe6c127cf3b6a8466 Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Sun, 9 Dec 2018 22:47:11 +0000 Subject: [PATCH 06/11] python-generator: per-exercise configuration First pass at per-exercise configuration; it's crude and we still need to figure out how to map canonical properties to classes, as well as needing a few different ways of handling argument variations ... but it's getting a surprisingly large number of them to passing. --- .gitignore | 3 + exercises/alphametics/.meta/generate.yml | 4 + .../armstrong-numbers/.meta/generate.yml | 2 + exercises/atbash-cipher/.meta/generate.yml | 5 + exercises/binary-search/.meta/generate.yml | 2 + exercises/bob/.meta/generate.yml | 2 + exercises/book-store/.meta/generate.yml | 2 + exercises/change/.meta/generate.yml | 2 + .../collatz-conjecture/.meta/generate.yml | 2 + exercises/crypto-square/.meta/generate.yml | 2 + exercises/diamond/.meta/generate.yml | 2 + .../difference-of-squares/.meta/generate.yml | 2 + exercises/flatten-array/.meta/generate.yml | 10 ++ exercises/grains/.meta/generate.yml | 22 ++++ exercises/hamming/.meta/generate.yml | 63 ++++++++++ exercises/isbn-verifier/.meta/generate.yml | 2 + exercises/isogram/.meta/generate.yml | 6 + .../largest-series-product/.meta/generate.yml | 27 +++++ exercises/leap/.meta/generate.yml | 2 + exercises/markdown/.meta/generate.yml | 2 + exercises/minesweeper/.meta/generate.yml | 20 ++++ exercises/nth-prime/.meta/generate.yml | 2 + .../palindrome-products/.meta/generate.yml | 3 + exercises/prime-factors/.meta/generate.yml | 2 + exercises/rectangles/.meta/generate.yml | 2 + exercises/roman-numerals/.meta/generate.yml | 2 + exercises/secret-handshake/.meta/generate.yml | 2 + exercises/sieve/.meta/generate.yml | 2 + exercises/spiral-matrix/.meta/generate.yml | 2 + exercises/sum-of-multiples/.meta/generate.yml | 2 + exercises/triangle/.meta/generate.yml | 4 + exercises/word-count/.meta/generate.yml | 29 +++++ exercises/wordy/.meta/generate.yml | 15 +++ generators/generate.py | 111 +++++++++++++++--- generators/templates/__macros.j2 | 90 ++++++++------ generators/templates/_default.j2 | 3 +- requirements-generate.txt | 2 + 37 files changed, 404 insertions(+), 53 deletions(-) create mode 100644 exercises/alphametics/.meta/generate.yml create mode 100644 exercises/armstrong-numbers/.meta/generate.yml create mode 100644 exercises/atbash-cipher/.meta/generate.yml create mode 100644 exercises/binary-search/.meta/generate.yml create mode 100644 exercises/bob/.meta/generate.yml create mode 100644 exercises/book-store/.meta/generate.yml create mode 100644 exercises/change/.meta/generate.yml create mode 100644 exercises/collatz-conjecture/.meta/generate.yml create mode 100644 exercises/crypto-square/.meta/generate.yml create mode 100644 exercises/diamond/.meta/generate.yml create mode 100644 exercises/difference-of-squares/.meta/generate.yml create mode 100644 exercises/flatten-array/.meta/generate.yml create mode 100644 exercises/grains/.meta/generate.yml create mode 100644 exercises/hamming/.meta/generate.yml create mode 100644 exercises/isbn-verifier/.meta/generate.yml create mode 100644 exercises/isogram/.meta/generate.yml create mode 100644 exercises/largest-series-product/.meta/generate.yml create mode 100644 exercises/leap/.meta/generate.yml create mode 100644 exercises/markdown/.meta/generate.yml create mode 100644 exercises/minesweeper/.meta/generate.yml create mode 100644 exercises/nth-prime/.meta/generate.yml create mode 100644 exercises/palindrome-products/.meta/generate.yml create mode 100644 exercises/prime-factors/.meta/generate.yml create mode 100644 exercises/rectangles/.meta/generate.yml create mode 100644 exercises/roman-numerals/.meta/generate.yml create mode 100644 exercises/secret-handshake/.meta/generate.yml create mode 100644 exercises/sieve/.meta/generate.yml create mode 100644 exercises/spiral-matrix/.meta/generate.yml create mode 100644 exercises/sum-of-multiples/.meta/generate.yml create mode 100644 exercises/triangle/.meta/generate.yml create mode 100644 exercises/word-count/.meta/generate.yml create mode 100644 exercises/wordy/.meta/generate.yml diff --git a/.gitignore b/.gitignore index edce8452bf..7597bbec44 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ bin/configlet.exe .idea/ .cache __pycache__ + +# virtual environments +venv diff --git a/exercises/alphametics/.meta/generate.yml b/exercises/alphametics/.meta/generate.yml new file mode 100644 index 0000000000..07359c97f7 --- /dev/null +++ b/exercises/alphametics/.meta/generate.yml @@ -0,0 +1,4 @@ +skip_tests: + - description: "puzzle with ten letters and 199 addends" + property: "solve" + message: "extra-credit" diff --git a/exercises/armstrong-numbers/.meta/generate.yml b/exercises/armstrong-numbers/.meta/generate.yml new file mode 100644 index 0000000000..3398efcfc8 --- /dev/null +++ b/exercises/armstrong-numbers/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + isArmstrongNumber: is_armstrong diff --git a/exercises/atbash-cipher/.meta/generate.yml b/exercises/atbash-cipher/.meta/generate.yml new file mode 100644 index 0000000000..74a9f415c5 --- /dev/null +++ b/exercises/atbash-cipher/.meta/generate.yml @@ -0,0 +1,5 @@ +cases: + - description: "encode decode" + property: "decode" + input: 'encode("Testing, 1 2 3, testing.")' + expected: "testing123testing" diff --git a/exercises/binary-search/.meta/generate.yml b/exercises/binary-search/.meta/generate.yml new file mode 100644 index 0000000000..e2301881fd --- /dev/null +++ b/exercises/binary-search/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + find: binary_search diff --git a/exercises/bob/.meta/generate.yml b/exercises/bob/.meta/generate.yml new file mode 100644 index 0000000000..7c23231519 --- /dev/null +++ b/exercises/bob/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + response: hey diff --git a/exercises/book-store/.meta/generate.yml b/exercises/book-store/.meta/generate.yml new file mode 100644 index 0000000000..3682ce07de --- /dev/null +++ b/exercises/book-store/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + total: calculate_total diff --git a/exercises/change/.meta/generate.yml b/exercises/change/.meta/generate.yml new file mode 100644 index 0000000000..c143ed4705 --- /dev/null +++ b/exercises/change/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + findFewestCoins: find_minimum_coins diff --git a/exercises/collatz-conjecture/.meta/generate.yml b/exercises/collatz-conjecture/.meta/generate.yml new file mode 100644 index 0000000000..cf2ba9ff01 --- /dev/null +++ b/exercises/collatz-conjecture/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + steps: collatz_steps diff --git a/exercises/crypto-square/.meta/generate.yml b/exercises/crypto-square/.meta/generate.yml new file mode 100644 index 0000000000..ea570e0bed --- /dev/null +++ b/exercises/crypto-square/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + ciphertext: encode diff --git a/exercises/diamond/.meta/generate.yml b/exercises/diamond/.meta/generate.yml new file mode 100644 index 0000000000..eabecdf072 --- /dev/null +++ b/exercises/diamond/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + rows: make_diamond diff --git a/exercises/difference-of-squares/.meta/generate.yml b/exercises/difference-of-squares/.meta/generate.yml new file mode 100644 index 0000000000..a1face7271 --- /dev/null +++ b/exercises/difference-of-squares/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + differenceOfSquares: difference diff --git a/exercises/flatten-array/.meta/generate.yml b/exercises/flatten-array/.meta/generate.yml new file mode 100644 index 0000000000..df3fde0a90 --- /dev/null +++ b/exercises/flatten-array/.meta/generate.yml @@ -0,0 +1,10 @@ +cases: + - description: "empty nested lists" + property: "flatten" + input: '[[()]]' + expected: [] + - description: "strings" + property: "flatten" + input: + array: ['0', ['1', '2']] + expected: ['0', '1', '2'] diff --git a/exercises/grains/.meta/generate.yml b/exercises/grains/.meta/generate.yml new file mode 100644 index 0000000000..d5e8d9ff41 --- /dev/null +++ b/exercises/grains/.meta/generate.yml @@ -0,0 +1,22 @@ +properties_map: + square: on_square + total: total_after +cases: + - description: "total of square 0 raises an exception" + property: "total" + input: + square: 0 + expected: + error: "square must be between 1 and 64" + - description: "total of negative square raises an exception" + property: "square" + input: + square: -1 + expected: + error: "square must be between 1 and 64" + - description: "square greater than 64 raises an exception" + property: "square" + input: + square: 65 + expected: + error: "square must be between 1 and 64" diff --git a/exercises/hamming/.meta/generate.yml b/exercises/hamming/.meta/generate.yml new file mode 100644 index 0000000000..c891a37e78 --- /dev/null +++ b/exercises/hamming/.meta/generate.yml @@ -0,0 +1,63 @@ +cases: + - description: "complete distance in single nucleotide strands" + property: "distance" + input: + strand1: "A" + strand2: "G" + expected: 1 + + - description: "complete distance in small strands" + property: "distance" + input: + strand1: "AG" + strand2: "CT" + expected: 2 + + - description: "small distance in small strands" + property: "distance" + input: + strand1: "AT" + strand2: "CT" + expected: 1 + + - description: "small distance" + property: "distance" + input: + strand1: "GGACG" + strand2: "GGTCG" + expected: 1 + + - description: "small distance in long strands" + property: "distance" + input: + strand1: "ACCAGGG" + strand2: "ACTATGG" + expected: 2 + + - description: "non unique character in first strand" + property: "distance" + input: + strand1: "AAG" + strand2: "AAA" + expected: 1 + + - description: "non unique character in second strand" + property: "distance" + input: + strand1: "AAA" + strand2: "AAG" + expected: 1 + + - description: "same nucleotides in different positions" + property: "distance" + input: + strand1: "TAG" + strand2: "GAT" + expected: 2 + + - description: "large distance" + property: "distance" + input: + strand1: "GATACA" + strand2: "GCATAA" + expected: 4 diff --git a/exercises/isbn-verifier/.meta/generate.yml b/exercises/isbn-verifier/.meta/generate.yml new file mode 100644 index 0000000000..e7bec34491 --- /dev/null +++ b/exercises/isbn-verifier/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + isValid: verify diff --git a/exercises/isogram/.meta/generate.yml b/exercises/isogram/.meta/generate.yml new file mode 100644 index 0000000000..04762452e5 --- /dev/null +++ b/exercises/isogram/.meta/generate.yml @@ -0,0 +1,6 @@ +cases: + - description: "duplicated letter and nonletter character" + property: "isIsogram" + input: + phrase: "Aleph Bot Chap" + expected: false diff --git a/exercises/largest-series-product/.meta/generate.yml b/exercises/largest-series-product/.meta/generate.yml new file mode 100644 index 0000000000..6c54735538 --- /dev/null +++ b/exercises/largest-series-product/.meta/generate.yml @@ -0,0 +1,27 @@ +skip_tests: + - description: "project euler big number" + property: "largestProduct" + message: "extra-credit" +cases: + - description: "project euler big number" + property: "largestProduct" + input: + digits: + "73167176531330624919225119674426574742355349194934969835203127745\ + 06326239578318016984801869478851843858615607891129494954595017379\ + 58331952853208805511125406987471585238630507156932909632952274430\ + 43557668966489504452445231617318564030987111217223831136222989342\ + 33803081353362766142828064444866452387493035890729629049156044077\ + 23907138105158593079608667017242712188399879790879227492190169972\ + 08880937766572733300105336788122023542180975125454059475224352584\ + 90771167055601360483958644670632441572215539753697817977846174064\ + 95514929086256932197846862248283972241375657056057490261407972968\ + 65241453510047482166370484403199890008895243450658541227588666881\ + 16427171479924442928230863465674813919123162824586178664583591245\ + 66529476545682848912883142607690042242190226710556263211111093705\ + 44217506941658960408071984038509624554443629812309878799272442849\ + 09188845801561660979191338754992005240636899125607176060588611646\ + 71094050775410022569831552000559357297257163626956188267042825248\ + 3600823257530420752963450" + span: 13 + expected: 23514624000 diff --git a/exercises/leap/.meta/generate.yml b/exercises/leap/.meta/generate.yml new file mode 100644 index 0000000000..3edf968fc6 --- /dev/null +++ b/exercises/leap/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + leapYear: is_leap_year diff --git a/exercises/markdown/.meta/generate.yml b/exercises/markdown/.meta/generate.yml new file mode 100644 index 0000000000..57f63937fd --- /dev/null +++ b/exercises/markdown/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + parse: parse_markdown diff --git a/exercises/minesweeper/.meta/generate.yml b/exercises/minesweeper/.meta/generate.yml new file mode 100644 index 0000000000..11c7a2ae94 --- /dev/null +++ b/exercises/minesweeper/.meta/generate.yml @@ -0,0 +1,20 @@ +properties_map: + annotate: board +cases: + - description: "board9" + property: "annotate" + input: + minefield: [" ", " * ", " ", " ", " * "] + expected: [" 111", " 1*1", " 111", "111 ", "1*1 "] + - description: "different length" + property: "annotate" + input: + minefield: [" ", "* ", " "] + expected: + error: "Board rows must be of the same length" + - description: "invalid character" + property: "annotate" + input: + minefield: ["X * "] + expected: + error: "Illegal characters not allowed." diff --git a/exercises/nth-prime/.meta/generate.yml b/exercises/nth-prime/.meta/generate.yml new file mode 100644 index 0000000000..cf4897df57 --- /dev/null +++ b/exercises/nth-prime/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + prime: nth-prime diff --git a/exercises/palindrome-products/.meta/generate.yml b/exercises/palindrome-products/.meta/generate.yml new file mode 100644 index 0000000000..df751ee752 --- /dev/null +++ b/exercises/palindrome-products/.meta/generate.yml @@ -0,0 +1,3 @@ +properties_map: + largest: largest_palindrome + smallest: smallest_palindrome diff --git a/exercises/prime-factors/.meta/generate.yml b/exercises/prime-factors/.meta/generate.yml new file mode 100644 index 0000000000..d38ad5a2b9 --- /dev/null +++ b/exercises/prime-factors/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + factors: prime_factors diff --git a/exercises/rectangles/.meta/generate.yml b/exercises/rectangles/.meta/generate.yml new file mode 100644 index 0000000000..b83a7e5cbe --- /dev/null +++ b/exercises/rectangles/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + rectangles: count diff --git a/exercises/roman-numerals/.meta/generate.yml b/exercises/roman-numerals/.meta/generate.yml new file mode 100644 index 0000000000..fb213df4f6 --- /dev/null +++ b/exercises/roman-numerals/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + roman: numeral diff --git a/exercises/secret-handshake/.meta/generate.yml b/exercises/secret-handshake/.meta/generate.yml new file mode 100644 index 0000000000..3f46d02284 --- /dev/null +++ b/exercises/secret-handshake/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + commands: handshake diff --git a/exercises/sieve/.meta/generate.yml b/exercises/sieve/.meta/generate.yml new file mode 100644 index 0000000000..5ae42232b4 --- /dev/null +++ b/exercises/sieve/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + primes: sieve diff --git a/exercises/spiral-matrix/.meta/generate.yml b/exercises/spiral-matrix/.meta/generate.yml new file mode 100644 index 0000000000..01be28827a --- /dev/null +++ b/exercises/spiral-matrix/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + spiralMatrix: spiral diff --git a/exercises/sum-of-multiples/.meta/generate.yml b/exercises/sum-of-multiples/.meta/generate.yml new file mode 100644 index 0000000000..aada32a06d --- /dev/null +++ b/exercises/sum-of-multiples/.meta/generate.yml @@ -0,0 +1,2 @@ +properties_map: + sum: sum_of_multiples diff --git a/exercises/triangle/.meta/generate.yml b/exercises/triangle/.meta/generate.yml new file mode 100644 index 0000000000..fce66b9f0a --- /dev/null +++ b/exercises/triangle/.meta/generate.yml @@ -0,0 +1,4 @@ +properties_map: + equilateral: is_equilateral + isosceles: is_isosceles + scalene: is_scalene diff --git a/exercises/word-count/.meta/generate.yml b/exercises/word-count/.meta/generate.yml new file mode 100644 index 0000000000..619f347221 --- /dev/null +++ b/exercises/word-count/.meta/generate.yml @@ -0,0 +1,29 @@ +properties_map: + countWords: word_count +cases: + - description: "tabs" + property: "countWords" + input: + sentence: "rah rah ah ah ah\troma roma ma\tga ga oh la la\twant your bad romance" + expected: + rah: 2 + ah: 3 + roma: 2 + ma: 1 + ga: 2 + oh: 1 + la: 2 + want: 1 + your: 1 + bad: 1 + romance: 1 + - description: "non alphanumeric" + property: "countWords" + input: + sentence: "hey,my_spacebar_is_broken." + expected: + hey: 1 + my: 1 + spacebar: 1 + is: 1 + broken: 1 diff --git a/exercises/wordy/.meta/generate.yml b/exercises/wordy/.meta/generate.yml new file mode 100644 index 0000000000..4fb8a41538 --- /dev/null +++ b/exercises/wordy/.meta/generate.yml @@ -0,0 +1,15 @@ +properties_map: + answer: calculate +cases: + - description: "missing operation" + property: "answer" + input: + question: "What is 2 2 minus 3?" + expected: + error: "An operation is missingh" + - description: "missing number" + property: "answer" + input: + question: "What is 7 plus multiplied by -2?" + expected: + error: "A number is missing." diff --git a/generators/generate.py b/generators/generate.py index dc4309978b..7048fa8c1a 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -7,13 +7,19 @@ import logging from argparse import ArgumentParser from collections import OrderedDict -from pathlib import Path +from functools import partial from itertools import repeat +from pathlib import Path from string import punctuation, whitespace # 3rd party imports: from jinja2 import Environment, PackageLoader, select_autoescape, TemplateError from yapf.yapflib.yapf_api import FormatFile, style +from yaml import load, dump +try: + from yaml import CLoader as Loader, CDumper as Dumper +except ImportError: + from yaml import Loader, Dumper logging.basicConfig(format="%(levelname)s: %(message)s") LOGGER = logging.getLogger(__name__) @@ -44,6 +50,19 @@ def to_camel(string): return "".join(w.title() for w in to_snake(string).split("_")) +def should_skip(case, tests=None): + """ + Determins if the given case should be skipped. + """ + for test in tests or []: + prop = case.get("property") + description = case.get("description") + if prop and prop == test["property"]: + if description and re.match(test["description"], description): + return test.get("message", "extra-credit") + return False + + def is_error(case): """ Determines if the given case is an error. @@ -80,6 +99,23 @@ def _get_properties(obj): return sorted(set(_get_properties(data))) +def remap_property(string, properties_map=None): + """ + Builds a function that maps a property name to a new name, if required. + """ + return (properties_map or {}).get(string, string) + + +def prefer_double_quotes(string): + """ + Prefer double quotes on a string that does not embed them. + """ + if string.startswith("'") and string.endswith("'"): + if '"' not in string: + return '"' + string[1:-1] + '"' + return string + + def format_input(case, n_args=None): """ Format the case's input values for use in a function call. @@ -88,15 +124,20 @@ def format_input(case, n_args=None): If n_args is provided, convert only the first n argss to positional form. """ + if isinstance(case["input"], str): + return case["input"] raw = list(case["input"].items()) n_args = len(raw) if n_args is None else n_args result = [] for _ in range(n_args): arg = raw.pop(0)[1] if isinstance(arg, dict): - result.extend(map(repr, arg.values())) + result.extend(map(prefer_double_quotes, map(repr, arg.values()))) + elif isinstance(arg, list): + result.append("[" + ", ".join( + map(prefer_double_quotes, map(repr, arg))) + "]") else: - result.append(repr(arg)) + result.append(prefer_double_quotes(repr(arg))) while raw: result.append("{0}={1!s}".format(raw.pop(0))) return ", ".join(result) @@ -107,11 +148,18 @@ def format_expect(case): Format the case's expected values for use in a function call. """ if is_error(case): - return repr(case["expected"]["error"]) + return prefer_double_quotes(repr(case["expected"]["error"])) expected = case["expected"] if isinstance(expected, dict): - return "{" + ", ".join("{0!r}: {1!r}".format(*i) for i in expected.items()) + "}" - return repr(case["expected"]) + pairs = [] + for k, v in expected.items(): + pairs.append("{0}: {1}".format( + prefer_double_quotes(repr(k)), prefer_double_quotes(repr(v)))) + return "{" + ", ".join(pairs) + "}" + if isinstance(expected, list): + return "[" + ", ".join(map(prefer_double_quotes, map(repr, + expected))) + "]" + return prefer_double_quotes(repr(case["expected"])) def main(args=None): @@ -169,16 +217,6 @@ def main(args=None): # set verbosity if args.verbose: LOGGER.setLevel(logging.DEBUG if args.verbose > 1 else logging.INFO) - - # build the Jinja2 template environment - env = Environment( - loader=PackageLoader(__name__, "templates"), - autoescape=select_autoescape([])) - env.filters["to_snake"] = to_snake - env.filters["to_camel"] = to_camel - env.filters["is_error"] = is_error - env.filters["format_input"] = format_input - env.filters["format_expect"] = format_expect # get the (possibly limited) list of exercises to generate config = json.loads(args.config.read_text()) @@ -196,9 +234,47 @@ def main(args=None): LOGGER.warning("No canonical data found for %s: skipping", exercise) continue + + exercise_overrides = {} + exercise_override_file = args.output.joinpath(exercise, '.meta', + 'generate.yml') + if exercise_override_file.is_file(): + LOGGER.info("Using override file for %s", exercise) + exercise_overrides = load(exercise_override_file.read_text()) + + # build the Jinja2 template environment + env = Environment( + loader=PackageLoader(__name__, "templates"), + autoescape=select_autoescape([]), + trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) + env.filters["to_snake"] = to_snake + env.filters["to_camel"] = to_camel + env.filters["is_error"] = is_error + env.filters["format_input"] = format_input + env.filters["format_expect"] = format_expect + env.filters["should_skip"] = lambda c: False + env.filters["remap_property"] = lambda p: p + + exercise_properties_map = exercise_overrides.get("properties_map", {}) + if "skip_tests" in exercise_overrides: + env.filters["should_skip"] = partial( + should_skip, tests=exercise_overrides["skip_tests"]) + + if "properties_map" in exercise_overrides: + env.filters["remap_property"] = partial( + remap_property, + properties_map=exercise_overrides["properties_map"]) + data = json.loads(canonical.read_text(), object_pairs_hook=OrderedDict) data["has_error"] = has_error(data) data["properties"] = get_properties(data) + data["track_cases"] = [] + + if "cases" in exercise_overrides: + exercise_cases = exercise_overrides["cases"] + data["has_error"] = data["has_error"] or has_error({"cases": exercise_cases}) + data["track_cases"] = exercise_cases + template = env.select_template( ["{0}.j2".format(exercise), "_default.j2"]) try: @@ -217,7 +293,8 @@ def main(args=None): in_place=True, verify=True) except SyntaxError: - LOGGER.exception("Unable to reformat %s: skipping", exercise) + LOGGER.exception("Unable to reformat %s: skipping", + exercise) except TemplateError: LOGGER.exception("Unable to render %s: skipping", exercise) continue diff --git a/generators/templates/__macros.j2 b/generators/templates/__macros.j2 index 94cfc7e9ef..aff2005fca 100644 --- a/generators/templates/__macros.j2 +++ b/generators/templates/__macros.j2 @@ -1,6 +1,6 @@ {% macro make_import(data) %} {% if data.properties | length > 0 %} -from {{ data.exercise | to_snake }} import {{ data.properties | map("to_snake") | join(", ") }} +from {{ data.exercise | to_snake }} import {{ data.properties | map("remap_property") | map("to_snake") | join(", ") }} {% else %} import {{ data.exercise | to_snake }} {% endif %} @@ -8,51 +8,73 @@ import {{ data.exercise | to_snake }} {% macro make_tests(data) %} class {{ data.exercise | to_camel }}Test(unittest.TestCase): - {% for case in data.cases recursive %} - {% if case.cases %} - {#% if loop.nextitem is defined %#} - {#{ "#" * loop.depth }} {{ case.description }#} - {#% endif %#} - {{ loop(case.cases) }} - {% else %} - def test_{{ case.description | to_snake }}(self): - {% if case | is_error %} - {{- add_assert_raises(case) | indent(12) }} - {% else %} - {{- add_assert_equal(case) | indent(12) }} - {% endif %} - {% endif %} - {% endfor %} +{{ add_cases(data.cases) }} + {% if data.track_cases %} + # Track-specific test cases +{{ add_cases(data.track_cases) }} + {% endif %} {% if data.has_error %} - {{ add_setup(error=data.has_error) | indent(8) }} +{{ add_setup(error=data.has_error) }} {% endif %} {% endmacro %} {% macro add_setup(error=False) %} -# Utility functions -def setUp(self): -{%- if error %} - try: - self.assertRaisesRegex - except AttributeError: - self.assertRaisesRegex = self.assertRaisesRegexp + # Utility functions + def setUp(self): +{% if error %} + try: + self.assertRaisesRegex + except AttributeError: + self.assertRaisesRegex = self.assertRaisesRegexp {% endif %} {% if error %} -def assertRaisesWithMessage(self, exception): - return self.assertRaisesRegex(exception, r".+") + def assertRaisesWithMessage(self, exception): + return self.assertRaisesRegex(exception, r".+") +{% endif %} +{% endmacro %} + +{% macro add_cases(cases) %} +{% for case in cases recursive %} +{% if case.cases %} +{#% if loop.nextitem is defined %#} +{#{ "#" * loop.depth }} {{ case.description }#} +{#% endif %#} +{{ loop(case.cases) }} +{% else %} +{% if case | should_skip %} + @unittest.skip("{{ case | should_skip }}") +{% endif %} + def test_{{ case.description | to_snake }}(self): +{% if case | is_error %} +{{ add_assert_raises(case) }} +{% elif case | format_expect in ['True', 'False'] %} + {{ add_assert_is(case) }} +{% else %} +{{ add_assert_equal(case) }} +{% endif %} {% endif %} +{% endfor %} {% endmacro %} -{% macro add_assert_equal(case) -%} -{% if not case.input -%} - self.assertEqual({{ case.property | to_snake }}(), {{ case | format_expect }}) -{% else -%} - self.assertEqual({{ case.property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) +{% macro add_assert_is(case) %} +{% if not case.input %} + self.assertIs({{ case.property | remap_property | to_snake }}(), {{ case | format_expect }}) +{% else %} + self.assertIs({{ case.property | remap_property | to_snake }}({{ case | format_input }}), {{ case | format_expect }}) +{% endif %} +{% endmacro %} + +{% macro add_assert_equal(case) %} + expected = {{ case | format_expect }} +{% if not case.input %} + self.assertEqual({{ case.property | remap_property | to_snake }}(), expected) +{% else %} + self.assertEqual({{ case.property | remap_property | to_snake }}({{ case | format_input }}), expected) {% endif %} {% endmacro %} -{% macro add_assert_raises(case) -%} - with self.assertRaisesWithMessage(ValueError): - {{ case.property | to_snake }}({{ case | format_input }}) +{% macro add_assert_raises(case) %} + with self.assertRaisesWithMessage(ValueError): + {{ case.property | remap_property | to_snake }}({{ case | format_input }}) {% endmacro %} diff --git a/generators/templates/_default.j2 b/generators/templates/_default.j2 index 72526ab75b..75eaf625de 100644 --- a/generators/templates/_default.j2 +++ b/generators/templates/_default.j2 @@ -2,10 +2,9 @@ import unittest {{ make_import(data) }} - - # Tests adapted from `problem-specifications//canonical-data.json` @ v{{ data.version }} + {{ make_tests(data) }} if __name__ == '__main__': diff --git a/requirements-generate.txt b/requirements-generate.txt index 95154dfb87..346095efa1 100644 --- a/requirements-generate.txt +++ b/requirements-generate.txt @@ -1,2 +1,4 @@ Jinja2==2.10 yapf==0.24.0 +MarkupSafe==1.1.0 +PyYAML==3.13 From e93669ea589c4894563583977acd73cc0577bf58 Mon Sep 17 00:00:00 2001 From: Michael Morehouse <640167+yawpitch@users.noreply.github.com> Date: Tue, 11 Dec 2018 15:35:44 +0000 Subject: [PATCH 07/11] python-generator: yaml explanation comments Add comments to explain the yaml import statement. --- generators/generate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/generators/generate.py b/generators/generate.py index 7048fa8c1a..5be1b2bf98 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -17,8 +17,10 @@ from yapf.yapflib.yapf_api import FormatFile, style from yaml import load, dump try: + # attempt to load libyaml C-extension if available from yaml import CLoader as Loader, CDumper as Dumper except ImportError: + # otherwise fall back on slower pure-Python version from yaml import Loader, Dumper logging.basicConfig(format="%(levelname)s: %(message)s") From 2f2ae5787b49fc9c4b16495789381219734832e5 Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Wed, 2 Jan 2019 10:35:27 -0500 Subject: [PATCH 08/11] CLI changes: - -o/--output is not -d/--output-dir - -o/--only is now used to specify a single exercise for which to generate tests --- generators/generate.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/generators/generate.py b/generators/generate.py index 5be1b2bf98..2350c5ebfd 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -185,7 +185,11 @@ def main(args=None): help="path to the Python track config.json file: (%(default)s)") parser.add_argument( "-o", - "--output", + "--only", + help="generate tests for just the exercise specified") + parser.add_argument( + "-d", + "--output-dir", type=Path, default="./exercises", help="path to the output directory: (%(default)s)") @@ -226,6 +230,11 @@ def main(args=None): e["slug"] for e in config["exercises"] if not e.get("deprecated") } + if args.only is not None: + if args.only not in exercises: + LOGGER.error(f'"{args.only}" is not a valid exercise') + exit(1) + exercises = {args.only} if args.exercises: exercises = set(args.exercises).intersection(exercises) @@ -238,7 +247,7 @@ def main(args=None): continue exercise_overrides = {} - exercise_override_file = args.output.joinpath(exercise, '.meta', + exercise_override_file = args.output_dir.joinpath(exercise, '.meta', 'generate.yml') if exercise_override_file.is_file(): LOGGER.info("Using override file for %s", exercise) @@ -281,7 +290,7 @@ def main(args=None): ["{0}.j2".format(exercise), "_default.j2"]) try: code = template.render(data=data) - out_dir = args.output.joinpath(exercise) + out_dir = args.output_dir.joinpath(exercise) if not out_dir.exists(): out_dir.mkdir(parents=True) out_file = out_dir.joinpath("{0}_test.py".format( From 59f344082a747f4b7b37a2e900a8422f30c646ed Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Wed, 2 Jan 2019 10:36:57 -0500 Subject: [PATCH 09/11] generate.yml: allow explicit imports, allow input transforms --- generators/generate.py | 20 ++++++++++++++++++-- generators/templates/__macros.j2 | 3 +++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/generators/generate.py b/generators/generate.py index 2350c5ebfd..c2a67a8bce 100644 --- a/generators/generate.py +++ b/generators/generate.py @@ -119,6 +119,10 @@ def prefer_double_quotes(string): def format_input(case, n_args=None): + format_input_with_mappings({}) + + +def format_input_with_mappings(input_mappings, case, n_args=None): """ Format the case's input values for use in a function call. @@ -132,8 +136,10 @@ def format_input(case, n_args=None): n_args = len(raw) if n_args is None else n_args result = [] for _ in range(n_args): - arg = raw.pop(0)[1] - if isinstance(arg, dict): + name, arg = raw.pop(0) + if name in input_mappings: + result.append(input_mappings[name](arg)) + elif isinstance(arg, dict): result.extend(map(prefer_double_quotes, map(repr, arg.values()))) elif isinstance(arg, list): result.append("[" + ", ".join( @@ -276,10 +282,20 @@ def main(args=None): remap_property, properties_map=exercise_overrides["properties_map"]) + if "inputs_map" in exercise_overrides: + env.filters["format_input"] = partial( + format_input_with_mappings, + { + k: eval(v) + for k, v in exercise_overrides["inputs_map"].items() + } + ) + data = json.loads(canonical.read_text(), object_pairs_hook=OrderedDict) data["has_error"] = has_error(data) data["properties"] = get_properties(data) data["track_cases"] = [] + data["imports"] = exercise_overrides.get("imports", []) if "cases" in exercise_overrides: exercise_cases = exercise_overrides["cases"] diff --git a/generators/templates/__macros.j2 b/generators/templates/__macros.j2 index aff2005fca..f52bde1f1c 100644 --- a/generators/templates/__macros.j2 +++ b/generators/templates/__macros.j2 @@ -4,6 +4,9 @@ from {{ data.exercise | to_snake }} import {{ data.properties | map("remap_prope {% else %} import {{ data.exercise | to_snake }} {% endif %} +{%- if data.imports | length > 0 %} +from {{ data.exercise | to_snake }} import ({{ data.imports | join(", ") }}) +{% endif %} {% endmacro %} {% macro make_tests(data) %} From 2f758dc5b3964a20e66c20ab654a777fa1104345 Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Wed, 2 Jan 2019 11:10:39 -0500 Subject: [PATCH 10/11] yacht: add generate.yml --- exercises/yacht/.meta/generate.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 exercises/yacht/.meta/generate.yml diff --git a/exercises/yacht/.meta/generate.yml b/exercises/yacht/.meta/generate.yml new file mode 100644 index 0000000000..0ce2100846 --- /dev/null +++ b/exercises/yacht/.meta/generate.yml @@ -0,0 +1,15 @@ +imports: + - YACHT + - ONES + - TWOS + - THREES + - FOURS + - FIVES + - SIXES + - FULL_HOUSE + - FOUR_OF_A_KIND + - LITTLE_STRAIGHT + - BIG_STRAIGHT + - CHOICE +inputs_map: + category: "lambda c: c.upper().replace(' ', '_')" \ No newline at end of file From 6b7a36e3adc0af416412452227e1ea0195530796 Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Mon, 15 Apr 2019 10:16:41 -0400 Subject: [PATCH 11/11] update Jinja2 to 2.10.1 --- requirements-generate.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-generate.txt b/requirements-generate.txt index 346095efa1..91b98b4877 100644 --- a/requirements-generate.txt +++ b/requirements-generate.txt @@ -1,4 +1,4 @@ -Jinja2==2.10 +Jinja2==2.10.1 yapf==0.24.0 MarkupSafe==1.1.0 PyYAML==3.13