Commit 8431c74d848717e67ca6dbed2bc2ffc88aa9beff
1 parent
92211444
Exists in
master
and in
1 other branch
updates questions to match aprendizations.
moves json save to app (instead of test). moves markdown code to new file parser_markdown.py. new function run_script_async in tools.
Showing
7 changed files
with
340 additions
and
251 deletions
Show diff stats
perguntations/app.py
| ... | ... | @@ -7,6 +7,7 @@ import asyncio |
| 7 | 7 | |
| 8 | 8 | # user installed packages |
| 9 | 9 | import bcrypt |
| 10 | +import json | |
| 10 | 11 | from sqlalchemy import create_engine |
| 11 | 12 | from sqlalchemy.orm import sessionmaker |
| 12 | 13 | |
| ... | ... | @@ -142,11 +143,11 @@ class App(object): |
| 142 | 143 | # ----------------------------------------------------------------------- |
| 143 | 144 | async def generate_test(self, uid): |
| 144 | 145 | if uid in self.online: |
| 145 | - logger.info(f'Student {uid}: started generating new test.') | |
| 146 | + logger.info(f'Student {uid}: generating new test.') | |
| 146 | 147 | student_id = self.online[uid]['student'] # {number, name} |
| 147 | 148 | test = await self.testfactory.generate(student_id) |
| 148 | 149 | self.online[uid]['test'] = test |
| 149 | - logger.debug(f'Student {uid}: finished generating test.') | |
| 150 | + logger.debug(f'Student {uid}: test is ready.') | |
| 150 | 151 | return self.online[uid]['test'] |
| 151 | 152 | else: |
| 152 | 153 | # this implies an error in the code. should never be here! |
| ... | ... | @@ -164,7 +165,10 @@ class App(object): |
| 164 | 165 | fields = (t['student']['number'], t['ref'], str(t['finish_time'])) |
| 165 | 166 | fname = ' -- '.join(fields) + '.json' |
| 166 | 167 | fpath = path.join(t['answers_dir'], fname) |
| 167 | - t.save_json(fpath) | |
| 168 | + with open(path.expanduser(fpath), 'w') as f: | |
| 169 | + # default=str required for datetime objects: | |
| 170 | + json.dump(t, f, indent=2, default=str) | |
| 171 | + logger.info(f'Student {t["student"]["number"]}: saved JSON file.') | |
| 168 | 172 | |
| 169 | 173 | # insert test and questions into database |
| 170 | 174 | with self.db_session() as s: |
| ... | ... | @@ -186,7 +190,7 @@ class App(object): |
| 186 | 190 | student_id=t['student']['number'], |
| 187 | 191 | test_id=t['ref']) for q in t['questions'] if 'grade' in q]) |
| 188 | 192 | |
| 189 | - logger.info(f'Student {uid}: finished test.') | |
| 193 | + logger.info(f'Student {uid}: finished test, grade = {grade}.') | |
| 190 | 194 | return grade |
| 191 | 195 | |
| 192 | 196 | # ----------------------------------------------------------------------- | ... | ... |
perguntations/factory.py
| ... | ... | @@ -145,7 +145,7 @@ class QuestionFactory(dict): |
| 145 | 145 | if q['type'] == 'generator': |
| 146 | 146 | logger.debug(f'Generating "{ref}" from {q["script"]}') |
| 147 | 147 | q.setdefault('args', []) # optional arguments |
| 148 | - q.setdefault('stdin', '') | |
| 148 | + q.setdefault('stdin', '') # FIXME necessary? | |
| 149 | 149 | script = path.join(q['path'], q['script']) |
| 150 | 150 | out = run_script(script=script, args=q['args'], stdin=q['stdin']) |
| 151 | 151 | try: | ... | ... |
| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | +# python standard library | |
| 2 | +import logging | |
| 3 | +import re | |
| 4 | + | |
| 5 | +# third party libraries | |
| 6 | +import mistune | |
| 7 | +from pygments import highlight | |
| 8 | +from pygments.lexers import get_lexer_by_name | |
| 9 | +from pygments.formatters import HtmlFormatter | |
| 10 | + | |
| 11 | + | |
| 12 | +# setup logger for this module | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +# ------------------------------------------------------------------------- | |
| 17 | +# Markdown to HTML renderer with support for LaTeX equations | |
| 18 | +# Inline math: $x$ | |
| 19 | +# Block math: $$x$$ or \begin{equation}x\end{equation} | |
| 20 | +# ------------------------------------------------------------------------- | |
| 21 | +class MathBlockGrammar(mistune.BlockGrammar): | |
| 22 | + block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) | |
| 23 | + latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", | |
| 24 | + re.DOTALL) | |
| 25 | + | |
| 26 | + | |
| 27 | +class MathBlockLexer(mistune.BlockLexer): | |
| 28 | + default_rules = ['block_math', 'latex_environment'] \ | |
| 29 | + + mistune.BlockLexer.default_rules | |
| 30 | + | |
| 31 | + def __init__(self, rules=None, **kwargs): | |
| 32 | + if rules is None: | |
| 33 | + rules = MathBlockGrammar() | |
| 34 | + super().__init__(rules, **kwargs) | |
| 35 | + | |
| 36 | + def parse_block_math(self, m): | |
| 37 | + """Parse a $$math$$ block""" | |
| 38 | + self.tokens.append({ | |
| 39 | + 'type': 'block_math', | |
| 40 | + 'text': m.group(1) | |
| 41 | + }) | |
| 42 | + | |
| 43 | + def parse_latex_environment(self, m): | |
| 44 | + self.tokens.append({ | |
| 45 | + 'type': 'latex_environment', | |
| 46 | + 'name': m.group(1), | |
| 47 | + 'text': m.group(2) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + | |
| 51 | +class MathInlineGrammar(mistune.InlineGrammar): | |
| 52 | + math = re.compile(r"^\$(.+?)\$", re.DOTALL) | |
| 53 | + block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) | |
| 54 | + text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)') | |
| 55 | + | |
| 56 | + | |
| 57 | +class MathInlineLexer(mistune.InlineLexer): | |
| 58 | + default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules | |
| 59 | + | |
| 60 | + def __init__(self, renderer, rules=None, **kwargs): | |
| 61 | + if rules is None: | |
| 62 | + rules = MathInlineGrammar() | |
| 63 | + super().__init__(renderer, rules, **kwargs) | |
| 64 | + | |
| 65 | + def output_math(self, m): | |
| 66 | + return self.renderer.inline_math(m.group(1)) | |
| 67 | + | |
| 68 | + def output_block_math(self, m): | |
| 69 | + return self.renderer.block_math(m.group(1)) | |
| 70 | + | |
| 71 | + | |
| 72 | +class MarkdownWithMath(mistune.Markdown): | |
| 73 | + def __init__(self, renderer, **kwargs): | |
| 74 | + if 'inline' not in kwargs: | |
| 75 | + kwargs['inline'] = MathInlineLexer | |
| 76 | + if 'block' not in kwargs: | |
| 77 | + kwargs['block'] = MathBlockLexer | |
| 78 | + super().__init__(renderer, **kwargs) | |
| 79 | + | |
| 80 | + def output_block_math(self): | |
| 81 | + return self.renderer.block_math(self.token['text']) | |
| 82 | + | |
| 83 | + def output_latex_environment(self): | |
| 84 | + return self.renderer.latex_environment(self.token['name'], | |
| 85 | + self.token['text']) | |
| 86 | + | |
| 87 | + | |
| 88 | +class HighlightRenderer(mistune.Renderer): | |
| 89 | + def __init__(self, qref='.'): | |
| 90 | + super().__init__(escape=True) | |
| 91 | + self.qref = qref | |
| 92 | + | |
| 93 | + def block_code(self, code, lang='text'): | |
| 94 | + try: | |
| 95 | + lexer = get_lexer_by_name(lang, stripall=False) | |
| 96 | + except Exception: | |
| 97 | + lexer = get_lexer_by_name('text', stripall=False) | |
| 98 | + | |
| 99 | + formatter = HtmlFormatter() | |
| 100 | + return highlight(code, lexer, formatter) | |
| 101 | + | |
| 102 | + def table(self, header, body): | |
| 103 | + return '<table class="table table-sm"><thead class="thead-light">' \ | |
| 104 | + + header + '</thead><tbody>' + body + '</tbody></table>' | |
| 105 | + | |
| 106 | + def image(self, src, title, alt): | |
| 107 | + alt = mistune.escape(alt, quote=True) | |
| 108 | + if title is not None: | |
| 109 | + if title: # not empty string, show as caption | |
| 110 | + title = mistune.escape(title, quote=True) | |
| 111 | + caption = f'<figcaption class="figure-caption">{title}' \ | |
| 112 | + '</figcaption>' | |
| 113 | + else: # title is an empty string, show as centered figure | |
| 114 | + caption = '' | |
| 115 | + | |
| 116 | + return f''' | |
| 117 | + <div class="text-center"> | |
| 118 | + <figure class="figure"> | |
| 119 | + <img src="/file?ref={self.qref}&image={src}" | |
| 120 | + class="figure-img img-fluid rounded" | |
| 121 | + alt="{alt}" title="{title}"> | |
| 122 | + {caption} | |
| 123 | + </figure> | |
| 124 | + </div> | |
| 125 | + ''' | |
| 126 | + | |
| 127 | + else: # title indefined, show as inline image | |
| 128 | + return f''' | |
| 129 | + <img src="/file?ref={self.qref}&image={src}" | |
| 130 | + class="figure-img img-fluid" alt="{alt}" title="{title}"> | |
| 131 | + ''' | |
| 132 | + | |
| 133 | + # Pass math through unaltered - mathjax does the rendering in the browser | |
| 134 | + def block_math(self, text): | |
| 135 | + return fr'$$ {text} $$' | |
| 136 | + | |
| 137 | + def latex_environment(self, name, text): | |
| 138 | + return fr'\begin{{{name}}} {text} \end{{{name}}}' | |
| 139 | + | |
| 140 | + def inline_math(self, text): | |
| 141 | + return fr'$$$ {text} $$$' | |
| 142 | + | |
| 143 | + | |
| 144 | +def md_to_html(qref='.'): | |
| 145 | + return MarkdownWithMath(HighlightRenderer(qref=qref)) | ... | ... |
perguntations/questions.py
| ... | ... | @@ -4,21 +4,21 @@ import random |
| 4 | 4 | import re |
| 5 | 5 | from os import path |
| 6 | 6 | import logging |
| 7 | -import asyncio | |
| 8 | - | |
| 9 | -# user installed libraries | |
| 10 | -import yaml | |
| 7 | +from typing import Any, Dict, NewType | |
| 8 | +# import uuid | |
| 11 | 9 | |
| 12 | 10 | # this project |
| 13 | -from perguntations.tools import run_script | |
| 11 | +from perguntations.tools import run_script, run_script_async | |
| 12 | + | |
| 13 | +# setup logger for this module | |
| 14 | +logger = logging.getLogger(__name__) | |
| 14 | 15 | |
| 15 | 16 | |
| 16 | -# regular expressions in yaml files, e.g. correct: !regex '[aA]zul' | |
| 17 | -yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) | |
| 17 | +QDict = NewType('QDict', Dict[str, Any]) | |
| 18 | 18 | |
| 19 | 19 | |
| 20 | -# setup logger for this module | |
| 21 | -logger = logging.getLogger(__name__) | |
| 20 | +class QuestionException(Exception): | |
| 21 | + pass | |
| 22 | 22 | |
| 23 | 23 | |
| 24 | 24 | # =========================================================================== |
| ... | ... | @@ -27,35 +27,31 @@ logger = logging.getLogger(__name__) |
| 27 | 27 | # =========================================================================== |
| 28 | 28 | class Question(dict): |
| 29 | 29 | ''' |
| 30 | - Classes derived from this base class are meant to instantiate a question | |
| 31 | - to a student. | |
| 32 | - Instances can shuffle options, or automatically generate questions. | |
| 30 | + Classes derived from this base class are meant to instantiate questions | |
| 31 | + for each student. | |
| 32 | + Instances can shuffle options or automatically generate questions. | |
| 33 | 33 | ''' |
| 34 | - def __init__(self, q): | |
| 34 | + def __init__(self, q: QDict) -> None: | |
| 35 | 35 | super().__init__(q) |
| 36 | 36 | |
| 37 | - # add these if missing | |
| 38 | - self.set_defaults({ | |
| 37 | + # add required keys if missing | |
| 38 | + self.set_defaults(QDict({ | |
| 39 | 39 | 'title': '', |
| 40 | 40 | 'answer': None, |
| 41 | 41 | 'comments': '', |
| 42 | + 'solution': '', | |
| 42 | 43 | 'files': {}, |
| 43 | - }) | |
| 44 | - | |
| 45 | - # FIXME unused. do childs need do override this? | |
| 46 | - # def updateAnswer(answer=None): | |
| 47 | - # self['answer'] = answer | |
| 44 | + 'max_tries': 3, | |
| 45 | + })) | |
| 48 | 46 | |
| 49 | - def correct(self): | |
| 47 | + def correct(self) -> None: | |
| 48 | + self['comments'] = '' | |
| 50 | 49 | self['grade'] = 0.0 |
| 51 | - return 0.0 | |
| 52 | 50 | |
| 53 | - async def correct_async(self): | |
| 54 | - loop = asyncio.get_running_loop() | |
| 55 | - grade = await loop.run_in_executor(None, self.correct) | |
| 56 | - return grade | |
| 51 | + async def correct_async(self) -> None: | |
| 52 | + self.correct() | |
| 57 | 53 | |
| 58 | - def set_defaults(self, d): | |
| 54 | + def set_defaults(self, d: QDict) -> None: | |
| 59 | 55 | 'Add k:v pairs from default dict d for nonexistent keys' |
| 60 | 56 | for k, v in d.items(): |
| 61 | 57 | self.setdefault(k, v) |
| ... | ... | @@ -75,55 +71,61 @@ class QuestionRadio(Question): |
| 75 | 71 | ''' |
| 76 | 72 | |
| 77 | 73 | # ------------------------------------------------------------------------ |
| 78 | - def __init__(self, q): | |
| 74 | + def __init__(self, q: QDict) -> None: | |
| 79 | 75 | super().__init__(q) |
| 80 | 76 | |
| 81 | 77 | n = len(self['options']) |
| 82 | 78 | |
| 83 | 79 | # set defaults if missing |
| 84 | - self.set_defaults({ | |
| 80 | + self.set_defaults(QDict({ | |
| 85 | 81 | 'text': '', |
| 86 | 82 | 'correct': 0, |
| 87 | 83 | 'shuffle': True, |
| 88 | 84 | 'discount': True, |
| 89 | - }) | |
| 85 | + })) | |
| 90 | 86 | |
| 91 | - # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] | |
| 87 | + # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] | |
| 92 | 88 | # correctness levels from 0.0 to 1.0 (no discount here!) |
| 93 | 89 | if isinstance(self['correct'], int): |
| 94 | 90 | self['correct'] = [1.0 if x == self['correct'] else 0.0 |
| 95 | 91 | for x in range(n)] |
| 96 | 92 | |
| 97 | 93 | if len(self['correct']) != n: |
| 98 | - logger.error(f'Options and correct mismatch in ' | |
| 99 | - f'"{self["ref"]}", file "{self["filename"]}".') | |
| 94 | + msg = (f'Options and correct mismatch in ' | |
| 95 | + f'"{self["ref"]}", file "{self["filename"]}".') | |
| 96 | + logger.error(msg) | |
| 97 | + raise QuestionException(msg) | |
| 100 | 98 | |
| 101 | 99 | if self['shuffle']: |
| 102 | 100 | # separate right from wrong options |
| 103 | - right = [i for i in range(n) if self['correct'][i] == 1] | |
| 101 | + right = [i for i in range(n) if self['correct'][i] >= 1] | |
| 104 | 102 | wrong = [i for i in range(n) if self['correct'][i] < 1] |
| 105 | 103 | |
| 106 | 104 | self.set_defaults({'choose': 1+len(wrong)}) |
| 107 | 105 | |
| 108 | - # choose 1 correct option | |
| 109 | - r = random.choice(right) | |
| 110 | - options = [self['options'][r]] | |
| 111 | - correct = [1.0] | |
| 106 | + # try to choose 1 correct option | |
| 107 | + if right: | |
| 108 | + r = random.choice(right) | |
| 109 | + options = [self['options'][r]] | |
| 110 | + correct = [self['correct'][r]] | |
| 111 | + else: | |
| 112 | + options = [] | |
| 113 | + correct = [] | |
| 112 | 114 | |
| 113 | 115 | # choose remaining wrong options |
| 114 | - random.shuffle(wrong) | |
| 115 | - nwrong = self['choose']-1 | |
| 116 | - options.extend(self['options'][i] for i in wrong[:nwrong]) | |
| 117 | - correct.extend(self['correct'][i] for i in wrong[:nwrong]) | |
| 116 | + nwrong = self['choose'] - len(correct) | |
| 117 | + wrongsample = random.sample(wrong, k=nwrong) | |
| 118 | + options += [self['options'][i] for i in wrongsample] | |
| 119 | + correct += [self['correct'][i] for i in wrongsample] | |
| 118 | 120 | |
| 119 | 121 | # final shuffle of the options |
| 120 | - perm = random.sample(range(self['choose']), self['choose']) | |
| 122 | + perm = random.sample(range(self['choose']), k=self['choose']) | |
| 121 | 123 | self['options'] = [str(options[i]) for i in perm] |
| 122 | 124 | self['correct'] = [float(correct[i]) for i in perm] |
| 123 | 125 | |
| 124 | 126 | # ------------------------------------------------------------------------ |
| 125 | 127 | # can return negative values for wrong answers |
| 126 | - def correct(self): | |
| 128 | + def correct(self) -> None: | |
| 127 | 129 | super().correct() |
| 128 | 130 | |
| 129 | 131 | if self['answer'] is not None: |
| ... | ... | @@ -134,8 +136,6 @@ class QuestionRadio(Question): |
| 134 | 136 | x = (x - x_aver) / (1.0 - x_aver) |
| 135 | 137 | self['grade'] = x |
| 136 | 138 | |
| 137 | - return self['grade'] | |
| 138 | - | |
| 139 | 139 | |
| 140 | 140 | # =========================================================================== |
| 141 | 141 | class QuestionCheckbox(Question): |
| ... | ... | @@ -151,7 +151,7 @@ class QuestionCheckbox(Question): |
| 151 | 151 | ''' |
| 152 | 152 | |
| 153 | 153 | # ------------------------------------------------------------------------ |
| 154 | - def __init__(self, q): | |
| 154 | + def __init__(self, q: QDict) -> None: | |
| 155 | 155 | super().__init__(q) |
| 156 | 156 | |
| 157 | 157 | n = len(self['options']) |
| ... | ... | @@ -166,8 +166,10 @@ class QuestionCheckbox(Question): |
| 166 | 166 | }) |
| 167 | 167 | |
| 168 | 168 | if len(self['correct']) != n: |
| 169 | - logger.error(f'Options and correct size mismatch in ' | |
| 170 | - f'"{self["ref"]}", file "{self["filename"]}".') | |
| 169 | + msg = (f'Options and correct size mismatch in ' | |
| 170 | + f'"{self["ref"]}", file "{self["filename"]}".') | |
| 171 | + logger.error(msg) | |
| 172 | + raise QuestionException(msg) | |
| 171 | 173 | |
| 172 | 174 | # if an option is a list of (right, wrong), pick one |
| 173 | 175 | # FIXME it's possible that all options are chosen wrong |
| ... | ... | @@ -190,7 +192,7 @@ class QuestionCheckbox(Question): |
| 190 | 192 | |
| 191 | 193 | # ------------------------------------------------------------------------ |
| 192 | 194 | # can return negative values for wrong answers |
| 193 | - def correct(self): | |
| 195 | + def correct(self) -> None: | |
| 194 | 196 | super().correct() |
| 195 | 197 | |
| 196 | 198 | if self['answer'] is not None: |
| ... | ... | @@ -210,8 +212,6 @@ class QuestionCheckbox(Question): |
| 210 | 212 | |
| 211 | 213 | self['grade'] = x / sum_abs |
| 212 | 214 | |
| 213 | - return self['grade'] | |
| 214 | - | |
| 215 | 215 | |
| 216 | 216 | # ============================================================================ |
| 217 | 217 | class QuestionText(Question): |
| ... | ... | @@ -223,7 +223,7 @@ class QuestionText(Question): |
| 223 | 223 | ''' |
| 224 | 224 | |
| 225 | 225 | # ------------------------------------------------------------------------ |
| 226 | - def __init__(self, q): | |
| 226 | + def __init__(self, q: QDict) -> None: | |
| 227 | 227 | super().__init__(q) |
| 228 | 228 | |
| 229 | 229 | self.set_defaults({ |
| ... | ... | @@ -240,14 +240,12 @@ class QuestionText(Question): |
| 240 | 240 | |
| 241 | 241 | # ------------------------------------------------------------------------ |
| 242 | 242 | # can return negative values for wrong answers |
| 243 | - def correct(self): | |
| 243 | + def correct(self) -> None: | |
| 244 | 244 | super().correct() |
| 245 | 245 | |
| 246 | 246 | if self['answer'] is not None: |
| 247 | 247 | self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 |
| 248 | 248 | |
| 249 | - return self['grade'] | |
| 250 | - | |
| 251 | 249 | |
| 252 | 250 | # =========================================================================== |
| 253 | 251 | class QuestionTextRegex(Question): |
| ... | ... | @@ -259,7 +257,7 @@ class QuestionTextRegex(Question): |
| 259 | 257 | ''' |
| 260 | 258 | |
| 261 | 259 | # ------------------------------------------------------------------------ |
| 262 | - def __init__(self, q): | |
| 260 | + def __init__(self, q: QDict) -> None: | |
| 263 | 261 | super().__init__(q) |
| 264 | 262 | |
| 265 | 263 | self.set_defaults({ |
| ... | ... | @@ -269,17 +267,15 @@ class QuestionTextRegex(Question): |
| 269 | 267 | |
| 270 | 268 | # ------------------------------------------------------------------------ |
| 271 | 269 | # can return negative values for wrong answers |
| 272 | - def correct(self): | |
| 270 | + def correct(self) -> None: | |
| 273 | 271 | super().correct() |
| 274 | 272 | if self['answer'] is not None: |
| 275 | 273 | try: |
| 276 | - self['grade'] = 1.0 if re.match(self['correct'], | |
| 277 | - self['answer']) else 0.0 | |
| 274 | + ok = re.match(self['correct'], self['answer']) | |
| 278 | 275 | except TypeError: |
| 279 | - logger.error('While matching regex {self["correct"]} with ' | |
| 280 | - 'answer {self["answer"]}.') | |
| 281 | - | |
| 282 | - return self['grade'] | |
| 276 | + logger.error(f'While matching regex {self["correct"]} with ' | |
| 277 | + f'answer {self["answer"]}.') | |
| 278 | + self['grade'] = 1.0 if ok else 0.0 | |
| 283 | 279 | |
| 284 | 280 | |
| 285 | 281 | # =========================================================================== |
| ... | ... | @@ -293,17 +289,17 @@ class QuestionNumericInterval(Question): |
| 293 | 289 | ''' |
| 294 | 290 | |
| 295 | 291 | # ------------------------------------------------------------------------ |
| 296 | - def __init__(self, q): | |
| 292 | + def __init__(self, q: QDict) -> None: | |
| 297 | 293 | super().__init__(q) |
| 298 | 294 | |
| 299 | - self.set_defaults({ | |
| 295 | + self.set_defaults(QDict({ | |
| 300 | 296 | 'text': '', |
| 301 | 297 | 'correct': [1.0, -1.0], # will always return false |
| 302 | - }) | |
| 298 | + })) | |
| 303 | 299 | |
| 304 | 300 | # ------------------------------------------------------------------------ |
| 305 | 301 | # can return negative values for wrong answers |
| 306 | - def correct(self): | |
| 302 | + def correct(self) -> None: | |
| 307 | 303 | super().correct() |
| 308 | 304 | if self['answer'] is not None: |
| 309 | 305 | lower, upper = self['correct'] |
| ... | ... | @@ -316,8 +312,6 @@ class QuestionNumericInterval(Question): |
| 316 | 312 | else: |
| 317 | 313 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
| 318 | 314 | |
| 319 | - return self['grade'] | |
| 320 | - | |
| 321 | 315 | |
| 322 | 316 | # =========================================================================== |
| 323 | 317 | class QuestionTextArea(Question): |
| ... | ... | @@ -326,16 +320,14 @@ class QuestionTextArea(Question): |
| 326 | 320 | text (str) |
| 327 | 321 | correct (str with script to run) |
| 328 | 322 | answer (None or an actual answer) |
| 329 | - lines (int) | |
| 330 | 323 | ''' |
| 331 | 324 | |
| 332 | 325 | # ------------------------------------------------------------------------ |
| 333 | - def __init__(self, q): | |
| 326 | + def __init__(self, q: QDict) -> None: | |
| 334 | 327 | super().__init__(q) |
| 335 | 328 | |
| 336 | 329 | self.set_defaults({ |
| 337 | 330 | 'text': '', |
| 338 | - 'lines': 8, # FIXME not being used??? | |
| 339 | 331 | 'timeout': 5, # seconds |
| 340 | 332 | 'correct': '', # trying to execute this will fail => grade 0.0 |
| 341 | 333 | 'args': [] |
| ... | ... | @@ -344,33 +336,62 @@ class QuestionTextArea(Question): |
| 344 | 336 | self['correct'] = path.join(self['path'], self['correct']) |
| 345 | 337 | |
| 346 | 338 | # ------------------------------------------------------------------------ |
| 347 | - # can return negative values for wrong answers | |
| 348 | - def correct(self): | |
| 339 | + def correct(self) -> None: | |
| 349 | 340 | super().correct() |
| 350 | 341 | |
| 351 | - if self['answer'] is not None: | |
| 352 | - out = run_script( # and parse yaml ouput | |
| 342 | + if self['answer'] is not None: # correct answer and parse yaml ouput | |
| 343 | + out = run_script( | |
| 353 | 344 | script=self['correct'], |
| 354 | 345 | args=self['args'], |
| 355 | 346 | stdin=self['answer'], |
| 356 | 347 | timeout=self['timeout'] |
| 357 | 348 | ) |
| 358 | 349 | |
| 359 | - if isinstance(out, dict): | |
| 350 | + if out is None: | |
| 351 | + logger.warning(f'No grade after running "{self["correct"]}".') | |
| 352 | + self['grade'] = 0.0 | |
| 353 | + elif isinstance(out, dict): | |
| 360 | 354 | self['comments'] = out.get('comments', '') |
| 361 | 355 | try: |
| 362 | 356 | self['grade'] = float(out['grade']) |
| 363 | 357 | except ValueError: |
| 364 | - logger.error(f'Grade value error in "{self["correct"]}".') | |
| 358 | + logger.error(f'Output error in "{self["correct"]}".') | |
| 365 | 359 | except KeyError: |
| 366 | 360 | logger.error(f'No grade in "{self["correct"]}".') |
| 367 | 361 | else: |
| 368 | 362 | try: |
| 369 | 363 | self['grade'] = float(out) |
| 370 | 364 | except (TypeError, ValueError): |
| 371 | - logger.error(f'Grade value error in "{self["correct"]}".') | |
| 365 | + logger.error(f'Invalid grade in "{self["correct"]}".') | |
| 372 | 366 | |
| 373 | - return self['grade'] | |
| 367 | + # ------------------------------------------------------------------------ | |
| 368 | + async def correct_async(self) -> None: | |
| 369 | + super().correct() | |
| 370 | + | |
| 371 | + if self['answer'] is not None: # correct answer and parse yaml ouput | |
| 372 | + out = await run_script_async( | |
| 373 | + script=self['correct'], | |
| 374 | + args=self['args'], | |
| 375 | + stdin=self['answer'], | |
| 376 | + timeout=self['timeout'] | |
| 377 | + ) | |
| 378 | + | |
| 379 | + if out is None: | |
| 380 | + logger.warning(f'No grade after running "{self["correct"]}".') | |
| 381 | + self['grade'] = 0.0 | |
| 382 | + elif isinstance(out, dict): | |
| 383 | + self['comments'] = out.get('comments', '') | |
| 384 | + try: | |
| 385 | + self['grade'] = float(out['grade']) | |
| 386 | + except ValueError: | |
| 387 | + logger.error(f'Output error in "{self["correct"]}".') | |
| 388 | + except KeyError: | |
| 389 | + logger.error(f'No grade in "{self["correct"]}".') | |
| 390 | + else: | |
| 391 | + try: | |
| 392 | + self['grade'] = float(out) | |
| 393 | + except (TypeError, ValueError): | |
| 394 | + logger.error(f'Invalid grade in "{self["correct"]}".') | |
| 374 | 395 | |
| 375 | 396 | |
| 376 | 397 | # =========================================================================== |
| ... | ... | @@ -381,15 +402,13 @@ class QuestionInformation(Question): |
| 381 | 402 | points (0.0) |
| 382 | 403 | ''' |
| 383 | 404 | # ------------------------------------------------------------------------ |
| 384 | - def __init__(self, q): | |
| 405 | + def __init__(self, q: QDict) -> None: | |
| 385 | 406 | super().__init__(q) |
| 386 | - self.set_defaults({ | |
| 407 | + self.set_defaults(QDict({ | |
| 387 | 408 | 'text': '', |
| 388 | - }) | |
| 409 | + })) | |
| 389 | 410 | |
| 390 | 411 | # ------------------------------------------------------------------------ |
| 391 | - # can return negative values for wrong answers | |
| 392 | - def correct(self): | |
| 412 | + def correct(self) -> None: | |
| 393 | 413 | super().correct() |
| 394 | 414 | self['grade'] = 1.0 # always "correct" but points should be zero! |
| 395 | - return self['grade'] | ... | ... |
perguntations/serve.py
| ... | ... | @@ -21,8 +21,9 @@ import tornado.httpserver |
| 21 | 21 | |
| 22 | 22 | # this project |
| 23 | 23 | from perguntations.app import App, AppException |
| 24 | -from perguntations.tools import load_yaml, md_to_html | |
| 24 | +from perguntations.tools import load_yaml | |
| 25 | 25 | from perguntations import APP_NAME |
| 26 | +from perguntations.parser_markdown import md_to_html | |
| 26 | 27 | |
| 27 | 28 | |
| 28 | 29 | # ---------------------------------------------------------------------------- | ... | ... |
perguntations/test.py
| ... | ... | @@ -4,7 +4,6 @@ from os import path, listdir |
| 4 | 4 | import fnmatch |
| 5 | 5 | import random |
| 6 | 6 | from datetime import datetime |
| 7 | -import json | |
| 8 | 7 | import logging |
| 9 | 8 | import asyncio |
| 10 | 9 | |
| ... | ... | @@ -257,9 +256,10 @@ class Test(dict): |
| 257 | 256 | self['state'] = 'FINISHED' |
| 258 | 257 | grade = 0.0 |
| 259 | 258 | for q in self['questions']: |
| 260 | - g = await q.correct_async() | |
| 261 | - grade += g * q['points'] | |
| 262 | - logger.debug(f'Correcting "{q["ref"]}": {g*100.0} %') | |
| 259 | + await q.correct_async() | |
| 260 | + | |
| 261 | + grade += q['grade'] * q['points'] | |
| 262 | + logger.debug(f'Correcting "{q["ref"]}": {q["grade"]*100.0} %') | |
| 263 | 263 | |
| 264 | 264 | self['grade'] = max(0, round(grade, 1)) # avoid negative grades |
| 265 | 265 | logger.info(f'Student {self["student"]["number"]}: ' |
| ... | ... | @@ -273,10 +273,3 @@ class Test(dict): |
| 273 | 273 | self['grade'] = 0.0 |
| 274 | 274 | logger.info(f'Student {self["student"]["number"]}: gave up.') |
| 275 | 275 | return self['grade'] |
| 276 | - | |
| 277 | - # ----------------------------------------------------------------------- | |
| 278 | - def save_json(self, filepath): | |
| 279 | - with open(path.expanduser(filepath), 'w') as f: | |
| 280 | - # default=str required for datetime objects: | |
| 281 | - json.dump(self, f, indent=2, default=str) | |
| 282 | - logger.info(f'Student {self["student"]["number"]}: saved JSON file.') | ... | ... |
perguntations/tools.py
| 1 | 1 | |
| 2 | 2 | # python standard library |
| 3 | +import asyncio | |
| 4 | +import logging | |
| 3 | 5 | from os import path |
| 4 | 6 | import subprocess |
| 5 | -import logging | |
| 6 | -import re | |
| 7 | +from typing import Any, List | |
| 7 | 8 | |
| 8 | -# user installed libraries | |
| 9 | +# third party libraries | |
| 9 | 10 | import yaml |
| 10 | -import mistune | |
| 11 | -from pygments import highlight | |
| 12 | -from pygments.lexers import get_lexer_by_name | |
| 13 | -from pygments.formatters import HtmlFormatter | |
| 11 | + | |
| 14 | 12 | |
| 15 | 13 | # setup logger for this module |
| 16 | 14 | logger = logging.getLogger(__name__) |
| 17 | 15 | |
| 18 | 16 | |
| 19 | -# ------------------------------------------------------------------------- | |
| 20 | -# Markdown to HTML renderer with support for LaTeX equations | |
| 21 | -# Inline math: $x$ | |
| 22 | -# Block math: $$x$$ or \begin{equation}x\end{equation} | |
| 23 | -# ------------------------------------------------------------------------- | |
| 24 | -class MathBlockGrammar(mistune.BlockGrammar): | |
| 25 | - block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) | |
| 26 | - latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) | |
| 27 | - | |
| 28 | - | |
| 29 | -class MathBlockLexer(mistune.BlockLexer): | |
| 30 | - default_rules = ['block_math', 'latex_environment'] + mistune.BlockLexer.default_rules | |
| 31 | - | |
| 32 | - def __init__(self, rules=None, **kwargs): | |
| 33 | - if rules is None: | |
| 34 | - rules = MathBlockGrammar() | |
| 35 | - super().__init__(rules, **kwargs) | |
| 36 | - | |
| 37 | - def parse_block_math(self, m): | |
| 38 | - """Parse a $$math$$ block""" | |
| 39 | - self.tokens.append({ | |
| 40 | - 'type': 'block_math', | |
| 41 | - 'text': m.group(1) | |
| 42 | - }) | |
| 43 | - | |
| 44 | - def parse_latex_environment(self, m): | |
| 45 | - self.tokens.append({ | |
| 46 | - 'type': 'latex_environment', | |
| 47 | - 'name': m.group(1), | |
| 48 | - 'text': m.group(2) | |
| 49 | - }) | |
| 50 | - | |
| 51 | - | |
| 52 | -class MathInlineGrammar(mistune.InlineGrammar): | |
| 53 | - math = re.compile(r"^\$(.+?)\$", re.DOTALL) | |
| 54 | - block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) | |
| 55 | - text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)') | |
| 56 | - | |
| 57 | - | |
| 58 | -class MathInlineLexer(mistune.InlineLexer): | |
| 59 | - default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules | |
| 60 | - | |
| 61 | - def __init__(self, renderer, rules=None, **kwargs): | |
| 62 | - if rules is None: | |
| 63 | - rules = MathInlineGrammar() | |
| 64 | - super().__init__(renderer, rules, **kwargs) | |
| 65 | - | |
| 66 | - def output_math(self, m): | |
| 67 | - return self.renderer.inline_math(m.group(1)) | |
| 68 | - | |
| 69 | - def output_block_math(self, m): | |
| 70 | - return self.renderer.block_math(m.group(1)) | |
| 71 | - | |
| 72 | - | |
| 73 | -class MarkdownWithMath(mistune.Markdown): | |
| 74 | - def __init__(self, renderer, **kwargs): | |
| 75 | - if 'inline' not in kwargs: | |
| 76 | - kwargs['inline'] = MathInlineLexer | |
| 77 | - if 'block' not in kwargs: | |
| 78 | - kwargs['block'] = MathBlockLexer | |
| 79 | - super().__init__(renderer, **kwargs) | |
| 80 | - | |
| 81 | - def output_block_math(self): | |
| 82 | - return self.renderer.block_math(self.token['text']) | |
| 83 | - | |
| 84 | - def output_latex_environment(self): | |
| 85 | - return self.renderer.latex_environment(self.token['name'], | |
| 86 | - self.token['text']) | |
| 87 | - | |
| 88 | - | |
| 89 | -class HighlightRenderer(mistune.Renderer): | |
| 90 | - def __init__(self, qref='.'): | |
| 91 | - super().__init__(escape=True) | |
| 92 | - self.qref = qref | |
| 93 | - | |
| 94 | - def block_code(self, code, lang='text'): | |
| 95 | - try: | |
| 96 | - lexer = get_lexer_by_name(lang, stripall=False) | |
| 97 | - except Exception: | |
| 98 | - lexer = get_lexer_by_name('text', stripall=False) | |
| 99 | - | |
| 100 | - formatter = HtmlFormatter() | |
| 101 | - return highlight(code, lexer, formatter) | |
| 102 | - | |
| 103 | - def table(self, header, body): | |
| 104 | - return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" | |
| 105 | - | |
| 106 | - def image(self, src, title, alt): | |
| 107 | - alt = mistune.escape(alt, quote=True) | |
| 108 | - if title is not None: | |
| 109 | - if title: # not empty string, show as caption | |
| 110 | - title = mistune.escape(title, quote=True) | |
| 111 | - caption = f'<figcaption class="figure-caption">{title}</figcaption>' | |
| 112 | - else: # title is an empty string, show as centered figure | |
| 113 | - caption = '' | |
| 114 | - | |
| 115 | - return f''' | |
| 116 | - <div class="text-center"> | |
| 117 | - <figure class="figure"> | |
| 118 | - <img src="/file?ref={self.qref}&image={src}" class="figure-img img-fluid rounded" alt="{alt}" title="{title}"> | |
| 119 | - {caption} | |
| 120 | - </figure> | |
| 121 | - </div> | |
| 122 | - ''' | |
| 123 | - | |
| 124 | - else: # title indefined, show as inline image | |
| 125 | - return f'<img src="/file?ref={self.qref}&image={src}" class="figure-img img-fluid" alt="{alt}" title="{title}">' | |
| 126 | - | |
| 127 | - # Pass math through unaltered - mathjax does the rendering in the browser | |
| 128 | - def block_math(self, text): | |
| 129 | - return fr'$$ {text} $$' | |
| 130 | - | |
| 131 | - def latex_environment(self, name, text): | |
| 132 | - return fr'\begin{{{name}}} {text} \end{{{name}}}' | |
| 133 | - | |
| 134 | - def inline_math(self, text): | |
| 135 | - return fr'$$$ {text} $$$' | |
| 136 | - | |
| 137 | - | |
| 138 | -def md_to_html(qref='.'): | |
| 139 | - return MarkdownWithMath(HighlightRenderer(qref=qref)) | |
| 140 | - | |
| 141 | - | |
| 142 | 17 | # --------------------------------------------------------------------------- |
| 143 | 18 | # load data from yaml file |
| 144 | 19 | # --------------------------------------------------------------------------- |
| 145 | -def load_yaml(filename, default=None): | |
| 20 | +def load_yaml(filename: str, default: Any = None) -> Any: | |
| 21 | + filename = path.expanduser(filename) | |
| 146 | 22 | try: |
| 147 | - with open(path.expanduser(filename), 'r', encoding='utf-8') as f: | |
| 148 | - default = yaml.safe_load(f) | |
| 23 | + f = open(filename, 'r', encoding='utf-8') | |
| 149 | 24 | except FileNotFoundError: |
| 150 | - logger.error(f'File not found: "{filename}".') | |
| 25 | + logger.error(f'Cannot open "{filename}": not found') | |
| 151 | 26 | except PermissionError: |
| 152 | - logger.error(f'Permission error: "{filename}"') | |
| 27 | + logger.error(f'Cannot open "{filename}": no permission') | |
| 153 | 28 | except OSError: |
| 154 | - logger.error(f'Error reading file "{filename}"') | |
| 155 | - except yaml.YAMLError as e: | |
| 156 | - mark = e.problem_mark | |
| 157 | - logger.error(f'In YAML file "{filename}" near line ' | |
| 158 | - f'{mark.line}, column {mark.column+1}') | |
| 159 | - return default | |
| 29 | + logger.error(f'Cannot open file "{filename}"') | |
| 30 | + else: | |
| 31 | + with f: | |
| 32 | + try: | |
| 33 | + default = yaml.safe_load(f) | |
| 34 | + except yaml.YAMLError as e: | |
| 35 | + if hasattr(e, 'problem_mark'): | |
| 36 | + mark = e.problem_mark | |
| 37 | + logger.error(f'File "{filename}" near line {mark.line}, ' | |
| 38 | + f'column {mark.column+1}') | |
| 39 | + else: | |
| 40 | + logger.error(f'File "{filename}"') | |
| 41 | + finally: | |
| 42 | + return default | |
| 160 | 43 | |
| 161 | 44 | |
| 162 | 45 | # --------------------------------------------------------------------------- |
| ... | ... | @@ -164,7 +47,11 @@ def load_yaml(filename, default=None): |
| 164 | 47 | # The script is run in another process but this function blocks waiting |
| 165 | 48 | # for its termination. |
| 166 | 49 | # --------------------------------------------------------------------------- |
| 167 | -def run_script(script, args=[], stdin='', timeout=5): | |
| 50 | +def run_script(script: str, | |
| 51 | + args: List[str] = [], | |
| 52 | + stdin: str = '', | |
| 53 | + timeout: int = 2) -> Any: | |
| 54 | + | |
| 168 | 55 | script = path.expanduser(script) |
| 169 | 56 | try: |
| 170 | 57 | cmd = [script] + [str(a) for a in args] |
| ... | ... | @@ -176,16 +63,18 @@ def run_script(script, args=[], stdin='', timeout=5): |
| 176 | 63 | timeout=timeout, |
| 177 | 64 | ) |
| 178 | 65 | except FileNotFoundError: |
| 179 | - logger.error(f'Could not execute script "{script}": not found') | |
| 66 | + logger.error(f'Can not execute script "{script}": not found.') | |
| 180 | 67 | except PermissionError: |
| 181 | - logger.error(f'Could not execute script "{script}": wrong permissions') | |
| 68 | + logger.error(f'Can not execute script "{script}": wrong permissions.') | |
| 182 | 69 | except OSError: |
| 183 | - logger.error(f'Could not execute script "{script}": unknown reason') | |
| 70 | + logger.error(f'Can not execute script "{script}": unknown reason.') | |
| 184 | 71 | except subprocess.TimeoutExpired: |
| 185 | - logger.error(f'Timeout exceeded ({timeout}s) while running "{script}"') | |
| 72 | + logger.error(f'Timeout {timeout}s exceeded while running "{script}".') | |
| 73 | + except Exception: | |
| 74 | + logger.error(f'An Exception ocurred running {script}.') | |
| 186 | 75 | else: |
| 187 | 76 | if p.returncode != 0: |
| 188 | - logger.error(f'Script "{script}" returned code {p.returncode}') | |
| 77 | + logger.error(f'Return code {p.returncode} running "{script}".') | |
| 189 | 78 | else: |
| 190 | 79 | try: |
| 191 | 80 | output = yaml.safe_load(p.stdout) |
| ... | ... | @@ -193,3 +82,41 @@ def run_script(script, args=[], stdin='', timeout=5): |
| 193 | 82 | logger.error(f'Error parsing yaml output of "{script}"') |
| 194 | 83 | else: |
| 195 | 84 | return output |
| 85 | + | |
| 86 | + | |
| 87 | +# ---------------------------------------------------------------------------- | |
| 88 | +# Same as above, but asynchronous | |
| 89 | +# ---------------------------------------------------------------------------- | |
| 90 | +async def run_script_async(script: str, | |
| 91 | + args: List[str] = [], | |
| 92 | + stdin: str = '', | |
| 93 | + timeout: int = 2) -> Any: | |
| 94 | + | |
| 95 | + script = path.expanduser(script) | |
| 96 | + args = [str(a) for a in args] | |
| 97 | + | |
| 98 | + p = await asyncio.create_subprocess_exec( | |
| 99 | + script, *args, | |
| 100 | + stdin=asyncio.subprocess.PIPE, | |
| 101 | + stdout=asyncio.subprocess.PIPE, | |
| 102 | + stderr=asyncio.subprocess.DEVNULL, | |
| 103 | + ) | |
| 104 | + | |
| 105 | + try: | |
| 106 | + stdout, stderr = await asyncio.wait_for( | |
| 107 | + p.communicate(input=stdin.encode('utf-8')), | |
| 108 | + timeout=timeout | |
| 109 | + ) | |
| 110 | + except asyncio.TimeoutError: | |
| 111 | + logger.warning(f'Timeout {timeout}s running script "{script}".') | |
| 112 | + return | |
| 113 | + | |
| 114 | + if p.returncode != 0: | |
| 115 | + logger.error(f'Return code {p.returncode} running "{script}".') | |
| 116 | + else: | |
| 117 | + try: | |
| 118 | + output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) | |
| 119 | + except Exception: | |
| 120 | + logger.error(f'Error parsing yaml output of "{script}"') | |
| 121 | + else: | |
| 122 | + return output | ... | ... |