Skip to content

Assets files & index customizations #286

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Jul 25, 2018
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
53ec2ef
Add support for static_path resources.
T4rk1n Jul 4, 2018
a14709a
Add static directory walk for files to include.
T4rk1n Jul 4, 2018
69b8adc
pylint fixes.
T4rk1n Jul 4, 2018
7cafb47
Add support for meta tags.
T4rk1n Jul 4, 2018
7ebab39
Add support favicon located in static dir.
T4rk1n Jul 4, 2018
fc26544
Fix static walking nested directories.
T4rk1n Jul 5, 2018
d2cce95
Changed the meta tags dict to a list, added meta_tags to dash.__init__.
T4rk1n Jul 6, 2018
1212ee5
Add test for meta tags.
T4rk1n Jul 6, 2018
846c8fc
Fix bad line that was included in rebase.
T4rk1n Jul 10, 2018
4777731
Add support for static external resources.
T4rk1n Jul 10, 2018
23195f0
Rename `static` to `assets` for user static file includes.
T4rk1n Jul 10, 2018
0c96dc7
Add _generate_meta_html to build html meta tags.
T4rk1n Jul 10, 2018
f943471
Add index customization by string interpolations.
T4rk1n Jul 10, 2018
cd0c15c
Re-add support for favicon in interpolate_index.
T4rk1n Jul 10, 2018
0d9151c
Change add_meta_tag args to a dict to support every meta attributes.
T4rk1n Jul 10, 2018
ff1cff3
Add test for index customization.
T4rk1n Jul 10, 2018
eaf91a1
Add test for assets.
T4rk1n Jul 11, 2018
2197e38
Add checks for index, change the format syntax to {%key%}, more tests.
T4rk1n Jul 11, 2018
ea0d2ca
Change interpolate_index params to kwargs.
T4rk1n Jul 17, 2018
4cf9e5c
Remove `add_meta_tag`.
T4rk1n Jul 17, 2018
f6e9922
Block pylint version to 1.9.2
T4rk1n Jul 18, 2018
8cc781b
Put assets Blueprint in ctor, remove related configs.
T4rk1n Jul 23, 2018
0046402
Use `flask.helpers.get_root_path` for assets folder resolution.
T4rk1n Jul 23, 2018
6af0381
Add docstring to interpolate_index.
T4rk1n Jul 24, 2018
2c104a5
Ensure assets files are ordered, add more test files.
T4rk1n Jul 25, 2018
1171f0a
Update changelog and version.
T4rk1n Jul 25, 2018
cd4cfa9
Merge branch 'master' into assets-index-customizations
T4rk1n Jul 25, 2018
495762e
pylint fixes.
T4rk1n Jul 25, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
def interpolate_str(template, **data):
s = template
for k, v in data.items():
key = '{' + k + '}'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably too brittle... in modern Javascript you could easily write something like setConfig({config}) and have someone paste that into index

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think doubling the brackets {{config}} would be enough or maybe add a character before %{config} ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doubling is probably not enough... I would do something like {%dash_config%} or something personally just to make it super clear

s = s.replace(key, v)
return s


class AttributeDict(dict):
"""
Dictionary subclass enabling attribute lookup/assignment of keys/values.
Expand Down
167 changes: 137 additions & 30 deletions dash/dash.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,18 +22,51 @@
from .development.base_component import Component
from . import exceptions
from ._utils import AttributeDict as _AttributeDict
from ._utils import interpolate_str as _interpolate

_default_index = '''
<!DOCTYPE html>
<html>
<head>
{metas}
<title>{title}</title>
{favicon}
{css}
</head>
<body>
{app_entry}
<footer>
{config}
{scripts}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we split out scripts into dash_renderer_scripts and dash_components_scripts? The main reason is that dash_renderer is going to become configurable in when we add in custom JS hooks. So, users will end up needing to do something like:

dash_renderer = DashRenderer({
    request_hook: function(...) {
         ...
    }
})

And so it'd be nice if they could replace this:

<!DOCTYPE html>
<html>
    <head>
        {metas}
        <title>{title}</title>
        {favicon}
        {css}
    </head>
    <body>
        {app_entry}
        <footer>
            {config}
            {dash_renderer}
            {dash_component_scripts}
        </footer>
    </body>
</html>

with something like this:

<!DOCTYPE html>
<html>
    <head>
        {metas}
        <title>{title}</title>
        {favicon}
        {css}
    </head>
    <body>
        {app_entry}
        <footer>
            {config}
            dash_renderer = DashRenderer({
                request_hook: function(...) {
                     ...
                }
            })
            {dash_component_scripts}
        </footer>
    </body>
</html>

Now, I suppose the other way they could do this would be with the interpolated index function, where they would do something like:

class CustomDash(dash):
    def interpolated_index(metas, title, css, config, scripts, _app_entry, favicon):
        filtered_scripts = [
            script for script in scripts if 'dash_renderer' not in script
        ]

        return '''
        <!DOCTYPE html>
        <html>
            <head>
                {metas}
                <title>{title}</title>
                {favicon}
                {css}
            </head>
            <body>
                {app_entry}
                <footer>
                    {config}
                    dash_renderer = DashRenderer({
                        request_hook: function(...) {
                             ...
                        }
                    })
                    {filtered_scripts}
                </footer>
            </body>
        </html>
        '''.format(metas, title, css, config, filtered_scripts, _app_entry, favicon)

Am I understanding that correctly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't too sure about the js hooks so I left them out of this, right now the scripts are all bundled together in _generate_scripts_html that return a string with all the scripts tags, can't iterate over it but it could change.

I thought we could have a config to disable the includes of dash-renderer. Then the user can append a custom renderer to the scripts resources or include it a custom index.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they're customizing the DashRenderer don't they need to have the tags to load the uncustomized one first?

</footer>
</body>
</html>
'''

_app_entry = '''
<div id="react-entry-point">
<div class="_dash-loading">
Loading...
</div>
</div>
'''


# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments
# pylint: disable=too-many-arguments, too-many-locals
class Dash(object):
def __init__(
self,
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):
Copy link
Member

@chriddyp chriddyp Jul 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also create an issue about adding a css_urls and js_script_urls arguments to this init argument as an alternative to our app.css.append_css and app.scripts.append_script:

I actually like this declarative syntax. This seems like a nice pattern that we could use elsewhere, like as a way to replace app.scripts.append_script:

app = dash.Dash(
     js_script_urls=['https://cdn.google.com/google-analytics.js'],
     css_stylesheet_urls=['https://cdn.bootstrap.com/bootstrap.css']
)

I never really liked the app.scripts.append_script syntax and I'd generally prefer for the dash.Dash() object to have as few methods and properties as possible. If everything could just be in the app.config and settable through the dash.Dash() constructor, I'd be very happy.

(from comment #286)


# pylint-disable: too-many-instance-attributes
Expand All @@ -43,19 +79,33 @@ def __init__(
''', DeprecationWarning)

name = name or 'dash'

self.assets_folder = assets_folder or os.path.join(
os.getcwd(), 'assets'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little worried that the use of os.getcwd() could be a little brittle. The name param to the Flask instance is mandatory so that Flask can derive the root for place the relative static_folder. If we use getcwd and Flask is always placing its static folder relative to the user's package/module then there is the possibility for the root of our assets location and the root of Flask's static folder locations to become out of synch (eg with os.chdir()). Does that make any sense?

If this is a real issue I'm wondering if it would be better to tap into however Flask derives the root location from the name param?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I investigated what flask does, it get the root directory from flask.helpers.get_root_path. It first try to get it from sys.modules, if it's not found, it will do os.getcwd as a fallback. That is the reason it takes a required arg and generally you do app = Flask(__name__), this will give the directory of the file that contain the app instance in every case.

But dash do name='__main__' by default, which can result in unexpected behavior from what I've tested. If the app was defined on the root folder, but it uses a script in a sub folder to start the server, it will have the wrong directory. Or if you run the app with the command python -m flask run, it will be /venv/lib/site-packages/flask. I think it also applies to wsgi runned instances, the __main__ file won't be the app file but the wsgi runner.

We could use flask.helpers.get_root_path instead of os.getcwd, but I think we should require the __name__ of the file like flask does to ensure consistency, but it would break compatibility with older version as now we would have a required arg in the dash constructor.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a subsequent PR, maybe we can make name a keyword argument and then include usage in the docs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that's interesting. Changing name to default to __main__ was my change. I didn't test it as thoroughly as that, so missed those issues. So while it was an improvement on the previous default of dash, it's still got issues. Well spotted!

Not sure what you mean @chriddyp? name currently is a keyword argument of Dash.

I don't think we can hope to intelligently infer the correct value of name, as the challenges around that are precisely why Flask makes this param mandatory, as @T4rk1n points out. So perhaps documentation is the best way around those edge cases.

)

# allow users to supply their own flask server
self.server = server or Flask(name, static_folder=static_folder)

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_folder': assets_folder or os.path.join(
os.getcwd(), 'assets'),
'assets_external_path': '',
'assets_url_path': assets_url_path,
})

# list of dependencies
self.callback_map = {}

self.index_string = index_string
self._meta_tags = meta_tags or []
self._favicon = None

if compress:
# gzip
Compress(self.server)
Expand Down Expand Up @@ -149,12 +199,6 @@ 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()
)

def serve_layout(self):
layout = self._layout_value()
Expand All @@ -180,6 +224,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
Expand Down Expand Up @@ -217,8 +262,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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should wire in cache-busting URLs while we're here. It's a really common issue in the community forum (e.g. https://community.plot.ly/t/reloading-css-automatically/11065).

I originally thought that we could just do this with a query string with the last modified timestamp but it seems like that's not recommended (https://css-tricks.com/strategies-for-cache-busting-css/#article-header-id-2). Instead, it seems like we'd need to somehow encode it into the resource name. Do you have a sense of how hard that might be?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I'm being pretty hypocritical here, looks like I did cache busting with the component libraries with query strings:

dash/dash/dash.py

Lines 194 to 199 in 3dfa941

return '{}_dash-component-suites/{}/{}?v={}'.format(
self.config['routes_pathname_prefix'],
namespace,
relative_package_path,
importlib.import_module(namespace).__version__
)

🙄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did cache busting before with webpack and an extension. Was basically a template render that replaced the hash part of a filename with a new one.

In dash, I think it would be kinda hard to do that, but we could have a watcher on the asset folder, copy those assets with a filename including the hash to a temp static folder, keep the hash in a dict with the path as key and serve the file with the hash formatted in. When a file change, copy it to the temp folder with a new hash and put the new hash as the value for the path. Could also tell the browser to reload while we're at it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a good idea. I'm a little worried about introducing a temp folder, I feel like users won't know why it's there or if they should commit it, etc.

Instead of having a watcher, could we just call this function on every page load (while in dev mode) and get the latest timestamp of the file? And then if we're not in dev mode, we could store the timestamps on the first page load?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temp folder would be created with the tempfile module and located in the user temp directory (ie %appdata%/local/temp).

But yea, just checking the timestamps of the files before index and appending that in a query string would be a quick fix.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, sounds like there are a couple of options. So, let's create a new GitHub issue about this and tackle it in a subsequent PR

return srcs

def _generate_css_dist_html(self):
Expand Down Expand Up @@ -260,6 +309,20 @@ def _generate_config_html(self):
'</script>'
).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('<meta charset="UTF-8"/>')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very thoughtful 👍

for meta in self._meta_tags:
attributes = []
for k, v in meta.items():
attributes.append('{}="{}"'.format(k, v))
tags.append('<meta {} />'.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:
Expand Down Expand Up @@ -294,28 +357,27 @@ 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 '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{}</title>
{}
</head>
<body>
<div id="react-entry-point">
<div class="_dash-loading">
Loading...
</div>
</div>
<footer>
{}
{}
</footer>
</body>
</html>
'''.format(title, css, config, scripts)
if self._favicon:
favicon = '<link rel="icon" type="image/x-icon" href="{}">'.format(
flask.url_for('assets.static', filename=self._favicon))
else:
favicon = ''
return self.interpolate_index(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

metas, title, css, config, scripts, _app_entry, favicon)

def interpolate_index(self,
metas, title, css, config,
scripts, app_entry, favicon):
return _interpolate(self.index_string,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's probably not enough error-checking here... Shouldn't we raise a very helpful and clear message if they forget to add {scripts} or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two options I thought of:

  • check in a property setter on the index_string.
    • fast only one check, but no checking on interpolate_index.
    • raise when set.
  • check after interpolate_index the string contains the ids of required elements.
    • check every time the index render.
    • raise only when browsing.

Which would be best ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would actually do both :) That way it fails fast for those using index but it still checks for those using interpolate_index ... better developer experience all around

metas=metas,
title=title,
css=css,
config=config,
scripts=scripts,
favicon=favicon,
app_entry=app_entry)

def dependencies(self):
return flask.jsonify([
Expand Down Expand Up @@ -558,11 +620,56 @@ 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()

self._generate_scripts_html()
self._generate_css_dist_html()

def _walk_assets_directory(self):
walk_dir = self.config.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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use base = os.path.split(walk_dir)[0]?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NVM, I get it now, we have to replace \ with / for URL friendly paths.


for f in files:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.walk calls os.listdir internally and the os.listdir docs state "The [returned] list is in arbitrary order" since the user's OS ultimately decides what ordering this returns. Definitely not desirable as apps will behave differently on different machines.

I had a hunch the test case was just getting lucky so I added more files and got this ordering: ['load_first', 'load_after6', 'load_after', 'load_after1', 'load_after2', 'load_after5', 'load_after11', 'load_after4', 'load_after3', 'load_after10', 'load_ after7']

This can be fixed by just adding

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]
            files.sort()  # ADDED LINE: Sort the files!
            for f in files:
                if base:
                    path = '/'.join([base, f])
                else:
                    path = f

This sorts the files as: ['load_first', 'load_after', 'load_after1', 'load_after10', 'load_after11', 'load_after2', 'load_after3', 'load_after4', 'load_after5', 'load_after6', 'load_ after7']

I personally would prefer if 'load_after10' and 'load_after11' came last, which can be done by changing
files.sort() -> files.sort(key=lambda f: int('0' + ''.join(filter(str.isdigit, f))))

The former is what is expected, but the latter is what a programmer usually wants when sorting files. Either way I like the way everything else looks and am 💃 once the sorting works.

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 add_meta_tag(self, meta):
self._meta_tags.append(meta)

def run_server(self,
port=8050,
debug=False,
**flask_run_options):
bp = flask.Blueprint('assets', 'assets',
static_folder=self.config.assets_folder,
static_url_path=self.config.assets_url_path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to ensure a minimum version of flask for this? i.e. >1.0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was add in 0.7, we have Flask>=0.12.

self.server.register_blueprint(bp)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know much about Flask Blueprints, but what about users who are using a WSGI server like Gunicorn rather than the builtin server. Would this setup mean that they won't get the assets added?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will work, wsgi just take the flask instance as the wsgi app.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I'm confused. run_server won't be invoked when pointing a WSGI server at the app.server Flask instance, so the Blueprint won't be registered, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh you'r right, thanks for bringing it up, I'll move it.

self.server.run(port=port, debug=debug, **flask_run_options)
6 changes: 3 additions & 3 deletions dash/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
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
Expand Down Expand Up @@ -266,3 +268,79 @@ 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 = '''
<!DOCTYPE html>
<html>
<head>
{metas}
<title>{title}</title>
{favicon}
{css}
</head>
<body>
<div id="custom-header">My custom header</div>
<div id="add"></div>
{app_entry}
<footer>
{config}
{scripts}
</footer>
<div id="custom-footer">My custom footer</div>
<script>
// Test the formatting doesn't mess up script tags.
var elem = document.getElementById('add');
if (!elem) {
throw Error('could not find container to add');
}
elem.innerHTML = 'Got added';
</script>
</body>
</html>
'''

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')