Commit 8431c74d848717e67ca6dbed2bc2ffc88aa9beff

Authored by Miguel Barão
1 parent 92211444
Exists in master and in 1 other branch dev

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.
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:
... ...
perguntations/parser_markdown.py 0 → 100644
... ... @@ -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=&#39;&#39;, 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=&#39;&#39;, 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
... ...