Converting workshop checker to use Reporter class.
[rnaseq-cwl-training.git] / bin / workshop_check.py
1 #!/usr/bin/env python
2
3 '''Check that a workshop's index.html metadata is valid.  See the
4 docstrings on the checking functions for a summary of the checks.
5 '''
6
7 import sys
8 import os
9 import re
10 import yaml
11 from datetime import date
12 from util import Reporter, split_metadata
13
14
15 # Metadata field patterns.
16 EMAIL_PATTERN = r'[^@]+@[^@]+\.[^@]+'
17 HUMANTIME_PATTERN = r'((0?[1-9]|1[0-2]):[0-5]\d(am|pm)(-|to)(0?[1-9]|1[0-2]):[0-5]\d(am|pm))|((0?\d|1\d|2[0-3]):[0-5]\d(-|to)(0?\d|1\d|2[0-3]):[0-5]\d)'
18 EVENTBRITE_PATTERN = r'\d{9,10}'
19 URL_PATTERN = r'https?://.+'
20
21 # Defaults.
22 CARPENTRIES = ("dc", "swc")
23 DEFAULT_CONTACT_EMAIL = 'admin@software-carpentry.org'
24
25 USAGE = 'Usage: "check-workshop path/to/root/directory"'
26
27 # Country and language codes.  Note that codes mean different things: 'ar'
28 # is 'Arabic' as a language but 'Argentina' as a country.
29
30 ISO_COUNTRY = [
31     'ad', 'ae', 'af', 'ag', 'ai', 'al', 'am', 'an', 'ao', 'aq', 'ar', 'as',
32     'at', 'au', 'aw', 'ax', 'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh',
33     'bi', 'bj', 'bm', 'bn', 'bo', 'br', 'bs', 'bt', 'bv', 'bw', 'by', 'bz',
34     'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co',
35     'cr', 'cu', 'cv', 'cx', 'cy', 'cz', 'de', 'dj', 'dk', 'dm', 'do', 'dz',
36     'ec', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm',
37     'fo', 'fr', 'ga', 'gb', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm',
38     'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn',
39     'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir', 'is',
40     'it', 'je', 'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn', 'kp',
41     'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 'ls', 'lt',
42     'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'me', 'mg', 'mh', 'mk', 'ml', 'mm',
43     'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my',
44     'mz', 'na', 'nc', 'ne', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu',
45     'nz', 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr',
46     'ps', 'pt', 'pw', 'py', 'qa', 're', 'ro', 'rs', 'ru', 'rw', 'sa', 'sb',
47     'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so',
48     'sr', 'st', 'sv', 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 'tk',
49     'tl', 'tm', 'tn', 'to', 'tr', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'um',
50     'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws',
51     'ye', 'yt', 'za', 'zm', 'zw'
52 ]
53
54 ISO_LANGUAGE = [
55     'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az',
56     'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs', 'ca', 'ce',
57     'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', 'da', 'de', 'dv', 'dz', 'ee',
58     'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'ff', 'fi', 'fj', 'fo', 'fr',
59     'fy', 'ga', 'gd', 'gl', 'gn', 'gu', 'gv', 'ha', 'he', 'hi', 'ho', 'hr',
60     'ht', 'hu', 'hy', 'hz', 'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'io', 'is',
61     'it', 'iu', 'ja', 'jv', 'ka', 'kg', 'ki', 'kj', 'kk', 'kl', 'km', 'kn',
62     'ko', 'kr', 'ks', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lg', 'li', 'ln',
63     'lo', 'lt', 'lu', 'lv', 'mg', 'mh', 'mi', 'mk', 'ml', 'mn', 'mr', 'ms',
64     'mt', 'my', 'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no', 'nr', 'nv',
65     'ny', 'oc', 'oj', 'om', 'or', 'os', 'pa', 'pi', 'pl', 'ps', 'pt', 'qu',
66     'rm', 'rn', 'ro', 'ru', 'rw', 'sa', 'sc', 'sd', 'se', 'sg', 'si', 'sk',
67     'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta',
68     'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw',
69     'ty', 'ug', 'uk', 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi',
70     'yo', 'za', 'zh', 'zu'
71 ]
72
73
74 def look_for_fixme(func):
75     """Decorator to fail test if text argument starts with "FIXME"."""
76
77     def inner(arg):
78         if (arg is not None) and \
79            isinstance(arg, str) and \
80            arg.lstrip().startswith('FIXME'):
81             return False
82         return func(arg)
83     return inner
84
85
86 @look_for_fixme
87 def check_layout(layout):
88     '''"layout" in YAML header must be "workshop".'''
89
90     return layout == 'workshop'
91
92
93 @look_for_fixme
94 def check_carpentry(layout):
95     '''"carpentry" in YAML header must be "dc" or "swc".'''
96
97     return layout in CARPENTRIES
98
99
100 @look_for_fixme
101 def check_country(country):
102     '''"country" must be a lowercase ISO-3166 two-letter code.'''
103
104     return country in ISO_COUNTRY
105
106
107 @look_for_fixme
108 def check_language(language):
109     '''"language" must be a lowercase ISO-639 two-letter code.'''
110
111     return language in ISO_LANGUAGE
112
113
114 @look_for_fixme
115 def check_humandate(date):
116     """
117     'humandate' must be a human-readable date with a 3-letter month
118     and 4-digit year.  Examples include 'Feb 18-20, 2025' and 'Feb 18
119     and 20, 2025'.  It may be in languages other than English, but the
120     month name should be kept short to aid formatting of the main
121     Software Carpentry web site.
122     """
123
124     if ',' not in date:
125         return False
126
127     month_dates, year = date.split(',')
128
129     # The first three characters of month_dates are not empty
130     month = month_dates[:3]
131     if any(char == ' ' for char in month):
132         return False
133
134     # But the fourth character is empty ("February" is illegal)
135     if month_dates[3] != ' ':
136         return False
137
138     # year contains *only* numbers
139     try:
140         int(year)
141     except:
142         return False
143
144     return True
145
146
147 @look_for_fixme
148 def check_humantime(time):
149     """
150     'humantime' is a human-readable start and end time for the
151     workshop, such as '09:00 - 16:00'.
152     """
153
154     return bool(re.match(HUMANTIME_PATTERN, time.replace(' ', '')))
155
156
157 def check_date(this_date):
158     """
159     'startdate' and 'enddate' are machine-readable start and end dates
160     for the workshop, and must be in YYYY-MM-DD format, e.g.,
161     '2015-07-01'.
162     """
163
164     # YAML automatically loads valid dates as datetime.date.
165     return isinstance(this_date, date)
166
167
168 @look_for_fixme
169 def check_latitude_longitude(latlng):
170     """
171     'latlng' must be a valid latitude and longitude represented as two
172     floating-point numbers separated by a comma.
173     """
174
175     try:
176         lat, lng = latlng.split(',')
177         lat = float(lat)
178         long = float(lng)
179         return (-90.0 <= lat <= 90.0) and (-180.0 <= long <= 180.0)
180     except ValueError:
181         return False
182
183
184 def check_instructors(instructors):
185     """
186     'instructor' must be a non-empty comma-separated list of quoted
187     names, e.g. ['First name', 'Second name', ...'].  Do not use 'TBD'
188     or other placeholders.
189     """
190
191     # YAML automatically loads list-like strings as lists.
192     return isinstance(instructors, list) and len(instructors) > 0
193
194
195 def check_helpers(helpers):
196     """
197     'helper' must be a comma-separated list of quoted names,
198     e.g. ['First name', 'Second name', ...'].  The list may be empty.
199     Do not use 'TBD' or other placeholders.
200     """
201
202     # YAML automatically loads list-like strings as lists.
203     return isinstance(helpers, list) and len(helpers) >= 0
204
205
206 @look_for_fixme
207 def check_email(email):
208     """
209     'contact' must be a valid email address consisting of characters,
210     an '@', and more characters.  It should not be the default contact
211     email address 'admin@software-carpentry.org'.
212     """
213
214     return bool(re.match(EMAIL_PATTERN, email)) and \
215            (email != DEFAULT_CONTACT_EMAIL)
216
217
218 def check_eventbrite(eventbrite):
219     """
220     'eventbrite' (the Eventbrite registration key) must be 9 or more
221     digits.  It may appear as an integer or as a string.
222     """
223
224     if isinstance(eventbrite, int):
225         return True
226     else:
227         return bool(re.match(EVENTBRITE_PATTERN, eventbrite))
228
229
230 @look_for_fixme
231 def check_etherpad(etherpad):
232     """
233     'etherpad' must be a valid URL.
234     """
235
236     return bool(re.match(URL_PATTERN, etherpad))
237
238
239 @look_for_fixme
240 def check_pass(value):
241     """
242     This test always passes (it is used for 'checking' things like the
243     workshop address, for which no sensible validation is feasible).
244     """
245
246     return True
247
248
249 HANDLERS = {
250     'layout':     (True, check_layout, 'layout isn\'t "workshop"'),
251
252     'carpentry':  (True, check_carpentry, 'carpentry isn\'t in ' +
253                    ', '.join(CARPENTRIES)),
254
255     'country':    (True, check_country,
256                    'country invalid: must use lowercase two-letter ISO code ' +
257                    'from ' + ', '.join(ISO_COUNTRY)),
258
259     'language':   (False,  check_language,
260                    'language invalid: must use lowercase two-letter ISO code' +
261                    ' from ' + ', '.join(ISO_LANGUAGE)),
262
263     'humandate':  (True, check_humandate,
264                    'humandate invalid. Please use three-letter months like ' +
265                    '"Jan" and four-letter years like "2025"'),
266
267     'humantime':  (True, check_humantime,
268                    'humantime doesn\'t include numbers'),
269
270     'startdate':  (True, check_date,
271                    'startdate invalid. Must be of format year-month-day, ' +
272                    'i.e., 2014-01-31'),
273
274     'enddate':    (False, check_date,
275                    'enddate invalid. Must be of format year-month-day, i.e.,' +
276                    ' 2014-01-31'),
277
278     'latlng':     (True, check_latitude_longitude,
279                    'latlng invalid. Check that it is two floating point ' +
280                    'numbers, separated by a comma'),
281
282     'instructor': (True, check_instructors,
283                    'instructor list isn\'t a valid list of format ' +
284                    '["First instructor", "Second instructor",..]'),
285
286     'helper':     (True, check_helpers,
287                    'helper list isn\'t a valid list of format ' +
288                    '["First helper", "Second helper",..]'),
289
290     'contact':    (True, check_email,
291                    'contact email invalid or still set to ' +
292                    '"{0}".'.format(DEFAULT_CONTACT_EMAIL)),
293
294     'eventbrite': (False, check_eventbrite, 'Eventbrite key appears invalid'),
295
296     'etherpad':   (False, check_etherpad, 'Etherpad URL appears invalid'),
297
298     'venue':      (False, check_pass, 'venue name not specified'),
299
300     'address':    (False, check_pass, 'address not specified')
301 }
302
303 # REQUIRED is all required categories.
304 REQUIRED = set([k for k in HANDLERS if HANDLERS[k][0]])
305
306 # OPTIONAL is all optional categories.
307 OPTIONAL = set([k for k in HANDLERS if not HANDLERS[k][0]])
308
309
310 def check_blank_lines(reporter, raw):
311     """
312     Blank lines are not allowed in category headers.
313     """
314
315     lines = [(i, x) for (i, x) in enumerate(raw.strip().split('\n')) if not x.strip()]
316     reporter.check(not lines,
317                    None,
318                    'Blank line(s) in header: {0}',
319                    ', '.join(["{0}: {1}".format(i, x.rstrip()) for (i, x) in lines]))
320
321
322 def check_categories(reporter, left, right, msg):
323     """
324     Report differences (if any) between two sets of categories.
325     """
326
327     diff = left - right
328     reporter.check(len(diff) == 0,
329                    None,
330                    '{0}: offending entries {1}',
331                    msg, sorted(list(diff)))
332
333
334 def check_file(reporter, path, data):
335     """
336     Get header from file, call all other functions, and check file for
337     validity.
338     """
339
340     # Get metadata as text and as YAML.
341     raw, header, body = split_metadata(path, data)
342
343     # Do we have any blank lines in the header?
344     check_blank_lines(reporter, raw)
345
346     # Look through all header entries.  If the category is in the input
347     # file and is either required or we have actual data (as opposed to
348     # a commented-out entry), we check it.  If it *isn't* in the header
349     # but is required, report an error.
350     for category in HANDLERS:
351         required, handler, message = HANDLERS[category]
352         if category in header:
353             if required or header[category]:
354                 reporter.check(handler(header[category]),
355                                None,
356                                '{0}\n    actual value "{1}"',
357                                message, header[category])
358         elif required:
359             reporter.add(None,
360                          'Missing mandatory key "{0}"',
361                          category)
362
363     # Check whether we have missing or too many categories
364     seen_categories = set(header.keys())
365     check_categories(reporter, REQUIRED, seen_categories,
366                      'Missing categories')
367     check_categories(reporter, seen_categories, REQUIRED.union(OPTIONAL),
368                      'Superfluous categories')
369
370
371 def check_config(reporter, filename):
372     """
373     Check YAML configuration file.
374     """
375
376     with open(filename, 'r') as reader:
377         config = yaml.load(reader)
378
379     reporter.check(config['kind'] == 'workshop',
380                    filename,
381                    'Not configured as a workshop: found "{0}" instead',
382                    config['kind'])
383
384
385 def main():
386     '''Run as the main program.'''
387
388     if len(sys.argv) != 2:
389         print(USAGE, file=sys.stderr)
390         sys.exit(1)
391
392     root_dir = sys.argv[1]
393     index_file = os.path.join(root_dir, 'index.html')
394     config_file = os.path.join(root_dir, '_config.yml')
395
396     reporter = Reporter()
397     check_config(reporter, config_file)
398     with open(index_file) as reader:
399         data = reader.read()
400         check_file(reporter, index_file, data)
401     reporter.report()
402
403
404 if __name__ == '__main__':
405     main()