Commit a333dc72030ffe2603e289666d469e8cc5e10caa
1 parent
3259fc7c
Exists in
master
and in
1 other branch
Add type annotations mostly in questions.py
Showing
6 changed files
with
86 additions
and
62 deletions
Show diff stats
.gitignore
aprendizations/learnapp.py
... | ... | @@ -6,6 +6,7 @@ from contextlib import contextmanager # `with` statement in db sessions |
6 | 6 | import asyncio |
7 | 7 | from datetime import datetime |
8 | 8 | from random import random |
9 | +from typing import Dict | |
9 | 10 | |
10 | 11 | # third party libraries |
11 | 12 | import bcrypt |
... | ... | @@ -75,13 +76,12 @@ class LearnApp(object): |
75 | 76 | errors = 0 |
76 | 77 | for qref in self.factory: |
77 | 78 | logger.debug(f'Checking "{qref}"...') |
78 | - q = self.factory[qref].generate() | |
79 | 79 | try: |
80 | 80 | q = self.factory[qref].generate() |
81 | 81 | except Exception: |
82 | 82 | logger.error(f'Failed to generate "{qref}".') |
83 | 83 | errors += 1 |
84 | - raise | |
84 | + raise LearnException('Sanity checks') | |
85 | 85 | continue |
86 | 86 | |
87 | 87 | if 'tests_right' in q: |
... | ... | @@ -89,8 +89,7 @@ class LearnApp(object): |
89 | 89 | q['answer'] = t |
90 | 90 | q.correct() |
91 | 91 | if q['grade'] < 1.0: |
92 | - logger.error(f'Failed to correct right answer in ' | |
93 | - f'"{qref}".') | |
92 | + logger.error(f'Failed right answer in "{qref}".') | |
94 | 93 | errors += 1 |
95 | 94 | continue # to next right test |
96 | 95 | |
... | ... | @@ -99,14 +98,13 @@ class LearnApp(object): |
99 | 98 | q['answer'] = t |
100 | 99 | q.correct() |
101 | 100 | if q['grade'] >= 1.0: |
102 | - logger.error(f'Failed to correct right answer in ' | |
103 | - f'"{qref}".') | |
101 | + logger.error(f'Failed wrong answer in "{qref}".') | |
104 | 102 | errors += 1 |
105 | 103 | continue # to next wrong test |
106 | 104 | |
107 | 105 | if errors > 0: |
108 | 106 | logger.info(f'{errors:>6} errors found.') |
109 | - raise | |
107 | + raise LearnException('Sanity checks') | |
110 | 108 | else: |
111 | 109 | logger.info('No errors found.') |
112 | 110 | |
... | ... | @@ -315,8 +313,8 @@ class LearnApp(object): |
315 | 313 | for tref, attr in topics.items(): |
316 | 314 | for d in attr.get('deps', []): |
317 | 315 | if d not in g.nodes(): |
318 | - logger.error(f'Topic "{tref}" depends on "{d}" but the ' | |
319 | - f'latter does not exist') | |
316 | + logger.error(f'Topic "{tref}" depends on "{d}" but it ' | |
317 | + f'does not exist') | |
320 | 318 | raise LearnException() |
321 | 319 | else: |
322 | 320 | g.add_edge(d, tref) |
... | ... | @@ -345,7 +343,7 @@ class LearnApp(object): |
345 | 343 | # ------------------------------------------------------------------------ |
346 | 344 | # Buils dictionary of question factories |
347 | 345 | # ------------------------------------------------------------------------ |
348 | - def make_factory(self): | |
346 | + def make_factory(self) -> Dict[str, QFactory]: | |
349 | 347 | logger.info('Building questions factory...') |
350 | 348 | factory = {} # {'qref': QFactory()} |
351 | 349 | g = self.deps |
... | ... | @@ -382,39 +380,39 @@ class LearnApp(object): |
382 | 380 | return factory |
383 | 381 | |
384 | 382 | # ------------------------------------------------------------------------ |
385 | - def get_login_counter(self, uid): | |
383 | + def get_login_counter(self, uid: str) -> int: | |
386 | 384 | return self.online[uid]['counter'] |
387 | 385 | |
388 | 386 | # ------------------------------------------------------------------------ |
389 | - def get_student_name(self, uid): | |
387 | + def get_student_name(self, uid: str) -> str: | |
390 | 388 | return self.online[uid].get('name', '') |
391 | 389 | |
392 | 390 | # ------------------------------------------------------------------------ |
393 | - def get_student_state(self, uid): | |
391 | + def get_student_state(self, uid: str): | |
394 | 392 | return self.online[uid]['state'].get_knowledge_state() |
395 | 393 | |
396 | 394 | # ------------------------------------------------------------------------ |
397 | - def get_student_progress(self, uid): | |
395 | + def get_student_progress(self, uid: str): | |
398 | 396 | return self.online[uid]['state'].get_topic_progress() |
399 | 397 | |
400 | 398 | # ------------------------------------------------------------------------ |
401 | - def get_current_question(self, uid): | |
399 | + def get_current_question(self, uid: str): | |
402 | 400 | return self.online[uid]['state'].get_current_question() # dict |
403 | 401 | |
404 | 402 | # ------------------------------------------------------------------------ |
405 | - def get_student_question_type(self, uid): | |
403 | + def get_student_question_type(self, uid: str) -> str: | |
406 | 404 | return self.online[uid]['state'].get_current_question()['type'] |
407 | 405 | |
408 | 406 | # ------------------------------------------------------------------------ |
409 | - def get_student_topic(self, uid): | |
407 | + def get_student_topic(self, uid: str) -> str: | |
410 | 408 | return self.online[uid]['state'].get_current_topic() # str |
411 | 409 | |
412 | 410 | # ------------------------------------------------------------------------ |
413 | - def get_title(self): | |
411 | + def get_title(self) -> str: | |
414 | 412 | return self.deps.graph.get('title', '') # FIXME |
415 | 413 | |
416 | 414 | # ------------------------------------------------------------------------ |
417 | - def get_topic_name(self, ref): | |
415 | + def get_topic_name(self, ref: str) -> str: | |
418 | 416 | return self.deps.node[ref]['name'] |
419 | 417 | |
420 | 418 | # ------------------------------------------------------------------------ | ... | ... |
aprendizations/questions.py
... | ... | @@ -5,6 +5,7 @@ import re |
5 | 5 | from os import path |
6 | 6 | import logging |
7 | 7 | import asyncio |
8 | +from typing import Any, Dict, NewType | |
8 | 9 | |
9 | 10 | # this project |
10 | 11 | from .tools import run_script |
... | ... | @@ -13,6 +14,9 @@ from .tools import run_script |
13 | 14 | logger = logging.getLogger(__name__) |
14 | 15 | |
15 | 16 | |
17 | +QDict = NewType('QDict', Dict[str, Any]) | |
18 | + | |
19 | + | |
16 | 20 | class QuestionException(Exception): |
17 | 21 | pass |
18 | 22 | |
... | ... | @@ -27,18 +31,18 @@ class Question(dict): |
27 | 31 | to a student. |
28 | 32 | Instances can shuffle options, or automatically generate questions. |
29 | 33 | ''' |
30 | - def __init__(self, q): | |
34 | + def __init__(self, q: QDict) -> None: | |
31 | 35 | super().__init__(q) |
32 | 36 | |
33 | 37 | # add required keys if missing |
34 | - self.set_defaults({ | |
38 | + self.set_defaults(QDict({ | |
35 | 39 | 'title': '', |
36 | 40 | 'answer': None, |
37 | 41 | 'comments': '', |
38 | 42 | 'solution': '', |
39 | 43 | 'files': {}, |
40 | 44 | 'max_tries': 3, |
41 | - }) | |
45 | + })) | |
42 | 46 | |
43 | 47 | def correct(self) -> None: |
44 | 48 | self['comments'] = '' |
... | ... | @@ -48,7 +52,7 @@ class Question(dict): |
48 | 52 | loop = asyncio.get_running_loop() |
49 | 53 | await loop.run_in_executor(None, self.correct) |
50 | 54 | |
51 | - def set_defaults(self, d) -> None: | |
55 | + def set_defaults(self, d: QDict) -> None: | |
52 | 56 | 'Add k:v pairs from default dict d for nonexistent keys' |
53 | 57 | for k, v in d.items(): |
54 | 58 | self.setdefault(k, v) |
... | ... | @@ -69,18 +73,18 @@ class QuestionRadio(Question): |
69 | 73 | |
70 | 74 | # ------------------------------------------------------------------------ |
71 | 75 | # FIXME marking all options right breaks |
72 | - def __init__(self, q): | |
76 | + def __init__(self, q: QDict) -> None: | |
73 | 77 | super().__init__(q) |
74 | 78 | |
75 | 79 | n = len(self['options']) |
76 | 80 | |
77 | - self.set_defaults({ | |
81 | + self.set_defaults(QDict({ | |
78 | 82 | 'text': '', |
79 | 83 | 'correct': 0, |
80 | 84 | 'shuffle': True, |
81 | 85 | 'discount': True, |
82 | 86 | 'max_tries': (n + 3) // 4 # 1 try for each 4 options |
83 | - }) | |
87 | + })) | |
84 | 88 | |
85 | 89 | # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] |
86 | 90 | # correctness levels from 0.0 to 1.0 (no discount here!) |
... | ... | @@ -97,7 +101,7 @@ class QuestionRadio(Question): |
97 | 101 | right = [i for i in range(n) if self['correct'][i] >= 1] |
98 | 102 | wrong = [i for i in range(n) if self['correct'][i] < 1] |
99 | 103 | |
100 | - self.set_defaults({'choose': 1+len(wrong)}) | |
104 | + self.set_defaults(QDict({'choose': 1+len(wrong)})) | |
101 | 105 | |
102 | 106 | # try to choose 1 correct option |
103 | 107 | if right: |
... | ... | @@ -147,20 +151,20 @@ class QuestionCheckbox(Question): |
147 | 151 | ''' |
148 | 152 | |
149 | 153 | # ------------------------------------------------------------------------ |
150 | - def __init__(self, q): | |
154 | + def __init__(self, q: QDict) -> None: | |
151 | 155 | super().__init__(q) |
152 | 156 | |
153 | 157 | n = len(self['options']) |
154 | 158 | |
155 | 159 | # set defaults if missing |
156 | - self.set_defaults({ | |
160 | + self.set_defaults(QDict({ | |
157 | 161 | 'text': '', |
158 | 162 | 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options |
159 | 163 | 'shuffle': True, |
160 | 164 | 'discount': True, |
161 | 165 | 'choose': n, # number of options |
162 | 166 | 'max_tries': max(1, min(n - 1, 3)) |
163 | - }) | |
167 | + })) | |
164 | 168 | |
165 | 169 | if len(self['correct']) != n: |
166 | 170 | msg = f'Options and correct mismatch in "{self["ref"]}"' |
... | ... | @@ -218,13 +222,13 @@ class QuestionText(Question): |
218 | 222 | ''' |
219 | 223 | |
220 | 224 | # ------------------------------------------------------------------------ |
221 | - def __init__(self, q): | |
225 | + def __init__(self, q: QDict) -> None: | |
222 | 226 | super().__init__(q) |
223 | 227 | |
224 | - self.set_defaults({ | |
228 | + self.set_defaults(QDict({ | |
225 | 229 | 'text': '', |
226 | 230 | 'correct': [], |
227 | - }) | |
231 | + })) | |
228 | 232 | |
229 | 233 | # make sure its always a list of possible correct answers |
230 | 234 | if not isinstance(self['correct'], list): |
... | ... | @@ -251,13 +255,13 @@ class QuestionTextRegex(Question): |
251 | 255 | ''' |
252 | 256 | |
253 | 257 | # ------------------------------------------------------------------------ |
254 | - def __init__(self, q): | |
258 | + def __init__(self, q: QDict) -> None: | |
255 | 259 | super().__init__(q) |
256 | 260 | |
257 | - self.set_defaults({ | |
261 | + self.set_defaults(QDict({ | |
258 | 262 | 'text': '', |
259 | 263 | 'correct': '$.^', # will always return false |
260 | - }) | |
264 | + })) | |
261 | 265 | |
262 | 266 | # ------------------------------------------------------------------------ |
263 | 267 | def correct(self) -> None: |
... | ... | @@ -282,13 +286,13 @@ class QuestionNumericInterval(Question): |
282 | 286 | ''' |
283 | 287 | |
284 | 288 | # ------------------------------------------------------------------------ |
285 | - def __init__(self, q): | |
289 | + def __init__(self, q: QDict) -> None: | |
286 | 290 | super().__init__(q) |
287 | 291 | |
288 | - self.set_defaults({ | |
292 | + self.set_defaults(QDict({ | |
289 | 293 | 'text': '', |
290 | 294 | 'correct': [1.0, -1.0], # will always return false |
291 | - }) | |
295 | + })) | |
292 | 296 | |
293 | 297 | # ------------------------------------------------------------------------ |
294 | 298 | def correct(self) -> None: |
... | ... | @@ -317,16 +321,16 @@ class QuestionTextArea(Question): |
317 | 321 | ''' |
318 | 322 | |
319 | 323 | # ------------------------------------------------------------------------ |
320 | - def __init__(self, q): | |
324 | + def __init__(self, q: QDict) -> None: | |
321 | 325 | super().__init__(q) |
322 | 326 | |
323 | - self.set_defaults({ | |
327 | + self.set_defaults(QDict({ | |
324 | 328 | 'text': '', |
325 | 329 | 'lines': 8, |
326 | 330 | 'timeout': 5, # seconds |
327 | 331 | 'correct': '', # trying to execute this will fail => grade 0.0 |
328 | 332 | 'args': [] |
329 | - }) | |
333 | + })) | |
330 | 334 | |
331 | 335 | self['correct'] = path.join(self['path'], self['correct']) # FIXME |
332 | 336 | |
... | ... | @@ -360,11 +364,11 @@ class QuestionTextArea(Question): |
360 | 364 | # =========================================================================== |
361 | 365 | class QuestionInformation(Question): |
362 | 366 | # ------------------------------------------------------------------------ |
363 | - def __init__(self, q): | |
367 | + def __init__(self, q: QDict) -> None: | |
364 | 368 | super().__init__(q) |
365 | - self.set_defaults({ | |
369 | + self.set_defaults(QDict({ | |
366 | 370 | 'text': '', |
367 | - }) | |
371 | + })) | |
368 | 372 | |
369 | 373 | # ------------------------------------------------------------------------ |
370 | 374 | # can return negative values for wrong answers |
... | ... | @@ -414,14 +418,14 @@ class QFactory(object): |
414 | 418 | 'success': QuestionInformation, |
415 | 419 | } |
416 | 420 | |
417 | - def __init__(self, question_dict={}): | |
421 | + def __init__(self, question_dict: QDict = QDict({})) -> None: | |
418 | 422 | self.question = question_dict |
419 | 423 | |
420 | 424 | # ----------------------------------------------------------------------- |
421 | 425 | # Given a ref returns an instance of a descendent of Question(), |
422 | 426 | # i.e. a question object (radio, checkbox, ...). |
423 | 427 | # ----------------------------------------------------------------------- |
424 | - def generate(self): | |
428 | + def generate(self) -> Question: | |
425 | 429 | logger.debug(f'Generating "{self.question["ref"]}"...') |
426 | 430 | # Shallow copy so that script generated questions will not replace |
427 | 431 | # the original generators |
... | ... | @@ -433,14 +437,14 @@ class QFactory(object): |
433 | 437 | if q['type'] == 'generator': |
434 | 438 | logger.debug(f' \\_ Running "{q["script"]}".') |
435 | 439 | q.setdefault('args', []) |
436 | - q.setdefault('stdin', '') | |
440 | + q.setdefault('stdin', '') # FIXME does not exist anymore? | |
437 | 441 | script = path.join(q['path'], q['script']) |
438 | 442 | out = run_script(script=script, args=q['args'], stdin=q['stdin']) |
439 | 443 | q.update(out) |
440 | 444 | |
441 | 445 | # Finally we create an instance of Question() |
442 | 446 | try: |
443 | - qinstance = self._types[q['type']](q) # instance matching class | |
447 | + qinstance = self._types[q['type']](QDict(q)) # of matching class | |
444 | 448 | except QuestionException as e: |
445 | 449 | logger.error(e) |
446 | 450 | raise e | ... | ... |
aprendizations/serve.py
... | ... | @@ -13,6 +13,7 @@ import signal |
13 | 13 | import functools |
14 | 14 | import ssl |
15 | 15 | import asyncio |
16 | +# from typing import NoReturn | |
16 | 17 | |
17 | 18 | # third party libraries |
18 | 19 | import tornado.ioloop |
... | ... | @@ -187,8 +188,6 @@ class RootHandler(BaseHandler): |
187 | 188 | # FIXME should not change state... |
188 | 189 | # ---------------------------------------------------------------------------- |
189 | 190 | class TopicHandler(BaseHandler): |
190 | - SUPPORTED_METHODS = ['GET'] | |
191 | - | |
192 | 191 | @tornado.web.authenticated |
193 | 192 | async def get(self, topic): |
194 | 193 | uid = self.current_user |
... | ... | @@ -209,8 +208,6 @@ class TopicHandler(BaseHandler): |
209 | 208 | # Serves files from the /public subdir of the topics. |
210 | 209 | # ---------------------------------------------------------------------------- |
211 | 210 | class FileHandler(BaseHandler): |
212 | - SUPPORTED_METHODS = ['GET'] | |
213 | - | |
214 | 211 | @tornado.web.authenticated |
215 | 212 | async def get(self, filename): |
216 | 213 | uid = self.current_user |
... | ... | @@ -238,8 +235,6 @@ class FileHandler(BaseHandler): |
238 | 235 | # respond to AJAX to get a JSON question |
239 | 236 | # ---------------------------------------------------------------------------- |
240 | 237 | class QuestionHandler(BaseHandler): |
241 | - SUPPORTED_METHODS = ['GET', 'POST'] | |
242 | - | |
243 | 238 | templates = { |
244 | 239 | 'checkbox': 'question-checkbox.html', |
245 | 240 | 'radio': 'question-radio.html', | ... | ... |
aprendizations/tools.py
... | ... | @@ -4,6 +4,7 @@ from os import path |
4 | 4 | import subprocess |
5 | 5 | import logging |
6 | 6 | import re |
7 | +from typing import Any, List | |
7 | 8 | |
8 | 9 | # third party libraries |
9 | 10 | import yaml |
... | ... | @@ -123,7 +124,7 @@ class HighlightRenderer(mistune.Renderer): |
123 | 124 | markdown = MarkdownWithMath(HighlightRenderer(escape=True)) |
124 | 125 | |
125 | 126 | |
126 | -def md_to_html(text, strip_p_tag=False, q=None): | |
127 | +def md_to_html(text: str, strip_p_tag: bool = False) -> str: | |
127 | 128 | md = markdown(text) |
128 | 129 | if strip_p_tag and md.startswith('<p>') and md.endswith('</p>'): |
129 | 130 | return md[3:-5] |
... | ... | @@ -134,7 +135,7 @@ def md_to_html(text, strip_p_tag=False, q=None): |
134 | 135 | # --------------------------------------------------------------------------- |
135 | 136 | # load data from yaml file |
136 | 137 | # --------------------------------------------------------------------------- |
137 | -def load_yaml(filename, default=None): | |
138 | +def load_yaml(filename: str, default: Any = None) -> Any: | |
138 | 139 | filename = path.expanduser(filename) |
139 | 140 | try: |
140 | 141 | f = open(filename, 'r', encoding='utf-8') |
... | ... | @@ -149,9 +150,12 @@ def load_yaml(filename, default=None): |
149 | 150 | try: |
150 | 151 | default = yaml.safe_load(f) |
151 | 152 | except yaml.YAMLError as e: |
152 | - mark = e.problem_mark | |
153 | - logger.error(f'In file "{filename}" near line {mark.line}, ' | |
154 | - f'column {mark.column+1}') | |
153 | + if hasattr(e, 'problem_mark'): | |
154 | + mark = e.problem_mark | |
155 | + logger.error(f'File "{filename}" near line {mark.line}, ' | |
156 | + f'column {mark.column+1}') | |
157 | + else: | |
158 | + logger.error(f'File "{filename}"') | |
155 | 159 | finally: |
156 | 160 | return default |
157 | 161 | |
... | ... | @@ -161,7 +165,11 @@ def load_yaml(filename, default=None): |
161 | 165 | # The script is run in another process but this function blocks waiting |
162 | 166 | # for its termination. |
163 | 167 | # --------------------------------------------------------------------------- |
164 | -def run_script(script, args=[], stdin='', timeout=5): | |
168 | +def run_script(script: str, | |
169 | + args: List[str] = [], | |
170 | + stdin: str = '', | |
171 | + timeout: int = 5) -> Any: | |
172 | + | |
165 | 173 | script = path.expanduser(script) |
166 | 174 | try: |
167 | 175 | cmd = [script] + [str(a) for a in args] | ... | ... |
... | ... | @@ -0,0 +1,18 @@ |
1 | +[mypy-sqlalchemy.*] | |
2 | +ignore_missing_imports = True | |
3 | + | |
4 | +[mypy-pygments.*] | |
5 | +ignore_missing_imports = True | |
6 | + | |
7 | +[mypy-networkx.*] | |
8 | +ignore_missing_imports = True | |
9 | + | |
10 | +[mypy-bcrypt.*] | |
11 | +ignore_missing_imports = True | |
12 | + | |
13 | +[mypy-mistune.*] | |
14 | +ignore_missing_imports = True | |
15 | + | |
16 | +[mypy-setuptools.*] | |
17 | +ignore_missing_imports = True | |
18 | + | ... | ... |