ui.cli: Don't record EOF or blank entries as answers
[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             if self.question.accept_all:
94                 yield 'or fill in something else'
95         return []
96
97     def _process_answer(self, answer):
98         "Back out any mappings suggested by _extra_ps1_lines()"
99         if (isinstance(self.question, _question.ChoiceQuestion) and
100                 self.question.display_choices):
101             try:
102                 a = int(answer)
103                 return self.question.answer[a]
104             except (ValueError, IndexError):
105                 pass
106         return answer
107
108     def default(self, line):
109         self.answers.append(line)
110         if self.question.multiline:
111             self._set_ps2()
112         else:
113             return self._answer()
114
115     def emptyline(self):
116         return self._answer()
117
118     def _answer(self):
119         if self.question.multiline:
120             answer = self.answers
121         elif self.answers:
122             answer = self.answers[0]
123         else:
124             answer = ''
125         if answer == 'EOF':
126             return True  # quit
127         if answer == '':
128             return
129         kwargs = {}
130         if self._tempdir:
131             kwargs['tempdir'] = self._tempdir
132         answer = self._process_answer(answer=answer)
133         correct,details = self.ui.process_answer(
134             question=self.question, answer=answer, **kwargs)
135         if correct:
136             print(_colorize(self.ui.colors['correct'], 'correct\n'))
137         else:
138             print(_colorize(self.ui.colors['incorrect'], 'incorrect'))
139             if details:
140                 print(_colorize(
141                         self.ui.colors['incorrect'], '{}\n'.format(details)))
142             else:
143                 print('')
144         return self.get_question()
145
146     def do_answer(self, arg):
147         """Explicitly add a line to your answer
148
149         This is useful if the line you'd like to add starts with a
150         quizzer-shell command.  For example:
151
152           quizzer? answer help=5
153         """
154         return self.default(arg)
155
156     def do_shell(self, arg):
157         """Run a shell command in the question temporary directory
158
159         For example, you can spawn an interactive session with:
160
161           quizzer? !bash
162
163         If the question does not allow interactive sessions, this
164         action is a no-op.
165         """
166         if getattr(self.question, 'allow_interactive', False):
167             if not self._tempdir:
168                 self._tempdir = self.question.setup_tempdir()
169             try:
170                 self._tempdir.invoke(
171                     interpreter='/bin/sh', text=arg, stdout=None, stderr=None,
172                     universal_newlines=False,
173                     env=self.question.get_environment())
174             except (KeyboardInterrupt, _error.CommandError) as e:
175                 if isinstance(e, KeyboardInterrupt):
176                     LOG.warning('KeyboardInterrupt')
177                 else:
178                     LOG.warning(e)
179                 self._tempdir.cleanup()
180                 self._tempdir = None
181
182     def do_quit(self, arg):
183         "Stop taking the quiz"
184         self._reset()
185         return True
186
187     def do_skip(self, arg):
188         "Skip the current question, and continue with the quiz"
189         self.ui.stack[self.ui.user].append(self.question)
190         return self.get_question()
191
192     def do_hint(self, arg):
193         "Show a hint for the current question"
194         self._reset()
195         print(self.question.format_help())
196
197     def do_copyright(self, arg):
198         "Print the quiz copyright notice"
199         if self.ui.quiz.copyright:
200             print('\n'.join(self.ui.quiz.copyright))
201         else:
202             print(self.ui.quiz.copyright)
203
204     def do_help(self, arg):
205         'List available commands with "help" or detailed help with "help cmd"'
206         if not arg:
207             print('\n'.join(self._help))
208         super(QuestionCommandLine, self).do_help(arg)
209
210
211 class CommandLineInterface (_UserInterface):
212     colors = {  # listed in pygments.console.light_colors
213         'question': 'turquoise',
214         'prompt': 'blue',
215         'correct': 'green',
216         'incorrect': 'red',
217         'result': 'fuchsia',
218         }
219
220     def run(self):
221         self.user = None
222         if self.stack[self.user]:
223             cmd = QuestionCommandLine(ui=self)
224             cmd.cmdloop()
225             print()
226         self._display_results()
227
228     def _display_results(self):
229         print(_colorize(self.colors['result'], 'results:'))
230         answers = self.answers.get_answers(user=self.user)
231         for question in self.quiz:
232             if question.id in answers:
233                 self._display_result(question=question)
234                 print()
235         self._display_totals()
236
237     def _display_result(self, question):
238         answers = self.answers.get_answers(user=self.user).get(question.id, [])
239         print('question:')
240         print('  {}'.format(
241             _colorize(
242                 self.colors['question'],
243                 question.format_prompt(newline='\n  '))))
244         la = len(answers)
245         lc = len([a for a in answers if a['correct']])
246         if la:
247             print('answers: {}/{} ({:.2f})'.format(lc, la, float(lc)/la))
248         for answer in answers:
249             if answer['correct']:
250                 correct = 'correct'
251             else:
252                 correct = 'incorrect'
253             correct = _colorize(self.colors[correct], correct)
254             ans = answer['answer']
255             if question.multiline:
256                 ans = '\n                '.join(ans)
257             print('  you answered: {}'.format(ans))
258             print('     which was: {}'.format(correct))
259
260     def _display_totals(self):
261         answered = self.answers.get_answered(
262             questions=self.quiz, user=self.user)
263         correctly_answered = self.answers.get_correctly_answered(
264             questions=self.quiz, user=self.user)
265         la = len(answered)
266         lc = len(correctly_answered)
267         print('answered {} of {} questions'.format(la, len(self.quiz)))
268         if la:
269             print(('of the answered questions, '
270                    '{} ({:.2f}) were answered correctly'
271                    ).format(lc, float(lc)/la))