-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathread_config.py
392 lines (326 loc) · 12.3 KB
/
read_config.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
"""
read_config.py
Reads the `config.yaml` file.
This file can be run to validate the codePost courses in the file.
"""
# ==============================================================================
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
import codepost
import pytz
import yaml
from slack_sdk.errors import SlackApiError
from utils import (_error, _try_format, get_slack_client, now_dt,
validate_codepost)
# ==============================================================================
__all__ = ('read',)
# ==============================================================================
CONFIG_FILE = Path('config.yaml')
UTC_TZ = pytz.utc
EASTERN_TZ = pytz.timezone('US/Eastern')
DATE_FMT = '%Y-%m-%d'
DEADLINE_FMT = '%Y-%m-%d %H:%M'
NO_DAY = timedelta()
ONE_DAY = timedelta(days=1)
# ==============================================================================
# the support messages and the required keys, with test values to validate the
# format string
MESSAGES_KWARGS = {
'notification': {
'assignment': 'assignment',
'done': 0.3,
'total': 10,
'finalized': 3,
'drafts': 3,
'unclaimed': 4,
},
'recent_graders': {
'graders': '`grader1`, `grader2`',
},
'deadline': {
'assignment': 'assignment',
'deadline': '2022-01-01 00:00',
'done': 0.3,
'total': 10,
'finalized': 3,
'drafts': 3,
'unclaimed': 4,
},
}
# ==============================================================================
def _validate_slack_channels(slack_client, channels, fmt_error):
errors = []
for channel, channel_id in channels.items():
try:
# https://api.slack.com/methods/conversations.info
slack_client.conversations_info(channel=channel_id)
except SlackApiError as e:
if not e.response or e.response.get('ok', None) is not False:
raise
reason = e.response.get('error', None)
if reason is None:
raise
if reason == 'channel_not_found':
errors.append(
fmt_error('Invalid id for Slack channel "{}"', channel))
elif reason == 'missing_scope':
errors.append(
fmt_error('Slack key does not have access to channel "{}"',
channel))
else:
raise
return errors
def _eastern_to_utc(dt):
return EASTERN_TZ.localize(dt).astimezone(UTC_TZ)
def _valid_date_range(start, end):
if start is None and end is None:
return True
if start is None:
return now_dt() < end
if end is None:
return start <= now_dt()
return start <= now_dt() < end
def _validate_config_course(index, config_course):
"""Validates a course config dict.
Returns a message and None if the course is invalid;
otherwise, returns None and the course dict.
"""
_invalid_msg = f'Config file has an invalid course format at index {index}'
def invalid(msg=None):
if msg is None:
msg = _invalid_msg
return msg, None
if not isinstance(config_course, dict):
return invalid()
course = {}
# must be strs
for key in ('course', 'period', 'channel'):
if key not in config_course:
return invalid()
if not isinstance(config_course[key], str):
return invalid()
course[key] = config_course[key]
# assignments
if 'assignments' not in config_course:
return invalid()
if not isinstance(config_course['assignments'], list):
return invalid()
assignments = []
has_deadline = False
for j, config_assignment in enumerate(config_course['assignments']):
_invalid_assignment_msg = _invalid_msg + f', assignment index {j}'
if not isinstance(config_assignment, dict):
return invalid(_invalid_assignment_msg)
assignment = {}
for key, required in (
('name', True),
('start', False),
('end', False),
('deadline', False),
):
if key not in config_assignment:
if not required:
assignment[key] = None
continue
return invalid(_invalid_assignment_msg)
if not isinstance(config_assignment[key], str):
return invalid(_invalid_assignment_msg)
assignment[key] = config_assignment[key]
for key, delta in zip(('start', 'end'), (NO_DAY, ONE_DAY)):
date_str = assignment[key]
if date_str is None:
continue
try:
date = datetime.strptime(date_str.strip(), DATE_FMT)
except ValueError:
return invalid(_invalid_assignment_msg +
': invalid date format')
date += delta
assignment[key] = _eastern_to_utc(date)
assignment['valid_date_range'] = \
_valid_date_range(assignment['start'], assignment['end'])
deadline = assignment['deadline']
if deadline is not None:
deadline = deadline.strip()
assignment['deadline'] = deadline
has_deadline = True
try:
deadline_dt = datetime.strptime(deadline, DEADLINE_FMT)
except ValueError:
return invalid(_invalid_assignment_msg +
': invalid deadline format')
deadline_utc = _eastern_to_utc(deadline_dt)
assignment['passed_deadline'] = now_dt() >= deadline_utc
assignments.append(assignment)
course['assignments'] = assignments
course['has_deadline'] = has_deadline
return None, course
def read(slack_client, fmt_error=_error):
"""Reads the config file.
Fails on invalid channel ids, missing required keys, unexpected types,
repeated course name and period pairs, and unknown channel names.
Returns the mapping of channels, the mapping of messages, the list of
courses, and a list of errors.
"""
errors = []
INVALID_RETURN = None, None, None, errors
if not CONFIG_FILE.exists():
errors.append(fmt_error('Config file "{}" does not exist', CONFIG_FILE))
return INVALID_RETURN
config = yaml.safe_load(CONFIG_FILE.read_text(encoding='utf-8'))
# validate highest-level types
if not isinstance(config, dict):
errors.append(
fmt_error('Config file has an invalid format (expected dict)'))
return INVALID_RETURN
for key, default, expected in (
('channels', None, dict),
('messages', {}, dict),
('sources', None, list),
):
if not isinstance(config.get(key, default), expected):
errors.append(
fmt_error(
'Config file has an invalid format: key "{}" '
'expected to have type {}', key, expected.__name__))
if len(errors) > 0:
return INVALID_RETURN
# read channels
channels = {}
for channel, channel_id in config['channels'].items():
if not isinstance(channel_id, str):
errors.append(
fmt_error('Invalid channel id for channel "{}" (expected str)',
channel))
continue
channels[channel] = channel_id
errors += _validate_slack_channels(slack_client, channels, fmt_error)
if len(errors) > 0:
return INVALID_RETURN
# read messages
messages = {}
for key, value in config.get('messages', {}).items():
if not isinstance(value, str):
errors.append(
fmt_error('Invalid message for key "{}" (expected str)', key))
continue
if key not in MESSAGES_KWARGS:
continue
messages[key] = value
if len(errors) > 0:
return INVALID_RETURN
if 'notification' not in messages:
# required
errors.append(fmt_error('Missing message format for "notification"'))
return INVALID_RETURN
# validate messages
for key, kwargs in MESSAGES_KWARGS.items():
if key not in messages:
continue
message = messages[key].strip()
if message == '':
errors.append(fmt_error('Empty message str for key "{}"', key))
continue
messages[key] = message
_, error = _try_format(message, **kwargs)
if error is not None:
errors.append(
fmt_error(
'Invalid message format for key "{}" '
'(supported variable keys are: {}): {}', key,
', '.join(f'"{var_key}"' for var_key in kwargs), error))
if len(errors) > 0:
return INVALID_RETURN
# read sources
courses = {}
has_deadline = False
for i, config_course in enumerate(config['sources']):
invalid_msg, course = _validate_config_course(i, config_course)
if invalid_msg is not None:
errors.append(fmt_error(invalid_msg))
continue
course_period = course['course'] + ' ' + course['period']
if course_period in courses:
errors.append(
fmt_error('Config file has a repeating course name and period'))
continue
if course['channel'] not in channels:
errors.append(
fmt_error(
'Config file has unknown channel name "{}" for course "{}"',
course['channel'], course_period))
continue
if course.pop('has_deadline'):
has_deadline = True
courses[course_period] = course
if len(errors) > 0:
return INVALID_RETURN
if has_deadline and 'deadline' not in messages:
errors.append(
fmt_error(
'Deadlines given in assignments, but missing deadline message'))
return INVALID_RETURN
return channels, messages, courses, errors
# ==============================================================================
def check(success):
if not success:
sys.exit(1)
def validate():
"""Reads the config file and validates the codePost courses and assignments.
"""
failed = False
# read environment variables
secrets = {}
for name in ('CODEPOST_API_KEY', 'SLACK_TOKEN'):
secret = os.environ.get(name, None)
if secret is None or secret == '':
failed = True
print(f'Environment variable "{name}" could not be found')
continue
secrets[name] = secret
check(not failed)
success = validate_codepost(secrets['CODEPOST_API_KEY'])
if not success:
failed = True
print('codePost API key is invalid')
success, slack_client = get_slack_client(secrets['SLACK_TOKEN'])
if not success:
failed = True
print('Slack API token is invalid')
check(not failed)
def fmt_error(msg, *args, **kwargs):
print(msg.format(*args, **kwargs))
return 0
_, _, config, errors = read(slack_client, fmt_error=fmt_error)
check(len(errors) == 0)
# validate all codePost objects
courses = {}
repeated = set()
for course in codepost.course.list_available():
course_period = course.name + ' ' + course.period
if course_period not in courses:
# use the first course if there are duplicates
courses[course_period] = course
elif course_period not in repeated:
print('Warning: there are multiple courses with the name '
f'"{course.name}" and period "{course.period}"')
repeated.add(course_period)
for course_period, course_data in config.items():
if course_period not in courses:
failed = True
print(f'Course "{course_period}" could not be found')
continue
course = courses[course_period]
assignments = {assignment.name for assignment in course.assignments}
for assignment_data in course_data['assignments']:
assignment_name = assignment_data['name']
if assignment_name not in assignments:
failed = True
print(f'Course "{course_period}" does not have an assignment '
f'"{assignment_name}"')
check(not failed)
print('Passed')
if __name__ == '__main__':
validate()