question: Add the Question.multimedia attribute
[quizzer.git] / quizzer / ui / cli.py
index a9bb100e3302ee01940b4da425fd5bc1fb871fcc..a7b645ed67da0340e45ea2a711c73e720b143232 100644 (file)
 # You should have received a copy of the GNU General Public License along with
 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
 
+import cmd as _cmd
+import logging as _logging
+import os.path as _os_path
 try:
     import readline as _readline
 except ImportError as _readline_import_error:
     _readline = None
 
-from . import UserInterface
+try:
+    from pygments.console import colorize as _colorize
+except ImportError as e:
+    def _colorize(color_key=None, text=None):
+        return text
+    print(e)
 
+from .. import error as _error
+from .. import question as _question
+from . import UserInterface as _UserInterface
+from . import util as _util
 
-class CommandLineInterface (UserInterface):
-    def run(self):
-        while True:
-            question = self.get_question()
-            if not question:
-                break
-            print(question.format_prompt())
-            if question.multiline:
+
+_LOG = _logging.getLogger(__name__)
+
+
+class QuestionCommandLine (_cmd.Cmd):
+    _help = [
+        'Type help or ? to list commands.',
+        'Non-commands will be interpreted as answers.',
+        'Use a blank line to terminate multi-line answers.',
+        ]
+    intro = '\n'.join(['Welcome to the quizzer shell.'] + _help)
+    _prompt = 'quizzer? '
+
+    def __init__(self, ui):
+        super(QuestionCommandLine, self).__init__()
+        self.ui = ui
+        if self.ui.quiz.introduction:
+            self.intro = '\n\n'.join([self.intro, self.ui.quiz.introduction])
+        self._tempdir = None
+        self._children = []
+
+    def get_question(self):
+        self.question = self.ui.get_question(user=self.ui.user)
+        if self.question:
+            self._reset()
+        else:
+            return True  # out of questions
+
+    def preloop(self):
+        self.get_question()
+
+    def postcmd(self, stop, line):
+        self._reap_children()
+        return stop
+
+    def _reset(self):
+        self.answers = []
+        if self._tempdir:
+            self._tempdir.cleanup()  # occasionally redundant, but that's ok
+        self._tempdir = None
+        self._set_ps1()
+
+    def _set_ps1(self):
+        "Pose a question and prompt"
+        if self.question:
+            lines = [
+                '',
+                _colorize(
+                    self.ui.colors['question'], self.question.format_prompt()),
+                ]
+            lines.extend(
+                _colorize(self.ui.colors['prompt'], line)
+                for line in self._extra_ps1_lines())
+            lines.append(_colorize(self.ui.colors['prompt'], self._prompt))
+            self.prompt = '\n'.join(lines)
+        else:
+            self.prompt = _colorize(self.ui.colors['prompt'], self._prompt)
+
+    def _set_ps2(self):
+        "Just prompt (without the question, e.g. for multi-line answers)"
+        self.prompt = _colorize(self.ui.colors['prompt'], self._prompt)
+
+    def _extra_ps1_lines(self):
+        for multimedia in self.question.multimedia:
+            for line in self._format_multimedia(multimedia):
+                yield line  # for Python 3.3, use PEP 380's `yield from ...`
+        if (isinstance(self.question, _question.ChoiceQuestion) and
+                self.question.display_choices):
+            for line in self._format_choices(question=self.question):
+                yield line
+
+    def _format_multimedia(self, multimedia):
+        path = self.ui.quiz.multimedia_path(multimedia=multimedia)
+        content_type = multimedia['content-type']
+        try:
+            self._children.append(_util.mailcap_view(
+                    path=path, content_type=content_type, background=True))
+        except NotImplementedError:
+            path = _os_path.abspath(path)
+            yield 'multimedia ({}): {}'.format(content_type, path)
+
+    def _reap_children(self):
+        reaped = []
+        for process in self._children:
+            _LOG.debug('poll child process {}'.format(process.pid))
+            if process.poll() is not None:
+                _LOG.debug('process {} returned {}'.format(
+                        process.pid, process.returncode))
+                reaped.append(process)
+        for process in reaped:
+            self._children.remove(process)
+
+    def _format_choices(self, question):
+        for i,choice in enumerate(question.answer):
+            yield '{}) {}'.format(i, choice)
+        yield 'Answer with the index of your choice'
+        if question.accept_all:
+            conj = 'or'
+            if question.multiple_answers:
+                conj = 'and/or'
+            yield '{} fill in an alternative answer'.format(conj)
+        if question.multiple_answers:
+            self._separator = ','
+            yield ("Separate multiple answers with the '{}' character"
+                   ).format(self._separator)
+
+    def _process_answer(self, answer):
+        "Back out any mappings suggested by _extra_ps1_lines()"
+        if (isinstance(self.question, _question.ChoiceQuestion) and
+                self.question.display_choices):
+            if self.question.multiple_answers:
                 answers = []
-            while True:
+                for a in answer.split(self._separator):
+                    try:
+                        i = int(a)
+                        answers.append(self.question.answer[i])
+                    except (ValueError, IndexError):
+                        answers.append(a)
+                return answers
+            else:
                 try:
-                    answer = input('? ')
-                except EOFError:
-                    answer = 'quit'
-                a = answer.strip().lower()
-                if a in ['q', 'quit']:
-                    print()
-                    return
-                if a in ['?', 'help']:
-                    print()
-                    print(question.format_prompt())
-                    print(question.help)
-                    continue
-                if question.multiline:
-                    answers.append(answer)
-                    if not a:
-                        break
-                else:
-                    break
-            if question.multiline:
-                answer = answers
-            correct = self.process_answer(question=question, answer=answer)
-            if correct:
-                print('correct\n')
+                    i = int(answer)
+                    return self.question.answer[i]
+                except (ValueError, IndexError):
+                    pass
+        return answer
+
+    def default(self, line):
+        self.answers.append(line)
+        if self.question.multiline:
+            self._set_ps2()
+        else:
+            return self._answer()
+
+    def emptyline(self):
+        return self._answer()
+
+    def _answer(self):
+        if self.question.multiline:
+            answer = self.answers
+        elif self.answers:
+            answer = self.answers[0]
+        else:
+            answer = ''
+        if answer == 'EOF':
+            return True  # quit
+        if answer == '':
+            return
+        kwargs = {}
+        if self._tempdir:
+            kwargs['tempdir'] = self._tempdir
+        answer = self._process_answer(answer=answer)
+        correct,details = self.ui.process_answer(
+            question=self.question, answer=answer, **kwargs)
+        if correct:
+            print(_colorize(self.ui.colors['correct'], 'correct\n'))
+        else:
+            print(_colorize(self.ui.colors['incorrect'], 'incorrect'))
+            if details:
+                print(_colorize(
+                        self.ui.colors['incorrect'], '{}\n'.format(details)))
             else:
-                print('incorrect\n')
+                print('')
+        return self.get_question()
+
+    def do_answer(self, arg):
+        """Explicitly add a line to your answer
+
+        This is useful if the line you'd like to add starts with a
+        quizzer-shell command.  For example:
+
+          quizzer? answer help=5
+        """
+        return self.default(arg)
+
+    def do_shell(self, arg):
+        """Run a shell command in the question temporary directory
+
+        For example, you can spawn an interactive session with:
+
+          quizzer? !bash
+
+        If the question does not allow interactive sessions, this
+        action is a no-op.
+        """
+        if getattr(self.question, 'allow_interactive', False):
+            if not self._tempdir:
+                self._tempdir = self.question.setup_tempdir()
+            try:
+                self._tempdir.invoke(
+                    interpreter='/bin/sh', text=arg, stdout=None, stderr=None,
+                    universal_newlines=False,
+                    env=self.question.get_environment())
+            except (KeyboardInterrupt, _error.CommandError) as e:
+                if isinstance(e, KeyboardInterrupt):
+                    LOG.warning('KeyboardInterrupt')
+                else:
+                    LOG.warning(e)
+                self._tempdir.cleanup()
+                self._tempdir = None
 
-    def display_results(self):
-        print('results:')
+    def do_quit(self, arg):
+        "Stop taking the quiz"
+        self._reset()
+        return True
+
+    def do_skip(self, arg):
+        "Skip the current question, and continue with the quiz"
+        self.ui.stack[self.ui.user].append(self.question)
+        return self.get_question()
+
+    def do_hint(self, arg):
+        "Show a hint for the current question"
+        self._reset()
+        print(self.question.format_help())
+
+    def do_copyright(self, arg):
+        "Print the quiz copyright notice"
+        if self.ui.quiz.copight:
+            print('\n'.join(self.ui.quiz.copyright))
+        else:
+            print(self.ui.quiz.copyright)
+
+    def do_help(self, arg):
+        'List available commands with "help" or detailed help with "help cmd"'
+        if not arg:
+            print('\n'.join(self._help))
+        super(QuestionCommandLine, self).do_help(arg)
+
+
+class CommandLineInterface (_UserInterface):
+    colors = {  # listed in pygments.console.light_colors
+        'question': 'turquoise',
+        'prompt': 'blue',
+        'correct': 'green',
+        'incorrect': 'red',
+        'result': 'fuchsia',
+        }
+
+    def run(self):
+        self.user = None
+        if self.stack[self.user]:
+            cmd = QuestionCommandLine(ui=self)
+            cmd.cmdloop()
+            print()
+        self._display_results()
+
+    def _display_results(self):
+        print(_colorize(self.colors['result'], 'results:'))
+        answers = self.answers.get_answers(user=self.user)
         for question in self.quiz:
-            if question.id in self.answers:
-                self.display_result(question=question)
+            if question.id in answers:
+                self._display_result(question=question)
                 print()
-        self.display_totals()
+        self._display_totals()
 
-    def display_result(self, question):
-        answers = self.answers.get(question.id, [])
+    def _display_result(self, question):
+        answers = self.answers.get_answers(user=self.user).get(question.id, [])
         print('question:')
-        print('  {}'.format(question.format_prompt(newline='\n  ')))
+        print('  {}'.format(
+            _colorize(
+                self.colors['question'],
+                question.format_prompt(newline='\n  '))))
         la = len(answers)
         lc = len([a for a in answers if a['correct']])
-        print('answers: {}/{} ({:.2f})'.format(lc, la, float(lc)/la))
+        if la:
+            print('answers: {}/{} ({:.2f})'.format(lc, la, float(lc)/la))
         for answer in answers:
             if answer['correct']:
                 correct = 'correct'
             else:
                 correct = 'incorrect'
-            print('  you answered: {}'.format(answer['answer']))
+            correct = _colorize(self.colors[correct], correct)
+            ans = answer['answer']
+            if question.multiline:
+                ans = '\n                '.join(ans)
+            print('  you answered: {}'.format(ans))
             print('     which was: {}'.format(correct))
 
-    def display_totals(self):
-        answered = self.answers.get_answered(questions=self.quiz)
+    def _display_totals(self):
+        answered = self.answers.get_answered(
+            questions=self.quiz, user=self.user)
         correctly_answered = self.answers.get_correctly_answered(
-            questions=self.quiz)
+            questions=self.quiz, user=self.user)
         la = len(answered)
         lc = len(correctly_answered)
         print('answered {} of {} questions'.format(la, len(self.quiz)))
-        print(('of the answered questions, {} ({:.2f}) were answered correctly'
-               ).format(lc, float(lc)/la))
+        if la:
+            print(('of the answered questions, '
+                   '{} ({:.2f}) were answered correctly'
+                   ).format(lc, float(lc)/la))