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