aboutsummaryrefslogtreecommitdiff
path: root/scripts/course-validate.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/course-validate.py')
-rw-r--r--scripts/course-validate.py95
1 files changed, 95 insertions, 0 deletions
diff --git a/scripts/course-validate.py b/scripts/course-validate.py
new file mode 100644
index 0000000..9b5dcf4
--- /dev/null
+++ b/scripts/course-validate.py
@@ -0,0 +1,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())