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 = '''
+
+'''
+
+_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 '''
-
-
-
-
- {}
- {}
-
-
-
-
-
-
- '''.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
+
+
+
+ {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%}
+
+
+
+
+ {%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%}
+
+
+
+
+
+
+
+ '''
+
+ 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')