Commit f9bea841320b8e786591d1d6c4d2cd4cb6ce20e7

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

Use asyncio subprocesses to correct textarea questions.

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
... ... @@ -64,7 +64,6 @@
64 64 # correct: correct-timeout.py
65 65 # opcional
66 66 answer: Vulcano, Krypton, Plutão
67   - lines: 3
68   - timeout: 50
  67 + timeout: 3
69 68 solution: |
70 69 Os 3 planetas mais perto do Sol são: Mercúrio, Vénus e Terra.
... ...