Commit a333dc72030ffe2603e289666d469e8cc5e10caa

Authored by Miguel Barão
1 parent 3259fc7c
Exists in master and in 1 other branch dev

Add type annotations mostly in questions.py

.gitignore
... ... @@ -2,4 +2,5 @@
2 2 /aprendizations.egg-info/
3 3 /aprendizations/__pycache__/
4 4 /demo/students.db
5   -/node_modules/
6 5 \ No newline at end of file
  6 +/node_modules/
  7 +/.mypy_cache/
7 8 \ No newline at end of file
... ...
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]
... ...
mypy.ini 0 → 100644
... ... @@ -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 +
... ...