Commit bb42f4ab0ea18b327cf96f1f6a53457535fb415e

Authored by Miguel Barão
1 parent 51392b56
Exists in dev

Code reformat using black.

perguntations/__init__.py
... ... @@ -21,7 +21,7 @@
21 21 # SOFTWARE.
22 22  
23 23  
24   -'''A webserver for running tests in class
  24 +"""A webserver for running tests in class
25 25  
26 26 perguntations is a webserver that is intended to be run in an isolated
27 27 local network in classroom environment.
... ... @@ -29,13 +29,13 @@ The server will run a preconfigured test and serve the test as a web form.
29 29 The answers submitted by the students are immediatly checked and classified.
30 30 The grades are stored in a database and the tests are stored in JSON files as
31 31 proof of submission and for review.
32   -'''
  32 +"""
33 33  
34   -APP_NAME = 'perguntations'
35   -APP_VERSION = '2022.04.dev1'
  34 +APP_NAME = "perguntations"
  35 +APP_VERSION = "2022.04.dev1"
36 36 APP_DESCRIPTION = str(__doc__)
37 37  
38   -__author__ = 'Miguel Barão'
39   -__copyright__ = 'Copyright 2022, Miguel Barão'
40   -__license__ = 'MIT license'
  38 +__author__ = "Miguel Barão"
  39 +__copyright__ = "Copyright 2022, Miguel Barão"
  40 +__license__ = "MIT license"
41 41 __version__ = APP_VERSION
... ...
perguntations/app.py
1   -'''
  1 +"""
2 2 File: perguntations/app.py
3 3 Description: Main application logic.
4   -'''
  4 +"""
5 5  
6 6  
7 7 # python standard libraries
... ... @@ -30,68 +30,73 @@ from .questions import question_from
30 30 # setup logger for this module
31 31 logger = logging.getLogger(__name__)
32 32  
  33 +
33 34 async def check_password(password: str, hashed: bytes) -> bool:
34   - '''check password in executor'''
  35 + """check password in executor"""
35 36 loop = asyncio.get_running_loop()
36   - return await loop.run_in_executor(None, bcrypt.checkpw,
37   - password.encode('utf-8'), hashed)
  37 + return await loop.run_in_executor(
  38 + None, bcrypt.checkpw, password.encode("utf-8"), hashed
  39 + )
  40 +
38 41  
39 42 async def hash_password(password: str) -> bytes:
40   - '''get hash for password'''
  43 + """get hash for password"""
41 44 loop = asyncio.get_running_loop()
42   - return await loop.run_in_executor(None, bcrypt.hashpw,
43   - password.encode('utf-8'), bcrypt.gensalt())
  45 + return await loop.run_in_executor(
  46 + None, bcrypt.hashpw, password.encode("utf-8"), bcrypt.gensalt()
  47 + )
  48 +
44 49  
45 50 # ============================================================================
46 51 class AppException(Exception):
47   - '''Exception raised in this module'''
  52 + """Exception raised in this module"""
  53 +
48 54  
49 55 # ============================================================================
50 56 # main application
51 57 # ============================================================================
52   -class App():
53   - '''
  58 +class App:
  59 + """
54 60 Main application
55   - '''
  61 + """
56 62  
57 63 # ------------------------------------------------------------------------
58 64 def __init__(self, config):
59   - self.debug = config['debug']
60   - self._make_test_factory(config['testfile'])
  65 + self.debug = config["debug"]
  66 + self._make_test_factory(config["testfile"])
61 67 self._db_setup() # setup engine and load all students
62 68  
63 69 # FIXME get_event_loop will be deprecated in python3.10
64 70 asyncio.get_event_loop().run_until_complete(self._assign_tests())
65 71  
66 72 # command line options: --allow-all, --allow-list filename
67   - if config['allow_all']:
  73 + if config["allow_all"]:
68 74 self.allow_all_students()
69   - elif config['allow_list'] is not None:
70   - self.allow_from_list(config['allow_list'])
  75 + elif config["allow_list"] is not None:
  76 + self.allow_from_list(config["allow_list"])
71 77 else:
72   - logger.info('Students not allowed to login')
  78 + logger.info("Students not allowed to login")
73 79  
74   - if config['correct']:
  80 + if config["correct"]:
75 81 self._correct_tests()
76 82  
77 83 # ------------------------------------------------------------------------
78 84 def _db_setup(self) -> None:
79   - '''
  85 + """
80 86 Create database engine and checks for admin and students
81   - '''
82   - dbfile = os.path.expanduser(self._testfactory['database'])
  87 + """
  88 + dbfile = os.path.expanduser(self._testfactory["database"])
83 89 logger.debug('Checking database "%s"...', dbfile)
84 90 if not os.path.exists(dbfile):
85 91 raise AppException('No database, use "initdb" to create')
86 92  
87 93 # connect to database and check for admin & registered students
88   - self._engine = create_engine(f'sqlite:///{dbfile}', future=True)
  94 + self._engine = create_engine(f"sqlite:///{dbfile}", future=True)
89 95 try:
90 96 with Session(self._engine, future=True) as session:
91   - query = select(Student.id, Student.name)\
92   - .where(Student.id != '0')
  97 + query = select(Student.id, Student.name).where(Student.id != "0")
93 98 dbstudents = session.execute(query).all()
94   - session.execute(select(Student).where(Student.id == '0')).one()
  99 + session.execute(select(Student).where(Student.id == "0")).one()
95 100 except NoResultFound:
96 101 msg = 'Database has no administrator (user "0")'
97 102 logger.error(msg)
... ... @@ -100,158 +105,162 @@ class App():
100 105 msg = f'Database "{dbfile}" unusable'
101 106 logger.error(msg)
102 107 raise AppException(msg) from None
103   - logger.info('Database has %d students', len(dbstudents))
  108 + logger.info("Database has %d students", len(dbstudents))
104 109  
105   - self._students = {uid: {
106   - 'name': name,
107   - 'state': 'offline',
108   - 'test': None,
109   - } for uid, name in dbstudents}
  110 + self._students = {
  111 + uid: {
  112 + "name": name,
  113 + "state": "offline",
  114 + "test": None,
  115 + }
  116 + for uid, name in dbstudents
  117 + }
110 118  
111 119 # ------------------------------------------------------------------------
112 120 async def _assign_tests(self) -> None:
113   - '''Generate tests for all students that don't yet have a test'''
114   - logger.info('Generating tests...')
  121 + """Generate tests for all students that don't yet have a test"""
  122 + logger.info("Generating tests...")
115 123 for student in self._students.values():
116   - if student.get('test', None) is None:
117   - student['test'] = await self._testfactory.generate()
118   - logger.info('Tests assigned to all students')
  124 + if student.get("test", None) is None:
  125 + student["test"] = await self._testfactory.generate()
  126 + logger.info("Tests assigned to all students")
119 127  
120 128 # ------------------------------------------------------------------------
121 129 async def login(self, uid: str, password: str, headers: dict) -> Optional[str]:
122   - '''
  130 + """
123 131 Login authentication
124 132 If successful returns None, else returns an error message
125   - '''
  133 + """
126 134 try:
127 135 with Session(self._engine, future=True) as session:
128 136 query = select(Student.password).where(Student.id == uid)
129 137 hashed = session.execute(query).scalar_one()
130 138 except NoResultFound:
131 139 logger.warning('"%s" does not exist', uid)
132   - return 'nonexistent'
  140 + return "nonexistent"
133 141  
134   - if uid != '0' and self._students[uid]['state'] != 'allowed':
  142 + if uid != "0" and self._students[uid]["state"] != "allowed":
135 143 logger.warning('"%s" login not allowed', uid)
136   - return 'not_allowed'
  144 + return "not_allowed"
137 145  
138   - if hashed == '': # set password on first login
  146 + if hashed == "": # set password on first login
139 147 await self.set_password(uid, password)
140 148 elif not await check_password(password, hashed):
141 149 logger.info('"%s" wrong password', uid)
142   - return 'wrong_password'
  150 + return "wrong_password"
143 151  
144 152 # success
145   - if uid == '0':
146   - logger.info('Admin login from %s', headers['remote_ip'])
  153 + if uid == "0":
  154 + logger.info("Admin login from %s", headers["remote_ip"])
147 155 else:
148 156 student = self._students[uid]
149   - student['test'].start(uid)
150   - student['state'] = 'online'
151   - student['headers'] = headers
152   - student['unfocus'] = False
153   - student['area'] = 0.0
154   - logger.info('"%s" login from %s', uid, headers['remote_ip'])
  157 + student["test"].start(uid)
  158 + student["state"] = "online"
  159 + student["headers"] = headers
  160 + student["unfocus"] = False
  161 + student["area"] = 0.0
  162 + logger.info('"%s" login from %s', uid, headers["remote_ip"])
155 163 return None
156 164  
157 165 # ------------------------------------------------------------------------
158 166 async def set_password(self, uid: str, password: str) -> None:
159   - '''change password in the database'''
  167 + """change password in the database"""
160 168 with Session(self._engine, future=True) as session:
161 169 query = select(Student).where(Student.id == uid)
162 170 student = session.execute(query).scalar_one()
163   - student.password = await hash_password(password) if password else ''
  171 + student.password = await hash_password(password) if password else ""
164 172 session.commit()
165 173 logger.info('"%s" password updated', uid)
166 174  
167 175 # ------------------------------------------------------------------------
168 176 def logout(self, uid: str) -> None:
169   - '''student logout'''
  177 + """student logout"""
170 178 student = self._students.get(uid, None)
171 179 if student is not None:
172 180 # student['test'] = None
173   - student['state'] = 'offline'
174   - student.pop('headers', None)
175   - student.pop('unfocus', None)
176   - student.pop('area', None)
  181 + student["state"] = "offline"
  182 + student.pop("headers", None)
  183 + student.pop("unfocus", None)
  184 + student.pop("area", None)
177 185 logger.info('"%s" logged out', uid)
178 186  
179 187 # ------------------------------------------------------------------------
180 188 def _make_test_factory(self, filename: str) -> None:
181   - '''
  189 + """
182 190 Setup a factory for the test
183   - '''
  191 + """
184 192  
185 193 # load configuration from yaml file
186 194 try:
187 195 testconf = load_yaml(filename)
188   - testconf['testfile'] = filename
  196 + testconf["testfile"] = filename
189 197 except (OSError, yaml.YAMLError) as exc:
190 198 msg = f'Cannot read test configuration "{filename}"'
191 199 logger.error(msg)
192 200 raise AppException(msg) from exc
193 201  
194 202 # make test factory
195   - logger.info('Running test factory...')
  203 + logger.info("Running test factory...")
196 204 try:
197 205 self._testfactory = TestFactory(testconf)
198 206 except TestFactoryException as exc:
199 207 logger.error(exc)
200   - raise AppException('Failed to create test factory!') from exc
  208 + raise AppException("Failed to create test factory!") from exc
201 209  
202 210 # ------------------------------------------------------------------------
203 211 async def submit_test(self, uid, ans) -> None:
204   - '''
  212 + """
205 213 Handles test submission and correction.
206 214  
207 215 ans is a dictionary {question_index: answer, ...} with the answers for
208 216 the complete test. For example: {0:'hello', 1:[1,2]}
209   - '''
210   - if self._students[uid]['state'] != 'online':
  217 + """
  218 + if self._students[uid]["state"] != "online":
211 219 logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid)
212 220 return
213 221  
214 222 # --- submit answers and correct test
215 223 logger.info('"%s" submitted %d answers', uid, len(ans))
216   - test = self._students[uid]['test']
  224 + test = self._students[uid]["test"]
217 225 test.submit(ans)
218 226  
219   - if test['autocorrect']:
  227 + if test["autocorrect"]:
220 228 await test.correct_async()
221   - logger.info('"%s" grade = %g points', uid, test['grade'])
  229 + logger.info('"%s" grade = %g points', uid, test["grade"])
222 230  
223 231 # --- save test in JSON format
224 232 fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json'
225   - fpath = os.path.join(test['answers_dir'], fname)
  233 + fpath = os.path.join(test["answers_dir"], fname)
226 234 test.save_json(fpath)
227 235 logger.info('"%s" saved JSON', uid)
228 236  
229 237 # --- insert test and questions into the database
230 238 # only corrected questions are added
231 239 test_row = Test(
232   - ref=test['ref'],
233   - title=test['title'],
234   - grade=test['grade'],
235   - state=test['state'],
236   - comment=test['comment'],
237   - starttime=str(test['start_time']),
238   - finishtime=str(test['finish_time']),
  240 + ref=test["ref"],
  241 + title=test["title"],
  242 + grade=test["grade"],
  243 + state=test["state"],
  244 + comment=test["comment"],
  245 + starttime=str(test["start_time"]),
  246 + finishtime=str(test["finish_time"]),
239 247 filename=fpath,
240   - student_id=uid)
  248 + student_id=uid,
  249 + )
241 250  
242   - if test['state'] == 'CORRECTED':
  251 + if test["state"] == "CORRECTED":
243 252 test_row.questions = [
244 253 Question(
245 254 number=n,
246   - ref=q['ref'],
247   - grade=q['grade'],
248   - comment=q.get('comment', ''),
249   - starttime=str(test['start_time']),
250   - finishtime=str(test['finish_time']),
251   - test_id=test['ref']
252   - )
253   - for n, q in enumerate(test['questions'])
254   - ]
  255 + ref=q["ref"],
  256 + grade=q["grade"],
  257 + comment=q.get("comment", ""),
  258 + starttime=str(test["start_time"]),
  259 + finishtime=str(test["finish_time"]),
  260 + test_id=test["ref"],
  261 + )
  262 + for n, q in enumerate(test["questions"])
  263 + ]
255 264  
256 265 with Session(self._engine, future=True) as session:
257 266 session.add(test_row)
... ... @@ -262,21 +271,23 @@ class App():
262 271 def _correct_tests(self) -> None:
263 272 with Session(self._engine, future=True) as session:
264 273 # Find which tests have to be corrected
265   - query = select(Test) \
266   - .where(Test.ref == self._testfactory['ref']) \
267   - .where(Test.state == "SUBMITTED")
  274 + query = (
  275 + select(Test)
  276 + .where(Test.ref == self._testfactory["ref"])
  277 + .where(Test.state == "SUBMITTED")
  278 + )
268 279 dbtests = session.execute(query).scalars().all()
269 280 if not dbtests:
270   - logger.info('No tests to correct')
  281 + logger.info("No tests to correct")
271 282 return
272 283  
273   - logger.info('Correcting %d tests...', len(dbtests))
  284 + logger.info("Correcting %d tests...", len(dbtests))
274 285 for dbtest in dbtests:
275 286 try:
276 287 with open(dbtest.filename) as file:
277 288 testdict = json.load(file)
278 289 except OSError:
279   - logger.error('Failed: %s', dbtest.filename)
  290 + logger.error("Failed: %s", dbtest.filename)
280 291 continue
281 292  
282 293 # creates a class Test with the methods to correct it
... ... @@ -284,31 +295,32 @@ class App():
284 295 # question_from() to produce Question() instances that can be
285 296 # corrected. Finally the test can be corrected.
286 297 test = TestInstance(testdict)
287   - test['questions'] = [question_from(q) for q in test['questions']]
  298 + test["questions"] = [question_from(q) for q in test["questions"]]
288 299 test.correct()
289   - logger.info(' %s: %f', test['student'], test['grade'])
  300 + logger.info(" %s: %f", test["student"], test["grade"])
290 301  
291 302 # save JSON file (overwriting the old one)
292   - uid = test['student']
  303 + uid = test["student"]
293 304 test.save_json(dbtest.filename)
294   - logger.debug('%s saved JSON file', uid)
  305 + logger.debug("%s saved JSON file", uid)
295 306  
296 307 # update database
297   - dbtest.grade = test['grade']
298   - dbtest.state = test['state']
  308 + dbtest.grade = test["grade"]
  309 + dbtest.state = test["state"]
299 310 dbtest.questions = [
300 311 Question(
301 312 number=n,
302   - ref=q['ref'],
303   - grade=q['grade'],
304   - comment=q.get('comment', ''),
305   - starttime=str(test['start_time']),
306   - finishtime=str(test['finish_time']),
307   - test_id=test['ref']
308   - ) for n, q in enumerate(test['questions'])
309   - ]
  313 + ref=q["ref"],
  314 + grade=q["grade"],
  315 + comment=q.get("comment", ""),
  316 + starttime=str(test["start_time"]),
  317 + finishtime=str(test["finish_time"]),
  318 + test_id=test["ref"],
  319 + )
  320 + for n, q in enumerate(test["questions"])
  321 + ]
310 322 session.commit()
311   - logger.info('Database updated')
  323 + logger.info("Database updated")
312 324  
313 325 # ------------------------------------------------------------------------
314 326 # def giveup_test(self, uid):
... ... @@ -340,176 +352,196 @@ class App():
340 352  
341 353 # ------------------------------------------------------------------------
342 354 def register_event(self, uid, cmd, value):
343   - '''handles browser events the occur during the test'''
344   - if cmd == 'focus':
  355 + """handles browser events the occur during the test"""
  356 + if cmd == "focus":
345 357 if value:
346 358 self._focus_student(uid)
347 359 else:
348 360 self._unfocus_student(uid)
349   - elif cmd == 'size':
  361 + elif cmd == "size":
350 362 self._set_screen_area(uid, value)
  363 + elif cmd == "update_answer":
  364 + print(cmd, value) # FIXME
  365 + elif cmd == "lint":
  366 + print(cmd, value) # FIXME
351 367  
352 368 # ========================================================================
353 369 # GETTERS
354 370 # ========================================================================
355 371 def get_test(self, uid: str) -> Optional[dict]:
356   - '''return student test'''
357   - return self._students[uid]['test']
  372 + """return student test"""
  373 + return self._students[uid]["test"]
358 374  
359 375 # ------------------------------------------------------------------------
360 376 def get_name(self, uid: str) -> str:
361   - '''return name of student'''
362   - return self._students[uid]['name']
  377 + """return name of student"""
  378 + return self._students[uid]["name"]
363 379  
364 380 # ------------------------------------------------------------------------
365 381 def get_test_config(self) -> dict:
366   - '''return brief test configuration to use as header in /admin'''
367   - return {'title': self._testfactory['title'],
368   - 'ref': self._testfactory['ref'],
369   - 'filename': self._testfactory['testfile'],
370   - 'database': self._testfactory['database'],
371   - 'answers_dir': self._testfactory['answers_dir']
372   - }
  382 + """return brief test configuration to use as header in /admin"""
  383 + return {
  384 + "title": self._testfactory["title"],
  385 + "ref": self._testfactory["ref"],
  386 + "filename": self._testfactory["testfile"],
  387 + "database": self._testfactory["database"],
  388 + "answers_dir": self._testfactory["answers_dir"],
  389 + }
373 390  
374 391 # ------------------------------------------------------------------------
375 392 def get_grades_csv(self):
376   - '''generates a CSV with the grades of the test currently running'''
377   - test_ref = self._testfactory['ref']
  393 + """generates a CSV with the grades of the test currently running"""
  394 + test_ref = self._testfactory["ref"]
378 395 with Session(self._engine, future=True) as session:
379   - query = select(Test.student_id, Test.grade,
380   - Test.starttime, Test.finishtime)\
381   - .where(Test.ref == test_ref)\
382   - .order_by(Test.student_id)
  396 + query = (
  397 + select(Test.student_id, Test.grade, Test.starttime, Test.finishtime)
  398 + .where(Test.ref == test_ref)
  399 + .order_by(Test.student_id)
  400 + )
383 401 tests = session.execute(query).all()
384 402 if not tests:
385   - logger.warning('Empty CSV: there are no tests!')
386   - return test_ref, ''
  403 + logger.warning("Empty CSV: there are no tests!")
  404 + return test_ref, ""
387 405  
388 406 csvstr = io.StringIO()
389   - writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
390   - writer.writerow(('Aluno', 'Nota', 'Início', 'Fim'))
  407 + writer = csv.writer(csvstr, delimiter=";", quoting=csv.QUOTE_ALL)
  408 + writer.writerow(("Aluno", "Nota", "Início", "Fim"))
391 409 writer.writerows(tests)
392 410 return test_ref, csvstr.getvalue()
393 411  
394 412 # ------------------------------------------------------------------------
395 413 def get_detailed_grades_csv(self):
396   - '''generates a CSV with the grades of the test'''
397   - test_ref = self._testfactory['ref']
  414 + """generates a CSV with the grades of the test"""
  415 + test_ref = self._testfactory["ref"]
398 416 with Session(self._engine, future=True) as session:
399   - query = select(Test.id, Test.student_id, Test.starttime,
400   - Question.number, Question.grade)\
401   - .join(Question)\
402   - .where(Test.ref == test_ref)
  417 + query = (
  418 + select(
  419 + Test.id,
  420 + Test.student_id,
  421 + Test.starttime,
  422 + Question.number,
  423 + Question.grade,
  424 + )
  425 + .join(Question)
  426 + .where(Test.ref == test_ref)
  427 + )
403 428 questions = session.execute(query).all()
404 429  
405   - cols = ['Aluno', 'Início']
406   - tests = {} # {test_id: {student_id, starttime, 0: grade, ...}}
  430 + cols = ["Aluno", "Início"]
  431 + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}}
407 432 for test_id, student_id, starttime, num, grade in questions:
408   - default_test_id = {'Aluno': student_id, 'Início': starttime}
  433 + default_test_id = {"Aluno": student_id, "Início": starttime}
409 434 tests.setdefault(test_id, default_test_id)[num] = grade
410 435 if num not in cols:
411 436 cols.append(num)
412 437  
413 438 if not tests:
414   - logger.warning('Empty CSV: there are no tests!')
415   - return test_ref, ''
  439 + logger.warning("Empty CSV: there are no tests!")
  440 + return test_ref, ""
416 441  
417 442 csvstr = io.StringIO()
418   - writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None,
419   - delimiter=';', quoting=csv.QUOTE_ALL)
  443 + writer = csv.DictWriter(
  444 + csvstr, fieldnames=cols, restval=None, delimiter=";", quoting=csv.QUOTE_ALL
  445 + )
420 446 writer.writeheader()
421 447 writer.writerows(tests.values())
422 448 return test_ref, csvstr.getvalue()
423 449  
424 450 # ------------------------------------------------------------------------
425 451 def get_json_filename_of_test(self, test_id):
426   - '''get JSON filename from database given the test_id'''
  452 + """get JSON filename from database given the test_id"""
427 453 with Session(self._engine, future=True) as session:
428 454 query = select(Test.filename).where(Test.id == test_id)
429 455 return session.execute(query).scalar()
430 456  
431 457 # ------------------------------------------------------------------------
432 458 def get_grades(self, uid, ref):
433   - '''get grades of student for a given testid'''
  459 + """get grades of student for a given testid"""
434 460 with Session(self._engine, future=True) as session:
435   - query = select(Test.grade, Test.finishtime, Test.id)\
436   - .where(Test.student_id == uid)\
437   - .where(Test.ref == ref)
  461 + query = (
  462 + select(Test.grade, Test.finishtime, Test.id)
  463 + .where(Test.student_id == uid)
  464 + .where(Test.ref == ref)
  465 + )
438 466 grades = session.execute(query).all()
439 467 return [tuple(grade) for grade in grades]
440 468  
441 469 # ------------------------------------------------------------------------
442 470 def get_students_state(self) -> list:
443   - '''get list of states of every student to show in /admin page'''
444   - return [{ 'uid': uid,
445   - 'name': student['name'],
446   - 'allowed': student['state'] == 'allowed',
447   - 'online': student['state'] == 'online',
448   - 'start_time': student.get('test', {}).get('start_time', ''),
449   - 'unfocus': student.get('unfocus', False),
450   - 'area': student.get('area', 1.0),
451   - 'grades': self.get_grades(uid, self._testfactory['ref']) }
452   - for uid, student in self._students.items()]
  471 + """get list of states of every student to show in /admin page"""
  472 + return [
  473 + {
  474 + "uid": uid,
  475 + "name": student["name"],
  476 + "allowed": student["state"] == "allowed",
  477 + "online": student["state"] == "online",
  478 + "start_time": student.get("test", {}).get("start_time", ""),
  479 + "unfocus": student.get("unfocus", False),
  480 + "area": student.get("area", 1.0),
  481 + "grades": self.get_grades(uid, self._testfactory["ref"]),
  482 + }
  483 + for uid, student in self._students.items()
  484 + ]
453 485  
454 486 # ========================================================================
455 487 # SETTERS
456 488 # ========================================================================
457 489 def allow_student(self, uid: str) -> None:
458   - '''allow a single student to login'''
459   - self._students[uid]['state'] = 'allowed'
  490 + """allow a single student to login"""
  491 + self._students[uid]["state"] = "allowed"
460 492 logger.info('"%s" allowed to login', uid)
461 493  
462 494 # ------------------------------------------------------------------------
463 495 def deny_student(self, uid: str) -> None:
464   - '''deny a single student to login'''
  496 + """deny a single student to login"""
465 497 student = self._students[uid]
466   - if student['state'] == 'allowed':
467   - student['state'] = 'offline'
  498 + if student["state"] == "allowed":
  499 + student["state"] = "offline"
468 500 logger.info('"%s" denied to login', uid)
469 501  
470 502 # ------------------------------------------------------------------------
471 503 def allow_all_students(self) -> None:
472   - '''allow all students to login'''
  504 + """allow all students to login"""
473 505 for student in self._students.values():
474   - student['state'] = 'allowed'
475   - logger.info('Allowed %d students', len(self._students))
  506 + student["state"] = "allowed"
  507 + logger.info("Allowed %d students", len(self._students))
476 508  
477 509 # ------------------------------------------------------------------------
478 510 def deny_all_students(self) -> None:
479   - '''deny all students to login'''
480   - logger.info('Denying all students...')
  511 + """deny all students to login"""
  512 + logger.info("Denying all students...")
481 513 for student in self._students.values():
482   - if student['state'] == 'allowed':
483   - student['state'] = 'offline'
  514 + if student["state"] == "allowed":
  515 + student["state"] = "offline"
484 516  
485 517 # ------------------------------------------------------------------------
486 518 async def insert_new_student(self, uid: str, name: str) -> None:
487   - '''insert new student into the database'''
  519 + """insert new student into the database"""
488 520 with Session(self._engine, future=True) as session:
489 521 try:
490   - session.add(Student(id=uid, name=name, password=''))
  522 + session.add(Student(id=uid, name=name, password=""))
491 523 session.commit()
492 524 except IntegrityError:
493 525 logger.warning('"%s" already exists!', uid)
494 526 session.rollback()
495 527 return
496   - logger.info('New student added: %s %s', uid, name)
  528 + logger.info("New student added: %s %s", uid, name)
497 529 self._students[uid] = {
498   - 'name': name,
499   - 'state': 'offline',
500   - 'test': await self._testfactory.generate(),
501   - }
  530 + "name": name,
  531 + "state": "offline",
  532 + "test": await self._testfactory.generate(),
  533 + }
502 534  
503 535 # ------------------------------------------------------------------------
504 536 def allow_from_list(self, filename: str) -> None:
505   - '''allow students listed in text file (one number per line)'''
  537 + """allow students listed in text file (one number per line)"""
506 538 # parse list of students to allow (one number per line)
507 539 try:
508   - with open(filename, 'r', encoding='utf-8') as file:
  540 + with open(filename, "r", encoding="utf-8") as file:
509 541 allowed = {line.strip() for line in file}
510   - allowed.discard('')
  542 + allowed.discard("")
511 543 except OSError as exc:
512   - error_msg = f'Cannot read file {filename}'
  544 + error_msg = f"Cannot read file {filename}"
513 545 logger.critical(error_msg)
514 546 raise AppException(error_msg) from exc
515 547  
... ... @@ -522,27 +554,34 @@ class App():
522 554 logger.warning('Allowed student "%s" does not exist!', uid)
523 555 missing += 1
524 556  
525   - logger.info('Allowed %d students', len(allowed)-missing)
  557 + logger.info("Allowed %d students", len(allowed) - missing)
526 558 if missing:
527   - logger.warning(' %d missing!', missing)
  559 + logger.warning(" %d missing!", missing)
528 560  
529 561 # ------------------------------------------------------------------------
530 562 def _focus_student(self, uid):
531   - '''set student in focus state'''
532   - self._students[uid]['unfocus'] = False
  563 + """set student in focus state"""
  564 + self._students[uid]["unfocus"] = False
533 565 logger.info('"%s" focus', uid)
534 566  
535 567 # ------------------------------------------------------------------------
536 568 def _unfocus_student(self, uid):
537   - '''set student in unfocus state'''
538   - self._students[uid]['unfocus'] = True
  569 + """set student in unfocus state"""
  570 + self._students[uid]["unfocus"] = True
539 571 logger.info('"%s" unfocus', uid)
540 572  
541 573 # ------------------------------------------------------------------------
542 574 def _set_screen_area(self, uid, sizes):
543   - '''set current browser area as detected in resize event'''
  575 + """set current browser area as detected in resize event"""
544 576 scr_y, scr_x, win_y, win_x = sizes
545 577 area = win_x * win_y / (scr_x * scr_y) * 100
546   - self._students[uid]['area'] = area
547   - logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d',
548   - uid, area, win_x, win_y, scr_x, scr_y)
  578 + self._students[uid]["area"] = area
  579 + logger.info(
  580 + '"%s" area=%g%%, window=%dx%d, screen=%dx%d',
  581 + uid,
  582 + area,
  583 + win_x,
  584 + win_y,
  585 + scr_x,
  586 + scr_y,
  587 + )
... ...
perguntations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
3   -'''
  3 +"""
4 4 Commandline utility to initialize and update student database
5   -'''
  5 +"""
6 6  
7 7 # base
8 8 import csv
... ... @@ -23,77 +23,84 @@ from .models import Base, Student
23 23  
24 24 # ============================================================================
25 25 def parse_commandline_arguments():
26   - '''Parse command line options'''
  26 + """Parse command line options"""
27 27  
28 28 parser = argparse.ArgumentParser(
29 29 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
30   - description='Insert new users into a database. Users can be imported '
31   - 'from CSV files in the SIIUE format or defined in the '
32   - 'command line. If the database does not exist, a new one '
33   - 'is created.')
34   -
35   - parser.add_argument('csvfile',
36   - nargs='*',
37   - type=str,
38   - default='',
39   - help='CSV file to import (SIIUE)')
40   -
41   - parser.add_argument('--db',
42   - default='students.db',
43   - type=str,
44   - help='database file')
45   -
46   - parser.add_argument('-A', '--admin',
47   - action='store_true',
48   - help='insert admin user 0 "Admin"')
49   -
50   - parser.add_argument('-a', '--add',
51   - nargs=2,
52   - action='append',
53   - metavar=('uid', 'name'),
54   - help='add new user id and name')
55   -
56   - parser.add_argument('-u', '--update',
57   - nargs='+',
58   - metavar='uid',
59   - default=[],
60   - help='list of users whose password is to be updated')
61   -
62   - parser.add_argument('-U', '--update-all',
63   - action='store_true',
64   - help='all except admin will have the password updated')
65   -
66   - parser.add_argument('--pw',
67   - default=None,
68   - type=str,
69   - help='password for new or updated users')
70   -
71   - parser.add_argument('-V', '--verbose',
72   - action='store_true',
73   - help='show all students in database')
  30 + description="Insert new users into a database. Users can be imported "
  31 + "from CSV files in the SIIUE format or defined in the "
  32 + "command line. If the database does not exist, a new one "
  33 + "is created.",
  34 + )
  35 +
  36 + parser.add_argument(
  37 + "csvfile", nargs="*", type=str, default="", help="CSV file to import (SIIUE)"
  38 + )
  39 +
  40 + parser.add_argument("--db", default="students.db", type=str, help="database file")
  41 +
  42 + parser.add_argument(
  43 + "-A", "--admin", action="store_true", help='insert admin user 0 "Admin"'
  44 + )
  45 +
  46 + parser.add_argument(
  47 + "-a",
  48 + "--add",
  49 + nargs=2,
  50 + action="append",
  51 + metavar=("uid", "name"),
  52 + help="add new user id and name",
  53 + )
  54 +
  55 + parser.add_argument(
  56 + "-u",
  57 + "--update",
  58 + nargs="+",
  59 + metavar="uid",
  60 + default=[],
  61 + help="list of users whose password is to be updated",
  62 + )
  63 +
  64 + parser.add_argument(
  65 + "-U",
  66 + "--update-all",
  67 + action="store_true",
  68 + help="all except admin will have the password updated",
  69 + )
  70 +
  71 + parser.add_argument(
  72 + "--pw", default=None, type=str, help="password for new or updated users"
  73 + )
  74 +
  75 + parser.add_argument(
  76 + "-V", "--verbose", action="store_true", help="show all students in database"
  77 + )
74 78  
75 79 return parser.parse_args()
76 80  
77 81  
78 82 # ============================================================================
79 83 def get_students_from_csv(filename):
80   - '''
  84 + """
81 85 SIIUE names have alien strings like "(TE)" and are sometimes capitalized
82 86 We remove them so that students dont keep asking what it means
83   - '''
  87 + """
84 88 csv_settings = {
85   - 'delimiter': ';',
86   - 'quotechar': '"',
87   - 'skipinitialspace': True,
88   - }
  89 + "delimiter": ";",
  90 + "quotechar": '"',
  91 + "skipinitialspace": True,
  92 + }
89 93  
90 94 try:
91   - with open(filename, encoding='iso-8859-1') as file:
  95 + with open(filename, encoding="iso-8859-1") as file:
92 96 csvreader = csv.DictReader(file, **csv_settings)
93   - students = [{
94   - 'uid': s['N.º'],
95   - 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
96   - } for s in csvreader]
  97 + students = [
  98 + {
  99 + "uid": s["N.º"],
  100 + "name": capwords(re.sub(r"\(.*\)", "", s["Nome"]).strip()),
  101 + }
  102 + for s in csvreader
  103 + ]
97 104 except OSError:
98 105 print(f'!!! Error reading file "{filename}" !!!')
99 106 students = []
... ... @@ -106,63 +113,63 @@ def get_students_from_csv(filename):
106 113  
107 114 # ============================================================================
108 115 def hashpw(student, password=None) -> None:
109   - '''replace password by hash for a single student'''
110   - print('.', end='', flush=True)
  116 + """replace password by hash for a single student"""
  117 + print(".", end="", flush=True)
111 118 if password is None:
112   - student['pw'] = ''
  119 + student["pw"] = ""
113 120 else:
114   - student['pw'] = bcrypt.hashpw(password.encode('utf-8'),
115   - bcrypt.gensalt())
  121 + student["pw"] = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
116 122  
117 123  
118 124 # ============================================================================
119 125 def insert_students_into_db(session, students) -> None:
120   - '''insert list of students into the database'''
  126 + """insert list of students into the database"""
121 127 try:
122   - session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])
123   - for s in students])
  128 + session.add_all(
  129 + [Student(id=s["uid"], name=s["name"], password=s["pw"]) for s in students]
  130 + )
124 131 session.commit()
125 132  
126 133 except IntegrityError:
127   - print('!!! Integrity error. Users already in database. Aborted !!!\n')
  134 + print("!!! Integrity error. Users already in database. Aborted !!!\n")
128 135 session.rollback()
129 136  
130 137  
131 138 # ============================================================================
132 139 def show_students_in_database(session, verbose=False):
133   - '''get students from database'''
  140 + """get students from database"""
134 141 users = session.execute(select(Student)).scalars().all()
135 142 # users = session.query(Student).all()
136 143 total = len(users)
137 144  
138   - print('Registered users:')
  145 + print("Registered users:")
139 146 if total == 0:
140   - print(' -- none --')
  147 + print(" -- none --")
141 148 else:
142   - users.sort(key=lambda u: f'{u.id:>12}') # sort by number
  149 + users.sort(key=lambda u: f"{u.id:>12}") # sort by number
143 150 if verbose:
144 151 for user in users:
145   - print(f'{user.id:>12} {user.name}')
  152 + print(f"{user.id:>12} {user.name}")
146 153 else:
147   - print(f'{users[0].id:>12} {users[0].name}')
  154 + print(f"{users[0].id:>12} {users[0].name}")
148 155 if total > 1:
149   - print(f'{users[1].id:>12} {users[1].name}')
  156 + print(f"{users[1].id:>12} {users[1].name}")
150 157 if total > 3:
151   - print(' | |')
  158 + print(" | |")
152 159 if total > 2:
153   - print(f'{users[-1].id:>12} {users[-1].name}')
154   - print(f'Total: {total}.')
  160 + print(f"{users[-1].id:>12} {users[-1].name}")
  161 + print(f"Total: {total}.")
155 162  
156 163  
157 164 # ============================================================================
158 165 def main():
159   - '''insert, update, show students from database'''
  166 + """insert, update, show students from database"""
160 167  
161 168 args = parse_commandline_arguments()
162 169  
163 170 # --- database
164   - print(f'Database: {args.db}')
165   - engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True)
  171 + print(f"Database: {args.db}")
  172 + engine = create_engine(f"sqlite:///{args.db}", echo=False, future=True)
166 173 Base.metadata.create_all(engine) # Criates schema if needed
167 174 session = Session(engine, future=True)
168 175  
... ... @@ -170,49 +177,48 @@ def main():
170 177 students = []
171 178  
172 179 if args.admin:
173   - print('Adding user: 0, Admin.')
174   - students.append({'uid': '0', 'name': 'Admin'})
  180 + print("Adding user: 0, Admin.")
  181 + students.append({"uid": "0", "name": "Admin"})
175 182  
176 183 for csvfile in args.csvfile:
177   - print('Adding users from:', csvfile)
  184 + print("Adding users from:", csvfile)
178 185 students.extend(get_students_from_csv(csvfile))
179 186  
180 187 if args.add:
181 188 for uid, name in args.add:
182   - print(f'Adding user: {uid}, {name}.')
183   - students.append({'uid': uid, 'name': name})
  189 + print(f"Adding user: {uid}, {name}.")
  190 + students.append({"uid": uid, "name": name})
184 191  
185 192 # --- insert new students
186 193 if students:
187   - print('Generating password hashes', end='')
  194 + print("Generating password hashes", end="")
188 195 with ThreadPoolExecutor() as executor: # hashing
189 196 executor.map(lambda s: hashpw(s, args.pw), students)
190   - print(f'\nInserting {len(students)}')
  197 + print(f"\nInserting {len(students)}")
191 198 insert_students_into_db(session, students)
192 199  
193 200 # --- update all students
194 201 if args.update_all:
195   - all_students = session.execute(
196   - select(Student).where(Student.id != '0')
197   - ).scalars().all()
  202 + all_students = (
  203 + session.execute(select(Student).where(Student.id != "0")).scalars().all()
  204 + )
198 205  
199   - print(f'Updating password of {len(all_students)} users', end='')
  206 + print(f"Updating password of {len(all_students)} users", end="")
200 207 for student in all_students:
201   - password = (args.pw or student.id).encode('utf-8')
  208 + password = (args.pw or student.id).encode("utf-8")
202 209 student.password = bcrypt.hashpw(password, bcrypt.gensalt())
203   - print('.', end='', flush=True)
  210 + print(".", end="", flush=True)
204 211 print()
205 212 session.commit()
206 213  
207 214 # --- update some students
208 215 else:
209 216 for student_id in args.update:
210   - print(f'Updating password of {student_id}')
  217 + print(f"Updating password of {student_id}")
211 218 student = session.execute(
212   - select(Student).
213   - where(Student.id == student_id)
  219 + select(Student).where(Student.id == student_id)
214 220 ).scalar_one()
215   - new_password = (args.pw or student_id).encode('utf-8')
  221 + new_password = (args.pw or student_id).encode("utf-8")
216 222 student.password = bcrypt.hashpw(new_password, bcrypt.gensalt())
217 223 session.commit()
218 224  
... ... @@ -222,5 +228,5 @@ def main():
222 228  
223 229  
224 230 # ============================================================================
225   -if __name__ == '__main__':
  231 +if __name__ == "__main__":
226 232 main()
... ...
perguntations/main.py
1 1 #!/usr/bin/env python3
2 2  
3   -'''
  3 +"""
4 4 Start application and web server
5   -'''
  5 +"""
6 6  
7 7  
8 8 # python standard library
... ... @@ -21,126 +21,157 @@ from . import APP_NAME, APP_VERSION
21 21  
22 22 # ----------------------------------------------------------------------------
23 23 def parse_cmdline_arguments() -> argparse.Namespace:
24   - '''
  24 + """
25 25 Get command line arguments
26   - '''
  26 + """
27 27 parser = argparse.ArgumentParser(
28   - description='Server for online tests. Enrolled students and tests '
29   - 'have to be previously configured. Please read the documentation '
30   - 'included with this software before running the server.')
31   - parser.add_argument('testfile',
32   - type=str,
33   - help='tests in YAML format')
34   - parser.add_argument('--allow-all',
35   - action='store_true',
36   - help='Allow all students to login immediately')
37   - parser.add_argument('--allow-list',
38   - type=str,
39   - help='File with list of students to allow immediately')
40   - parser.add_argument('--debug',
41   - action='store_true',
42   - help='Enable debug messages')
43   - parser.add_argument('--review',
44   - action='store_true',
45   - help='Review mode: doesn\'t generate test')
46   - parser.add_argument('--correct',
47   - action='store_true',
48   - help='Correct test and update JSON files and database')
49   - parser.add_argument('--port',
50   - type=int,
51   - default=8443,
52   - help='port for the HTTPS server (default: 8443)')
53   - parser.add_argument('--version',
54   - action='version',
55   - version=f'{APP_VERSION} - python {sys.version}',
56   - help='Show version information and exit')
  28 + description="Server for online tests. Enrolled students and tests "
  29 + "have to be previously configured. Please read the documentation "
  30 + "included with this software before running the server."
  31 + )
  32 + parser.add_argument(
  33 + "testfile",
  34 + type=str,
  35 + help="test configuration in YAML format",
  36 + )
  37 + parser.add_argument(
  38 + "--allow-all",
  39 + action="store_true",
  40 + help="Allow login for all students",
  41 + )
  42 + parser.add_argument(
  43 + "--allow-list",
  44 + type=str,
  45 + help="File with list of students to allow immediately",
  46 + )
  47 + parser.add_argument(
  48 + "--debug",
  49 + action="store_true",
  50 + help="Enable debug mode in the logger and webserver",
  51 + )
  52 + parser.add_argument(
  53 + "--review",
  54 + action="store_true",
  55 + help="Fast start by not generating tests",
  56 + )
  57 + parser.add_argument(
  58 + "--correct",
  59 + action="store_true",
  60 + help="Correct test and update JSON files and database",
  61 + )
  62 + parser.add_argument(
  63 + "--port",
  64 + type=int,
  65 + default=8443,
  66 + help="port for the HTTPS server (default: 8443)",
  67 + )
  68 + parser.add_argument(
  69 + "--version",
  70 + action="version",
  71 + version=f"{APP_VERSION} - python {sys.version}",
  72 + help="Show version information and exit",
  73 + )
57 74 return parser.parse_args()
58 75  
  76 +
59 77 # ----------------------------------------------------------------------------
60 78 def get_logger_config(debug=False) -> dict:
61   - '''
  79 + """
62 80 Load logger configuration from ~/.config directory if exists,
63 81 otherwise set default paramenters.
64   - '''
  82 + """
65 83  
66   - file = 'logger-debug.yaml' if debug else 'logger.yaml'
67   - path = os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config/'))
  84 + file = "logger-debug.yaml" if debug else "logger.yaml"
  85 + path = os.path.expanduser(os.environ.get("XDG_CONFIG_HOME", "~/.config/"))
68 86 try:
69 87 return load_yaml(os.path.join(path, APP_NAME, file))
70 88 except OSError:
71   - print('Using default logger configuration...')
  89 + print("Using default logger configuration...")
72 90  
73 91 if debug:
74   - level = 'DEBUG'
75   - fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s'
76   - dateformat = ''
  92 + level = "DEBUG"
  93 + fmt = (
  94 + "%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s"
  95 + )
  96 + dateformat = ""
77 97 else:
78   - level = 'INFO'
79   - fmt = '%(asctime)s| %(levelname)-8s| %(message)s'
80   - dateformat = '%Y-%m-%d %H:%M:%S'
81   - modules = ['main', 'serve', 'app', 'models', 'questions', 'test',
82   - 'testfactory', 'tools']
83   - logger = {'handlers': ['default'], 'level': level, 'propagate': False}
  98 + level = "INFO"
  99 + fmt = "%(asctime)s| %(levelname)-8s| %(message)s"
  100 + dateformat = "%Y-%m-%d %H:%M:%S"
  101 + modules = [
  102 + "main",
  103 + "serve",
  104 + "app",
  105 + "models",
  106 + "questions",
  107 + "test",
  108 + "testfactory",
  109 + "tools",
  110 + ]
  111 + logger = {"handlers": ["default"], "level": level, "propagate": False}
84 112 return {
85   - 'version': 1,
86   - 'formatters': {
87   - 'standard': {
88   - 'format': fmt,
89   - 'datefmt': dateformat,
90   - },
91   - },
92   - 'handlers': {
93   - 'default': {
94   - 'level': level,
95   - 'class': 'logging.StreamHandler',
96   - 'formatter': 'standard',
97   - 'stream': 'ext://sys.stdout',
98   - },
99   - },
100   - 'loggers': {f'{APP_NAME}.{module}': logger for module in modules}
101   - }
  113 + "version": 1,
  114 + "formatters": {
  115 + "standard": {
  116 + "format": fmt,
  117 + "datefmt": dateformat,
  118 + },
  119 + },
  120 + "handlers": {
  121 + "default": {
  122 + "level": level,
  123 + "class": "logging.StreamHandler",
  124 + "formatter": "standard",
  125 + "stream": "ext://sys.stdout",
  126 + },
  127 + },
  128 + "loggers": {f"{APP_NAME}.{module}": logger for module in modules},
  129 + }
  130 +
102 131  
103 132 # ----------------------------------------------------------------------------
104 133 def main() -> None:
105   - '''
  134 + """
106 135 Tornado web server
107   - '''
  136 + """
108 137 args = parse_cmdline_arguments()
109 138  
110 139 # --- Setup logging ------------------------------------------------------
111 140 logging.config.dictConfig(get_logger_config(args.debug))
112 141 logger = logging.getLogger(__name__)
113 142  
114   - logger.info('================== Start Logging ==================')
  143 + logger.info("================== Start Logging ==================")
115 144  
116 145 # --- start application --------------------------------------------------
117 146 config = {
118   - 'testfile': args.testfile,
119   - 'allow_all': args.allow_all,
120   - 'allow_list': args.allow_list,
121   - 'debug': args.debug,
122   - 'review': args.review,
123   - 'correct': args.correct,
124   - }
  147 + "testfile": args.testfile,
  148 + "allow_all": args.allow_all,
  149 + "allow_list": args.allow_list,
  150 + "debug": args.debug,
  151 + "review": args.review,
  152 + "correct": args.correct,
  153 + }
125 154  
126 155 try:
127 156 app = App(config)
128 157 except AppException:
129   - logger.critical('Failed to start application!')
  158 + logger.critical("Failed to start application!")
130 159 sys.exit(1)
131 160  
132 161 # --- get SSL certificates -----------------------------------------------
133   - if 'XDG_DATA_HOME' in os.environ:
134   - certs_dir = os.path.join(os.environ['XDG_DATA_HOME'], 'certs')
  162 + if "XDG_DATA_HOME" in os.environ:
  163 + certs_dir = os.path.join(os.environ["XDG_DATA_HOME"], "certs")
135 164 else:
136   - certs_dir = os.path.expanduser('~/.local/share/certs')
  165 + certs_dir = os.path.expanduser("~/.local/share/certs")
137 166  
138 167 ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
139 168 try:
140   - ssl_opt.load_cert_chain(os.path.join(certs_dir, 'cert.pem'),
141   - os.path.join(certs_dir, 'privkey.pem'))
  169 + ssl_opt.load_cert_chain(
  170 + os.path.join(certs_dir, "cert.pem"),
  171 + os.path.join(certs_dir, "privkey.pem"),
  172 + )
142 173 except FileNotFoundError:
143   - logger.critical('SSL certificates missing in %s', certs_dir)
  174 + logger.critical("SSL certificates missing in %s", certs_dir)
144 175 sys.exit(1)
145 176  
146 177 # --- run webserver ------------------------------------------------------
... ...
perguntations/models.py
1   -'''
  1 +"""
2 2 perguntations/models.py
3 3 SQLAlchemy ORM
4   -'''
  4 +"""
5 5  
6 6 from typing import Any
7 7  
... ... @@ -16,26 +16,30 @@ Base: Any = declarative_base()
16 16  
17 17 # ----------------------------------------------------------------------------
18 18 class Student(Base):
19   - '''Student table'''
20   - __tablename__ = 'students'
  19 + """Student table"""
  20 +
  21 + __tablename__ = "students"
21 22 id = Column(String, primary_key=True)
22 23 name = Column(String)
23 24 password = Column(String)
24 25  
25 26 # ---
26   - tests = relationship('Test', back_populates='student')
  27 + tests = relationship("Test", back_populates="student")
27 28  
28 29 def __repr__(self):
29   - return (f'Student('
30   - f'id={self.id!r}, '
31   - f'name={self.name!r}, '
32   - f'password={self.password!r})')
  30 + return (
  31 + f"Student("
  32 + f"id={self.id!r}, "
  33 + f"name={self.name!r}, "
  34 + f"password={self.password!r})"
  35 + )
33 36  
34 37  
35 38 # ----------------------------------------------------------------------------
36 39 class Test(Base):
37   - '''Test table'''
38   - __tablename__ = 'tests'
  40 + """Test table"""
  41 +
  42 + __tablename__ = "tests"
39 43 id = Column(Integer, primary_key=True) # auto_increment
40 44 ref = Column(String)
41 45 title = Column(String)
... ... @@ -45,49 +49,54 @@ class Test(Base):
45 49 starttime = Column(String)
46 50 finishtime = Column(String)
47 51 filename = Column(String)
48   - student_id = Column(String, ForeignKey('students.id'))
  52 + student_id = Column(String, ForeignKey("students.id"))
49 53  
50 54 # ---
51   - student = relationship('Student', back_populates='tests')
52   - questions = relationship('Question', back_populates='test')
  55 + student = relationship("Student", back_populates="tests")
  56 + questions = relationship("Question", back_populates="test")
53 57  
54 58 def __repr__(self):
55   - return (f'Test('
56   - f'id={self.id!r}, '
57   - f'ref={self.ref!r}, '
58   - f'title={self.title!r}, '
59   - f'grade={self.grade!r}, '
60   - f'state={self.state!r}, '
61   - f'comment={self.comment!r}, '
62   - f'starttime={self.starttime!r}, '
63   - f'finishtime={self.finishtime!r}, '
64   - f'filename={self.filename!r}, '
65   - f'student_id={self.student_id!r})')
  59 + return (
  60 + f"Test("
  61 + f"id={self.id!r}, "
  62 + f"ref={self.ref!r}, "
  63 + f"title={self.title!r}, "
  64 + f"grade={self.grade!r}, "
  65 + f"state={self.state!r}, "
  66 + f"comment={self.comment!r}, "
  67 + f"starttime={self.starttime!r}, "
  68 + f"finishtime={self.finishtime!r}, "
  69 + f"filename={self.filename!r}, "
  70 + f"student_id={self.student_id!r})"
  71 + )
66 72  
67 73  
68 74 # ---------------------------------------------------------------------------
69 75 class Question(Base):
70   - '''Question table'''
71   - __tablename__ = 'questions'
  76 + """Question table"""
  77 +
  78 + __tablename__ = "questions"
72 79 id = Column(Integer, primary_key=True) # auto_increment
73   - number = Column(Integer) # question number (ref may be not be unique)
  80 + number = Column(Integer) # question number (ref may be not be unique)
74 81 ref = Column(String)
75 82 grade = Column(Float)
76 83 comment = Column(String)
77 84 starttime = Column(String)
78 85 finishtime = Column(String)
79   - test_id = Column(String, ForeignKey('tests.id'))
  86 + test_id = Column(String, ForeignKey("tests.id"))
80 87  
81 88 # ---
82   - test = relationship('Test', back_populates='questions')
  89 + test = relationship("Test", back_populates="questions")
83 90  
84 91 def __repr__(self):
85   - return (f'Question('
86   - f'id={self.id!r}, '
87   - f'number={self.number!r}, '
88   - f'ref={self.ref!r}, '
89   - f'grade={self.grade!r}, '
90   - f'comment={self.comment!r}, '
91   - f'starttime={self.starttime!r}, '
92   - f'finishtime={self.finishtime!r}, '
93   - f'test_id={self.test_id!r})')
  92 + return (
  93 + f"Question("
  94 + f"id={self.id!r}, "
  95 + f"number={self.number!r}, "
  96 + f"ref={self.ref!r}, "
  97 + f"grade={self.grade!r}, "
  98 + f"comment={self.comment!r}, "
  99 + f"starttime={self.starttime!r}, "
  100 + f"finishtime={self.finishtime!r}, "
  101 + f"test_id={self.test_id!r})"
  102 + )
... ...
perguntations/parser_markdown.py
1   -
2   -'''
  1 +"""
3 2 Parse markdown and generate HTML
4 3 Includes support for LaTeX formulas
5   -'''
  4 +"""
6 5  
7 6  
8 7 # python standard library
... ... @@ -26,21 +25,26 @@ logger = logging.getLogger(__name__)
26 25 # Block math: $$x$$ or \begin{equation}x\end{equation}
27 26 # -------------------------------------------------------------------------
28 27 class MathBlockGrammar(mistune.BlockGrammar):
29   - '''
  28 + """
30 29 match block math $$x$$ and math environments begin{} end{}
31   - '''
  30 + """
  31 +
32 32 # pylint: disable=too-few-public-methods
33 33 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
34   - latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}",
35   - re.DOTALL)
  34 + latex_environment = re.compile(
  35 + r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL
  36 + )
36 37  
37 38  
38 39 class MathBlockLexer(mistune.BlockLexer):
39   - '''
  40 + """
40 41 parser for block math and latex environment
41   - '''
42   - default_rules = ['block_math', 'latex_environment'] \
43   - + mistune.BlockLexer.default_rules
  42 + """
  43 +
  44 + default_rules = [
  45 + "block_math",
  46 + "latex_environment",
  47 + ] + mistune.BlockLexer.default_rules
44 48  
45 49 def __init__(self, rules=None, **kwargs):
46 50 if rules is None:
... ... @@ -48,36 +52,37 @@ class MathBlockLexer(mistune.BlockLexer):
48 52 super().__init__(rules, **kwargs)
49 53  
50 54 def parse_block_math(self, math):
51   - '''Parse a $$math$$ block'''
52   - self.tokens.append({
53   - 'type': 'block_math',
54   - 'text': math.group(1)
55   - })
  55 + """Parse a $$math$$ block"""
  56 + self.tokens.append({"type": "block_math", "text": math.group(1)})
56 57  
57 58 def parse_latex_environment(self, math):
58   - '''Parse latex environment in formula'''
59   - self.tokens.append({
60   - 'type': 'latex_environment',
61   - 'name': math.group(1),
62   - 'text': math.group(2)
63   - })
  59 + """Parse latex environment in formula"""
  60 + self.tokens.append(
  61 + {
  62 + "type": "latex_environment",
  63 + "name": math.group(1),
  64 + "text": math.group(2),
  65 + }
  66 + )
64 67  
65 68  
66 69 class MathInlineGrammar(mistune.InlineGrammar):
67   - '''
  70 + """
68 71 match inline math $x$, block math $$x$$ and text
69   - '''
  72 + """
  73 +
70 74 # pylint: disable=too-few-public-methods
71 75 math = re.compile(r"^\$(.+?)\$", re.DOTALL)
72 76 block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL)
73   - text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)')
  77 + text = re.compile(r"^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)")
74 78  
75 79  
76 80 class MathInlineLexer(mistune.InlineLexer):
77   - '''
  81 + """
78 82 render output math
79   - '''
80   - default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules
  83 + """
  84 +
  85 + default_rules = ["block_math", "math"] + mistune.InlineLexer.default_rules
81 86  
82 87 def __init__(self, renderer, rules=None, **kwargs):
83 88 if rules is None:
... ... @@ -85,70 +90,75 @@ class MathInlineLexer(mistune.InlineLexer):
85 90 super().__init__(renderer, rules, **kwargs)
86 91  
87 92 def output_math(self, math):
88   - '''render inline math'''
  93 + """render inline math"""
89 94 return self.renderer.inline_math(math.group(1))
90 95  
91 96 def output_block_math(self, math):
92   - '''render block math'''
  97 + """render block math"""
93 98 return self.renderer.block_math(math.group(1))
94 99  
95 100  
96 101 class MarkdownWithMath(mistune.Markdown):
97   - '''
  102 + """
98 103 render ouput latex
99   - '''
  104 + """
  105 +
100 106 def __init__(self, renderer, **kwargs):
101   - if 'inline' not in kwargs:
102   - kwargs['inline'] = MathInlineLexer
103   - if 'block' not in kwargs:
104   - kwargs['block'] = MathBlockLexer
  107 + if "inline" not in kwargs:
  108 + kwargs["inline"] = MathInlineLexer
  109 + if "block" not in kwargs:
  110 + kwargs["block"] = MathBlockLexer
105 111 super().__init__(renderer, **kwargs)
106 112  
107 113 def output_block_math(self):
108   - '''render block math'''
109   - return self.renderer.block_math(self.token['text'])
  114 + """render block math"""
  115 + return self.renderer.block_math(self.token["text"])
110 116  
111 117 def output_latex_environment(self):
112   - '''render latex environment'''
113   - return self.renderer.latex_environment(self.token['name'],
114   - self.token['text'])
  118 + """render latex environment"""
  119 + return self.renderer.latex_environment(self.token["name"], self.token["text"])
115 120  
116 121  
117 122 class HighlightRenderer(mistune.Renderer):
118   - '''
  123 + """
119 124 images, tables, block code
120   - '''
121   - def __init__(self, qref='.'):
  125 + """
  126 +
  127 + def __init__(self, qref="."):
122 128 super().__init__(escape=True)
123 129 self.qref = qref
124 130  
125   - def block_code(self, code, lang='text'):
126   - '''render code block with syntax highlight'''
  131 + def block_code(self, code, lang="text"):
  132 + """render code block with syntax highlight"""
127 133 try:
128 134 lexer = get_lexer_by_name(lang, stripall=False)
129 135 except Exception:
130   - lexer = get_lexer_by_name('text', stripall=False)
  136 + lexer = get_lexer_by_name("text", stripall=False)
131 137  
132 138 formatter = HtmlFormatter()
133 139 return highlight(code, lexer, formatter)
134 140  
135 141 def table(self, header, body):
136   - '''render table'''
137   - return '<table class="table table-sm"><thead class="thead-light">' \
138   - + header + '</thead><tbody>' + body + '</tbody></table>'
  142 + """render table"""
  143 + return (
  144 + '<table class="table table-sm"><thead class="thead-light">'
  145 + + header
  146 + + "</thead><tbody>"
  147 + + body
  148 + + "</tbody></table>"
  149 + )
139 150  
140 151 def image(self, src, title, text):
141   - '''render image'''
  152 + """render image"""
142 153 alt = mistune.escape(text, quote=True)
143 154 if title is not None:
144 155 if title: # not empty string, show as caption
145 156 title = mistune.escape(title, quote=True)
146   - caption = f'<figcaption class="figure-caption">{title}' \
147   - '</figcaption>'
  157 + caption = f'<figcaption class="figure-caption">{title}' "</figcaption>"
148 158 else: # title is an empty string, show as centered figure
149   - caption = ''
  159 + caption = ""
150 160  
151   - return f'''
  161 + return f"""
152 162 <div class="text-center">
153 163 <figure class="figure">
154 164 <img src="/file?ref={self.qref}&image={src}"
... ... @@ -157,31 +167,31 @@ class HighlightRenderer(mistune.Renderer):
157 167 {caption}
158 168 </figure>
159 169 </div>
160   - '''
  170 + """
161 171  
162 172 # title indefined, show as inline image
163   - return f'''
  173 + return f"""
164 174 <img src="/file?ref={self.qref}&image={src}"
165 175 class="figure-img img-fluid" alt="{alt}" title="{title}">
166   - '''
  176 + """
167 177  
168 178 # Pass math through unaltered - mathjax does the rendering in the browser
169 179 def block_math(self, text):
170   - '''bypass block math'''
  180 + """bypass block math"""
171 181 # pylint: disable=no-self-use
172   - return fr'$$ {text} $$'
  182 + return rf"$$ {text} $$"
173 183  
174 184 def latex_environment(self, name, text):
175   - '''bypass latex environment'''
  185 + """bypass latex environment"""
176 186 # pylint: disable=no-self-use
177   - return fr'\begin{{{name}}} {text} \end{{{name}}}'
  187 + return rf"\begin{{{name}}} {text} \end{{{name}}}"
178 188  
179 189 def inline_math(self, text):
180   - '''bypass inline math'''
  190 + """bypass inline math"""
181 191 # pylint: disable=no-self-use
182   - return fr'$$$ {text} $$$'
  192 + return rf"$$$ {text} $$$"
183 193  
184 194  
185   -def md_to_html(qref='.'):
186   - '''markdown to html interface'''
  195 +def md_to_html(qref="."):
  196 + """markdown to html interface"""
187 197 return MarkdownWithMath(HighlightRenderer(qref=qref))
... ...
perguntations/questions.py
1   -'''
  1 +"""
2 2 File: perguntations/questions.py
3 3 Description: Classes the implement several types of questions.
4   -'''
  4 +"""
5 5  
6 6  
7 7 # python standard library
... ... @@ -19,11 +19,11 @@ from .tools import run_script, run_script_async
19 19 # setup logger for this module
20 20 logger = logging.getLogger(__name__)
21 21  
22   -QDict = NewType('QDict', Dict[str, Any])
  22 +QDict = NewType("QDict", Dict[str, Any])
23 23  
24 24  
25 25 class QuestionException(Exception):
26   - '''Exceptions raised in this module'''
  26 + """Exceptions raised in this module"""
27 27  
28 28  
29 29 # ============================================================================
... ... @@ -31,68 +31,72 @@ class QuestionException(Exception):
31 31 # presented to students.
32 32 # ============================================================================
33 33 class Question(dict):
34   - '''
  34 + """
35 35 Classes derived from this base class are meant to instantiate questions
36 36 for each student.
37 37 Instances can shuffle options or automatically generate questions.
38   - '''
  38 + """
39 39  
40 40 def gen(self) -> None:
41   - '''
  41 + """
42 42 Sets defaults that are valid for any question type
43   - '''
  43 + """
44 44  
45 45 # add required keys if missing
46   - self.set_defaults(QDict({
47   - 'title': '',
48   - 'answer': None,
49   - 'comments': '',
50   - 'solution': '',
51   - 'files': {},
52   - }))
  46 + self.set_defaults(
  47 + QDict(
  48 + {
  49 + "title": "",
  50 + "answer": None,
  51 + "comments": "",
  52 + "solution": "",
  53 + "files": {},
  54 + }
  55 + )
  56 + )
53 57  
54 58 def set_answer(self, ans) -> None:
55   - '''set answer field and register time'''
56   - self['answer'] = ans
57   - self['finish_time'] = datetime.now()
  59 + """set answer field and register time"""
  60 + self["answer"] = ans
  61 + self["finish_time"] = datetime.now()
58 62  
59 63 def correct(self) -> None:
60   - '''default correction (synchronous version)'''
61   - self['comments'] = ''
62   - self['grade'] = 0.0
  64 + """default correction (synchronous version)"""
  65 + self["comments"] = ""
  66 + self["grade"] = 0.0
63 67  
64 68 async def correct_async(self) -> None:
65   - '''default correction (async version)'''
  69 + """default correction (async version)"""
66 70 self.correct()
67 71  
68 72 def set_defaults(self, qdict: QDict) -> None:
69   - '''Add k:v pairs from default dict d for nonexistent keys'''
  73 + """Add k:v pairs from default dict d for nonexistent keys"""
70 74 for k, val in qdict.items():
71 75 self.setdefault(k, val)
72 76  
73 77  
74 78 # ============================================================================
75 79 class QuestionRadio(Question):
76   - '''An instance of QuestionRadio will always have the keys:
77   - type (str)
78   - text (str)
79   - options (list of strings)
80   - correct (list of floats)
81   - discount (bool, default=True)
82   - answer (None or an actual answer)
83   - shuffle (bool, default=True)
84   - choose (int) # only used if shuffle=True
85   - '''
  80 + """An instance of QuestionRadio will always have the keys:
  81 + type (str)
  82 + text (str)
  83 + options (list of strings)
  84 + correct (list of floats)
  85 + discount (bool, default=True)
  86 + answer (None or an actual answer)
  87 + shuffle (bool, default=True)
  88 + choose (int) # only used if shuffle=True
  89 + """
86 90  
87 91 # ------------------------------------------------------------------------
88 92 def gen(self) -> None:
89   - '''
  93 + """
90 94 Sets defaults, performs checks and generates the actual question
91 95 by modifying the options and correct values
92   - '''
  96 + """
93 97 super().gen()
94 98 try:
95   - nopts = len(self['options'])
  99 + nopts = len(self["options"])
96 100 except KeyError as exc:
97 101 msg = f'Missing `options`. In question "{self["ref"]}"'
98 102 logger.error(msg)
... ... @@ -102,121 +106,125 @@ class QuestionRadio(Question):
102 106 logger.error(msg)
103 107 raise QuestionException(msg) from exc
104 108  
105   - self.set_defaults(QDict({
106   - 'text': '',
107   - 'correct': 0,
108   - 'shuffle': True,
109   - 'discount': True,
110   - 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options
111   - }))
  109 + self.set_defaults(
  110 + QDict(
  111 + {
  112 + "text": "",
  113 + "correct": 0,
  114 + "shuffle": True,
  115 + "discount": True,
  116 + "max_tries": (nopts + 3) // 4, # 1 try for each 4 options
  117 + }
  118 + )
  119 + )
112 120  
113 121 # check correct bounds and convert int to list,
114 122 # e.g. correct: 2 --> correct: [0,0,1,0,0]
115   - if isinstance(self['correct'], int):
116   - if not 0 <= self['correct'] < nopts:
  123 + if isinstance(self["correct"], int):
  124 + if not 0 <= self["correct"] < nopts:
117 125 msg = f'"{self["ref"]}": correct out of range 0..{nopts-1}'
118 126 logger.error(msg)
119 127 raise QuestionException(msg)
120 128  
121   - self['correct'] = [1.0 if x == self['correct'] else 0.0
122   - for x in range(nopts)]
  129 + self["correct"] = [
  130 + 1.0 if x == self["correct"] else 0.0 for x in range(nopts)
  131 + ]
123 132  
124   - elif isinstance(self['correct'], list):
  133 + elif isinstance(self["correct"], list):
125 134 # must match number of options
126   - if len(self['correct']) != nopts:
  135 + if len(self["correct"]) != nopts:
127 136 msg = f'"{self["ref"]}": number of options/correct mismatch'
128 137 logger.error(msg)
129 138 raise QuestionException(msg)
130 139  
131 140 # make sure is a list of floats
132 141 try:
133   - self['correct'] = [float(x) for x in self['correct']]
  142 + self["correct"] = [float(x) for x in self["correct"]]
134 143 except (ValueError, TypeError) as exc:
135 144 msg = f'"{self["ref"]}": correct must contain floats or bools'
136 145 logger.error(msg)
137 146 raise QuestionException(msg) from exc
138 147  
139 148 # check grade boundaries
140   - if self['discount'] and not all(0.0 <= x <= 1.0
141   - for x in self['correct']):
  149 + if self["discount"] and not all(0.0 <= x <= 1.0 for x in self["correct"]):
142 150 msg = f'"{self["ref"]}": correct must be in [0.0, 1.0]'
143 151 logger.error(msg)
144 152 raise QuestionException(msg)
145 153  
146 154 # at least one correct option
147   - if all(x < 1.0 for x in self['correct']):
  155 + if all(x < 1.0 for x in self["correct"]):
148 156 msg = f'"{self["ref"]}": has no correct options'
149 157 logger.error(msg)
150 158 raise QuestionException(msg)
151 159  
152 160 # If shuffle==false, all options are shown as defined
153 161 # otherwise, select 1 correct and choose a few wrong ones
154   - if self['shuffle']:
  162 + if self["shuffle"]:
155 163 # lists with indices of right and wrong options
156   - right = [i for i in range(nopts) if self['correct'][i] >= 1]
157   - wrong = [i for i in range(nopts) if self['correct'][i] < 1]
  164 + right = [i for i in range(nopts) if self["correct"][i] >= 1]
  165 + wrong = [i for i in range(nopts) if self["correct"][i] < 1]
158 166  
159   - self.set_defaults(QDict({'choose': 1+len(wrong)}))
  167 + self.set_defaults(QDict({"choose": 1 + len(wrong)}))
160 168  
161 169 # try to choose 1 correct option
162 170 if right:
163 171 sel = random.choice(right)
164   - options = [self['options'][sel]]
165   - correct = [self['correct'][sel]]
  172 + options = [self["options"][sel]]
  173 + correct = [self["correct"][sel]]
166 174 else:
167 175 options = []
168 176 correct = []
169 177  
170 178 # choose remaining wrong options
171   - nwrong = self['choose'] - len(correct)
  179 + nwrong = self["choose"] - len(correct)
172 180 wrongsample = random.sample(wrong, k=nwrong)
173   - options += [self['options'][i] for i in wrongsample]
174   - correct += [self['correct'][i] for i in wrongsample]
  181 + options += [self["options"][i] for i in wrongsample]
  182 + correct += [self["correct"][i] for i in wrongsample]
175 183  
176 184 # final shuffle of the options
177   - perm = random.sample(range(self['choose']), k=self['choose'])
178   - self['options'] = [str(options[i]) for i in perm]
179   - self['correct'] = [correct[i] for i in perm]
  185 + perm = random.sample(range(self["choose"]), k=self["choose"])
  186 + self["options"] = [str(options[i]) for i in perm]
  187 + self["correct"] = [correct[i] for i in perm]
180 188  
181 189 # ------------------------------------------------------------------------
182 190 def correct(self) -> None:
183   - '''
  191 + """
184 192 Correct `answer` and set `grade`.
185 193 Can assign negative grades for wrong answers
186   - '''
  194 + """
187 195 super().correct()
188 196  
189   - if self['answer'] is not None:
190   - grade = self['correct'][int(self['answer'])] # grade of the answer
191   - nopts = len(self['options'])
192   - grade_aver = sum(self['correct']) / nopts # expected value
  197 + if self["answer"] is not None:
  198 + grade = self["correct"][int(self["answer"])] # grade of the answer
  199 + nopts = len(self["options"])
  200 + grade_aver = sum(self["correct"]) / nopts # expected value
193 201  
194 202 # note: there are no numerical errors when summing 1.0s so the
195 203 # x_aver can be exactly 1.0 if all options are right
196   - if self['discount'] and grade_aver != 1.0:
  204 + if self["discount"] and grade_aver != 1.0:
197 205 grade = (grade - grade_aver) / (1.0 - grade_aver)
198   - self['grade'] = grade
  206 + self["grade"] = grade
199 207  
200 208  
201 209 # ============================================================================
202 210 class QuestionCheckbox(Question):
203   - '''An instance of QuestionCheckbox will always have the keys:
204   - type (str)
205   - text (str)
206   - options (list of strings)
207   - shuffle (bool, default True)
208   - correct (list of floats)
209   - discount (bool, default True)
210   - choose (int)
211   - answer (None or an actual answer)
212   - '''
  211 + """An instance of QuestionCheckbox will always have the keys:
  212 + type (str)
  213 + text (str)
  214 + options (list of strings)
  215 + shuffle (bool, default True)
  216 + correct (list of floats)
  217 + discount (bool, default True)
  218 + choose (int)
  219 + answer (None or an actual answer)
  220 + """
213 221  
214 222 # ------------------------------------------------------------------------
215 223 def gen(self) -> None:
216 224 super().gen()
217 225  
218 226 try:
219   - nopts = len(self['options'])
  227 + nopts = len(self["options"])
220 228 except KeyError as exc:
221 229 msg = f'Missing `options`. In question "{self["ref"]}"'
222 230 logger.error(msg)
... ... @@ -227,50 +235,56 @@ class QuestionCheckbox(Question):
227 235 raise QuestionException(msg) from exc
228 236  
229 237 # set defaults if missing
230   - self.set_defaults(QDict({
231   - 'text': '',
232   - 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
233   - 'shuffle': True,
234   - 'discount': True,
235   - 'choose': nopts, # number of options
236   - 'max_tries': max(1, min(nopts - 1, 3))
237   - }))
  238 + self.set_defaults(
  239 + QDict(
  240 + {
  241 + "text": "",
  242 + "correct": [1.0] * nopts, # Using 0.0 breaks (right, wrong)
  243 + "shuffle": True,
  244 + "discount": True,
  245 + "choose": nopts, # number of options
  246 + "max_tries": max(1, min(nopts - 1, 3)),
  247 + }
  248 + )
  249 + )
238 250  
239 251 # must be a list of numbers
240   - if not isinstance(self['correct'], list):
241   - msg = 'Correct must be a list of numbers or booleans'
  252 + if not isinstance(self["correct"], list):
  253 + msg = "Correct must be a list of numbers or booleans"
242 254 logger.error(msg)
243 255 raise QuestionException(msg)
244 256  
245 257 # must match number of options
246   - if len(self['correct']) != nopts:
247   - msg = (f'{nopts} options vs {len(self["correct"])} correct. '
248   - f'In question "{self["ref"]}"')
  258 + if len(self["correct"]) != nopts:
  259 + msg = (
  260 + f'{nopts} options vs {len(self["correct"])} correct. '
  261 + f'In question "{self["ref"]}"'
  262 + )
249 263 logger.error(msg)
250 264 raise QuestionException(msg)
251 265  
252 266 # make sure is a list of floats
253 267 try:
254   - self['correct'] = [float(x) for x in self['correct']]
  268 + self["correct"] = [float(x) for x in self["correct"]]
255 269 except (ValueError, TypeError) as exc:
256   - msg = ('`correct` must be list of numbers or booleans.'
257   - f'In "{self["ref"]}"')
  270 + msg = "`correct` must be list of numbers or booleans." f'In "{self["ref"]}"'
258 271 logger.error(msg)
259 272 raise QuestionException(msg) from exc
260 273  
261 274 # check grade boundaries
262   - if self['discount'] and not all(0.0 <= x <= 1.0
263   - for x in self['correct']):
264   - msg = ('values in the `correct` field of checkboxes must be in '
265   - 'the [0.0, 1.0] interval. '
266   - f'Please fix "{self["ref"]}" in "{self["path"]}"')
  275 + if self["discount"] and not all(0.0 <= x <= 1.0 for x in self["correct"]):
  276 + msg = (
  277 + "values in the `correct` field of checkboxes must be in "
  278 + "the [0.0, 1.0] interval. "
  279 + f'Please fix "{self["ref"]}" in "{self["path"]}"'
  280 + )
267 281 logger.error(msg)
268 282 raise QuestionException(msg)
269 283  
270 284 # if an option is a list of (right, wrong), pick one
271 285 options = []
272 286 correct = []
273   - for option, corr in zip(self['options'], self['correct']):
  287 + for option, corr in zip(self["options"], self["correct"]):
274 288 if isinstance(option, list):
275 289 sel = random.randint(0, 1)
276 290 option = option[sel]
... ... @@ -281,252 +295,288 @@ class QuestionCheckbox(Question):
281 295  
282 296 # generate random permutation, e.g. [2,1,4,0,3]
283 297 # and apply to `options` and `correct`
284   - if self['shuffle']:
285   - perm = random.sample(range(nopts), k=self['choose'])
286   - self['options'] = [options[i] for i in perm]
287   - self['correct'] = [correct[i] for i in perm]
  298 + if self["shuffle"]:
  299 + perm = random.sample(range(nopts), k=self["choose"])
  300 + self["options"] = [options[i] for i in perm]
  301 + self["correct"] = [correct[i] for i in perm]
288 302 else:
289   - self['options'] = options[:self['choose']]
290   - self['correct'] = correct[:self['choose']]
  303 + self["options"] = options[: self["choose"]]
  304 + self["correct"] = correct[: self["choose"]]
291 305  
292 306 # ------------------------------------------------------------------------
293 307 # can return negative values for wrong answers
294 308 def correct(self) -> None:
295 309 super().correct()
296 310  
297   - if self['answer'] is not None:
  311 + if self["answer"] is not None:
298 312 grade = 0.0
299   - if self['discount']:
300   - sum_abs = sum(abs(2*p-1) for p in self['correct'])
301   - for i, pts in enumerate(self['correct']):
302   - grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts
  313 + if self["discount"]:
  314 + sum_abs = sum(abs(2 * p - 1) for p in self["correct"])
  315 + for i, pts in enumerate(self["correct"]):
  316 + grade += 2 * pts - 1 if str(i) in self["answer"] else 1 - 2 * pts
303 317 else:
304   - sum_abs = sum(abs(p) for p in self['correct'])
305   - for i, pts in enumerate(self['correct']):
306   - grade += pts if str(i) in self['answer'] else 0.0
  318 + sum_abs = sum(abs(p) for p in self["correct"])
  319 + for i, pts in enumerate(self["correct"]):
  320 + grade += pts if str(i) in self["answer"] else 0.0
307 321  
308 322 try:
309   - self['grade'] = grade / sum_abs
  323 + self["grade"] = grade / sum_abs
310 324 except ZeroDivisionError:
311   - self['grade'] = 1.0 # limit p->0
  325 + self["grade"] = 1.0 # limit p->0
312 326  
313 327  
314 328 # ============================================================================
315 329 class QuestionText(Question):
316   - '''An instance of QuestionText will always have the keys:
317   - type (str)
318   - text (str)
319   - correct (list of str)
320   - answer (None or an actual answer)
321   - '''
  330 + """An instance of QuestionText will always have the keys:
  331 + type (str)
  332 + text (str)
  333 + correct (list of str)
  334 + answer (None or an actual answer)
  335 + """
322 336  
323 337 # ------------------------------------------------------------------------
324 338 def gen(self) -> None:
325 339 super().gen()
326   - self.set_defaults(QDict({
327   - 'text': '',
328   - 'correct': [], # no correct answers, always wrong
329   - 'transform': [], # transformations applied to the answer, in order
330   - }))
  340 + self.set_defaults(
  341 + QDict(
  342 + {
  343 + "text": "",
  344 + "correct": [], # no correct answers, always wrong
  345 + "transform": [], # transformations applied to the answer, in order
  346 + }
  347 + )
  348 + )
331 349  
332 350 # make sure its always a list of possible correct answers
333   - if not isinstance(self['correct'], list):
334   - self['correct'] = [str(self['correct'])]
  351 + if not isinstance(self["correct"], list):
  352 + self["correct"] = [str(self["correct"])]
335 353 else:
336 354 # make sure all elements of the list are strings
337   - self['correct'] = [str(a) for a in self['correct']]
338   -
339   - for transform in self['transform']:
340   - if transform not in ('remove_space', 'trim', 'normalize_space',
341   - 'lower', 'upper'):
342   - msg = (f'Unknown transform "{transform}" in "{self["ref"]}"')
  355 + self["correct"] = [str(a) for a in self["correct"]]
  356 +
  357 + for transform in self["transform"]:
  358 + if transform not in (
  359 + "remove_space",
  360 + "trim",
  361 + "normalize_space",
  362 + "lower",
  363 + "upper",
  364 + ):
  365 + msg = f'Unknown transform "{transform}" in "{self["ref"]}"'
343 366 raise QuestionException(msg)
344 367  
345 368 # check if answers are invariant with respect to the transforms
346   - if any(c != self.transform(c) for c in self['correct']):
347   - logger.warning('in "%s", correct answers are not invariant wrt '
348   - 'transformations => never correct', self["ref"])
  369 + if any(c != self.transform(c) for c in self["correct"]):
  370 + logger.warning(
  371 + 'in "%s", correct answers are not invariant wrt '
  372 + "transformations => never correct",
  373 + self["ref"],
  374 + )
349 375  
350 376 # ------------------------------------------------------------------------
351 377 def transform(self, ans):
352   - '''apply optional filters to the answer'''
  378 + """apply optional filters to the answer"""
353 379  
354 380 # apply transformations in sequence
355   - for transform in self['transform']:
356   - if transform == 'remove_space': # removes all spaces
357   - ans = ans.replace(' ', '')
358   - elif transform == 'trim': # removes spaces around
  381 + for transform in self["transform"]:
  382 + if transform == "remove_space": # removes all spaces
  383 + ans = ans.replace(" ", "")
  384 + elif transform == "trim": # removes spaces around
359 385 ans = ans.strip()
360   - elif transform == 'normalize_space': # replaces many spaces by one
361   - ans = re.sub(r'\s+', ' ', ans.strip())
362   - elif transform == 'lower': # convert to lowercase
  386 + elif transform == "normalize_space": # replaces many spaces by one
  387 + ans = re.sub(r"\s+", " ", ans.strip())
  388 + elif transform == "lower": # convert to lowercase
363 389 ans = ans.lower()
364   - elif transform == 'upper': # convert to uppercase
  390 + elif transform == "upper": # convert to uppercase
365 391 ans = ans.upper()
366 392 else:
367   - logger.warning('in "%s", unknown transform "%s"',
368   - self["ref"], transform)
  393 + logger.warning(
  394 + 'in "%s", unknown transform "%s"', self["ref"], transform
  395 + )
369 396 return ans
370 397  
371 398 # ------------------------------------------------------------------------
372 399 def correct(self) -> None:
373 400 super().correct()
374 401  
375   - if self['answer'] is not None:
376   - answer = self.transform(self['answer'])
377   - self['grade'] = 1.0 if answer in self['correct'] else 0.0
  402 + if self["answer"] is not None:
  403 + answer = self.transform(self["answer"])
  404 + self["grade"] = 1.0 if answer in self["correct"] else 0.0
378 405  
379 406  
380 407 # ============================================================================
381 408 class QuestionTextRegex(Question):
382   - '''An instance of QuestionTextRegex will always have the keys:
383   - type (str)
384   - text (str)
385   - correct (str or list[str])
386   - answer (None or an actual answer)
  409 + """An instance of QuestionTextRegex will always have the keys:
  410 + type (str)
  411 + text (str)
  412 + correct (str or list[str])
  413 + answer (None or an actual answer)
387 414  
388   - The correct strings are python standard regular expressions.
389   - Grade is 1.0 when the answer matches any of the regex in the list.
390   - '''
  415 + The correct strings are python standard regular expressions.
  416 + Grade is 1.0 when the answer matches any of the regex in the list.
  417 + """
391 418  
392 419 # ------------------------------------------------------------------------
393 420 def gen(self) -> None:
394 421 super().gen()
395 422  
396   - self.set_defaults(QDict({
397   - 'text': '',
398   - 'correct': ['$.^'], # will always return false
399   - }))
  423 + self.set_defaults(
  424 + QDict(
  425 + {
  426 + "text": "",
  427 + "correct": ["$.^"], # will always return false
  428 + }
  429 + )
  430 + )
400 431  
401 432 # make sure its always a list of regular expressions
402   - if not isinstance(self['correct'], list):
403   - self['correct'] = [self['correct']]
  433 + if not isinstance(self["correct"], list):
  434 + self["correct"] = [self["correct"]]
404 435  
405 436 # ------------------------------------------------------------------------
406 437 def correct(self) -> None:
407 438 super().correct()
408   - if self['answer'] is not None:
409   - for regex in self['correct']:
  439 + if self["answer"] is not None:
  440 + for regex in self["correct"]:
410 441 try:
411   - if re.fullmatch(regex, self['answer']):
412   - self['grade'] = 1.0
  442 + if re.fullmatch(regex, self["answer"]):
  443 + self["grade"] = 1.0
413 444 return
414 445 except TypeError:
415   - logger.error('While matching regex "%s" with answer "%s".',
416   - regex, self['answer'])
417   - self['grade'] = 0.0
  446 + logger.error(
  447 + 'While matching regex "%s" with answer "%s".',
  448 + regex,
  449 + self["answer"],
  450 + )
  451 + self["grade"] = 0.0
  452 +
418 453  
419 454 # ============================================================================
420 455 class QuestionNumericInterval(Question):
421   - '''An instance of QuestionTextNumeric will always have the keys:
  456 + """An instance of QuestionTextNumeric will always have the keys:
422 457 type (str)
423 458 text (str)
424 459 correct (list [lower bound, upper bound])
425 460 answer (None or an actual answer)
426 461 An answer is correct if it's in the closed interval.
427   - '''
  462 + """
428 463  
429 464 # ------------------------------------------------------------------------
430 465 def gen(self) -> None:
431 466 super().gen()
432 467  
433   - self.set_defaults(QDict({
434   - 'text': '',
435   - 'correct': [1.0, -1.0], # will always return false
436   - }))
  468 + self.set_defaults(
  469 + QDict(
  470 + {
  471 + "text": "",
  472 + "correct": [1.0, -1.0], # will always return false
  473 + }
  474 + )
  475 + )
437 476  
438 477 # if only one number n is given, make an interval [n,n]
439   - if isinstance(self['correct'], (int, float)):
440   - self['correct'] = [float(self['correct']), float(self['correct'])]
  478 + if isinstance(self["correct"], (int, float)):
  479 + self["correct"] = [float(self["correct"]), float(self["correct"])]
441 480  
442 481 # make sure its a list of two numbers
443   - elif isinstance(self['correct'], list):
444   - if len(self['correct']) != 2:
445   - msg = (f'Numeric interval must be a list with two numbers, in '
446   - f'{self["ref"]}')
  482 + elif isinstance(self["correct"], list):
  483 + if len(self["correct"]) != 2:
  484 + msg = (
  485 + f"Numeric interval must be a list with two numbers, in "
  486 + f'{self["ref"]}'
  487 + )
447 488 logger.error(msg)
448 489 raise QuestionException(msg)
449 490  
450 491 try:
451   - self['correct'] = [float(n) for n in self['correct']]
  492 + self["correct"] = [float(n) for n in self["correct"]]
452 493 except Exception as exc:
453   - msg = (f'Numeric interval must be a list with two numbers, in '
454   - f'{self["ref"]}')
  494 + msg = (
  495 + f"Numeric interval must be a list with two numbers, in "
  496 + f'{self["ref"]}'
  497 + )
455 498 logger.error(msg)
456 499 raise QuestionException(msg) from exc
457 500  
458 501 # invalid
459 502 else:
460   - msg = (f'Numeric interval must be a list with two numbers, in '
461   - f'{self["ref"]}')
  503 + msg = (
  504 + f"Numeric interval must be a list with two numbers, in "
  505 + f'{self["ref"]}'
  506 + )
462 507 logger.error(msg)
463 508 raise QuestionException(msg)
464 509  
465 510 # ------------------------------------------------------------------------
466 511 def correct(self) -> None:
467 512 super().correct()
468   - if self['answer'] is not None:
469   - lower, upper = self['correct']
  513 + if self["answer"] is not None:
  514 + lower, upper = self["correct"]
470 515  
471   - try: # replace , by . and convert to float
472   - answer = float(self['answer'].replace(',', '.', 1))
  516 + try: # replace , by . and convert to float
  517 + answer = float(self["answer"].replace(",", ".", 1))
473 518 except ValueError:
474   - self['comments'] = ('A resposta tem de ser numérica, '
475   - 'por exemplo `12.345`.')
476   - self['grade'] = 0.0
  519 + self["comments"] = (
  520 + "A resposta tem de ser numérica, " "por exemplo `12.345`."
  521 + )
  522 + self["grade"] = 0.0
477 523 else:
478   - self['grade'] = 1.0 if lower <= answer <= upper else 0.0
  524 + self["grade"] = 1.0 if lower <= answer <= upper else 0.0
479 525  
480 526  
481 527 # ============================================================================
482 528 class QuestionTextArea(Question):
483   - '''An instance of QuestionTextArea will always have the keys:
484   - type (str)
485   - text (str)
486   - correct (str with script to run)
487   - answer (None or an actual answer)
488   - '''
  529 + """An instance of QuestionTextArea will always have the keys:
  530 + type (str)
  531 + text (str)
  532 + correct (str with script to run)
  533 + answer (None or an actual answer)
  534 + """
489 535  
490 536 # ------------------------------------------------------------------------
491 537 def gen(self) -> None:
492 538 super().gen()
493 539  
494   - self.set_defaults(QDict({
495   - 'text': '',
496   - 'timeout': 5, # seconds
497   - 'correct': '', # trying to execute this will fail => grade 0.0
498   - 'args': []
499   - }))
  540 + self.set_defaults(
  541 + QDict(
  542 + {
  543 + "text": "",
  544 + "timeout": 5, # seconds
  545 + "correct": "", # trying to execute this will fail => grade 0.0
  546 + "args": [],
  547 + }
  548 + )
  549 + )
500 550  
501   - self['correct'] = os.path.join(self['path'], self['correct'])
  551 + self["correct"] = os.path.join(self["path"], self["correct"])
502 552  
503 553 # ------------------------------------------------------------------------
504 554 def correct(self) -> None:
505 555 super().correct()
506 556  
507   - if self['answer'] is not None: # correct answer and parse yaml ouput
  557 + if self["answer"] is not None: # correct answer and parse yaml ouput
508 558 out = run_script(
509   - script=self['correct'],
510   - args=self['args'],
511   - stdin=self['answer'],
512   - timeout=self['timeout']
513   - )
  559 + script=self["correct"],
  560 + args=self["args"],
  561 + stdin=self["answer"],
  562 + timeout=self["timeout"],
  563 + )
514 564  
515 565 if out is None:
516 566 logger.warning('No grade after running "%s".', self["correct"])
517   - self['comments'] = 'O programa de correcção abortou...'
518   - self['grade'] = 0.0
  567 + self["comments"] = "O programa de correcção abortou..."
  568 + self["grade"] = 0.0
519 569 elif isinstance(out, dict):
520   - self['comments'] = out.get('comments', '')
  570 + self["comments"] = out.get("comments", "")
521 571 try:
522   - self['grade'] = float(out['grade'])
  572 + self["grade"] = float(out["grade"])
523 573 except ValueError:
524 574 logger.error('Output error in "%s".', self["correct"])
525 575 except KeyError:
526 576 logger.error('No grade in "%s".', self["correct"])
527 577 else:
528 578 try:
529   - self['grade'] = float(out)
  579 + self["grade"] = float(out)
530 580 except (TypeError, ValueError):
531 581 logger.error('Invalid grade in "%s".', self["correct"])
532 582  
... ... @@ -534,92 +584,96 @@ class QuestionTextArea(Question):
534 584 async def correct_async(self) -> None:
535 585 super().correct()
536 586  
537   - if self['answer'] is not None: # correct answer and parse yaml ouput
  587 + if self["answer"] is not None: # correct answer and parse yaml ouput
538 588 out = await run_script_async(
539   - script=self['correct'],
540   - args=self['args'],
541   - stdin=self['answer'],
542   - timeout=self['timeout']
543   - )
  589 + script=self["correct"],
  590 + args=self["args"],
  591 + stdin=self["answer"],
  592 + timeout=self["timeout"],
  593 + )
544 594  
545 595 if out is None:
546 596 logger.warning('No grade after running "%s".', self["correct"])
547   - self['comments'] = 'O programa de correcção abortou...'
548   - self['grade'] = 0.0
  597 + self["comments"] = "O programa de correcção abortou..."
  598 + self["grade"] = 0.0
549 599 elif isinstance(out, dict):
550   - self['comments'] = out.get('comments', '')
  600 + self["comments"] = out.get("comments", "")
551 601 try:
552   - self['grade'] = float(out['grade'])
  602 + self["grade"] = float(out["grade"])
553 603 except ValueError:
554 604 logger.error('Output error in "%s".', self["correct"])
555 605 except KeyError:
556 606 logger.error('No grade in "%s".', self["correct"])
557 607 else:
558 608 try:
559   - self['grade'] = float(out)
  609 + self["grade"] = float(out)
560 610 except (TypeError, ValueError):
561 611 logger.error('Invalid grade in "%s".', self["correct"])
562 612  
563 613  
564 614 # ============================================================================
565 615 class QuestionInformation(Question):
566   - '''
  616 + """
567 617 Not really a question, just an information panel.
568 618 The correction is always right.
569   - '''
  619 + """
570 620  
571 621 # ------------------------------------------------------------------------
572 622 def gen(self) -> None:
573 623 super().gen()
574   - self.set_defaults(QDict({
575   - 'text': '',
576   - }))
  624 + self.set_defaults(
  625 + QDict(
  626 + {
  627 + "text": "",
  628 + }
  629 + )
  630 + )
577 631  
578 632 # ------------------------------------------------------------------------
579 633 def correct(self) -> None:
580 634 super().correct()
581   - self['grade'] = 1.0 # always "correct" but points should be zero!
  635 + self["grade"] = 1.0 # always "correct" but points should be zero!
582 636  
583 637  
584 638 # ============================================================================
585 639 def question_from(qdict: QDict) -> Question:
586   - '''
  640 + """
587 641 Converts a question specified in a dict into an instance of Question()
588   - '''
  642 + """
589 643 types = {
590   - 'radio': QuestionRadio,
591   - 'checkbox': QuestionCheckbox,
592   - 'text': QuestionText,
593   - 'text-regex': QuestionTextRegex,
594   - 'numeric-interval': QuestionNumericInterval,
595   - 'textarea': QuestionTextArea,
  644 + "radio": QuestionRadio,
  645 + "checkbox": QuestionCheckbox,
  646 + "text": QuestionText,
  647 + "text-regex": QuestionTextRegex,
  648 + "numeric-interval": QuestionNumericInterval,
  649 + "textarea": QuestionTextArea,
596 650 # -- informative panels --
597   - 'information': QuestionInformation,
598   - 'success': QuestionInformation,
599   - 'warning': QuestionInformation,
600   - 'alert': QuestionInformation,
601   - }
  651 + "information": QuestionInformation,
  652 + "success": QuestionInformation,
  653 + "warning": QuestionInformation,
  654 + "alert": QuestionInformation,
  655 + }
602 656  
603 657 # Get class for this question type
604 658 try:
605   - qclass = types[qdict['type']]
  659 + qclass = types[qdict["type"]]
606 660 except KeyError:
607   - logger.error('Invalid type "%s" in "%s"', qdict['type'], qdict['ref'])
  661 + logger.error('Invalid type "%s" in "%s"', qdict["type"], qdict["ref"])
608 662 raise
609 663  
610 664 # Create an instance of Question() of appropriate type
611 665 try:
612 666 qinstance = qclass(qdict.copy())
613 667 except QuestionException:
614   - logger.error('Generating "%s" in %s', qdict['ref'], qdict['filename'])
  668 + logger.error('Generating "%s" in %s', qdict["ref"], qdict["filename"])
615 669 raise
616 670  
617 671 return qinstance
618 672  
619 673  
620 674 # ============================================================================
621   -class QFactory():
622   - '''
  675 +class QFactory:
  676 + """
623 677 QFactory is a class that can generate question instances, e.g. by shuffling
624 678 options, running a script to generate the question, etc.
625 679  
... ... @@ -644,35 +698,35 @@ class QFactory():
644 698 question.set_answer(42) # set answer
645 699 question.correct() # correct answer
646 700 grade = question['grade'] # get grade
647   - '''
  701 + """
648 702  
649 703 def __init__(self, qdict: QDict = QDict({})) -> None:
650 704 self.qdict = qdict
651 705  
652 706 # ------------------------------------------------------------------------
653 707 async def gen_async(self) -> Question:
654   - '''
  708 + """
655 709 generates a question instance of QuestionRadio, QuestionCheckbox, ...,
656 710 which is a descendent of base class Question.
657   - '''
  711 + """
658 712  
659   - logger.debug('generating %s...', self.qdict["ref"])
  713 + logger.debug("generating %s...", self.qdict["ref"])
660 714 # Shallow copy so that script generated questions will not replace
661 715 # the original generators
662 716 qdict = QDict(self.qdict.copy())
663   - qdict['qid'] = str(uuid.uuid4()) # unique for each question
  717 + qdict["qid"] = str(uuid.uuid4()) # unique for each question
664 718  
665 719 # If question is of generator type, an external program will be run
666 720 # which will print a valid question in yaml format to stdout. This
667 721 # output is then yaml parsed into a dictionary `q`.
668   - if qdict['type'] == 'generator':
669   - logger.debug(' \\_ Running "%s"', qdict['script'])
670   - qdict.setdefault('args', [])
671   - qdict.setdefault('stdin', '')
672   - script = os.path.join(qdict['path'], qdict['script'])
673   - out = await run_script_async(script=script,
674   - args=qdict['args'],
675   - stdin=qdict['stdin'])
  722 + if qdict["type"] == "generator":
  723 + logger.debug(' \\_ Running "%s"', qdict["script"])
  724 + qdict.setdefault("args", [])
  725 + qdict.setdefault("stdin", "")
  726 + script = os.path.join(qdict["path"], qdict["script"])
  727 + out = await run_script_async(
  728 + script=script, args=qdict["args"], stdin=qdict["stdin"]
  729 + )
676 730 qdict.update(out)
677 731  
678 732 question = question_from(qdict) # returns a Question instance
... ...
perguntations/serve.py
1 1 #!/usr/bin/env python3
2 2  
3   -'''
  3 +"""
4 4 Handles the web, http & html part of the application interface.
5 5 Uses the tornadoweb framework.
6   -'''
  6 +"""
7 7  
8 8 # python standard library
9 9 import asyncio
... ... @@ -35,29 +35,30 @@ logger = logging.getLogger(__name__)
35 35  
36 36 # ----------------------------------------------------------------------------
37 37 class WebApplication(tornado.web.Application):
38   - '''
  38 + """
39 39 Web Application. Routes to handler classes.
40   - '''
  40 + """
  41 +
41 42 def __init__(self, testapp, debug=False):
42 43 handlers = [
43   - (r'/login', LoginHandler),
44   - (r'/logout', LogoutHandler),
45   - (r'/review', ReviewHandler),
46   - (r'/admin', AdminHandler),
47   - (r'/file', FileHandler),
48   - (r'/adminwebservice', AdminWebservice),
49   - (r'/studentwebservice', StudentWebservice),
50   - (r'/', RootHandler),
  44 + (r"/login", LoginHandler),
  45 + (r"/logout", LogoutHandler),
  46 + (r"/review", ReviewHandler),
  47 + (r"/admin", AdminHandler),
  48 + (r"/file", FileHandler),
  49 + (r"/adminwebservice", AdminWebservice),
  50 + (r"/studentwebservice", StudentWebservice),
  51 + (r"/", RootHandler),
51 52 ]
52 53  
53 54 settings = {
54   - 'template_path': path.join(path.dirname(__file__), 'templates'),
55   - 'static_path': path.join(path.dirname(__file__), 'static'),
56   - 'static_url_prefix': '/static/',
57   - 'xsrf_cookies': True,
58   - 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
59   - 'login_url': '/login',
60   - 'debug': debug,
  55 + "template_path": path.join(path.dirname(__file__), "templates"),
  56 + "static_path": path.join(path.dirname(__file__), "static"),
  57 + "static_url_prefix": "/static/",
  58 + "xsrf_cookies": True,
  59 + "cookie_secret": base64.b64encode(uuid.uuid4().bytes),
  60 + "login_url": "/login",
  61 + "debug": debug,
61 62 }
62 63 super().__init__(handlers, **settings)
63 64 self.testapp = testapp
... ... @@ -65,32 +66,34 @@ class WebApplication(tornado.web.Application):
65 66  
66 67 # ----------------------------------------------------------------------------
67 68 def admin_only(func):
68   - '''
  69 + """
69 70 Decorator to restrict access to the administrator:
70 71  
71 72 @admin_only
72 73 def get(self):
73   - '''
  74 + """
  75 +
74 76 @functools.wraps(func)
75 77 async def wrapper(self, *args, **kwargs):
76   - if self.current_user != '0':
  78 + if self.current_user != "0":
77 79 raise tornado.web.HTTPError(403) # forbidden
78 80 await func(self, *args, **kwargs)
  81 +
79 82 return wrapper
80 83  
81 84  
82 85 # ----------------------------------------------------------------------------
83 86 # pylint: disable=abstract-method
84 87 class BaseHandler(tornado.web.RequestHandler):
85   - '''
  88 + """
86 89 Handlers should inherit this one instead of tornado.web.RequestHandler.
87 90 It automatically gets the user cookie, which is required to identify the
88 91 user in most handlers.
89   - '''
  92 + """
90 93  
91 94 @property
92 95 def testapp(self):
93   - '''simplifies access to the application a little bit'''
  96 + """simplifies access to the application a little bit"""
94 97 return self.application.testapp
95 98  
96 99 # @property
... ... @@ -99,62 +102,62 @@ class BaseHandler(tornado.web.RequestHandler):
99 102 # return self.application.testapp.debug
100 103  
101 104 def get_current_user(self):
102   - '''
  105 + """
103 106 Since HTTP is stateless, a cookie is used to identify the user.
104 107 This function returns the cookie for the current user.
105   - '''
106   - cookie = self.get_secure_cookie('perguntations_user')
  108 + """
  109 + cookie = self.get_secure_cookie("perguntations_user")
107 110 if cookie:
108   - return cookie.decode('utf-8')
  111 + return cookie.decode("utf-8")
109 112 return None
110 113  
111 114  
112 115 # ----------------------------------------------------------------------------
113 116 # pylint: disable=abstract-method
114 117 class LoginHandler(BaseHandler):
115   - '''Handles /login'''
  118 + """Handles /login"""
116 119  
117   - _prefix = re.compile(r'[a-z]')
  120 + _prefix = re.compile(r"[a-z]")
118 121 _error_msg = {
119   - 'wrong_password': 'Senha errada',
120   - 'not_allowed': 'Não está autorizado a fazer o teste',
121   - 'nonexistent': 'Número de aluno inválido'
  122 + "wrong_password": "Senha errada",
  123 + "not_allowed": "Não está autorizado a fazer o teste",
  124 + "nonexistent": "Número de aluno inválido",
122 125 }
123 126  
124 127 def get(self):
125   - '''Render login page.'''
126   - self.render('login.html', error='')
  128 + """Render login page."""
  129 + self.render("login.html", error="")
127 130  
128 131 async def post(self):
129   - '''Authenticates student and login.'''
130   - uid = self.get_body_argument('uid')
131   - password = self.get_body_argument('pw')
  132 + """Authenticates student and login."""
  133 + uid = self.get_body_argument("uid")
  134 + password = self.get_body_argument("pw")
132 135 headers = {
133   - 'remote_ip': self.request.remote_ip,
134   - 'user_agent': self.request.headers.get('User-Agent')
  136 + "remote_ip": self.request.remote_ip,
  137 + "user_agent": self.request.headers.get("User-Agent"),
135 138 }
136 139  
137 140 error = await self.testapp.login(uid, password, headers)
138 141  
139 142 if error is not None:
140 143 await asyncio.sleep(3) # delay to avoid spamming the server...
141   - self.render('login.html', error=self._error_msg[error])
  144 + self.render("login.html", error=self._error_msg[error])
142 145 else:
143   - self.set_secure_cookie('perguntations_user', str(uid))
144   - self.redirect('/')
  146 + self.set_secure_cookie("perguntations_user", str(uid))
  147 + self.redirect("/")
145 148  
146 149  
147 150 # ----------------------------------------------------------------------------
148 151 # pylint: disable=abstract-method
149 152 class LogoutHandler(BaseHandler):
150   - '''Handle /logout'''
  153 + """Handle /logout"""
151 154  
152 155 @tornado.web.authenticated
153 156 def get(self):
154   - '''Logs out a user.'''
  157 + """Logs out a user."""
155 158 self.testapp.logout(self.current_user)
156   - self.clear_cookie('perguntations_user')
157   - self.render('login.html', error='')
  159 + self.clear_cookie("perguntations_user")
  160 + self.render("login.html", error="")
158 161  
159 162  
160 163 # ----------------------------------------------------------------------------
... ... @@ -162,57 +165,64 @@ class LogoutHandler(BaseHandler):
162 165 # ----------------------------------------------------------------------------
163 166 # pylint: disable=abstract-method
164 167 class RootHandler(BaseHandler):
165   - '''
  168 + """
166 169 Presents test to student.
167 170 Receives answers, corrects the test and sends back the grade.
168 171 Redirects user 0 to /admin.
169   - '''
  172 + """
170 173  
171 174 _templates = {
172 175 # -- question templates --
173   - 'radio': 'question-radio.html',
174   - 'checkbox': 'question-checkbox.html',
175   - 'text': 'question-text.html',
176   - 'text-regex': 'question-text.html',
177   - 'numeric-interval': 'question-text.html',
178   - 'textarea': 'question-textarea.html',
  176 + "radio": "question-radio.html",
  177 + "checkbox": "question-checkbox.html",
  178 + "text": "question-text.html",
  179 + "text-regex": "question-text.html",
  180 + "numeric-interval": "question-text.html",
  181 + "textarea": "question-textarea.html",
179 182 # -- information panels --
180   - 'information': 'question-information.html',
181   - 'success': 'question-information.html',
182   - 'warning': 'question-information.html',
183   - 'alert': 'question-information.html',
  183 + "information": "question-information.html",
  184 + "success": "question-information.html",
  185 + "warning": "question-information.html",
  186 + "alert": "question-information.html",
184 187 }
185 188  
186 189 # --- GET
187 190 @tornado.web.authenticated
188 191 async def get(self):
189   - '''
  192 + """
190 193 Handles GET /
191 194 Sends test to student or redirects 0 to admin page.
192 195 Multiple calls to this function will return the same test.
193   - '''
  196 + """
194 197 uid = self.current_user
195 198 logger.debug('"%s" GET /', uid)
196 199  
197   - if uid == '0':
198   - self.redirect('/admin')
  200 + if uid == "0":
  201 + self.redirect("/admin")
199 202 else:
200 203 test = self.testapp.get_test(uid)
201 204 name = self.testapp.get_name(uid)
202   - self.render('test.html', t=test, uid=uid, name=name, md=md_to_html,
203   - templ=self._templates, debug=self.testapp.debug)
  205 + self.render(
  206 + "test.html",
  207 + t=test,
  208 + uid=uid,
  209 + name=name,
  210 + md=md_to_html,
  211 + templ=self._templates,
  212 + debug=self.testapp.debug,
  213 + )
204 214  
205 215 # --- POST
206 216 @tornado.web.authenticated
207 217 async def post(self):
208   - '''
  218 + """
209 219 Receives answers, fixes some html weirdness, corrects test and
210 220 renders the grade.
211 221  
212 222 self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']}
213 223 builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...}
214 224 unanswered questions are not included.
215   - '''
  225 + """
216 226 starttime = timer() # performance timer
217 227  
218 228 uid = self.current_user
... ... @@ -224,42 +234,47 @@ class RootHandler(BaseHandler):
224 234 raise tornado.web.HTTPError(403) # Forbidden
225 235  
226 236 ans = {}
227   - for i, question in enumerate(test['questions']):
  237 + for i, question in enumerate(test["questions"]):
228 238 qid = str(i)
229   - if f'answered-{qid}' in self.request.arguments:
  239 + if f"answered-{qid}" in self.request.arguments:
230 240 ans[i] = self.get_body_arguments(qid)
231 241  
232 242 # remove enclosing list in some question types
233   - if question['type'] == 'radio':
  243 + if question["type"] == "radio":
234 244 ans[i] = ans[i][0] if ans[i] else None
235   - elif question['type'] in ('text', 'text-regex', 'textarea',
236   - 'numeric-interval'):
  245 + elif question["type"] in (
  246 + "text",
  247 + "text-regex",
  248 + "textarea",
  249 + "numeric-interval",
  250 + ):
237 251 ans[i] = ans[i][0]
238 252  
239 253 # submit answered questions, correct
240 254 await self.testapp.submit_test(uid, ans)
241 255  
242 256 name = self.testapp.get_name(uid)
243   - self.render('grade.html', t=test, uid=uid, name=name)
244   - self.clear_cookie('perguntations_user')
  257 + self.render("grade.html", t=test, uid=uid, name=name)
  258 + self.clear_cookie("perguntations_user")
245 259 self.testapp.logout(uid)
246   - logger.info(' elapsed time: %fs', timer() - starttime)
  260 + logger.info(" elapsed time: %fs", timer() - starttime)
247 261  
248 262  
249 263 # ----------------------------------------------------------------------------
250 264 # pylint: disable=abstract-method
  265 +# FIXME also to update answers
251 266 class StudentWebservice(BaseHandler):
252   - '''
  267 + """
253 268 Receive ajax from students during the test in response to the events
254   - focus, unfocus and resize.
255   - '''
  269 + focus, unfocus and resize, etc.
  270 + """
256 271  
257 272 @tornado.web.authenticated
258 273 def post(self):
259   - '''handle ajax post'''
  274 + """handle ajax post"""
260 275 uid = self.current_user
261   - cmd = self.get_body_argument('cmd', None)
262   - value = self.get_body_argument('value', None)
  276 + cmd = self.get_body_argument("cmd", None)
  277 + value = self.get_body_argument("value", None)
263 278 if cmd is not None and value is not None:
264 279 self.testapp.register_event(uid, cmd, json.loads(value))
265 280  
... ... @@ -267,29 +282,31 @@ class StudentWebservice(BaseHandler):
267 282 # ----------------------------------------------------------------------------
268 283 # pylint: disable=abstract-method
269 284 class AdminWebservice(BaseHandler):
270   - '''
  285 + """
271 286 Receive ajax requests from admin
272   - '''
  287 + """
273 288  
274 289 @tornado.web.authenticated
275 290 @admin_only
276 291 async def get(self):
277   - '''admin webservices that do not change state'''
278   - cmd = self.get_query_argument('cmd')
279   - logger.debug('GET /adminwebservice %s', cmd)
  292 + """admin webservices that do not change state"""
  293 + cmd = self.get_query_argument("cmd")
  294 + logger.debug("GET /adminwebservice %s", cmd)
280 295  
281   - if cmd == 'testcsv':
  296 + if cmd == "testcsv":
282 297 test_ref, data = self.testapp.get_grades_csv()
283   - self.set_header('Content-Type', 'text/csv')
284   - self.set_header('content-Disposition',
285   - f'attachment; filename={test_ref}.csv')
  298 + self.set_header("Content-Type", "text/csv")
  299 + self.set_header(
  300 + "content-Disposition", f"attachment; filename={test_ref}.csv"
  301 + )
286 302 self.write(data)
287 303 await self.flush()
288   - elif cmd == 'questionscsv':
  304 + elif cmd == "questionscsv":
289 305 test_ref, data = self.testapp.get_detailed_grades_csv()
290   - self.set_header('Content-Type', 'text/csv')
291   - self.set_header('content-Disposition',
292   - f'attachment; filename={test_ref}-detailed.csv')
  306 + self.set_header("Content-Type", "text/csv")
  307 + self.set_header(
  308 + "content-Disposition", f"attachment; filename={test_ref}-detailed.csv"
  309 + )
293 310 self.write(data)
294 311 await self.flush()
295 312  
... ... @@ -297,52 +314,53 @@ class AdminWebservice(BaseHandler):
297 314 # ----------------------------------------------------------------------------
298 315 # pylint: disable=abstract-method
299 316 class AdminHandler(BaseHandler):
300   - '''Handle /admin'''
  317 + """Handle /admin"""
301 318  
302 319 # --- GET
303 320 @tornado.web.authenticated
304 321 @admin_only
305 322 async def get(self):
306   - '''
  323 + """
307 324 Admin page.
308   - '''
309   - cmd = self.get_query_argument('cmd', default=None)
310   - logger.debug('GET /admin (cmd=%s)', cmd)
  325 + """
  326 + cmd = self.get_query_argument("cmd", default=None)
  327 + logger.debug("GET /admin (cmd=%s)", cmd)
311 328  
312 329 if cmd is None:
313   - self.render('admin.html')
314   - elif cmd == 'test':
315   - data = { 'data': self.testapp.get_test_config() }
  330 + self.render("admin.html")
  331 + elif cmd == "test":
  332 + data = {"data": self.testapp.get_test_config()}
316 333 self.write(json.dumps(data, default=str))
317   - elif cmd == 'students_table':
318   - data = {'data': self.testapp.get_students_state()}
  334 + elif cmd == "students_table":
  335 + data = {"data": self.testapp.get_students_state()}
319 336 self.write(json.dumps(data, default=str))
320 337  
321 338 # --- POST
322 339 @tornado.web.authenticated
323 340 @admin_only
324 341 async def post(self):
325   - '''
  342 + """
326 343 Executes commands from the admin page.
327   - '''
328   - cmd = self.get_body_argument('cmd', None)
329   - value = self.get_body_argument('value', None)
330   - logger.debug('POST /admin (cmd=%s, value=%s)', cmd, value)
  344 + """
  345 + cmd = self.get_body_argument("cmd", None)
  346 + value = self.get_body_argument("value", None)
  347 + logger.debug("POST /admin (cmd=%s, value=%s)", cmd, value)
331 348  
332   - if cmd == 'allow':
  349 + if cmd == "allow":
333 350 self.testapp.allow_student(value)
334   - elif cmd == 'deny':
  351 + elif cmd == "deny":
335 352 self.testapp.deny_student(value)
336   - elif cmd == 'allow_all':
  353 + elif cmd == "allow_all":
337 354 self.testapp.allow_all_students()
338   - elif cmd == 'deny_all':
  355 + elif cmd == "deny_all":
339 356 self.testapp.deny_all_students()
340   - elif cmd == 'reset_password':
341   - await self.testapp.set_password(uid=value, password='')
342   - elif cmd == 'insert_student' and value is not None:
  357 + elif cmd == "reset_password":
  358 + await self.testapp.set_password(uid=value, password="")
  359 + elif cmd == "insert_student" and value is not None:
343 360 student = json.loads(value)
344   - await self.testapp.insert_new_student(uid=student['number'],
345   - name=student['name'])
  361 + await self.testapp.insert_new_student(
  362 + uid=student["number"], name=student["name"]
  363 + )
346 364  
347 365  
348 366 # ----------------------------------------------------------------------------
... ... @@ -350,22 +368,22 @@ class AdminHandler(BaseHandler):
350 368 # ----------------------------------------------------------------------------
351 369 # pylint: disable=abstract-method
352 370 class FileHandler(BaseHandler):
353   - '''
  371 + """
354 372 Handles static files from questions like images, etc.
355   - '''
  373 + """
356 374  
357 375 _filecache: Dict[Tuple[str, str], bytes] = {}
358 376  
359 377 @tornado.web.authenticated
360 378 async def get(self):
361   - '''
  379 + """
362 380 Returns requested file. Files are obtained from the 'public' directory
363 381 of each question.
364   - '''
  382 + """
365 383 uid = self.current_user
366   - ref = self.get_query_argument('ref', None)
367   - image = self.get_query_argument('image', None)
368   - logger.debug('GET /file (ref=%s, image=%s)', ref, image)
  384 + ref = self.get_query_argument("ref", None)
  385 + image = self.get_query_argument("image", None)
  386 + logger.debug("GET /file (ref=%s, image=%s)", ref, image)
369 387  
370 388 if ref is None or image is None:
371 389 return
... ... @@ -373,7 +391,7 @@ class FileHandler(BaseHandler):
373 391 content_type = mimetypes.guess_type(image)[0]
374 392  
375 393 if (ref, image) in self._filecache:
376   - logger.debug('using cached file')
  394 + logger.debug("using cached file")
377 395 self.write(self._filecache[(ref, image)])
378 396 if content_type is not None:
379 397 self.set_header("Content-Type", content_type)
... ... @@ -383,16 +401,16 @@ class FileHandler(BaseHandler):
383 401 try:
384 402 test = self.testapp.get_test(uid)
385 403 except KeyError:
386   - logger.warning('Could not get test to serve image file')
  404 + logger.warning("Could not get test to serve image file")
387 405 raise tornado.web.HTTPError(404) from None # Not Found
388 406  
389 407 # search for the question that contains the image
390   - for question in test['questions']:
391   - if question['ref'] == ref:
392   - filepath = path.join(question['path'], 'public', image)
  408 + for question in test["questions"]:
  409 + if question["ref"] == ref:
  410 + filepath = path.join(question["path"], "public", image)
393 411  
394 412 try:
395   - with open(filepath, 'rb') as file:
  413 + with open(filepath, "rb") as file:
396 414 data = file.read()
397 415 except OSError:
398 416 logger.error('Error reading file "%s"', filepath)
... ... @@ -408,39 +426,39 @@ class FileHandler(BaseHandler):
408 426 # --- REVIEW -----------------------------------------------------------------
409 427 # pylint: disable=abstract-method
410 428 class ReviewHandler(BaseHandler):
411   - '''
  429 + """
412 430 Show test for review
413   - '''
  431 + """
414 432  
415 433 _templates = {
416   - 'radio': 'review-question-radio.html',
417   - 'checkbox': 'review-question-checkbox.html',
418   - 'text': 'review-question-text.html',
419   - 'text-regex': 'review-question-text.html',
420   - 'numeric-interval': 'review-question-text.html',
421   - 'textarea': 'review-question-text.html',
  434 + "radio": "review-question-radio.html",
  435 + "checkbox": "review-question-checkbox.html",
  436 + "text": "review-question-text.html",
  437 + "text-regex": "review-question-text.html",
  438 + "numeric-interval": "review-question-text.html",
  439 + "textarea": "review-question-text.html",
422 440 # -- information panels --
423   - 'information': 'review-question-information.html',
424   - 'success': 'review-question-information.html',
425   - 'warning': 'review-question-information.html',
426   - 'alert': 'review-question-information.html',
  441 + "information": "review-question-information.html",
  442 + "success": "review-question-information.html",
  443 + "warning": "review-question-information.html",
  444 + "alert": "review-question-information.html",
427 445 }
428 446  
429 447 @tornado.web.authenticated
430 448 @admin_only
431 449 async def get(self):
432   - '''
  450 + """
433 451 Opens JSON file with a given corrected test and renders it
434   - '''
435   - test_id = self.get_query_argument('test_id', None)
436   - logger.info('Review test %s.', test_id)
  452 + """
  453 + test_id = self.get_query_argument("test_id", None)
  454 + logger.info("Review test %s.", test_id)
437 455 fname = self.testapp.get_json_filename_of_test(test_id)
438 456  
439 457 if fname is None:
440 458 raise tornado.web.HTTPError(404) # Not Found
441 459  
442 460 try:
443   - with open(path.expanduser(fname), encoding='utf-8') as jsonfile:
  461 + with open(path.expanduser(fname), encoding="utf-8") as jsonfile:
444 462 test = json.load(jsonfile)
445 463 except OSError:
446 464 msg = f'Cannot open "{fname}" for review.'
... ... @@ -451,57 +469,65 @@ class ReviewHandler(BaseHandler):
451 469 logger.error(msg)
452 470 raise tornado.web.HTTPError(status_code=404, reason=msg)
453 471  
454   - uid = test['student']
  472 + uid = test["student"]
455 473 name = self.testapp.get_name(uid)
456   - self.render('review.html', t=test, uid=uid, name=name, md=md_to_html,
457   - templ=self._templates, debug=self.testapp.debug)
  474 + self.render(
  475 + "review.html",
  476 + t=test,
  477 + uid=uid,
  478 + name=name,
  479 + md=md_to_html,
  480 + templ=self._templates,
  481 + debug=self.testapp.debug,
  482 + )
458 483  
459 484  
460 485 # ----------------------------------------------------------------------------
461 486 def signal_handler(*_):
462   - '''
  487 + """
463 488 Catches Ctrl-C and stops webserver
464   - '''
465   - reply = input(' --> Stop webserver? (yes/no) ')
466   - if reply.lower() == 'yes':
  489 + """
  490 + reply = input(" --> Stop webserver? (yes/no) ")
  491 + if reply.lower() == "yes":
467 492 tornado.ioloop.IOLoop.current().stop()
468   - logger.critical('Webserver stopped.')
  493 + logger.critical("Webserver stopped.")
469 494 sys.exit(0)
470 495  
  496 +
471 497 # ----------------------------------------------------------------------------
472 498 def run_webserver(app, ssl_opt, port, debug):
473   - '''
  499 + """
474 500 Starts and runs webserver until a SIGINT signal (Ctrl-C) is received.
475   - '''
  501 + """
476 502  
477 503 # --- create web application
478   - logger.info('-------- Starting WebApplication (tornado) --------')
  504 + logger.info("-------- Starting WebApplication (tornado) --------")
479 505 try:
480 506 webapp = WebApplication(app, debug=debug)
481 507 except Exception:
482   - logger.critical('Failed to start web application.')
  508 + logger.critical("Failed to start web application.")
483 509 raise
484 510  
485 511 # --- create httpserver
486 512 try:
487 513 httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt)
488 514 except ValueError:
489   - logger.critical('Certificates cert.pem, privkey.pem not found')
  515 + logger.critical("Certificates cert.pem, privkey.pem not found")
490 516 sys.exit(1)
491 517  
492 518 try:
493 519 httpserver.listen(port)
494 520 except OSError:
495   - logger.critical('Cannot bind port %d. Already in use?', port)
  521 + logger.critical("Cannot bind port %d. Already in use?", port)
496 522 sys.exit(1)
497 523  
498   - logger.info('Listening on port %d... (Ctrl-C to stop)', port)
  524 + logger.info("Listening on port %d... (Ctrl-C to stop)", port)
499 525 signal.signal(signal.SIGINT, signal_handler)
500 526  
501 527 # --- run webserver
502 528 try:
503 529 tornado.ioloop.IOLoop.current().start() # running...
504 530 except Exception:
505   - logger.critical('Webserver stopped!')
  531 + logger.critical("Webserver stopped!")
506 532 tornado.ioloop.IOLoop.current().stop()
507 533 raise
... ...
perguntations/templates/question-textarea.html
... ... @@ -4,4 +4,6 @@
4 4  
5 5 <textarea class="form-control" name="{{i}}" autocomplete="off">{{q['answer'] or ''}}</textarea><br />
6 6  
  7 +<button>Verificar sintaxe</button>
  8 +
7 9 {% end %}
... ...
perguntations/test.py
1   -'''
  1 +"""
2 2 Test - instances of this class are individual tests
3   -'''
  3 +"""
4 4  
5 5 # python standard library
6 6 from datetime import datetime
... ... @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
14 14  
15 15 # ============================================================================
16 16 class Test(dict):
17   - '''
  17 + """
18 18 Each instance Test() is a concrete test of a single student.
19 19 A test can be in one of the states: ACTIVE, SUBMITTED, CORRECTED, QUIT
20 20 Methods:
... ... @@ -26,88 +26,90 @@ class Test(dict):
26 26 t.correct() - corrects questions and compute grade, register state
27 27 t.giveup() - register the test as given up, answers are not corrected
28 28 t.save_json(filename) - save the current test to file in JSON format
29   - '''
  29 + """
30 30  
31 31 # ------------------------------------------------------------------------
32 32 def __init__(self, d: dict):
33 33 super().__init__(d)
34   - self['grade'] = nan
35   - self['comment'] = ''
  34 + self["grade"] = nan
  35 + self["comment"] = ""
36 36  
37 37 # ------------------------------------------------------------------------
38 38 def start(self, uid: str) -> None:
39   - '''
  39 + """
40 40 Register student id and start time in the test
41   - '''
42   - self['student'] = uid
43   - self['start_time'] = datetime.now()
44   - self['finish_time'] = None
45   - self['state'] = 'ACTIVE'
  41 + """
  42 + self["student"] = uid
  43 + self["start_time"] = datetime.now()
  44 + self["finish_time"] = None
  45 + self["state"] = "ACTIVE"
46 46  
47 47 # ------------------------------------------------------------------------
48 48 def reset_answers(self) -> None:
49   - '''Removes all answers from the test (clean)'''
50   - for question in self['questions']:
51   - question['answer'] = None
  49 + """Removes all answers from the test (clean)"""
  50 + for question in self["questions"]:
  51 + question["answer"] = None
52 52  
53 53 # ------------------------------------------------------------------------
54 54 def update_answer(self, ref: str, ans) -> None:
55   - '''updates one answer in the test'''
56   - self['questions'][ref].set_answer(ans)
  55 + """updates one answer in the test"""
  56 + self["questions"][ref].set_answer(ans)
57 57  
58 58 # ------------------------------------------------------------------------
59 59 def submit(self, answers: dict) -> None:
60   - '''
  60 + """
61 61 Given a dictionary ans={'ref': 'some answer'} updates the answers of
62 62 multiple questions in the test.
63 63 Only affects the questions referred in the dictionary.
64   - '''
65   - self['finish_time'] = datetime.now()
  64 + """
  65 + self["finish_time"] = datetime.now()
66 66 for ref, ans in answers.items():
67   - self['questions'][ref].set_answer(ans)
68   - self['state'] = 'SUBMITTED'
  67 + self["questions"][ref].set_answer(ans)
  68 + self["state"] = "SUBMITTED"
69 69  
70 70 # ------------------------------------------------------------------------
71 71 async def correct_async(self) -> None:
72   - '''Corrects all the answers of the test and computes the final grade'''
  72 + """Corrects all the answers of the test and computes the final grade"""
73 73 grade = 0.0
74   - for question in self['questions']:
  74 + for question in self["questions"]:
75 75 await question.correct_async()
76   - grade += question['grade'] * question['points']
77   - logger.debug('Correcting %30s: %3g%%',
78   - question['ref'], question['grade']*100)
  76 + grade += question["grade"] * question["points"]
  77 + logger.debug(
  78 + "Correcting %30s: %3g%%", question["ref"], question["grade"] * 100
  79 + )
79 80  
80 81 # truncate to avoid negative final grade and adjust scale
81   - self['grade'] = max(0.0, grade) + self['scale'][0]
82   - self['state'] = 'CORRECTED'
  82 + self["grade"] = max(0.0, grade) + self["scale"][0]
  83 + self["state"] = "CORRECTED"
83 84  
84 85 # ------------------------------------------------------------------------
85 86 def correct(self) -> None:
86   - '''Corrects all the answers of the test and computes the final grade'''
  87 + """Corrects all the answers of the test and computes the final grade"""
87 88 grade = 0.0
88   - for question in self['questions']:
  89 + for question in self["questions"]:
89 90 question.correct()
90   - grade += question['grade'] * question['points']
91   - logger.debug('Correcting %30s: %3g%%',
92   - question['ref'], question['grade']*100)
  91 + grade += question["grade"] * question["points"]
  92 + logger.debug(
  93 + "Correcting %30s: %3g%%", question["ref"], question["grade"] * 100
  94 + )
93 95  
94 96 # truncate to avoid negative final grade and adjust scale
95   - self['grade'] = max(0.0, grade) + self['scale'][0]
96   - self['state'] = 'CORRECTED'
  97 + self["grade"] = max(0.0, grade) + self["scale"][0]
  98 + self["state"] = "CORRECTED"
97 99  
98 100 # ------------------------------------------------------------------------
99 101 def giveup(self) -> None:
100   - '''Test is marqued as QUIT and is not corrected'''
101   - self['finish_time'] = datetime.now()
102   - self['state'] = 'QUIT'
103   - self['grade'] = 0.0
  102 + """Test is marqued as QUIT and is not corrected"""
  103 + self["finish_time"] = datetime.now()
  104 + self["state"] = "QUIT"
  105 + self["grade"] = 0.0
104 106  
105 107 # ------------------------------------------------------------------------
106 108 def save_json(self, filename: str) -> None:
107   - '''save test in JSON format'''
108   - with open(filename, 'w', encoding='utf-8') as file:
  109 + """save test in JSON format"""
  110 + with open(filename, "w", encoding="utf-8") as file:
109 111 json.dump(self, file, indent=2, default=str) # str for datetime
110 112  
111 113 # ------------------------------------------------------------------------
112 114 def __str__(self) -> str:
113   - return '\n'.join([f'{k}: {v}' for k,v in self.items()])
  115 + return "\n".join([f"{k}: {v}" for k, v in self.items()])
... ...
perguntations/testfactory.py
1   -'''
  1 +"""
2 2 TestFactory - generates tests for students
3   -'''
  3 +"""
4 4  
5 5 # python standard library
6 6 import asyncio
... ... @@ -21,17 +21,18 @@ logger = logging.getLogger(__name__)
21 21  
22 22 # --- test validation --------------------------------------------------------
23 23 def check_answers_directory(ans: str) -> bool:
24   - '''Checks is answers_dir exists and is writable'''
25   - testfile = path.join(path.expanduser(ans), 'REMOVE-ME')
  24 + """Checks is answers_dir exists and is writable"""
  25 + testfile = path.join(path.expanduser(ans), "REMOVE-ME")
26 26 try:
27   - with open(testfile, 'w', encoding='utf-8') as file:
28   - file.write('You can safely remove this file.')
  27 + with open(testfile, "w", encoding="utf-8") as file:
  28 + file.write("You can safely remove this file.")
29 29 except OSError:
30 30 return False
31 31 return True
32 32  
  33 +
33 34 def check_import_files(files: list) -> bool:
34   - '''Checks if the question files exist'''
  35 + """Checks if the question files exist"""
35 36 if not files:
36 37 return False
37 38 for file in files:
... ... @@ -39,64 +40,69 @@ def check_import_files(files: list) -&gt; bool:
39 40 return False
40 41 return True
41 42  
  43 +
42 44 def normalize_question_list(questions: list) -> None:
43   - '''convert question ref from string to list of string'''
  45 + """convert question ref from string to list of string"""
44 46 for question in questions:
45   - if isinstance(question['ref'], str):
46   - question['ref'] = [question['ref']]
47   -
48   -test_schema = schema.Schema({
49   - 'ref': schema.Regex('^[a-zA-Z0-9_-]+$'),
50   - 'database': schema.And(str, path.isfile),
51   - 'answers_dir': schema.And(str, check_answers_directory),
52   - 'title': str,
53   - schema.Optional('duration'): int,
54   - schema.Optional('autosubmit'): bool,
55   - schema.Optional('autocorrect'): bool,
56   - schema.Optional('show_points'): bool,
57   - schema.Optional('scale'): schema.And([schema.Use(float)],
58   - lambda s: len(s) == 2),
59   - 'files': schema.And([str], check_import_files),
60   - 'questions': [{
61   - 'ref': schema.Or(str, [str]),
62   - schema.Optional('points'): float
63   - }]
64   - }, ignore_extra_keys=True)
65   - # FIXME schema error with 'testfile' which is added in the code
  47 + if isinstance(question["ref"], str):
  48 + question["ref"] = [question["ref"]]
  49 +
  50 +
  51 +test_schema = schema.Schema(
  52 + {
  53 + "ref": schema.Regex("^[a-zA-Z0-9_-]+$"),
  54 + "database": schema.And(str, path.isfile),
  55 + "answers_dir": schema.And(str, check_answers_directory),
  56 + "title": str,
  57 + schema.Optional("duration"): int,
  58 + schema.Optional("autosubmit"): bool,
  59 + schema.Optional("autocorrect"): bool,
  60 + schema.Optional("show_points"): bool,
  61 + schema.Optional("scale"): schema.And(
  62 + [schema.Use(float)], lambda s: len(s) == 2
  63 + ),
  64 + "files": schema.And([str], check_import_files),
  65 + "questions": [{"ref": schema.Or(str, [str]), schema.Optional("points"): float}],
  66 + },
  67 + ignore_extra_keys=True,
  68 +)
  69 +# FIXME schema error with 'testfile' which is added in the code
66 70  
67 71 # ============================================================================
68 72 class TestFactoryException(Exception):
69   - '''exception raised in this module'''
  73 + """exception raised in this module"""
70 74  
71 75  
72 76 # ============================================================================
73 77 class TestFactory(dict):
74   - '''
  78 + """
75 79 Each instance of TestFactory() is a test generator.
76 80 For example, if we want to serve two different tests, then we need two
77 81 instances of TestFactory(), one for each test.
78   - '''
  82 + """
79 83  
80 84 # ------------------------------------------------------------------------
81 85 def __init__(self, conf) -> None:
82   - '''
  86 + """
83 87 Loads configuration from yaml file, then overrides some configurations
84 88 using the conf argument.
85 89 Base questions are added to a pool of questions factories.
86   - '''
  90 + """
87 91  
88 92 test_schema.validate(conf)
89 93  
90 94 # --- set test defaults and then use given configuration
91   - super().__init__({ # defaults
92   - 'show_points': True,
93   - 'scale': None,
94   - 'duration': 0, # 0=infinite
95   - 'autosubmit': False,
96   - 'autocorrect': True,
97   - })
  95 + super().__init__(
  96 + { # defaults
  97 + "show_points": True,
  98 + "scale": None,
  99 + "duration": 0, # 0=infinite
  100 + "autosubmit": False,
  101 + "autocorrect": True,
  102 + }
  103 + )
98 104 self.update(conf)
99   - normalize_question_list(self['questions'])
  105 + normalize_question_list(self["questions"])
100 106  
101 107 # --- for review, we are done. no factories needed
102 108 # if self['review']: FIXME
... ... @@ -104,53 +110,57 @@ class TestFactory(dict):
104 110 # return
105 111  
106 112 # --- find refs of all questions used in the test
107   - qrefs = {r for qq in self['questions'] for r in qq['ref']}
108   - logger.info('Declared %d questions (each test uses %d).',
109   - len(qrefs), len(self["questions"]))
  113 + qrefs = {r for qq in self["questions"] for r in qq["ref"]}
  114 + logger.info(
  115 + "Declared %d questions (each test uses %d).",
  116 + len(qrefs),
  117 + len(self["questions"]),
  118 + )
110 119  
111 120 # --- load and build question factories
112   - self['question_factory'] = {}
  121 + self["question_factory"] = {}
113 122  
114 123 for file in self["files"]:
115 124 fullpath = path.normpath(file)
116 125  
117 126 logger.info('Loading "%s"...', fullpath)
118   - questions = load_yaml(fullpath) # , default=[])
  127 + questions = load_yaml(fullpath) # , default=[])
119 128  
120 129 for i, question in enumerate(questions):
121 130 # make sure every question in the file is a dictionary
122 131 if not isinstance(question, dict):
123   - msg = f'Question {i} in {file} is not a dictionary'
  132 + msg = f"Question {i} in {file} is not a dictionary"
124 133 raise TestFactoryException(msg)
125 134  
126 135 # check if ref is missing, then set to '//file.yaml:3'
127   - if 'ref' not in question:
128   - question['ref'] = f'{file}:{i:04}'
  136 + if "ref" not in question:
  137 + question["ref"] = f"{file}:{i:04}"
129 138 logger.warning('Missing ref set to "%s"', question["ref"])
130 139  
131 140 # check for duplicate refs
132   - qref = question['ref']
133   - if qref in self['question_factory']:
134   - other = self['question_factory'][qref]
135   - otherfile = path.join(other.question['path'],
136   - other.question['filename'])
  141 + qref = question["ref"]
  142 + if qref in self["question_factory"]:
  143 + other = self["question_factory"][qref]
  144 + otherfile = path.join(
  145 + other.question["path"], other.question["filename"]
  146 + )
137 147 msg = f'Duplicate "{qref}" in {otherfile} and {fullpath}'
138 148 raise TestFactoryException(msg)
139 149  
140 150 # make factory only for the questions used in the test
141 151 if qref in qrefs:
142   - question.update(zip(('path', 'filename', 'index'),
143   - path.split(fullpath) + (i,)))
144   - self['question_factory'][qref] = QFactory(QDict(question))
  152 + question.update(
  153 + zip(("path", "filename", "index"), path.split(fullpath) + (i,))
  154 + )
  155 + self["question_factory"][qref] = QFactory(QDict(question))
145 156  
146   - qmissing = qrefs.difference(set(self['question_factory'].keys()))
  157 + qmissing = qrefs.difference(set(self["question_factory"].keys()))
147 158 if qmissing:
148   - raise TestFactoryException(f'Could not find questions {qmissing}.')
  159 + raise TestFactoryException(f"Could not find questions {qmissing}.")
149 160  
150 161 self.check_questions()
151 162  
152   - logger.info('Test factory ready. No errors found.')
153   -
  163 + logger.info("Test factory ready. No errors found.")
154 164  
155 165 # ------------------------------------------------------------------------
156 166 # def check_test_ref(self) -> None:
... ... @@ -202,8 +212,8 @@ class TestFactory(dict):
202 212 # 'question files to import!')
203 213 # raise TestFactoryException(msg)
204 214  
205   - # if isinstance(self['files'], str):
206   - # self['files'] = [self['files']]
  215 + # if isinstance(self['files'], str):
  216 + # self['files'] = [self['files']]
207 217  
208 218 # def check_question_list(self) -> None:
209 219 # '''normalize question list'''
... ... @@ -235,127 +245,140 @@ class TestFactory(dict):
235 245 # logger.warning(msg)
236 246 # self['scale'] = [self['scale_min'], self['scale_max']]
237 247  
238   -
239 248 # ------------------------------------------------------------------------
240 249 # def sanity_checks(self) -> None:
241 250 # '''
242 251 # Checks for valid keys and sets default values.
243 252 # Also checks if some files and directories exist
244 253 # '''
245   - # self.check_test_ref()
246   - # self.check_missing_database()
247   - # self.check_missing_answers_directory()
248   - # self.check_answers_directory_writable()
249   - # self.check_questions_directory()
250   - # self.check_import_files()
251   - # self.check_question_list()
252   - # self.check_missing_title()
253   - # self.check_grade_scaling()
  254 + # self.check_test_ref()
  255 + # self.check_missing_database()
  256 + # self.check_missing_answers_directory()
  257 + # self.check_answers_directory_writable()
  258 + # self.check_questions_directory()
  259 + # self.check_import_files()
  260 + # self.check_question_list()
  261 + # self.check_missing_title()
  262 + # self.check_grade_scaling()
254 263  
255 264 # ------------------------------------------------------------------------
256 265 def check_questions(self) -> None:
257   - '''
  266 + """
258 267 checks if questions can be correctly generated and corrected
259   - '''
260   - logger.info('Checking questions...')
  268 + """
  269 + logger.info("Checking questions...")
261 270 # FIXME get_event_loop will be deprecated in python3.10
262 271 loop = asyncio.get_event_loop()
263   - for i, (qref, qfact) in enumerate(self['question_factory'].items()):
  272 + for i, (qref, qfact) in enumerate(self["question_factory"].items()):
264 273 try:
265 274 question = loop.run_until_complete(qfact.gen_async())
266 275 except Exception as exc:
267 276 msg = f'Failed to generate "{qref}"'
268 277 raise TestFactoryException(msg) from exc
269 278 else:
270   - logger.info('%4d. %s: Ok', i, qref)
  279 + logger.info("%4d. %s: Ok", i, qref)
271 280  
272   - if question['type'] == 'textarea':
  281 + if question["type"] == "textarea":
273 282 _runtests_textarea(qref, question)
274 283  
275 284 # ------------------------------------------------------------------------
276 285 async def generate(self):
277   - '''
  286 + """
278 287 Given a dictionary with a student dict {'name':'john', 'number': 123}
279 288 returns instance of Test() for that particular student
280   - '''
  289 + """
281 290  
282 291 # make list of questions
283 292 questions = []
284 293 qnum = 1 # track question number
285 294 nerr = 0 # count errors during questions generation
286 295  
287   - for qlist in self['questions']:
  296 + for qlist in self["questions"]:
288 297 # choose list of question variants
289   - choose = qlist.get('choose', 1)
290   - qrefs = random.sample(qlist['ref'], k=choose)
  298 + choose = qlist.get("choose", 1)
  299 + qrefs = random.sample(qlist["ref"], k=choose)
291 300  
292 301 for qref in qrefs:
293 302 # generate instance of question
294 303 try:
295   - question = await self['question_factory'][qref].gen_async()
  304 + question = await self["question_factory"][qref].gen_async()
296 305 except QuestionException:
297 306 logger.error('Can\'t generate question "%s". Skipping.', qref)
298 307 nerr += 1
299 308 continue
300 309  
301 310 # some defaults
302   - if question['type'] in ('information', 'success', 'warning',
303   - 'alert'):
304   - question['points'] = qlist.get('points', 0.0)
  311 + if question["type"] in ("information", "success", "warning", "alert"):
  312 + question["points"] = qlist.get("points", 0.0)
305 313 else:
306   - question['points'] = qlist.get('points', 1.0)
307   - question['number'] = qnum # counter for non informative panels
  314 + question["points"] = qlist.get("points", 1.0)
  315 + question["number"] = qnum # counter for non informative panels
308 316 qnum += 1
309 317  
310 318 questions.append(question)
311 319  
312 320 # setup scale
313   - total_points = sum(q['points'] for q in questions)
  321 + total_points = sum(q["points"] for q in questions)
314 322  
315 323 if total_points > 0:
316 324 # normalize question points to scale
317   - if self['scale'] is not None:
318   - scale_min, scale_max = self['scale']
  325 + if self["scale"] is not None:
  326 + scale_min, scale_max = self["scale"]
319 327 factor = (scale_max - scale_min) / total_points
320 328 for question in questions:
321   - question['points'] *= factor
322   - logger.debug('Points normalized from %g to [%g, %g]',
323   - total_points, scale_min, scale_max)
  329 + question["points"] *= factor
  330 + logger.debug(
  331 + "Points normalized from %g to [%g, %g]",
  332 + total_points,
  333 + scale_min,
  334 + scale_max,
  335 + )
324 336 else:
325   - self['scale'] = [0, total_points]
  337 + self["scale"] = [0, total_points]
326 338 else:
327   - logger.warning('Total points is **ZERO**.')
328   - if self['scale'] is None:
329   - self['scale'] = [0, 20] # default
  339 + logger.warning("Total points is **ZERO**.")
  340 + if self["scale"] is None:
  341 + self["scale"] = [0, 20] # default
330 342  
331 343 if nerr > 0:
332   - logger.error('%s errors found!', nerr)
  344 + logger.error("%s errors found!", nerr)
333 345  
334 346 # copy these from the test configuratoin to each test instance
335   - inherit = ['ref', 'title', 'database', 'answers_dir', 'files', 'scale',
336   - 'duration', 'autosubmit', 'autocorrect', 'show_points']
337   -
338   - return Test({'questions': questions, **{k:self[k] for k in inherit}})
  347 + inherit = [
  348 + "ref",
  349 + "title",
  350 + "database",
  351 + "answers_dir",
  352 + "files",
  353 + "scale",
  354 + "duration",
  355 + "autosubmit",
  356 + "autocorrect",
  357 + "show_points",
  358 + ]
  359 +
  360 + return Test({"questions": questions, **{k: self[k] for k in inherit}})
339 361  
340 362 # ------------------------------------------------------------------------
341 363 def __repr__(self):
342   - testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items())
343   - return 'TestFactory({\n' + testsettings + '\n})'
  364 + testsettings = "\n".join(f" {k:14s}: {v}" for k, v in self.items())
  365 + return "TestFactory({\n" + testsettings + "\n})"
  366 +
344 367  
345 368 # ============================================================================
346 369 def _runtests_textarea(qref, question):
347   - '''
  370 + """
348 371 Checks if correction script works and runs tests if available
349   - '''
  372 + """
350 373 try:
351   - question.set_answer('')
  374 + question.set_answer("")
352 375 question.correct()
353 376 except Exception as exc:
354 377 msg = f'Failed to correct "{qref}"'
355 378 raise TestFactoryException(msg) from exc
356   - logger.info(' correction works')
  379 + logger.info(" correction works")
357 380  
358   - for tnum, right_answer in enumerate(question.get('tests_right', {})):
  381 + for tnum, right_answer in enumerate(question.get("tests_right", {})):
359 382 try:
360 383 question.set_answer(right_answer)
361 384 question.correct()
... ... @@ -363,12 +386,12 @@ def _runtests_textarea(qref, question):
363 386 msg = f'Failed to correct "{qref}"'
364 387 raise TestFactoryException(msg) from exc
365 388  
366   - if question['grade'] == 1.0:
367   - logger.info(' tests_right[%i] Ok', tnum)
  389 + if question["grade"] == 1.0:
  390 + logger.info(" tests_right[%i] Ok", tnum)
368 391 else:
369   - logger.error(' tests_right[%i] FAILED!!!', tnum)
  392 + logger.error(" tests_right[%i] FAILED!!!", tnum)
370 393  
371   - for tnum, wrong_answer in enumerate(question.get('tests_wrong', {})):
  394 + for tnum, wrong_answer in enumerate(question.get("tests_wrong", {})):
372 395 try:
373 396 question.set_answer(wrong_answer)
374 397 question.correct()
... ... @@ -376,7 +399,7 @@ def _runtests_textarea(qref, question):
376 399 msg = f'Failed to correct "{qref}"'
377 400 raise TestFactoryException(msg) from exc
378 401  
379   - if question['grade'] < 1.0:
380   - logger.info(' tests_wrong[%i] Ok', tnum)
  402 + if question["grade"] < 1.0:
  403 + logger.info(" tests_wrong[%i] Ok", tnum)
381 404 else:
382   - logger.error(' tests_wrong[%i] FAILED!!!', tnum)
  405 + logger.error(" tests_wrong[%i] FAILED!!!", tnum)
... ...
perguntations/tools.py
1   -'''
  1 +"""
2 2 File: perguntations/tools.py
3 3 Description: Helper functions to load yaml files and run external programs.
4   -'''
  4 +"""
5 5  
6 6  
7 7 # python standard library
... ... @@ -21,20 +21,18 @@ logger = logging.getLogger(__name__)
21 21  
22 22 # ----------------------------------------------------------------------------
23 23 def load_yaml(filename: str) -> Any:
24   - '''load yaml file or raise exception on error'''
25   - with open(path.expanduser(filename), 'r', encoding='utf-8') as file:
  24 + """load yaml file or raise exception on error"""
  25 + with open(path.expanduser(filename), "r", encoding="utf-8") as file:
26 26 return yaml.safe_load(file)
27 27  
  28 +
28 29 # ---------------------------------------------------------------------------
29   -def run_script(script: str,
30   - args: List[str],
31   - stdin: str = '',
32   - timeout: int = 3) -> Any:
33   - '''
  30 +def run_script(script: str, args: List[str], stdin: str = "", timeout: int = 3) -> Any:
  31 + """
34 32 Runs a script and returns its stdout parsed as yaml, or None on error.
35 33 The script is run in another process but this function blocks waiting
36 34 for its termination.
37   - '''
  35 + """
38 36 logger.debug('run_script "%s"', script)
39 37  
40 38 output = None
... ... @@ -43,14 +41,15 @@ def run_script(script: str,
43 41  
44 42 # --- run process
45 43 try:
46   - proc = subprocess.run(cmd,
47   - input=stdin,
48   - stdout=subprocess.PIPE,
49   - stderr=subprocess.STDOUT,
50   - universal_newlines=True,
51   - timeout=timeout,
52   - check=True,
53   - )
  44 + proc = subprocess.run(
  45 + cmd,
  46 + input=stdin,
  47 + stdout=subprocess.PIPE,
  48 + stderr=subprocess.STDOUT,
  49 + universal_newlines=True,
  50 + timeout=timeout,
  51 + check=True,
  52 + )
54 53 except subprocess.TimeoutExpired:
55 54 logger.error('Timeout %ds exceeded running "%s".', timeout, script)
56 55 return output
... ... @@ -71,11 +70,10 @@ def run_script(script: str,
71 70  
72 71  
73 72 # ----------------------------------------------------------------------------
74   -async def run_script_async(script: str,
75   - args: List[str],
76   - stdin: str = '',
77   - timeout: int = 3) -> Any:
78   - '''Same as above, but asynchronous'''
  73 +async def run_script_async(
  74 + script: str, args: List[str], stdin: str = "", timeout: int = 3
  75 +) -> Any:
  76 + """Same as above, but asynchronous"""
79 77  
80 78 script = path.expanduser(script)
81 79 args = [str(a) for a in args]
... ... @@ -84,11 +82,12 @@ async def run_script_async(script: str,
84 82 # --- start process
85 83 try:
86 84 proc = await asyncio.create_subprocess_exec(
87   - script, *args,
  85 + script,
  86 + *args,
88 87 stdin=asyncio.subprocess.PIPE,
89 88 stdout=asyncio.subprocess.PIPE,
90 89 stderr=asyncio.subprocess.DEVNULL,
91   - )
  90 + )
92 91 except OSError:
93 92 logger.error('Can not execute script "%s".', script)
94 93 return output
... ... @@ -96,8 +95,8 @@ async def run_script_async(script: str,
96 95 # --- send input and wait for termination
97 96 try:
98 97 stdout, _ = await asyncio.wait_for(
99   - proc.communicate(input=stdin.encode('utf-8')),
100   - timeout=timeout)
  98 + proc.communicate(input=stdin.encode("utf-8")), timeout=timeout
  99 + )
101 100 except asyncio.TimeoutError:
102 101 logger.warning('Timeout %ds running script "%s".', timeout, script)
103 102 return output
... ... @@ -109,7 +108,7 @@ async def run_script_async(script: str,
109 108  
110 109 # --- parse yaml
111 110 try:
112   - output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))
  111 + output = yaml.safe_load(stdout.decode("utf-8", "ignore"))
113 112 except yaml.YAMLError:
114 113 logger.error('Error parsing yaml output of "%s"', script)
115 114  
... ...