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 | + | ... | ... |