Add script invocation to ScriptQuestion
authorW. Trevor King <wking@tremily.us>
Tue, 5 Feb 2013 18:46:31 +0000 (13:46 -0500)
committerW. Trevor King <wking@tremily.us>
Tue, 5 Feb 2013 18:46:31 +0000 (13:46 -0500)
We need Python >= 3.3 for the `timeout` argument to
Popen.communicate().

pq.py
quizzer/__init__.py
quizzer/error.py [new file with mode: 0644]
quizzer/question.py
quizzer/ui/__init__.py
quizzer/util.py [new file with mode: 0644]

diff --git a/pq.py b/pq.py
index 5cdd0bb9e0d4bdafd56194af7521fd0318ed9c02..d595691aefbcef2afbd99be04f7dcb59090d3a08 100755 (executable)
--- a/pq.py
+++ b/pq.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3.3
 
 import quizzer.cli
 
index 3a4b73101c0e418c701f5754a22e3cf83d14ad34..c350f0f32229e60f4f3b7375b44217ce76761e1c 100644 (file)
@@ -7,4 +7,5 @@ import logging as _logging
 __version__ = '0.1'
 
 LOG = _logging.getLogger(__name__)
-LOG.setLevel(_logging.ERROR)
+LOG.setLevel(_logging.DEBUG)
+LOG.addHandler(_logging.StreamHandler())
diff --git a/quizzer/error.py b/quizzer/error.py
new file mode 100644 (file)
index 0000000..f16be24
--- /dev/null
@@ -0,0 +1,12 @@
+class CommandError (RuntimeError):
+    def __init__(self, arguments, stdin=None, stdout=None, stderr=None,
+                 status=None, msg=None):
+        error_msg = 'error executing {}'.format(arguments)
+        if msg:
+            error_msg = '{}: {}'.format(error_msg, msg)
+        super(CommandError, self).__init__(error_msg)
+        self.arguments = arguments
+        self.stdin = stdin
+        self.stdout = stdout
+        self.stderr = stderr
+        self.status = status
index 1783d9c9234c6401210ba821083ffa0ee6c7920a..2dd57e906c2a92dbb8fee649949f29c51b437537 100644 (file)
@@ -1,3 +1,11 @@
+import logging as _logging
+import tempfile as _tempfile
+
+from . import error as _error
+from . import util as _util
+
+
+LOG = _logging.getLogger(__name__)
 QUESTION_CLASS = {}
 
 
@@ -55,20 +63,57 @@ class ScriptQuestion (Question):
         'interpreter',
         'setup',
         'teardown',
+        'timeout',
         ]
 
     def __setstate__(self, state):
         if 'interpreter' not in state:
             state['interpreter'] = 'sh'  # POSIX-compatible shell
+        if 'timeout' not in state:
+            state['timeout'] = 3
         for key in ['setup', 'teardown']:
             if key not in state:
                 state[key] = []
         super(ScriptQuestion, self).__setstate__(state)
 
     def check(self, answer):
-        script = '\n'.join(self.setup + [answer] + self.teardown)
-        raise ValueError(script)
-
+        # figure out the expected values
+        e_status,e_stdout,e_stderr = self._invoke(self.answer)
+        # get values for the user-supplied answer
+        try:
+            a_status,a_stdout,a_stderr = self._invoke(answer)
+        except _error.CommandError as e:
+            LOG.warning(e)
+            return False
+        for (name, e, a) in [
+                ('stderr', e_stderr, a_stderr),
+                ('status', e_status, a_status),
+                ('stdout', e_stdout, a_stdout),
+                ]:
+            if a != e:
+                if name == 'status':
+                    LOG.info(
+                        'missmatched {}, expected {!r} but got {!r}'.format(
+                            name, e, a))
+                else:
+                    LOG.info('missmatched {}, expected:'.format(name))
+                    LOG.info(e)
+                    LOG.info('but got:')
+                    LOG.info(a)
+                return False
+        return True
+
+    def _invoke(self, answer):
+        with _tempfile.TemporaryDirectory(
+                prefix='{}-'.format(type(self).__name__),
+                ) as tempdir:
+            script = '\n'.join(self.setup + [answer] + self.teardown)
+            return _util.invoke(
+                args=[self.interpreter],
+                stdin=script,
+                cwd=tempdir,
+                universal_newlines=True,
+                timeout=self.timeout,)
 
 for name,obj in list(locals().items()):
     if name.startswith('_'):
index 5bbb214c8dcc471e0870feed2895867267d3db31..caf2a71c713a845b4f115ab9ddbb41a0d4b96ab1 100644 (file)
@@ -9,7 +9,8 @@ class UserInterface (object):
             answers = _answerdb.AnswerDatabase()
         self.answers = answers
         if stack is None:
-            stack = quiz.leaf_questions()
+            stack = self.answers.get_never_correctly_answered(
+                questions=quiz.leaf_questions())
         self.stack = stack
 
     def run(self):
diff --git a/quizzer/util.py b/quizzer/util.py
new file mode 100644 (file)
index 0000000..a985631
--- /dev/null
@@ -0,0 +1,35 @@
+import subprocess as _subprocess
+
+from . import error as _error
+
+
+def invoke(args, stdin=None, universal_newlines=False, timeout=None,
+           expect=None, **kwargs):
+    if stdin:
+        stdin_pipe = _subprocess.PIPE
+    else:
+        stdin_pipe = None
+    try:
+        p = _subprocess.Popen(
+            args, stdin=stdin_pipe, stdout=_subprocess.PIPE,
+            stderr=_subprocess.PIPE, universal_newlines=universal_newlines,
+            **kwargs)
+    except FileNotFoundError as e:
+        raise _error.CommandError(arguments=args, stdin=stdin) from e
+    try:
+        stdout,stderr = p.communicate(input=stdin, timeout=timeout)
+    except _subprocess.TimeoutExpired as e:
+        p.kill()
+        stdout,stderr = p.communicate()
+        status = p.wait()
+        raise _error.CommandError(
+            msg='timeout ({}s) expired'.format(timeout),
+            arguments=args, stdin=stdin, stdout=stdout, stderr=stderr,
+            status=status) from e
+    status = p.wait()
+    if expect and status not in expect:
+        raise _error.CommandError(
+            msg='unexpected exit status ({} not in {})'.format(status, expect),
+            args=args, stdin=stdin, stdout=stdout, stderr=stderr,
+            status=status)
+    return (status, stdout, stderr)