question: Add the Question.multimedia attribute
[quizzer.git] / quizzer / question.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 logging as _logging
18 import os as _os
19
20 from . import error as _error
21 from . import util as _util
22
23
24 LOG = _logging.getLogger(__name__)
25 QUESTION_CLASS = {}
26
27
28 def register_question(question_class):
29     QUESTION_CLASS[question_class.__name__] = question_class
30
31
32 class Question (object):
33     _state_attributes = [
34         'id',
35         'prompt',
36         'multimedia',
37         'answer',
38         'accept_all',
39         'multiline',
40         'help',
41         'dependencies',
42         'tags',
43         ]
44
45     def __init__(self, **kwargs):
46         self.__setstate__(kwargs)
47
48     def __str__(self):
49         return '<{} id:{!r}>'.format(type(self).__name__, self.id)
50
51     def __repr__(self):
52         return '<{} id:{!r} at {:#x}>'.format(
53             type(self).__name__, self.id, id(self))
54
55     def __getstate__(self):
56         return {attr: getattr(self, attr)
57                 for attr in self._state_attributes} 
58
59     def __setstate__(self, state):
60         if 'id' not in state:
61             state['id'] = state.get('prompt', None)
62         if 'tags' not in state:
63             state['tags'] = set()
64         else:
65             state['tags'] = set(state['tags'])
66         for attr in ['accept_all', 'multiline']:
67             if attr not in state:
68                 state[attr] = False
69         for attr in ['dependencies', 'multimedia']:
70             if attr not in state:
71                 state[attr] = []
72         for attr in self._state_attributes:
73             if attr not in state:
74                 state[attr] = None
75         self.__dict__.update(state)
76
77     def check(self, answer):
78         if self.accept_all:
79             return (True, None)
80         return self._check(answer)
81
82     def _check(self, answer):
83         correct = answer == self.answer
84         details = None
85         if not correct:
86             details = 'answer ({}) does not match expected value'.format(
87                 answer)
88         return (correct, details)
89
90     def _format_attribute(self, attribute, newline='\n'):
91         value = getattr(self, attribute)
92         if isinstance(value, str):
93             return value
94         return newline.join(value)
95
96     def format_prompt(self, **kwargs):
97         return self._format_attribute(attribute='prompt', **kwargs)
98
99     def format_help(self, **kwargs):
100         return self._format_attribute(attribute='help', **kwargs)
101
102
103 class NormalizedStringQuestion (Question):
104     def normalize(self, string):
105         return string.strip().lower()
106
107     def _check(self, answer):
108         normalized_answer = self.normalize(answer)
109         correct = normalized_answer == self.normalize(self.answer)
110         details = None
111         if not correct:
112             details = ('normalized answer ({}) does not match expected value'
113                        ).format(normalized_answer)
114         return (correct, details)
115
116
117 class ChoiceQuestion (Question):
118     _state_attributes = Question._state_attributes + [
119         'display_choices',
120         'multiple_answers',
121         ]
122
123     def __setstate__(self, state):
124         for key in ['display_choices', 'multiple_answers']:
125             if key not in state:
126                 state[key] = False
127         super(ChoiceQuestion, self).__setstate__(state)
128
129     def _check(self, answer):
130         if self.multiple_answers and not isinstance(answer, str):
131             correct = min([a in self.answer for a in answer])
132         else:
133             correct = answer in self.answer
134         details = None
135         if not correct:
136             details = 'answer ({}) is not in list of expected values'.format(
137                 answer)
138         return (correct, details)
139
140
141 class ScriptQuestion (Question):
142     """Question testing scripting knowledge
143
144     Or using a script interpreter (like the POSIX shell) to test some
145     other knowledge.
146
147     If stdout/stderr capture is acceptable (e.g. if you're only
148     running non-interactive commands or curses applications that grab
149     the TTY directly), you can just run `.check()` like a normal
150     question.
151
152     If, on the other hand, you want users to be able to interact with
153     stdout and stderr (e.g. to drop into a shell in the temporary
154     directory), use:
155
156         tempdir = q.setup_tempdir()
157         try:
158             tempdir.invoke(..., env=q.get_environment())
159             # can call .invoke() multiple times here
160             self.check(tempdir=tempdir)  # optional answer argument
161         finally:
162             tempdir.cleanup()  # occasionally redundant, but that's ok
163     """
164     _state_attributes = Question._state_attributes + [
165         'interpreter',
166         'setup',
167         'pre_answer',
168         'post_answer',
169         'teardown',
170         'environment',
171         'allow_interactive',
172         'compare_answers',
173         'timeout',
174         ]
175
176     def __setstate__(self, state):
177         if 'interpreter' not in state:
178             state['interpreter'] = 'sh'  # POSIX-compatible shell
179         if 'timeout' not in state:
180             state['timeout'] = 3
181         if 'environment' not in state:
182             state['environment'] = {}
183         for key in ['allow_interactive', 'compare_answers']:
184             if key not in state:
185                 state[key] = False
186         for key in ['setup', 'pre_answer', 'post_answer', 'teardown']:
187             if key not in state:
188                 state[key] = []
189         super(ScriptQuestion, self).__setstate__(state)
190
191     def run(self, tempdir, lines, **kwargs):
192         text = '\n'.join(lines + [''])
193         try:
194             status,stdout,stderr = tempdir.invoke(
195                 interpreter=self.interpreter, text=text,
196                 timeout=self.timeout, **kwargs)
197         except:
198             tempdir.cleanup()
199             raise
200         else:
201             return (status, stdout, stderr)
202
203     def setup_tempdir(self):
204         tempdir = _util.TemporaryDirectory()
205         self.run(tempdir=tempdir, lines=self.setup)
206         return tempdir
207
208     def teardown_tempdir(self, tempdir):
209         return self.run(tempdir=tempdir, lines=self.teardown)
210
211     def get_environment(self):
212         if self.environment:
213             env = {}
214             env.update(_os.environ)
215             env.update(self.environment)
216             return env
217
218     def _invoke(self, answer=None, tempdir=None):
219         """Run the setup/answer/teardown process
220
221         If tempdir is not None, skip the setup process.
222         If answer is None, skip the answer process.
223
224         In any case, cleanup the tempdir before returning.
225         """
226         if not tempdir:
227             tempdir = self.setup_tempdir()
228         try:
229             if answer:
230                 if not self.multiline:
231                     answer = [answer]
232                 a_status,a_stdout,a_stderr = self.run(
233                     tempdir=tempdir,
234                     lines=self.pre_answer + answer + self.post_answer,
235                     env=self.get_environment())
236             else:
237                 a_status = a_stdout = a_stderr = None
238             t_status,t_stdout,t_stderr = self.teardown_tempdir(tempdir=tempdir)
239         finally:
240             tempdir.cleanup()
241         return (a_status,a_stdout,a_stderr,
242                 t_status,t_stdout,t_stderr)
243
244     def _check(self, answer=None, tempdir=None):
245         """Compare the user's answer with expected values
246
247         Arguments are passed through to ._invoke() for calculating the
248         user's response.
249         """
250         details = None
251         # figure out the expected values
252         (ea_status,ea_stdout,ea_stderr,
253          et_status,et_stdout,et_stderr) = self._invoke(answer=self.answer)
254         # get values for the user-supplied answer
255         try:
256             (ua_status,ua_stdout,ua_stderr,
257              ut_status,ut_stdout,ut_stderr) = self._invoke(
258                 answer=answer, tempdir=tempdir)
259         except (KeyboardInterrupt, _error.CommandError) as e:
260             if isinstance(e, KeyboardInterrupt):
261                 details = 'KeyboardInterrupt'
262             else:
263                 details = str(e)
264             return (False, details)
265         # compare user-generated output with expected values
266         if answer:
267             if self.compare_answers:
268                 difference = _util.invocation_difference(  # compare answers
269                     ea_status, ea_stdout, ea_stderr,
270                     ua_status, ua_stdout, ua_stderr)
271                 if difference:
272                     details = _util.format_invocation_difference(*difference)
273                     return (False, details)
274             elif ua_stderr:
275                 LOG.warning(ua_stderr)
276         difference = _util.invocation_difference(  # compare teardown
277             et_status, et_stdout, et_stderr,
278             ut_status, ut_stdout, ut_stderr)
279         if difference:
280             details = _util.format_invocation_difference(*difference)
281             return (False, details)
282         return (True, None)
283
284
285 for name,obj in list(locals().items()):
286     if name.startswith('_'):
287         continue
288     try:
289         subclass = issubclass(obj, Question)
290     except TypeError:  # obj is not a class
291         continue
292     if subclass:
293         register_question(obj)
294 del name, obj