Commit a221e180a4d478f42187afb1398f09fde1665634
1 parent
a1764bc3
Exists in
master
and in
1 other branch
- fixed mistune to also serve images.
- nice error message when certificates missing. - handle files. - updated checkbox and radio questions to generate reduced set of randomized options.
Showing
3 changed files
with
138 additions
and
71 deletions
Show diff stats
questions.py
| @@ -61,16 +61,19 @@ class QuestionRadio(Question): | @@ -61,16 +61,19 @@ class QuestionRadio(Question): | ||
| 61 | type (str) | 61 | type (str) |
| 62 | text (str) | 62 | text (str) |
| 63 | options (list of strings) | 63 | options (list of strings) |
| 64 | - shuffle (bool, default=True) | ||
| 65 | correct (list of floats) | 64 | correct (list of floats) |
| 66 | discount (bool, default=True) | 65 | discount (bool, default=True) |
| 67 | answer (None or an actual answer) | 66 | answer (None or an actual answer) |
| 67 | + shuffle (bool, default=True) | ||
| 68 | + choose (int) # only used if shuffle=True | ||
| 68 | ''' | 69 | ''' |
| 69 | 70 | ||
| 70 | #------------------------------------------------------------------------ | 71 | #------------------------------------------------------------------------ |
| 71 | def __init__(self, q): | 72 | def __init__(self, q): |
| 72 | super().__init__(q) | 73 | super().__init__(q) |
| 73 | 74 | ||
| 75 | + n = len(self['options']) | ||
| 76 | + | ||
| 74 | # set defaults if missing | 77 | # set defaults if missing |
| 75 | self.set_defaults({ | 78 | self.set_defaults({ |
| 76 | 'text': '', | 79 | 'text': '', |
| @@ -79,23 +82,33 @@ class QuestionRadio(Question): | @@ -79,23 +82,33 @@ class QuestionRadio(Question): | ||
| 79 | 'discount': True, | 82 | 'discount': True, |
| 80 | }) | 83 | }) |
| 81 | 84 | ||
| 82 | - n = len(self['options']) | ||
| 83 | - | ||
| 84 | # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] | 85 | # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] |
| 85 | # correctness levels from 0.0 to 1.0 (no discount here!) | 86 | # correctness levels from 0.0 to 1.0 (no discount here!) |
| 86 | if isinstance(self['correct'], int): | 87 | if isinstance(self['correct'], int): |
| 87 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] | 88 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] |
| 88 | 89 | ||
| 89 | - elif len(self['correct']) != n: | ||
| 90 | - logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') | ||
| 91 | - | ||
| 92 | - # generate random permutation, e.g. [2,1,4,0,3] | ||
| 93 | - # and apply to `options` and `correct` | ||
| 94 | if self['shuffle']: | 90 | if self['shuffle']: |
| 95 | - perm = list(range(n)) | ||
| 96 | - random.shuffle(perm) | ||
| 97 | - self['options'] = [ str(self['options'][i]) for i in perm ] | ||
| 98 | - self['correct'] = [ float(self['correct'][i]) for i in perm ] | 91 | + # separate right from wrong options |
| 92 | + right = [i for i in range(n) if self['correct'][i] == 1] | ||
| 93 | + wrong = [i for i in range(n) if self['correct'][i] < 1] | ||
| 94 | + | ||
| 95 | + self.set_defaults({'choose': 1+len(wrong)}) | ||
| 96 | + | ||
| 97 | + # choose 1 correct option | ||
| 98 | + r = random.choice(right) | ||
| 99 | + options = [ self['options'][r] ] | ||
| 100 | + correct = [ 1.0 ] | ||
| 101 | + | ||
| 102 | + # choose remaining wrong options | ||
| 103 | + random.shuffle(wrong) | ||
| 104 | + nwrong = self['choose']-1 | ||
| 105 | + options.extend(self['options'][i] for i in wrong[:nwrong]) | ||
| 106 | + correct.extend(self['correct'][i] for i in wrong[:nwrong]) | ||
| 107 | + | ||
| 108 | + # final shuffle of the options | ||
| 109 | + perm = random.sample(range(self['choose']), self['choose']) | ||
| 110 | + self['options'] = [ str(options[i]) for i in perm ] | ||
| 111 | + self['correct'] = [ float(correct[i]) for i in perm ] | ||
| 99 | 112 | ||
| 100 | #------------------------------------------------------------------------ | 113 | #------------------------------------------------------------------------ |
| 101 | # can return negative values for wrong answers | 114 | # can return negative values for wrong answers |
| @@ -122,6 +135,7 @@ class QuestionCheckbox(Question): | @@ -122,6 +135,7 @@ class QuestionCheckbox(Question): | ||
| 122 | shuffle (bool, default True) | 135 | shuffle (bool, default True) |
| 123 | correct (list of floats) | 136 | correct (list of floats) |
| 124 | discount (bool, default True) | 137 | discount (bool, default True) |
| 138 | + choose (int) | ||
| 125 | answer (None or an actual answer) | 139 | answer (None or an actual answer) |
| 126 | ''' | 140 | ''' |
| 127 | 141 | ||
| @@ -137,18 +151,30 @@ class QuestionCheckbox(Question): | @@ -137,18 +151,30 @@ class QuestionCheckbox(Question): | ||
| 137 | 'correct': [0.0] * n, # useful for questionaries | 151 | 'correct': [0.0] * n, # useful for questionaries |
| 138 | 'shuffle': True, | 152 | 'shuffle': True, |
| 139 | 'discount': True, | 153 | 'discount': True, |
| 154 | + 'choose': n, # number of options | ||
| 140 | }) | 155 | }) |
| 141 | 156 | ||
| 142 | if len(self['correct']) != n: | 157 | if len(self['correct']) != n: |
| 143 | - logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') | 158 | + logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".') |
| 159 | + | ||
| 160 | + # if an option is a list of (right, wrong), pick one | ||
| 161 | + # FIXME it's possible that all options are chosen wrong | ||
| 162 | + options = [] | ||
| 163 | + correct = [] | ||
| 164 | + for o,c in zip(self['options'], self['correct']): | ||
| 165 | + if isinstance(o, list): | ||
| 166 | + r = random.randint(0,1) | ||
| 167 | + o = o[r] | ||
| 168 | + c = c if r==0 else -c | ||
| 169 | + options.append(str(o)) | ||
| 170 | + correct.append(float(c)) | ||
| 144 | 171 | ||
| 145 | # generate random permutation, e.g. [2,1,4,0,3] | 172 | # generate random permutation, e.g. [2,1,4,0,3] |
| 146 | # and apply to `options` and `correct` | 173 | # and apply to `options` and `correct` |
| 147 | if self['shuffle']: | 174 | if self['shuffle']: |
| 148 | - perm = list(range(n)) | ||
| 149 | - random.shuffle(perm) | ||
| 150 | - self['options'] = [ str(self['options'][i]) for i in perm ] | ||
| 151 | - self['correct'] = [ float(self['correct'][i]) for i in perm ] | 175 | + perm = random.sample(range(n), self['choose']) |
| 176 | + self['options'] = [options[i] for i in perm] | ||
| 177 | + self['correct'] = [correct[i] for i in perm] | ||
| 152 | 178 | ||
| 153 | #------------------------------------------------------------------------ | 179 | #------------------------------------------------------------------------ |
| 154 | # can return negative values for wrong answers | 180 | # can return negative values for wrong answers |
| @@ -277,7 +303,7 @@ class QuestionNumericInterval(Question): | @@ -277,7 +303,7 @@ class QuestionNumericInterval(Question): | ||
| 277 | # answer = locale.atof(self['answer']) | 303 | # answer = locale.atof(self['answer']) |
| 278 | 304 | ||
| 279 | except ValueError: | 305 | except ValueError: |
| 280 | - self['comments'] = 'A resposta dada não é numérica.' | 306 | + self['comments'] = 'A resposta não é numérica.' |
| 281 | self['grade'] = 0.0 | 307 | self['grade'] = 0.0 |
| 282 | else: | 308 | else: |
| 283 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 | 309 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
| @@ -316,7 +342,7 @@ class QuestionTextArea(Question): | @@ -316,7 +342,7 @@ class QuestionTextArea(Question): | ||
| 316 | 342 | ||
| 317 | if self['answer'] is not None: | 343 | if self['answer'] is not None: |
| 318 | # correct answer | 344 | # correct answer |
| 319 | - out = run_script( | 345 | + out = run_script( # and parse yaml ouput |
| 320 | script=self['correct'], | 346 | script=self['correct'], |
| 321 | stdin=self['answer'], | 347 | stdin=self['answer'], |
| 322 | timeout=self['timeout'] | 348 | timeout=self['timeout'] |
| @@ -326,7 +352,7 @@ class QuestionTextArea(Question): | @@ -326,7 +352,7 @@ class QuestionTextArea(Question): | ||
| 326 | self['grade'] = float(out) | 352 | self['grade'] = float(out) |
| 327 | 353 | ||
| 328 | elif isinstance(out, dict): | 354 | elif isinstance(out, dict): |
| 329 | - self['comments'] = out.get('comments', self['comments']) | 355 | + self['comments'] = out.get('comments', '') |
| 330 | try: | 356 | try: |
| 331 | self['grade'] = float(out['grade']) | 357 | self['grade'] = float(out['grade']) |
| 332 | except ValueError: | 358 | except ValueError: |
serve.py
| @@ -8,7 +8,7 @@ import logging.config | @@ -8,7 +8,7 @@ import logging.config | ||
| 8 | import json | 8 | import json |
| 9 | import base64 | 9 | import base64 |
| 10 | import uuid | 10 | import uuid |
| 11 | -# from mimetypes import guess_type | 11 | +import mimetypes |
| 12 | import signal | 12 | import signal |
| 13 | 13 | ||
| 14 | # packages | 14 | # packages |
| @@ -88,22 +88,63 @@ class LoginHandler(BaseHandler): | @@ -88,22 +88,63 @@ class LoginHandler(BaseHandler): | ||
| 88 | class LogoutHandler(BaseHandler): | 88 | class LogoutHandler(BaseHandler): |
| 89 | @tornado.web.authenticated | 89 | @tornado.web.authenticated |
| 90 | def get(self): | 90 | def get(self): |
| 91 | - self.testapp.logout(self.current_user) | 91 | + # self.testapp.logout(self.current_user) |
| 92 | self.clear_cookie('user') | 92 | self.clear_cookie('user') |
| 93 | self.redirect('/') | 93 | self.redirect('/') |
| 94 | 94 | ||
| 95 | + def on_finish(self): | ||
| 96 | + self.testapp.logout(self.current_user) | ||
| 95 | 97 | ||
| 96 | -# ------------------------------------------------------------------------- | ||
| 97 | -# FIXME checkit | 98 | + |
| 99 | +# ---------------------------------------------------------------------------- | ||
| 100 | +# Serves files from the /public subdir of the topics. | ||
| 101 | +# Based on https://bhch.github.io/posts/2017/12/serving-large-files-with-tornado-safely-without-blocking/ | ||
| 102 | +# ---------------------------------------------------------------------------- | ||
| 98 | class FileHandler(BaseHandler): | 103 | class FileHandler(BaseHandler): |
| 104 | + chunk_size = 4 * 1024 * 1024 # serve up to 4 MiB multiple times | ||
| 105 | + | ||
| 99 | @tornado.web.authenticated | 106 | @tornado.web.authenticated |
| 100 | - def get(self): | 107 | + async def get(self, filename): |
| 101 | uid = self.current_user | 108 | uid = self.current_user |
| 102 | - qref = self.get_query_argument('ref') | ||
| 103 | - qfile = self.get_query_argument('file') | ||
| 104 | - print(f'FileHandler: ref={ref}, file={file}') | 109 | + # public_dir = self.learn.get_current_public_dir(uid) |
| 110 | + filepath = path.expanduser(path.join(public_dir, filename)) | ||
| 111 | + | ||
| 112 | + try: | ||
| 113 | + f = open(filepath, 'rb') | ||
| 114 | + except FileNotFoundError: | ||
| 115 | + logging.error(f'File not found: {filepath}') | ||
| 116 | + except PermissionError: | ||
| 117 | + logging.error(f'No permission: {filepath}') | ||
| 118 | + else: | ||
| 119 | + content_type = mimetypes.guess_type(filename) | ||
| 120 | + self.set_header("Content-Type", content_type[0]) | ||
| 121 | + | ||
| 122 | + # divide the file into chunks and write one chunk at a time, so | ||
| 123 | + # that the write does not block the ioloop for very long. | ||
| 124 | + with f: | ||
| 125 | + chunk = f.read(self.chunk_size) | ||
| 126 | + while chunk: | ||
| 127 | + try: | ||
| 128 | + self.write(chunk) # write the cunk to response | ||
| 129 | + await self.flush() # flush the current chunk to socket | ||
| 130 | + except iostream.StreamClosedError: | ||
| 131 | + break # client closed the connection | ||
| 132 | + finally: | ||
| 133 | + del chunk | ||
| 134 | + await gen.sleep(0.000000001) # 1 nanosecond (hack) | ||
| 135 | + # FIXME in the upcomming tornado 5.0 use `await asyncio.sleep(0)` instead | ||
| 136 | + chunk = f.read(self.chunk_size) | ||
| 137 | +# ------------------------------------------------------------------------- | ||
| 138 | +# FIXME checkit | ||
| 139 | +# class FileHandler(BaseHandler): | ||
| 140 | +# @tornado.web.authenticated | ||
| 141 | +# def get(self): | ||
| 142 | +# uid = self.current_user | ||
| 143 | +# qref = self.get_query_argument('ref') | ||
| 144 | +# qfile = self.get_query_argument('file') | ||
| 145 | +# print(f'FileHandler: ref={ref}, file={file}') | ||
| 105 | 146 | ||
| 106 | - self.write(self.testapp.get_file(ref, filename)) | 147 | +# self.write(self.testapp.get_file(ref, filename)) |
| 107 | 148 | ||
| 108 | 149 | ||
| 109 | # if not os.path.isfile(file_location): | 150 | # if not os.path.isfile(file_location): |
| @@ -227,15 +268,6 @@ class ReviewHandler(BaseHandler): | @@ -227,15 +268,6 @@ class ReviewHandler(BaseHandler): | ||
| 227 | self.render('review.html', t=t, md=md_to_html, templ=self._templates) | 268 | self.render('review.html', t=t, md=md_to_html, templ=self._templates) |
| 228 | 269 | ||
| 229 | 270 | ||
| 230 | -# --- FILE ------------------------------------------------------------- | ||
| 231 | -# class FIXME | ||
| 232 | -# @cherrypy.expose | ||
| 233 | -# @require(name_is('0')) | ||
| 234 | -# def absfile(self, name): | ||
| 235 | -# filename = path.abspath(path.join(self.app.get_questions_path(), name)) | ||
| 236 | -# return cherrypy.lib.static.serve_file(filename) | ||
| 237 | - | ||
| 238 | - | ||
| 239 | # ------------------------------------------------------------------------- | 271 | # ------------------------------------------------------------------------- |
| 240 | # FIXME this should be a post in the test with command giveup instead of correct... | 272 | # FIXME this should be a post in the test with command giveup instead of correct... |
| 241 | class GiveupHandler(BaseHandler): | 273 | class GiveupHandler(BaseHandler): |
| @@ -363,25 +395,31 @@ def main(): | @@ -363,25 +395,31 @@ def main(): | ||
| 363 | sys.exit(1) | 395 | sys.exit(1) |
| 364 | 396 | ||
| 365 | # --- create web application | 397 | # --- create web application |
| 398 | + logging.info('Starting Web App (tornado)') | ||
| 366 | try: | 399 | try: |
| 367 | webapp = WebApplication(testapp, debug=arg.debug) | 400 | webapp = WebApplication(testapp, debug=arg.debug) |
| 368 | except Exception as e: | 401 | except Exception as e: |
| 369 | - logging.critical('Can\'t start application.') | 402 | + logging.critical('Failed to start web application.') |
| 370 | raise e | 403 | raise e |
| 371 | 404 | ||
| 372 | # --- create webserver | 405 | # --- create webserver |
| 373 | - http_server = tornado.httpserver.HTTPServer(webapp, | ||
| 374 | - ssl_options={ | ||
| 375 | - "certfile": "certs/cert.crt", | ||
| 376 | - "keyfile": "certs/cert.key" | ||
| 377 | - }) | ||
| 378 | - http_server.listen(8443) | 406 | + try: |
| 407 | + http_server = tornado.httpserver.HTTPServer(webapp, | ||
| 408 | + ssl_options={ | ||
| 409 | + "certfile": "certs/cert.pem", | ||
| 410 | + "keyfile": "certs/privkey.pem" | ||
| 411 | + }) | ||
| 412 | + except ValueError: | ||
| 413 | + logging.critical('Certificates cert.pem, privkey.pem not found') | ||
| 414 | + sys.exit(1) | ||
| 415 | + else: | ||
| 416 | + http_server.listen(8443) | ||
| 379 | 417 | ||
| 380 | # --- run webserver | 418 | # --- run webserver |
| 419 | + logging.info('Webserver running... (Ctrl-C to stop)') | ||
| 381 | signal.signal(signal.SIGINT, signal_handler) | 420 | signal.signal(signal.SIGINT, signal_handler) |
| 382 | 421 | ||
| 383 | try: | 422 | try: |
| 384 | - logging.info('Webserver running... (Ctrl-C to stop)') | ||
| 385 | tornado.ioloop.IOLoop.current().start() # running... | 423 | tornado.ioloop.IOLoop.current().start() # running... |
| 386 | except Exception: | 424 | except Exception: |
| 387 | logging.critical('Webserver stopped.') | 425 | logging.critical('Webserver stopped.') |
tools.py
| @@ -16,11 +16,11 @@ from pygments.formatters import HtmlFormatter | @@ -16,11 +16,11 @@ from pygments.formatters import HtmlFormatter | ||
| 16 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 17 | 17 | ||
| 18 | 18 | ||
| 19 | -# --------------------------------------------------------------------------- | 19 | +# ------------------------------------------------------------------------- |
| 20 | # Markdown to HTML renderer with support for LaTeX equations | 20 | # Markdown to HTML renderer with support for LaTeX equations |
| 21 | # Inline math: $x$ | 21 | # Inline math: $x$ |
| 22 | # Block math: $$x$$ or \begin{equation}x\end{equation} | 22 | # Block math: $$x$$ or \begin{equation}x\end{equation} |
| 23 | -# --------------------------------------------------------------------------- | 23 | +# ------------------------------------------------------------------------- |
| 24 | class MathBlockGrammar(mistune.BlockGrammar): | 24 | class MathBlockGrammar(mistune.BlockGrammar): |
| 25 | block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) | 25 | block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) |
| 26 | latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) | 26 | latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) |
| @@ -99,19 +99,21 @@ class HighlightRenderer(mistune.Renderer): | @@ -99,19 +99,21 @@ class HighlightRenderer(mistune.Renderer): | ||
| 99 | def table(self, header, body): | 99 | def table(self, header, body): |
| 100 | return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" | 100 | return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" |
| 101 | 101 | ||
| 102 | - # def image(self, src, title, text): | ||
| 103 | - # if src.startswith('javascript:'): | ||
| 104 | - # src = '' | ||
| 105 | - # text = mistune.escape(text, quote=True) | ||
| 106 | - # if title: | ||
| 107 | - # title = mistune.escape(title, quote=True) | ||
| 108 | - # html = '<img class="img-responsive center-block" src="%s" alt="%s" title="%s"' % (src, text, title) | ||
| 109 | - # else: | ||
| 110 | - # html = '<img class="img-responsive center-block" src="%s" alt="%s"' % (src, text) | ||
| 111 | - # if self.options.get('use_xhtml'): | ||
| 112 | - # return '%s />' % html | ||
| 113 | - # return '%s>' % html | 102 | + def image(self, src, title, alt): |
| 103 | + alt = mistune.escape(alt, quote=True) | ||
| 104 | + title = mistune.escape(title or '', quote=True) | ||
| 105 | + if title: | ||
| 106 | + caption = f'<figcaption class="figure-caption">{title}</figcaption>' | ||
| 107 | + else: | ||
| 108 | + caption = '' | ||
| 114 | 109 | ||
| 110 | + return f''' | ||
| 111 | + <figure class="figure"> | ||
| 112 | + <img src="/file/{src}" class="figure-img img-fluid rounded" alt="{alt}" title="{title}"> | ||
| 113 | + {caption} | ||
| 114 | + </figure> | ||
| 115 | + ''' | ||
| 116 | + # return f'<img src="/file/{src}" class="img-fluid mx-auto d-block" alt="{alt}" title="{title}">' | ||
| 115 | 117 | ||
| 116 | # Pass math through unaltered - mathjax does the rendering in the browser | 118 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 117 | def block_math(self, text): | 119 | def block_math(self, text): |
| @@ -126,35 +128,36 @@ class HighlightRenderer(mistune.Renderer): | @@ -126,35 +128,36 @@ class HighlightRenderer(mistune.Renderer): | ||
| 126 | 128 | ||
| 127 | markdown = MarkdownWithMath(HighlightRenderer(escape=True)) # hard_wrap=True to insert <br> on newline | 129 | markdown = MarkdownWithMath(HighlightRenderer(escape=True)) # hard_wrap=True to insert <br> on newline |
| 128 | 130 | ||
| 129 | -def md_to_html(text): | 131 | +def md_to_html(text, q=None): |
| 130 | return markdown(text) | 132 | return markdown(text) |
| 131 | 133 | ||
| 132 | # --------------------------------------------------------------------------- | 134 | # --------------------------------------------------------------------------- |
| 133 | # load data from yaml file | 135 | # load data from yaml file |
| 134 | # --------------------------------------------------------------------------- | 136 | # --------------------------------------------------------------------------- |
| 135 | def load_yaml(filename, default=None): | 137 | def load_yaml(filename, default=None): |
| 138 | + filename = path.expanduser(filename) | ||
| 136 | try: | 139 | try: |
| 137 | - f = open(path.expanduser(filename), 'r', encoding='utf-8') | 140 | + f = open(filename, 'r', encoding='utf-8') |
| 138 | except FileNotFoundError: | 141 | except FileNotFoundError: |
| 139 | - logger.error(f'Can\'t open "{script}": not found.') | ||
| 140 | - return default | 142 | + logger.error(f'Can\'t open "{filename}": not found') |
| 141 | except PermissionError: | 143 | except PermissionError: |
| 142 | - logger.error(f'Can\'t open "{script}": no permission.') | ||
| 143 | - return default | 144 | + logger.error(f'Can\'t open "{filename}": no permission') |
| 144 | except IOError: | 145 | except IOError: |
| 145 | - logger.error(f'Can\'t open file "{filename}".') | ||
| 146 | - return default | 146 | + logger.error(f'Can\'t open file "{filename}"') |
| 147 | else: | 147 | else: |
| 148 | with f: | 148 | with f: |
| 149 | try: | 149 | try: |
| 150 | - return yaml.load(f) | 150 | + default = yaml.load(f) |
| 151 | except yaml.YAMLError as e: | 151 | except yaml.YAMLError as e: |
| 152 | mark = e.problem_mark | 152 | mark = e.problem_mark |
| 153 | - logger.error(f'In YAML file "{filename}" near line {mark.line}, column {mark.column+1}.') | ||
| 154 | - return default | 153 | + logger.error(f'In YAML file "{filename}" near line {mark.line}, column {mark.column+1}') |
| 154 | + finally: | ||
| 155 | + return default | ||
| 155 | 156 | ||
| 156 | # --------------------------------------------------------------------------- | 157 | # --------------------------------------------------------------------------- |
| 157 | # Runs a script and returns its stdout parsed as yaml, or None on error. | 158 | # Runs a script and returns its stdout parsed as yaml, or None on error. |
| 159 | +# The script is run in another process but this function blocks waiting | ||
| 160 | +# for its termination. | ||
| 158 | # --------------------------------------------------------------------------- | 161 | # --------------------------------------------------------------------------- |
| 159 | def run_script(script, stdin='', timeout=5): | 162 | def run_script(script, stdin='', timeout=5): |
| 160 | script = path.expanduser(script) | 163 | script = path.expanduser(script) |