Repository: plotly/dash
Pull request #687 (2019-04-13 05:25:20)

Flag for props check
- [x] add test case once confirmed with design


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27a9be91f..e55b39990 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## Unreleased
+
+### Added
+- Added `dev_tools_props_check` config flag in `app.run_server` to enable or explicitly disable the forthcoming Component Validation in Dash's front-end /(dash-renderer). Note this will only be effective with dash version bumping to React 16 [#687](https://github.com/plotly/dash/pull/687)
+
 ## [0.41.0] - 2019-04-10
 ### Added
 - Support for "Clientside Callbacks" - an escape hatch to execute your callbacks in JavaScript instead of Python [#672](https://github.com/plotly/dash/pull/672)
diff --git a/dash/_configs.py b/dash/_configs.py
index e887ee14c..e187a5f08 100644
--- a/dash/_configs.py
+++ b/dash/_configs.py
@@ -5,46 +5,56 @@ from . import exceptions
 from ._utils import AttributeDict
 
 
-def env_configs():
-    """
-    Configs from the environ.
-
-    :return: A dict with the dash environ vars
-    """
-    return AttributeDict({x: os.getenv(x, os.getenv(x.lower())) for x in (
-        'DASH_APP_NAME',
-        'DASH_URL_BASE_PATHNAME',
-        'DASH_ROUTES_PATHNAME_PREFIX',
-        'DASH_REQUESTS_PATHNAME_PREFIX',
-        'DASH_SUPPRESS_CALLBACK_EXCEPTIONS',
-        'DASH_ASSETS_EXTERNAL_PATH',
-        'DASH_INCLUDE_ASSETS_FILES',
-        'DASH_COMPONENTS_CACHE_MAX_AGE',
-        'DASH_INCLUDE_ASSETS_FILES',
-        'DASH_SERVE_DEV_BUNDLES',
-        'DASH_DEBUG',
-        'DASH_HOT_RELOAD',
-        'DASH_HOT_RELOAD_INTERVAL',
-        'DASH_HOT_RELOAD_WATCH_INTERVAL',
-        'DASH_HOT_RELOAD_MAX_RETRY',
-        'DASH_SILENCE_ROUTES_LOGGING'
-    )})
-
-
-def get_config(config_name, init, env, default=None, is_bool=False):
-    if init is not None:
-        return init
-
-    env_value = env.get('DASH_{}'.format(config_name.upper()))
-    if env_value is None:
+def load_dash_env_vars():
+    return AttributeDict(
+        {
+            var: os.getenv(var, os.getenv(var.lower()))
+            for var in (
+                'DASH_APP_NAME',
+                'DASH_URL_BASE_PATHNAME',
+                'DASH_ROUTES_PATHNAME_PREFIX',
+                'DASH_REQUESTS_PATHNAME_PREFIX',
+                'DASH_SUPPRESS_CALLBACK_EXCEPTIONS',
+                'DASH_ASSETS_EXTERNAL_PATH',
+                'DASH_INCLUDE_ASSETS_FILES',
+                'DASH_COMPONENTS_CACHE_MAX_AGE',
+                'DASH_INCLUDE_ASSETS_FILES',
+                'DASH_SERVE_DEV_BUNDLES',
+                'DASH_DEBUG',
+                'DASH_DEV_TOOLS_UI',
+                'DASH_DEV_TOOLS_PROPS_CHECK',
+                'DASH_HOT_RELOAD',
+                'DASH_HOT_RELOAD_INTERVAL',
+                'DASH_HOT_RELOAD_WATCH_INTERVAL',
+                'DASH_HOT_RELOAD_MAX_RETRY',
+                'DASH_SILENCE_ROUTES_LOGGING',
+            )
+        }
+    )
+
+
+DASH_ENV_VARS = load_dash_env_vars()
+
+
+def get_combined_config(name, val, default=None):
+    '''consolidate the config with priority from high to low
+    provided init value > OS environ > default
+    '''
+    if val is not None:
+        return val
+
+    env = load_dash_env_vars().get('DASH_{}'.format(name.upper()))
+    if env is None:
         return default
-    return env_value if not is_bool else env_value.lower() == 'true'
 
+    return env.lower() == 'true' if env.lower() in {'true', 'false'} \
+        else env
 
-def pathname_configs(url_base_pathname=None,
-                     routes_pathname_prefix=None,
-                     requests_pathname_prefix=None,
-                     environ_configs=None):
+
+def pathname_configs(
+        url_base_pathname=None,
+        routes_pathname_prefix=None,
+        requests_pathname_prefix=None):
     _pathname_config_error_message = '''
     {} This is ambiguous.
     To fix this, set `routes_pathname_prefix` instead of `url_base_pathname`.
@@ -58,19 +68,14 @@ def pathname_configs(url_base_pathname=None,
     `requests_pathname_prefix` and `routes_pathname_prefix`,
     not `url_base_pathname`.
     '''
-    environ_configs = environ_configs or env_configs()
-
-    url_base_pathname = get_config('url_base_pathname',
-                                   url_base_pathname,
-                                   environ_configs)
+    url_base_pathname = get_combined_config(
+        'url_base_pathname', url_base_pathname)
 
-    routes_pathname_prefix = get_config('routes_pathname_prefix',
-                                        routes_pathname_prefix,
-                                        environ_configs)
+    routes_pathname_prefix = get_combined_config(
+        'routes_pathname_prefix', routes_pathname_prefix)
 
-    requests_pathname_prefix = get_config('requests_pathname_prefix',
-                                          requests_pathname_prefix,
-                                          environ_configs)
+    requests_pathname_prefix = get_combined_config(
+        'requests_pathname_prefix', requests_pathname_prefix)
 
     if url_base_pathname is not None and requests_pathname_prefix is not None:
         raise exceptions.InvalidConfig(
@@ -99,7 +104,7 @@ def pathname_configs(url_base_pathname=None,
         raise exceptions.InvalidConfig(
             '`routes_pathname_prefix` needs to end with `/`')
 
-    app_name = environ_configs.DASH_APP_NAME
+    app_name = load_dash_env_vars().DASH_APP_NAME
 
     if not requests_pathname_prefix and app_name:
         requests_pathname_prefix = '/' + app_name + routes_pathname_prefix
diff --git a/dash/dash.py b/dash/dash.py
index cc74688d3..f3a810f6b 100644
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -35,7 +35,7 @@ from ._utils import patch_collections_abc as _patch_collections_abc
 from . import _watch
 from ._utils import get_asset_path as _get_asset_path
 from ._utils import create_callback_id as _create_callback_id
-from . import _configs
+from ._configs import (get_combined_config, pathname_configs)
 
 _default_index = '''<!DOCTYPE html>
 <html>
@@ -126,33 +126,29 @@ class Dash(object):
         # allow users to supply their own flask server
         self.server = server or Flask(name, static_folder=static_folder)
 
-        env_configs = _configs.env_configs()
-
         url_base_pathname, routes_pathname_prefix, requests_pathname_prefix = \
-            _configs.pathname_configs(
+            pathname_configs(
                 url_base_pathname,
                 routes_pathname_prefix,
-                requests_pathname_prefix,
-                environ_configs=env_configs)
+                requests_pathname_prefix
+            )
 
         self.url_base_pathname = url_base_pathname
         self.config = _AttributeDict({
-            'suppress_callback_exceptions': _configs.get_config(
+            'suppress_callback_exceptions': get_combined_config(
                 'suppress_callback_exceptions',
-                suppress_callback_exceptions, env_configs, False
-            ),
+                suppress_callback_exceptions,
+                False),
             'routes_pathname_prefix': routes_pathname_prefix,
             'requests_pathname_prefix': requests_pathname_prefix,
-            'include_assets_files': _configs.get_config(
-                'include_assets_files',
-                include_assets_files,
-                env_configs,
-                True),
-            'assets_external_path': _configs.get_config(
-                'assets_external_path', assets_external_path, env_configs, ''),
-            'components_cache_max_age': int(_configs.get_config(
-                'components_cache_max_age', components_cache_max_age,
-                env_configs, 2678400))
+            'include_assets_files': get_combined_config(
+                'include_assets_files', include_assets_files, True),
+            'assets_external_path': get_combined_config(
+                'assets_external_path', assets_external_path, ''),
+            'components_cache_max_age': int(get_combined_config(
+                'components_cache_max_age',
+                components_cache_max_age,
+                2678400))
         })
 
         assets_blueprint_name = '{}{}'.format(
@@ -248,6 +244,7 @@ class Dash(object):
             'hot_reload_watch_interval': 0.5,
             'hot_reload_max_retry': 8,
             'dev_tools_ui': False,
+            'dev_tools_props_check': False,
         })
 
         # add a handler for components suites errors to return 404
@@ -330,8 +327,7 @@ class Dash(object):
 
         # TODO - Set browser cache limit - pass hash into frontend
         return flask.Response(
-            json.dumps(layout,
-                       cls=plotly.utils.PlotlyJSONEncoder),
+            json.dumps(layout, cls=plotly.utils.PlotlyJSONEncoder),
             mimetype='application/json'
         )
 
@@ -340,6 +336,7 @@ class Dash(object):
             'url_base_pathname': self.url_base_pathname,
             'requests_pathname_prefix': self.config.requests_pathname_prefix,
             'dev_tools_ui': self._dev_tools.dev_tools_ui,
+            'dev_tools_props_check': self._dev_tools.dev_tools_props_check,
         }
         if self._dev_tools.hot_reload:
             config['hot_reload'] = {
@@ -365,8 +362,7 @@ class Dash(object):
 
     def serve_routes(self):
         return flask.Response(
-            json.dumps(self.routes,
-                       cls=plotly.utils.PlotlyJSONEncoder),
+            json.dumps(self.routes, cls=plotly.utils.PlotlyJSONEncoder),
             mimetype='application/json'
         )
 
@@ -1235,9 +1231,11 @@ class Dash(object):
             'Cache-Control': 'public, max-age={}'.format(
                 self.config.components_cache_max_age)
         }
-        return flask.Response(pkgutil.get_data('dash', 'favicon.ico'),
-                              headers=headers,
-                              content_type='image/x-icon')
+        return flask.Response(
+            pkgutil.get_data('dash', 'favicon.ico'),
+            headers=headers,
+            content_type='image/x-icon',
+        )
 
     def get_asset_url(self, path):
         asset = _get_asset_path(
@@ -1250,6 +1248,8 @@ class Dash(object):
 
     def enable_dev_tools(self,
                          debug=False,
+                         dev_tools_ui=None,
+                         dev_tools_props_check=None,
                          dev_tools_serve_dev_bundles=None,
                          dev_tools_hot_reload=None,
                          dev_tools_hot_reload_interval=None,
@@ -1267,6 +1267,8 @@ class Dash(object):
         Available dev_tools environment variables:
 
             - DASH_DEBUG
+            - DASH_DEV_TOOLS_UI
+            - DASH_DEV_TOOLS_PROPS_CHECK
             - DASH_SERVE_DEV_BUNDLES
             - DASH_HOT_RELOAD
             - DASH_HOT_RELOAD_INTERVAL
@@ -1278,6 +1280,11 @@ class Dash(object):
             disabled by the arguments or by environ variables. Available as
             `DASH_DEBUG` environment variable.
         :type debug: bool
+        :param dev_tools_ui: Switch the dev tools UI in debugger mode
+        :type dev_tools_ui: bool
+        :param dev_tools_props_check: Validate the properties of
+            the Dash components
+        :type dev_tools_props_check: bool
         :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
@@ -1302,46 +1309,40 @@ class Dash(object):
         :type dev_tools_silence_routes_logging: bool
         :return: debug
         """
-        env = _configs.env_configs()
-        debug = debug or _configs.get_config('debug', None, env, debug,
-                                             is_bool=True)
-
-        self._dev_tools.dev_tools_ui = debug
+        debug = debug or get_combined_config('debug', None, debug)
 
-        self._dev_tools['serve_dev_bundles'] = _configs.get_config(
-            'serve_dev_bundles', dev_tools_serve_dev_bundles, env,
-            default=debug,
-            is_bool=True
+        self._dev_tools['dev_tools_ui'] = get_combined_config(
+            'dev_tools_ui', dev_tools_ui, default=debug
         )
-        self._dev_tools['hot_reload'] = _configs.get_config(
-            'hot_reload', dev_tools_hot_reload, env,
-            default=debug,
-            is_bool=True
+        self._dev_tools['dev_tools_props_check'] = get_combined_config(
+            'dev_tools_props_check', dev_tools_props_check, default=debug
         )
-        self._dev_tools['hot_reload_interval'] = int(_configs.get_config(
-            'hot_reload_interval', dev_tools_hot_reload_interval, env,
-            default=3000
+        self._dev_tools['serve_dev_bundles'] = get_combined_config(
+            'serve_dev_bundles', dev_tools_serve_dev_bundles, default=debug)
+
+        self._dev_tools['hot_reload'] = get_combined_config(
+            'hot_reload', dev_tools_hot_reload, default=debug)
+        self._dev_tools['hot_reload_interval'] = int(get_combined_config(
+            'hot_reload_interval', dev_tools_hot_reload_interval, default=3000
         ))
         self._dev_tools['hot_reload_watch_interval'] = float(
-            _configs.get_config(
+            get_combined_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(
+            get_combined_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,
+        self._dev_tools['silence_routes_logging'] = get_combined_config(
+            'silence_routes_logging',
+            dev_tools_silence_routes_logging,
             default=debug,
-            is_bool=True,
         )
 
         if self._dev_tools.silence_routes_logging:
@@ -1425,6 +1426,8 @@ class Dash(object):
     def run_server(self,
                    port=8050,
                    debug=False,
+                   dev_tools_ui=None,
+                   dev_tools_props_check=None,
                    dev_tools_serve_dev_bundles=None,
                    dev_tools_hot_reload=None,
                    dev_tools_hot_reload_interval=None,
@@ -1440,6 +1443,11 @@ class Dash(object):
         :type port: int
         :param debug: Set the debug mode of flask and enable the dev tools.
         :type debug: bool
+        :param dev_tools_ui: Switch the dev tools UI in debugger mode
+        :type dev_tools_ui: bool
+        :param dev_tools_props_check: Validate the properties of
+            the Dash components
+        :type dev_tools_props_check: 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.
@@ -1458,6 +1466,8 @@ class Dash(object):
         """
         debug = self.enable_dev_tools(
             debug,
+            dev_tools_ui,
+            dev_tools_props_check,
             dev_tools_serve_dev_bundles,
             dev_tools_hot_reload,
             dev_tools_hot_reload_interval,
@@ -1483,10 +1493,6 @@ class Dash(object):
                     for _ in range(3))
             )
 
-            self.logger.info(
-                'Debugger PIN: %s',
-                debugger_pin
-            )
+            self.logger.info('Debugger PIN: %s', debugger_pin)
 
-        self.server.run(port=port, debug=debug,
-                        **flask_run_options)
+        self.server.run(port=port, debug=debug, **flask_run_options)


Test patch:
diff --git a/test.sh b/test.sh
index 48f97a1cc..bc00cf94e 100755
--- a/test.sh
+++ b/test.sh
@@ -1,16 +1,16 @@
 EXIT_STATE=0
 
+pylint dash setup.py --rcfile=$PYLINTRC || EXIT_STATE=$?
+pylint tests -d all -e C0410,C0411,C0412,C0413,W0109 || EXIT_STATE=$?
+flake8 dash setup.py || EXIT_STATE=$?
+flake8 --ignore=E123,E126,E501,E722,E731,F401,F841,W503,W504 --exclude=metadata_test.py tests || EXIT_STATE=$?
+
 python -m unittest tests.development.test_base_component || EXIT_STATE=$?
 python -m unittest tests.development.test_component_loader || EXIT_STATE=$?
 python -m unittest tests.test_integration || EXIT_STATE=$?
 python -m unittest tests.test_resources || EXIT_STATE=$?
 python -m unittest tests.test_configs || EXIT_STATE=$?
 
-pylint dash setup.py --rcfile=$PYLINTRC || EXIT_STATE=$?
-pylint tests -d all -e C0410,C0411,C0412,C0413,W0109 || EXIT_STATE=$?
-flake8 dash setup.py || EXIT_STATE=$?
-flake8 --ignore=E123,E126,E501,E722,E731,F401,F841,W503,W504 --exclude=metadata_test.py tests || EXIT_STATE=$?
-
 if [ $EXIT_STATE -ne 0 ]; then
     echo "One or more tests failed"
 else
diff --git a/tests/test_configs.py b/tests/test_configs.py
index ef630e6cd..28abb82bb 100644
--- a/tests/test_configs.py
+++ b/tests/test_configs.py
@@ -1,39 +1,43 @@
 import os
 import unittest
 # noinspection PyProtectedMember
-from dash import _configs
+from dash._configs import (
+    pathname_configs, DASH_ENV_VARS, get_combined_config, load_dash_env_vars)
 from dash import exceptions as _exc
 from dash._utils import get_asset_path
 
 
-class MyTestCase(unittest.TestCase):
+class TestConfigs(unittest.TestCase):
 
     def setUp(self):
-        environ = _configs.env_configs()
-
-        for k in environ.keys():
+        for k in DASH_ENV_VARS.keys():
             if k in os.environ:
                 os.environ.pop(k)
 
+    def test_dash_env_vars(self):
+        self.assertEqual(
+            {None}, {val for _, val in DASH_ENV_VARS.items()},
+            "initial var values are None without extra OS environ setting")
+
     def test_valid_pathname_prefix_init(self):
-        _, routes, req = _configs.pathname_configs()
+        _, routes, req = pathname_configs()
 
         self.assertEqual('/', routes)
         self.assertEqual('/', req)
 
-        _, routes, req = _configs.pathname_configs(
+        _, routes, req = pathname_configs(
             routes_pathname_prefix='/dash/')
 
         self.assertEqual('/dash/', req)
 
-        _, routes, req = _configs.pathname_configs(
+        _, routes, req = pathname_configs(
             requests_pathname_prefix='/my-dash-app/',
         )
 
         self.assertEqual(routes, '/')
         self.assertEqual(req, '/my-dash-app/')
 
-        _, routes, req = _configs.pathname_configs(
+        _, routes, req = pathname_configs(
             routes_pathname_prefix='/dash/',
             requests_pathname_prefix='/my-dash-app/dash/'
         )
@@ -43,12 +47,12 @@ class MyTestCase(unittest.TestCase):
 
     def test_invalid_pathname_prefix(self):
         with self.assertRaises(_exc.InvalidConfig) as context:
-            _, _, _ = _configs.pathname_configs('/my-path', '/another-path')
+            _, _, _ = pathname_configs('/my-path', '/another-path')
 
             self.assertTrue('url_base_pathname' in str(context.exception))
 
         with self.assertRaises(_exc.InvalidConfig) as context:
-            _, _, _ = _configs.pathname_configs(
+            _, _, _ = pathname_configs(
                 url_base_pathname='/invalid',
                 routes_pathname_prefix='/invalid')
 
@@ -56,7 +60,7 @@ class MyTestCase(unittest.TestCase):
                             .endswith('`routes_pathname_prefix`'))
 
         with self.assertRaises(_exc.InvalidConfig) as context:
-            _, _, _ = _configs.pathname_configs(
+            _, _, _ = pathname_configs(
                 url_base_pathname='/my-path',
                 requests_pathname_prefix='/another-path')
 
@@ -64,29 +68,29 @@ class MyTestCase(unittest.TestCase):
                             .endswith('`requests_pathname_prefix`'))
 
         with self.assertRaises(_exc.InvalidConfig) as context:
-            _, _, _ = _configs.pathname_configs('my-path')
+            _, _, _ = pathname_configs('my-path')
 
             self.assertTrue('start with `/`' in str(context.exception))
 
         with self.assertRaises(_exc.InvalidConfig) as context:
-            _, _, _ = _configs.pathname_configs('/my-path')
+            _, _, _ = pathname_configs('/my-path')
 
             self.assertTrue('end with `/`' in str(context.exception))
 
     def test_pathname_prefix_from_environ_app_name(self):
         os.environ['DASH_APP_NAME'] = 'my-dash-app'
-        _, routes, req = _configs.pathname_configs()
+        _, routes, req = pathname_configs()
         self.assertEqual('/my-dash-app/', req)
         self.assertEqual('/', routes)
 
     def test_pathname_prefix_environ_routes(self):
         os.environ['DASH_ROUTES_PATHNAME_PREFIX'] = '/routes/'
-        _, routes, req = _configs.pathname_configs()
+        _, routes, _ = pathname_configs()
         self.assertEqual('/routes/', routes)
 
     def test_pathname_prefix_environ_requests(self):
         os.environ['DASH_REQUESTS_PATHNAME_PREFIX'] = '/requests/'
-        _, routes, req = _configs.pathname_configs()
+        _, _, req = pathname_configs()
         self.assertEqual('/requests/', req)
 
     def test_pathname_prefix_assets(self):
@@ -102,6 +106,37 @@ class MyTestCase(unittest.TestCase):
         path = get_asset_path(req, 'reset.css', 'assets')
         self.assertEqual('/requests/routes/assets/reset.css', path)
 
+    def test_get_combined_config_dev_tools_ui(self):
+        val1 = get_combined_config('dev_tools_ui', None, default=False)
+        self.assertEqual(
+            val1, False,
+            "should return the default value if None is provided for init and environment")
+        os.environ['DASH_DEV_TOOLS_UI'] = 'true'
+        val2 = get_combined_config('dev_tools_ui', None, default=False)
+        self.assertEqual(val2, True, "should return the set environment value as True")
+        val3 = get_combined_config('dev_tools_ui', False, default=True)
+        self.assertEqual(val3, False, "init value overrides the environment value")
+
+    def test_get_combined_config_dev_tools_props_check(self):
+        val1 = get_combined_config('dev_tools_props_check', None, default=False)
+        self.assertEqual(
+            val1, False,
+            "should return the default value if None is provided for init and environment")
+        os.environ['DASH_DEV_TOOLS_PROPS_CHECK'] = 'true'
+        val2 = get_combined_config('dev_tools_props_check', None, default=False)
+        self.assertEqual(val2, True, "should return the set environment value as True")
+        val3 = get_combined_config('dev_tools_props_check', False, default=True)
+        self.assertEqual(val3, False, "init value overrides the environment value")
+
+    def test_load_dash_env_vars_refects_to_os_environ(self):
+        for var in DASH_ENV_VARS.keys():
+            os.environ[var] = 'true'
+            vars = load_dash_env_vars()
+            self.assertEqual(vars[var], 'true')
+            os.environ[var] = 'false'
+            vars = load_dash_env_vars()
+            self.assertEqual(vars[var], 'false')
+
 
 if __name__ == '__main__':
     unittest.main()