question: Add the Question.multimedia attribute master
authorW. Trevor King <wking@tremily.us>
Fri, 26 Apr 2013 12:46:06 +0000 (08:46 -0400)
committerW. Trevor King <wking@tremily.us>
Fri, 26 Apr 2013 12:46:06 +0000 (08:46 -0400)
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
<img> for image/* types).  The assets themselves are served as
media/<HASH>, 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
quizzer/quiz.py
quizzer/ui/cli.py
quizzer/ui/util.py [new file with mode: 0644]
quizzer/ui/wsgi.py

index 393379574548e2934ee8c0491b8afa0f2a6249d0..1b776aa90f9b6d196f7abc9cfa44bc9debfe29fe 100644 (file)
@@ -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
index 6161fc259aa21ef0e9514dd854fef1f37f3ffd4d..4a51b1294d308f9ff833dae04c7cbcda647f3823 100644 (file)
@@ -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
index 763e3a71c7ddd79a1991a09cebd94a325e5d23a8..a7b645ed67da0340e45ea2a711c73e720b143232 100644 (file)
@@ -15,6 +15,8 @@
 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
 
 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 (file)
index 0000000..84196fd
--- /dev/null
@@ -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()
index 514f51fd5493b71c2f2c43fb606ea5ba700135d8..45c296fba6fb29366560734560f17d8d1082246b 100644 (file)
@@ -14,7 +14,9 @@
 # You should have received a copy of the GNU General Public License along with
 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
 
+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 = [
             '<h2>Question</h2>',
-            '<p>{}</p>'.format(question.format_prompt(newline='<br />')),
+            self._format_prompt(question=question),
             '<p>Answers: {}/{} ({:.2f})</p>'.format(lc, la, float(lc)/la),
             ]
         if answers:
@@ -303,8 +307,7 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject):
             '      <input type="hidden" name="user" value="{}">'.format(user),
             '      <input type="hidden" name="question" value="{}">'.format(
                 question.id),
-            '      <p>{}</p>'.format(
-                question.format_prompt(newline='<br/>')),
+            self._format_prompt(question=question),
             answer_element,
             '      <br />',
             '      <input type="submit" value="submit">',
@@ -372,8 +375,7 @@ class QuestionApp (WSGI_UrlObject, WSGI_DataObject):
             '  </head>',
             '  <body>',
             '    <h1>Answer</h1>',
-            '    <p>{}</p>'.format(
-                question.format_prompt(newline='<br/>')),
+            self._format_prompt(question=question),
             '    <pre>{}</pre>'.format(print_answer),
             '    <p>{}</p>'.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 = ['<p>{}</p>'.format(question.format_prompt(newline='<br/>\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 '<p><img src="{}" /></p>'.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):