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,6 +7,7 @@ import asyncio | ||
| 7 | 7 | ||
| 8 | # user installed packages | 8 | # user installed packages |
| 9 | import bcrypt | 9 | import bcrypt |
| 10 | +import json | ||
| 10 | from sqlalchemy import create_engine | 11 | from sqlalchemy import create_engine |
| 11 | from sqlalchemy.orm import sessionmaker | 12 | from sqlalchemy.orm import sessionmaker |
| 12 | 13 | ||
| @@ -142,11 +143,11 @@ class App(object): | @@ -142,11 +143,11 @@ class App(object): | ||
| 142 | # ----------------------------------------------------------------------- | 143 | # ----------------------------------------------------------------------- |
| 143 | async def generate_test(self, uid): | 144 | async def generate_test(self, uid): |
| 144 | if uid in self.online: | 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 | student_id = self.online[uid]['student'] # {number, name} | 147 | student_id = self.online[uid]['student'] # {number, name} |
| 147 | test = await self.testfactory.generate(student_id) | 148 | test = await self.testfactory.generate(student_id) |
| 148 | self.online[uid]['test'] = test | 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 | return self.online[uid]['test'] | 151 | return self.online[uid]['test'] |
| 151 | else: | 152 | else: |
| 152 | # this implies an error in the code. should never be here! | 153 | # this implies an error in the code. should never be here! |
| @@ -164,7 +165,10 @@ class App(object): | @@ -164,7 +165,10 @@ class App(object): | ||
| 164 | fields = (t['student']['number'], t['ref'], str(t['finish_time'])) | 165 | fields = (t['student']['number'], t['ref'], str(t['finish_time'])) |
| 165 | fname = ' -- '.join(fields) + '.json' | 166 | fname = ' -- '.join(fields) + '.json' |
| 166 | fpath = path.join(t['answers_dir'], fname) | 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 | # insert test and questions into database | 173 | # insert test and questions into database |
| 170 | with self.db_session() as s: | 174 | with self.db_session() as s: |
| @@ -186,7 +190,7 @@ class App(object): | @@ -186,7 +190,7 @@ class App(object): | ||
| 186 | student_id=t['student']['number'], | 190 | student_id=t['student']['number'], |
| 187 | test_id=t['ref']) for q in t['questions'] if 'grade' in q]) | 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 | return grade | 194 | return grade |
| 191 | 195 | ||
| 192 | # ----------------------------------------------------------------------- | 196 | # ----------------------------------------------------------------------- |
perguntations/factory.py
| @@ -145,7 +145,7 @@ class QuestionFactory(dict): | @@ -145,7 +145,7 @@ class QuestionFactory(dict): | ||
| 145 | if q['type'] == 'generator': | 145 | if q['type'] == 'generator': |
| 146 | logger.debug(f'Generating "{ref}" from {q["script"]}') | 146 | logger.debug(f'Generating "{ref}" from {q["script"]}') |
| 147 | q.setdefault('args', []) # optional arguments | 147 | q.setdefault('args', []) # optional arguments |
| 148 | - q.setdefault('stdin', '') | 148 | + q.setdefault('stdin', '') # FIXME necessary? |
| 149 | script = path.join(q['path'], q['script']) | 149 | script = path.join(q['path'], q['script']) |
| 150 | out = run_script(script=script, args=q['args'], stdin=q['stdin']) | 150 | out = run_script(script=script, args=q['args'], stdin=q['stdin']) |
| 151 | try: | 151 | try: |
| @@ -0,0 +1,145 @@ | @@ -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,21 +4,21 @@ import random | ||
| 4 | import re | 4 | import re |
| 5 | from os import path | 5 | from os import path |
| 6 | import logging | 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 | # this project | 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,35 +27,31 @@ logger = logging.getLogger(__name__) | ||
| 27 | # =========================================================================== | 27 | # =========================================================================== |
| 28 | class Question(dict): | 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 | super().__init__(q) | 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 | 'title': '', | 39 | 'title': '', |
| 40 | 'answer': None, | 40 | 'answer': None, |
| 41 | 'comments': '', | 41 | 'comments': '', |
| 42 | + 'solution': '', | ||
| 42 | 'files': {}, | 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 | self['grade'] = 0.0 | 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 | 'Add k:v pairs from default dict d for nonexistent keys' | 55 | 'Add k:v pairs from default dict d for nonexistent keys' |
| 60 | for k, v in d.items(): | 56 | for k, v in d.items(): |
| 61 | self.setdefault(k, v) | 57 | self.setdefault(k, v) |
| @@ -75,55 +71,61 @@ class QuestionRadio(Question): | @@ -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 | super().__init__(q) | 75 | super().__init__(q) |
| 80 | 76 | ||
| 81 | n = len(self['options']) | 77 | n = len(self['options']) |
| 82 | 78 | ||
| 83 | # set defaults if missing | 79 | # set defaults if missing |
| 84 | - self.set_defaults({ | 80 | + self.set_defaults(QDict({ |
| 85 | 'text': '', | 81 | 'text': '', |
| 86 | 'correct': 0, | 82 | 'correct': 0, |
| 87 | 'shuffle': True, | 83 | 'shuffle': True, |
| 88 | 'discount': True, | 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 | # correctness levels from 0.0 to 1.0 (no discount here!) | 88 | # correctness levels from 0.0 to 1.0 (no discount here!) |
| 93 | if isinstance(self['correct'], int): | 89 | if isinstance(self['correct'], int): |
| 94 | self['correct'] = [1.0 if x == self['correct'] else 0.0 | 90 | self['correct'] = [1.0 if x == self['correct'] else 0.0 |
| 95 | for x in range(n)] | 91 | for x in range(n)] |
| 96 | 92 | ||
| 97 | if len(self['correct']) != n: | 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 | if self['shuffle']: | 99 | if self['shuffle']: |
| 102 | # separate right from wrong options | 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 | wrong = [i for i in range(n) if self['correct'][i] < 1] | 102 | wrong = [i for i in range(n) if self['correct'][i] < 1] |
| 105 | 103 | ||
| 106 | self.set_defaults({'choose': 1+len(wrong)}) | 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 | # choose remaining wrong options | 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 | # final shuffle of the options | 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 | self['options'] = [str(options[i]) for i in perm] | 123 | self['options'] = [str(options[i]) for i in perm] |
| 122 | self['correct'] = [float(correct[i]) for i in perm] | 124 | self['correct'] = [float(correct[i]) for i in perm] |
| 123 | 125 | ||
| 124 | # ------------------------------------------------------------------------ | 126 | # ------------------------------------------------------------------------ |
| 125 | # can return negative values for wrong answers | 127 | # can return negative values for wrong answers |
| 126 | - def correct(self): | 128 | + def correct(self) -> None: |
| 127 | super().correct() | 129 | super().correct() |
| 128 | 130 | ||
| 129 | if self['answer'] is not None: | 131 | if self['answer'] is not None: |
| @@ -134,8 +136,6 @@ class QuestionRadio(Question): | @@ -134,8 +136,6 @@ class QuestionRadio(Question): | ||
| 134 | x = (x - x_aver) / (1.0 - x_aver) | 136 | x = (x - x_aver) / (1.0 - x_aver) |
| 135 | self['grade'] = x | 137 | self['grade'] = x |
| 136 | 138 | ||
| 137 | - return self['grade'] | ||
| 138 | - | ||
| 139 | 139 | ||
| 140 | # =========================================================================== | 140 | # =========================================================================== |
| 141 | class QuestionCheckbox(Question): | 141 | class QuestionCheckbox(Question): |
| @@ -151,7 +151,7 @@ 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 | super().__init__(q) | 155 | super().__init__(q) |
| 156 | 156 | ||
| 157 | n = len(self['options']) | 157 | n = len(self['options']) |
| @@ -166,8 +166,10 @@ class QuestionCheckbox(Question): | @@ -166,8 +166,10 @@ class QuestionCheckbox(Question): | ||
| 166 | }) | 166 | }) |
| 167 | 167 | ||
| 168 | if len(self['correct']) != n: | 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 | # if an option is a list of (right, wrong), pick one | 174 | # if an option is a list of (right, wrong), pick one |
| 173 | # FIXME it's possible that all options are chosen wrong | 175 | # FIXME it's possible that all options are chosen wrong |
| @@ -190,7 +192,7 @@ class QuestionCheckbox(Question): | @@ -190,7 +192,7 @@ class QuestionCheckbox(Question): | ||
| 190 | 192 | ||
| 191 | # ------------------------------------------------------------------------ | 193 | # ------------------------------------------------------------------------ |
| 192 | # can return negative values for wrong answers | 194 | # can return negative values for wrong answers |
| 193 | - def correct(self): | 195 | + def correct(self) -> None: |
| 194 | super().correct() | 196 | super().correct() |
| 195 | 197 | ||
| 196 | if self['answer'] is not None: | 198 | if self['answer'] is not None: |
| @@ -210,8 +212,6 @@ class QuestionCheckbox(Question): | @@ -210,8 +212,6 @@ class QuestionCheckbox(Question): | ||
| 210 | 212 | ||
| 211 | self['grade'] = x / sum_abs | 213 | self['grade'] = x / sum_abs |
| 212 | 214 | ||
| 213 | - return self['grade'] | ||
| 214 | - | ||
| 215 | 215 | ||
| 216 | # ============================================================================ | 216 | # ============================================================================ |
| 217 | class QuestionText(Question): | 217 | class QuestionText(Question): |
| @@ -223,7 +223,7 @@ 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 | super().__init__(q) | 227 | super().__init__(q) |
| 228 | 228 | ||
| 229 | self.set_defaults({ | 229 | self.set_defaults({ |
| @@ -240,14 +240,12 @@ class QuestionText(Question): | @@ -240,14 +240,12 @@ class QuestionText(Question): | ||
| 240 | 240 | ||
| 241 | # ------------------------------------------------------------------------ | 241 | # ------------------------------------------------------------------------ |
| 242 | # can return negative values for wrong answers | 242 | # can return negative values for wrong answers |
| 243 | - def correct(self): | 243 | + def correct(self) -> None: |
| 244 | super().correct() | 244 | super().correct() |
| 245 | 245 | ||
| 246 | if self['answer'] is not None: | 246 | if self['answer'] is not None: |
| 247 | self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 | 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 | class QuestionTextRegex(Question): | 251 | class QuestionTextRegex(Question): |
| @@ -259,7 +257,7 @@ 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 | super().__init__(q) | 261 | super().__init__(q) |
| 264 | 262 | ||
| 265 | self.set_defaults({ | 263 | self.set_defaults({ |
| @@ -269,17 +267,15 @@ class QuestionTextRegex(Question): | @@ -269,17 +267,15 @@ class QuestionTextRegex(Question): | ||
| 269 | 267 | ||
| 270 | # ------------------------------------------------------------------------ | 268 | # ------------------------------------------------------------------------ |
| 271 | # can return negative values for wrong answers | 269 | # can return negative values for wrong answers |
| 272 | - def correct(self): | 270 | + def correct(self) -> None: |
| 273 | super().correct() | 271 | super().correct() |
| 274 | if self['answer'] is not None: | 272 | if self['answer'] is not None: |
| 275 | try: | 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 | except TypeError: | 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,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 | super().__init__(q) | 293 | super().__init__(q) |
| 298 | 294 | ||
| 299 | - self.set_defaults({ | 295 | + self.set_defaults(QDict({ |
| 300 | 'text': '', | 296 | 'text': '', |
| 301 | 'correct': [1.0, -1.0], # will always return false | 297 | 'correct': [1.0, -1.0], # will always return false |
| 302 | - }) | 298 | + })) |
| 303 | 299 | ||
| 304 | # ------------------------------------------------------------------------ | 300 | # ------------------------------------------------------------------------ |
| 305 | # can return negative values for wrong answers | 301 | # can return negative values for wrong answers |
| 306 | - def correct(self): | 302 | + def correct(self) -> None: |
| 307 | super().correct() | 303 | super().correct() |
| 308 | if self['answer'] is not None: | 304 | if self['answer'] is not None: |
| 309 | lower, upper = self['correct'] | 305 | lower, upper = self['correct'] |
| @@ -316,8 +312,6 @@ class QuestionNumericInterval(Question): | @@ -316,8 +312,6 @@ class QuestionNumericInterval(Question): | ||
| 316 | else: | 312 | else: |
| 317 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 | 313 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
| 318 | 314 | ||
| 319 | - return self['grade'] | ||
| 320 | - | ||
| 321 | 315 | ||
| 322 | # =========================================================================== | 316 | # =========================================================================== |
| 323 | class QuestionTextArea(Question): | 317 | class QuestionTextArea(Question): |
| @@ -326,16 +320,14 @@ class QuestionTextArea(Question): | @@ -326,16 +320,14 @@ class QuestionTextArea(Question): | ||
| 326 | text (str) | 320 | text (str) |
| 327 | correct (str with script to run) | 321 | correct (str with script to run) |
| 328 | answer (None or an actual answer) | 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 | super().__init__(q) | 327 | super().__init__(q) |
| 335 | 328 | ||
| 336 | self.set_defaults({ | 329 | self.set_defaults({ |
| 337 | 'text': '', | 330 | 'text': '', |
| 338 | - 'lines': 8, # FIXME not being used??? | ||
| 339 | 'timeout': 5, # seconds | 331 | 'timeout': 5, # seconds |
| 340 | 'correct': '', # trying to execute this will fail => grade 0.0 | 332 | 'correct': '', # trying to execute this will fail => grade 0.0 |
| 341 | 'args': [] | 333 | 'args': [] |
| @@ -344,33 +336,62 @@ class QuestionTextArea(Question): | @@ -344,33 +336,62 @@ class QuestionTextArea(Question): | ||
| 344 | self['correct'] = path.join(self['path'], self['correct']) | 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 | super().correct() | 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 | script=self['correct'], | 344 | script=self['correct'], |
| 354 | args=self['args'], | 345 | args=self['args'], |
| 355 | stdin=self['answer'], | 346 | stdin=self['answer'], |
| 356 | timeout=self['timeout'] | 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 | self['comments'] = out.get('comments', '') | 354 | self['comments'] = out.get('comments', '') |
| 361 | try: | 355 | try: |
| 362 | self['grade'] = float(out['grade']) | 356 | self['grade'] = float(out['grade']) |
| 363 | except ValueError: | 357 | except ValueError: |
| 364 | - logger.error(f'Grade value error in "{self["correct"]}".') | 358 | + logger.error(f'Output error in "{self["correct"]}".') |
| 365 | except KeyError: | 359 | except KeyError: |
| 366 | logger.error(f'No grade in "{self["correct"]}".') | 360 | logger.error(f'No grade in "{self["correct"]}".') |
| 367 | else: | 361 | else: |
| 368 | try: | 362 | try: |
| 369 | self['grade'] = float(out) | 363 | self['grade'] = float(out) |
| 370 | except (TypeError, ValueError): | 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,15 +402,13 @@ class QuestionInformation(Question): | ||
| 381 | points (0.0) | 402 | points (0.0) |
| 382 | ''' | 403 | ''' |
| 383 | # ------------------------------------------------------------------------ | 404 | # ------------------------------------------------------------------------ |
| 384 | - def __init__(self, q): | 405 | + def __init__(self, q: QDict) -> None: |
| 385 | super().__init__(q) | 406 | super().__init__(q) |
| 386 | - self.set_defaults({ | 407 | + self.set_defaults(QDict({ |
| 387 | 'text': '', | 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 | super().correct() | 413 | super().correct() |
| 394 | self['grade'] = 1.0 # always "correct" but points should be zero! | 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,8 +21,9 @@ import tornado.httpserver | ||
| 21 | 21 | ||
| 22 | # this project | 22 | # this project |
| 23 | from perguntations.app import App, AppException | 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 | from perguntations import APP_NAME | 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,7 +4,6 @@ from os import path, listdir | ||
| 4 | import fnmatch | 4 | import fnmatch |
| 5 | import random | 5 | import random |
| 6 | from datetime import datetime | 6 | from datetime import datetime |
| 7 | -import json | ||
| 8 | import logging | 7 | import logging |
| 9 | import asyncio | 8 | import asyncio |
| 10 | 9 | ||
| @@ -257,9 +256,10 @@ class Test(dict): | @@ -257,9 +256,10 @@ class Test(dict): | ||
| 257 | self['state'] = 'FINISHED' | 256 | self['state'] = 'FINISHED' |
| 258 | grade = 0.0 | 257 | grade = 0.0 |
| 259 | for q in self['questions']: | 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 | self['grade'] = max(0, round(grade, 1)) # avoid negative grades | 264 | self['grade'] = max(0, round(grade, 1)) # avoid negative grades |
| 265 | logger.info(f'Student {self["student"]["number"]}: ' | 265 | logger.info(f'Student {self["student"]["number"]}: ' |
| @@ -273,10 +273,3 @@ class Test(dict): | @@ -273,10 +273,3 @@ class Test(dict): | ||
| 273 | self['grade'] = 0.0 | 273 | self['grade'] = 0.0 |
| 274 | logger.info(f'Student {self["student"]["number"]}: gave up.') | 274 | logger.info(f'Student {self["student"]["number"]}: gave up.') |
| 275 | return self['grade'] | 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 | # python standard library | 2 | # python standard library |
| 3 | +import asyncio | ||
| 4 | +import logging | ||
| 3 | from os import path | 5 | from os import path |
| 4 | import subprocess | 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 | import yaml | 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 | # setup logger for this module | 13 | # setup logger for this module |
| 16 | logger = logging.getLogger(__name__) | 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 | # load data from yaml file | 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 | try: | 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 | except FileNotFoundError: | 24 | except FileNotFoundError: |
| 150 | - logger.error(f'File not found: "{filename}".') | 25 | + logger.error(f'Cannot open "{filename}": not found') |
| 151 | except PermissionError: | 26 | except PermissionError: |
| 152 | - logger.error(f'Permission error: "{filename}"') | 27 | + logger.error(f'Cannot open "{filename}": no permission') |
| 153 | except OSError: | 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,7 +47,11 @@ def load_yaml(filename, default=None): | ||
| 164 | # The script is run in another process but this function blocks waiting | 47 | # The script is run in another process but this function blocks waiting |
| 165 | # for its termination. | 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 | script = path.expanduser(script) | 55 | script = path.expanduser(script) |
| 169 | try: | 56 | try: |
| 170 | cmd = [script] + [str(a) for a in args] | 57 | cmd = [script] + [str(a) for a in args] |
| @@ -176,16 +63,18 @@ def run_script(script, args=[], stdin='', timeout=5): | @@ -176,16 +63,18 @@ def run_script(script, args=[], stdin='', timeout=5): | ||
| 176 | timeout=timeout, | 63 | timeout=timeout, |
| 177 | ) | 64 | ) |
| 178 | except FileNotFoundError: | 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 | except PermissionError: | 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 | except OSError: | 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 | except subprocess.TimeoutExpired: | 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 | else: | 75 | else: |
| 187 | if p.returncode != 0: | 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 | else: | 78 | else: |
| 190 | try: | 79 | try: |
| 191 | output = yaml.safe_load(p.stdout) | 80 | output = yaml.safe_load(p.stdout) |
| @@ -193,3 +82,41 @@ def run_script(script, args=[], stdin='', timeout=5): | @@ -193,3 +82,41 @@ def run_script(script, args=[], stdin='', timeout=5): | ||
| 193 | logger.error(f'Error parsing yaml output of "{script}"') | 82 | logger.error(f'Error parsing yaml output of "{script}"') |
| 194 | else: | 83 | else: |
| 195 | return output | 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 |