From 3fcfd9ceeb8e80eb15f38bf9bd396b62fb5bc0dc Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Fri, 26 Apr 2013 08:46:06 -0400 Subject: [PATCH] question: Add the Question.multimedia attribute Many questions have figures associated with the prompt text or the answer choices. This attribute provides a means to link to these multimedia assets from the question. Here's an example from the force concept inventory, which I'm currently transcribing: { "class": "ChoiceQuestion", "id": "sling shot", "prompt": "A heavy ball is attached to a string...", "multimedia": [ { "content-type": "image/png", "path": "force-concept-inventory/04.png" } ], "display_choices": true, "answer": [ "(A) in the figure", "(B) in the figure", "(C) in the figure", "(D) in the figure", "(E) in the figure" ] } The 'multimedia' attribute is an array because a question might conceivably reference several assets (e.g. an image and an audio clip, or multiple images). To display the assets alongside the question, I've refactored the WSGI handler to pull out QuestionApp._format_prompt(), which in turn delegates to the new QuestionApp._format_multimedia(). This embeds a link to the asset, using a MIME-appropriate container (currently just for image/* types). The assets themselves are served as media/, where I hash the whole multimedia-defining dictionary to avoid information leakage which might otherwise help in solving the problem. Using hash-based URLs also avoids problems with special characters in the multimedia data. In the CLI handler, we can't inline multimedia. I use the user's mailcap configuration (RFC 1524) to spawn their preferred MIME-appropriate viewer when the prompt for a new question is being generated. If the user hasn't configured a viewer for the MIME type in question, we just print a message referring to the asset by its full path and let the user take it from there. The mailcap viewer is adapted from an example I initially posted on my blog in 2011 [1]. The path-quoting follows the Mutt docs [2]: > The interpretion of shell meta-characters embedded in MIME > parameters can lead to security problems in general. Mutt tries to > quote parameters in expansion of `%s` syntaxes properly, and avoids > risky characters by substituting them, see the `mailcap_sanitize` > variable. [1]: http://blog.tremily.us/posts/mailcap/mailcap-test.py [2]: http://www.mutt.org/doc/manual/manual-5.html --- quizzer/question.py | 6 +++-- quizzer/quiz.py | 11 ++++++++ quizzer/ui/cli.py | 65 ++++++++++++++++++++++++++++++++++++--------- quizzer/ui/util.py | 35 ++++++++++++++++++++++++ quizzer/ui/wsgi.py | 51 +++++++++++++++++++++++++++++++---- 5 files changed, 148 insertions(+), 20 deletions(-) create mode 100644 quizzer/ui/util.py diff --git a/quizzer/question.py b/quizzer/question.py index 3933795..1b776aa 100644 --- a/quizzer/question.py +++ b/quizzer/question.py @@ -33,6 +33,7 @@ class Question (object): _state_attributes = [ 'id', 'prompt', + 'multimedia', 'answer', 'accept_all', 'multiline', @@ -58,8 +59,6 @@ class Question (object): def __setstate__(self, state): if 'id' not in state: state['id'] = state.get('prompt', None) - if 'dependencies' not in state: - state['dependencies'] = [] if 'tags' not in state: state['tags'] = set() else: @@ -67,6 +66,9 @@ class Question (object): for attr in ['accept_all', 'multiline']: if attr not in state: state[attr] = False + for attr in ['dependencies', 'multimedia']: + if attr not in state: + state[attr] = [] for attr in self._state_attributes: if attr not in state: state[attr] = None diff --git a/quizzer/quiz.py b/quizzer/quiz.py index 6161fc2..4a51b12 100644 --- a/quizzer/quiz.py +++ b/quizzer/quiz.py @@ -16,6 +16,7 @@ import codecs as _codecs import json as _json +import os.path as _os_path from . import __version__ from . import question as _question @@ -92,6 +93,16 @@ class Quiz (list): raise NotImplementedError( 'multiple questions with one ID: {}'.format(matches)) + def multimedia_path(self, multimedia): + if 'path' in multimedia: + basedir = _os_path.dirname(self.path) + path = multimedia['path'] + if _os_path.sep != '/': # convert to native separators + path = path.replace('/', _os_path.sep) + return _os_path.join(basedir, multimedia['path']) + else: + raise NotImplementedError(question.multimedia) + def _upgrade_from_0_1(self, data): data['version'] = __version__ return data diff --git a/quizzer/ui/cli.py b/quizzer/ui/cli.py index 763e3a7..a7b645e 100644 --- a/quizzer/ui/cli.py +++ b/quizzer/ui/cli.py @@ -15,6 +15,8 @@ # quizzer. If not, see . import cmd as _cmd +import logging as _logging +import os.path as _os_path try: import readline as _readline except ImportError as _readline_import_error: @@ -30,6 +32,10 @@ except ImportError as e: from .. import error as _error from .. import question as _question from . import UserInterface as _UserInterface +from . import util as _util + + +_LOG = _logging.getLogger(__name__) class QuestionCommandLine (_cmd.Cmd): @@ -47,6 +53,7 @@ class QuestionCommandLine (_cmd.Cmd): if self.ui.quiz.introduction: self.intro = '\n\n'.join([self.intro, self.ui.quiz.introduction]) self._tempdir = None + self._children = [] def get_question(self): self.question = self.ui.get_question(user=self.ui.user) @@ -58,6 +65,10 @@ class QuestionCommandLine (_cmd.Cmd): def preloop(self): self.get_question() + def postcmd(self, stop, line): + self._reap_children() + return stop + def _reset(self): self.answers = [] if self._tempdir: @@ -86,20 +97,48 @@ class QuestionCommandLine (_cmd.Cmd): self.prompt = _colorize(self.ui.colors['prompt'], self._prompt) def _extra_ps1_lines(self): + for multimedia in self.question.multimedia: + for line in self._format_multimedia(multimedia): + yield line # for Python 3.3, use PEP 380's `yield from ...` if (isinstance(self.question, _question.ChoiceQuestion) and self.question.display_choices): - for i,choice in enumerate(self.question.answer): - yield '{}) {}'.format(i, choice) - yield 'Answer with the index of your choice' - if self.question.accept_all: - conj = 'or' - if self.question.multiple_answers: - conj = 'and/or' - yield '{} fill in an alternative answer'.format(conj) - if self.question.multiple_answers: - self._separator = ',' - yield ("Separate multiple answers with the '{}' character" - ).format(self._separator) + for line in self._format_choices(question=self.question): + yield line + + def _format_multimedia(self, multimedia): + path = self.ui.quiz.multimedia_path(multimedia=multimedia) + content_type = multimedia['content-type'] + try: + self._children.append(_util.mailcap_view( + path=path, content_type=content_type, background=True)) + except NotImplementedError: + path = _os_path.abspath(path) + yield 'multimedia ({}): {}'.format(content_type, path) + + def _reap_children(self): + reaped = [] + for process in self._children: + _LOG.debug('poll child process {}'.format(process.pid)) + if process.poll() is not None: + _LOG.debug('process {} returned {}'.format( + process.pid, process.returncode)) + reaped.append(process) + for process in reaped: + self._children.remove(process) + + def _format_choices(self, question): + for i,choice in enumerate(question.answer): + yield '{}) {}'.format(i, choice) + yield 'Answer with the index of your choice' + if question.accept_all: + conj = 'or' + if question.multiple_answers: + conj = 'and/or' + yield '{} fill in an alternative answer'.format(conj) + if question.multiple_answers: + self._separator = ',' + yield ("Separate multiple answers with the '{}' character" + ).format(self._separator) def _process_answer(self, answer): "Back out any mappings suggested by _extra_ps1_lines()" @@ -213,7 +252,7 @@ class QuestionCommandLine (_cmd.Cmd): def do_copyright(self, arg): "Print the quiz copyright notice" - if self.ui.quiz.copyright: + if self.ui.quiz.copight: print('\n'.join(self.ui.quiz.copyright)) else: print(self.ui.quiz.copyright) diff --git a/quizzer/ui/util.py b/quizzer/ui/util.py new file mode 100644 index 0000000..84196fd --- /dev/null +++ b/quizzer/ui/util.py @@ -0,0 +1,35 @@ +# Copyright + +"""View files using mailcap-specified commands +""" + +import logging as _logging +import mailcap as _mailcap +import mimetypes as _mimetypes +import shlex as _shlex +if not hasattr(_shlex, 'quote'): # Python < 3.3 + import pipes as _pipes + _shlex.quote = _pipes.quote +import subprocess as _subprocess + + +_LOG = _logging.getLogger(__name__) +_CAPS = _mailcap.getcaps() + + +def mailcap_view(path, content_type=None, background=False): + if content_type is None: + content_type,encoding = _mimetypes.guess_type(path) + if content_type is None: + return 1 + _LOG.debug('guessed {} for {}'.format(content_type, path)) + match = _mailcap.findmatch( + _CAPS, content_type, filename=_shlex.quote(path)) + if match[0] is None: + _LOG.warn('no mailcap viewer found for {}'.format(content_type)) + raise NotImplementedError(content_type) + _LOG.debug('view {} with: {}'.format(path, match[0])) + process = _subprocess.Popen(match[0], shell=True) + if background: + return process + return process.wait() diff --git a/quizzer/ui/wsgi.py b/quizzer/ui/wsgi.py index 514f51f..45c296f 100644 --- a/quizzer/ui/wsgi.py +++ b/quizzer/ui/wsgi.py @@ -14,7 +14,9 @@ # You should have received a copy of the GNU General Public License along with # quizzer. If not, see . +import hashlib as _hashlib import logging as _logging +import os.path as _os_path import select as _select import socket as _socket import re as _re @@ -153,11 +155,13 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject): ('^question/', self._question), ('^answer/', self._answer), ('^results/', self._results), + ('^media/([^/]+)', self._media), ], **kwargs) self.ui = ui self.user_regexp = _re.compile('^\w+$') + self._local_media = {} def _index(self, environ, start_response): lines = [ @@ -217,7 +221,7 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject): lc = len([a for a in answers if a['correct']]) lines = [ '

Question

', - '

{}

'.format(question.format_prompt(newline='
')), + self._format_prompt(question=question), '

Answers: {}/{} ({:.2f})

'.format(lc, la, float(lc)/la), ] if answers: @@ -303,8 +307,7 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject): ' '.format(user), ' '.format( question.id), - '

{}

'.format( - question.format_prompt(newline='
')), + self._format_prompt(question=question), answer_element, '
', ' ', @@ -372,8 +375,7 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject): ' ', ' ', '

Answer

', - '

{}

'.format( - question.format_prompt(newline='
')), + self._format_prompt(question=question), '
{}
'.format(print_answer), '

{}

'.format(correct_msg), details or '', @@ -391,6 +393,45 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject): environ, start_response, content=content, encoding='utf-8', content_type='text/html') + def _format_prompt(self, question): + lines = ['

{}

'.format(question.format_prompt(newline='
\n'))] + for multimedia in question.multimedia: + lines.append(self._format_multimedia(multimedia=multimedia)) + return '\n'.join(lines) + + def _format_multimedia(self, multimedia): + content_type = multimedia['content-type'] + if 'path' in multimedia: + uid = _hashlib.sha1( + str(multimedia).encode('unicode-escape') + ).hexdigest() + path = self.ui.quiz.multimedia_path(multimedia) + self._local_media[uid] = { + 'content-type': content_type, + 'path': path, + } + url = '../media/{}'.format(uid) + else: + raise NotImplementedError(multimedia) + if content_type.startswith('image/'): + return '

'.format(url) + else: + raise NotImplementedError(content_type) + + def _media(self, environ, start_response): + try: + uid, = environ['{}.url_args'.format(self.setting)] + except: + raise HandlerError(404, 'Not Found') + if uid not in self._local_media: + raise HandlerError(404, 'Not Found') + content_type = self._local_media[uid]['content-type'] + with open(self._local_media[uid]['path'], 'rb') as f: + content = f.read() + return self.ok_response( + environ, start_response, content=content, + content_type=content_type) + class HTMLInterface (_UserInterface): def run(self, host='', port=8000): -- 2.26.2