Skip to content

Commit 66edade

Browse files
committed
various tooling improvements
1 parent d47202b commit 66edade

4 files changed

Lines changed: 139 additions & 104 deletions

File tree

.github/workflows/ci-workflow.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
run: |
2626
python -m pip install --upgrade pip
2727
pip install flake8 requests
28-
git clone https://github.com/exercism/problem-specifications spec
28+
git clone https://github.com/exercism/problem-specifications .problem-specifications
2929
pip install -r requirements-generator.txt
3030
3131
# - name: Check readmes
@@ -34,7 +34,7 @@ jobs:
3434

3535
- name: Generate tests
3636
run: |
37-
bin/generate_tests.py --verbose -p spec --check
37+
bin/generate_tests.py --verbose -p .problem-specifications --check
3838
3939
- name: Lint with flake8
4040
run: |
@@ -46,7 +46,7 @@ jobs:
4646
- name: Test template status
4747
continue-on-error: true
4848
run: |
49-
./bin/template_status.py -v -p spec
49+
./bin/template_status.py -v -p .problem-specifications
5050
5151
canonical_sync:
5252
runs-on: ubuntu-16.04

bin/generate_tests.py

Lines changed: 39 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414
"""
1515
import sys
1616

17+
from githelp import Repo
18+
1719
_py = sys.version_info
1820
if _py.major < 3 or (_py.major == 3 and _py.minor < 6):
1921
print("Python version must be at least 3.6")
2022
sys.exit(1)
2123

2224
import argparse
23-
from contextlib import contextmanager
2425
from datetime import datetime
2526
import difflib
2627
import filecmp
@@ -41,6 +42,8 @@
4142
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, UndefinedError
4243
from dateutil.parser import parse
4344

45+
from githelp import clone_if_missing, Repo
46+
4447
VERSION = "0.3.0"
4548

4649
TypeJSON = Dict[str, Any]
@@ -258,21 +261,41 @@ def format_file(path: Path) -> NoReturn:
258261
check_call(["black", "-q", path])
259262

260263

261-
@contextmanager
262-
def clone_if_missing(repo: str, directory: Union[str, Path, None] = None):
263-
if directory is None:
264-
directory = repo.split("/")[-1].split(".")[0]
265-
directory = Path(directory)
266-
if not directory.is_dir():
267-
temp_clone = True
268-
check_call(["git", "clone", repo, str(directory)])
269-
else:
270-
temp_clone = False
264+
def check_template(slug: str, tests_path: Path, tmpfile: Path):
271265
try:
272-
yield directory
266+
check_ok = True
267+
if not tmpfile.is_file():
268+
logger.debug(f"{slug}: tmp file {tmpfile} not found")
269+
check_ok = False
270+
if not tests_path.is_file():
271+
logger.debug(f"{slug}: tests file {tests_path} not found")
272+
check_ok = False
273+
if check_ok and not filecmp.cmp(tmpfile, tests_path):
274+
with tests_path.open() as f:
275+
current_lines = f.readlines()
276+
with tmpfile.open() as f:
277+
rendered_lines = f.readlines()
278+
diff = difflib.unified_diff(
279+
current_lines,
280+
rendered_lines,
281+
fromfile=f"[current] {tests_path.name}",
282+
tofile=f"[generated] {tmpfile.name}",
283+
)
284+
logger.debug(f"{slug}: ##### DIFF START #####")
285+
for line in diff:
286+
logger.debug(line.strip())
287+
logger.debug(f"{slug}: ##### DIFF END #####")
288+
check_ok = False
289+
if not check_ok:
290+
logger.error(
291+
f"{slug}: check failed; tests must be regenerated with bin/generate_tests.py"
292+
)
293+
return False
294+
logger.debug(f"{slug}: check passed")
273295
finally:
274-
if temp_clone:
275-
shutil.rmtree(directory)
296+
logger.debug(f"{slug}: removing tmp file {tmpfile}")
297+
tmpfile.unlink()
298+
return True
276299

277300

278301
def generate_exercise(env: Environment, spec_path: Path, exercise: Path, check: bool = False):
@@ -322,39 +345,7 @@ def generate_exercise(env: Environment, spec_path: Path, exercise: Path, check:
322345
return False
323346

324347
if check:
325-
try:
326-
check_ok = True
327-
if not tmpfile.is_file():
328-
logger.debug(f"{slug}: tmp file {tmpfile} not found")
329-
check_ok = False
330-
if not tests_path.is_file():
331-
logger.debug(f"{slug}: tests file {tests_path} not found")
332-
check_ok = False
333-
if check_ok and not filecmp.cmp(tmpfile, tests_path):
334-
with tests_path.open() as f:
335-
current_lines = f.readlines()
336-
with tmpfile.open() as f:
337-
rendered_lines = f.readlines()
338-
diff = difflib.unified_diff(
339-
current_lines,
340-
rendered_lines,
341-
fromfile=f"[current] {tests_path.name}",
342-
tofile=f"[generated] {tmpfile.name}",
343-
)
344-
logger.debug(f"{slug}: ##### DIFF START #####")
345-
for line in diff:
346-
logger.debug(line.strip())
347-
logger.debug(f"{slug}: ##### DIFF END #####")
348-
check_ok = False
349-
if not check_ok:
350-
logger.error(
351-
f"{slug}: check failed; tests must be regenerated with bin/generate_tests.py"
352-
)
353-
return False
354-
logger.debug(f"{slug}: check passed")
355-
finally:
356-
logger.debug(f"{slug}: removing tmp file {tmpfile}")
357-
tmpfile.unlink()
348+
return check_template(slug, tests_path, tmpfile)
358349
else:
359350
logger.debug(f"{slug}: moving tmp file {tmpfile}->{tests_path}")
360351
shutil.move(tmpfile, tests_path)
@@ -435,5 +426,5 @@ def generate(
435426
opts = parser.parse_args()
436427
if opts.verbose:
437428
logger.setLevel(logging.DEBUG)
438-
with clone_if_missing(repo=PROBLEM_SPEC_REPO, directory=opts.spec_path):
429+
with clone_if_missing(repo=Repo.ProblemSpecifications, directory=opts.spec_path):
439430
generate(**opts.__dict__)

bin/githelp.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from contextlib import contextmanager
2+
from enum import Enum
3+
from pathlib import Path
4+
import shutil
5+
import subprocess
6+
from typing import Iterator, Union
7+
8+
9+
GITHUB_EXERCISM = f"https://github.com/exercism"
10+
11+
12+
class Repo(Enum):
13+
ProblemSpecifications = f"{GITHUB_EXERCISM}/problem-specifications.git"
14+
15+
16+
17+
def clone(repo: Union[str, Repo], directory: Union[str, Path, None] = None) -> bool:
18+
if isinstance(repo, Repo):
19+
repo = repo.value
20+
if directory is None:
21+
directory = repo.split("/")[-1].split(".")[0]
22+
directory = Path(directory)
23+
if not directory.is_dir():
24+
try:
25+
subprocess.run(["git", "clone", repo, str(directory)], check=True)
26+
return True
27+
except subprocess.CalledProcessError:
28+
pass
29+
return False
30+
31+
32+
@contextmanager
33+
def clone_if_missing(repo: Union[str, Repo], directory: Union[str, Path, None] = None) -> Iterator[None]:
34+
temp_clone = clone(repo, directory)
35+
try:
36+
yield directory
37+
finally:
38+
if temp_clone:
39+
shutil.rmtree(directory)

bin/template_status.py

Lines changed: 58 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,35 @@
11
#!/usr/bin/env python3.7
22
import argparse
3-
from enum import Enum, auto
3+
from argparse import Namespace
4+
from enum import IntEnum, auto
45
from fnmatch import fnmatch
5-
import json
66
import logging
77
from pathlib import Path
88
import shlex
99
from subprocess import check_call, DEVNULL, CalledProcessError
1010
import sys
11+
from typing import Dict, List, Any, Iterator
1112

1213
from data import Config, ExerciseInfo
14+
from generate_tests import clone_if_missing
15+
from githelp import Repo
1316
from test_exercises import check_assignment
1417

15-
DEFAULT_SPEC_LOCATION = Path('spec')
18+
DEFAULT_SPEC_LOCATION = Path('.problem-specifications')
1619

1720
logging.basicConfig(format="%(levelname)s:%(message)s")
1821
logger = logging.getLogger("generator")
1922
logger.setLevel(logging.WARN)
2023

2124

22-
class TemplateStatus(Enum):
25+
class TemplateStatus(IntEnum):
2326
OK = auto()
2427
MISSING = auto()
2528
INVALID = auto()
2629
TEST_FAILURE = auto()
2730

28-
def __lt__(self, other):
29-
return self.value < other.value
3031

31-
32-
def exec_cmd(cmd):
32+
def exec_cmd(cmd: str) -> bool:
3333
try:
3434
args = shlex.split(cmd)
3535
if logger.isEnabledFor(logging.DEBUG):
@@ -51,7 +51,7 @@ def run_tests(exercise: ExerciseInfo) -> bool:
5151
return check_assignment(exercise, quiet=True) == 0
5252

5353

54-
def get_status(exercise: ExerciseInfo, spec_path: Path):
54+
def get_status(exercise: ExerciseInfo, spec_path: Path) -> TemplateStatus:
5555
if exercise.template_path.is_file():
5656
if generate_template(exercise, spec_path):
5757
if run_tests(exercise):
@@ -65,6 +65,25 @@ def get_status(exercise: ExerciseInfo, spec_path: Path):
6565
return TemplateStatus.MISSING
6666

6767

68+
def set_loglevel(opts: Namespace):
69+
if opts.quiet:
70+
logger.setLevel(logging.FATAL)
71+
elif opts.verbose >= 2:
72+
logger.setLevel(logging.DEBUG)
73+
elif opts.verbose >= 1:
74+
logger.setLevel(logging.INFO)
75+
76+
77+
def filter_exercises(exercises: List[ExerciseInfo], pattern: str) -> Iterator[ExerciseInfo]:
78+
for exercise in exercises:
79+
if not exercise.get("deprecated", False):
80+
if exercise.type == 'concept':
81+
# Concept exercises are not generated
82+
continue
83+
if fnmatch(exercise["slug"], pattern):
84+
yield exercise
85+
86+
6887
if __name__ == "__main__":
6988
parser = argparse.ArgumentParser()
7089
parser.add_argument("exercise_pattern", nargs="?", default="*", metavar="EXERCISE")
@@ -81,51 +100,37 @@ def get_status(exercise: ExerciseInfo, spec_path: Path):
81100
),
82101
)
83102
opts = parser.parse_args()
84-
if opts.quiet:
85-
logger.setLevel(logging.FATAL)
86-
elif opts.verbose >= 2:
87-
logger.setLevel(logging.DEBUG)
88-
elif opts.verbose >= 1:
89-
logger.setLevel(logging.INFO)
103+
set_loglevel(opts)
90104

91105
if not opts.spec_path.is_dir():
92106
logger.error(f"{opts.spec_path} is not a directory")
93107
sys.exit(1)
94-
opts.spec_path = opts.spec_path.absolute()
95-
logger.debug(f"problem-specifications path is {opts.spec_path}")
96-
97-
result = True
98-
buckets = {
99-
TemplateStatus.MISSING: [],
100-
TemplateStatus.INVALID: [],
101-
TemplateStatus.TEST_FAILURE: [],
102-
}
103-
config = Config.load()
104-
for exercise in filter(
105-
lambda e: fnmatch(e.slug, opts.exercise_pattern),
106-
config.exercises.all()
107-
):
108-
if exercise.deprecated:
109-
continue
110-
if exercise.type == 'concept':
111-
# Concept exercises are not generated
112-
continue
113-
status = get_status(exercise, opts.spec_path)
114-
if status == TemplateStatus.OK:
115-
logger.info(f"{exercise.slug}: {status.name}")
116-
else:
117-
buckets[status].append(exercise.slug)
118-
result = False
119-
if opts.stop_on_failure:
120-
logger.error(f"{exercise.slug}: {status.name}")
121-
break
122-
123-
if not opts.quiet and not opts.stop_on_failure:
124-
for status, bucket in sorted(buckets.items()):
125-
if bucket:
126-
print(f"The following exercises have status '{status.name}'")
127-
for exercise in sorted(bucket):
128-
print(f' {exercise}')
129-
130-
if not result:
131-
sys.exit(1)
108+
with clone_if_missing(repo=Repo.ProblemSpecifications, directory=opts.spec_path):
109+
110+
result = True
111+
buckets = {
112+
TemplateStatus.MISSING: [],
113+
TemplateStatus.INVALID: [],
114+
TemplateStatus.TEST_FAILURE: [],
115+
}
116+
config = Config.load()
117+
for exercise in filter_exercises(config.exercises.all()):
118+
status = get_status(exercise, opts.spec_path)
119+
if status == TemplateStatus.OK:
120+
logger.info(f"{exercise.slug}: {status.name}")
121+
else:
122+
buckets[status].append(exercise.slug)
123+
result = False
124+
if opts.stop_on_failure:
125+
logger.error(f"{exercise.slug}: {status.name}")
126+
break
127+
128+
if not opts.quiet and not opts.stop_on_failure:
129+
for status, bucket in sorted(buckets.items()):
130+
if bucket:
131+
print(f"The following exercises have status '{status.name}'")
132+
for exercise in sorted(bucket):
133+
print(f' {exercise}')
134+
135+
if not result:
136+
sys.exit(1)

0 commit comments

Comments
 (0)