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