question: Add the Question.multimedia attribute
[quizzer.git] / quizzer / ui / cli.py
1 # Copyright (C) 2013 W. Trevor King <wking@tremily.us>
2 #
3 # This file is part of quizzer.
4 #
5 # quizzer is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # quizzer is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
16
17 import cmd as _cmd
18 import logging as _logging
19 import os.path as _os_path
20 try:
21     import readline as _readline
22 except ImportError as _readline_import_error:
23     _readline = None
24
25 try:
26     from pygments.console import colorize as _colorize
27 except ImportError as e:
28     def _colorize(color_key=None, text=None):
29         return text
30     print(e)
31
32 from .. import error as _error
33 from .. import question as _question
34 from . import UserInterface as _UserInterface
35 from . import util as _util
36
37
38 _LOG = _logging.getLogger(__name__)
39
40
41 class QuestionCommandLine (_cmd.Cmd):
42     _help = [
43         'Type help or ? to list commands.',
44         'Non-commands will be interpreted as answers.',
45         'Use a blank line to terminate multi-line answers.',
46         ]
47     intro = '\n'.join(['Welcome to the quizzer shell.'] + _help)
48     _prompt = 'quizzer? '
49
50     def __init__(self, ui):
51         super(QuestionCommandLine, self).__init__()
52         self.ui = ui
53         if self.ui.quiz.introduction:
54             self.intro = '\n\n'.join([self.intro, self.ui.quiz.introduction])
55         self._tempdir = None
56         self._children = []
57
58     def get_question(self):
59         self.question = self.ui.get_question(user=self.ui.user)
60         if self.question:
61             self._reset()
62         else:
63             return True  # out of questions
64
65     def preloop(self):
66         self.get_question()
67
68     def postcmd(self, stop, line):
69         self._reap_children()
70         return stop
71
72     def _reset(self):
73         self.answers = []
74         if self._tempdir:
75             self._tempdir.cleanup()  # occasionally redundant, but that's ok
76         self._tempdir = None
77         self._set_ps1()
78
79     def _set_ps1(self):
80         "Pose a question and prompt"
81         if self.question:
82             lines = [
83                 '',
84                 _colorize(
85                     self.ui.colors['question'], self.question.format_prompt()),
86                 ]
87             lines.extend(
88                 _colorize(self.ui.colors['prompt'], line)
89                 for line in self._extra_ps1_lines())
90             lines.append(_colorize(self.ui.colors['prompt'], self._prompt))
91             self.prompt = '\n'.join(lines)
92         else:
93             self.prompt = _colorize(self.ui.colors['prompt'], self._prompt)
94
95     def _set_ps2(self):
96         "Just prompt (without the question, e.g. for multi-line answers)"
97         self.prompt = _colorize(self.ui.colors['prompt'], self._prompt)
98
99     def _extra_ps1_lines(self):
100         for multimedia in self.question.multimedia:
101             for line in self._format_multimedia(multimedia):
102                 yield line  # for Python 3.3, use PEP 380's `yield from ...`
103         if (isinstance(self.question, _question.ChoiceQuestion) and
104                 self.question.display_choices):
105             for line in self._format_choices(question=self.question):
106                 yield line
107
108     def _format_multimedia(self, multimedia):
109         path = self.ui.quiz.multimedia_path(multimedia=multimedia)
110         content_type = multimedia['content-type']
111         try:
112             self._children.append(_util.mailcap_view(
113                     path=path, content_type=content_type, background=True))
114         except NotImplementedError:
115             path = _os_path.abspath(path)
116             yield 'multimedia ({}): {}'.format(content_type, path)
117
118     def _reap_children(self):
119         reaped = []
120         for process in self._children:
121             _LOG.debug('poll child process {}'.format(process.pid))
122             if process.poll() is not None:
123                 _LOG.debug('process {} returned {}'.format(
124                         process.pid, process.returncode))
125                 reaped.append(process)
126         for process in reaped:
127             self._children.remove(process)
128
129     def _format_choices(self, question):
130         for i,choice in enumerate(question.answer):
131             yield '{}) {}'.format(i, choice)
132         yield 'Answer with the index of your choice'
133         if question.accept_all:
134             conj = 'or'
135             if question.multiple_answers:
136                 conj = 'and/or'
137             yield '{} fill in an alternative answer'.format(conj)
138         if question.multiple_answers:
139             self._separator = ','
140             yield ("Separate multiple answers with the '{}' character"
141                    ).format(self._separator)
142
143     def _process_answer(self, answer):
144         "Back out any mappings suggested by _extra_ps1_lines()"
145         if (isinstance(self.question, _question.ChoiceQuestion) and
146                 self.question.display_choices):
147             if self.question.multiple_answers:
148                 answers = []
149                 for a in answer.split(self._separator):
150                     try:
151                         i = int(a)
152                         answers.append(self.question.answer[i])
153                     except (ValueError, IndexError):
154                         answers.append(a)
155                 return answers
156             else:
157                 try:
158                     i = int(answer)
159                     return self.question.answer[i]
160                 except (ValueError, IndexError):
161                     pass
162         return answer
163
164     def default(self, line):
165         self.answers.append(line)
166         if self.question.multiline:
167             self._set_ps2()
168         else:
169             return self._answer()
170
171     def emptyline(self):
172         return self._answer()
173
174     def _answer(self):
175         if self.question.multiline:
176             answer = self.answers
177         elif self.answers:
178             answer = self.answers[0]
179         else:
180             answer = ''
181         if answer == 'EOF':
182             return True  # quit
183         if answer == '':
184             return
185         kwargs = {}
186         if self._tempdir:
187             kwargs['tempdir'] = self._tempdir
188         answer = self._process_answer(answer=answer)
189         correct,details = self.ui.process_answer(
190             question=self.question, answer=answer, **kwargs)
191         if correct:
192             print(_colorize(self.ui.colors['correct'], 'correct\n'))
193         else:
194             print(_colorize(self.ui.colors['incorrect'], 'incorrect'))
195             if details:
196                 print(_colorize(
197                         self.ui.colors['incorrect'], '{}\n'.format(details)))
198             else:
199                 print('')
200         return self.get_question()
201
202     def do_answer(self, arg):
203         """Explicitly add a line to your answer
204
205         This is useful if the line you'd like to add starts with a
206         quizzer-shell command.  For example:
207
208           quizzer? answer help=5
209         """
210         return self.default(arg)
211
212     def do_shell(self, arg):
213         """Run a shell command in the question temporary directory
214
215         For example, you can spawn an interactive session with:
216
217           quizzer? !bash
218
219         If the question does not allow interactive sessions, this
220         action is a no-op.
221         """
222         if getattr(self.question, 'allow_interactive', False):
223             if not self._tempdir:
224                 self._tempdir = self.question.setup_tempdir()
225             try:
226                 self._tempdir.invoke(
227                     interpreter='/bin/sh', text=arg, stdout=None, stderr=None,
228                     universal_newlines=False,
229                     env=self.question.get_environment())
230             except (KeyboardInterrupt, _error.CommandError) as e:
231                 if isinstance(e, KeyboardInterrupt):
232                     LOG.warning('KeyboardInterrupt')
233                 else:
234                     LOG.warning(e)
235                 self._tempdir.cleanup()
236                 self._tempdir = None
237
238     def do_quit(self, arg):
239         "Stop taking the quiz"
240         self._reset()
241         return True
242
243     def do_skip(self, arg):
244         "Skip the current question, and continue with the quiz"
245         self.ui.stack[self.ui.user].append(self.question)
246         return self.get_question()
247
248     def do_hint(self, arg):
249         "Show a hint for the current question"
250         self._reset()
251         print(self.question.format_help())
252
253     def do_copyright(self, arg):
254         "Print the quiz copyright notice"
255         if self.ui.quiz.copight:
256             print('\n'.join(self.ui.quiz.copyright))
257         else:
258             print(self.ui.quiz.copyright)
259
260     def do_help(self, arg):
261         'List available commands with "help" or detailed help with "help cmd"'
262         if not arg:
263             print('\n'.join(self._help))
264         super(QuestionCommandLine, self).do_help(arg)
265
266
267 class CommandLineInterface (_UserInterface):
268     colors = {  # listed in pygments.console.light_colors
269         'question': 'turquoise',
270         'prompt': 'blue',
271         'correct': 'green',
272         'incorrect': 'red',
273         'result': 'fuchsia',
274         }
275
276     def run(self):
277         self.user = None
278         if self.stack[self.user]:
279             cmd = QuestionCommandLine(ui=self)
280             cmd.cmdloop()
281             print()
282         self._display_results()
283
284     def _display_results(self):
285         print(_colorize(self.colors['result'], 'results:'))
286         answers = self.answers.get_answers(user=self.user)
287         for question in self.quiz:
288             if question.id in answers:
289                 self._display_result(question=question)
290                 print()
291         self._display_totals()
292
293     def _display_result(self, question):
294         answers = self.answers.get_answers(user=self.user).get(question.id, [])
295         print('question:')
296         print('  {}'.format(
297             _colorize(
298                 self.colors['question'],
299                 question.format_prompt(newline='\n  '))))
300         la = len(answers)
301         lc = len([a for a in answers if a['correct']])
302         if la:
303             print('answers: {}/{} ({:.2f})'.format(lc, la, float(lc)/la))
304         for answer in answers:
305             if answer['correct']:
306                 correct = 'correct'
307             else:
308                 correct = 'incorrect'
309             correct = _colorize(self.colors[correct], correct)
310             ans = answer['answer']
311             if question.multiline:
312                 ans = '\n                '.join(ans)
313             print('  you answered: {}'.format(ans))
314             print('     which was: {}'.format(correct))
315
316     def _display_totals(self):
317         answered = self.answers.get_answered(
318             questions=self.quiz, user=self.user)
319         correctly_answered = self.answers.get_correctly_answered(
320             questions=self.quiz, user=self.user)
321         la = len(answered)
322         lc = len(correctly_answered)
323         print('answered {} of {} questions'.format(la, len(self.quiz)))
324         if la:
325             print(('of the answered questions, '
326                    '{} ({:.2f}) were answered correctly'
327                    ).format(lc, float(lc)/la))