Commit f9bea841320b8e786591d1d6c4d2cd4cb6ce20e7
1 parent
c2abd6bf
Exists in
master
and in
1 other branch
Use asyncio subprocesses to correct textarea questions.
Showing
7 changed files
with
102 additions
and
28 deletions
Show diff stats
BUGS.md
1 | 1 | |
2 | 2 | # BUGS |
3 | 3 | |
4 | +- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) | |
5 | +- devia mostrar timout para o aluno saber a razao. | |
4 | 6 | - permitir configuracao para escolher entre static files locais ou remotos |
5 | 7 | - sqlalchemy.pool.impl.NullPool: Exception during reset or similar |
6 | 8 | sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread. | ... | ... |
aprendizations/learnapp.py
... | ... | @@ -102,7 +102,7 @@ class LearnApp(object): |
102 | 102 | continue # to next test |
103 | 103 | |
104 | 104 | if errors > 0: |
105 | - logger.info(f'{errors:>6} errors found.') | |
105 | + logger.error(f'{errors:>6} errors found.') | |
106 | 106 | raise LearnException('Sanity checks') |
107 | 107 | else: |
108 | 108 | logger.info('No errors found.') | ... | ... |
aprendizations/questions.py
... | ... | @@ -4,12 +4,11 @@ import random |
4 | 4 | import re |
5 | 5 | from os import path |
6 | 6 | import logging |
7 | -import asyncio | |
8 | 7 | from typing import Any, Dict, NewType |
9 | 8 | import uuid |
10 | 9 | |
11 | 10 | # this project |
12 | -from .tools import run_script | |
11 | +from .tools import run_script, run_script_async | |
13 | 12 | |
14 | 13 | # setup logger for this module |
15 | 14 | logger = logging.getLogger(__name__) |
... | ... | @@ -50,8 +49,9 @@ class Question(dict): |
50 | 49 | self['grade'] = 0.0 |
51 | 50 | |
52 | 51 | async def correct_async(self) -> None: |
53 | - loop = asyncio.get_running_loop() | |
54 | - await loop.run_in_executor(None, self.correct) | |
52 | + self.correct() | |
53 | + # loop = asyncio.get_running_loop() | |
54 | + # await loop.run_in_executor(None, self.correct) | |
55 | 55 | |
56 | 56 | def set_defaults(self, d: QDict) -> None: |
57 | 57 | 'Add k:v pairs from default dict d for nonexistent keys' |
... | ... | @@ -305,7 +305,7 @@ class QuestionNumericInterval(Question): |
305 | 305 | answer = float(self['answer'].replace(',', '.', 1)) |
306 | 306 | except ValueError: |
307 | 307 | self['comments'] = ('A resposta tem de ser numérica, ' |
308 | - 'por exemplo 12.345.') | |
308 | + 'por exemplo `12.345`.') | |
309 | 309 | self['grade'] = 0.0 |
310 | 310 | else: |
311 | 311 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
... | ... | @@ -327,13 +327,13 @@ class QuestionTextArea(Question): |
327 | 327 | |
328 | 328 | self.set_defaults(QDict({ |
329 | 329 | 'text': '', |
330 | - 'lines': 8, | |
330 | + # 'lines': 8, | |
331 | 331 | 'timeout': 5, # seconds |
332 | 332 | 'correct': '', # trying to execute this will fail => grade 0.0 |
333 | 333 | 'args': [] |
334 | 334 | })) |
335 | 335 | |
336 | - self['correct'] = path.join(self['path'], self['correct']) # FIXME | |
336 | + self['correct'] = path.join(self['path'], self['correct']) | |
337 | 337 | |
338 | 338 | # ------------------------------------------------------------------------ |
339 | 339 | def correct(self) -> None: |
... | ... | @@ -347,7 +347,39 @@ class QuestionTextArea(Question): |
347 | 347 | timeout=self['timeout'] |
348 | 348 | ) |
349 | 349 | |
350 | - 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): | |
354 | + self['comments'] = out.get('comments', '') | |
355 | + try: | |
356 | + self['grade'] = float(out['grade']) | |
357 | + except ValueError: | |
358 | + logger.error(f'Output error in "{self["correct"]}".') | |
359 | + except KeyError: | |
360 | + logger.error(f'No grade in "{self["correct"]}".') | |
361 | + else: | |
362 | + try: | |
363 | + self['grade'] = float(out) | |
364 | + except (TypeError, ValueError): | |
365 | + logger.error(f'Invalid grade in "{self["correct"]}".') | |
366 | + | |
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): | |
351 | 383 | self['comments'] = out.get('comments', '') |
352 | 384 | try: |
353 | 385 | self['grade'] = float(out['grade']) |
... | ... | @@ -372,7 +404,6 @@ class QuestionInformation(Question): |
372 | 404 | })) |
373 | 405 | |
374 | 406 | # ------------------------------------------------------------------------ |
375 | - # can return negative values for wrong answers | |
376 | 407 | def correct(self) -> None: |
377 | 408 | super().correct() |
378 | 409 | self['grade'] = 1.0 # always "correct" but points should be zero! | ... | ... |
aprendizations/serve.py
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | |
3 | 3 | # python standard library |
4 | -import sys | |
4 | +import argparse | |
5 | +import asyncio | |
5 | 6 | import base64 |
6 | -import uuid | |
7 | +import functools | |
7 | 8 | import logging.config |
8 | -import argparse | |
9 | 9 | import mimetypes |
10 | +from os import path, environ | |
10 | 11 | import signal |
11 | -import functools | |
12 | 12 | import ssl |
13 | -import asyncio | |
14 | -from os import path, environ | |
13 | +import sys | |
14 | +import uuid | |
15 | 15 | |
16 | 16 | # third party libraries |
17 | 17 | import tornado.ioloop |
... | ... | @@ -20,9 +20,9 @@ import tornado.web |
20 | 20 | from tornado.escape import to_unicode |
21 | 21 | |
22 | 22 | # this project |
23 | +from . import APP_NAME | |
23 | 24 | from .learnapp import LearnApp |
24 | 25 | from .tools import load_yaml, md_to_html |
25 | -from . import APP_NAME | |
26 | 26 | |
27 | 27 | |
28 | 28 | # ---------------------------------------------------------------------------- | ... | ... |
aprendizations/tools.py
1 | 1 | |
2 | 2 | # python standard library |
3 | -from os import path | |
4 | -import subprocess | |
3 | +import asyncio | |
5 | 4 | import logging |
5 | +from os import path | |
6 | 6 | import re |
7 | +import subprocess | |
7 | 8 | from typing import Any, List |
8 | 9 | |
9 | 10 | # third party libraries |
10 | -import yaml | |
11 | 11 | import mistune |
12 | 12 | from pygments import highlight |
13 | 13 | from pygments.lexers import get_lexer_by_name |
14 | 14 | from pygments.formatters import HtmlFormatter |
15 | +import yaml | |
15 | 16 | |
16 | 17 | # setup logger for this module |
17 | 18 | logger = logging.getLogger(__name__) |
... | ... | @@ -100,14 +101,14 @@ class HighlightRenderer(mistune.Renderer): |
100 | 101 | return highlight(code, lexer, formatter) |
101 | 102 | |
102 | 103 | def table(self, header, body): |
103 | - return '<table class="table table-sm"><thead class="thead-light">' \ | |
104 | - + header + '</thead><tbody>' + body + "</tbody></table>" | |
104 | + return (f'<table class="table table-sm"><thead class="thead-light">' | |
105 | + f'{header}</thead><tbody>{body}</tbody></table>') | |
105 | 106 | |
106 | 107 | def image(self, src, title, alt): |
107 | 108 | alt = mistune.escape(alt, quote=True) |
108 | 109 | title = mistune.escape(title or '', quote=True) |
109 | - return f'<img src="/file/{src}" class="img-fluid mx-auto d-block" ' \ | |
110 | - f'alt="{alt}" title="{title}">' | |
110 | + return (f'<img src="/file/{src}" class="img-fluid mx-auto d-block" ' | |
111 | + f'alt="{alt}" title="{title}">') | |
111 | 112 | |
112 | 113 | # Pass math through unaltered - mathjax does the rendering in the browser |
113 | 114 | def block_math(self, text): |
... | ... | @@ -200,3 +201,41 @@ def run_script(script: str, |
200 | 201 | logger.error(f'Error parsing yaml output of "{script}"') |
201 | 202 | else: |
202 | 203 | return output |
204 | + | |
205 | + | |
206 | +# ---------------------------------------------------------------------------- | |
207 | +# Same as above, but asynchronous | |
208 | +# ---------------------------------------------------------------------------- | |
209 | +async def run_script_async(script: str, | |
210 | + args: List[str] = [], | |
211 | + stdin: str = '', | |
212 | + timeout: int = 5) -> Any: | |
213 | + | |
214 | + script = path.expanduser(script) | |
215 | + cmd = [script] + [str(a) for a in args] | |
216 | + | |
217 | + p = await asyncio.create_subprocess_shell( | |
218 | + *cmd, | |
219 | + stdin=asyncio.subprocess.PIPE, | |
220 | + stdout=asyncio.subprocess.PIPE, | |
221 | + stderr=asyncio.subprocess.DEVNULL, | |
222 | + ) | |
223 | + | |
224 | + try: | |
225 | + stdout, stderr = await asyncio.wait_for( | |
226 | + p.communicate(input=stdin.encode('utf-8')), | |
227 | + timeout=timeout | |
228 | + ) | |
229 | + except asyncio.TimeoutError: | |
230 | + logger.warning(f'Timeout {timeout}s running script "{script}".') | |
231 | + return | |
232 | + | |
233 | + if p.returncode != 0: | |
234 | + logger.error(f'Return code {p.returncode} running "{script}".') | |
235 | + else: | |
236 | + try: | |
237 | + output = yaml.safe_load(stdout.decode('utf-8')) | |
238 | + except Exception: | |
239 | + logger.error(f'Error parsing yaml output of "{script}"') | |
240 | + else: | |
241 | + return output | ... | ... |
demo/math/questions.yaml
... | ... | @@ -16,6 +16,7 @@ |
16 | 16 | - $2\times(9-2\times 3)$ |
17 | 17 | correct: [1, 1, 0, 0, 0] |
18 | 18 | choose: 3 |
19 | + max_tries: 1 | |
19 | 20 | solution: | |
20 | 21 | Colocando o 2 em evidência, obtém-se a resposta correcta $2\times(9 - 3)$ |
21 | 22 | ou $(9-3)\times 2$. |
... | ... | @@ -50,6 +51,7 @@ |
50 | 51 | - 12 |
51 | 52 | - 14 |
52 | 53 | - 1, a **unidade** |
54 | + max_tries: 1 | |
53 | 55 | solution: | |
54 | 56 | O único número primo é o 13. |
55 | 57 | |
... | ... | @@ -76,9 +78,10 @@ |
76 | 78 | números negativos são menores que os positivos. |
77 | 79 | |
78 | 80 | # --------------------------------------------------------------------------- |
81 | +# the program should print a question in yaml format. The args will be | |
82 | +# sent as command line options when the program is run. | |
79 | 83 | - type: generator |
80 | 84 | ref: overflow |
81 | 85 | script: gen-multiples-of-3.py |
82 | 86 | args: [11, 120] |
83 | - # the program should print a question in yaml format. The args will be | |
84 | - # sent as command line options when the program is run. | |
87 | + choose: 3 | ... | ... |
demo/solar_system/questions.yaml