aboutsummaryrefslogtreecommitdiff
path: root/scripts/course-validate.py
blob: 9b5dcf4c98e15cebb2d1a4d4636aedeef2245ed4 (plain)
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())