diff --git a/CHANGELOG.md b/CHANGELOG.md index 3455701ca7..da263773ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.22.0 - 2018-07-25 +## Added +- Assets files & index customization [#286](https://github.com/plotly/dash/pull/286) +- Raise an error if there is no layout present when the server is running [#294](https://github.com/plotly/dash/pull/294) + ## 0.21.1 - 2018-04-10 ## Added - `aria-*` and `data-*` attributes are now supported in all dash html components. (#40) diff --git a/dash/_utils.py b/dash/_utils.py index edc5c20f52..1c6e5ca51c 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -1,3 +1,11 @@ +def interpolate_str(template, **data): + s = template + for k, v in data.items(): + key = '{%' + k + '%}' + s = s.replace(key, v) + return s + + class AttributeDict(dict): """ Dictionary subclass enabling attribute lookup/assignment of keys/values. diff --git a/dash/dash.py b/dash/dash.py index 891dbeb99a..44fad1efee 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1,11 +1,14 @@ from __future__ import print_function +import os import sys import collections import importlib import json import pkgutil import warnings +import re + from functools import wraps import plotly @@ -19,6 +22,42 @@ from .development.base_component import Component from . import exceptions from ._utils import AttributeDict as _AttributeDict +from ._utils import interpolate_str as _interpolate + +_default_index = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + + {%app_entry%} + + + +''' + +_app_entry = ''' +
+
+ Loading... +
+
+''' + +_re_index_entry = re.compile(r'{%app_entry%}') +_re_index_config = re.compile(r'{%config%}') +_re_index_scripts = re.compile(r'{%scripts%}') + +_re_index_entry_id = re.compile(r'id="react-entry-point"') +_re_index_config_id = re.compile(r'id="_dash-config"') +_re_index_scripts_id = re.compile(r'src=".*dash[-_]renderer.*"') # pylint: disable=too-many-instance-attributes @@ -29,8 +68,13 @@ def __init__( name='__main__', server=None, static_folder='static', + assets_folder=None, + assets_url_path='/assets', + include_assets_files=True, url_base_pathname='/', compress=True, + meta_tags=None, + index_string=_default_index, **kwargs): # pylint-disable: too-many-instance-attributes @@ -42,20 +86,35 @@ def __init__( See https://github.com/plotly/dash/issues/141 for details. ''', DeprecationWarning) - name = name or 'dash' + self._assets_folder = assets_folder or os.path.join( + flask.helpers.get_root_path(name), 'assets' + ) + # allow users to supply their own flask server self.server = server or Flask(name, static_folder=static_folder) + self.server.register_blueprint( + flask.Blueprint('assets', 'assets', + static_folder=self._assets_folder, + static_url_path=assets_url_path)) + self.url_base_pathname = url_base_pathname self.config = _AttributeDict({ 'suppress_callback_exceptions': False, 'routes_pathname_prefix': url_base_pathname, - 'requests_pathname_prefix': url_base_pathname + 'requests_pathname_prefix': url_base_pathname, + 'include_assets_files': include_assets_files, + 'assets_external_path': '', }) # list of dependencies self.callback_map = {} + self._index_string = '' + self.index_string = index_string + self._meta_tags = meta_tags or [] + self._favicon = None + if compress: # gzip Compress(self.server) @@ -149,12 +208,26 @@ def layout(self, value): # pylint: disable=protected-access self.css._update_layout(layout_value) self.scripts._update_layout(layout_value) - self._collect_and_register_resources( - self.scripts.get_all_scripts() - ) - self._collect_and_register_resources( - self.css.get_all_css() + + @property + def index_string(self): + return self._index_string + + @index_string.setter + def index_string(self, value): + checks = ( + (_re_index_entry.search(value), 'app_entry'), + (_re_index_config.search(value), 'config',), + (_re_index_scripts.search(value), 'scripts'), ) + missing = [missing for check, missing in checks if not check] + if missing: + raise Exception( + 'Did you forget to include {} in your index string ?'.format( + ', '.join('{%' + x + '%}' for x in missing) + ) + ) + self._index_string = value def serve_layout(self): layout = self._layout_value() @@ -180,6 +253,7 @@ def serve_routes(self): ) def _collect_and_register_resources(self, resources): + # now needs the app context. # template in the necessary component suite JS bundles # add the version number of the package as a query parameter # for cache busting @@ -217,8 +291,12 @@ def _relative_url_path(relative_package_path='', namespace=''): srcs.append(url) elif 'absolute_path' in resource: raise Exception( - 'Serving files form absolute_path isn\'t supported yet' + 'Serving files from absolute_path isn\'t supported yet' ) + elif 'asset_path' in resource: + static_url = flask.url_for('assets.static', + filename=resource['asset_path']) + srcs.append(static_url) return srcs def _generate_css_dist_html(self): @@ -260,6 +338,20 @@ def _generate_config_html(self): '' ).format(json.dumps(self._config())) + def _generate_meta_html(self): + has_charset = any('charset' in x for x in self._meta_tags) + + tags = [] + if not has_charset: + tags.append('') + for meta in self._meta_tags: + attributes = [] + for k, v in meta.items(): + attributes.append('{}="{}"'.format(k, v)) + tags.append(''.format(' '.join(attributes))) + + return '\n '.join(tags) + # Serve the JS bundles for each package def serve_component_suites(self, package_name, path_in_package_dist): if package_name not in self.registered_paths: @@ -294,28 +386,83 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument scripts = self._generate_scripts_html() css = self._generate_css_dist_html() config = self._generate_config_html() + metas = self._generate_meta_html() title = getattr(self, 'title', 'Dash') - return ''' - - - - - {} - {} - - -
-
- Loading... -
-
- - - - '''.format(title, css, config, scripts) + if self._favicon: + favicon = ''.format( + flask.url_for('assets.static', filename=self._favicon)) + else: + favicon = '' + + index = self.interpolate_index( + metas=metas, title=title, css=css, config=config, + scripts=scripts, app_entry=_app_entry, favicon=favicon) + + checks = ( + (_re_index_entry_id.search(index), '#react-entry-point'), + (_re_index_config_id.search(index), '#_dash-configs'), + (_re_index_scripts_id.search(index), 'dash-renderer'), + ) + missing = [missing for check, missing in checks if not check] + + if missing: + plural = 's' if len(missing) > 1 else '' + raise Exception( + 'Missing element{pl} {ids} in index.'.format( + ids=', '.join(missing), + pl=plural + ) + ) + + return index + + def interpolate_index(self, + metas='', title='', css='', config='', + scripts='', app_entry='', favicon=''): + """ + Called to create the initial HTML string that is loaded on page. + Override this method to provide you own custom HTML. + + :Example: + + class MyDash(dash.Dash): + def interpolate_index(self, **kwargs): + return ''' + + + + My App + + +
My custom header
+ {app_entry} + {config} + {scripts} + + + + '''.format( + app_entry=kwargs.get('app_entry'), + config=kwargs.get('config'), + scripts=kwargs.get('scripts')) + + :param metas: Collected & formatted meta tags. + :param title: The title of the app. + :param css: Collected & formatted css dependencies as tags. + :param config: Configs needed by dash-renderer. + :param scripts: Collected & formatted scripts tags. + :param app_entry: Where the app will render. + :param favicon: A favicon tag if found in assets folder. + :return: The interpolated HTML string for the index. + """ + return _interpolate(self.index_string, + metas=metas, + title=title, + css=css, + config=config, + scripts=scripts, + favicon=favicon, + app_entry=app_entry) def dependencies(self): return flask.jsonify([ @@ -558,6 +705,9 @@ def dispatch(self): return self.callback_map[target_id]['callback'](*args) def _setup_server(self): + if self.config.include_assets_files: + self._walk_assets_directory() + # Make sure `layout` is set before running the server value = getattr(self, 'layout') if value is None: @@ -567,9 +717,45 @@ def _setup_server(self): 'at the time that `run_server` was called. ' 'Make sure to set the `layout` attribute of your application ' 'before running the server.') + self._generate_scripts_html() self._generate_css_dist_html() + def _walk_assets_directory(self): + walk_dir = self._assets_folder + slash_splitter = re.compile(r'[\\/]+') + + def add_resource(p): + res = {'asset_path': p} + if self.config.assets_external_path: + res['external_url'] = '{}{}'.format( + self.config.assets_external_path, path) + return res + + for current, _, files in os.walk(walk_dir): + if current == walk_dir: + base = '' + else: + s = current.replace(walk_dir, '').lstrip('\\').lstrip('/') + splitted = slash_splitter.split(s) + if len(splitted) > 1: + base = '/'.join(slash_splitter.split(s)) + else: + base = splitted[0] + + for f in sorted(files): + if base: + path = '/'.join([base, f]) + else: + path = f + + if f.endswith('js'): + self.scripts.append_script(add_resource(path)) + elif f.endswith('css'): + self.css.append_css(add_resource(path)) + elif f == 'favicon.ico': + self._favicon = path + def run_server(self, port=8050, debug=False, diff --git a/dash/resources.py b/dash/resources.py index c08c0bad3f..70f37a5389 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -21,7 +21,6 @@ def _filter_resources(self, all_resources): filtered_resource = {} if 'namespace' in s: filtered_resource['namespace'] = s['namespace'] - if 'external_url' in s and not self.config.serve_locally: filtered_resource['external_url'] = s['external_url'] elif 'relative_package_path' in s: @@ -30,6 +29,8 @@ def _filter_resources(self, all_resources): ) elif 'absolute_path' in s: filtered_resource['absolute_path'] = s['absolute_path'] + elif 'asset_path' in s: + filtered_resource['asset_path'] = s['asset_path'] elif self.config.serve_locally: warnings.warn( 'A local version of {} is not available'.format( @@ -112,8 +113,7 @@ class config: serve_locally = False -class Scripts: - # pylint: disable=old-style-class +class Scripts: # pylint: disable=old-style-class def __init__(self, layout=None): self._resources = Resources('_js_dist', layout) self._resources.config = self.config diff --git a/dash/version.py b/dash/version.py index 8c306aa668..81edede8b4 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '0.21.1' +__version__ = '0.22.0' diff --git a/tests/assets/load_first.js b/tests/assets/load_first.js new file mode 100644 index 0000000000..b68378509e --- /dev/null +++ b/tests/assets/load_first.js @@ -0,0 +1 @@ +window.tested = ['load_first']; \ No newline at end of file diff --git a/tests/assets/nested_css/nested.css b/tests/assets/nested_css/nested.css new file mode 100644 index 0000000000..24ba3d2fa2 --- /dev/null +++ b/tests/assets/nested_css/nested.css @@ -0,0 +1,3 @@ +#content { + padding: 8px; +} \ No newline at end of file diff --git a/tests/assets/nested_js/load_after.js b/tests/assets/nested_js/load_after.js new file mode 100644 index 0000000000..6f520fdb85 --- /dev/null +++ b/tests/assets/nested_js/load_after.js @@ -0,0 +1 @@ +window.tested.push('load_after'); \ No newline at end of file diff --git a/tests/assets/nested_js/load_after1.js b/tests/assets/nested_js/load_after1.js new file mode 100644 index 0000000000..1629d393d7 --- /dev/null +++ b/tests/assets/nested_js/load_after1.js @@ -0,0 +1 @@ +window.tested.push('load_after1'); diff --git a/tests/assets/nested_js/load_after10.js b/tests/assets/nested_js/load_after10.js new file mode 100644 index 0000000000..fcfdb59ae9 --- /dev/null +++ b/tests/assets/nested_js/load_after10.js @@ -0,0 +1 @@ +window.tested.push('load_after10'); \ No newline at end of file diff --git a/tests/assets/nested_js/load_after11.js b/tests/assets/nested_js/load_after11.js new file mode 100644 index 0000000000..bd11cd28be --- /dev/null +++ b/tests/assets/nested_js/load_after11.js @@ -0,0 +1 @@ +window.tested.push('load_after11'); \ No newline at end of file diff --git a/tests/assets/nested_js/load_after2.js b/tests/assets/nested_js/load_after2.js new file mode 100644 index 0000000000..0b76a55fae --- /dev/null +++ b/tests/assets/nested_js/load_after2.js @@ -0,0 +1 @@ +window.tested.push('load_after2'); \ No newline at end of file diff --git a/tests/assets/nested_js/load_after3.js b/tests/assets/nested_js/load_after3.js new file mode 100644 index 0000000000..d913af94e7 --- /dev/null +++ b/tests/assets/nested_js/load_after3.js @@ -0,0 +1 @@ +window.tested.push('load_after3'); \ No newline at end of file diff --git a/tests/assets/nested_js/load_after4.js b/tests/assets/nested_js/load_after4.js new file mode 100644 index 0000000000..2507e38b00 --- /dev/null +++ b/tests/assets/nested_js/load_after4.js @@ -0,0 +1 @@ +window.tested.push('load_after4'); \ No newline at end of file diff --git a/tests/assets/nested_js/load_last.js b/tests/assets/nested_js/load_last.js new file mode 100644 index 0000000000..285aa60506 --- /dev/null +++ b/tests/assets/nested_js/load_last.js @@ -0,0 +1 @@ +document.getElementById('tested').innerHTML = JSON.stringify(window.tested); diff --git a/tests/assets/reset.css b/tests/assets/reset.css new file mode 100644 index 0000000000..8c521ddd5e --- /dev/null +++ b/tests/assets/reset.css @@ -0,0 +1 @@ +body {margin: 0;} diff --git a/tests/test_integration.py b/tests/test_integration.py index 1bdc298c41..770b9152cb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,3 +1,4 @@ +import json from multiprocessing import Value import datetime import itertools @@ -5,7 +6,10 @@ import dash_html_components as html import dash_core_components as dcc import dash_flow_example + import dash +import time + from dash.dependencies import Input, Output from dash.exceptions import PreventUpdate from .IntegrationTests import IntegrationTests @@ -266,3 +270,168 @@ def display_output(react_value, flow_value): self.startServer(app) self.wait_for_element_by_id('waitfor') self.percy_snapshot(name='flowtype') + + def test_meta_tags(self): + metas = ( + {'name': 'description', 'content': 'my dash app'}, + {'name': 'custom', 'content': 'customized'} + ) + + app = dash.Dash(meta_tags=metas) + + app.layout = html.Div(id='content') + + self.startServer(app) + + meta = self.driver.find_elements_by_tag_name('meta') + + # -1 for the meta charset. + self.assertEqual(len(metas), len(meta) - 1, 'Not enough meta tags') + + for i in range(1, len(meta)): + meta_tag = meta[i] + meta_info = metas[i - 1] + name = meta_tag.get_attribute('name') + content = meta_tag.get_attribute('content') + self.assertEqual(name, meta_info['name']) + self.assertEqual(content, meta_info['content']) + + def test_index_customization(self): + app = dash.Dash() + + app.index_string = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + +
My custom header
+
+ {%app_entry%} + + + + + + ''' + + app.layout = html.Div('Dash app', id='app') + + self.startServer(app) + + time.sleep(0.5) + + header = self.wait_for_element_by_id('custom-header') + footer = self.wait_for_element_by_id('custom-footer') + + self.assertEqual('My custom header', header.text) + self.assertEqual('My custom footer', footer.text) + + add = self.wait_for_element_by_id('add') + + self.assertEqual('Got added', add.text) + + self.percy_snapshot('custom-index') + + def test_assets(self): + app = dash.Dash(assets_folder='tests/assets') + app.index_string = ''' + + + + {%metas%} + {%title%} + {%css%} + + +
+ {%app_entry%} + + + + ''' + + app.layout = html.Div([ + html.Div(id='content'), + dcc.Input(id='test') + ], id='layout') + + self.startServer(app) + + body = self.driver.find_element_by_tag_name('body') + + body_margin = body.value_of_css_property('margin') + self.assertEqual('0px', body_margin) + + content = self.wait_for_element_by_id('content') + content_padding = content.value_of_css_property('padding') + self.assertEqual('8px', content_padding) + + tested = self.wait_for_element_by_id('tested') + tested = json.loads(tested.text) + + order = ('load_first', 'load_after', 'load_after1', + 'load_after10', 'load_after11', 'load_after2', + 'load_after3', 'load_after4', ) + + self.assertEqual(len(order), len(tested)) + + for i in range(len(tested)): + self.assertEqual(order[i], tested[i]) + + self.percy_snapshot('test assets includes') + + def test_invalid_index_string(self): + app = dash.Dash() + + def will_raise(): + app.index_string = ''' + + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + +
My custom header
+
+ + + + ''' + + with self.assertRaises(Exception) as context: + will_raise() + + app.layout = html.Div() + self.startServer(app) + + exc_msg = str(context.exception) + self.assertTrue('{%app_entry%}' in exc_msg) + self.assertTrue('{%config%}' in exc_msg) + self.assertTrue('{%scripts%}' in exc_msg) + time.sleep(0.5) + print('invalid index string')