From 74e23546af9dcad10d162e9491862b78f5310efe Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Wed, 31 Oct 2012 18:40:10 -0400 Subject: [PATCH] More inputtype refactor - add an Attribute class - input types just need to declare which attributes they want, and how to transform and validate them, and the base class will do all the rest. - change OptionInput to new format. --- common/lib/capa/capa/inputtypes.py | 156 +++++++++++++++++++++++------ 1 file changed, 124 insertions(+), 32 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index de29b5e664..f154569fe4 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -32,8 +32,7 @@ graded status as'status' # makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a # general css and layout strategy for capa, document it, then implement it. - - +from collections import namedtuple import json import logging from lxml import etree @@ -50,6 +49,58 @@ log = logging.getLogger('mitx.' + __name__) registry = TagRegistry() +class Attribute(object): + """ + Allows specifying required and optional attributes for input types. + """ + + # want to allow default to be None, but also allow required objects + _sentinel = object() + + def __init__(self, name, default=_sentinel, transform=None, validate=None): + """ + Define an attribute + + name (str): then name of the attribute--should be alphanumeric (valid for an XML attribute) + + default (any type): If not specified, this attribute is required. If specified, use this as the default value + if the attribute is not specified. Note that this value will not be transformed or validated. + + transform (function str -> any type): If not None, will be called to transform the parsed value into an internal + representation. + + validate (function str-or-return-type-of-tranform -> unit or exception): If not None, called to validate the + (possibly transformed) value of the attribute. Should raise ValueError with a helpful message if + the value is invalid. + """ + self.name = name + self.default = default + self.validate = validate + self.transform = transform + + def parse_from_xml(self, element): + """ + Given an etree xml element that should have this attribute, do the obvious thing: + - look for it. raise ValueError if not found and required. + - transform and validate. pass through any exceptions from transform or validate. + """ + val = element.get(self.name) + if self.default == self._sentinel and val is None: + raise ValueError('Missing required attribute {0}.'.format(self.name)) + + if val is None: + # not required, so return default + return self.default + + if self.transform is not None: + val = self.transform(val) + + if self.validate is not None: + self.validate(val) + + return val + + class InputTypeBase(object): """ Abstract base class for input types. @@ -102,9 +153,12 @@ class InputTypeBase(object): self.status = state.get('status', 'unanswered') - # Call subclass "constructor" -- means they don't have to worry about calling - # super().__init__, and are isolated from changes to the input constructor interface. try: + # Pre-parse and propcess all the declared requirements. + self.process_requirements() + + # Call subclass "constructor" -- means they don't have to worry about calling + # super().__init__, and are isolated from changes to the input constructor interface. self.setup() except Exception as err: # Something went wrong: add xml to message, but keep the traceback @@ -112,6 +166,32 @@ class InputTypeBase(object): raise Exception, msg, sys.exc_info()[2] + @classmethod + def get_attributes(cls): + """ + Should return a list of Attribute objects (see docstring there for details). Subclasses should override. e.g. + + return super(MyClass, cls).attributes + [Attribute('unicorn', True), + Attribute('num_dragons', 12, transform=int), ...] + """ + return [] + + + def process_requirements(self): + """ + Subclasses can declare lists of required and optional attributes. This + function parses the input xml and pulls out those attributes. This + isolates most simple input types from needing to deal with xml parsing at all. + + Processes attributes, putting the results in the self.loaded_attributes dictionary. + """ + # Use a local dict so that if there are exceptions, we don't end up in a partially-initialized state. + d = {} + for a in self.get_attributes(): + d[a.name] = a.parse_from_xml(self.xml) + + self.loaded_attributes = d + def setup(self): """ InputTypes should override this to do any needed initialization. It is called after the @@ -122,14 +202,28 @@ class InputTypeBase(object): """ pass + def _get_render_context(self): """ - Abstract method. Subclasses should implement to return the dictionary - of keys needed to render their template. + Should return a dictionary of keys needed to render the template for the input type. (Separate from get_html to faciliate testing of logic separately from the rendering) + + The default implementation gets the following rendering context: basic things like value, id, + status, and msg, as well as everything in self.loaded_attributes. + + This means that input types that only parse attributes get everything they need, and don't need + to override this method. """ - raise NotImplementedError + context = { + 'id': self.id, + 'value': self.value, + 'status': self.status, + 'msg': self.msg, + } + context.update(self.loaded_attributes) + return context + def get_html(self): """ @@ -139,7 +233,10 @@ class InputTypeBase(object): raise NotImplementedError("no rendering template specified for class {0}" .format(self.__class__)) - html = self.system.render_template(self.template, self._get_render_context()) + context = self._default_render_context() + context.update(self._get_render_context()) + + html = self.system.render_template(self.template, context) return etree.XML(html) @@ -158,33 +255,28 @@ class OptionInput(InputTypeBase): template = "optioninput.html" tags = ['optioninput'] - def setup(self): - # Extract the options... - options = self.xml.get('options') - if not options: - raise ValueError("optioninput: Missing 'options' specification.") + @classmethod + def get_attributes(cls): + """ + Convert options to a convenient format. + """ - # parse the set of possible options - oset = shlex.shlex(options[1:-1]) - oset.quotes = "'" - oset.whitespace = "," - oset = [x[1:-1] for x in list(oset)] + def parse_options(options): + """Given options string, convert it into an ordered list of (option, option) tuples + (Why? I don't know--that's what the template uses at the moment) + """ + # parse the set of possible options + oset = shlex.shlex(options[1:-1]) + oset.quotes = "'" + oset.whitespace = "," + oset = [x[1:-1] for x in list(oset)] - # make ordered list with (key, value) same - self.osetdict = [(oset[x], oset[x]) for x in range(len(oset))] - # TODO: allow ordering to be randomized + # make ordered list with (key, value) same + return [(oset[x], oset[x]) for x in range(len(oset))] - def _get_render_context(self): - - context = { - 'id': self.id, - 'value': self.value, - 'status': self.status, - 'msg': self.msg, - 'options': self.osetdict, - 'inline': self.xml.get('inline',''), - } - return context + return super(OptionInput, cls).get_attributes() + [ + Attribute('options', transform=parse_options), + Attribute('inline', '')] registry.register(OptionInput)