Skip to content

Commit a43e360

Browse files
committed
merge test_exercises.py and test_exercises_docker.py
1 parent 753bccc commit a43e360

File tree

5 files changed

+190
-110
lines changed

5 files changed

+190
-110
lines changed

bin/data.py

+31
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,34 @@ class ExerciseStatus(str, Enum):
3535
Deprecated = 'deprecated'
3636

3737

38+
@dataclass
39+
class ExerciseFiles:
40+
solution: List[str]
41+
test: List[str]
42+
exemplar: List[str]
43+
44+
45+
@dataclass
46+
class ExerciseConfig:
47+
files: ExerciseFiles
48+
authors: List[str] = None
49+
forked_from: str = None
50+
contributors: List[str] = None
51+
language_versions: List[str] = None
52+
53+
def __post_init__(self):
54+
if isinstance(self.files, dict):
55+
self.files = ExerciseFiles(**self.files)
56+
for attr in ['authors', 'contributors', 'language_versions']:
57+
if getattr(self, attr) is None:
58+
setattr(self, attr, [])
59+
60+
@classmethod
61+
def load(cls, config_file: Path) -> 'ExerciseConfig':
62+
with config_file.open() as f:
63+
return cls(**json.load(f))
64+
65+
3866
@dataclass
3967
class ExerciseInfo:
4068
path: Path
@@ -93,6 +121,9 @@ def template_path(self):
93121
def config_file(self):
94122
return self.meta_dir / 'config.json'
95123

124+
def load_config(self) -> ExerciseConfig:
125+
return ExerciseConfig.load(self.config_file)
126+
96127

97128
@dataclass
98129
class Exercises:

bin/test_exercises.py

+155-31
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,175 @@
11
#!/usr/bin/env python3
2-
2+
"""Meant to be run from inside python-test-runner container,
3+
where this track repo is mounted at /python
4+
"""
5+
import argparse
6+
from functools import wraps
7+
from itertools import zip_longest
8+
import json
9+
from pathlib import Path
310
import shutil
411
import subprocess
512
import sys
613
import tempfile
7-
from pathlib import Path
8-
9-
from data import Config, ExerciseInfo
14+
from data import Config, ExerciseConfig, ExerciseInfo
1015

1116
# Allow high-performance tests to be skipped
1217
ALLOW_SKIP = ['alphametics', 'largest-series-product']
1318

19+
TEST_RUNNER_DIR = Path('/opt/test-runner')
20+
21+
RUNNERS = {}
22+
23+
24+
def runner(name):
25+
def _decorator(runner_func):
26+
RUNNERS[name] = runner_func
27+
@wraps(runner_func)
28+
def _wrapper(exercise: ExerciseInfo, workdir: Path, quiet: bool = False):
29+
return runner_func(exercise, workdir, quiet=quiet)
30+
return _wrapper
31+
return _decorator
32+
1433

15-
def check_assignment(exercise: ExerciseInfo, quiet=False) -> int:
16-
# Returns the exit code of the tests
17-
workdir = Path(tempfile.mkdtemp(exercise.slug))
18-
solution_file = exercise.solution_stub.name
19-
try:
20-
test_file_out = workdir / exercise.test_file.name
21-
if exercise.slug in ALLOW_SKIP:
22-
shutil.copyfile(exercise.test_file, test_file_out)
34+
def copy_file(src: Path, dst: Path, strip_skips=False):
35+
if strip_skips:
36+
with src.open('r') as src_file:
37+
lines = [line for line in src_file.readlines()
38+
if not line.strip().startswith('@unittest.skip')]
39+
with dst.open('w') as dst_file:
40+
dst_file.writelines(lines)
41+
else:
42+
shutil.copy2(src, dst)
43+
44+
def copy_solution_files(exercise: ExerciseInfo, workdir: Path, exercise_config: ExerciseConfig = None):
45+
if exercise_config is not None:
46+
solution_files = exercise_config.files.solution
47+
exemplar_files = exercise_config.files.exemplar
48+
else:
49+
solution_files = []
50+
exemplar_files = []
51+
if not solution_files:
52+
solution_files.append(exercise.solution_stub.name)
53+
solution_files = [exercise.path / s for s in solution_files]
54+
if not exemplar_files:
55+
exemplar_files.append(exercise.exemplar_file.relative_to(exercise.path))
56+
exemplar_files = [exercise.path / e for e in exemplar_files]
57+
for solution_file, exemplar_file in zip_longest(solution_files, exemplar_files):
58+
if solution_file is None:
59+
copy_file(exemplar_file, workdir / exemplar_file.name)
60+
elif exemplar_file is None:
61+
copy_file(solution_file, workdir / solution_file.name)
2362
else:
24-
with exercise.test_file.open('r') as src_file:
25-
lines = [line for line in src_file.readlines()
26-
if not line.strip().startswith('@unittest.skip')]
27-
with test_file_out.open('w') as dst_file:
28-
dst_file.writelines(lines)
29-
shutil.copyfile(exercise.exemplar_file, workdir / solution_file)
30-
kwargs = {}
31-
if quiet:
32-
kwargs['stdout'] = subprocess.DEVNULL
33-
kwargs['stderr'] = subprocess.DEVNULL
34-
return subprocess.run([sys.executable, '-m', 'pytest', test_file_out], **kwargs).returncode
35-
finally:
36-
shutil.rmtree(workdir)
63+
dst = workdir / solution_file.relative_to(exercise.path)
64+
copy_file(exemplar_file, dst)
65+
66+
67+
def copy_test_files(exercise: ExerciseInfo, workdir: Path, exercise_config = None):
68+
if exercise_config is not None:
69+
test_files = exercise_config.files.test
70+
else:
71+
test_files = []
72+
if not test_files:
73+
test_files.append(exercise.test_file.name)
74+
for test_file_name in test_files:
75+
test_file = exercise.path / test_file_name
76+
test_file_out = workdir / test_file_name
77+
copy_file(test_file, test_file_out, strip_skips=(exercise.slug not in ALLOW_SKIP))
78+
79+
80+
def copy_exercise_files(exercise: ExerciseInfo, workdir: Path):
81+
exercise_config = None
82+
if exercise.config_file.is_file():
83+
workdir_meta = workdir / '.meta'
84+
workdir_meta.mkdir(exist_ok=True)
85+
copy_file(exercise.config_file, workdir_meta / exercise.config_file.name)
86+
exercise_config = exercise.load_config()
87+
copy_solution_files(exercise, workdir, exercise_config)
88+
copy_test_files(exercise, workdir, exercise_config)
89+
90+
91+
@runner('pytest')
92+
def run_with_pytest(_exercise, workdir, quiet: bool = False) -> int:
93+
kwargs = {'cwd': str(workdir)}
94+
if quiet:
95+
kwargs['stdout'] = subprocess.DEVNULL
96+
kwargs['stderr'] = subprocess.DEVNULL
97+
return subprocess.run([sys.executable, '-m', 'pytest'], **kwargs).returncode
98+
99+
100+
@runner('test-runner')
101+
def run_with_test_runner(exercise, workdir, quiet: bool = False) -> int:
102+
kwargs = {}
103+
if quiet:
104+
kwargs['stdout'] = subprocess.DEVNULL
105+
kwargs['stderr'] = subprocess.DEVNULL
106+
if TEST_RUNNER_DIR.is_dir():
107+
kwargs['cwd'] = str(TEST_RUNNER_DIR)
108+
args = ['./bin/run.sh', exercise.slug, workdir, workdir]
109+
else:
110+
args = [
111+
'docker-compose',
112+
'run',
113+
'-w', str(TEST_RUNNER_DIR),
114+
'--entrypoint', './bin/run.sh',
115+
'-v', f'{workdir}:/{exercise.slug}',
116+
'test-runner',
117+
exercise.slug,
118+
f'/{exercise.slug}',
119+
f'/{exercise.slug}',
120+
]
121+
subprocess.run(args, **kwargs)
122+
results_file = workdir / 'results.json'
123+
if results_file.is_file():
124+
with results_file.open() as f:
125+
results = json.load(f)
126+
if results['status'] == 'pass':
127+
return 0
128+
return 1
129+
130+
131+
def check_assignment(exercise: ExerciseInfo, runner: str = 'pytest', quiet: bool = False) -> int:
132+
ret = 1
133+
with tempfile.TemporaryDirectory(exercise.slug) as workdir:
134+
workdir = Path(workdir)
135+
copy_exercise_files(exercise, workdir)
136+
ret = RUNNERS[runner](exercise, workdir, quiet=quiet)
137+
return ret
138+
139+
140+
def get_cli() -> argparse.ArgumentParser:
141+
parser = argparse.ArgumentParser()
142+
runners = list(RUNNERS.keys())
143+
if not runners:
144+
print('No runners registered!')
145+
raise SystemExit(1)
146+
parser.add_argument('-q', '--quiet', action='store_true')
147+
parser.add_argument('-r', '--runner', choices=runners, default=runners[0])
148+
parser.add_argument('exercises', nargs='*')
149+
return parser
37150

38151

39152
def main():
153+
opts = get_cli().parse_args()
40154
config = Config.load()
41155
exercises = config.exercises.all()
42-
if len(sys.argv) >= 2:
156+
if opts.exercises:
43157
# test specific exercises
44158
exercises = [
45-
e for e in exercises if e.slug in sys.argv[1:]
159+
e for e in exercises if e.slug in opts.exercises
46160
]
161+
not_found = [
162+
slug for slug in opts.exercises
163+
if not any(e.slug == slug for e in exercises)
164+
]
165+
if not_found:
166+
for slug in not_found:
167+
if slug not in exercises:
168+
print(f"unknown exercise '{slug}'")
169+
raise SystemExit(1)
170+
171+
print(f'TestEnvironment: {sys.executable.capitalize()}')
172+
print(f'Runner: {opts.runner}\n\n')
47173

48174
failures = []
49175
for exercise in exercises:
@@ -52,18 +178,16 @@ def main():
52178
print('FAIL: File with test cases not found')
53179
failures.append('{} (FileNotFound)'.format(exercise.slug))
54180
else:
55-
if check_assignment(exercise):
181+
if check_assignment(exercise, runner=opts.runner, quiet=opts.quiet):
56182
failures.append('{} (TestFailed)'.format(exercise.slug))
57183
print('')
58184

59-
print('TestEnvironment:', sys.executable.capitalize(), '\n\n')
60-
61185
if failures:
62186
print('FAILURES: ', ', '.join(failures))
63187
raise SystemExit(1)
64188
else:
65189
print('SUCCESS!')
66190

67191

68-
if __name__ == '__main__':
192+
if __name__ == "__main__":
69193
main()

bin/test_runner_exercises.py

-77
This file was deleted.

docker-compose.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ services:
44
test-runner:
55
image: exercism/python-test-runner
66
working_dir: /python
7-
entrypoint: ./bin/test_runner_exercises.py
7+
entrypoint: ./bin/test_exercises.py --runner test-runner
88
volumes:
99
- .:/python
+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{
22
"files": {
3-
"test": ["paasio_test.py", "test_utils.py"]
3+
"solution": ["paasio.py"],
4+
"test": ["paasio_test.py", "test_utils.py"],
5+
"exemplar": [".meta/example.py"]
46
}
57
}

0 commit comments

Comments
 (0)