Commit 19861c70afc10a5da4ffd410c22bd40f7493d790

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

Asynchronous run_script_async using asyncio.subprocess.

Asynchronous question generators.
Asynchronous correction of textarea questions.
Removes 'lines' option in textarea questions (unused in codemirror).
The async implementations fixes errors with threading and sqlalchemy.
1 1
2 # BUGS 2 # BUGS
3 3
  4 +- quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo)
  5 +- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
  6 +- devia mostrar timout para o aluno saber a razao.
4 - permitir configuracao para escolher entre static files locais ou remotos 7 - permitir configuracao para escolher entre static files locais ou remotos
5 - sqlalchemy.pool.impl.NullPool: Exception during reset or similar 8 - 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. 9 sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread.
aprendizations/knowledge.py
@@ -58,7 +58,7 @@ class StudentKnowledge(object): @@ -58,7 +58,7 @@ class StudentKnowledge(object):
58 'level': 0.0, # unlocked 58 'level': 0.0, # unlocked
59 'date': datetime.now() 59 'date': datetime.now()
60 } 60 }
61 - logger.debug(f'Unlocked "{topic}".') 61 + logger.debug(f'[unlock_topics] Unlocked "{topic}".')
62 # else: # lock this topic if deps do not satisfy min_level 62 # else: # lock this topic if deps do not satisfy min_level
63 # del self.state[topic] 63 # del self.state[topic]
64 64
@@ -68,7 +68,7 @@ class StudentKnowledge(object): @@ -68,7 +68,7 @@ class StudentKnowledge(object):
68 # current_question: the current question to be presented 68 # current_question: the current question to be presented
69 # ------------------------------------------------------------------------ 69 # ------------------------------------------------------------------------
70 async def start_topic(self, topic): 70 async def start_topic(self, topic):
71 - logger.debug('StudentKnowledge.start_topic()') 71 + logger.debug(f'[start_topic] topic "{topic}"')
72 72
73 if self.current_topic == topic: 73 if self.current_topic == topic:
74 logger.info('Restarting current topic is not allowed.') 74 logger.info('Restarting current topic is not allowed.')
@@ -76,7 +76,7 @@ class StudentKnowledge(object): @@ -76,7 +76,7 @@ class StudentKnowledge(object):
76 76
77 # do not allow locked topics 77 # do not allow locked topics
78 if self.is_locked(topic): 78 if self.is_locked(topic):
79 - logger.debug(f'Topic {topic} is locked') 79 + logger.debug(f'[start_topic] topic "{topic}" is locked')
80 return False 80 return False
81 81
82 # starting new topic 82 # starting new topic
@@ -90,23 +90,20 @@ class StudentKnowledge(object): @@ -90,23 +90,20 @@ class StudentKnowledge(object):
90 questions = random.sample(t['questions'], k=k) 90 questions = random.sample(t['questions'], k=k)
91 else: 91 else:
92 questions = t['questions'][:k] 92 questions = t['questions'][:k]
93 - logger.debug(f'Questions: {", ".join(questions)}') 93 + logger.debug(f'[start_topic] questions: {", ".join(questions)}')
94 94
95 - # generate instances of questions 95 + # synchronous
  96 + # self.questions = [self.factory[ref].generate()
  97 + # for ref in questions]
96 98
97 - # synchronous:  
98 - # self.questions = [self.factory[ref].generate() for ref in questions]  
99 -  
100 - # async:  
101 - loop = asyncio.get_running_loop()  
102 - generators = [loop.run_in_executor(None, self.factory[qref].generate)  
103 - for qref in questions]  
104 - self.questions = await asyncio.gather(*generators) 99 + # asynchronous:
  100 + self.questions = [await self.factory[ref].generate_async()
  101 + for ref in questions]
105 102
106 # get first question 103 # get first question
107 self.next_question() 104 self.next_question()
108 105
109 - logger.debug(f'Generated {len(self.questions)} questions') 106 + logger.debug(f'[start_topic] generated {len(self.questions)} questions')
110 return True 107 return True
111 108
112 # ------------------------------------------------------------------------ 109 # ------------------------------------------------------------------------
@@ -115,7 +112,7 @@ class StudentKnowledge(object): @@ -115,7 +112,7 @@ class StudentKnowledge(object):
115 # The current topic is unchanged. 112 # The current topic is unchanged.
116 # ------------------------------------------------------------------------ 113 # ------------------------------------------------------------------------
117 def finish_topic(self): 114 def finish_topic(self):
118 - logger.debug(f'StudentKnowledge.finish_topic({self.current_topic})') 115 + logger.debug(f'[finish_topic] current_topic {self.current_topic}')
119 116
120 self.state[self.current_topic] = { 117 self.state[self.current_topic] = {
121 'date': datetime.now(), 118 'date': datetime.now(),
@@ -132,13 +129,13 @@ class StudentKnowledge(object): @@ -132,13 +129,13 @@ class StudentKnowledge(object):
132 # - if wrong, counts number of tries. If exceeded, moves on. 129 # - if wrong, counts number of tries. If exceeded, moves on.
133 # ------------------------------------------------------------------------ 130 # ------------------------------------------------------------------------
134 async def check_answer(self, answer): 131 async def check_answer(self, answer):
135 - logger.debug('StudentKnowledge.check_answer()') 132 + logger.debug('[check_answer]')
136 133
137 q = self.current_question 134 q = self.current_question
138 q['answer'] = answer 135 q['answer'] = answer
139 q['finish_time'] = datetime.now() 136 q['finish_time'] = datetime.now()
140 await q.correct_async() 137 await q.correct_async()
141 - logger.debug(f'Grade {q["grade"]:.2} ({q["ref"]})') 138 + logger.debug(f'[check_answer] Grade {q["grade"]:.2} in {q["ref"]}')
142 139
143 if q['grade'] > 0.999: 140 if q['grade'] > 0.999:
144 self.correct_answers += 1 141 self.correct_answers += 1
@@ -154,7 +151,7 @@ class StudentKnowledge(object): @@ -154,7 +151,7 @@ class StudentKnowledge(object):
154 else: 151 else:
155 action = 'wrong' 152 action = 'wrong'
156 if self.current_question['append_wrong']: 153 if self.current_question['append_wrong']:
157 - logger.debug("Append new instance of question at the end") 154 + logger.debug('[check_answer] Wrong, append new instance')
158 self.questions.append(self.factory[q['ref']].generate()) 155 self.questions.append(self.factory[q['ref']].generate())
159 self.next_question() 156 self.next_question()
160 157
@@ -175,7 +172,7 @@ class StudentKnowledge(object): @@ -175,7 +172,7 @@ class StudentKnowledge(object):
175 default_maxtries = self.deps.nodes[self.current_topic]['max_tries'] 172 default_maxtries = self.deps.nodes[self.current_topic]['max_tries']
176 maxtries = self.current_question.get('max_tries', default_maxtries) 173 maxtries = self.current_question.get('max_tries', default_maxtries)
177 self.current_question['tries'] = maxtries 174 self.current_question['tries'] = maxtries
178 - logger.debug(f'Next question is "{self.current_question["ref"]}"') 175 + logger.debug(f'[next_question] "{self.current_question["ref"]}"')
179 176
180 return self.current_question # question or None 177 return self.current_question # question or None
181 178
aprendizations/learnapp.py
@@ -75,7 +75,7 @@ class LearnApp(object): @@ -75,7 +75,7 @@ class LearnApp(object):
75 75
76 errors = 0 76 errors = 0
77 for qref in self.factory: 77 for qref in self.factory:
78 - logger.debug(f'Checking "{qref}"...') 78 + logger.debug(f'[sanity_check_questions] Checking "{qref}"...')
79 try: 79 try:
80 q = self.factory[qref].generate() 80 q = self.factory[qref].generate()
81 except Exception: 81 except Exception:
@@ -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.')
@@ -204,7 +204,7 @@ class LearnApp(object): @@ -204,7 +204,7 @@ class LearnApp(object):
204 finishtime=str(q['finish_time']), 204 finishtime=str(q['finish_time']),
205 student_id=uid, 205 student_id=uid,
206 topic_id=topic)) 206 topic_id=topic))
207 - logger.debug(f'Saved "{q["ref"]}" into database') 207 + logger.debug(f'[check_answer] Saved "{q["ref"]}" into database')
208 208
209 if knowledge.topic_has_finished(): 209 if knowledge.topic_has_finished():
210 # finished topic, save into database 210 # finished topic, save into database
@@ -218,7 +218,7 @@ class LearnApp(object): @@ -218,7 +218,7 @@ class LearnApp(object):
218 .one_or_none() 218 .one_or_none()
219 if a is None: 219 if a is None:
220 # insert new studenttopic into database 220 # insert new studenttopic into database
221 - logger.debug('Database insert new studenttopic') 221 + logger.debug('[check_answer] Database insert studenttopic')
222 t = s.query(Topic).get(topic) 222 t = s.query(Topic).get(topic)
223 u = s.query(Student).get(uid) 223 u = s.query(Student).get(uid)
224 # association object 224 # association object
@@ -227,13 +227,13 @@ class LearnApp(object): @@ -227,13 +227,13 @@ class LearnApp(object):
227 u.topics.append(a) 227 u.topics.append(a)
228 else: 228 else:
229 # update studenttopic in database 229 # update studenttopic in database
230 - logger.debug('Database update studenttopic') 230 + logger.debug('[check_answer] Database update studenttopic')
231 a.level = level 231 a.level = level
232 a.date = date 232 a.date = date
233 233
234 s.add(a) 234 s.add(a)
235 235
236 - logger.debug(f'Saved topic "{topic}" into database') 236 + logger.debug(f'[check_answer] Saved topic "{topic}" into database')
237 237
238 return q, action 238 return q, action
239 239
@@ -244,8 +244,8 @@ class LearnApp(object): @@ -244,8 +244,8 @@ class LearnApp(object):
244 student = self.online[uid]['state'] 244 student = self.online[uid]['state']
245 try: 245 try:
246 await student.start_topic(topic) 246 await student.start_topic(topic)
247 - except Exception:  
248 - logger.warning(f'User "{uid}" could not start topic "{topic}"') 247 + except Exception as e:
  248 + logger.warning(f'User "{uid}" couldn\'t start "{topic}": {e}')
249 else: 249 else:
250 logger.info(f'User "{uid}" started topic "{topic}"') 250 logger.info(f'User "{uid}" started topic "{topic}"')
251 251
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
@@ -318,7 +318,6 @@ class QuestionTextArea(Question): @@ -318,7 +318,6 @@ class QuestionTextArea(Question):
318 text (str) 318 text (str)
319 correct (str with script to run) 319 correct (str with script to run)
320 answer (None or an actual answer) 320 answer (None or an actual answer)
321 - lines (int)  
322 ''' 321 '''
323 322
324 # ------------------------------------------------------------------------ 323 # ------------------------------------------------------------------------
@@ -327,13 +326,12 @@ class QuestionTextArea(Question): @@ -327,13 +326,12 @@ class QuestionTextArea(Question):
327 326
328 self.set_defaults(QDict({ 327 self.set_defaults(QDict({
329 'text': '', 328 'text': '',
330 - 'lines': 8,  
331 - 'timeout': 5, # seconds 329 + 'timeout': 5, # seconds
332 'correct': '', # trying to execute this will fail => grade 0.0 330 'correct': '', # trying to execute this will fail => grade 0.0
333 'args': [] 331 'args': []
334 })) 332 }))
335 333
336 - self['correct'] = path.join(self['path'], self['correct']) # FIXME 334 + self['correct'] = path.join(self['path'], self['correct'])
337 335
338 # ------------------------------------------------------------------------ 336 # ------------------------------------------------------------------------
339 def correct(self) -> None: 337 def correct(self) -> None:
@@ -347,7 +345,39 @@ class QuestionTextArea(Question): @@ -347,7 +345,39 @@ class QuestionTextArea(Question):
347 timeout=self['timeout'] 345 timeout=self['timeout']
348 ) 346 )
349 347
350 - if isinstance(out, dict): 348 + if out is None:
  349 + logger.warning(f'No grade after running "{self["correct"]}".')
  350 + self['grade'] = 0.0
  351 + elif isinstance(out, dict):
  352 + self['comments'] = out.get('comments', '')
  353 + try:
  354 + self['grade'] = float(out['grade'])
  355 + except ValueError:
  356 + logger.error(f'Output error in "{self["correct"]}".')
  357 + except KeyError:
  358 + logger.error(f'No grade in "{self["correct"]}".')
  359 + else:
  360 + try:
  361 + self['grade'] = float(out)
  362 + except (TypeError, ValueError):
  363 + logger.error(f'Invalid grade in "{self["correct"]}".')
  364 +
  365 + # ------------------------------------------------------------------------
  366 + async def correct_async(self) -> None:
  367 + super().correct()
  368 +
  369 + if self['answer'] is not None: # correct answer and parse yaml ouput
  370 + out = await run_script_async(
  371 + script=self['correct'],
  372 + args=self['args'],
  373 + stdin=self['answer'],
  374 + timeout=self['timeout']
  375 + )
  376 +
  377 + if out is None:
  378 + logger.warning(f'No grade after running "{self["correct"]}".')
  379 + self['grade'] = 0.0
  380 + elif isinstance(out, dict):
351 self['comments'] = out.get('comments', '') 381 self['comments'] = out.get('comments', '')
352 try: 382 try:
353 self['grade'] = float(out['grade']) 383 self['grade'] = float(out['grade'])
@@ -372,7 +402,6 @@ class QuestionInformation(Question): @@ -372,7 +402,6 @@ class QuestionInformation(Question):
372 })) 402 }))
373 403
374 # ------------------------------------------------------------------------ 404 # ------------------------------------------------------------------------
375 - # can return negative values for wrong answers  
376 def correct(self) -> None: 405 def correct(self) -> None:
377 super().correct() 406 super().correct()
378 self['grade'] = 1.0 # always "correct" but points should be zero! 407 self['grade'] = 1.0 # always "correct" but points should be zero!
@@ -400,7 +429,7 @@ class QuestionInformation(Question): @@ -400,7 +429,7 @@ class QuestionInformation(Question):
400 # # answer one question and correct it 429 # # answer one question and correct it
401 # question['answer'] = 42 # set answer 430 # question['answer'] = 42 # set answer
402 # question.correct() # correct answer 431 # question.correct() # correct answer
403 -# print(question['grade']) # print grade 432 +# grade = question['grade'] # get grade
404 # =========================================================================== 433 # ===========================================================================
405 class QFactory(object): 434 class QFactory(object):
406 # Depending on the type of question, a different question class will be 435 # Depending on the type of question, a different question class will be
@@ -427,7 +456,7 @@ class QFactory(object): @@ -427,7 +456,7 @@ class QFactory(object):
427 # i.e. a question object (radio, checkbox, ...). 456 # i.e. a question object (radio, checkbox, ...).
428 # ----------------------------------------------------------------------- 457 # -----------------------------------------------------------------------
429 def generate(self) -> Question: 458 def generate(self) -> Question:
430 - logger.debug(f'Generating "{self.question["ref"]}"...') 459 + logger.debug(f'[generate] "{self.question["ref"]}"...')
431 # Shallow copy so that script generated questions will not replace 460 # Shallow copy so that script generated questions will not replace
432 # the original generators 461 # the original generators
433 q = self.question.copy() 462 q = self.question.copy()
@@ -439,7 +468,7 @@ class QFactory(object): @@ -439,7 +468,7 @@ class QFactory(object):
439 if q['type'] == 'generator': 468 if q['type'] == 'generator':
440 logger.debug(f' \\_ Running "{q["script"]}".') 469 logger.debug(f' \\_ Running "{q["script"]}".')
441 q.setdefault('args', []) 470 q.setdefault('args', [])
442 - q.setdefault('stdin', '') # FIXME does not exist anymore? 471 + q.setdefault('stdin', '') # FIXME is it really necessary?
443 script = path.join(q['path'], q['script']) 472 script = path.join(q['path'], q['script'])
444 out = run_script(script=script, args=q['args'], stdin=q['stdin']) 473 out = run_script(script=script, args=q['args'], stdin=q['stdin'])
445 q.update(out) 474 q.update(out)
@@ -455,3 +484,36 @@ class QFactory(object): @@ -455,3 +484,36 @@ class QFactory(object):
455 raise 484 raise
456 else: 485 else:
457 return qinstance 486 return qinstance
  487 +
  488 + # -----------------------------------------------------------------------
  489 + async def generate_async(self) -> Question:
  490 + logger.debug(f'[generate_async] "{self.question["ref"]}"...')
  491 + # Shallow copy so that script generated questions will not replace
  492 + # the original generators
  493 + q = self.question.copy()
  494 + q['qid'] = str(uuid.uuid4()) # unique for each generated question
  495 +
  496 + # If question is of generator type, an external program will be run
  497 + # which will print a valid question in yaml format to stdout. This
  498 + # output is then yaml parsed into a dictionary `q`.
  499 + if q['type'] == 'generator':
  500 + logger.debug(f' \\_ Running "{q["script"]}".')
  501 + q.setdefault('args', [])
  502 + q.setdefault('stdin', '') # FIXME is it really necessary?
  503 + script = path.join(q['path'], q['script'])
  504 + out = await run_script_async(script=script, args=q['args'],
  505 + stdin=q['stdin'])
  506 + q.update(out)
  507 +
  508 + # Finally we create an instance of Question()
  509 + try:
  510 + qinstance = self._types[q['type']](QDict(q)) # of matching class
  511 + except QuestionException as e:
  512 + logger.error(e)
  513 + raise e
  514 + except KeyError:
  515 + logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
  516 + raise
  517 + else:
  518 + logger.debug(f'[generate_async] Done instance of {q["ref"]}')
  519 + return qinstance
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
16 # third party libraries 17 # third party libraries
17 import tornado.ioloop 18 import tornado.ioloop
@@ -253,7 +254,7 @@ class QuestionHandler(BaseHandler): @@ -253,7 +254,7 @@ class QuestionHandler(BaseHandler):
253 # --- get question to render 254 # --- get question to render
254 @tornado.web.authenticated 255 @tornado.web.authenticated
255 def get(self): 256 def get(self):
256 - logging.debug('QuestionHandler.get()') 257 + logging.debug('[QuestionHandler.get]')
257 user = self.current_user 258 user = self.current_user
258 q = self.learn.get_current_question(user) 259 q = self.learn.get_current_question(user)
259 260
@@ -284,7 +285,7 @@ class QuestionHandler(BaseHandler): @@ -284,7 +285,7 @@ class QuestionHandler(BaseHandler):
284 # --- post answer, returns what to do next: shake, new_question, finished 285 # --- post answer, returns what to do next: shake, new_question, finished
285 @tornado.web.authenticated 286 @tornado.web.authenticated
286 async def post(self) -> None: 287 async def post(self) -> None:
287 - logging.debug('QuestionHandler.post()') 288 + logging.debug('[QuestionHandler.post]')
288 user = self.current_user 289 user = self.current_user
289 answer = self.get_body_arguments('answer') # list 290 answer = self.get_body_arguments('answer') # list
290 291
aprendizations/templates/question-textarea.html
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 2
3 {% block answer %} 3 {% block answer %}
4 4
5 -<textarea class="form-control" rows="{{ question['lines'] }}" name="answer" id="code" autofocus>{{ question['answer'] or '' }}</textarea><br /> 5 +<textarea class="form-control" name="answer" id="code" autofocus>{{ question['answer'] or '' }}</textarea><br />
6 <input type="hidden" name="qid" value="{{ question['qid'] }}"> 6 <input type="hidden" name="qid" value="{{ question['qid'] }}">
7 7
8 <script> 8 <script>
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
  16 +
15 17
16 # setup logger for this module 18 # setup logger for this module
17 logger = logging.getLogger(__name__) 19 logger = logging.getLogger(__name__)
@@ -101,7 +103,7 @@ class HighlightRenderer(mistune.Renderer): @@ -101,7 +103,7 @@ class HighlightRenderer(mistune.Renderer):
101 103
102 def table(self, header, body): 104 def table(self, header, body):
103 return '<table class="table table-sm"><thead class="thead-light">' \ 105 return '<table class="table table-sm"><thead class="thead-light">' \
104 - + header + '</thead><tbody>' + body + "</tbody></table>" 106 + + header + '</thead><tbody>' + body + '</tbody></table>'
105 107
106 def image(self, src, title, alt): 108 def image(self, src, title, alt):
107 alt = mistune.escape(alt, quote=True) 109 alt = mistune.escape(alt, quote=True)
@@ -168,7 +170,7 @@ def load_yaml(filename: str, default: Any = None) -&gt; Any: @@ -168,7 +170,7 @@ def load_yaml(filename: str, default: Any = None) -&gt; Any:
168 def run_script(script: str, 170 def run_script(script: str,
169 args: List[str] = [], 171 args: List[str] = [],
170 stdin: str = '', 172 stdin: str = '',
171 - timeout: int = 5) -> Any: 173 + timeout: int = 2) -> Any:
172 174
173 script = path.expanduser(script) 175 script = path.expanduser(script)
174 try: 176 try:
@@ -200,3 +202,41 @@ def run_script(script: str, @@ -200,3 +202,41 @@ def run_script(script: str,
200 logger.error(f'Error parsing yaml output of "{script}"') 202 logger.error(f'Error parsing yaml output of "{script}"')
201 else: 203 else:
202 return output 204 return output
  205 +
  206 +
  207 +# ----------------------------------------------------------------------------
  208 +# Same as above, but asynchronous
  209 +# ----------------------------------------------------------------------------
  210 +async def run_script_async(script: str,
  211 + args: List[str] = [],
  212 + stdin: str = '',
  213 + timeout: int = 2) -> Any:
  214 +
  215 + script = path.expanduser(script)
  216 + args = [str(a) for a in args]
  217 +
  218 + p = await asyncio.create_subprocess_exec(
  219 + script, *args,
  220 + stdin=asyncio.subprocess.PIPE,
  221 + stdout=asyncio.subprocess.PIPE,
  222 + stderr=asyncio.subprocess.DEVNULL,
  223 + )
  224 +
  225 + try:
  226 + stdout, stderr = await asyncio.wait_for(
  227 + p.communicate(input=stdin.encode('utf-8')),
  228 + timeout=timeout
  229 + )
  230 + except asyncio.TimeoutError:
  231 + logger.warning(f'Timeout {timeout}s running script "{script}".')
  232 + return
  233 +
  234 + if p.returncode != 0:
  235 + logger.error(f'Return code {p.returncode} running "{script}".')
  236 + else:
  237 + try:
  238 + output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))
  239 + except Exception:
  240 + logger.error(f'Error parsing yaml output of "{script}"')
  241 + else:
  242 + 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/correct-first_3_planets.py
@@ -2,26 +2,27 @@ @@ -2,26 +2,27 @@
2 2
3 import re 3 import re
4 import sys 4 import sys
  5 +import time
5 6
6 s = sys.stdin.read() 7 s = sys.stdin.read()
7 8
8 -# set of words converted to lowercase  
9 -answer = set(re.findall(r'[\w]+', s.lower()))  
10 -answer.difference_update({'e', 'a', 'planeta', 'planetas'}) # ignore these 9 +ans = set(re.findall(r'[\w]+', s.lower())) # convert answer to lowercase
  10 +ans.difference_update({'e', 'a', 'o', 'planeta', 'planetas'}) # ignore words
11 11
12 # correct set of planets 12 # correct set of planets
13 planets = {'mercúrio', 'vénus', 'terra'} 13 planets = {'mercúrio', 'vénus', 'terra'}
14 14
15 -correct = set.intersection(answer, planets) # the ones I got right  
16 -wrong = set.difference(answer, planets) # the ones I got wrong 15 +correct = set.intersection(ans, planets) # the ones I got right
  16 +wrong = set.difference(ans, planets) # the ones I got wrong
17 17
18 grade = (len(correct) - len(wrong)) / len(planets) 18 grade = (len(correct) - len(wrong)) / len(planets)
19 19
  20 +comments = 'Certo' if grade == 1.0 else 'as iniciais dos planetas são M, V e T'
  21 +
20 out = f'''--- 22 out = f'''---
21 -grade: {grade}''' 23 +grade: {grade}
  24 +comments: {comments}'''
22 25
23 -if grade < 1.0:  
24 - out += '\ncomments: Vou dar uma ajuda, as iniciais são M, V e T...' 26 +time.sleep(2) # simulate computation time (may generate timeout)
25 27
26 print(out) 28 print(out)
27 -exit(0)  
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.