1 # Copyright (C) 2013 W. Trevor King <wking@tremily.us>
3 # This file is part of quizzer.
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
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.
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/>.
17 import logging as _logging
20 from . import error as _error
21 from . import util as _util
24 LOG = _logging.getLogger(__name__)
28 def register_question(question_class):
29 QUESTION_CLASS[question_class.__name__] = question_class
32 class Question (object):
45 def __init__(self, **kwargs):
46 self.__setstate__(kwargs)
49 return '<{} id:{!r}>'.format(type(self).__name__, self.id)
52 return '<{} id:{!r} at {:#x}>'.format(
53 type(self).__name__, self.id, id(self))
55 def __getstate__(self):
56 return {attr: getattr(self, attr)
57 for attr in self._state_attributes}
59 def __setstate__(self, state):
61 state['id'] = state.get('prompt', None)
62 if 'tags' not in state:
65 state['tags'] = set(state['tags'])
66 for attr in ['accept_all', 'multiline']:
69 for attr in ['dependencies', 'multimedia']:
72 for attr in self._state_attributes:
75 self.__dict__.update(state)
77 def check(self, answer):
80 return self._check(answer)
82 def _check(self, answer):
83 correct = answer == self.answer
86 details = 'answer ({}) does not match expected value'.format(
88 return (correct, details)
90 def _format_attribute(self, attribute, newline='\n'):
91 value = getattr(self, attribute)
92 if isinstance(value, str):
94 return newline.join(value)
96 def format_prompt(self, **kwargs):
97 return self._format_attribute(attribute='prompt', **kwargs)
99 def format_help(self, **kwargs):
100 return self._format_attribute(attribute='help', **kwargs)
103 class NormalizedStringQuestion (Question):
104 def normalize(self, string):
105 return string.strip().lower()
107 def _check(self, answer):
108 normalized_answer = self.normalize(answer)
109 correct = normalized_answer == self.normalize(self.answer)
112 details = ('normalized answer ({}) does not match expected value'
113 ).format(normalized_answer)
114 return (correct, details)
117 class ChoiceQuestion (Question):
118 _state_attributes = Question._state_attributes + [
123 def __setstate__(self, state):
124 for key in ['display_choices', 'multiple_answers']:
127 super(ChoiceQuestion, self).__setstate__(state)
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])
133 correct = answer in self.answer
136 details = 'answer ({}) is not in list of expected values'.format(
138 return (correct, details)
141 class ScriptQuestion (Question):
142 """Question testing scripting knowledge
144 Or using a script interpreter (like the POSIX shell) to test some
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
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
156 tempdir = q.setup_tempdir()
158 tempdir.invoke(..., env=q.get_environment())
159 # can call .invoke() multiple times here
160 self.check(tempdir=tempdir) # optional answer argument
162 tempdir.cleanup() # occasionally redundant, but that's ok
164 _state_attributes = Question._state_attributes + [
176 def __setstate__(self, state):
177 if 'interpreter' not in state:
178 state['interpreter'] = 'sh' # POSIX-compatible shell
179 if 'timeout' not in state:
181 if 'environment' not in state:
182 state['environment'] = {}
183 for key in ['allow_interactive', 'compare_answers']:
186 for key in ['setup', 'pre_answer', 'post_answer', 'teardown']:
189 super(ScriptQuestion, self).__setstate__(state)
191 def run(self, tempdir, lines, **kwargs):
192 text = '\n'.join(lines + [''])
194 status,stdout,stderr = tempdir.invoke(
195 interpreter=self.interpreter, text=text,
196 timeout=self.timeout, **kwargs)
201 return (status, stdout, stderr)
203 def setup_tempdir(self):
204 tempdir = _util.TemporaryDirectory()
205 self.run(tempdir=tempdir, lines=self.setup)
208 def teardown_tempdir(self, tempdir):
209 return self.run(tempdir=tempdir, lines=self.teardown)
211 def get_environment(self):
214 env.update(_os.environ)
215 env.update(self.environment)
218 def _invoke(self, answer=None, tempdir=None):
219 """Run the setup/answer/teardown process
221 If tempdir is not None, skip the setup process.
222 If answer is None, skip the answer process.
224 In any case, cleanup the tempdir before returning.
227 tempdir = self.setup_tempdir()
230 if not self.multiline:
232 a_status,a_stdout,a_stderr = self.run(
234 lines=self.pre_answer + answer + self.post_answer,
235 env=self.get_environment())
237 a_status = a_stdout = a_stderr = None
238 t_status,t_stdout,t_stderr = self.teardown_tempdir(tempdir=tempdir)
241 return (a_status,a_stdout,a_stderr,
242 t_status,t_stdout,t_stderr)
244 def _check(self, answer=None, tempdir=None):
245 """Compare the user's answer with expected values
247 Arguments are passed through to ._invoke() for calculating the
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
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'
264 return (False, details)
265 # compare user-generated output with expected values
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)
272 details = _util.format_invocation_difference(*difference)
273 return (False, details)
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)
280 details = _util.format_invocation_difference(*difference)
281 return (False, details)
285 for name,obj in list(locals().items()):
286 if name.startswith('_'):
289 subclass = issubclass(obj, Question)
290 except TypeError: # obj is not a class
293 register_question(obj)