diff --git a/.pylintrc37 b/.pylintrc37 index 8ff5bee0db..57c45836cd 100644 --- a/.pylintrc37 +++ b/.pylintrc37 @@ -146,7 +146,8 @@ disable=invalid-name, no-else-return, useless-object-inheritance, possibly-unused-variable, - too-many-lines + too-many-lines, + too-many-statements # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/CHANGELOG.md b/CHANGELOG.md index 5936f46e20..1e82d00ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.30.0 - 2018-11-14 +## Added +- Hot reload from the browser [#362](https://github.com/plotly/dash/pull/362) +- Silence routes logging with `dev_tools_silence_routes_logging`. + ## 0.29.0 - 2018-11-06 ## Added - Added component namespaces registry, collect the resources needed by component library when they are imported instead of crawling the layout. [#444](https://github.com/plotly/dash/pull/444) diff --git a/dash/_configs.py b/dash/_configs.py index e5087bd534..a2aa35df25 100644 --- a/dash/_configs.py +++ b/dash/_configs.py @@ -22,7 +22,12 @@ def env_configs(): 'DASH_COMPONENTS_CACHE_MAX_AGE', 'DASH_INCLUDE_ASSETS_FILES', 'DASH_SERVE_DEV_BUNDLES', - 'DASH_DEBUG' + 'DASH_DEBUG', + 'DASH_HOT_RELOAD', + 'DASH_HOT_RELOAD_INTERVAL', + 'DASH_HOT_RELOAD_WATCH_INTERVAL', + 'DASH_HOT_RELOAD_MAX_RETRY', + 'DASH_SILENCE_ROUTES_LOGGING' )}) diff --git a/dash/_utils.py b/dash/_utils.py index 17dc247ebe..811da08ebe 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -1,3 +1,6 @@ +import uuid + + def interpolate_str(template, **data): s = template for k, v in data.items(): @@ -20,12 +23,15 @@ def format_tag(tag_name, attributes, inner='', closed=False, opened=False): '{}="{}"'.format(k, v) for k, v in attributes.items()])) +def generate_hash(): + return str(uuid.uuid4().hex).strip('-') + + def get_asset_path( requests_pathname, routes_pathname, asset_path, asset_url_path): - i = requests_pathname.rfind(routes_pathname) req = requests_pathname[:i] diff --git a/dash/_watch.py b/dash/_watch.py new file mode 100644 index 0000000000..34c523478c --- /dev/null +++ b/dash/_watch.py @@ -0,0 +1,36 @@ +import collections +import os +import re +import time + + +def watch(folders, on_change, pattern=None, sleep_time=0.1): + pattern = re.compile(pattern) if pattern else None + watched = collections.defaultdict(lambda: -1) + + def walk(): + walked = [] + for folder in folders: + for current, _, files, in os.walk(folder): + for f in files: + if pattern and not pattern.search(f): + continue + path = os.path.join(current, f) + + info = os.stat(path) + new_time = info.st_mtime + + if new_time > watched[path] > 0: + on_change(path, new_time, False) + + watched[path] = new_time + walked.append(path) + + # Look for deleted files + for w in [x for x in watched.keys() if x not in walked]: + del watched[w] + on_change(w, -1, True) + + while True: + walk() + time.sleep(sleep_time) diff --git a/dash/dash.py b/dash/dash.py index 8c3c7d4221..3beb789871 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1,13 +1,17 @@ from __future__ import print_function +import itertools import os +import random import sys import collections import importlib import json import pkgutil +import threading import warnings import re +import logging from functools import wraps @@ -24,6 +28,8 @@ from ._utils import AttributeDict as _AttributeDict from ._utils import interpolate_str as _interpolate from ._utils import format_tag as _format_tag +from ._utils import generate_hash as _generate_hash +from . import _watch from ._utils import get_asset_path as _get_asset_path from . import _configs @@ -202,6 +208,10 @@ def _handle_error(error): self.config['routes_pathname_prefix'], self.index) + self._add_url( + '{}_reload-hash'.format(self.config['routes_pathname_prefix']), + self.serve_reload_hash) + # catch-all for front-end routes, used by dcc.Location self._add_url( '{}'.format(self.config['routes_pathname_prefix']), @@ -216,13 +226,29 @@ def _handle_error(error): self._layout = None self._cached_layout = None self._dev_tools = _AttributeDict({ - 'serve_dev_bundles': False + 'serve_dev_bundles': False, + 'hot_reload': False, + 'hot_reload_interval': 3000, + 'hot_reload_watch_interval': 0.5, + 'hot_reload_max_retry': 8 }) # add a handler for components suites errors to return 404 self.server.errorhandler(exceptions.InvalidResourceError)( self._invalid_resources_handler) + self._assets_files = [] + + # hot reload + self._reload_hash = None + self._hard_reload = False + self._lock = threading.RLock() + self._watch_thread = None + self._changed_assets = [] + + self.logger = logging.getLogger(name) + self.logger.addHandler(logging.StreamHandler(stream=sys.stdout)) + def _add_url(self, name, view_func, methods=('GET',)): self.server.add_url_rule( name, @@ -293,10 +319,31 @@ def serve_layout(self): ) def _config(self): - return { + config = { 'url_base_pathname': self.url_base_pathname, 'requests_pathname_prefix': self.config['requests_pathname_prefix'] } + if self._dev_tools.hot_reload: + config['hot_reload'] = { + 'interval': self._dev_tools.hot_reload_interval, + 'max_retry': self._dev_tools.hot_reload_max_retry + } + return config + + def serve_reload_hash(self): + hard = self._hard_reload + changed = self._changed_assets + self._lock.acquire() + self._hard_reload = False + self._changed_assets = [] + self._lock.release() + + return flask.jsonify({ + 'reloadHash': self._reload_hash, + 'hard': hard, + 'packages': list(self.registered_paths.keys()), + 'files': list(changed) + }) def serve_routes(self): return flask.Response( @@ -945,19 +992,20 @@ def _setup_server(self): self._generate_scripts_html() self._generate_css_dist_html() + def _add_assets_resource(self, url_path, file_path): + res = {'asset_path': url_path, 'filepath': file_path} + if self.config.assets_external_path: + res['external_url'] = '{}{}'.format( + self.config.assets_external_path, url_path) + self._assets_files.append(file_path) + return res + def _walk_assets_directory(self): walk_dir = self._assets_folder slash_splitter = re.compile(r'[\\/]+') ignore_filter = re.compile(self.assets_ignore) \ if self.assets_ignore else None - def add_resource(p, filepath): - res = {'asset_path': p, 'filepath': filepath} - 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 = '' @@ -982,9 +1030,9 @@ def add_resource(p, filepath): if f.endswith('js'): self.scripts.append_script( - add_resource(path, full)) + self._add_assets_resource(path, full)) elif f.endswith('css'): - self.css.append_css(add_resource(path, full)) + self.css.append_css(self._add_assets_resource(path, full)) elif f == 'favicon.ico': self._favicon = path @@ -1012,17 +1060,57 @@ def get_asset_url(self, path): def enable_dev_tools(self, debug=False, - dev_tools_serve_dev_bundles=None): + dev_tools_serve_dev_bundles=None, + dev_tools_hot_reload=None, + dev_tools_hot_reload_interval=None, + dev_tools_hot_reload_watch_interval=None, + dev_tools_hot_reload_max_retry=None, + dev_tools_silence_routes_logging=None): """ Activate the dev tools, called by `run_server`. If your application is served by wsgi and you want to activate the dev tools, you can call this method out of `__main__`. - :param debug: If True, then activate all the tools unless specified. + If an argument is not provided, it can be set with environment + variables. + + Available dev_tools environment variables: + + - DASH_DEBUG + - DASH_SERVE_DEV_BUNDLES + - DASH_HOT_RELOAD + - DASH_HOT_RELOAD_INTERVAL + - DASH_HOT_RELOAD_WATCH_INTERVAL + - DASH_HOT_RELOAD_MAX_RETRY + - DASH_SILENCE_ROUTES_LOGGING + + :param debug: If True, then activate all the tools unless specifically + disabled by the arguments or by environ variables. Available as + `DASH_DEBUG` environment variable. :type debug: bool - :param dev_tools_serve_dev_bundles: Serve the dev bundles. + :param dev_tools_serve_dev_bundles: Serve the dev bundles. Available + as `DASH_SERVE_DEV_BUNDLES` environment variable. :type dev_tools_serve_dev_bundles: bool - :return: + :param dev_tools_hot_reload: Activate the hot reloading. Available as + `DASH_HOT_RELOAD` environment variable. + :type dev_tools_hot_reload: bool + :param dev_tools_hot_reload_interval: Interval at which the client will + request the reload hash. Available as `DASH_HOT_RELOAD_INTERVAL` + environment variable. + :type dev_tools_hot_reload_interval: int + :param dev_tools_hot_reload_watch_interval: Interval at which the + assets folder are walked for changes. Available as + `DASH_HOT_RELOAD_WATCH_INTERVAL` environment variable. + :type dev_tools_hot_reload_watch_interval: float + :param dev_tools_hot_reload_max_retry: Maximum amount of retries before + failing and display a pop up. Default 30. Available as + `DASH_HOT_RELOAD_MAX_RETRY` environment variable. + :type dev_tools_hot_reload_max_retry: int + :param dev_tools_silence_routes_logging: Silence the `werkzeug` logger, + will remove all routes logging. Available as + `DASH_SILENCE_ROUTES_LOGGING` environment variable. + :type dev_tools_silence_routes_logging: bool + :return: debug """ env = _configs.env_configs() debug = debug or _configs.get_config('debug', None, env, debug, @@ -1033,12 +1121,112 @@ def enable_dev_tools(self, default=debug, is_bool=True ) + self._dev_tools['hot_reload'] = _configs.get_config( + 'hot_reload', dev_tools_hot_reload, env, + default=debug, + is_bool=True + ) + self._dev_tools['hot_reload_interval'] = int(_configs.get_config( + 'hot_reload_interval', dev_tools_hot_reload_interval, env, + default=3000 + )) + self._dev_tools['hot_reload_watch_interval'] = float( + _configs.get_config( + 'hot_reload_watch_interval', + dev_tools_hot_reload_watch_interval, + env, + default=0.5 + ) + ) + self._dev_tools['hot_reload_max_retry'] = int( + _configs.get_config( + 'hot_reload_max_retry', + dev_tools_hot_reload_max_retry, + env, + default=8 + ) + ) + self._dev_tools['silence_routes_logging'] = _configs.get_config( + 'silence_routes_logging', dev_tools_silence_routes_logging, env, + default=debug, + is_bool=True, + ) + + if self._dev_tools.silence_routes_logging: + logging.getLogger('werkzeug').setLevel(logging.ERROR) + self.logger.setLevel(logging.INFO) + + if self._dev_tools.hot_reload: + self._reload_hash = _generate_hash() + self._watch_thread = threading.Thread( + target=lambda: _watch.watch( + [self._assets_folder], + self._on_assets_change, + sleep_time=self._dev_tools.hot_reload_watch_interval) + ) + self._watch_thread.daemon = True + self._watch_thread.start() + + if debug and self._dev_tools.serve_dev_bundles: + # Dev bundles only works locally. + self.scripts.config.serve_locally = True + return debug + # noinspection PyProtectedMember + def _on_assets_change(self, filename, modified, deleted): + self._lock.acquire() + self._hard_reload = True + self._reload_hash = _generate_hash() + + asset_path = os.path.relpath( + filename, os.path.commonprefix([self._assets_folder, filename]))\ + .replace('\\', '/').lstrip('/') + + self._changed_assets.append({ + 'url': self.get_asset_url(asset_path), + 'modified': int(modified), + 'is_css': filename.endswith('css') + }) + + if filename not in self._assets_files and not deleted: + res = self._add_assets_resource(asset_path, filename) + if filename.endswith('js'): + self.scripts.append_script(res) + elif filename.endswith('css'): + self.css.append_css(res) + + if deleted: + if filename in self._assets_files: + self._assets_files.remove(filename) + + def delete_resource(resources): + to_delete = None + for r in resources: + if r.get('asset_path') == asset_path: + to_delete = r + break + if to_delete: + resources.remove(to_delete) + + if filename.endswith('js'): + # pylint: disable=protected-access + delete_resource(self.scripts._resources._resources) + elif filename.endswith('css'): + # pylint: disable=protected-access + delete_resource(self.css._resources._resources) + + self._lock.release() + def run_server(self, port=8050, debug=False, dev_tools_serve_dev_bundles=None, + dev_tools_hot_reload=None, + dev_tools_hot_reload_interval=None, + dev_tools_hot_reload_watch_interval=None, + dev_tools_hot_reload_max_retry=None, + dev_tools_silence_routes_logging=None, **flask_run_options): """ Start the flask server in local mode, you should not run this on a @@ -1050,9 +1238,51 @@ def run_server(self, :type debug: bool :param dev_tools_serve_dev_bundles: Serve the dev bundles of components :type dev_tools_serve_dev_bundles: bool + :param dev_tools_hot_reload: Enable the hot reload. + :type dev_tools_hot_reload: bool + :param dev_tools_hot_reload_interval: Reload request interval. + :type dev_tools_hot_reload_interval: int + :param dev_tools_hot_reload_watch_interval: + :type dev_tools_hot_reload_watch_interval: float + :param dev_tools_hot_reload_max_retry: The number of times the reloader + requests can fail before displaying an alert. + :type dev_tools_hot_reload_max_retry: int + :param dev_tools_silence_routes_logging: Silence the routes logs. + :type dev_tools_silence_routes_logging: bool :param flask_run_options: Given to `Flask.run` :return: """ - debug = self.enable_dev_tools(debug, dev_tools_serve_dev_bundles) + debug = self.enable_dev_tools( + debug, + dev_tools_serve_dev_bundles, + dev_tools_hot_reload, + dev_tools_hot_reload_interval, + dev_tools_hot_reload_watch_interval, + dev_tools_hot_reload_max_retry, + dev_tools_silence_routes_logging, + ) + + if self._dev_tools.silence_routes_logging: + # Since it's silenced, the address don't show anymore. + host = flask_run_options.get('host', '127.0.0.1') + ssl_context = flask_run_options.get('ssl_context') + self.logger.info( + 'Running on %s://%s:%s%s', + 'https' if ssl_context else 'http', + host, port, self.config.requests_pathname_prefix + ) + + # Generate a debugger pin and log it to the screen. + debugger_pin = os.environ['WERKZEUG_DEBUG_PIN'] = '-'.join( + itertools.chain( + ''.join([str(random.randint(0, 9)) for _ in range(3)]) + for _ in range(3)) + ) + + self.logger.info( + 'Debugger PIN: %s', + debugger_pin + ) + self.server.run(port=port, debug=debug, **flask_run_options) diff --git a/dash/resources.py b/dash/resources.py index c01546df35..603bd37afe 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -11,7 +11,6 @@ def __init__(self, resource_name, layout): self._resources = [] self.resource_name = resource_name self.layout = layout - self._resources_cache = [] def append_resource(self, resource): self._resources.append(resource) @@ -59,15 +58,10 @@ def _filter_resources(self, all_resources, dev_bundles=False): return filtered_resources def get_all_resources(self, dev_bundles=False): - if self._resources_cache: - return self._resources_cache + lib_resources = ComponentRegistry.get_resources(self.resource_name) + all_resources = lib_resources + self._resources - all_resources = ComponentRegistry.get_resources(self.resource_name) - all_resources.extend(self._resources) - - self._resources_cache = res = \ - self._filter_resources(all_resources, dev_bundles) - return res + return self._filter_resources(all_resources, dev_bundles) class Css: # pylint: disable=old-style-class diff --git a/dash/version.py b/dash/version.py index 9093e4e468..e187e0aa61 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '0.29.0' +__version__ = '0.30.0'