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