diff --git a/.gitignore b/.gitignore index 839143bddf..72b64857ef 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ venv/ ENV/ env.bak/ venv.bak/ +vv # IDE .idea/* @@ -64,4 +65,4 @@ npm-debug* dash_renderer/ digest.json -VERSION.txt \ No newline at end of file +VERSION.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc726bc16..15690cd70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- [#1073](https://github.com/plotly/dash/pull/1073) Two new functions to simplify usage handling URLs and pathnames: `app.get_relative_path` & `app.trim_relative_path`. +These functions are particularly useful for apps deployed on Dash Enterprise where the apps served under a URL prefix (the app name) which is unlike apps served on localhost:8050. + - `app.get_relative_path` returns a path with the config setting `requests_pathname_prefix` prefixed. Use `app.get_relative_path` anywhere you would provide a relative pathname, like `dcc.Link(href=app.relative_path('/page-2'))` or even as an alternative to `app.get_asset_url` with e.g. `html.Img(src=app.get_relative_path('/assets/logo.png'))`. + - `app.trim_relative_path` a path with `requests_pathname_prefix` and leading & trailing + slashes stripped from it. Use this function in callbacks that deal with `dcc.Location` `pathname` + routing. + Example usage: + ```python + app.layout = html.Div([ + dcc.Location(id='url'), + html.Div(id='content') + ]) + @app.callback(Output('content', 'children'), [Input('url', 'pathname')]) + def display_content(path): + page_name = app.strip_relative_path(path) + if not page_name: # None or '' + return html.Div([ + html.Img(src=app.get_relative_path('/assets/logo.png')), + dcc.Link(href=app.get_relative_path('/page-1')), + dcc.Link(href=app.get_relative_path('/page-2')), + ]) + elif page_name == 'page-1': + return chapters.page_1 + if page_name == "page-2": + return chapters.page_2 + ``` + ### Changed - [#1035](https://github.com/plotly/dash/pull/1035) Simplify our build process. - [#1074](https://github.com/plotly/dash/pull/1045) Error messages when providing an incorrect property to a component have been improved: they now specify the component type, library, version, and ID (if available). diff --git a/dash/_utils.py b/dash/_utils.py index 4816864385..6b0b36a6fd 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -10,6 +10,7 @@ from io import open # pylint: disable=redefined-builtin from functools import wraps import future.utils as utils +from . import exceptions logger = logging.getLogger() @@ -54,6 +55,49 @@ def get_asset_path(requests_pathname, asset_path, asset_url_path): ) +def get_relative_path(requests_pathname, path): + if requests_pathname == '/' and path == '': + return '/' + elif requests_pathname != '/' and path == '': + return requests_pathname + elif not path.startswith('/'): + raise exceptions.UnsupportedRelativePath( + "Paths that aren't prefixed with a leading / are not supported.\n" + + "You supplied: {}".format(path) + ) + return "/".join( + [ + requests_pathname.rstrip("/"), + path.lstrip("/") + ] + ) + +def strip_relative_path(requests_pathname, path): + if path is None: + return None + elif ((requests_pathname != '/' and + not path.startswith(requests_pathname.rstrip('/'))) + or (requests_pathname == '/' and not path.startswith('/'))): + raise exceptions.UnsupportedRelativePath( + "Paths that aren't prefixed with a leading " + + "requests_pathname_prefix are not supported.\n" + + "You supplied: {} and requests_pathname_prefix was {}".format( + path, + requests_pathname + ) + ) + if (requests_pathname != '/' and + path.startswith(requests_pathname.rstrip('/'))): + path = path.replace( + # handle the case where the path might be `/my-dash-app` + # but the requests_pathname_prefix is `/my-dash-app/` + requests_pathname.rstrip('/'), + '', + 1 + ) + return path.strip('/') + + # pylint: disable=no-member def patch_collections_abc(member): return getattr(collections if utils.PY2 else collections.abc, member) diff --git a/dash/dash.py b/dash/dash.py index d21371bbbb..679ed0ee9a 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -36,6 +36,8 @@ from . import _watch from ._utils import get_asset_path as _get_asset_path from ._utils import create_callback_id as _create_callback_id +from ._utils import get_relative_path as _get_relative_path +from ._utils import strip_relative_path as _strip_relative_path from ._configs import get_combined_config, pathname_configs from .version import __version__ @@ -1565,6 +1567,102 @@ def get_asset_url(self, path): return asset + def get_relative_path(self, path): + """ + Return a path with `requests_pathname_prefix` prefixed before it. + Use this function when specifying local URL paths that will work + in environments regardless of what `requests_pathname_prefix` is. + In some deployment environments, like Dash Enterprise, + `requests_pathname_prefix` is set to the application name, + e.g. `my-dash-app`. + When working locally, `requests_pathname_prefix` might be unset and + so a relative URL like `/page-2` can just be `/page-2`. + However, when the app is deployed to a URL like `/my-dash-app`, then + `app.get_relative_path('/page-2')` will return `/my-dash-app/page-2`. + This can be used as an alternative to `get_asset_url` as well with + `app.get_relative_path('/assets/logo.png')` + + Use this function with `app.strip_relative_path` in callbacks that + deal with `dcc.Location` `pathname` routing. + That is, your usage may look like: + ``` + app.layout = html.Div([ + dcc.Location(id='url'), + html.Div(id='content') + ]) + @app.callback(Output('content', 'children'), [Input('url', 'pathname')]) + def display_content(path): + page_name = app.strip_relative_path(path) + if not page_name: # None or '' + return html.Div([ + dcc.Link(href=app.get_relative_path('/page-1')), + dcc.Link(href=app.get_relative_path('/page-2')), + ]) + elif page_name == 'page-1': + return chapters.page_1 + if page_name == "page-2": + return chapters.page_2 + ``` + """ + asset = _get_relative_path( + self.config.requests_pathname_prefix, + path, + ) + + return asset + + def strip_relative_path(self, path): + """ + Return a path with `requests_pathname_prefix` and leading and trailing + slashes stripped from it. Also, if None is passed in, None is returned. + Use this function with `get_relative_path` in callbacks that deal + with `dcc.Location` `pathname` routing. + That is, your usage may look like: + ``` + app.layout = html.Div([ + dcc.Location(id='url'), + html.Div(id='content') + ]) + @app.callback(Output('content', 'children'), [Input('url', 'pathname')]) + def display_content(path): + page_name = app.strip_relative_path(path) + if not page_name: # None or '' + return html.Div([ + dcc.Link(href=app.get_relative_path('/page-1')), + dcc.Link(href=app.get_relative_path('/page-2')), + ]) + elif page_name == 'page-1': + return chapters.page_1 + if page_name == "page-2": + return chapters.page_2 + ``` + Note that `chapters.page_1` will be served if the user visits `/page-1` + _or_ `/page-1/` since `strip_relative_path` removes the trailing slash. + + Also note that `strip_relative_path` is compatible with + `get_relative_path` in environments where `requests_pathname_prefix` set. + In some deployment environments, like Dash Enterprise, + `requests_pathname_prefix` is set to the application name, e.g. `my-dash-app`. + When working locally, `requests_pathname_prefix` might be unset and + so a relative URL like `/page-2` can just be `/page-2`. + However, when the app is deployed to a URL like `/my-dash-app`, then + `app.get_relative_path('/page-2')` will return `/my-dash-app/page-2` + + The `pathname` property of `dcc.Location` will return '`/my-dash-app/page-2`' + to the callback. + In this case, `app.strip_relative_path('/my-dash-app/page-2')` + will return `'page-2'` + + For nested URLs, slashes are still included: + `app.strip_relative_path('/page-1/sub-page-1/')` will return + `page-1/sub-page-1` + ``` + """ + return _strip_relative_path( + self.config.requests_pathname_prefix, + path, + ) + def _setup_dev_tools(self, **kwargs): debug = kwargs.get("debug", False) dev_tools = self._dev_tools = _AttributeDict() diff --git a/dash/exceptions.py b/dash/exceptions.py index 27f115f84e..4756da912d 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -98,3 +98,7 @@ class SameInputOutputException(CallbackException): class MissingCallbackContextException(CallbackException): pass + + +class UnsupportedRelativePath(CallbackException): + pass diff --git a/tests/unit/test_configs.py b/tests/unit/test_configs.py index 2481174b99..270c1327b5 100644 --- a/tests/unit/test_configs.py +++ b/tests/unit/test_configs.py @@ -12,7 +12,11 @@ get_combined_config, load_dash_env_vars, ) -from dash._utils import get_asset_path +from dash._utils import ( + get_asset_path, + get_relative_path, + strip_relative_path, +) @pytest.fixture @@ -156,3 +160,81 @@ def test_load_dash_env_vars_refects_to_os_environ(empty_environ): def test_app_name_server(empty_environ, name, server, expected): app = Dash(name=name, server=server) assert app.config.name == expected + + +@pytest.mark.parametrize( + "prefix, partial_path, expected", + [ + ("/", "", "/"), + ("/my-dash-app/", "", "/my-dash-app/"), + + ("/", "/", "/"), + ("/my-dash-app/", "/", "/my-dash-app/"), + + ("/", "/page-1", "/page-1"), + ("/my-dash-app/", "/page-1", "/my-dash-app/page-1"), + + ("/", "/page-1/", "/page-1/"), + ("/my-dash-app/", "/page-1/", "/my-dash-app/page-1/"), + + ("/", "/page-1/sub-page-1", "/page-1/sub-page-1"), + ("/my-dash-app/", "/page-1/sub-page-1", "/my-dash-app/page-1/sub-page-1"), + ] +) +def test_pathname_prefix_relative_url(prefix, partial_path, expected): + path = get_relative_path(prefix, partial_path) + assert path == expected + +@pytest.mark.parametrize( + "prefix, partial_path", + [ + ("/", "relative-page-1"), + ("/my-dash-app/", "relative-page-1"), + ] +) +def test_invalid_get_relative_path(prefix, partial_path): + with pytest.raises(_exc.UnsupportedRelativePath): + get_relative_path(prefix, partial_path) + +@pytest.mark.parametrize( + "prefix, partial_path, expected", + [ + ("/", None, None), + ("/my-dash-app/", None, None), + + ("/", "/", ""), + ("/my-dash-app/", "/my-dash-app", ""), + ("/my-dash-app/", "/my-dash-app/", ""), + + ("/", "/page-1", "page-1"), + ("/my-dash-app/", "/my-dash-app/page-1", "page-1"), + + ("/", "/page-1/", "page-1"), + ("/my-dash-app/", "/my-dash-app/page-1/", "page-1"), + + ("/", "/page-1/sub-page-1", "page-1/sub-page-1"), + ("/my-dash-app/", "/my-dash-app/page-1/sub-page-1", "page-1/sub-page-1"), + + ("/", "/page-1/sub-page-1/", "page-1/sub-page-1"), + ("/my-dash-app/", "/my-dash-app/page-1/sub-page-1/", "page-1/sub-page-1"), + + ("/my-dash-app/", "/my-dash-app/my-dash-app/", "my-dash-app"), + ("/my-dash-app/", "/my-dash-app/something-else/my-dash-app/", "something-else/my-dash-app"), + ] +) +def test_strip_relative_path(prefix, partial_path, expected): + path = strip_relative_path(prefix, partial_path) + assert path == expected + + +@pytest.mark.parametrize( + "prefix, partial_path", + [ + ("/", "relative-page-1"), + ("/my-dash-app", "relative-page-1"), + ("/my-dash-app", "/some-other-path") + ] +) +def test_invalid_strip_relative_path(prefix, partial_path): + with pytest.raises(_exc.UnsupportedRelativePath): + strip_relative_path(prefix, partial_path)