Checking ends of lines
[rnaseq-cwl-training.git] / bin / lesson_check.py
1 #!/usr/bin/env python
2
3 """
4 Check lesson files and their contents.
5 """
6
7 import sys
8 import os
9 import glob
10 import json
11 import re
12 from optparse import OptionParser
13
14 from util import Reporter, read_markdown, load_yaml
15
16 __version__ = '0.2'
17
18 # Where to look for source Markdown files.
19 SOURCE_DIRS = ['', '_episodes', '_extras']
20
21 # Required files: each entry is ('path': YAML_required).
22 # FIXME: We do not yet validate whether any files have the required
23 #   YAML headers, but should in the future.
24 # The '%' is replaced with the source directory path for checking.
25 # Episodes are handled specially, and extra files in '_extras' are also handled specially.
26 # This list must include all the Markdown files listed in the 'bin/initialize' script.
27 REQUIRED_FILES = {
28     '%/CONDUCT.md': True,
29     '%/CONTRIBUTING.md': False,
30     '%/LICENSE.md': True,
31     '%/README.md': False,
32     '%/_extras/discuss.md': True,
33     '%/_extras/figures.md': True,
34     '%/_extras/guide.md': True,
35     '%/index.md': True,
36     '%/reference.md': True,
37     '%/setup.md': True,
38 }
39
40 # Episode filename pattern.
41 P_EPISODE_FILENAME = re.compile(r'/_episodes/(\d\d)-[-\w]+.md$')
42
43 # What kinds of blockquotes are allowed?
44 KNOWN_BLOCKQUOTES = {
45     'callout',
46     'challenge',
47     'checklist',
48     'discussion',
49     'keypoints',
50     'objectives',
51     'prereq',
52     'quotation',
53     'solution',
54     'testimonial'
55 }
56
57 # What kinds of code fragments are allowed?
58 KNOWN_CODEBLOCKS = {
59     'error',
60     'output',
61     'source',
62     'bash',
63     'make',
64     'python',
65     'r',
66     'sql'
67 }
68
69 # What fields are required in teaching episode metadata?
70 TEACHING_METADATA_FIELDS = {
71     ('title', str),
72     ('teaching', int),
73     ('exercises', int),
74     ('questions', list),
75     ('objectives', list),
76     ('keypoints', list)
77 }
78
79 # What fields are required in break episode metadata?
80 BREAK_METADATA_FIELDS = {
81     ('layout', str),
82     ('title', str),
83     ('break', int)
84 }
85
86 # How long are lines allowed to be?
87 MAX_LINE_LEN = 100
88
89 def main():
90     """Main driver."""
91
92     args = parse_args()
93     args.reporter = Reporter()
94     check_config(args.reporter, args.source_dir)
95     docs = read_all_markdown(args.source_dir, args.parser)
96     check_fileset(args.source_dir, args.reporter, docs.keys())
97     for filename in docs.keys():
98         checker = create_checker(args, filename, docs[filename])
99         checker.check()
100     args.reporter.report()
101
102
103 def parse_args():
104     """Parse command-line arguments."""
105
106     parser = OptionParser()
107     parser.add_option('-l', '--linelen',
108                       default=False,
109                       dest='line_lengths',
110                       help='Check line lengths')
111     parser.add_option('-p', '--parser',
112                       default=None,
113                       dest='parser',
114                       help='path to Markdown parser')
115     parser.add_option('-s', '--source',
116                       default=os.curdir,
117                       dest='source_dir',
118                       help='source directory')
119     parser.add_option('-w', '--whitespace',
120                       default=False,
121                       dest='trailing_whitespace',
122                       help='Check for trailing whitespace')
123
124     args, extras = parser.parse_args()
125     require(args.parser is not None,
126             'Path to Markdown parser not provided')
127     require(not extras,
128             'Unexpected trailing command-line arguments "{0}"'.format(extras))
129
130     return args
131
132
133 def check_config(reporter, source_dir):
134     """Check configuration file."""
135
136     config_file = os.path.join(source_dir, '_config.yml')
137     config = load_yaml(config_file)
138     reporter.check_field(config_file, 'configuration', config, 'kind', 'lesson')
139
140
141 def read_all_markdown(source_dir, parser):
142     """Read source files, returning
143     {path : {'metadata':yaml, 'metadata_len':N, 'text':text, 'lines':[(i, line, len)], 'doc':doc}}
144     """
145
146     all_dirs = [os.path.join(source_dir, d) for d in SOURCE_DIRS]
147     all_patterns = [os.path.join(d, '*.md') for d in all_dirs]
148     result = {}
149     for pat in all_patterns:
150         for filename in glob.glob(pat):
151             data = read_markdown(parser, filename)
152             if data:
153                 result[filename] = data
154     return result
155
156
157 def check_fileset(source_dir, reporter, filenames_present):
158     """Are all required files present? Are extraneous files present?"""
159
160     # Check files with predictable names.
161     required = [p.replace('%', source_dir) for p in REQUIRED_FILES]
162     missing = set(required) - set(filenames_present)
163     for m in missing:
164         reporter.add(None, 'Missing required file {0}', m)
165
166     # Check episode files' names.
167     seen = []
168     for filename in filenames_present:
169         if '_episodes' not in filename:
170             continue
171         m = P_EPISODE_FILENAME.search(filename)
172         if m and m.group(1):
173             seen.append(m.group(1))
174         else:
175             reporter.add(None, 'Episode {0} has badly-formatted filename', filename)
176
177     # Check for duplicate episode numbers.
178     reporter.check(len(seen) == len(set(seen)),
179                         None,
180                         'Duplicate episode numbers {0} vs {1}',
181                         sorted(seen), sorted(set(seen)))
182
183     # Check that numbers are consecutive.
184     seen = [int(s) for s in seen]
185     seen.sort()
186     clean = True
187     for i in range(len(seen) - 1):
188         clean = clean and ((seen[i+1] - seen[i]) == 1)
189     reporter.check(clean,
190                    None,
191                    'Missing or non-consecutive episode numbers {0}',
192                    seen)
193
194
195 def create_checker(args, filename, info):
196     """Create appropriate checker for file."""
197
198     for (pat, cls) in CHECKERS:
199         if pat.search(filename):
200             return cls(args, filename, **info)
201
202
203 def require(condition, message):
204     """Fail if condition not met."""
205
206     if not condition:
207         print(message, file=sys.stderr)
208         sys.exit(1)
209
210
211 class CheckBase(object):
212     """Base class for checking Markdown files."""
213
214     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
215         """Cache arguments for checking."""
216
217         super(CheckBase, self).__init__()
218         self.args = args
219         self.reporter = self.args.reporter # for convenience
220         self.filename = filename
221         self.metadata = metadata
222         self.metadata_len = metadata_len
223         self.text = text
224         self.lines = lines
225         self.doc = doc
226
227         self.layout = None
228
229
230     def check(self):
231         """Run tests on metadata."""
232
233         self.check_metadata()
234         self.check_line_lengths()
235         self.check_trailing_whitespace()
236         self.check_blockquote_classes()
237         self.check_codeblock_classes()
238
239
240     def check_metadata(self):
241         """Check the YAML metadata."""
242
243         self.reporter.check(self.metadata is not None,
244                             self.filename,
245                             'Missing metadata entirely')
246
247         if self.metadata and (self.layout is not None):
248             self.reporter.check_field(self.filename, 'metadata', self.metadata, 'layout', self.layout)
249
250
251     def check_line_lengths(self):
252         """Check the raw text of the lesson body."""
253
254         if self.args.line_lengths:
255             over = [i for (i, l, n) in self.lines if (n > MAX_LINE_LEN) and (not l.startswith('!'))]
256             self.reporter.check(not over,
257                                 self.filename,
258                                 'Line(s) are too long: {0}',
259                                 ', '.join([str(i) for i in over]))
260
261
262     def check_trailing_whitespace(self):
263         """Check for whitespace at the ends of lines."""
264
265         if self.args.trailing_whitespace:
266             trailing = [i for (i, l, n) in self.lines if l.endswidth(' ')]
267             self.reporter.check(not trailing,
268                                 self.filename,
269                                 'Line(s) end with whitespace: {0}',
270                                 ', '.join([str[i] for i in over]))
271
272
273     def check_blockquote_classes(self):
274         """Check that all blockquotes have known classes."""
275
276         for node in self.find_all(self.doc, {'type' : 'blockquote'}):
277             cls = self.get_val(node, 'attr', 'class')
278             self.reporter.check(cls in KNOWN_BLOCKQUOTES,
279                                 (self.filename, self.get_loc(node)),
280                                 'Unknown or missing blockquote type {0}',
281                                 cls)
282
283
284     def check_codeblock_classes(self):
285         """Check that all code blocks have known classes."""
286
287         for node in self.find_all(self.doc, {'type' : 'codeblock'}):
288             cls = self.get_val(node, 'attr', 'class')
289             self.reporter.check(cls in KNOWN_CODEBLOCKS,
290                                 (self.filename, self.get_loc(node)),
291                                 'Unknown or missing code block type {0}',
292                                 cls)
293
294
295     def find_all(self, node, pattern, accum=None):
296         """Find all matches for a pattern."""
297
298         assert type(pattern) == dict, 'Patterns must be dictionaries'
299         if accum is None:
300             accum = []
301         if self.match(node, pattern):
302             accum.append(node)
303         for child in node.get('children', []):
304             self.find_all(child, pattern, accum)
305         return accum
306
307
308     def match(self, node, pattern):
309         """Does this node match the given pattern?"""
310
311         for key in pattern:
312             if key not in node:
313                 return False
314             val = pattern[key]
315             if type(val) == str:
316                 if node[key] != val:
317                     return False
318             elif type(val) == dict:
319                 if not self.match(node[key], val):
320                     return False
321         return True
322
323
324     def get_val(self, node, *chain):
325         """Get value one or more levels down."""
326
327         curr = node
328         for selector in chain:
329             curr = curr.get(selector, None)
330             if curr is None:
331                 break
332         return curr
333
334
335     def get_loc(self, node):
336         """Convenience method to get node's line number."""
337
338         result = self.get_val(node, 'options', 'location')
339         if self.metadata_len is not None:
340             result += self.metadata_len
341         return result
342
343
344 class CheckNonJekyll(CheckBase):
345     """Check a file that isn't translated by Jekyll."""
346
347     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
348         super(CheckNonJekyll, self).__init__(args, filename, metadata, metadata_len, text, lines, doc)
349
350
351     def check_metadata(self):
352         self.reporter.check(self.metadata is None,
353                             self.filename,
354                             'Unexpected metadata')
355
356
357 class CheckIndex(CheckBase):
358     """Check the main index page."""
359
360     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
361         super(CheckIndex, self).__init__(args, filename, metadata, metadata_len, text, lines, doc)
362         self.layout = 'lesson'
363
364
365 class CheckEpisode(CheckBase):
366     """Check an episode page."""
367
368     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
369         super(CheckEpisode, self).__init__(args, filename, metadata, metadata_len, text, lines, doc)
370
371     def check_metadata(self):
372         super(CheckEpisode, self).check_metadata()
373         if self.metadata:
374             if 'layout' in self.metadata:
375                 if self.metadata['layout'] == 'break':
376                     self.check_metadata_fields(BREAK_METADATA_FIELDS)
377                 else:
378                     self.reporter.add(self.filename,
379                                       'Unknown episode layout "{0}"',
380                                       self.metadata['layout'])
381             else:
382                 self.check_metadata_fields(TEACHING_METADATA_FIELDS)
383
384
385     def check_metadata_fields(self, expected):
386         for (name, type_) in expected:
387             if name not in self.metadata:
388                 self.reporter.add(self.filename,
389                                   'Missing metadata field {0}',
390                                   name)
391             elif type(self.metadata[name]) != type_:
392                 self.reporter.add(self.filename,
393                                   '"{0}" has wrong type in metadata ({1} instead of {2})',
394                                   name, type(self.metadata[name]), type_)
395
396
397 class CheckReference(CheckBase):
398     """Check the reference page."""
399
400     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
401         super(CheckReference, self).__init__(args, filename, metadata, metadata_len, text, lines, doc)
402         self.layout = 'reference'
403
404
405 class CheckGeneric(CheckBase):
406     """Check a generic page."""
407
408     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
409         super(CheckGeneric, self).__init__(args, filename, metadata, metadata_len, text, lines, doc)
410         self.layout = 'page'
411
412
413 CHECKERS = [
414     (re.compile(r'CONTRIBUTING\.md'), CheckNonJekyll),
415     (re.compile(r'README\.md'), CheckNonJekyll),
416     (re.compile(r'index\.md'), CheckIndex),
417     (re.compile(r'reference\.md'), CheckReference),
418     (re.compile(r'_episodes/.*\.md'), CheckEpisode),
419     (re.compile(r'.*\.md'), CheckGeneric)
420 ]
421
422
423 if __name__ == '__main__':
424     main()