1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
|
#!/usr/bin/env python3
"""Validate newOS4 course content. Exit 0 = publishable, 1 = errors printed."""
import json, os, glob, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COURSE = os.path.join(ROOT, 'course')
ATLAS = os.path.join(COURSE, 'atlas.json')
errors, warnings = [], []
def load(p):
try:
with open(p, encoding='utf-8') as f:
return json.load(f)
except Exception as e:
errors.append(f'{os.path.relpath(p, ROOT)}: invalid JSON: {e}')
return None
def main():
manifest = load(os.path.join(COURSE, 'course.json'))
if not manifest:
return finish()
ids, lesson_ids = set(), set()
for ch in manifest.get('chapters', []):
cid = ch.get('id')
if not cid:
errors.append('course.json: chapter missing id'); continue
if cid in ids:
errors.append(f'course.json: duplicate chapter id {cid}')
ids.add(cid)
path = os.path.join(COURSE, 'chapters', cid + '.json')
if not os.path.exists(path):
errors.append(f'missing chapter file {cid}.json'); continue
chd = load(path)
if not chd: continue
man_ids = [x.get('id') for x in ch.get('lessons', [])]
file_ids = [x.get('id') for x in chd.get('lessons', [])]
if man_ids != file_ids:
errors.append(f'{cid}: manifest lessons {man_ids} != chapter file lessons {file_ids}')
for l in chd.get('lessons', []):
lid = l.get('id')
if not lid:
errors.append(f'{cid}: lesson missing id'); continue
if lid in lesson_ids:
errors.append(f'duplicate lesson id {lid}')
lesson_ids.add(lid)
for k in ('title', 'objective'):
if not l.get(k): errors.append(f'{lid}: missing {k}')
mins = l.get('minutes', 0)
if not isinstance(mins, int) or isinstance(mins, bool) or mins < 1:
errors.append(f'{lid}: minutes must be >=1')
blocks = l.get('blocks') or []
if len(blocks) < 3: errors.append(f'{lid}: needs >=3 blocks')
for b in blocks:
if b.get('type') not in ('text', 'code', 'cmd', 'tip', 'warn', 'table'):
errors.append(f'{lid}: bad block type {b.get("type")!r}')
ex = l.get('exercise')
if ex:
if not ex.get('instruction') or not ex.get('accept'):
errors.append(f'{lid}: exercise needs instruction+accept')
chk = ex.get('check', {})
if chk.get('type') not in ('command', 'file', 'output', 'manual'):
errors.append(f'{lid}: exercise check.type invalid')
if chk.get('type') == 'file' and not chk.get('path'):
errors.append(f'{lid}: file check requires path')
if chk.get('type') == 'output' and not chk.get('contains'):
errors.append(f'{lid}: output check requires contains')
if not ex.get('hint'): warnings.append(f'{lid}: exercise without hint')
if l.get('generated') and not l.get('sources'):
errors.append(f'{lid}: generated lesson needs sources')
if os.path.exists(ATLAS):
atlas = load(ATLAS) or {}
known = set(atlas.get('commands', {}))
for f in glob.glob(os.path.join(COURSE, 'chapters', '*.json')):
chd = load(f) or {}
for l in chd.get('lessons', []):
for c in l.get('commands', []):
if c not in known:
errors.append(f'{l.get("id")}: unknown command {c}')
for name, key in (('feed.json', 'items'), ('faq.json', 'items')):
p = os.path.join(COURSE, name)
if not os.path.exists(p): continue
d = load(p) or {}
for it in d.get(key, []):
if isinstance(it, dict) and it.get('date') is None:
warnings.append(f'{name}: item without date')
return finish()
def finish():
for w in warnings: print(f'WARN {w}')
for e in errors: print(f'ERROR {e}')
print(f'course-validate: {len(errors)} error(s), {len(warnings)} warning(s)')
return 1 if errors else 0
if __name__ == '__main__':
sys.exit(main())
|