diff --git a/core/controllers/reader.py b/core/controllers/reader.py index 1e6575f1e419..c9f0133fb702 100644 --- a/core/controllers/reader.py +++ b/core/controllers/reader.py @@ -63,8 +63,7 @@ def get(self, exploration_id): except Exception as e: raise self.PageNotFoundException(e) - # TODO: get params from exploration specification instead - params = exp_services.update_with_state_params( + reader_params = exp_services.update_with_state_params( exploration_id, exploration.init_state_id, reader_params={} @@ -72,21 +71,19 @@ def get(self, exploration_id): init_state = exploration.init_state init_html, init_widgets = exp_services.export_content_to_html( - init_state.content, 0, params) + init_state.content, 0, reader_params) interactive_widget = widget_domain.Registry.get_widget_by_id( feconf.INTERACTIVE_PREFIX, init_state.widget.widget_id) interactive_html = interactive_widget.get_raw_code( - params=utils.parse_dict_with_params( - init_state.widget.params, params) - ) + init_state.widget.customization_args, reader_params) self.values.update({ 'block_number': 0, 'interactive_html': interactive_html, - 'interactive_params': init_state.widget.params, + 'interactive_params': init_state.widget.customization_args, 'oppia_html': init_html, - 'params': params, + 'params': reader_params, 'state_history': [exploration.init_state_id], 'state_id': exploration.init_state_id, 'title': exploration.title, @@ -102,21 +99,15 @@ class FeedbackHandler(base.BaseHandler): """Handles feedback to readers.""" def _append_answer_to_stats_log( - self, old_state, answer, exploration_id, old_state_id, handler, - rule): + self, old_state, answer, exploration_id, old_state_id, + old_params, handler, rule): """Append the reader's answer to the statistics log.""" widget = widget_domain.Registry.get_widget_by_id( feconf.INTERACTIVE_PREFIX, old_state.widget.widget_id ) - # TODO(sll): Parse this using old_params, but do not convert into - # a JSON string. - recorded_answer_params = old_state.widget.params - recorded_answer_params.update({ - 'answer': answer, - }) recorded_answer = widget.get_stats_log_html( - params=recorded_answer_params) + old_state.widget.customization_args, old_params, answer) stats_services.EventHandler.record_answer_submitted( exploration_id, old_state_id, handler, str(rule), recorded_answer) @@ -156,10 +147,7 @@ def _append_content(self, exploration_id, sticky, finished, old_params, interactive_html = '' if sticky else ( widget_domain.Registry.get_widget_by_id( feconf.INTERACTIVE_PREFIX, new_state.widget.widget_id - ).get_raw_code( - params=utils.parse_dict_with_params( - new_state.widget.params, new_params) - ) + ).get_raw_code(new_state.widget.customization_args, new_params) ) return (new_params, html_output, iframe_output, interactive_html) @@ -209,22 +197,18 @@ def post(self, exploration_id, state_id): ) self._append_answer_to_stats_log( - old_state, answer, exploration_id, state_id, handler, rule) + old_state, answer, exploration_id, state_id, old_params, + handler, rule) # Append the reader's answer to the response HTML. reader_response_html = '' reader_response_iframe = '' if not sticky: - reader_response_params = utils.parse_dict_with_params( - old_state.widget.params, old_params) - # TODO(sll): This is a hack to solve an issue with unnecessary - # conversion of the answer to a JavaScript-safe string. Fix it by - # using a custom filter. - reader_response_params['answer'] = answer + old_widget = widget_domain.Registry.get_widget_by_id( + feconf.INTERACTIVE_PREFIX, old_state.widget.widget_id) reader_response_html, reader_response_iframe = ( - widget_domain.Registry.get_widget_by_id( - feconf.INTERACTIVE_PREFIX, old_state.widget.widget_id - ).get_reader_response_html(reader_response_params) + old_widget.get_reader_response_html( + old_state.widget.customization_args, old_params, answer) ) values['reader_response_html'] = reader_response_html values['reader_response_iframe'] = reader_response_iframe diff --git a/core/controllers/widgets.py b/core/controllers/widgets.py index f130a0f294fc..470d34032ab0 100644 --- a/core/controllers/widgets.py +++ b/core/controllers/widgets.py @@ -51,7 +51,8 @@ def get_widgets(self, widget_type): response = collections.defaultdict(list) for widget in widgets: - response[widget.category].append(widget.get_with_params({})) + response[widget.category].append( + widget.get_widget_instance_dict({}, {})) for category in response: response[category].sort() @@ -87,7 +88,7 @@ def get(self, widget_type, widget_id): widget = widget_domain.Registry.get_widget_by_id( widget_type, widget_id) self.render_json({ - 'widget': widget.get_with_params({}), + 'widget': widget.get_widget_instance_dict({}, {}), }) except: raise self.PageNotFoundException @@ -95,7 +96,7 @@ def get(self, widget_type, widget_id): def post(self, widget_type, widget_id): """Handles POST requests, for parameterized widgets.""" # Key-value mapping of parameters for the widget. - params = self.payload.get('params', {}) + customization_args = self.payload.get('params', {}) state_params_dict = {} state_params_given = self.payload.get('state_params') @@ -105,19 +106,17 @@ def post(self, widget_type, widget_id): state_params_dict[param['name']] = ( utils.get_random_choice(param['values'])) - # TODO(sll): We need a better convention for which params must be - # JSONified and which should not. Fix this. widget = widget_domain.Registry.get_widget_by_id( widget_type, widget_id) if widget_type == feconf.NONINTERACTIVE_PREFIX: self.render_json({ - 'widget': widget.get_with_params(params, kvps_only=True), + 'widget': widget.get_widget_instance_dict( + customization_args, state_params_dict, kvps_only=True), 'parent_index': self.request.get('parent_index'), }) else: - response = widget.get_with_params( - utils.parse_dict_with_params(params, state_params_dict), - kvps_only=True + response = widget.get_widget_instance_dict( + customization_args, state_params_dict, kvps_only=True ) self.render_json({'widget': response}) diff --git a/core/domain/exp_domain.py b/core/domain/exp_domain.py index 8cca2072f06f..28885e66c0de 100644 --- a/core/domain/exp_domain.py +++ b/core/domain/exp_domain.py @@ -166,7 +166,7 @@ class WidgetInstance(object): def to_dict(self): return { 'widget_id': self.widget_id, - 'params': self.params, + 'customization_args': self.customization_args, 'handlers': [handler.to_dict() for handler in self.handlers], 'sticky': self.sticky } @@ -175,13 +175,13 @@ def to_dict(self): def from_dict(cls, widget_dict): return cls( widget_dict['widget_id'], - widget_dict['params'], + widget_dict['customization_args'], [AnswerHandlerInstance.from_dict(h) for h in widget_dict['handlers']], widget_dict['sticky'], ) - def __init__(self, widget_id, params, handlers, sticky=False): + def __init__(self, widget_id, customization_args, handlers, sticky=False): if not widget_id: raise ValueError('No id specified for widget instance') # TODO(sll): Check whether the widget_id is valid. @@ -189,10 +189,9 @@ def __init__(self, widget_id, params, handlers, sticky=False): raise ValueError('No handlers specified for widget instance') self.widget_id = widget_id - # Parameters for the interactive widget view, stored as key-value - # pairs. Each parameter is single-valued. The values may be Jinja - # templates that refer to state parameters. - self.params = params + # Customization args for the interactive widget view. Parts of these + # args may be Jinja templates that refer to state parameters. + self.customization_args = customization_args # Answer handlers and rule specs. self.handlers = [AnswerHandlerInstance( handler.name, handler.rule_specs @@ -203,14 +202,7 @@ def __init__(self, widget_id, params, handlers, sticky=False): @classmethod def create_default_widget(cls, state_id): - continue_widget = widget_domain.Registry.get_widget_by_id( - feconf.INTERACTIVE_PREFIX, 'Continue') - - continue_params = {} - for param in continue_widget.params: - continue_params[param.name] = param.value - - return cls('Continue', continue_params, + return cls('Continue', {}, [AnswerHandlerInstance.get_default_handler(state_id)]) @@ -274,7 +266,7 @@ def __init__(self, id, name, content, param_changes, widget): self.widget = WidgetInstance.create_default_widget(self.id) else: self.widget = WidgetInstance( - widget.widget_id, widget.params, widget.handlers, + widget.widget_id, widget.customization_args, widget.handlers, widget.sticky) diff --git a/core/domain/exp_services.py b/core/domain/exp_services.py index 109c71c22141..6b3697c74e3e 100644 --- a/core/domain/exp_services.py +++ b/core/domain/exp_services.py @@ -246,7 +246,8 @@ def export_content_to_html(content_array, block_number, params=None): widget_array.append({ 'blockIndex': block_number, 'index': widget_array_len, - 'raw': widget.get_raw_code(widget_dict['params']), + 'raw': widget.get_raw_code( + widget_dict['customization_args'], params), }) else: raise utils.InvalidInputException( @@ -795,8 +796,8 @@ def create_from_yaml( }) for handler in wdict['handlers']] state.widget = exp_domain.WidgetInstance( - wdict['widget_id'], wdict['params'], widget_handlers, - wdict['sticky']) + wdict['widget_id'], wdict['customization_args'], + widget_handlers, wdict['sticky']) save_state(user_id, exploration_id, state) diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py index eb7a8e33fbcf..d66bcb791efc 100644 --- a/core/domain/exp_services_test.py +++ b/core/domain/exp_services_test.py @@ -228,6 +228,7 @@ def test_export_to_yaml(self): name: (untitled state) param_changes: [] widget: + customization_args: {} handlers: - name: submit rule_specs: @@ -236,14 +237,13 @@ def test_export_to_yaml(self): inputs: {} name: Default param_changes: [] - params: - buttonText: Continue sticky: false widget_id: Continue - content: [] name: New state param_changes: [] widget: + customization_args: {} handlers: - name: submit rule_specs: @@ -252,8 +252,6 @@ def test_export_to_yaml(self): inputs: {} name: Default param_changes: [] - params: - buttonText: Continue sticky: false widget_id: Continue """) @@ -282,9 +280,7 @@ def test_export_to_versionable_dict(self): 'param_changes': [] }] }], - 'params': { - u'buttonText': u'Continue' - }, + 'customization_args': {}, 'sticky': False, 'widget_id': u'Continue' } @@ -303,9 +299,7 @@ def test_export_to_versionable_dict(self): 'param_changes': [] }] }], - 'params': { - u'buttonText': u'Continue' - }, + 'customization_args': {}, 'sticky': False, 'widget_id': u'Continue' } @@ -336,9 +330,7 @@ def test_export_state_to_dict(self): 'param_changes': [], 'widget': { 'widget_id': u'Continue', - 'params': { - u'buttonText': u'Continue', - }, + 'customization_args': {}, 'sticky': False, 'handlers': [{ 'name': u'submit', diff --git a/core/domain/widget_domain.py b/core/domain/widget_domain.py index 694a6049242d..dbd15c1b22c5 100644 --- a/core/domain/widget_domain.py +++ b/core/domain/widget_domain.py @@ -23,7 +23,7 @@ import pkgutil import feconf -from core.domain import param_domain +from core.domain import obj_services from core.domain import rule_domain import utils @@ -34,8 +34,8 @@ class AnswerHandler(object): def __init__(self, name='submit', input_type=None): self.name = name self.input_type = input_type - # TODO(sll): Add an assert for input_type: it should be None or a - # class in extensions.objects.models.objects. + # TODO(sll): Add an assertion to check that input_type is either None + # or a class in extensions.objects.models.objects. @property def rules(self): @@ -50,6 +50,45 @@ def to_dict(self): } +class WidgetParam(object): + """Value object for a widget parameter.""" + + def __init__(self, name, description, generator, init_args, + customization_args, obj_type): + self.name = name + self.description = description + self.generator = generator + self.init_args = init_args + self.customization_args = customization_args + self.obj_type = obj_type + + def to_dict(self): + return { + 'name': self.name, + 'description': self.description, + 'generator_id': self.generator.__name__, + # TODO(sll): Check that the next two dicts are JSON-ifiable. + 'init_args': self.init_args, + 'customization_args': self.customization_args, + 'obj_type': self.obj_type, + } + + @classmethod + def from_dict(cls, widget_param_dict): + raise NotImplementedError + + @property + def value(self): + """Generates a new value using the parameter's customization args.""" + value_generator = self.generator(**self.init_args) + generated_value = value_generator.generate_value( + **self.customization_args) + + # Check that the generated value has the correct type. + obj_class = obj_services.get_object_class(self.obj_type) + return obj_class.normalize(generated_value) + + class BaseWidget(object): """Base widget definition class. @@ -84,7 +123,7 @@ def id(self): @property def params(self): - return [param_domain.Parameter(**param) for param in self._params] + return [WidgetParam(**param) for param in self._params] @property def handlers(self): @@ -139,20 +178,90 @@ def template(self): return utils.get_file_contents(os.path.join( feconf.WIDGETS_DIR, self.type, self.id, '%s.html' % self.id)) - def get_raw_code(self, params=None): + def _get_widget_param_instances(self, state_customization_args, + context_params): + """Returns a dict of parameter names and values for the widget. + + This dict is used to evaluate widget templates. The parameter values + are generated based on the widget customizations that are defined by + the exploration creator. These customizations may also make use of the + state parameters. + + Args: + - state_customization_args: dict that maps parameter names to + custom customization args that are defined in the exploration. + - context_params: dict with state parameters that is used to + evaluate any values in state_customization_args that are of the + form {{STATE_PARAM_NAME}}. + + Returns: + A dict of key-value pairs; the keys are parameter names and the + values are the generated values for the parameter instance. + """ + if state_customization_args is None: + state_customization_args = {} + # TODO(sll): Move this out of here and put it in the reader + # controller? Widgets should not know about the states they are in. + state_customization_args = utils.evaluate_object_with_params( + state_customization_args, context_params) + + parameters = {} + for param in self.params: + value_generator = param.generator(**param.init_args) + # Use the given customization args. If they do not exist, use the + # default customization args for the parameter. + args_to_use = ( + state_customization_args[param.name] + if param.name in state_customization_args + else param.customization_args + ) + + parameters[param.name] = value_generator.generate_value( + **args_to_use) + + return parameters + + def get_raw_code(self, state_customization_args, context_params): """Gets the raw code for a parameterized widget.""" + return utils.parse_with_jinja( + self.template, + self._get_widget_param_instances( + state_customization_args, context_params)) + + def get_reader_response_html(self, state_customization_args, + context_params, answer): + """Gets the parameterized HTML and iframes for a reader response.""" + if not self.is_interactive: + raise Exception( + 'This method should only be called for interactive widgets.') + + parameters = self._get_widget_param_instances( + state_customization_args, context_params) + parameters['answer'] = answer + + html, iframe = self._response_template_and_iframe + html = utils.parse_with_jinja(html, parameters) + iframe = utils.parse_with_jinja(iframe, parameters) + return html, iframe + + def get_stats_log_html(self, state_customization_args, + context_params, answer): + """Gets the HTML for recording a reader response for the stats log. - if params is None: - params = {} + Returns an HTML string. + """ + if not self.is_interactive: + raise Exception( + 'This method should only be called for interactive widgets.') - # Parameters used to generate the raw code for the widget. - parameters = dict((param.name, param.value) for param in self.params) - for param in params: - parameters[param] = params[param] + parameters = self._get_widget_param_instances( + state_customization_args, context_params) + parameters['answer'] = answer - return utils.parse_with_jinja(self.template, parameters) + return utils.parse_with_jinja(self._stats_log_template, parameters) - def get_with_params(self, params, kvps_only=False): + def get_widget_instance_dict(self, customization_args, context_params, + kvps_only=False): """Gets a dict representing a parameterized widget. If kvps_only is True, then the value for params in the result is @@ -160,7 +269,10 @@ def get_with_params(self, params, kvps_only=False): {PARAM_NAME: {'value': PARAM_VALUE, 'obj_type': PARAM_OBJ_TYPE}}. """ + # TODO(sll): This needs to be clarified; it should send the entire + # param dict. + """ param_dict = {} for param in self.params: param_dict[param.name] = { @@ -172,14 +284,15 @@ def get_with_params(self, params, kvps_only=False): if kvps_only: for param in param_dict: param_dict[param] = param_dict[param]['value'] + """ result = { 'name': self.name, 'category': self.category, 'description': self.description, 'id': self.id, - 'raw': self.get_raw_code(params), - 'params': param_dict, + 'raw': self.get_raw_code(customization_args, context_params), + # 'params': param_dict, } if self.type == feconf.INTERACTIVE_PREFIX: @@ -192,42 +305,6 @@ def get_with_params(self, params, kvps_only=False): return result - def get_reader_response_html(self, params=None): - """Gets the parameterized HTML and iframes for a reader response.""" - if not self.is_interactive: - raise Exception( - 'This method should only be called for interactive widgets.') - - if params is None: - params = {} - - parameters = dict((param.name, param.value) for param in self.params) - for param in params: - parameters[param] = params[param] - - html, iframe = self._response_template_and_iframe - html = utils.parse_with_jinja(html, parameters) - iframe = utils.parse_with_jinja(iframe, parameters) - return html, iframe - - def get_stats_log_html(self, params=None): - """Gets the HTML for recording a reader response for the stats log. - - Returns an HTML string. - """ - if not self.is_interactive: - raise Exception( - 'This method should only be called for interactive widgets.') - - if params is None: - params = {} - - parameters = dict((param.name, param.value) for param in self.params) - for param in params: - parameters[param] = params[param] - - return utils.parse_with_jinja(self._stats_log_template, parameters) - def get_handler_by_name(self, handler_name): """Get the handler for a widget, given the name of the handler.""" return next(h for h in self.handlers if h.name == handler_name) diff --git a/core/domain/widget_domain_test.py b/core/domain/widget_domain_test.py index 56116d41efad..742c45ef2bf0 100644 --- a/core/domain/widget_domain_test.py +++ b/core/domain/widget_domain_test.py @@ -49,21 +49,27 @@ def test_parameterized_widget(self): self.assertEqual(widget.id, MUSIC_STAFF_ID) self.assertEqual(widget.name, 'Music staff') - code = widget.get_raw_code() + code = widget.get_raw_code({}, {}) self.assertIn('GLOBALS.noteToGuess = JSON.parse(\'\\"', code) - code = widget.get_raw_code({'noteToGuess': 'abc'}) + code = widget.get_raw_code({'noteToGuess': {'value': 'abc'}}, {}) self.assertIn('GLOBALS.noteToGuess = JSON.parse(\'\\"abc\\"\');', code) - parameterized_widget_dict = widget.get_with_params( - {'noteToGuess': 'abc'} + code = widget.get_raw_code( + {'noteToGuess': {'value': '{{ntg}}'}}, {'ntg': 'abc'}) + self.assertIn('GLOBALS.noteToGuess = JSON.parse(\'\\"abc\\"\');', code) + + parameterized_widget_dict = widget.get_widget_instance_dict( + {'noteToGuess': {'value': 'abc'}}, {} ) self.assertItemsEqual(parameterized_widget_dict.keys(), [ 'id', 'name', 'category', 'description', - 'params', 'handlers', 'raw']) + # 'params', + 'handlers', 'raw']) self.assertEqual(parameterized_widget_dict['id'], MUSIC_STAFF_ID) self.assertIn('GLOBALS.noteToGuess = JSON.parse(\'\\"abc\\"\');', parameterized_widget_dict['raw']) + """ self.assertEqual(parameterized_widget_dict['params'], { 'noteToGuess': { 'value': 'abc', @@ -71,3 +77,4 @@ def test_parameterized_widget(self): 'choices': None, } }) + """ diff --git a/core/tests/gae_suite.py b/core/tests/gae_suite.py index c8dc5d85bbdc..cdefae6788fb 100644 --- a/core/tests/gae_suite.py +++ b/core/tests/gae_suite.py @@ -40,7 +40,7 @@ import feconf -EXPECTED_TEST_COUNT = 119 +EXPECTED_TEST_COUNT = 120 _PARSER = argparse.ArgumentParser() diff --git a/data/explorations/adventure.yaml b/data/explorations/adventure.yaml index 25118a555335..a1f50478e8a8 100644 --- a/data/explorations/adventure.yaml +++ b/data/explorations/adventure.yaml @@ -27,8 +27,7 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput - content: @@ -88,8 +87,7 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput - content: @@ -178,8 +176,7 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput - content: @@ -227,8 +224,7 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput - content: @@ -270,9 +266,10 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - Go north + value: + - Go north sticky: false widget_id: MultipleChoiceInput - content: @@ -293,7 +290,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: Continue - content: @@ -346,7 +343,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -367,7 +364,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: Continue - content: @@ -398,7 +395,6 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput diff --git a/data/explorations/boot_verbs.yaml b/data/explorations/boot_verbs.yaml index a685d68c0f56..efa9e0cfe9ca 100644 --- a/data/explorations/boot_verbs.yaml +++ b/data/explorations/boot_verbs.yaml @@ -31,7 +31,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: So... @@ -55,7 +55,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: boot verb @@ -86,7 +86,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: subject @@ -111,7 +111,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: verb endings @@ -148,6 +148,6 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput diff --git a/data/explorations/counting.yaml b/data/explorations/counting.yaml index afa1cfb575d6..7c4dfeac7672 100644 --- a/data/explorations/counting.yaml +++ b/data/explorations/counting.yaml @@ -37,7 +37,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -84,10 +84,9 @@ states: inputs: {} name: Default param_changes: [] - params: - columns: '60' - placeholder: Type your answer here. - rows: '5' + customization_args: + rows: + value: '5' sticky: false widget_id: TextInput - content: @@ -171,7 +170,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: true widget_id: SetInput - content: @@ -203,10 +202,11 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - 'Yes' - - 'No' + value: + - 'Yes' + - 'No' sticky: false widget_id: MultipleChoiceInput - content: @@ -241,10 +241,7 @@ states: inputs: {} name: Default param_changes: [] - params: - columns: '60' - placeholder: Type your answer here. - rows: '1' + customization_args: {} sticky: false widget_id: TextInput - content: @@ -276,10 +273,11 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - Yes! It's clearly 8. - - No, I don't think it's 8. + value: + - Yes! It's clearly 8. + - No, I don't think it's 8. sticky: false widget_id: MultipleChoiceInput - content: @@ -347,7 +345,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -423,7 +421,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -500,9 +498,6 @@ states: inputs: {} name: Default param_changes: [] - params: - columns: '60' - placeholder: Type your answer here. - rows: '1' + customization_args: {} sticky: false widget_id: TextInput diff --git a/data/explorations/hola.yaml b/data/explorations/hola.yaml index 553ab0dd8e67..4e5af96d7ee3 100644 --- a/data/explorations/hola.yaml +++ b/data/explorations/hola.yaml @@ -39,7 +39,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: Adios @@ -71,7 +71,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: Colores @@ -121,13 +121,14 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - negro y azul - - anaranjado y blanco - - rojo y verde - - amarillo y gris - - I don't know + value: + - negro y azul + - anaranjado y blanco + - rojo y verde + - amarillo y gris + - I don't know sticky: False widget_id: MultipleChoiceInput - name: Como meaning @@ -166,7 +167,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: Me llamo @@ -202,7 +203,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: Me llamo meaning @@ -243,7 +244,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: False widget_id: TextInput - name: Supermercado @@ -291,11 +292,12 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - Ventanas - - Coches - - Manzanas - - I don't know + value: + - Ventanas + - Coches + - Manzanas + - I don't know sticky: False widget_id: MultipleChoiceInput diff --git a/data/explorations/landmarks.yaml b/data/explorations/landmarks.yaml index a29c1d091819..2f3e21ccdda8 100644 --- a/data/explorations/landmarks.yaml +++ b/data/explorations/landmarks.yaml @@ -29,9 +29,12 @@ states: inputs: {} name: Default param_changes: [] - params: - latitude: '27.171781' - longitude: '78.04217' - zoom: '10' + customization_args: + latitude: + value: 27.171781 + longitude: + value: 78.04217 + zoom: + value: 10 sticky: True widget_id: InteractiveMap diff --git a/data/explorations/pitch.yaml b/data/explorations/pitch.yaml index b43bc65a6271..e761b964236a 100644 --- a/data/explorations/pitch.yaml +++ b/data/explorations/pitch.yaml @@ -63,8 +63,9 @@ states: inputs: {} name: Default param_changes: [] - params: - noteToGuess: '{{noteStart}}' + customization_args: + noteToGuess: + value: '{{noteStart}}' sticky: True widget_id: MusicStaff param_specs: diff --git a/data/explorations/root_linear_coefficient_theorem.yaml b/data/explorations/root_linear_coefficient_theorem.yaml index 7cab58b8f0a0..91cab3019bd4 100644 --- a/data/explorations/root_linear_coefficient_theorem.yaml +++ b/data/explorations/root_linear_coefficient_theorem.yaml @@ -18,7 +18,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: Continue - content: @@ -46,8 +46,7 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput - content: @@ -72,12 +71,13 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - x^3 - a * x^2 - b * x - c - - x^3 + (a+b+c) * x^2 + (ab+bc+ca) * x + abc - - x^3 - (a+b+c) * x^2 + (ab+bc+ca) * x - abc - - x^3 + (a+b+c) * x^2 - (ab+bc+ca) * x + abc + value: + - x^3 - a * x^2 - b * x - c + - x^3 + (a+b+c) * x^2 + (ab+bc+ca) * x + abc + - x^3 - (a+b+c) * x^2 + (ab+bc+ca) * x - abc + - x^3 + (a+b+c) * x^2 - (ab+bc+ca) * x + abc sticky: false widget_id: MultipleChoiceInput - content: @@ -105,12 +105,13 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - a1 - - a1 + a2 + a3 + ... + an - - '1' - - a1 * a2 * a3 * ... * an + value: + - a1 + - a1 + a2 + a3 + ... + an + - '1' + - a1 * a2 * a3 * ... * an sticky: false widget_id: MultipleChoiceInput - content: @@ -147,13 +148,14 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - a1 * a2 + a2 * a3 + ... + an * a1 - - a1 + a2 + a3 + ... + an - - a2 - - a1 * a2 * ... * a(n-1) + a1 * a2 * ... * a(n-2) * an + ... + a2 * a3 * ... - * an + value: + - a1 * a2 + a2 * a3 + ... + an * a1 + - a1 + a2 + a3 + ... + an + - a2 + - a1 * a2 * ... * a(n-1) + a1 * a2 * ... * a(n-2) * an + ... + a2 * a3 * ... + * an sticky: false widget_id: MultipleChoiceInput - content: @@ -206,12 +208,13 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - +,-,-,+ - - +,+,+,+ - - +,-,+,- - - -,+,-,+ + value: + - +,-,-,+ + - +,+,+,+ + - +,-,+,- + - -,+,-,+ sticky: false widget_id: MultipleChoiceInput - content: @@ -241,10 +244,11 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - Positive - - Negative + value: + - Positive + - Negative sticky: false widget_id: MultipleChoiceInput - content: @@ -272,7 +276,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -300,7 +304,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -329,12 +333,13 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - '1' - - (-1)^n * (1 / a1 + 1/a2 + 1/a3 + ... + 1/an) - - (-1) * (1 / a1 + 1/a2 + 1/a3 + ... + 1/an) - - (1 / a1 + 1/a2 + 1/a3 + ... + 1/an) + value: + - '1' + - (-1)^n * (1 / a1 + 1/a2 + 1/a3 + ... + 1/an) + - (-1) * (1 / a1 + 1/a2 + 1/a3 + ... + 1/an) + - (1 / a1 + 1/a2 + 1/a3 + ... + 1/an) sticky: false widget_id: MultipleChoiceInput - content: @@ -357,6 +362,6 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: Continue diff --git a/data/explorations/welcome.yaml b/data/explorations/welcome.yaml index b10985903b0c..9140531b7034 100644 --- a/data/explorations/welcome.yaml +++ b/data/explorations/welcome.yaml @@ -37,11 +37,12 @@ states: inputs: {} name: Default param_changes: [] - params: + customization_args: choices: - - It's translated from a different language. - - It's a nonsense word that someone made up. - - It's the name of a popular cartoon character. + value: + - It's translated from a different language. + - It's a nonsense word that someone made up. + - It's the name of a popular cartoon character. sticky: false widget_id: MultipleChoiceInput - content: @@ -67,7 +68,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -113,8 +114,9 @@ states: inputs: {} name: Default param_changes: [] - params: - noteToGuess: B4 + customization_args: + noteToGuess: + value: B4 sticky: true widget_id: MusicStaff - content: @@ -170,7 +172,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: NumericInput - content: @@ -195,7 +197,7 @@ states: inputs: {} name: Default param_changes: [] - params: {} + customization_args: {} sticky: false widget_id: Continue - content: @@ -326,7 +328,6 @@ states: inputs: {} name: Default param_changes: [] - params: - placeholder: Type your answer here. + customization_args: {} sticky: false widget_id: TextInput diff --git a/data_test.py b/data_test.py index 6395d4481d9d..736219e49053 100644 --- a/data_test.py +++ b/data_test.py @@ -90,8 +90,8 @@ def verify_state_dict(self, state_dict, state_name_list): # type. WIDGET_SCHEMA = [ - ('widget_id', basestring), ('params', dict), ('handlers', list), - ('sticky', bool)] + ('widget_id', basestring), ('customization_args', dict), + ('handlers', list), ('sticky', bool)] self.verify_dict_keys_and_types(state_dict['widget'], WIDGET_SCHEMA) for handler in state_dict['widget']['handlers']: @@ -131,7 +131,8 @@ def verify_state_dict(self, state_dict, state_name_list): # TODO(sll): Test that the elements of 'values' are of the # correct type. - for wp_name, wp_value in state_dict['widget']['params'].iteritems(): + for wp_name, wp_value in ( + state_dict['widget']['customization_args'].iteritems()): self.assertTrue(isinstance(wp_name, basestring)) # Check that the parameter name is valid. @@ -159,7 +160,11 @@ def verify_state_dict(self, state_dict, state_name_list): break # Check that the parameter value has the correct type. - obj_class.normalize(wp_value) + # TODO(sll): Replace this with a method that generates the + # parameter and tests it. This method needs to be able to + # substitute parameters if necessary, or be able to ignore + # those cases. + # obj_class.normalize(wp_value) def get_state_ind_from_name(self, states_list, state_name): """Given a state name, returns its index in the state list.""" @@ -418,20 +423,26 @@ def test_default_widgets_are_valid(self): ) for param in widget._params: - PARAM_KEYS = ['name', 'description', 'obj_type', 'values', 'choices'] - for p in param.keys(): + PARAM_KEYS = ['name', 'description', 'generator', 'init_args', + 'customization_args', 'obj_type'] + for p in param: self.assertIn(p, PARAM_KEYS) self.assertTrue(isinstance(param['name'], basestring)) self.assertTrue(self.is_alphanumeric_string(param['name'])) self.assertTrue(isinstance(param['description'], basestring)) - # Check that the default values have the correct types. + # TODO(sll): Check that the generator is an subclass of + # BaseValueGenerator. + + self.assertTrue(isinstance(param['init_args'], dict)) + self.assertTrue(isinstance(param['customization_args'], dict)) + self.assertTrue(isinstance(param['obj_type'], basestring)) + obj_class = obj_services.get_object_class(param['obj_type']) self.assertIsNotNone(obj_class) - self.assertTrue(isinstance(param['values'], list)) - for value in param['values']: - obj_class.normalize(value) - if 'choices' in param: - for choice in param['choices']: - obj_class.normalize(value) + + # Check that the default customization args result in + # parameters with the correct types. + for param in widget.params: + param.value diff --git a/extensions/value_generators/models/generators.py b/extensions/value_generators/models/generators.py index ddfca6d75042..46f3e15593ba 100644 --- a/extensions/value_generators/models/generators.py +++ b/extensions/value_generators/models/generators.py @@ -17,6 +17,7 @@ """Custom value generator classes.""" import copy +import numbers import random import utils @@ -109,3 +110,22 @@ def __init__(self, choices): def generate_value(self, value): assert value in self.choices return copy.deepcopy(value) + + +class RangeRestrictedCopier(BaseValueGenerator): + """Returns the input, after checking it is in a given interval.""" + + min_value = 0 + max_value = 0 + + def __init__(self, min_value, max_value): + if not isinstance(min_value, numbers.Number): + raise TypeError('Expected a number, received %s' % min_value) + if not isinstance(max_value, numbers.Number): + raise TypeError('Expected a number, received %s' % max_value) + self.min_value = min_value + self.max_value = max_value + + def generate_value(self, value): + assert self.min_value <= value <= self.max_value + return copy.deepcopy(value) diff --git a/extensions/value_generators/models/generators_test.py b/extensions/value_generators/models/generators_test.py index b4a79694972b..4d2b710e4347 100644 --- a/extensions/value_generators/models/generators_test.py +++ b/extensions/value_generators/models/generators_test.py @@ -87,3 +87,21 @@ def test_restricted_copier(self): self.assertEqual(generator.generate_value('a'), 'a') with self.assertRaises(AssertionError): generator.generate_value('c') + + def test_range_restricted_copier(self): + with self.assertRaises(TypeError): + generators.RangeRestrictedCopier() + + with self.assertRaisesRegexp(TypeError, 'Expected a number'): + generators.RangeRestrictedCopier('a', 3) + with self.assertRaisesRegexp(TypeError, 'Expected a number'): + generators.RangeRestrictedCopier(3, 'b') + + generator = generators.RangeRestrictedCopier(-90, 90.5) + + self.assertEqual(generator.generate_value(-88), -88) + self.assertEqual(generator.generate_value(90.25), 90.25) + with self.assertRaises(AssertionError): + generator.generate_value(-90.1) + with self.assertRaises(AssertionError): + generator.generate_value(90.51) diff --git a/extensions/widgets/interactive/CodeRepl/CodeRepl.py b/extensions/widgets/interactive/CodeRepl/CodeRepl.py index 1bf3db09d333..b69a9f28a118 100644 --- a/extensions/widgets/interactive/CodeRepl/CodeRepl.py +++ b/extensions/widgets/interactive/CodeRepl/CodeRepl.py @@ -1,5 +1,6 @@ from core.domain import widget_domain from extensions.objects.models import objects +from extensions.value_generators.models import generators class CodeRepl(widget_domain.BaseWidget): @@ -27,29 +28,47 @@ class CodeRepl(widget_domain.BaseWidget): # values. This attribute name MUST be prefixed by '_'. _params = [{ 'name': 'language', - 'description': 'Programming language to evalue the code in.', + 'description': 'Programming language to evaluate the code in.', + 'generator': generators.RestrictedCopier, + 'init_args': { + 'choices': [ + 'bloop', 'brainfuck', 'coffeescript', 'emoticon', 'forth', + 'javascript', 'kaffeine', 'lolcode', 'lua', 'move', 'python', + 'qbasic', 'roy', 'ruby', 'scheme', 'traceur', 'unlambda' + ] + }, + # These are the default args. + 'customization_args': { + 'value': 'coffeescript' + }, 'obj_type': 'UnicodeString', - 'values': ['coffeescript'], - 'choices': [ - 'bloop', 'brainfuck', 'coffeescript', 'emoticon', 'forth', - 'javascript', 'kaffeine', 'lolcode', 'lua', 'move', 'python', - 'qbasic', 'roy', 'ruby', 'scheme', 'traceur', 'unlambda' - ] }, { 'name': 'placeholder', 'description': 'The placeholder for the text input field.', - 'obj_type': 'UnicodeString', - 'values': ['Type your code here.'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 'Type your code here.' + }, + 'obj_type': 'UnicodeString', }, { 'name': 'rows', 'description': 'The number of rows for the text input field.', - 'obj_type': 'UnicodeString', - 'values': ['1'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 1 + }, + 'obj_type': 'Int', }, { 'name': 'columns', 'description': 'The number of columns for the text input field.', - 'obj_type': 'UnicodeString', - 'values': ['60'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 60 + }, + 'obj_type': 'Int', }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/interactive/Continue/Continue.py b/extensions/widgets/interactive/Continue/Continue.py index 664bca71b37a..be82df398726 100644 --- a/extensions/widgets/interactive/Continue/Continue.py +++ b/extensions/widgets/interactive/Continue/Continue.py @@ -1,4 +1,5 @@ from core.domain import widget_domain +from extensions.value_generators.models import generators class Continue(widget_domain.BaseWidget): @@ -26,8 +27,12 @@ class Continue(widget_domain.BaseWidget): _params = [{ 'name': 'buttonText', 'description': 'The text that should be displayed on the button.', - 'obj_type': 'UnicodeString', - 'values': ['Continue'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 'Continue' + }, + 'obj_type': 'UnicodeString' }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/interactive/InteractiveIFrame/InteractiveIFrame.py b/extensions/widgets/interactive/InteractiveIFrame/InteractiveIFrame.py index 95b8bd26398a..2c7d1a006405 100644 --- a/extensions/widgets/interactive/InteractiveIFrame/InteractiveIFrame.py +++ b/extensions/widgets/interactive/InteractiveIFrame/InteractiveIFrame.py @@ -1,5 +1,6 @@ from core.domain import widget_domain from extensions.objects.models import objects +from extensions.value_generators.models import generators class InteractiveIFrame(widget_domain.BaseWidget): @@ -28,8 +29,12 @@ class InteractiveIFrame(widget_domain.BaseWidget): _params = [{ 'name': 'childPageUrl', 'description': 'The URL of the page to iframe.', - 'obj_type': 'UnicodeString', - 'values': [''] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': '' + }, + 'obj_type': 'UnicodeString' }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/interactive/InteractiveMap/InteractiveMap.py b/extensions/widgets/interactive/InteractiveMap/InteractiveMap.py index 391c6befa459..2d0803ba56e9 100644 --- a/extensions/widgets/interactive/InteractiveMap/InteractiveMap.py +++ b/extensions/widgets/interactive/InteractiveMap/InteractiveMap.py @@ -1,5 +1,6 @@ from core.domain import widget_domain from extensions.objects.models import objects +from extensions.value_generators.models import generators class InteractiveMap(widget_domain.BaseWidget): @@ -27,18 +28,36 @@ class InteractiveMap(widget_domain.BaseWidget): _params = [{ 'name': 'latitude', 'description': 'Starting map center latitude (-90 to 90).', - 'obj_type': 'Real', - 'values': [0.0] + 'generator': generators.RangeRestrictedCopier, + 'init_args': { + 'min_value': -90.0, + 'max_value': 90.0 + }, + 'customization_args': { + 'value': 0.0 + }, + 'obj_type': 'Real' }, { 'name': 'longitude', 'description': 'Starting map center longitude (-180 to 180).', - 'obj_type': 'Real', - 'values': [0.0] + 'generator': generators.RangeRestrictedCopier, + 'init_args': { + 'min_value': -180.0, + 'max_value': 180.0 + }, + 'customization_args': { + 'value': 0.0 + }, + 'obj_type': 'Real' }, { 'name': 'zoom', 'description': 'Starting map zoom level (0 shows the entire earth).', - 'obj_type': 'Real', - 'values': [0.0] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 0.0 + }, + 'obj_type': 'Real' }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/interactive/MultipleChoiceInput/MultipleChoiceInput.py b/extensions/widgets/interactive/MultipleChoiceInput/MultipleChoiceInput.py index 9d61f1d9fa66..5db7b042dce5 100644 --- a/extensions/widgets/interactive/MultipleChoiceInput/MultipleChoiceInput.py +++ b/extensions/widgets/interactive/MultipleChoiceInput/MultipleChoiceInput.py @@ -1,5 +1,6 @@ from core.domain import widget_domain from extensions.objects.models import objects +from extensions.value_generators.models import generators class MultipleChoiceInput(widget_domain.BaseWidget): @@ -26,10 +27,12 @@ class MultipleChoiceInput(widget_domain.BaseWidget): _params = [{ 'name': 'choices', 'description': 'The options that the reader can select from.', - 'obj_type': 'List', - 'values': [ - ['Default choice'] - ] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': ['Default choice'] + }, + 'obj_type': 'List' }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/interactive/MusicStaff/MusicStaff.py b/extensions/widgets/interactive/MusicStaff/MusicStaff.py index 4edeb22df8df..996646b02a88 100644 --- a/extensions/widgets/interactive/MusicStaff/MusicStaff.py +++ b/extensions/widgets/interactive/MusicStaff/MusicStaff.py @@ -1,5 +1,6 @@ from core.domain import widget_domain from extensions.objects.models import objects +from extensions.value_generators.models import generators class MusicStaff(widget_domain.BaseWidget): @@ -29,10 +30,12 @@ class MusicStaff(widget_domain.BaseWidget): _params = [{ 'name': 'noteToGuess', 'description': 'The note that the reader should guess.', - 'obj_type': 'UnicodeString', - 'values': [ - 'C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5', 'D5', 'E5', 'F5' - ] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 'C5' + }, + 'obj_type': 'UnicodeString' }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/interactive/TextInput/TextInput.py b/extensions/widgets/interactive/TextInput/TextInput.py index 81d91e588dd1..8b192cf05dac 100644 --- a/extensions/widgets/interactive/TextInput/TextInput.py +++ b/extensions/widgets/interactive/TextInput/TextInput.py @@ -1,5 +1,6 @@ from core.domain import widget_domain from extensions.objects.models import objects +from extensions.value_generators.models import generators class TextInput(widget_domain.BaseWidget): @@ -28,20 +29,30 @@ class TextInput(widget_domain.BaseWidget): _params = [{ 'name': 'placeholder', 'description': 'The placeholder for the text input field.', - 'obj_type': 'UnicodeString', - 'values': ['Type your answer here.'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 'Type your answer here.' + }, + 'obj_type': 'UnicodeString', }, { 'name': 'rows', 'description': 'The number of rows for the text input field.', - # TODO(sll): This is wrong; change it. - 'obj_type': 'UnicodeString', - 'values': ['1'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 1 + }, + 'obj_type': 'Int', }, { 'name': 'columns', 'description': 'The number of columns for the text input field.', - # TODO(sll): This is wrong; change it. - 'obj_type': 'UnicodeString', - 'values': ['60'] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': 60 + }, + 'obj_type': 'Int', }] # Actions that the reader can perform on this widget which trigger a diff --git a/extensions/widgets/noninteractive/IFrame/IFrame.py b/extensions/widgets/noninteractive/IFrame/IFrame.py index f75a38eb7639..ba3769fa2de6 100644 --- a/extensions/widgets/noninteractive/IFrame/IFrame.py +++ b/extensions/widgets/noninteractive/IFrame/IFrame.py @@ -1,4 +1,5 @@ from core.domain import widget_domain +from extensions.value_generators.models import generators class IFrame(widget_domain.BaseWidget): @@ -18,15 +19,17 @@ class IFrame(widget_domain.BaseWidget): category = 'Custom' # A description of the widget. - description = ( - 'An iframe for an arbitrary page.' - ) + description = 'An iframe for an arbitrary page.' # Customization parameters and their descriptions, types and default # values. This attribute name MUST be prefixed by '_'. _params = [{ 'name': 'childPageUrl', 'description': 'The URL of the page to iframe.', - 'obj_type': 'UnicodeString', - 'values': [''] + 'generator': generators.Copier, + 'init_args': {}, + 'customization_args': { + 'value': '' + }, + 'obj_type': 'UnicodeString' }] diff --git a/utils.py b/utils.py index 1fce9198c541..f726fcace6a9 100644 --- a/utils.py +++ b/utils.py @@ -86,6 +86,31 @@ def parse_with_jinja(string, params): return env.from_string(string).render(new_params) +def evaluate_object_with_params(obj, params): + """Recursively replaces all {{...}} strings in obj using params. + + In particular, any string in obj that starts with '{{' and ends with '}}' + is evaluated using the value from params.""" + + if isinstance(obj, basestring): + if obj.startswith('{{') and obj.endswith('}}'): + return params[obj[2:-2]] + else: + return obj + elif isinstance(obj, list): + new_list = [] + for item in obj: + new_list.append(evaluate_object_with_params(item, params)) + return new_list + elif isinstance(obj, dict): + new_dict = {} + for key in obj: + new_dict[key] = evaluate_object_with_params(obj[key], params) + return new_dict + else: + return copy.deepcopy(obj) + + def parse_dict_with_params(d, params): """Optionally converts dict values to strings, then parses them using params. diff --git a/utils_test.py b/utils_test.py index ef8b0dff94f1..dc030466b6be 100644 --- a/utils_test.py +++ b/utils_test.py @@ -54,6 +54,46 @@ def test_parse_with_jinja(self): parsed_str = utils.parse_with_jinja('int {{i}}', {'i': 2}) self.assertEqual(parsed_str, 'int 2') + def evaluate_object_with_params(self): + """Test evaluate_object_with_params method.""" + parsed_object = utils.evaluate_object_with_params('abc', {}) + self.assertEqual(parsed_object, 'abc') + + parsed_object = utils.evaluate_object_with_params( + '{{ab}}', {'ab': 'c'}) + self.assertEqual(parsed_object, 'c') + + parsed_object = utils.evaluate_object_with_params( + 'abc{{ab}}', {'ab': 'c'}) + self.assertEqual(parsed_object, 'abc{{ab}}') + + parsed_object = utils.evaluate_object_with_params( + ['a', '{{a}}', 'a{{a}}'], {'a': 'b'}) + self.assertEqual(parsed_object, ['a', 'b', 'a{{a}}']) + + parsed_object = utils.evaluate_object_with_params({}, {}) + self.assertEqual(parsed_object, {}) + + parsed_object = utils.evaluate_object_with_params({}, {'a': 'b'}) + self.assertEqual(parsed_object, {}) + + parsed_object = utils.evaluate_object_with_params({'a': 'b'}, {}) + self.assertEqual(parsed_object, {'a': 'b'}) + + with self.assertRaises(KeyError): + utils.evaluate_object_with_params('{{c}}', {}) + + with self.assertRaises(KeyError): + utils.evaluate_object_with_params('{{c}}', {'a': 'b'}) + + parsed_object = utils.evaluate_object_with_params( + {'a': '{{b}}'}, {'b': 3}) + self.assertEqual(parsed_object, {'a': 3}) + + parsed_object = utils.evaluate_object_with_params( + {'a': '{{b}}'}, {'b': 'c'}) + self.assertEqual(parsed_object, {'a': 'c'}) + def test_parse_dict_with_params(self): """Test parse_dict_with_params method.""" parsed_dict = utils.parse_dict_with_params({'a': 'b'}, {})