diff --git a/.gitignore b/.gitignore index 0e35d99..3807d39 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /aprendizations.egg-info/ /aprendizations/__pycache__/ /demo/students.db -/node_modules/ \ No newline at end of file +/node_modules/ +/.mypy_cache/ \ No newline at end of file diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index e75c9a7..b1ecfa2 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -6,6 +6,7 @@ from contextlib import contextmanager # `with` statement in db sessions import asyncio from datetime import datetime from random import random +from typing import Dict # third party libraries import bcrypt @@ -75,13 +76,12 @@ class LearnApp(object): errors = 0 for qref in self.factory: logger.debug(f'Checking "{qref}"...') - q = self.factory[qref].generate() try: q = self.factory[qref].generate() except Exception: logger.error(f'Failed to generate "{qref}".') errors += 1 - raise + raise LearnException('Sanity checks') continue if 'tests_right' in q: @@ -89,8 +89,7 @@ class LearnApp(object): q['answer'] = t q.correct() if q['grade'] < 1.0: - logger.error(f'Failed to correct right answer in ' - f'"{qref}".') + logger.error(f'Failed right answer in "{qref}".') errors += 1 continue # to next right test @@ -99,14 +98,13 @@ class LearnApp(object): q['answer'] = t q.correct() if q['grade'] >= 1.0: - logger.error(f'Failed to correct right answer in ' - f'"{qref}".') + logger.error(f'Failed wrong answer in "{qref}".') errors += 1 continue # to next wrong test if errors > 0: logger.info(f'{errors:>6} errors found.') - raise + raise LearnException('Sanity checks') else: logger.info('No errors found.') @@ -315,8 +313,8 @@ class LearnApp(object): for tref, attr in topics.items(): for d in attr.get('deps', []): if d not in g.nodes(): - logger.error(f'Topic "{tref}" depends on "{d}" but the ' - f'latter does not exist') + logger.error(f'Topic "{tref}" depends on "{d}" but it ' + f'does not exist') raise LearnException() else: g.add_edge(d, tref) @@ -345,7 +343,7 @@ class LearnApp(object): # ------------------------------------------------------------------------ # Buils dictionary of question factories # ------------------------------------------------------------------------ - def make_factory(self): + def make_factory(self) -> Dict[str, QFactory]: logger.info('Building questions factory...') factory = {} # {'qref': QFactory()} g = self.deps @@ -382,39 +380,39 @@ class LearnApp(object): return factory # ------------------------------------------------------------------------ - def get_login_counter(self, uid): + def get_login_counter(self, uid: str) -> int: return self.online[uid]['counter'] # ------------------------------------------------------------------------ - def get_student_name(self, uid): + def get_student_name(self, uid: str) -> str: return self.online[uid].get('name', '') # ------------------------------------------------------------------------ - def get_student_state(self, uid): + def get_student_state(self, uid: str): return self.online[uid]['state'].get_knowledge_state() # ------------------------------------------------------------------------ - def get_student_progress(self, uid): + def get_student_progress(self, uid: str): return self.online[uid]['state'].get_topic_progress() # ------------------------------------------------------------------------ - def get_current_question(self, uid): + def get_current_question(self, uid: str): return self.online[uid]['state'].get_current_question() # dict # ------------------------------------------------------------------------ - def get_student_question_type(self, uid): + def get_student_question_type(self, uid: str) -> str: return self.online[uid]['state'].get_current_question()['type'] # ------------------------------------------------------------------------ - def get_student_topic(self, uid): + def get_student_topic(self, uid: str) -> str: return self.online[uid]['state'].get_current_topic() # str # ------------------------------------------------------------------------ - def get_title(self): + def get_title(self) -> str: return self.deps.graph.get('title', '') # FIXME # ------------------------------------------------------------------------ - def get_topic_name(self, ref): + def get_topic_name(self, ref: str) -> str: return self.deps.node[ref]['name'] # ------------------------------------------------------------------------ diff --git a/aprendizations/questions.py b/aprendizations/questions.py index 12688a9..6eb765b 100644 --- a/aprendizations/questions.py +++ b/aprendizations/questions.py @@ -5,6 +5,7 @@ import re from os import path import logging import asyncio +from typing import Any, Dict, NewType # this project from .tools import run_script @@ -13,6 +14,9 @@ from .tools import run_script logger = logging.getLogger(__name__) +QDict = NewType('QDict', Dict[str, Any]) + + class QuestionException(Exception): pass @@ -27,18 +31,18 @@ class Question(dict): to a student. Instances can shuffle options, or automatically generate questions. ''' - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) # add required keys if missing - self.set_defaults({ + self.set_defaults(QDict({ 'title': '', 'answer': None, 'comments': '', 'solution': '', 'files': {}, 'max_tries': 3, - }) + })) def correct(self) -> None: self['comments'] = '' @@ -48,7 +52,7 @@ class Question(dict): loop = asyncio.get_running_loop() await loop.run_in_executor(None, self.correct) - def set_defaults(self, d) -> None: + def set_defaults(self, d: QDict) -> None: 'Add k:v pairs from default dict d for nonexistent keys' for k, v in d.items(): self.setdefault(k, v) @@ -69,18 +73,18 @@ class QuestionRadio(Question): # ------------------------------------------------------------------------ # FIXME marking all options right breaks - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) n = len(self['options']) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, 'max_tries': (n + 3) // 4 # 1 try for each 4 options - }) + })) # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] # correctness levels from 0.0 to 1.0 (no discount here!) @@ -97,7 +101,7 @@ class QuestionRadio(Question): right = [i for i in range(n) if self['correct'][i] >= 1] wrong = [i for i in range(n) if self['correct'][i] < 1] - self.set_defaults({'choose': 1+len(wrong)}) + self.set_defaults(QDict({'choose': 1+len(wrong)})) # try to choose 1 correct option if right: @@ -147,20 +151,20 @@ class QuestionCheckbox(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) n = len(self['options']) # set defaults if missing - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options 'shuffle': True, 'discount': True, 'choose': n, # number of options 'max_tries': max(1, min(n - 1, 3)) - }) + })) if len(self['correct']) != n: msg = f'Options and correct mismatch in "{self["ref"]}"' @@ -218,13 +222,13 @@ class QuestionText(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': [], - }) + })) # make sure its always a list of possible correct answers if not isinstance(self['correct'], list): @@ -251,13 +255,13 @@ class QuestionTextRegex(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': '$.^', # will always return false - }) + })) # ------------------------------------------------------------------------ def correct(self) -> None: @@ -282,13 +286,13 @@ class QuestionNumericInterval(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': [1.0, -1.0], # will always return false - }) + })) # ------------------------------------------------------------------------ def correct(self) -> None: @@ -317,16 +321,16 @@ class QuestionTextArea(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'lines': 8, 'timeout': 5, # seconds 'correct': '', # trying to execute this will fail => grade 0.0 'args': [] - }) + })) self['correct'] = path.join(self['path'], self['correct']) # FIXME @@ -360,11 +364,11 @@ class QuestionTextArea(Question): # =========================================================================== class QuestionInformation(Question): # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', - }) + })) # ------------------------------------------------------------------------ # can return negative values for wrong answers @@ -414,14 +418,14 @@ class QFactory(object): 'success': QuestionInformation, } - def __init__(self, question_dict={}): + def __init__(self, question_dict: QDict = QDict({})) -> None: self.question = question_dict # ----------------------------------------------------------------------- # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- - def generate(self): + def generate(self) -> Question: logger.debug(f'Generating "{self.question["ref"]}"...') # Shallow copy so that script generated questions will not replace # the original generators @@ -433,14 +437,14 @@ class QFactory(object): if q['type'] == 'generator': logger.debug(f' \\_ Running "{q["script"]}".') q.setdefault('args', []) - q.setdefault('stdin', '') + q.setdefault('stdin', '') # FIXME does not exist anymore? script = path.join(q['path'], q['script']) out = run_script(script=script, args=q['args'], stdin=q['stdin']) q.update(out) # Finally we create an instance of Question() try: - qinstance = self._types[q['type']](q) # instance matching class + qinstance = self._types[q['type']](QDict(q)) # of matching class except QuestionException as e: logger.error(e) raise e diff --git a/aprendizations/serve.py b/aprendizations/serve.py index 4a36820..98cbd2e 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -13,6 +13,7 @@ import signal import functools import ssl import asyncio +# from typing import NoReturn # third party libraries import tornado.ioloop @@ -187,8 +188,6 @@ class RootHandler(BaseHandler): # FIXME should not change state... # ---------------------------------------------------------------------------- class TopicHandler(BaseHandler): - SUPPORTED_METHODS = ['GET'] - @tornado.web.authenticated async def get(self, topic): uid = self.current_user @@ -209,8 +208,6 @@ class TopicHandler(BaseHandler): # Serves files from the /public subdir of the topics. # ---------------------------------------------------------------------------- class FileHandler(BaseHandler): - SUPPORTED_METHODS = ['GET'] - @tornado.web.authenticated async def get(self, filename): uid = self.current_user @@ -238,8 +235,6 @@ class FileHandler(BaseHandler): # respond to AJAX to get a JSON question # ---------------------------------------------------------------------------- class QuestionHandler(BaseHandler): - SUPPORTED_METHODS = ['GET', 'POST'] - templates = { 'checkbox': 'question-checkbox.html', 'radio': 'question-radio.html', diff --git a/aprendizations/tools.py b/aprendizations/tools.py index 4c42a0d..d08d1c1 100644 --- a/aprendizations/tools.py +++ b/aprendizations/tools.py @@ -4,6 +4,7 @@ from os import path import subprocess import logging import re +from typing import Any, List # third party libraries import yaml @@ -123,7 +124,7 @@ class HighlightRenderer(mistune.Renderer): markdown = MarkdownWithMath(HighlightRenderer(escape=True)) -def md_to_html(text, strip_p_tag=False, q=None): +def md_to_html(text: str, strip_p_tag: bool = False) -> str: md = markdown(text) if strip_p_tag and md.startswith('

') and md.endswith('

'): return md[3:-5] @@ -134,7 +135,7 @@ def md_to_html(text, strip_p_tag=False, q=None): # --------------------------------------------------------------------------- # load data from yaml file # --------------------------------------------------------------------------- -def load_yaml(filename, default=None): +def load_yaml(filename: str, default: Any = None) -> Any: filename = path.expanduser(filename) try: f = open(filename, 'r', encoding='utf-8') @@ -149,9 +150,12 @@ def load_yaml(filename, default=None): try: default = yaml.safe_load(f) except yaml.YAMLError as e: - mark = e.problem_mark - logger.error(f'In file "{filename}" near line {mark.line}, ' - f'column {mark.column+1}') + if hasattr(e, 'problem_mark'): + mark = e.problem_mark + logger.error(f'File "{filename}" near line {mark.line}, ' + f'column {mark.column+1}') + else: + logger.error(f'File "{filename}"') finally: return default @@ -161,7 +165,11 @@ def load_yaml(filename, default=None): # The script is run in another process but this function blocks waiting # for its termination. # --------------------------------------------------------------------------- -def run_script(script, args=[], stdin='', timeout=5): +def run_script(script: str, + args: List[str] = [], + stdin: str = '', + timeout: int = 5) -> Any: + script = path.expanduser(script) try: cmd = [script] + [str(a) for a in args] diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..4a0325d --- /dev/null +++ b/mypy.ini @@ -0,0 +1,18 @@ +[mypy-sqlalchemy.*] +ignore_missing_imports = True + +[mypy-pygments.*] +ignore_missing_imports = True + +[mypy-networkx.*] +ignore_missing_imports = True + +[mypy-bcrypt.*] +ignore_missing_imports = True + +[mypy-mistune.*] +ignore_missing_imports = True + +[mypy-setuptools.*] +ignore_missing_imports = True + -- libgit2 0.21.2