Merge pull request #126 from gvwilson/consolidating-links
[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: "check-workshop 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     'contact' must be a valid email address consisting of characters,
209     an '@', and more characters.  It should not be the default contact
210     email address 'admin@software-carpentry.org'.
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_etherpad(etherpad):
231     """
232     'etherpad' must be a valid URL.
233     """
234
235     return bool(re.match(URL_PATTERN, etherpad))
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     'contact':    (True, check_email,
290                    'contact email invalid or still set to ' +
291                    '"{0}".'.format(DEFAULT_CONTACT_EMAIL)),
292
293     'eventbrite': (False, check_eventbrite, 'Eventbrite key appears invalid'),
294
295     'etherpad':   (False, check_etherpad, 'Etherpad URL appears invalid'),
296
297     'venue':      (False, check_pass, 'venue name not specified'),
298
299     'address':    (False, check_pass, 'address not specified')
300 }
301
302 # REQUIRED is all required categories.
303 REQUIRED = set([k for k in HANDLERS if HANDLERS[k][0]])
304
305 # OPTIONAL is all optional categories.
306 OPTIONAL = set([k for k in HANDLERS if not HANDLERS[k][0]])
307
308
309 def check_blank_lines(reporter, raw):
310     """
311     Blank lines are not allowed in category headers.
312     """
313
314     lines = [(i, x) for (i, x) in enumerate(raw.strip().split('\n')) if not x.strip()]
315     reporter.check(not lines,
316                    None,
317                    'Blank line(s) in header: {0}',
318                    ', '.join(["{0}: {1}".format(i, x.rstrip()) for (i, x) in lines]))
319
320
321 def check_categories(reporter, left, right, msg):
322     """
323     Report differences (if any) between two sets of categories.
324     """
325
326     diff = left - right
327     reporter.check(len(diff) == 0,
328                    None,
329                    '{0}: offending entries {1}',
330                    msg, sorted(list(diff)))
331
332
333 def check_file(reporter, path, data):
334     """
335     Get header from file, call all other functions, and check file for
336     validity.
337     """
338
339     # Get metadata as text and as YAML.
340     raw, header, body = split_metadata(path, data)
341
342     # Do we have any blank lines in the header?
343     check_blank_lines(reporter, raw)
344
345     # Look through all header entries.  If the category is in the input
346     # file and is either required or we have actual data (as opposed to
347     # a commented-out entry), we check it.  If it *isn't* in the header
348     # but is required, report an error.
349     for category in HANDLERS:
350         required, handler, message = HANDLERS[category]
351         if category in header:
352             if required or header[category]:
353                 reporter.check(handler(header[category]),
354                                None,
355                                '{0}\n    actual value "{1}"',
356                                message, header[category])
357         elif required:
358             reporter.add(None,
359                          'Missing mandatory key "{0}"',
360                          category)
361
362     # Check whether we have missing or too many categories
363     seen_categories = set(header.keys())
364     check_categories(reporter, REQUIRED, seen_categories,
365                      'Missing categories')
366     check_categories(reporter, seen_categories, REQUIRED.union(OPTIONAL),
367                      'Superfluous categories')
368
369
370 def check_config(reporter, filename):
371     """
372     Check YAML configuration file.
373     """
374
375     config = load_yaml(filename)
376
377     kind = config.get('kind', None)
378     reporter.check(kind == 'workshop',
379                    filename,
380                    'Missing or unknown kind of event: {0}',
381                    kind)
382
383     carpentry = config.get('carpentry', None)
384     reporter.check(carpentry in ('swc', 'dc'),
385                    filename,
386                    'Missing or unknown carpentry: {0}',
387                    carpentry)
388
389
390 def main():
391     '''Run as the main program.'''
392
393     if len(sys.argv) != 2:
394         print(USAGE, file=sys.stderr)
395         sys.exit(1)
396
397     root_dir = sys.argv[1]
398     index_file = os.path.join(root_dir, 'index.html')
399     config_file = os.path.join(root_dir, '_config.yml')
400
401     reporter = Reporter()
402     check_config(reporter, config_file)
403     check_unwanted_files(root_dir, reporter)
404     with open(index_file) as reader:
405         data = reader.read()
406         check_file(reporter, index_file, data)
407     reporter.report()
408
409
410 if __name__ == '__main__':
411     main()