Commit 37855b6ae069b2df800aeaa66e6fd35c6f9dffdf

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

- new generator question in demo.yaml

- fix grade format in logs
- fix many pyyaml warnings
- rewrite run_script and run_script_async
demo/demo.yaml
... ... @@ -65,7 +65,7 @@ questions:
65 65 - tut-success
66 66 - tut-warning
67 67 - tut-alert
68   -
  68 + - tut-generator
69 69  
70 70 # test:
71 71 # - ref1
... ...
demo/questions/generators/generate-question.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Example of a question generator.
  5 +Arguments are read from stdin.
  6 +'''
  7 +
3 8 from random import randint
4 9 import sys
5 10  
6   -arg = sys.stdin.read() # read arguments
  11 +a, b = (int(n) for n in sys.argv[1:])
7 12  
8   -a, b = (int(n) for n in arg.split(','))
  13 +x = randint(a, b)
  14 +y = randint(a, b)
  15 +r = x + y
9 16  
10   -q = f'''---
11   -type: checkbox
  17 +print(f"""---
  18 +type: text
  19 +title: Geradores de perguntas
12 20 text: |
13   - Indique quais das seguintes adições resultam em overflow quando se considera
14   - a adição de números com sinal (complemento para 2) em registos de 8 bits.
  21 + Existe a possibilidade da pergunta ser gerada por um programa externo.
  22 + Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como
  23 + os anteriores. Pode também receber argumentos para parametrizar a geração da
  24 + pergunta. Aqui está um exemplo de uma pergunta gerada por um script python:
15 25  
16   - Os números foram gerados aleatoriamente no intervalo de {a} a {b}.
17   -options:
18   -'''
  26 + ```python
  27 + #!/usr/bin/env python3
  28 +
  29 + from random import randint
  30 + import sys
  31 +
  32 + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando
19 33  
20   -correct = []
21   -for i in range(5):
22   - x = randint(a, b)
23   - y = randint(a, b)
24   - q += f' - "`{x} + {y}`"\n'
25   - correct.append(1 if x + y > 127 else -1)
  34 + x = randint(a, b)
  35 + y = randint(a, b)
  36 + r = x + y
26 37  
27   -q += 'correct: ' + str(correct)
  38 + print(f'''---
  39 + type: text
  40 + title: Contas de somar
  41 + text: |
  42 + bla bla bla
  43 + correct: '{{r}}'
  44 + solution: |
  45 + A solução é {{r}}.''')
  46 + ```
28 47  
29   -print(q)
  48 + Este script deve ter permissões para poder ser executado no terminal. Dá
  49 + jeito usar o comando `gen-somar.py 1 100 | yamllint -` para validar o `yaml`
  50 + gerado.
  51 +
  52 + Para indicar que uma pergunta é gerada externamente, esta é declarada com
  53 +
  54 + ```yaml
  55 + - type: generator
  56 + ref: gen-somar
  57 + script: gen-somar.py
  58 + # opcional
  59 + args: [1, 100]
  60 + ```
  61 +
  62 + Os argumentos `args` são opcionais e são passados para o programa como
  63 + argumentos da linha de comando.
  64 +
  65 + ---
  66 +
  67 + Calcule o resultado de ${x} + {y}$.
  68 +
  69 + Os números foram gerados aleatoriamente no intervalo de {a} a {b}.
  70 +correct: '{r}'
  71 +solution: |
  72 + A solução é {r}.""")
... ...
demo/questions/questions-tutorial.yaml
... ... @@ -22,6 +22,7 @@
22 22  
23 23 # opcional
24 24 duration: 60 # duração da prova em minutos (default: inf)
  25 + autosubmit: true # submissão automática (default: false)
25 26 show_points: true # mostra cotação das perguntas (default: true)
26 27 scale_points: true # recalcula cotações para [scale_min, scale_max]
27 28 scale_max: 20 # limite superior da escala (default: 20)
... ... @@ -159,11 +160,10 @@
159 160 shuffle: false
160 161 ```
161 162  
162   - Por defeito, as respostas erradas descontam, tendo uma cotação de -1/(n-1)
163   - do valor da pergunta, onde n é o número de opções apresentadas ao aluno
164   - (a ideia é o valor esperado ser zero quando as respostas são aleatórias e
165   - uniformemente distribuídas).
166   - Para não descontar acrescenta-se:
  163 + Por defeito, as respostas erradas descontam, tendo uma cotação de
  164 + $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas
  165 + ao aluno (a ideia é o valor esperado ser zero quando as respostas são
  166 + aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se:
167 167  
168 168 ```yaml
169 169 discount: false
... ... @@ -561,3 +561,7 @@
561 561 Indices start at 0.
562 562  
563 563 # ----------------------------------------------------------------------------
  564 +- type: generator
  565 + ref: tut-generator
  566 + script: generators/generate-question.py
  567 + args: [1, 100]
... ...
perguntations/app.py
... ... @@ -193,7 +193,7 @@ class App():
193 193 logger.info('Student %s: %d answers submitted.', uid, len(ans))
194 194  
195 195 grade = await test.correct()
196   - logger.info('Student %s: grade = %.1g points.', uid, grade)
  196 + logger.info('Student %s: grade = %g points.', uid, grade)
197 197  
198 198 # --- save test in JSON format
199 199 fields = (uid, test['ref'], str(test['finish_time']))
... ...
perguntations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Commandline utilizty to initialize and update student database
  5 +'''
  6 +
3 7 # base
4 8 import csv
5 9 import argparse
... ... @@ -16,8 +20,8 @@ from perguntations.models import Base, Student
16 20  
17 21  
18 22 # ===========================================================================
19   -# Parse command line options
20 23 def parse_commandline_arguments():
  24 + '''Parse command line options'''
21 25 parser = argparse.ArgumentParser(
22 26 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23 27 description='Insert new users into a database. Users can be imported '
... ... @@ -65,9 +69,11 @@ def parse_commandline_arguments():
65 69  
66 70  
67 71 # ===========================================================================
68   -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized
69   -# We remove them so that students dont keep asking what it means
70 72 def get_students_from_csv(filename):
  73 + '''
  74 + SIIUE names have alien strings like "(TE)" and are sometimes capitalized
  75 + We remove them so that students dont keep asking what it means
  76 + '''
71 77 csv_settings = {
72 78 'delimiter': ';',
73 79 'quotechar': '"',
... ... @@ -75,8 +81,8 @@ def get_students_from_csv(filename):
75 81 }
76 82  
77 83 try:
78   - with open(filename, encoding='iso-8859-1') as f:
79   - csvreader = csv.DictReader(f, **csv_settings)
  84 + with open(filename, encoding='iso-8859-1') as file:
  85 + csvreader = csv.DictReader(file, **csv_settings)
80 86 students = [{
81 87 'uid': s['N.º'],
82 88 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
... ... @@ -92,20 +98,22 @@ def get_students_from_csv(filename):
92 98  
93 99  
94 100 # ===========================================================================
95   -# replace password by hash for a single student
96   -def hashpw(student, pw=None):
  101 +def hashpw(student, password=None):
  102 + '''replace password by hash for a single student'''
97 103 print('.', end='', flush=True)
98   - if pw is None:
  104 + if password is None:
99 105 student['pw'] = ''
100 106 else:
101   - student['pw'] = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
  107 + student['pw'] = bcrypt.hashpw(password.encode('utf-8'),
  108 + bcrypt.gensalt())
102 109  
103 110  
104 111 # ===========================================================================
105 112 def insert_students_into_db(session, students):
  113 + '''insert list of students into the database'''
106 114 try:
107 115 session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])
108   - for s in students])
  116 + for s in students])
109 117 session.commit()
110 118  
111 119 except sa.exc.IntegrityError:
... ... @@ -115,33 +123,33 @@ def insert_students_into_db(session, students):
115 123  
116 124 # ============================================================================
117 125 def show_students_in_database(session, verbose=False):
118   - try:
119   - users = session.query(Student).all()
120   - except Exception:
121   - raise
  126 + '''get students from database'''
  127 + users = session.query(Student).all()
  128 +
  129 + total_users = len(users)
  130 + print('Registered users:')
  131 + if total_users == 0:
  132 + print(' -- none --')
122 133 else:
123   - n = len(users)
124   - print(f'Registered users:')
125   - if n == 0:
126   - print(' -- none --')
  134 + users.sort(key=lambda u: f'{u.id:>12}') # sort by number
  135 + if verbose:
  136 + for user in users:
  137 + print(f'{user.id:>12} {user.name}')
127 138 else:
128   - users.sort(key=lambda u: f'{u.id:>12}') # sort by number
129   - if verbose:
130   - for u in users:
131   - print(f'{u.id:>12} {u.name}')
132   - else:
133   - print(f'{users[0].id:>12} {users[0].name}')
134   - if n > 1:
135   - print(f'{users[1].id:>12} {users[1].name}')
136   - if n > 3:
137   - print(' | |')
138   - if n > 2:
139   - print(f'{users[-1].id:>12} {users[-1].name}')
140   - print(f'Total: {n}.')
  139 + print(f'{users[0].id:>12} {users[0].name}')
  140 + if total_users > 1:
  141 + print(f'{users[1].id:>12} {users[1].name}')
  142 + if total_users > 3:
  143 + print(' | |')
  144 + if total_users > 2:
  145 + print(f'{users[-1].id:>12} {users[-1].name}')
  146 + print(f'Total: {total_users}.')
141 147  
142 148  
143 149 # ============================================================================
144 150 def main():
  151 + '''insert, update, show students from database'''
  152 +
145 153 args = parse_commandline_arguments()
146 154  
147 155 # --- make list of students to insert/update
... ... @@ -162,27 +170,27 @@ def main():
162 170  
163 171 # --- password hashing
164 172 if students:
165   - print(f'Generating password hashes', end='')
  173 + print('Generating password hashes', end='')
166 174 with ThreadPoolExecutor() as executor: # hashing in parallel
167 175 executor.map(lambda s: hashpw(s, args.pw), students)
168 176 print()
169 177  
170 178 # --- database stuff
171   - print(f'Using database: ', args.db)
  179 + print(f'Using database: {args.db}')
172 180 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False)
173 181 Base.metadata.create_all(engine) # Criates schema if needed
174   - Session = sa.orm.sessionmaker(bind=engine)
175   - session = Session()
  182 + SessionMaker = sa.orm.sessionmaker(bind=engine)
  183 + session = SessionMaker()
176 184  
177 185 if students:
178 186 print(f'Inserting {len(students)}')
179 187 insert_students_into_db(session, students)
180 188  
181   - for s in args.update:
182   - print(f'Updating password of: {s}')
183   - u = session.query(Student).get(s)
184   - pw = (args.pw or s).encode('utf-8')
185   - u.password = bcrypt.hashpw(pw, bcrypt.gensalt())
  189 + for student_id in args.update:
  190 + print(f'Updating password of: {student_id}')
  191 + student = session.query(Student).get(student_id)
  192 + password = (args.pw or student_id).encode('utf-8')
  193 + student.password = bcrypt.hashpw(password, bcrypt.gensalt())
186 194 session.commit()
187 195  
188 196 show_students_in_database(session, args.verbose)
... ...
perguntations/models.py
  1 +'''
  2 +Database tables
  3 +'''
  4 +
1 5  
2 6 from sqlalchemy import Column, ForeignKey, Integer, Float, String
3 7 from sqlalchemy.ext.declarative import declarative_base
... ... @@ -11,6 +15,7 @@ Base = declarative_base()
11 15  
12 16 # ----------------------------------------------------------------------------
13 17 class Student(Base):
  18 + '''Student table'''
14 19 __tablename__ = 'students'
15 20 id = Column(String, primary_key=True)
16 21 name = Column(String)
... ... @@ -29,6 +34,7 @@ class Student(Base):
29 34  
30 35 # ----------------------------------------------------------------------------
31 36 class Test(Base):
  37 + '''Test table'''
32 38 __tablename__ = 'tests'
33 39 id = Column(Integer, primary_key=True) # auto_increment
34 40 ref = Column(String)
... ... @@ -61,6 +67,7 @@ class Test(Base):
61 67  
62 68 # ---------------------------------------------------------------------------
63 69 class Question(Base):
  70 + '''Question table'''
64 71 __tablename__ = 'questions'
65 72 id = Column(Integer, primary_key=True) # auto_increment
66 73 ref = Column(String)
... ...
perguntations/parser_markdown.py
  1 +'''
  2 +Parse markdown and generate HTML
  3 +Includes support for LaTeX formulas
  4 +'''
  5 +
  6 +
1 7 # python standard library
2 8 import logging
3 9 import re
... ... @@ -33,18 +39,19 @@ class MathBlockLexer(mistune.BlockLexer):
33 39 rules = MathBlockGrammar()
34 40 super().__init__(rules, **kwargs)
35 41  
36   - def parse_block_math(self, m):
37   - """Parse a $$math$$ block"""
  42 + def parse_block_math(self, math):
  43 + '''Parse a $$math$$ block'''
38 44 self.tokens.append({
39 45 'type': 'block_math',
40   - 'text': m.group(1)
  46 + 'text': math.group(1)
41 47 })
42 48  
43   - def parse_latex_environment(self, m):
  49 + def parse_latex_environment(self, math):
  50 + '''Parse latex environment in formula'''
44 51 self.tokens.append({
45 52 'type': 'latex_environment',
46   - 'name': m.group(1),
47   - 'text': m.group(2)
  53 + 'name': math.group(1),
  54 + 'text': math.group(2)
48 55 })
49 56  
50 57  
... ... @@ -62,11 +69,11 @@ class MathInlineLexer(mistune.InlineLexer):
62 69 rules = MathInlineGrammar()
63 70 super().__init__(renderer, rules, **kwargs)
64 71  
65   - def output_math(self, m):
66   - return self.renderer.inline_math(m.group(1))
  72 + def output_math(self, math):
  73 + return self.renderer.inline_math(math.group(1))
67 74  
68   - def output_block_math(self, m):
69   - return self.renderer.block_math(m.group(1))
  75 + def output_block_math(self, math):
  76 + return self.renderer.block_math(math.group(1))
70 77  
71 78  
72 79 class MarkdownWithMath(mistune.Markdown):
... ... @@ -104,6 +111,7 @@ class HighlightRenderer(mistune.Renderer):
104 111 + header + '</thead><tbody>' + body + '</tbody></table>'
105 112  
106 113 def image(self, src, title, alt):
  114 + '''render image'''
107 115 alt = mistune.escape(alt, quote=True)
108 116 if title is not None:
109 117 if title: # not empty string, show as caption
... ... @@ -124,22 +132,26 @@ class HighlightRenderer(mistune.Renderer):
124 132 </div>
125 133 '''
126 134  
127   - else: # title indefined, show as inline image
128   - return f'''
129   - <img src="/file?ref={self.qref}&image={src}"
130   - class="figure-img img-fluid" alt="{alt}" title="{title}">
131   - '''
  135 + # title indefined, show as inline image
  136 + return f'''
  137 + <img src="/file?ref={self.qref}&image={src}"
  138 + class="figure-img img-fluid" alt="{alt}" title="{title}">
  139 + '''
132 140  
133 141 # Pass math through unaltered - mathjax does the rendering in the browser
134 142 def block_math(self, text):
  143 + '''bypass block math'''
135 144 return fr'$$ {text} $$'
136 145  
137 146 def latex_environment(self, name, text):
  147 + '''bypass latex environment'''
138 148 return fr'\begin{{{name}}} {text} \end{{{name}}}'
139 149  
140 150 def inline_math(self, text):
  151 + '''bypass inline math'''
141 152 return fr'$$$ {text} $$$'
142 153  
143 154  
144 155 def md_to_html(qref='.'):
  156 + '''markdown to html interface'''
145 157 return MarkdownWithMath(HighlightRenderer(qref=qref))
... ...
perguntations/serve.py
... ... @@ -12,12 +12,10 @@ import sys
12 12 import base64
13 13 import uuid
14 14 import logging.config
15   -# import argparse
16 15 import mimetypes
17 16 import signal
18 17 import functools
19 18 import json
20   -# import ssl
21 19  
22 20 # user installed libraries
23 21 import tornado.ioloop
... ... @@ -30,9 +28,10 @@ from perguntations.parser_markdown import md_to_html
30 28  
31 29  
32 30 # ----------------------------------------------------------------------------
33   -# Web Application. Routes to handler classes.
34   -# ----------------------------------------------------------------------------
35 31 class WebApplication(tornado.web.Application):
  32 + '''
  33 + Web Application. Routes to handler classes.
  34 + '''
36 35 def __init__(self, testapp, debug=False):
37 36 handlers = [
38 37 (r'/login', LoginHandler),
... ... @@ -73,9 +72,11 @@ def admin_only(func):
73 72  
74 73  
75 74 # ----------------------------------------------------------------------------
76   -# Base handler. Other handlers will inherit this one.
77   -# ----------------------------------------------------------------------------
78 75 class BaseHandler(tornado.web.RequestHandler):
  76 + '''
  77 + Base handler. Other handlers will inherit this one.
  78 + '''
  79 +
79 80 @property
80 81 def testapp(self):
81 82 '''
... ... @@ -152,8 +153,10 @@ class BaseHandler(tornado.web.RequestHandler):
152 153 # AdminSocketHandler.send_updates(chat) # send to clients
153 154  
154 155  
155   -# --- ADMIN ------------------------------------------------------------------
  156 +# ----------------------------------------------------------------------------
156 157 class AdminHandler(BaseHandler):
  158 + '''Handle /admin'''
  159 +
157 160 # SUPPORTED_METHODS = ['GET', 'POST']
158 161  
159 162 @tornado.web.authenticated
... ... @@ -209,19 +212,15 @@ class AdminHandler(BaseHandler):
209 212  
210 213  
211 214 # ----------------------------------------------------------------------------
212   -# /login
213   -# ----------------------------------------------------------------------------
214 215 class LoginHandler(BaseHandler):
  216 + '''Handle /login'''
  217 +
215 218 def get(self):
216   - '''
217   - Render login page.
218   - '''
  219 + '''Render login page.'''
219 220 self.render('login.html', error='')
220 221  
221 222 async def post(self):
222   - '''
223   - Authenticates student (prefix 'l' are removed) and login.
224   - '''
  223 + '''Authenticates student (prefix 'l' are removed) and login.'''
225 224  
226 225 uid = self.get_body_argument('uid').lstrip('l')
227 226 password = self.get_body_argument('pw')
... ... @@ -235,14 +234,12 @@ class LoginHandler(BaseHandler):
235 234  
236 235  
237 236 # ----------------------------------------------------------------------------
238   -# /logout
239   -# ----------------------------------------------------------------------------
240 237 class LogoutHandler(BaseHandler):
  238 + '''Handle /logout'''
  239 +
241 240 @tornado.web.authenticated
242 241 def get(self):
243   - '''
244   - Logs out a user.
245   - '''
  242 + '''Logs out a user.'''
246 243 self.clear_cookie('user')
247 244 self.redirect('/')
248 245  
... ... @@ -251,9 +248,10 @@ class LogoutHandler(BaseHandler):
251 248  
252 249  
253 250 # ----------------------------------------------------------------------------
254   -# handles root / to redirect students to /test and admininistrator to /admin
255   -# ----------------------------------------------------------------------------
256 251 class RootHandler(BaseHandler):
  252 + '''
  253 + Handles / to redirect students and admin to /test and /admin, resp.
  254 + '''
257 255  
258 256 @tornado.web.authenticated
259 257 def get(self):
... ...
perguntations/tools.py
  1 +'''
  2 +This module contains helper functions to:
  3 +- load yaml files and report errors
  4 +- run external programs (sync and async)
  5 +'''
  6 +
1 7  
2 8 # python standard library
3 9 import asyncio
... ... @@ -15,105 +21,119 @@ logger = logging.getLogger(__name__)
15 21  
16 22  
17 23 # ---------------------------------------------------------------------------
18   -# load data from yaml file
19   -# ---------------------------------------------------------------------------
20 24 def load_yaml(filename: str, default: Any = None) -> Any:
  25 + '''load data from yaml file'''
  26 +
21 27 filename = path.expanduser(filename)
22 28 try:
23   - f = open(filename, 'r', encoding='utf-8')
24   - except Exception as e:
25   - logger.error(e)
  29 + file = open(filename, 'r', encoding='utf-8')
  30 + except Exception as exc:
  31 + logger.error(exc)
26 32 if default is not None:
27 33 return default
28   - else:
29   - raise
  34 + raise
30 35  
31   - with f:
  36 + with file:
32 37 try:
33   - return yaml.safe_load(f)
34   - except yaml.YAMLError as e:
35   - logger.error(str(e).replace('\n', ' '))
  38 + return yaml.safe_load(file)
  39 + except yaml.YAMLError as exc:
  40 + logger.error(str(exc).replace('\n', ' '))
36 41 if default is not None:
37 42 return default
38   - else:
39   - raise
  43 + raise
40 44  
41 45  
42 46 # ---------------------------------------------------------------------------
43   -# Runs a script and returns its stdout parsed as yaml, or None on error.
44   -# The script is run in another process but this function blocks waiting
45   -# for its termination.
46   -# ---------------------------------------------------------------------------
47 47 def run_script(script: str,
48   - args: List[str] = [],
  48 + args: List[str],
49 49 stdin: str = '',
50   - timeout: int = 2) -> Any:
51   -
  50 + timeout: int = 3) -> Any:
  51 + '''
  52 + Runs a script and returns its stdout parsed as yaml, or None on error.
  53 + The script is run in another process but this function blocks waiting
  54 + for its termination.
  55 + '''
  56 + logger.info('run_script "%s"', script)
  57 +
  58 + output = None
52 59 script = path.expanduser(script)
  60 + cmd = [script] + [str(a) for a in args]
  61 +
  62 + # --- run process
53 63 try:
54   - cmd = [script] + [str(a) for a in args]
55   - p = subprocess.run(cmd,
56   - input=stdin,
57   - stdout=subprocess.PIPE,
58   - stderr=subprocess.STDOUT,
59   - universal_newlines=True,
60   - timeout=timeout,
61   - )
62   - except FileNotFoundError:
63   - logger.error(f'Can not execute script "{script}": not found.')
64   - except PermissionError:
65   - logger.error(f'Can not execute script "{script}": wrong permissions.')
  64 + proc = subprocess.run(cmd,
  65 + input=stdin,
  66 + stdout=subprocess.PIPE,
  67 + stderr=subprocess.STDOUT,
  68 + universal_newlines=True,
  69 + timeout=timeout,
  70 + check=False,
  71 + )
66 72 except OSError:
67   - logger.error(f'Can not execute script "{script}": unknown reason.')
  73 + logger.error('Can not execute script "%s".', script)
  74 + return output
68 75 except subprocess.TimeoutExpired:
69   - logger.error(f'Timeout {timeout}s exceeded while running "{script}".')
  76 + logger.error('Timeout %ds exceeded running "%s".', timeout, script)
  77 + return output
70 78 except Exception:
71   - logger.error(f'An Exception ocurred running {script}.')
72   - else:
73   - if p.returncode != 0:
74   - logger.error(f'Return code {p.returncode} running "{script}".')
75   - else:
76   - try:
77   - output = yaml.safe_load(p.stdout)
78   - except Exception:
79   - logger.error(f'Error parsing yaml output of "{script}"')
80   - else:
81   - return output
  79 + logger.error('An Exception ocurred running "%s".', script)
  80 + return output
  81 +
  82 + # --- check return code
  83 + if proc.returncode != 0:
  84 + logger.error('Return code %d running "%s".', proc.returncode, script)
  85 + return output
  86 +
  87 + # --- parse yaml
  88 + try:
  89 + output = yaml.safe_load(proc.stdout)
  90 + except yaml.YAMLError:
  91 + logger.error('Error parsing yaml output of "%s".', script)
  92 +
  93 + return output
82 94  
83 95  
84   -# ----------------------------------------------------------------------------
85   -# Same as above, but asynchronous
86 96 # ----------------------------------------------------------------------------
87 97 async def run_script_async(script: str,
88   - args: List[str] = [],
  98 + args: List[str],
89 99 stdin: str = '',
90   - timeout: int = 2) -> Any:
  100 + timeout: int = 3) -> Any:
  101 + '''Same as above, but asynchronous'''
91 102  
92 103 script = path.expanduser(script)
93 104 args = [str(a) for a in args]
  105 + output = None
94 106  
95   - p = await asyncio.create_subprocess_exec(
96   - script, *args,
97   - stdin=asyncio.subprocess.PIPE,
98   - stdout=asyncio.subprocess.PIPE,
99   - stderr=asyncio.subprocess.DEVNULL,
100   - )
101   -
  107 + # --- start process
102 108 try:
103   - stdout, stderr = await asyncio.wait_for(
104   - p.communicate(input=stdin.encode('utf-8')),
105   - timeout=timeout
  109 + proc = await asyncio.create_subprocess_exec(
  110 + script, *args,
  111 + stdin=asyncio.subprocess.PIPE,
  112 + stdout=asyncio.subprocess.PIPE,
  113 + stderr=asyncio.subprocess.DEVNULL,
106 114 )
  115 + except OSError:
  116 + logger.error('Can not execute script "%s".', script)
  117 + return output
  118 +
  119 + # --- send input and wait for termination
  120 + try:
  121 + stdout, _ = await asyncio.wait_for(
  122 + proc.communicate(input=stdin.encode('utf-8')),
  123 + timeout=timeout)
107 124 except asyncio.TimeoutError:
108   - logger.warning(f'Timeout {timeout}s running script "{script}".')
109   - return
  125 + logger.warning('Timeout %ds running script "%s".', timeout, script)
  126 + return output
110 127  
111   - if p.returncode != 0:
112   - logger.error(f'Return code {p.returncode} running "{script}".')
113   - else:
114   - try:
115   - output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))
116   - except Exception:
117   - logger.error(f'Error parsing yaml output of "{script}"')
118   - else:
119   - return output
  128 + # --- check return code
  129 + if proc.returncode != 0:
  130 + logger.error('Return code %d running "%s".', proc.returncode, script)
  131 + return output
  132 +
  133 + # --- parse yaml
  134 + try:
  135 + output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))
  136 + except yaml.YAMLError:
  137 + logger.error('Error parsing yaml output of "%s"', script)
  138 +
  139 + return output
... ...