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.

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.