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,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:
perguntations/parser_markdown.py 0 → 100644
@@ -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=&#39;&#39;, timeout=5): @@ -176,16 +63,18 @@ def run_script(script, args=[], stdin=&#39;&#39;, 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=&#39;&#39;, timeout=5): @@ -193,3 +82,41 @@ def run_script(script, args=[], stdin=&#39;&#39;, 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