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 |