Skip to content

Commit 23f671e

Browse files
authored
Use import with context for generators (exercism#1896)
1 parent df011c0 commit 23f671e

23 files changed

Lines changed: 115 additions & 160 deletions

File tree

bin/generate_tests.py

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from subprocess import CalledProcessError, check_call
2727
from tempfile import NamedTemporaryFile
2828

29-
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
29+
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, UndefinedError
3030

3131
VERSION = '0.1.0'
3232

@@ -150,35 +150,44 @@ def generate_exercise(env, spec_path, exercise, check=False):
150150
additional_tests = load_additional_tests(slug)
151151
spec["additional_cases"] = additional_tests
152152
template_path = os.path.join(slug, '.meta', 'template.j2')
153-
try:
154-
template = env.get_template(template_path)
155-
tests_path = os.path.join(
156-
exercise, f'{to_snake(slug)}_test.py'
157-
)
158-
spec["has_error_case"] = has_error_case(spec["cases"])
159-
rendered = template.render(**spec)
160-
with NamedTemporaryFile('w', delete=False) as tmp:
161-
tmp.write(rendered)
162-
format_file(tmp.name)
163-
if check:
164-
try:
165-
if not filecmp.cmp(tmp.name, tests_path):
166-
logger.error(f'{slug}: check failed; tests must be regenerated with bin/generate_tests.py')
167-
sys.exit(1)
168-
finally:
169-
os.remove(tmp.name)
170-
else:
171-
shutil.move(tmp.name, tests_path)
172-
print(f'{slug} generated at {tests_path}')
173-
except TemplateNotFound as e:
174-
logger.debug(str(e))
175-
logger.info(f'{slug}: no template found; skipping')
153+
template = env.get_template(template_path)
154+
tests_path = os.path.join(
155+
exercise, f'{to_snake(slug)}_test.py'
156+
)
157+
spec["has_error_case"] = has_error_case(spec["cases"])
158+
logger.info(f'{slug}: attempting render')
159+
rendered = template.render(**spec)
160+
with NamedTemporaryFile('w', delete=False) as tmp:
161+
tmp.write(rendered)
162+
format_file(tmp.name)
163+
if check:
164+
try:
165+
if not filecmp.cmp(tmp.name, tests_path):
166+
logger.error(f'{slug}: check failed; tests must be regenerated with bin/generate_tests.py')
167+
return False
168+
finally:
169+
os.remove(tmp.name)
170+
else:
171+
shutil.move(tmp.name, tests_path)
172+
print(f'{slug} generated at {tests_path}')
173+
except (TypeError, UndefinedError) as e:
174+
logger.debug(str(e))
175+
logger.error(f'{slug}: generation failed')
176+
return False
177+
except TemplateNotFound as e:
178+
logger.debug(str(e))
179+
logger.info(f'{slug}: no template found; skipping')
176180
except FileNotFoundError as e:
177181
logger.debug(str(e))
178182
logger.info(f'{slug}: no canonical data found; skipping')
183+
return True
179184

180185

181-
def generate(exercise_glob, spec_path=DEFAULT_SPEC_LOCATION, check=False, **kwargs):
186+
def generate(
187+
exercise_glob, spec_path=DEFAULT_SPEC_LOCATION,
188+
stop_on_failure=False, check=False,
189+
**kwargs
190+
):
182191
"""
183192
Primary entry point. Generates test files for all exercises matching exercise_glob
184193
"""
@@ -187,8 +196,14 @@ def generate(exercise_glob, spec_path=DEFAULT_SPEC_LOCATION, check=False, **kwar
187196
env.filters['to_snake'] = to_snake
188197
env.filters['camel_case'] = camel_case
189198
env.tests['error_case'] = error_case
199+
result = True
190200
for exercise in glob(os.path.join('exercises', exercise_glob)):
191-
generate_exercise(env, spec_path, exercise, check)
201+
if not generate_exercise(env, spec_path, exercise, check):
202+
result = False
203+
if stop_on_failure:
204+
break
205+
if not result:
206+
sys.exit(1)
192207

193208

194209
if __name__ == '__main__':
@@ -204,6 +219,7 @@ def generate(exercise_glob, spec_path=DEFAULT_SPEC_LOCATION, check=False, **kwar
204219
)
205220
parser.add_argument('-v', '--verbose', action='store_true')
206221
parser.add_argument('-p', '--spec-path', default=DEFAULT_SPEC_LOCATION)
222+
parser.add_argument('--stop-on-failure', action='store_true')
207223
parser.add_argument('--check', action='store_true')
208224
opts = parser.parse_args()
209225
if opts.verbose:

config/generator_macros.j2

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,26 @@
99
{%- endfor %}
1010
{% endmacro -%}
1111

12-
{% macro properties(exercise, properties) -%}
13-
from {{ exercise | to_snake }} import {% for prop in properties -%}
14-
{{ prop | to_snake }}
15-
{%- if not loop.last %}, {% endif -%}
16-
{% endfor %}
17-
{% endmacro -%}
18-
19-
{% macro canonical_ref(version) -%}
12+
{% macro canonical_ref() -%}
2013
# Tests adapted from `problem-specifications//canonical-data.json` @ v{{ version }}
2114
{%- endmacro %}
2215

23-
{% macro header(exercise, props, version) -%}
16+
{% macro header(imports=[], ignore=[]) -%}
2417
import unittest
2518

26-
{{ properties(exercise, props) }}
19+
from {{ exercise | to_snake }} import ({% if imports -%}
20+
{% for name in imports -%}
21+
{{ name }},
22+
{% endfor %}
23+
{%- else -%}
24+
{% for prop in properties -%}
25+
{%- if prop not in ignore -%}
26+
{{ prop | to_snake }},
27+
{%- endif -%}
28+
{% endfor %}
29+
{%- endif %})
2730

28-
{{ canonical_ref(version) }}
31+
{{ canonical_ref() }}
2932
{%- endmacro %}
3033

3134
{% macro utility() -%}# Utility functions
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{%- import "generator_macros.j2" as macros -%}
1+
{%- import "generator_macros.j2" as macros with context -%}
22
{% macro test_case(case) -%}
33
{%- set input = case["input"] -%}
44
def test_{{ case["description"] | to_snake }}(self):
@@ -7,12 +7,12 @@
77
"{{ case["expected"] }}"
88
)
99
{%- endmacro %}
10-
{{ macros.header(exercise, properties, version)}}
10+
{{ macros.header()}}
1111

1212
class {{ exercise | camel_case }}Test(unittest.TestCase):
1313
{% for supercase in cases %}{% for case in supercase["cases"] -%}
1414
{{ test_case(case) }}
1515
{% endfor %}{% endfor %}
1616

1717

18-
{{ macros.footer(has_error_case) }}
18+
{{ macros.footer() }}

exercises/acronym/acronym_test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from acronym import abbreviate
44

5-
65
# Tests adapted from `problem-specifications//canonical-data.json` @ v1.7.0
76

87

exercises/clock/.meta/template.j2

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
{%- import "generator_macros.j2" as macros -%}
1+
{%- import "generator_macros.j2" as macros with context -%}
22
{%- macro clock(obj) -%}
33
Clock({{ obj["hour"] }}, {{ obj["minute"] }})
44
{%- endmacro -%}
5-
import unittest
6-
7-
from {{ exercise | to_snake }} import Clock
8-
9-
{{ macros.canonical_ref(version) }}
5+
{{ macros.header(["Clock"])}}
106

117
class {{ exercise | camel_case }}Test(unittest.TestCase):
128
{% for casegroup in cases -%}
@@ -32,4 +28,4 @@ class {{ exercise | camel_case }}Test(unittest.TestCase):
3228
{% endfor %}
3329
{%- endfor %}
3430

35-
{{ macros.footer(has_error_case) }}
31+
{{ macros.footer() }}
Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{%- import "generator_macros.j2" as macros -%}
1+
{%- import "generator_macros.j2" as macros with context -%}
22
{%- macro test_case( case) -%}
33
{%- set input = case["input"] -%}
44
def test_{{ case["description"] | to_snake }}(self):
@@ -13,15 +13,11 @@
1313
self.assertEqual(school.{{ case["property"] | to_snake }}(), expected)
1414
{%- endif %}
1515
{% endmacro -%}
16-
import unittest
17-
18-
from {{ exercise | to_snake }} import School
19-
20-
{{ macros.canonical_ref(version) }}
16+
{{ macros.header(["School"]) }}
2117

2218
class {{ exercise | camel_case }}Test(unittest.TestCase):
2319
{% for case in cases %}
2420
{{ test_case(case) }}
2521
{% endfor %}
2622

27-
{{ macros.footer(has_error_case) }}
23+
{{ macros.footer() }}
Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
{%- import "generator_macros.j2" as macros -%}
1+
{%- import "generator_macros.j2" as macros with context -%}
22
{%- macro test_call(case) %}
3-
{{ exercise | to_snake }}.{{ case["property"] }}(
3+
{{ case["property"] }}(
44
{% for arg in case["input"].values() -%}
55
"{{ arg }}",
66
{% endfor %}
77
)
88
{% endmacro -%}
9-
import unittest
10-
11-
import {{ exercise | to_snake }}
12-
13-
# Tests adapted from `problem-specifications//canonical-data.json` @ v{{ version }}
9+
{{ macros.header() }}
1410

1511
class {{ exercise | camel_case }}Test(unittest.TestCase):
1612
{% for case in cases -%}
@@ -28,6 +24,4 @@ class {{ exercise | camel_case }}Test(unittest.TestCase):
2824

2925
{{ macros.utility() }}
3026

31-
32-
if __name__ == '__main__':
33-
unittest.main()
27+
{{ macros.footer() }}

exercises/hamming/hamming_test.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,41 @@
11
import unittest
22

3-
import hamming
3+
from hamming import distance
44

55
# Tests adapted from `problem-specifications//canonical-data.json` @ v2.3.0
66

77

88
class HammingTest(unittest.TestCase):
99
def test_empty_strands(self):
10-
self.assertEqual(hamming.distance("", ""), 0)
10+
self.assertEqual(distance("", ""), 0)
1111

1212
def test_single_letter_identical_strands(self):
13-
self.assertEqual(hamming.distance("A", "A"), 0)
13+
self.assertEqual(distance("A", "A"), 0)
1414

1515
def test_single_letter_different_strands(self):
16-
self.assertEqual(hamming.distance("G", "T"), 1)
16+
self.assertEqual(distance("G", "T"), 1)
1717

1818
def test_long_identical_strands(self):
19-
self.assertEqual(hamming.distance("GGACTGAAATCTG", "GGACTGAAATCTG"), 0)
19+
self.assertEqual(distance("GGACTGAAATCTG", "GGACTGAAATCTG"), 0)
2020

2121
def test_long_different_strands(self):
22-
self.assertEqual(hamming.distance("GGACGGATTCTG", "AGGACGGATTCT"), 9)
22+
self.assertEqual(distance("GGACGGATTCTG", "AGGACGGATTCT"), 9)
2323

2424
def test_disallow_first_strand_longer(self):
2525
with self.assertRaisesWithMessage(ValueError):
26-
hamming.distance("AATG", "AAA")
26+
distance("AATG", "AAA")
2727

2828
def test_disallow_second_strand_longer(self):
2929
with self.assertRaisesWithMessage(ValueError):
30-
hamming.distance("ATA", "AGTG")
30+
distance("ATA", "AGTG")
3131

3232
def test_disallow_left_empty_strand(self):
3333
with self.assertRaisesWithMessage(ValueError):
34-
hamming.distance("", "G")
34+
distance("", "G")
3535

3636
def test_disallow_right_empty_strand(self):
3737
with self.assertRaisesWithMessage(ValueError):
38-
hamming.distance("G", "")
38+
distance("G", "")
3939

4040
# Utility functions
4141
def setUp(self):
Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
import unittest
2-
3-
import {{ exercise | to_snake }}
4-
5-
# Tests adapted from `problem-specifications//canonical-data.json` @ v{{ version }}
1+
{%- import "generator_macros.j2" as macros with context -%}
2+
{{ macros.header() }}
63

74
class {{ exercise | camel_case }}Test(unittest.TestCase):
85
{% for case in cases -%}
96
def test_{{ case["description"] | to_snake }}(self):
10-
self.assertEqual({{ exercise | to_snake }}.{{ case["property"] }}(), "{{ case["expected"] }}")
7+
self.assertEqual({{ case["property"] }}(), "{{ case["expected"] }}")
118
{% endfor %}
129

13-
14-
if __name__ == '__main__':
15-
unittest.main()
10+
{{ macros.footer() }}

exercises/hello-world/hello_world_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import unittest
22

3-
import hello_world
3+
from hello_world import hello
44

55
# Tests adapted from `problem-specifications//canonical-data.json` @ v1.1.0
66

77

88
class HelloWorldTest(unittest.TestCase):
99
def test_say_hi(self):
10-
self.assertEqual(hello_world.hello(), "Hello, World!")
10+
self.assertEqual(hello(), "Hello, World!")
1111

1212

1313
if __name__ == "__main__":

0 commit comments

Comments
 (0)