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 | # BUGS | 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 | - permitir configuracao para escolher entre static files locais ou remotos | 6 | - permitir configuracao para escolher entre static files locais ou remotos |
| 5 | - sqlalchemy.pool.impl.NullPool: Exception during reset or similar | 7 | - sqlalchemy.pool.impl.NullPool: Exception during reset or similar |
| 6 | sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread. | 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,7 +102,7 @@ class LearnApp(object): | ||
| 102 | continue # to next test | 102 | continue # to next test |
| 103 | 103 | ||
| 104 | if errors > 0: | 104 | if errors > 0: |
| 105 | - logger.info(f'{errors:>6} errors found.') | 105 | + logger.error(f'{errors:>6} errors found.') |
| 106 | raise LearnException('Sanity checks') | 106 | raise LearnException('Sanity checks') |
| 107 | else: | 107 | else: |
| 108 | logger.info('No errors found.') | 108 | logger.info('No errors found.') |
aprendizations/questions.py
| @@ -4,12 +4,11 @@ import random | @@ -4,12 +4,11 @@ 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 | from typing import Any, Dict, NewType | 7 | from typing import Any, Dict, NewType |
| 9 | import uuid | 8 | import uuid |
| 10 | 9 | ||
| 11 | # this project | 10 | # this project |
| 12 | -from .tools import run_script | 11 | +from .tools import run_script, run_script_async |
| 13 | 12 | ||
| 14 | # setup logger for this module | 13 | # setup logger for this module |
| 15 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| @@ -50,8 +49,9 @@ class Question(dict): | @@ -50,8 +49,9 @@ class Question(dict): | ||
| 50 | self['grade'] = 0.0 | 49 | self['grade'] = 0.0 |
| 51 | 50 | ||
| 52 | async def correct_async(self) -> None: | 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 | def set_defaults(self, d: QDict) -> None: | 56 | def set_defaults(self, d: QDict) -> None: |
| 57 | 'Add k:v pairs from default dict d for nonexistent keys' | 57 | 'Add k:v pairs from default dict d for nonexistent keys' |
| @@ -305,7 +305,7 @@ class QuestionNumericInterval(Question): | @@ -305,7 +305,7 @@ class QuestionNumericInterval(Question): | ||
| 305 | answer = float(self['answer'].replace(',', '.', 1)) | 305 | answer = float(self['answer'].replace(',', '.', 1)) |
| 306 | except ValueError: | 306 | except ValueError: |
| 307 | self['comments'] = ('A resposta tem de ser numérica, ' | 307 | self['comments'] = ('A resposta tem de ser numérica, ' |
| 308 | - 'por exemplo 12.345.') | 308 | + 'por exemplo `12.345`.') |
| 309 | self['grade'] = 0.0 | 309 | self['grade'] = 0.0 |
| 310 | else: | 310 | else: |
| 311 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 | 311 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
| @@ -327,13 +327,13 @@ class QuestionTextArea(Question): | @@ -327,13 +327,13 @@ class QuestionTextArea(Question): | ||
| 327 | 327 | ||
| 328 | self.set_defaults(QDict({ | 328 | self.set_defaults(QDict({ |
| 329 | 'text': '', | 329 | 'text': '', |
| 330 | - 'lines': 8, | 330 | + # 'lines': 8, |
| 331 | 'timeout': 5, # seconds | 331 | 'timeout': 5, # seconds |
| 332 | 'correct': '', # trying to execute this will fail => grade 0.0 | 332 | 'correct': '', # trying to execute this will fail => grade 0.0 |
| 333 | 'args': [] | 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 | def correct(self) -> None: | 339 | def correct(self) -> None: |
| @@ -347,7 +347,39 @@ class QuestionTextArea(Question): | @@ -347,7 +347,39 @@ class QuestionTextArea(Question): | ||
| 347 | timeout=self['timeout'] | 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 | self['comments'] = out.get('comments', '') | 383 | self['comments'] = out.get('comments', '') |
| 352 | try: | 384 | try: |
| 353 | self['grade'] = float(out['grade']) | 385 | self['grade'] = float(out['grade']) |
| @@ -372,7 +404,6 @@ class QuestionInformation(Question): | @@ -372,7 +404,6 @@ class QuestionInformation(Question): | ||
| 372 | })) | 404 | })) |
| 373 | 405 | ||
| 374 | # ------------------------------------------------------------------------ | 406 | # ------------------------------------------------------------------------ |
| 375 | - # can return negative values for wrong answers | ||
| 376 | def correct(self) -> None: | 407 | def correct(self) -> None: |
| 377 | super().correct() | 408 | super().correct() |
| 378 | self['grade'] = 1.0 # always "correct" but points should be zero! | 409 | self['grade'] = 1.0 # always "correct" but points should be zero! |
aprendizations/serve.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | # python standard library | 3 | # python standard library |
| 4 | -import sys | 4 | +import argparse |
| 5 | +import asyncio | ||
| 5 | import base64 | 6 | import base64 |
| 6 | -import uuid | 7 | +import functools |
| 7 | import logging.config | 8 | import logging.config |
| 8 | -import argparse | ||
| 9 | import mimetypes | 9 | import mimetypes |
| 10 | +from os import path, environ | ||
| 10 | import signal | 11 | import signal |
| 11 | -import functools | ||
| 12 | import ssl | 12 | import ssl |
| 13 | -import asyncio | ||
| 14 | -from os import path, environ | 13 | +import sys |
| 14 | +import uuid | ||
| 15 | 15 | ||
| 16 | # third party libraries | 16 | # third party libraries |
| 17 | import tornado.ioloop | 17 | import tornado.ioloop |
| @@ -20,9 +20,9 @@ import tornado.web | @@ -20,9 +20,9 @@ import tornado.web | ||
| 20 | from tornado.escape import to_unicode | 20 | from tornado.escape import to_unicode |
| 21 | 21 | ||
| 22 | # this project | 22 | # this project |
| 23 | +from . import APP_NAME | ||
| 23 | from .learnapp import LearnApp | 24 | from .learnapp import LearnApp |
| 24 | from .tools import load_yaml, md_to_html | 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 | # python standard library | 2 | # python standard library |
| 3 | -from os import path | ||
| 4 | -import subprocess | 3 | +import asyncio |
| 5 | import logging | 4 | import logging |
| 5 | +from os import path | ||
| 6 | import re | 6 | import re |
| 7 | +import subprocess | ||
| 7 | from typing import Any, List | 8 | from typing import Any, List |
| 8 | 9 | ||
| 9 | # third party libraries | 10 | # third party libraries |
| 10 | -import yaml | ||
| 11 | import mistune | 11 | import mistune |
| 12 | from pygments import highlight | 12 | from pygments import highlight |
| 13 | from pygments.lexers import get_lexer_by_name | 13 | from pygments.lexers import get_lexer_by_name |
| 14 | from pygments.formatters import HtmlFormatter | 14 | from pygments.formatters import HtmlFormatter |
| 15 | +import yaml | ||
| 15 | 16 | ||
| 16 | # setup logger for this module | 17 | # setup logger for this module |
| 17 | logger = logging.getLogger(__name__) | 18 | logger = logging.getLogger(__name__) |
| @@ -100,14 +101,14 @@ class HighlightRenderer(mistune.Renderer): | @@ -100,14 +101,14 @@ class HighlightRenderer(mistune.Renderer): | ||
| 100 | return highlight(code, lexer, formatter) | 101 | return highlight(code, lexer, formatter) |
| 101 | 102 | ||
| 102 | def table(self, header, body): | 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 | def image(self, src, title, alt): | 107 | def image(self, src, title, alt): |
| 107 | alt = mistune.escape(alt, quote=True) | 108 | alt = mistune.escape(alt, quote=True) |
| 108 | title = mistune.escape(title or '', quote=True) | 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 | # Pass math through unaltered - mathjax does the rendering in the browser | 113 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 113 | def block_math(self, text): | 114 | def block_math(self, text): |
| @@ -200,3 +201,41 @@ def run_script(script: str, | @@ -200,3 +201,41 @@ def run_script(script: str, | ||
| 200 | logger.error(f'Error parsing yaml output of "{script}"') | 201 | logger.error(f'Error parsing yaml output of "{script}"') |
| 201 | else: | 202 | else: |
| 202 | return output | 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,6 +16,7 @@ | ||
| 16 | - $2\times(9-2\times 3)$ | 16 | - $2\times(9-2\times 3)$ |
| 17 | correct: [1, 1, 0, 0, 0] | 17 | correct: [1, 1, 0, 0, 0] |
| 18 | choose: 3 | 18 | choose: 3 |
| 19 | + max_tries: 1 | ||
| 19 | solution: | | 20 | solution: | |
| 20 | Colocando o 2 em evidência, obtém-se a resposta correcta $2\times(9 - 3)$ | 21 | Colocando o 2 em evidência, obtém-se a resposta correcta $2\times(9 - 3)$ |
| 21 | ou $(9-3)\times 2$. | 22 | ou $(9-3)\times 2$. |
| @@ -50,6 +51,7 @@ | @@ -50,6 +51,7 @@ | ||
| 50 | - 12 | 51 | - 12 |
| 51 | - 14 | 52 | - 14 |
| 52 | - 1, a **unidade** | 53 | - 1, a **unidade** |
| 54 | + max_tries: 1 | ||
| 53 | solution: | | 55 | solution: | |
| 54 | O único número primo é o 13. | 56 | O único número primo é o 13. |
| 55 | 57 | ||
| @@ -76,9 +78,10 @@ | @@ -76,9 +78,10 @@ | ||
| 76 | números negativos são menores que os positivos. | 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 | - type: generator | 83 | - type: generator |
| 80 | ref: overflow | 84 | ref: overflow |
| 81 | script: gen-multiples-of-3.py | 85 | script: gen-multiples-of-3.py |
| 82 | args: [11, 120] | 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
| @@ -64,7 +64,6 @@ | @@ -64,7 +64,6 @@ | ||
| 64 | # correct: correct-timeout.py | 64 | # correct: correct-timeout.py |
| 65 | # opcional | 65 | # opcional |
| 66 | answer: Vulcano, Krypton, Plutão | 66 | answer: Vulcano, Krypton, Plutão |
| 67 | - lines: 3 | ||
| 68 | - timeout: 50 | 67 | + timeout: 3 |
| 69 | solution: | | 68 | solution: | |
| 70 | Os 3 planetas mais perto do Sol são: Mercúrio, Vénus e Terra. | 69 | Os 3 planetas mais perto do Sol são: Mercúrio, Vénus e Terra. |