Skip to content

Commit

Permalink
🔧 Support for config-aware relative paths (#1073)
Browse files Browse the repository at this point in the history
  • Loading branch information
chriddyp authored Jan 10, 2020
1 parent 172b782 commit 735480b
Show file tree
Hide file tree
Showing 6 changed files with 259 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ venv/
ENV/
env.bak/
venv.bak/
vv

# IDE
.idea/*
Expand Down Expand Up @@ -64,4 +65,4 @@ npm-debug*

dash_renderer/
digest.json
VERSION.txt
VERSION.txt
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
44 changes: 44 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__

Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions dash/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,7 @@ class SameInputOutputException(CallbackException):

class MissingCallbackContextException(CallbackException):
pass


class UnsupportedRelativePath(CallbackException):
pass
84 changes: 83 additions & 1 deletion tests/unit/test_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

0 comments on commit 735480b

Please sign in to comment.