Repository: plotly/dash
Pull request #322 (2018-08-03 18:57:14)

Pathname prefix from environ variables.
Fixes:

- Take `requests_pathname_prefix` config when creating scripts tags.

Added:

- configs from init or from environ.
  - If init is supplied, take that value, if not take from environ or default.
  -  Dash environ variables starts with `DASH_` (eg: `DASH_ROUTES_PATHNAME_PREFIX`) 
- `routes/requests_pathname_prefix` evaluation and checks.
  - defaults to `'/'`
  - `routes_pathname_prefix` must start/end with `/`.
  - Cannot supply both `url_base_pathname` and `routes/requests_pathname_prefix`.
  - `requests_pathname_prefix` must ends with `routes_pathname_prefix`
  - If no other options is supplied and `DASH_APP_NAME` is in `os.environ`, format `requests_pathname_prefix` as `/{DASH_APP_NAME}{routes_pathname_prefix}`

utyf: IMHO, taking config implicitly from environment sounds like not a very good idea, at least for Dash.
chriddyp: > IMHO, taking config implicitly from environment sounds like not a very good idea, at least for Dash.

Why is that? All the variables will be prefixed by `dash_`, so there shouldn't be any inadvertent readings. There is also an alternative to set them explicitly. 

For some more context, I laid out some ideas here: https://github.com/plotly/dash/issues/312
T4rk1n: @chriddyp 

`requests_pathname_prefix` needs to contains the `routes_pathname_prefix` to work properly.

Example

requests='/dash-app-name'
routes='/dash'
custom-flask-index='/'

The working url for that app would be `host/dash-app-name/` which leads to custom-flask-index that contains a link to the dash app in `/dash`.

The requests thus needs to come from `/dash-app-name/dash` in that case.

Should we issue a warning and append the routes_pathname if it's not included in the requests_pathname or raises an error in that case ?
T4rk1n: @chriddyp I updated this PR, please review.
chriddyp: > I wonder if it might make sense to come up with a systematic abstraction for getting config values from the constructor, falling back to os.environ (and handling either upper or lower case DASH_ vars), and then falling back to a final default value? This is probably taking the discussion further afield of this PR (sorry!) but one possibility is to switch to using **kwargs in the constructor. eg:

@ned2 - Check it out now. We're now taking precedence in the constructor with `None` checks. This will break down in the future if we end up having config values that are and truly should be `None`, but it works for now.
chriddyp: Looks good to me @T4rk1n ! 💃 
T4rk1n: One thing that bothers me with the None checks is that there is no more checking of the default value in the IDE while you type, but this could be countered with good docstring.

> This will break down in the future if we end up having config values that are and truly should be None, but it works for now.

Could put a `accept_none_init` as parameter and have the init be '' instead. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18cb56980..e28709da6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+## 0.25.0 - 2018-08-14
+## Added
+- Take configs values from init or environ variables (Prefixed with `DASH_`). [#322](https://github.com/plotly/dash/pull/322)
+
+## Fixed
+- Take `requests_pathname_prefix` config when creating scripts tags. 
+- `requests/routes_pathname_prefix` must starts and end with `/`.
+- `requests_pathname_prefix` must ends with `routes_pathname_prefix`. If you supplied both `requests` and `routes` pathname before this update, make sure `requests_pathname_prefix` ends with the same value as `routes_pathname_prefix`.
+- `url_base_pathname` set both `requests/routes` pathname, cannot supply it with either `requests` or `routes` pathname prefixes.
+
+
 ## 0.24.2 - 2018-08-13
 ## Fixed
 - Disallow duplicate component ids in the initial layout. [#320](https://github.com/plotly/dash/pull/320)
diff --git a/dash/_configs.py b/dash/_configs.py
new file mode 100644
index 000000000..6849f5be8
--- /dev/null
+++ b/dash/_configs.py
@@ -0,0 +1,104 @@
+import os
+
+# noinspection PyCompatibility
+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'
+    )})
+
+
+def get_config(config_name, init, env, default=None):
+    if init is not None:
+        return init
+
+    env_value = env.get('DASH_{}'.format(config_name.upper()))
+    if env_value is None:
+        return default
+    return env_value
+
+
+def pathname_configs(url_base_pathname=None,
+                     routes_pathname_prefix=None,
+                     requests_pathname_prefix=None,
+                     environ_configs=None):
+    _pathname_config_error_message = '''
+    {} This is ambiguous.
+    To fix this, set `routes_pathname_prefix` instead of `url_base_pathname`.
+
+    Note that `requests_pathname_prefix` is the prefix for the AJAX calls that
+    originate from the client (the web browser) and `routes_pathname_prefix` is
+    the prefix for the API routes on the backend (this flask server).
+    `url_base_pathname` will set `requests_pathname_prefix` and
+    `routes_pathname_prefix` to the same value.
+    If you need these to be different values then you should set
+    `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)
+
+    routes_pathname_prefix = get_config('routes_pathname_prefix',
+                                        routes_pathname_prefix,
+                                        environ_configs)
+
+    requests_pathname_prefix = get_config('requests_pathname_prefix',
+                                          requests_pathname_prefix,
+                                          environ_configs)
+
+    if url_base_pathname is not None and requests_pathname_prefix is not None:
+        raise exceptions.InvalidConfig(
+            _pathname_config_error_message.format(
+                'You supplied `url_base_pathname` and '
+                '`requests_pathname_prefix`.'
+            )
+        )
+    elif url_base_pathname is not None and routes_pathname_prefix is not None:
+        raise exceptions.InvalidConfig(
+            _pathname_config_error_message.format(
+                'You supplied `url_base_pathname` and '
+                '`routes_pathname_prefix`.')
+        )
+    elif url_base_pathname is not None and routes_pathname_prefix is None:
+        routes_pathname_prefix = url_base_pathname
+    elif routes_pathname_prefix is None:
+        routes_pathname_prefix = '/'
+
+    if not routes_pathname_prefix.startswith('/'):
+        raise exceptions.InvalidConfig(
+            '`routes_pathname_prefix` needs to start with `/`')
+    if not routes_pathname_prefix.endswith('/'):
+        raise exceptions.InvalidConfig(
+            '`routes_pathname_prefix` needs to end with `/`')
+
+    app_name = environ_configs.DASH_APP_NAME
+
+    if not requests_pathname_prefix and app_name:
+        requests_pathname_prefix = '/' + app_name + routes_pathname_prefix
+    elif requests_pathname_prefix is None:
+        requests_pathname_prefix = routes_pathname_prefix
+
+    if not requests_pathname_prefix.endswith(routes_pathname_prefix):
+        raise exceptions.InvalidConfig(
+            '`requests_pathname_prefix` needs to ends with '
+            '`routes_pathname_prefix`.'
+        )
+
+    return url_base_pathname, routes_pathname_prefix, requests_pathname_prefix
diff --git a/dash/dash.py b/dash/dash.py
index d5142e0a0..80691b9f8 100644
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -24,6 +24,8 @@ from . import exceptions
 from ._utils import AttributeDict as _AttributeDict
 from ._utils import interpolate_str as _interpolate
 from ._utils import format_tag as _format_tag
+from . import _configs
+
 
 _default_index = '''
 <!DOCTYPE html>
@@ -71,13 +73,17 @@ class Dash(object):
             static_folder='static',
             assets_folder=None,
             assets_url_path='/assets',
-            include_assets_files=True,
-            url_base_pathname='/',
+            assets_external_path=None,
+            include_assets_files=None,
+            url_base_pathname=None,
+            requests_pathname_prefix=None,
+            routes_pathname_prefix=None,
             compress=True,
             meta_tags=None,
             index_string=_default_index,
             external_scripts=None,
             external_stylesheets=None,
+            suppress_callback_exceptions=None,
             **kwargs):
 
         # pylint-disable: too-many-instance-attributes
@@ -101,13 +107,30 @@ class Dash(object):
                             static_folder=self._assets_folder,
                             static_url_path=assets_url_path))
 
+        env_configs = _configs.env_configs()
+
+        url_base_pathname, routes_pathname_prefix, requests_pathname_prefix = \
+            _configs.pathname_configs(
+                url_base_pathname,
+                routes_pathname_prefix,
+                requests_pathname_prefix,
+                environ_configs=env_configs)
+
         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,
-            'include_assets_files': include_assets_files,
-            'assets_external_path': '',
+            'suppress_callback_exceptions': _configs.get_config(
+                'suppress_callback_exceptions',
+                suppress_callback_exceptions, env_configs, 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, ''),
         })
 
         # list of dependencies
@@ -273,7 +296,7 @@ class Dash(object):
                 self.registered_paths[namespace] = [relative_package_path]
 
             return '{}_dash-component-suites/{}/{}?v={}'.format(
-                self.config['routes_pathname_prefix'],
+                self.config['requests_pathname_prefix'],
                 namespace,
                 relative_package_path,
                 importlib.import_module(namespace).__version__
diff --git a/dash/exceptions.py b/dash/exceptions.py
index 58cf32271..9cbb4e4dd 100644
--- a/dash/exceptions.py
+++ b/dash/exceptions.py
@@ -56,3 +56,7 @@ class DuplicateIdError(DashException):
 
 class InvalidCallbackReturnValue(CallbackException):
     pass
+
+
+class InvalidConfig(DashException):
+    pass
diff --git a/dash/version.py b/dash/version.py
index d1d123f30..8c308d723 100644
--- a/dash/version.py
+++ b/dash/version.py
@@ -1,1 +1,1 @@
-__version__ = '0.24.2'
+__version__ = '0.25.0'
diff --git a/tox.ini b/tox.ini
index e3e73acfe..d75924137 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,6 +13,7 @@ commands =
     python -m unittest tests.development.test_component_loader
     python -m unittest tests.test_integration
     python -m unittest tests.test_resources
+    python -m unittest tests.test_configs
 
     flake8 dash setup.py
     pylint dash setup.py
@@ -25,5 +26,7 @@ commands =
     python -m unittest tests.development.test_component_loader
     python -m unittest tests.test_integration
     python -m unittest tests.test_resources
+    python -m unittest tests.test_configs
+    
     flake8 dash setup.py
     pylint dash setup.py


Test patch:
diff --git a/tests/test_configs.py b/tests/test_configs.py
new file mode 100644
index 000000000..ee2967330
--- /dev/null
+++ b/tests/test_configs.py
@@ -0,0 +1,93 @@
+import unittest
+# noinspection PyProtectedMember
+from dash import _configs
+from dash import exceptions as _exc
+import os
+
+
+class MyTestCase(unittest.TestCase):
+
+    def setUp(self):
+        environ = _configs.env_configs()
+
+        for k in environ.keys():
+            if k in os.environ:
+                os.environ.pop(k)
+
+    def test_valid_pathname_prefix_init(self):
+        _, routes, req = _configs.pathname_configs()
+
+        self.assertEqual('/', routes)
+        self.assertEqual('/', req)
+
+        _, routes, req = _configs.pathname_configs(
+            routes_pathname_prefix='/dash/')
+
+        self.assertEqual('/dash/', req)
+
+        _, routes, req = _configs.pathname_configs(
+            requests_pathname_prefix='/my-dash-app/',
+        )
+
+        self.assertEqual(routes, '/')
+        self.assertEqual(req, '/my-dash-app/')
+
+        _, routes, req = _configs.pathname_configs(
+            routes_pathname_prefix='/dash/',
+            requests_pathname_prefix='/my-dash-app/dash/'
+        )
+
+        self.assertEqual('/dash/', routes)
+        self.assertEqual('/my-dash-app/dash/', req)
+
+    def test_invalid_pathname_prefix(self):
+        with self.assertRaises(_exc.InvalidConfig) as context:
+            _, _, _ = _configs.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(
+                url_base_pathname='/invalid',
+                routes_pathname_prefix='/invalid')
+
+            self.assertTrue(str(context.exception).split('.')[0]
+                            .endswith('`routes_pathname_prefix`'))
+
+        with self.assertRaises(_exc.InvalidConfig) as context:
+            _, _, _ = _configs.pathname_configs(
+                url_base_pathname='/my-path',
+                requests_pathname_prefix='/another-path')
+
+            self.assertTrue(str(context.exception).split('.')[0]
+                            .endswith('`requests_pathname_prefix`'))
+
+        with self.assertRaises(_exc.InvalidConfig) as context:
+            _, _, _ = _configs.pathname_configs('my-path')
+
+            self.assertTrue('start with `/`' in str(context.exception))
+
+        with self.assertRaises(_exc.InvalidConfig) as context:
+            _, _, _ = _configs.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()
+        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()
+        self.assertEqual('/routes/', routes)
+
+    def test_pathname_prefix_environ_requests(self):
+        os.environ['DASH_REQUESTS_PATHNAME_PREFIX'] = '/requests/'
+        _, routes, req = _configs.pathname_configs()
+        self.assertEqual('/requests/', req)
+
+
+if __name__ == '__main__':
+    unittest.main()