Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update track tooling for v3 structure changes #2311

Merged
merged 10 commits into from
Feb 2, 2021
8 changes: 3 additions & 5 deletions .github/workflows/ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

# TODO: cmccandless: directory and config.json structure changed in v3 migration,
# updates needed to tooling
# - name: Check exercises
# run: |
# ./test/check-exercises.py
- name: Check exercises
run: |
./bin/test-exercises.py
165 changes: 165 additions & 0 deletions bin/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from dataclasses import dataclass, asdict
import json
from pathlib import Path
from typing import List, Any


@dataclass
class TrackStatus:
concept_exercises: bool = False
test_runner: bool = False
representer: bool = False
analyzer: bool = False


@dataclass
class EditorSettings:
indent_style: str = 'space'
indent_size: int = 4


@dataclass
class ExerciseInfo:
path: Path
slug: str
name: str
uuid: str
prerequisites: List[str]
type: str = 'practice'
deprecated: bool = False

# concept only
concepts: List[str] = None
status: str = 'wip'

# practice only
difficulty: int = 1
topics: List[str] = None
practices: List[str] = None

def __post_init__(self):
if self.concepts is None:
self.concepts = []
if self.topics is None:
self.topics = []
if self.practices is None:
self.practices = []

@property
def solution_stub(self):
return next((
p for p in self.path.glob('*.py')
if not p.name.endswith('_test.py') and
p.name != 'example.py'
), None)

@property
def test_file(self):
return next(self.path.glob('*_test.py'), None)

@property
def meta_dir(self):
return self.path / '.meta'

@property
def exemplar_file(self):
if self.type == 'concept':
return self.meta_dir / 'exemplar.py'
# return self.meta_dir / 'example.py'
return self.path / 'example.py'

@property
def template_path(self):
return self.meta_dir / '.template.j2'


@dataclass
class Exercises:
concept: List[ExerciseInfo]
practice: List[ExerciseInfo]
foregone: List[str] = None

def __post_init__(self):
if self.foregone is None:
self.foregone = []
for attr_name in ['concept', 'practice']:
base_path = Path('exercises') / attr_name
setattr(
self,
attr_name,
[
(
ExerciseInfo(
path=(base_path / e['slug']),
type=attr_name,
**e
) if isinstance(e, dict) else e
)
for e in getattr(self, attr_name)
]
)

def all(self, include_deprecated=False):
_all = self.concept + self.practice
if not include_deprecated:
_all = [e for e in _all if not e.deprecated]
return _all


@dataclass
class Concept:
uuid: str
slug: str
name: str
blurb: str


@dataclass
class Config:
language: str
slug: str
active: bool
status: TrackStatus
blurb: str
version: int
online_editor: EditorSettings
exercises: Exercises
concepts: List[Concept]
key_features: List[Any] = None
tags: List[Any] = None

def __post_init__(self):
if isinstance(self.status, dict):
self.status = TrackStatus(**self.status)
if isinstance(self.online_editor, dict):
self.online_editor = EditorSettings(**self.online_editor)
if isinstance(self.exercises, dict):
self.exercises = Exercises(**self.exercises)
self.concepts = [
(Concept(**c) if isinstance(c, dict) else c)
for c in self.concepts
]
if self.key_features is None:
self.key_features = []
if self.tags is None:
self.tags = []

@classmethod
def load(cls, path='config.json'):
try:
with Path(path).open() as f:
return cls(**json.load(f))
except IOError:
print(f'FAIL: {path} file not found')
raise SystemExit(1)


if __name__ == "__main__":
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Path):
return str(obj)
return json.JSONEncoder.default(self, obj)

config = Config.load()
print(json.dumps(asdict(config), cls=CustomEncoder, indent=2))
65 changes: 65 additions & 0 deletions bin/test-exercises.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3

import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

from data import Config, ExerciseInfo

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


def check_assignment(exercise: ExerciseInfo) -> int:
# Returns the exit code of the tests
workdir = Path(tempfile.mkdtemp(exercise.slug))
solution_file = exercise.solution_stub.name
try:
test_file_out = workdir / exercise.test_file.name
if exercise.slug in ALLOW_SKIP:
shutil.copyfile(exercise.test_file, test_file_out)
else:
with exercise.test_file.open('r') as src_file:
lines = [line for line in src_file.readlines()
if not line.strip().startswith('@unittest.skip')]
with test_file_out.open('w') as dst_file:
dst_file.writelines(lines)
shutil.copyfile(exercise.exemplar_file, workdir / solution_file)
return subprocess.call([sys.executable, test_file_out])
finally:
shutil.rmtree(workdir)


def main():
config = Config.load()
exercises = config.exercises.all()
if len(sys.argv) >= 2:
# test specific exercises
exercises = [
e for e in exercises if e.slug in sys.argv[1:]
]

failures = []
for exercise in exercises:
print('# ', exercise.slug)
if not exercise.test_file:
print('FAIL: File with test cases not found')
failures.append('{} (FileNotFound)'.format(exercise.slug))
else:
if check_assignment(exercise):
failures.append('{} (TestFailed)'.format(exercise))
print('')

print('TestEnvironment:', sys.executable.capitalize(), '\n\n')

if failures:
print('FAILURES: ', ', '.join(failures))
raise SystemExit(1)
else:
print('SUCCESS!')


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ def _total(books):
def total(books):
if not books:
return 0
return _total(sorted(books))
return _total(sorted(books))
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ def convert(number):
"""
Converts a number to a string according to the raindrop sounds.
"""

result = ''
if number % 3 == 0:
result += 'Pling'
Expand Down
88 changes: 0 additions & 88 deletions test/check-exercises.py

This file was deleted.