From 08f62639d4d27b40db6bee3cf179feb231e76dd2 Mon Sep 17 00:00:00 2001 From: Ryan Marren Date: Wed, 4 Apr 2018 09:47:33 -0400 Subject: [PATCH 1/5] data-* and aria-* attribute support, with serialization --- dash/development/base_component.py | 77 +++++++++++++++++----- requirements.txt | 2 +- tests/development/test_base_component.py | 18 +++++ tests/development/test_component_loader.py | 17 +++++ tox.ini | 6 +- 5 files changed, 101 insertions(+), 19 deletions(-) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 2bedbab8b3..d3a46da75c 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -23,7 +23,11 @@ def __init__(self, **kwargs): # pylint: disable=super-init-not-called for k, v in list(kwargs.items()): # pylint: disable=no-member - if k not in self._prop_names: + k_in_propnames = k in self._prop_names + k_in_wildcards = any([k.startswith(w) + for w in + self._valid_wildcard_attributes]) + if not k_in_propnames and not k_in_wildcards: raise TypeError( 'Unexpected keyword argument `{}`'.format(k) + '\nAllowed arguments: {}'.format( @@ -34,10 +38,21 @@ def __init__(self, **kwargs): setattr(self, k, v) def to_plotly_json(self): + # Add normal properties + props = { + p: getattr(self, p) + for p in self._prop_names # pylint: disable=no-member + if hasattr(self, p) + } + # Add the wildcard properties data-* and aria-* + props.update({ + k: getattr(self, k) + for k in self.__dict__ + if any(k.startswith(w) for w in + self._valid_wildcard_attributes) # pylint:disable=no-member + }) as_json = { - 'props': {p: getattr(self, p) - for p in self._prop_names # pylint: disable=no-member - if hasattr(self, p)}, + 'props': props, 'type': self._type, # pylint: disable=no-member 'namespace': self._namespace # pylint: disable=no-member } @@ -225,6 +240,7 @@ def __init__(self, {default_argtext}): self._prop_names = {list_of_valid_keys} self._type = '{typename}' self._namespace = '{namespace}' + self._valid_wildcard_attributes={list_of_valid_wildcard_attr_prefixes} self.available_events = {events} self.available_properties = {list_of_valid_keys} @@ -236,15 +252,23 @@ def __init__(self, {default_argtext}): super({typename}, self).__init__({argtext}) def __repr__(self): - if(any(getattr(self, c, None) is not None for c in self._prop_names - if c is not self._prop_names[0])): - - return ( - '{typename}(' + - ', '.join([c+'='+repr(getattr(self, c, None)) - for c in self._prop_names - if getattr(self, c, None) is not None])+')') - + if(any(getattr(self, c, None) is not None + for c in self._prop_names + if c is not self._prop_names[0]) + or any(getattr(self, c, None) is not None + for c in self.__dict__.keys() + if any(c.startswith(wc_attr) + for wc_attr in self._valid_wildcard_attributes))): + props_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self._prop_names + if getattr(self, c, None) is not None]) + wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self.__dict__.keys() + if any([c.startswith(wc_attr) + for wc_attr in + self._valid_wildcard_attributes])]) + return ('{typename}(' + props_string + + (', ' + wilds_string if wilds_string != '' else '') + ')') else: return ( '{typename}(' + @@ -253,6 +277,8 @@ def __repr__(self): filtered_props = reorder_props(filter_props(props)) # pylint: disable=unused-variable + list_of_valid_wildcard_attr_prefixes = repr(parse_wildcards(props)) + # pylint: disable=unused-variable list_of_valid_keys = repr(list(filtered_props.keys())) # pylint: disable=unused-variable docstring = create_docstring( @@ -273,11 +299,9 @@ def __repr__(self): required_args = required_props(props) - d = c.format(**locals()) - scope = {'Component': Component} # pylint: disable=exec-used - exec(d, scope) + exec(c.format(**locals()), scope) result = scope[typename] return result @@ -366,6 +390,27 @@ def parse_events(props): return events +def parse_wildcards(props): + """ + Pull out the wildcard attributes from the Component props + + Parameters + ---------- + props: dict + Dictionary with {propName: propMetadata} structure + + Returns + ------- + list + List of Dash valid wildcard prefixes + """ + list_of_valid_wildcard_attr_prefixes = [] + for wildcard_attr in ["data-*", "aria-*"]: + if wildcard_attr in props.keys(): + list_of_valid_wildcard_attr_prefixes.append(wildcard_attr[:-1]) + return list_of_valid_wildcard_attr_prefixes + + def reorder_props(props): """ If "children" is in props, then move it to the diff --git a/requirements.txt b/requirements.txt index b515ff0c28..6bf1393757 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ appnope==0.1.0 backports.shutil-get-terminal-size==1.0.0 click==6.7 dash-core-components==0.3.3 -dash-html-components==0.4.0 +# dash-html-components==0.4.0 dash-renderer==0.2.9 dash.ly==0.14.0 decorator==4.0.11 diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index 73e459fde6..23986973b8 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -17,6 +17,7 @@ Component._prop_names = ('id', 'a', 'children', 'style', ) Component._type = 'TestComponent' Component._namespace = 'test_namespace' +Component._valid_wildcard_attributes = ['data-', 'aria-'] def nested_tree(): @@ -411,6 +412,23 @@ def to_dict(id, children): ) """ + def test_to_plotly_json_with_wildcards(self): + c = Component(id='a', **{'aria-expanded': 'true', + 'data-toggle': 'toggled'}) + c._prop_names = ('id',) + c._type = 'MyComponent' + c._namespace = 'basic' + self.assertEqual( + c.to_plotly_json(), + {'namespace': 'basic', + 'props': { + 'aria-expanded': 'true', + 'data-toggle': 'toggled', + 'id': 'a', + }, + 'type': 'MyComponent'} + ) + def test_len(self): self.assertEqual(len(Component()), 0) self.assertEqual(len(Component(children='Hello World')), 1) diff --git a/tests/development/test_component_loader.py b/tests/development/test_component_loader.py index b0a00a455f..3748705a90 100644 --- a/tests/development/test_component_loader.py +++ b/tests/development/test_component_loader.py @@ -28,6 +28,20 @@ "description": "Children", "required": false }, + "data-*": { + "type": { + "name": "string" + }, + "description": "Wildcard data", + "required": false + }, + "aria-*": { + "type": { + "name": "string" + }, + "description": "Wildcard aria", + "required": false + }, "bar": { "type": { "name": "custom" @@ -113,6 +127,9 @@ def test_loadcomponents(self): 'foo': 'Hello World', 'bar': 'Lah Lah', 'baz': 'Lemons', + 'data-foo': 'Blah', + 'aria-bar': 'Seven', + 'baz': 'Lemons', 'children': 'Child' } AKwargs = { diff --git a/tox.ini b/tox.ini index 53adc9dd7e..e3e73acfe7 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,9 @@ passenv = * basepython={env:TOX_PYTHON_27} commands = python --version + python -m unittest tests.development.test_base_component + python -m unittest tests.development.test_component_loader python -m unittest tests.test_integration - python -m unittest tests.test_react python -m unittest tests.test_resources flake8 dash setup.py @@ -20,8 +21,9 @@ commands = basepython={env:TOX_PYTHON_36} commands = python --version + python -m unittest tests.development.test_base_component + python -m unittest tests.development.test_component_loader python -m unittest tests.test_integration - python -m unittest tests.test_react python -m unittest tests.test_resources flake8 dash setup.py pylint dash setup.py From 8f2d3c2849a70d4afe4740abab4803ce6a40df08 Mon Sep 17 00:00:00 2001 From: Ryan Marren Date: Wed, 4 Apr 2018 14:47:09 -0400 Subject: [PATCH 2/5] edit callback validator for * attrs and a integration test --- dash/dash.py | 4 +- dash/development/base_component.py | 3 +- tests/development/test_base_component.py | 4 +- tests/test_integration.py | 112 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 6a358bd2ad..143d1e802b 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -408,7 +408,9 @@ def _validate_callback(self, output, inputs, state, events): if (hasattr(arg, 'component_property') and arg.component_property not in - component.available_properties): + component.available_properties and not + any(arg.component_property.startswith(w) for w in + component.available_wildcard_properties)): raise exceptions.NonExistantPropException(''' Attempting to assign a callback with the property "{}" but the component diff --git a/dash/development/base_component.py b/dash/development/base_component.py index d3a46da75c..13a610f6c5 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -240,9 +240,10 @@ def __init__(self, {default_argtext}): self._prop_names = {list_of_valid_keys} self._type = '{typename}' self._namespace = '{namespace}' - self._valid_wildcard_attributes={list_of_valid_wildcard_attr_prefixes} + self._valid_wildcard_attributes = {list_of_valid_wildcard_attr_prefixes} self.available_events = {events} self.available_properties = {list_of_valid_keys} + self.available_wildcard_properties = {list_of_valid_wildcard_attr_prefixes} for k in {required_args}: if k not in kwargs: diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index 23986973b8..e369284a48 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -414,7 +414,8 @@ def to_dict(id, children): def test_to_plotly_json_with_wildcards(self): c = Component(id='a', **{'aria-expanded': 'true', - 'data-toggle': 'toggled'}) + 'data-toggle': 'toggled', + 'data-none': None}) c._prop_names = ('id',) c._type = 'MyComponent' c._namespace = 'basic' @@ -424,6 +425,7 @@ def test_to_plotly_json_with_wildcards(self): 'props': { 'aria-expanded': 'true', 'data-toggle': 'toggled', + 'data-none': None, 'id': 'a', }, 'type': 'MyComponent'} diff --git a/tests/test_integration.py b/tests/test_integration.py index 034d83c65b..b9b5a41898 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,4 +1,7 @@ from multiprocessing import Value +import datetime +import itertools +import re import dash_html_components as html import dash_core_components as dcc import dash_flow_example @@ -67,6 +70,60 @@ def update_output(value): assert_clean_console(self) + def test_wildcard_callback(self): + app = dash.Dash(__name__) + app.layout = html.Div([ + dcc.Input( + id='input', + value='initial value' + ), + html.Div( + html.Div([ + 1.5, + None, + 'string', + html.Div(id='output-1', **{'data-cb': 'initial value', + 'aria-cb': 'initial value'}) + ]) + ) + ]) + + input_call_count = Value('i', 0) + + @app.callback(Output('output-1', 'data-cb'), [Input('input', 'value')]) + def update_data(value): + input_call_count.value = input_call_count.value + 1 + return value + + @app.callback(Output('output-1', 'children'), + [Input('output-1', 'data-cb')]) + def update_text(data): + return data + + self.startServer(app) + output1 = self.wait_for_element_by_id('output-1') + wait_for(lambda: output1.text == 'initial value') + self.percy_snapshot(name='wildcard-callback-1') + + input1 = self.wait_for_element_by_id('input') + input1.clear() + + input1.send_keys('hello world') + + output1 = lambda: self.wait_for_element_by_id('output-1') + wait_for(lambda: output1().text == 'hello world') + self.percy_snapshot(name='wildcard-callback-2') + + self.assertEqual( + input_call_count.value, + # an initial call + 1 + + # one for each hello world character + len('hello world') + ) + + assert_clean_console(self) + def test_aborted_callback(self): """Raising PreventUpdate prevents update and triggering dependencies""" @@ -116,6 +173,61 @@ def callback2(value): self.percy_snapshot(name='aborted') + def test_wildcard_data_attributes(self): + app = dash.Dash() + app.layout = html.Div([ + html.Div( + id="inner-element", + **{ + 'data-string': 'multiple words', + 'data-number': 512, + 'data-none': None, + 'data-date': datetime.datetime(2012, 1, 10), + 'aria-progress': 5 + } + ) + ], id='data-element') + + self.startServer(app) + + div = self.wait_for_element_by_id('data-element') + + # React wraps text and numbers with e.g. + # Remove those + comment_regex = '' + + # Somehow the html attributes are unordered. + # Try different combinations (they're all valid html) + permutations = itertools.permutations([ + 'id="inner-element"', + 'data-string="multiple words"', + 'data-number="512"', + 'data-date="2012-01-10"', + 'aria-progress="5"' + ], 5) + passed = False + for i, permutation in enumerate(permutations): + actual_cleaned = re.sub(comment_regex, '', + div.get_attribute('innerHTML')) + expected_cleaned = re.sub( + comment_regex, + '', + "
" + .replace('PERMUTE', ' '.join(list(permutation))) + ) + passed = passed or (actual_cleaned == expected_cleaned) + if passed: + break + if not passed: + raise Exception( + 'HTML does not match\nActual:\n{}\n\nExpected:\n{}'.format( + actual_cleaned, + expected_cleaned + ) + ) + + assert_clean_console(self) + def test_flow_component(self): app = dash.Dash() From 54c9ef507dec06610cc5081b82641f3a6984cef4 Mon Sep 17 00:00:00 2001 From: Ryan Marren Date: Wed, 4 Apr 2018 15:28:53 -0400 Subject: [PATCH 3/5] Tests for data-* aria-* docgen, repr --- tests/development/metadata_test.json | 14 ++++++++++++++ tests/development/test_base_component.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/tests/development/metadata_test.json b/tests/development/metadata_test.json index d7447995b3..a815b053ea 100644 --- a/tests/development/metadata_test.json +++ b/tests/development/metadata_test.json @@ -207,6 +207,20 @@ "required": false, "description": "" }, + "data-*": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "aria-*": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, "id": { "type": { "name": "string" diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index e369284a48..c44cb5efb0 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -600,6 +600,14 @@ def test_repr_nested_arguments(self): "Table(Table(children=Table(id='1'), id='2'))" ) + def test_repr_with_wildcards(self): + c = self.ComponentClass(id='1', **{"data-one": "one", + "aria-two": "two"}) + self.assertEqual( + repr(c), + "Table(id='1', data-one='one', aria-two='two')" + ) + def test_docstring(self): assert_docstring(self.assertEqual, self.ComponentClass.__doc__) @@ -694,6 +702,10 @@ def setUp(self): ['customArrayProp', 'list'], + ['data-*', 'string'], + + ['aria-*', 'string'], + ['id', 'string'], ['dashEvents', "a value equal to: 'restyle', 'relayout', 'click'"] @@ -769,6 +781,8 @@ def assert_docstring(assertEqual, docstring): "- customProp (optional)", "- customArrayProp (list; optional)", + '- data-* (string; optional)', + '- aria-* (string; optional)', '- id (string; optional)', '', "Available events: 'restyle', 'relayout', 'click'", From ec666eb64d395d1a434749a375b7715a19ccbb1e Mon Sep 17 00:00:00 2001 From: Ryan Marren Date: Tue, 10 Apr 2018 16:21:48 -0400 Subject: [PATCH 4/5] fixed linting issues and date-time issue with testing --- dash/development/base_component.py | 6 ++++-- dev-requirements.txt | 2 +- requirements.txt | 2 +- tests/development/test_base_component.py | 10 ++++++---- tests/test_integration.py | 7 +++++-- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 13a610f6c5..d647df2411 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -240,10 +240,12 @@ def __init__(self, {default_argtext}): self._prop_names = {list_of_valid_keys} self._type = '{typename}' self._namespace = '{namespace}' - self._valid_wildcard_attributes = {list_of_valid_wildcard_attr_prefixes} + self._valid_wildcard_attributes =\ + {list_of_valid_wildcard_attr_prefixes} self.available_events = {events} self.available_properties = {list_of_valid_keys} - self.available_wildcard_properties = {list_of_valid_wildcard_attr_prefixes} + self.available_wildcard_properties =\ + {list_of_valid_wildcard_attr_prefixes} for k in {required_args}: if k not in kwargs: diff --git a/dev-requirements.txt b/dev-requirements.txt index f78b7c60e4..282b362edc 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ dash_core_components>=0.4.0 -dash_html_components>=0.5.0 +dash_html_components>=0.11.0rc1 dash_flow_example==0.0.3 dash_renderer percy diff --git a/requirements.txt b/requirements.txt index 6bf1393757..4eb352abdc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ appnope==0.1.0 backports.shutil-get-terminal-size==1.0.0 click==6.7 dash-core-components==0.3.3 -# dash-html-components==0.4.0 +dash-html-components==0.11.0rc1 dash-renderer==0.2.9 dash.ly==0.14.0 decorator==4.0.11 diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index c44cb5efb0..9b4b3b609b 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -603,10 +603,12 @@ def test_repr_nested_arguments(self): def test_repr_with_wildcards(self): c = self.ComponentClass(id='1', **{"data-one": "one", "aria-two": "two"}) - self.assertEqual( - repr(c), - "Table(id='1', data-one='one', aria-two='two')" - ) + data_first = "Table(id='1', data-one='one', aria-two='two')" + aria_first = "Table(id='1', aria-two='two', data-one='one')" + repr_string = repr(c) + if not (repr_string == data_first or repr_string == aria_first): + raise Exception("%s\nDoes not equal\n%s\nor\n%s" % + (repr_string, data_first, aria_first)) def test_docstring(self): assert_docstring(self.assertEqual, self.ComponentClass.__doc__) diff --git a/tests/test_integration.py b/tests/test_integration.py index b9b5a41898..1bdc298c41 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -175,6 +175,9 @@ def callback2(value): def test_wildcard_data_attributes(self): app = dash.Dash() + test_time = datetime.datetime(2012, 1, 10, 2, 3) + test_date = datetime.date(test_time.year, test_time.month, + test_time.day) app.layout = html.Div([ html.Div( id="inner-element", @@ -182,7 +185,7 @@ def test_wildcard_data_attributes(self): 'data-string': 'multiple words', 'data-number': 512, 'data-none': None, - 'data-date': datetime.datetime(2012, 1, 10), + 'data-date': test_date, 'aria-progress': 5 } ) @@ -202,7 +205,7 @@ def test_wildcard_data_attributes(self): 'id="inner-element"', 'data-string="multiple words"', 'data-number="512"', - 'data-date="2012-01-10"', + 'data-date="%s"' % (test_date), 'aria-progress="5"' ], 5) passed = False From a4bb57816705d851146af4867447cadd2eb035f2 Mon Sep 17 00:00:00 2001 From: Ryan Marren Date: Tue, 10 Apr 2018 16:34:58 -0400 Subject: [PATCH 5/5] updated changelog.md and version.py --- CHANGELOG.md | 5 +++++ dash/version.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f309c0dc9..3455701ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.21.1 - 2018-04-10 +## Added +- `aria-*` and `data-*` attributes are now supported in all dash html components. (#40) +- These new keywords can be added using a dictionary expansion, e.g. `html.Div(id="my-div", **{"data-toggle": "toggled", "aria-toggled": "true"})` + ## 0.21.0 - 2018-02-21 ## Added - #207 Dash now supports React components that use [Flow](https://flow.org/en/docs/react/). diff --git a/dash/version.py b/dash/version.py index e453371221..8c306aa668 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '0.21.0' +__version__ = '0.21.1'