763e3a71c7ddd79a1991a09cebd94a325e5d23a8
[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 try:
19     import readline as _readline
20 except ImportError as _readline_import_error:
21     _readline = None
22
23 try:
24     from pygments.console import colorize as _colorize
25 except ImportError as e:
26     def _colorize(color_key=None, text=None):
27         return text
28     print(e)
29
30 from .. import error as _error
31 from .. import question as _question
32 from . import UserInterface as _UserInterface
33
34
35 class QuestionCommandLine (_cmd.Cmd):
36     _help = [
37         'Type help or ? to list commands.',
38         'Non-commands will be interpreted as answers.',
39         'Use a blank line to terminate multi-line answers.',
40         ]
41     intro = '\n'.join(['Welcome to the quizzer shell.'] + _help)
42     _prompt = 'quizzer? '
43
44     def __init__(self, ui):
45         super(QuestionCommandLine, self).__init__()
46         self.ui = ui
47         if self.ui.quiz.introduction:
48             self.intro = '\n\n'.join([self.intro, self.ui.quiz.introduction])
49         self._tempdir = None
50
51     def get_question(self):
52         self.question = self.ui.get_question(user=self.ui.user)
53         if self.question:
54             self._reset()
55         else:
56             return True  # out of questions
57
58     def preloop(self):
59         self.get_question()
60
61     def _reset(self):
62         self.answers = []
63         if self._tempdir:
64             self._tempdir.cleanup()  # occasionally redundant, but that's ok
65         self._tempdir = None
66         self._set_ps1()
67
68     def _set_ps1(self):
69         "Pose a question and prompt"
70         if self.question:
71             lines = [
72                 '',
73                 _colorize(
74                     self.ui.colors['question'], self.question.format_prompt()),
75                 ]
76             lines.extend(
77                 _colorize(self.ui.colors['prompt'], line)
78                 for line in self._extra_ps1_lines())
79             lines.append(_colorize(self.ui.colors['prompt'], self._prompt))
80             self.prompt = '\n'.join(lines)
81         else:
82             self.prompt = _colorize(self.ui.colors['prompt'], self._prompt)
83
84     def _set_ps2(self):
85         "Just prompt (without the question, e.g. for multi-line answers)"
86         self.prompt = _colorize(self.ui.colors['prompt'], self._prompt)
87
88     def _extra_ps1_lines(self):
89         if (isinstance(self.question, _question.ChoiceQuestion) and
90                 self.question.display_choices):
91             for i,choice in enumerate(self.question.answer):
92                 yield '{}) {}'.format(i, choice)
93             yield 'Answer with the index of your choice'
94             if self.question.accept_all:
95                 conj = 'or'
96                 if self.question.multiple_answers:
97                     conj = 'and/or'
98                 yield '{} fill in an alternative answer'.format(conj)
99             if self.question.multiple_answers:
100                 self._separator = ','
101                 yield ("Separate multiple answers with the '{}' character"
102                        ).format(self._separator)
103
104     def _process_answer(self, answer):
105         "Back out any mappings suggested by _extra_ps1_lines()"
106         if (isinstance(self.question, _question.ChoiceQuestion) and
107                 self.question.display_choices):
108             if self.question.multiple_answers:
109                 answers = []
110                 for a in answer.split(self._separator):
111                     try:
112                         i = int(a)
113                         answers.append(self.question.answer[i])
114                     except (ValueError, IndexError):
115                         answers.append(a)
116                 return answers
117             else:
118                 try:
119                     i = int(answer)
120                     return self.question.answer[i]
121                 except (ValueError, IndexError):
122                     pass
123         return answer
124
125     def default(self, line):
126         self.answers.append(line)
127         if self.question.multiline:
128             self._set_ps2()
129         else:
130             return self._answer()
131
132     def emptyline(self):
133         return self._answer()
134
135     def _answer(self):
136         if self.question.multiline:
137             answer = self.answers
138         elif self.answers:
139             answer = self.answers[0]
140         else:
141             answer = ''
142         if answer == 'EOF':
143             return True  # quit
144         if answer == '':
145             return
146         kwargs = {}
147         if self._tempdir:
148             kwargs['tempdir'] = self._tempdir
149         answer = self._process_answer(answer=answer)
150         correct,details = self.ui.process_answer(
151             question=self.question, answer=answer, **kwargs)
152         if correct:
153             print(_colorize(self.ui.colors['correct'], 'correct\n'))
154         else:
155             print(_colorize(self.ui.colors['incorrect'], 'incorrect'))
156             if details:
157                 print(_colorize(
158                         self.ui.colors['incorrect'], '{}\n'.format(details)))
159             else:
160                 print('')
161         return self.get_question()
162
163     def do_answer(self, arg):
164         """Explicitly add a line to your answer
165
166         This is useful if the line you'd like to add starts with a
167         quizzer-shell command.  For example:
168
169           quizzer? answer help=5
170         """
171         return self.default(arg)
172
173     def do_shell(self, arg):
174         """Run a shell command in the question temporary directory
175
176         For example, you can spawn an interactive session with:
177
178           quizzer? !bash
179
180         If the question does not allow interactive sessions, this
181         action is a no-op.
182         """
183         if getattr(self.question, 'allow_interactive', False):
184             if not self._tempdir:
185                 self._tempdir = self.question.setup_tempdir()
186             try:
187                 self._tempdir.invoke(
188                     interpreter='/bin/sh', text=arg, stdout=None, stderr=None,
189                     universal_newlines=False,
190                     env=self.question.get_environment())
191             except (KeyboardInterrupt, _error.CommandError) as e:
192                 if isinstance(e, KeyboardInterrupt):
193                     LOG.warning('KeyboardInterrupt')
194                 else:
195                     LOG.warning(e)
196                 self._tempdir.cleanup()
197                 self._tempdir = None
198
199     def do_quit(self, arg):
200         "Stop taking the quiz"
201         self._reset()
202         return True
203
204     def do_skip(self, arg):
205         "Skip the current question, and continue with the quiz"
206         self.ui.stack[self.ui.user].append(self.question)
207         return self.get_question()
208
209     def do_hint(self, arg):
210         "Show a hint for the current question"
211         self._reset()
212         print(self.question.format_help())
213
214     def do_copyright(self, arg):
215         "Print the quiz copyright notice"
216         if self.ui.quiz.copyright:
217             print('\n'.join(self.ui.quiz.copyright))
218         else:
219             print(self.ui.quiz.copyright)
220
221     def do_help(self, arg):
222         'List available commands with "help" or detailed help with "help cmd"'
223         if not arg:
224             print('\n'.join(self._help))
225         super(QuestionCommandLine, self).do_help(arg)
226
227
228 class CommandLineInterface (_UserInterface):
229     colors = {  # listed in pygments.console.light_colors
230         'question': 'turquoise',
231         'prompt': 'blue',
232         'correct': 'green',
233         'incorrect': 'red',
234         'result': 'fuchsia',
235         }
236
237     def run(self):
238         self.user = None
239         if self.stack[self.user]:
240             cmd = QuestionCommandLine(ui=self)
241             cmd.cmdloop()
242             print()
243         self._display_results()
244
245     def _display_results(self):
246         print(_colorize(self.colors['result'], 'results:'))
247         answers = self.answers.get_answers(user=self.user)
248         for question in self.quiz:
249             if question.id in answers:
250                 self._display_result(question=question)
251                 print()
252         self._display_totals()
253
254     def _display_result(self, question):
255         answers = self.answers.get_answers(user=self.user).get(question.id, [])
256         print('question:')
257         print('  {}'.format(
258             _colorize(
259                 self.colors['question'],
260                 question.format_prompt(newline='\n  '))))
261         la = len(answers)
262         lc = len([a for a in answers if a['correct']])
263         if la:
264             print('answers: {}/{} ({:.2f})'.format(lc, la, float(lc)/la))
265         for answer in answers:
266             if answer['correct']:
267                 correct = 'correct'
268             else:
269                 correct = 'incorrect'
270             correct = _colorize(self.colors[correct], correct)
271             ans = answer['answer']
272             if question.multiline:
273                 ans = '\n                '.join(ans)
274             print('  you answered: {}'.format(ans))
275             print('     which was: {}'.format(correct))
276
277     def _display_totals(self):
278         answered = self.answers.get_answered(
279             questions=self.quiz, user=self.user)
280         correctly_answered = self.answers.get_correctly_answered(
281             questions=self.quiz, user=self.user)
282         la = len(answered)
283         lc = len(correctly_answered)
284         print('answered {} of {} questions'.format(la, len(self.quiz)))
285         if la:
286             print(('of the answered questions, '
287                    '{} ({:.2f}) were answered correctly'
288                    ).format(lc, float(lc)/la))