From 8431c74d848717e67ca6dbed2bc2ffc88aa9beff Mon Sep 17 00:00:00 2001 From: Miguel BarĂ£o Date: Mon, 13 May 2019 16:05:17 +0100 Subject: [PATCH] updates questions to match aprendizations. moves json save to app (instead of test). moves markdown code to new file parser_markdown.py. new function run_script_async in tools. --- perguntations/app.py | 12 ++++++++---- perguntations/factory.py | 2 +- perguntations/parser_markdown.py | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ perguntations/questions.py | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------- perguntations/serve.py | 3 ++- perguntations/test.py | 15 ++++----------- perguntations/tools.py | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------------------------------------- 7 files changed, 340 insertions(+), 251 deletions(-) create mode 100644 perguntations/parser_markdown.py diff --git a/perguntations/app.py b/perguntations/app.py index 2e51c9c..6dc4583 100644 --- a/perguntations/app.py +++ b/perguntations/app.py @@ -7,6 +7,7 @@ import asyncio # user installed packages import bcrypt +import json from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -142,11 +143,11 @@ class App(object): # ----------------------------------------------------------------------- async def generate_test(self, uid): if uid in self.online: - logger.info(f'Student {uid}: started generating new test.') + logger.info(f'Student {uid}: generating new test.') student_id = self.online[uid]['student'] # {number, name} test = await self.testfactory.generate(student_id) self.online[uid]['test'] = test - logger.debug(f'Student {uid}: finished generating test.') + logger.debug(f'Student {uid}: test is ready.') return self.online[uid]['test'] else: # this implies an error in the code. should never be here! @@ -164,7 +165,10 @@ class App(object): fields = (t['student']['number'], t['ref'], str(t['finish_time'])) fname = ' -- '.join(fields) + '.json' fpath = path.join(t['answers_dir'], fname) - t.save_json(fpath) + with open(path.expanduser(fpath), 'w') as f: + # default=str required for datetime objects: + json.dump(t, f, indent=2, default=str) + logger.info(f'Student {t["student"]["number"]}: saved JSON file.') # insert test and questions into database with self.db_session() as s: @@ -186,7 +190,7 @@ class App(object): student_id=t['student']['number'], test_id=t['ref']) for q in t['questions'] if 'grade' in q]) - logger.info(f'Student {uid}: finished test.') + logger.info(f'Student {uid}: finished test, grade = {grade}.') return grade # ----------------------------------------------------------------------- diff --git a/perguntations/factory.py b/perguntations/factory.py index f656ae9..59fa90c 100644 --- a/perguntations/factory.py +++ b/perguntations/factory.py @@ -145,7 +145,7 @@ class QuestionFactory(dict): if q['type'] == 'generator': logger.debug(f'Generating "{ref}" from {q["script"]}') q.setdefault('args', []) # optional arguments - q.setdefault('stdin', '') + q.setdefault('stdin', '') # FIXME necessary? script = path.join(q['path'], q['script']) out = run_script(script=script, args=q['args'], stdin=q['stdin']) try: diff --git a/perguntations/parser_markdown.py b/perguntations/parser_markdown.py new file mode 100644 index 0000000..2a1d276 --- /dev/null +++ b/perguntations/parser_markdown.py @@ -0,0 +1,145 @@ +# python standard library +import logging +import re + +# third party libraries +import mistune +from pygments import highlight +from pygments.lexers import get_lexer_by_name +from pygments.formatters import HtmlFormatter + + +# setup logger for this module +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------------- +# Markdown to HTML renderer with support for LaTeX equations +# Inline math: $x$ +# Block math: $$x$$ or \begin{equation}x\end{equation} +# ------------------------------------------------------------------------- +class MathBlockGrammar(mistune.BlockGrammar): + block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) + latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", + re.DOTALL) + + +class MathBlockLexer(mistune.BlockLexer): + default_rules = ['block_math', 'latex_environment'] \ + + mistune.BlockLexer.default_rules + + def __init__(self, rules=None, **kwargs): + if rules is None: + rules = MathBlockGrammar() + super().__init__(rules, **kwargs) + + def parse_block_math(self, m): + """Parse a $$math$$ block""" + self.tokens.append({ + 'type': 'block_math', + 'text': m.group(1) + }) + + def parse_latex_environment(self, m): + self.tokens.append({ + 'type': 'latex_environment', + 'name': m.group(1), + 'text': m.group(2) + }) + + +class MathInlineGrammar(mistune.InlineGrammar): + math = re.compile(r"^\$(.+?)\$", re.DOTALL) + block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) + text = re.compile(r'^[\s\S]+?(?=[\\' \ + + header + '' + body + '' + + def image(self, src, title, alt): + alt = mistune.escape(alt, quote=True) + if title is not None: + if title: # not empty string, show as caption + title = mistune.escape(title, quote=True) + caption = f'
{title}' \ + '
' + else: # title is an empty string, show as centered figure + caption = '' + + return f''' +
+
+ {alt} + {caption} +
+
+ ''' + + else: # title indefined, show as inline image + return f''' + {alt} + ''' + + # Pass math through unaltered - mathjax does the rendering in the browser + def block_math(self, text): + return fr'$$ {text} $$' + + def latex_environment(self, name, text): + return fr'\begin{{{name}}} {text} \end{{{name}}}' + + def inline_math(self, text): + return fr'$$$ {text} $$$' + + +def md_to_html(qref='.'): + return MarkdownWithMath(HighlightRenderer(qref=qref)) diff --git a/perguntations/questions.py b/perguntations/questions.py index 636ad32..c88c186 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -4,21 +4,21 @@ import random import re from os import path import logging -import asyncio - -# user installed libraries -import yaml +from typing import Any, Dict, NewType +# import uuid # this project -from perguntations.tools import run_script +from perguntations.tools import run_script, run_script_async + +# setup logger for this module +logger = logging.getLogger(__name__) -# regular expressions in yaml files, e.g. correct: !regex '[aA]zul' -yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) +QDict = NewType('QDict', Dict[str, Any]) -# setup logger for this module -logger = logging.getLogger(__name__) +class QuestionException(Exception): + pass # =========================================================================== @@ -27,35 +27,31 @@ logger = logging.getLogger(__name__) # =========================================================================== class Question(dict): ''' - Classes derived from this base class are meant to instantiate a question - to a student. - Instances can shuffle options, or automatically generate questions. + Classes derived from this base class are meant to instantiate questions + for each student. + Instances can shuffle options or automatically generate questions. ''' - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - # add these if missing - self.set_defaults({ + # add required keys if missing + self.set_defaults(QDict({ 'title': '', 'answer': None, 'comments': '', + 'solution': '', 'files': {}, - }) - - # FIXME unused. do childs need do override this? - # def updateAnswer(answer=None): - # self['answer'] = answer + 'max_tries': 3, + })) - def correct(self): + def correct(self) -> None: + self['comments'] = '' self['grade'] = 0.0 - return 0.0 - async def correct_async(self): - loop = asyncio.get_running_loop() - grade = await loop.run_in_executor(None, self.correct) - return grade + async def correct_async(self) -> None: + self.correct() - def set_defaults(self, d): + def set_defaults(self, d: QDict) -> None: 'Add k:v pairs from default dict d for nonexistent keys' for k, v in d.items(): self.setdefault(k, v) @@ -75,55 +71,61 @@ class QuestionRadio(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) n = len(self['options']) # set defaults if missing - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, - }) + })) - # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] + # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] # correctness levels from 0.0 to 1.0 (no discount here!) if isinstance(self['correct'], int): self['correct'] = [1.0 if x == self['correct'] else 0.0 for x in range(n)] if len(self['correct']) != n: - logger.error(f'Options and correct mismatch in ' - f'"{self["ref"]}", file "{self["filename"]}".') + msg = (f'Options and correct mismatch in ' + f'"{self["ref"]}", file "{self["filename"]}".') + logger.error(msg) + raise QuestionException(msg) if self['shuffle']: # separate right from wrong options - right = [i for i in range(n) if self['correct'][i] == 1] + right = [i for i in range(n) if self['correct'][i] >= 1] wrong = [i for i in range(n) if self['correct'][i] < 1] self.set_defaults({'choose': 1+len(wrong)}) - # choose 1 correct option - r = random.choice(right) - options = [self['options'][r]] - correct = [1.0] + # try to choose 1 correct option + if right: + r = random.choice(right) + options = [self['options'][r]] + correct = [self['correct'][r]] + else: + options = [] + correct = [] # choose remaining wrong options - random.shuffle(wrong) - nwrong = self['choose']-1 - options.extend(self['options'][i] for i in wrong[:nwrong]) - correct.extend(self['correct'][i] for i in wrong[:nwrong]) + nwrong = self['choose'] - len(correct) + wrongsample = random.sample(wrong, k=nwrong) + options += [self['options'][i] for i in wrongsample] + correct += [self['correct'][i] for i in wrongsample] # final shuffle of the options - perm = random.sample(range(self['choose']), self['choose']) + perm = random.sample(range(self['choose']), k=self['choose']) self['options'] = [str(options[i]) for i in perm] self['correct'] = [float(correct[i]) for i in perm] # ------------------------------------------------------------------------ # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() if self['answer'] is not None: @@ -134,8 +136,6 @@ class QuestionRadio(Question): x = (x - x_aver) / (1.0 - x_aver) self['grade'] = x - return self['grade'] - # =========================================================================== class QuestionCheckbox(Question): @@ -151,7 +151,7 @@ class QuestionCheckbox(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) n = len(self['options']) @@ -166,8 +166,10 @@ class QuestionCheckbox(Question): }) if len(self['correct']) != n: - logger.error(f'Options and correct size mismatch in ' - f'"{self["ref"]}", file "{self["filename"]}".') + msg = (f'Options and correct size mismatch in ' + f'"{self["ref"]}", file "{self["filename"]}".') + logger.error(msg) + raise QuestionException(msg) # if an option is a list of (right, wrong), pick one # FIXME it's possible that all options are chosen wrong @@ -190,7 +192,7 @@ class QuestionCheckbox(Question): # ------------------------------------------------------------------------ # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() if self['answer'] is not None: @@ -210,8 +212,6 @@ class QuestionCheckbox(Question): self['grade'] = x / sum_abs - return self['grade'] - # ============================================================================ class QuestionText(Question): @@ -223,7 +223,7 @@ class QuestionText(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults({ @@ -240,14 +240,12 @@ class QuestionText(Question): # ------------------------------------------------------------------------ # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() if self['answer'] is not None: self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 - return self['grade'] - # =========================================================================== class QuestionTextRegex(Question): @@ -259,7 +257,7 @@ class QuestionTextRegex(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults({ @@ -269,17 +267,15 @@ class QuestionTextRegex(Question): # ------------------------------------------------------------------------ # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() if self['answer'] is not None: try: - self['grade'] = 1.0 if re.match(self['correct'], - self['answer']) else 0.0 + ok = re.match(self['correct'], self['answer']) except TypeError: - logger.error('While matching regex {self["correct"]} with ' - 'answer {self["answer"]}.') - - return self['grade'] + logger.error(f'While matching regex {self["correct"]} with ' + f'answer {self["answer"]}.') + self['grade'] = 1.0 if ok else 0.0 # =========================================================================== @@ -293,17 +289,17 @@ class QuestionNumericInterval(Question): ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', 'correct': [1.0, -1.0], # will always return false - }) + })) # ------------------------------------------------------------------------ # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() if self['answer'] is not None: lower, upper = self['correct'] @@ -316,8 +312,6 @@ class QuestionNumericInterval(Question): else: self['grade'] = 1.0 if lower <= answer <= upper else 0.0 - return self['grade'] - # =========================================================================== class QuestionTextArea(Question): @@ -326,16 +320,14 @@ class QuestionTextArea(Question): text (str) correct (str with script to run) answer (None or an actual answer) - lines (int) ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) self.set_defaults({ 'text': '', - 'lines': 8, # FIXME not being used??? 'timeout': 5, # seconds 'correct': '', # trying to execute this will fail => grade 0.0 'args': [] @@ -344,33 +336,62 @@ class QuestionTextArea(Question): self['correct'] = path.join(self['path'], self['correct']) # ------------------------------------------------------------------------ - # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() - if self['answer'] is not None: - out = run_script( # and parse yaml ouput + if self['answer'] is not None: # correct answer and parse yaml ouput + out = run_script( script=self['correct'], args=self['args'], stdin=self['answer'], timeout=self['timeout'] ) - if isinstance(out, dict): + if out is None: + logger.warning(f'No grade after running "{self["correct"]}".') + self['grade'] = 0.0 + elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: - logger.error(f'Grade value error in "{self["correct"]}".') + logger.error(f'Output error in "{self["correct"]}".') except KeyError: logger.error(f'No grade in "{self["correct"]}".') else: try: self['grade'] = float(out) except (TypeError, ValueError): - logger.error(f'Grade value error in "{self["correct"]}".') + logger.error(f'Invalid grade in "{self["correct"]}".') - return self['grade'] + # ------------------------------------------------------------------------ + async def correct_async(self) -> None: + super().correct() + + if self['answer'] is not None: # correct answer and parse yaml ouput + out = await run_script_async( + script=self['correct'], + args=self['args'], + stdin=self['answer'], + timeout=self['timeout'] + ) + + if out is None: + logger.warning(f'No grade after running "{self["correct"]}".') + self['grade'] = 0.0 + elif isinstance(out, dict): + self['comments'] = out.get('comments', '') + try: + self['grade'] = float(out['grade']) + except ValueError: + logger.error(f'Output error in "{self["correct"]}".') + except KeyError: + logger.error(f'No grade in "{self["correct"]}".') + else: + try: + self['grade'] = float(out) + except (TypeError, ValueError): + logger.error(f'Invalid grade in "{self["correct"]}".') # =========================================================================== @@ -381,15 +402,13 @@ class QuestionInformation(Question): points (0.0) ''' # ------------------------------------------------------------------------ - def __init__(self, q): + def __init__(self, q: QDict) -> None: super().__init__(q) - self.set_defaults({ + self.set_defaults(QDict({ 'text': '', - }) + })) # ------------------------------------------------------------------------ - # can return negative values for wrong answers - def correct(self): + def correct(self) -> None: super().correct() self['grade'] = 1.0 # always "correct" but points should be zero! - return self['grade'] diff --git a/perguntations/serve.py b/perguntations/serve.py index 78937b8..3d14c31 100644 --- a/perguntations/serve.py +++ b/perguntations/serve.py @@ -21,8 +21,9 @@ import tornado.httpserver # this project from perguntations.app import App, AppException -from perguntations.tools import load_yaml, md_to_html +from perguntations.tools import load_yaml from perguntations import APP_NAME +from perguntations.parser_markdown import md_to_html # ---------------------------------------------------------------------------- diff --git a/perguntations/test.py b/perguntations/test.py index d57dc38..52d9a4b 100644 --- a/perguntations/test.py +++ b/perguntations/test.py @@ -4,7 +4,6 @@ from os import path, listdir import fnmatch import random from datetime import datetime -import json import logging import asyncio @@ -257,9 +256,10 @@ class Test(dict): self['state'] = 'FINISHED' grade = 0.0 for q in self['questions']: - g = await q.correct_async() - grade += g * q['points'] - logger.debug(f'Correcting "{q["ref"]}": {g*100.0} %') + await q.correct_async() + + grade += q['grade'] * q['points'] + logger.debug(f'Correcting "{q["ref"]}": {q["grade"]*100.0} %') self['grade'] = max(0, round(grade, 1)) # avoid negative grades logger.info(f'Student {self["student"]["number"]}: ' @@ -273,10 +273,3 @@ class Test(dict): self['grade'] = 0.0 logger.info(f'Student {self["student"]["number"]}: gave up.') return self['grade'] - - # ----------------------------------------------------------------------- - def save_json(self, filepath): - with open(path.expanduser(filepath), 'w') as f: - # default=str required for datetime objects: - json.dump(self, f, indent=2, default=str) - logger.info(f'Student {self["student"]["number"]}: saved JSON file.') diff --git a/perguntations/tools.py b/perguntations/tools.py index 27d6249..17bc8c9 100644 --- a/perguntations/tools.py +++ b/perguntations/tools.py @@ -1,162 +1,45 @@ # python standard library +import asyncio +import logging from os import path import subprocess -import logging -import re +from typing import Any, List -# user installed libraries +# third party libraries import yaml -import mistune -from pygments import highlight -from pygments.lexers import get_lexer_by_name -from pygments.formatters import HtmlFormatter + # setup logger for this module logger = logging.getLogger(__name__) -# ------------------------------------------------------------------------- -# Markdown to HTML renderer with support for LaTeX equations -# Inline math: $x$ -# Block math: $$x$$ or \begin{equation}x\end{equation} -# ------------------------------------------------------------------------- -class MathBlockGrammar(mistune.BlockGrammar): - block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) - latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) - - -class MathBlockLexer(mistune.BlockLexer): - default_rules = ['block_math', 'latex_environment'] + mistune.BlockLexer.default_rules - - def __init__(self, rules=None, **kwargs): - if rules is None: - rules = MathBlockGrammar() - super().__init__(rules, **kwargs) - - def parse_block_math(self, m): - """Parse a $$math$$ block""" - self.tokens.append({ - 'type': 'block_math', - 'text': m.group(1) - }) - - def parse_latex_environment(self, m): - self.tokens.append({ - 'type': 'latex_environment', - 'name': m.group(1), - 'text': m.group(2) - }) - - -class MathInlineGrammar(mistune.InlineGrammar): - math = re.compile(r"^\$(.+?)\$", re.DOTALL) - block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) - text = re.compile(r'^[\s\S]+?(?=[\\' + header + '' + body + "" - - def image(self, src, title, alt): - alt = mistune.escape(alt, quote=True) - if title is not None: - if title: # not empty string, show as caption - title = mistune.escape(title, quote=True) - caption = f'
{title}
' - else: # title is an empty string, show as centered figure - caption = '' - - return f''' -
-
- {alt} - {caption} -
-
- ''' - - else: # title indefined, show as inline image - return f'{alt}' - - # Pass math through unaltered - mathjax does the rendering in the browser - def block_math(self, text): - return fr'$$ {text} $$' - - def latex_environment(self, name, text): - return fr'\begin{{{name}}} {text} \end{{{name}}}' - - def inline_math(self, text): - return fr'$$$ {text} $$$' - - -def md_to_html(qref='.'): - return MarkdownWithMath(HighlightRenderer(qref=qref)) - - # --------------------------------------------------------------------------- # load data from yaml file # --------------------------------------------------------------------------- -def load_yaml(filename, default=None): +def load_yaml(filename: str, default: Any = None) -> Any: + filename = path.expanduser(filename) try: - with open(path.expanduser(filename), 'r', encoding='utf-8') as f: - default = yaml.safe_load(f) + f = open(filename, 'r', encoding='utf-8') except FileNotFoundError: - logger.error(f'File not found: "{filename}".') + logger.error(f'Cannot open "{filename}": not found') except PermissionError: - logger.error(f'Permission error: "{filename}"') + logger.error(f'Cannot open "{filename}": no permission') except OSError: - logger.error(f'Error reading file "{filename}"') - except yaml.YAMLError as e: - mark = e.problem_mark - logger.error(f'In YAML file "{filename}" near line ' - f'{mark.line}, column {mark.column+1}') - return default + logger.error(f'Cannot open file "{filename}"') + else: + with f: + try: + default = yaml.safe_load(f) + except yaml.YAMLError as e: + if hasattr(e, 'problem_mark'): + mark = e.problem_mark + logger.error(f'File "{filename}" near line {mark.line}, ' + f'column {mark.column+1}') + else: + logger.error(f'File "{filename}"') + finally: + return default # --------------------------------------------------------------------------- @@ -164,7 +47,11 @@ def load_yaml(filename, default=None): # The script is run in another process but this function blocks waiting # for its termination. # --------------------------------------------------------------------------- -def run_script(script, args=[], stdin='', timeout=5): +def run_script(script: str, + args: List[str] = [], + stdin: str = '', + timeout: int = 2) -> Any: + script = path.expanduser(script) try: cmd = [script] + [str(a) for a in args] @@ -176,16 +63,18 @@ def run_script(script, args=[], stdin='', timeout=5): timeout=timeout, ) except FileNotFoundError: - logger.error(f'Could not execute script "{script}": not found') + logger.error(f'Can not execute script "{script}": not found.') except PermissionError: - logger.error(f'Could not execute script "{script}": wrong permissions') + logger.error(f'Can not execute script "{script}": wrong permissions.') except OSError: - logger.error(f'Could not execute script "{script}": unknown reason') + logger.error(f'Can not execute script "{script}": unknown reason.') except subprocess.TimeoutExpired: - logger.error(f'Timeout exceeded ({timeout}s) while running "{script}"') + logger.error(f'Timeout {timeout}s exceeded while running "{script}".') + except Exception: + logger.error(f'An Exception ocurred running {script}.') else: if p.returncode != 0: - logger.error(f'Script "{script}" returned code {p.returncode}') + logger.error(f'Return code {p.returncode} running "{script}".') else: try: output = yaml.safe_load(p.stdout) @@ -193,3 +82,41 @@ def run_script(script, args=[], stdin='', timeout=5): logger.error(f'Error parsing yaml output of "{script}"') else: return output + + +# ---------------------------------------------------------------------------- +# Same as above, but asynchronous +# ---------------------------------------------------------------------------- +async def run_script_async(script: str, + args: List[str] = [], + stdin: str = '', + timeout: int = 2) -> Any: + + script = path.expanduser(script) + args = [str(a) for a in args] + + p = await asyncio.create_subprocess_exec( + script, *args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + + try: + stdout, stderr = await asyncio.wait_for( + p.communicate(input=stdin.encode('utf-8')), + timeout=timeout + ) + except asyncio.TimeoutError: + logger.warning(f'Timeout {timeout}s running script "{script}".') + return + + if p.returncode != 0: + logger.error(f'Return code {p.returncode} running "{script}".') + else: + try: + output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) + except Exception: + logger.error(f'Error parsing yaml output of "{script}"') + else: + return output -- libgit2 0.21.2