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