Skip to content

Commit 735480b

Browse files
authored
🔧 Support for config-aware relative paths (#1073)
1 parent 172b782 commit 735480b

File tree

6 files changed

+259
-2
lines changed

6 files changed

+259
-2
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ venv/
1414
ENV/
1515
env.bak/
1616
venv.bak/
17+
vv
1718

1819
# IDE
1920
.idea/*
@@ -64,4 +65,4 @@ npm-debug*
6465

6566
dash_renderer/
6667
digest.json
67-
VERSION.txt
68+
VERSION.txt

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## Unreleased
66

7+
### Added
8+
- [#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`.
9+
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.
10+
- `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'))`.
11+
- `app.trim_relative_path` a path with `requests_pathname_prefix` and leading & trailing
12+
slashes stripped from it. Use this function in callbacks that deal with `dcc.Location` `pathname`
13+
routing.
14+
Example usage:
15+
```python
16+
app.layout = html.Div([
17+
dcc.Location(id='url'),
18+
html.Div(id='content')
19+
])
20+
@app.callback(Output('content', 'children'), [Input('url', 'pathname')])
21+
def display_content(path):
22+
page_name = app.strip_relative_path(path)
23+
if not page_name: # None or ''
24+
return html.Div([
25+
html.Img(src=app.get_relative_path('/assets/logo.png')),
26+
dcc.Link(href=app.get_relative_path('/page-1')),
27+
dcc.Link(href=app.get_relative_path('/page-2')),
28+
])
29+
elif page_name == 'page-1':
30+
return chapters.page_1
31+
if page_name == "page-2":
32+
return chapters.page_2
33+
```
34+
735
### Changed
836
- [#1035](https://github.com/plotly/dash/pull/1035) Simplify our build process.
937
- [#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).

dash/_utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from io import open # pylint: disable=redefined-builtin
1111
from functools import wraps
1212
import future.utils as utils
13+
from . import exceptions
1314

1415
logger = logging.getLogger()
1516

@@ -54,6 +55,49 @@ def get_asset_path(requests_pathname, asset_path, asset_url_path):
5455
)
5556

5657

58+
def get_relative_path(requests_pathname, path):
59+
if requests_pathname == '/' and path == '':
60+
return '/'
61+
elif requests_pathname != '/' and path == '':
62+
return requests_pathname
63+
elif not path.startswith('/'):
64+
raise exceptions.UnsupportedRelativePath(
65+
"Paths that aren't prefixed with a leading / are not supported.\n" +
66+
"You supplied: {}".format(path)
67+
)
68+
return "/".join(
69+
[
70+
requests_pathname.rstrip("/"),
71+
path.lstrip("/")
72+
]
73+
)
74+
75+
def strip_relative_path(requests_pathname, path):
76+
if path is None:
77+
return None
78+
elif ((requests_pathname != '/' and
79+
not path.startswith(requests_pathname.rstrip('/')))
80+
or (requests_pathname == '/' and not path.startswith('/'))):
81+
raise exceptions.UnsupportedRelativePath(
82+
"Paths that aren't prefixed with a leading " +
83+
"requests_pathname_prefix are not supported.\n" +
84+
"You supplied: {} and requests_pathname_prefix was {}".format(
85+
path,
86+
requests_pathname
87+
)
88+
)
89+
if (requests_pathname != '/' and
90+
path.startswith(requests_pathname.rstrip('/'))):
91+
path = path.replace(
92+
# handle the case where the path might be `/my-dash-app`
93+
# but the requests_pathname_prefix is `/my-dash-app/`
94+
requests_pathname.rstrip('/'),
95+
'',
96+
1
97+
)
98+
return path.strip('/')
99+
100+
57101
# pylint: disable=no-member
58102
def patch_collections_abc(member):
59103
return getattr(collections if utils.PY2 else collections.abc, member)

dash/dash.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from . import _watch
3737
from ._utils import get_asset_path as _get_asset_path
3838
from ._utils import create_callback_id as _create_callback_id
39+
from ._utils import get_relative_path as _get_relative_path
40+
from ._utils import strip_relative_path as _strip_relative_path
3941
from ._configs import get_combined_config, pathname_configs
4042
from .version import __version__
4143

@@ -1565,6 +1567,102 @@ def get_asset_url(self, path):
15651567

15661568
return asset
15671569

1570+
def get_relative_path(self, path):
1571+
"""
1572+
Return a path with `requests_pathname_prefix` prefixed before it.
1573+
Use this function when specifying local URL paths that will work
1574+
in environments regardless of what `requests_pathname_prefix` is.
1575+
In some deployment environments, like Dash Enterprise,
1576+
`requests_pathname_prefix` is set to the application name,
1577+
e.g. `my-dash-app`.
1578+
When working locally, `requests_pathname_prefix` might be unset and
1579+
so a relative URL like `/page-2` can just be `/page-2`.
1580+
However, when the app is deployed to a URL like `/my-dash-app`, then
1581+
`app.get_relative_path('/page-2')` will return `/my-dash-app/page-2`.
1582+
This can be used as an alternative to `get_asset_url` as well with
1583+
`app.get_relative_path('/assets/logo.png')`
1584+
1585+
Use this function with `app.strip_relative_path` in callbacks that
1586+
deal with `dcc.Location` `pathname` routing.
1587+
That is, your usage may look like:
1588+
```
1589+
app.layout = html.Div([
1590+
dcc.Location(id='url'),
1591+
html.Div(id='content')
1592+
])
1593+
@app.callback(Output('content', 'children'), [Input('url', 'pathname')])
1594+
def display_content(path):
1595+
page_name = app.strip_relative_path(path)
1596+
if not page_name: # None or ''
1597+
return html.Div([
1598+
dcc.Link(href=app.get_relative_path('/page-1')),
1599+
dcc.Link(href=app.get_relative_path('/page-2')),
1600+
])
1601+
elif page_name == 'page-1':
1602+
return chapters.page_1
1603+
if page_name == "page-2":
1604+
return chapters.page_2
1605+
```
1606+
"""
1607+
asset = _get_relative_path(
1608+
self.config.requests_pathname_prefix,
1609+
path,
1610+
)
1611+
1612+
return asset
1613+
1614+
def strip_relative_path(self, path):
1615+
"""
1616+
Return a path with `requests_pathname_prefix` and leading and trailing
1617+
slashes stripped from it. Also, if None is passed in, None is returned.
1618+
Use this function with `get_relative_path` in callbacks that deal
1619+
with `dcc.Location` `pathname` routing.
1620+
That is, your usage may look like:
1621+
```
1622+
app.layout = html.Div([
1623+
dcc.Location(id='url'),
1624+
html.Div(id='content')
1625+
])
1626+
@app.callback(Output('content', 'children'), [Input('url', 'pathname')])
1627+
def display_content(path):
1628+
page_name = app.strip_relative_path(path)
1629+
if not page_name: # None or ''
1630+
return html.Div([
1631+
dcc.Link(href=app.get_relative_path('/page-1')),
1632+
dcc.Link(href=app.get_relative_path('/page-2')),
1633+
])
1634+
elif page_name == 'page-1':
1635+
return chapters.page_1
1636+
if page_name == "page-2":
1637+
return chapters.page_2
1638+
```
1639+
Note that `chapters.page_1` will be served if the user visits `/page-1`
1640+
_or_ `/page-1/` since `strip_relative_path` removes the trailing slash.
1641+
1642+
Also note that `strip_relative_path` is compatible with
1643+
`get_relative_path` in environments where `requests_pathname_prefix` set.
1644+
In some deployment environments, like Dash Enterprise,
1645+
`requests_pathname_prefix` is set to the application name, e.g. `my-dash-app`.
1646+
When working locally, `requests_pathname_prefix` might be unset and
1647+
so a relative URL like `/page-2` can just be `/page-2`.
1648+
However, when the app is deployed to a URL like `/my-dash-app`, then
1649+
`app.get_relative_path('/page-2')` will return `/my-dash-app/page-2`
1650+
1651+
The `pathname` property of `dcc.Location` will return '`/my-dash-app/page-2`'
1652+
to the callback.
1653+
In this case, `app.strip_relative_path('/my-dash-app/page-2')`
1654+
will return `'page-2'`
1655+
1656+
For nested URLs, slashes are still included:
1657+
`app.strip_relative_path('/page-1/sub-page-1/')` will return
1658+
`page-1/sub-page-1`
1659+
```
1660+
"""
1661+
return _strip_relative_path(
1662+
self.config.requests_pathname_prefix,
1663+
path,
1664+
)
1665+
15681666
def _setup_dev_tools(self, **kwargs):
15691667
debug = kwargs.get("debug", False)
15701668
dev_tools = self._dev_tools = _AttributeDict()

dash/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,7 @@ class SameInputOutputException(CallbackException):
9898

9999
class MissingCallbackContextException(CallbackException):
100100
pass
101+
102+
103+
class UnsupportedRelativePath(CallbackException):
104+
pass

tests/unit/test_configs.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
get_combined_config,
1313
load_dash_env_vars,
1414
)
15-
from dash._utils import get_asset_path
15+
from dash._utils import (
16+
get_asset_path,
17+
get_relative_path,
18+
strip_relative_path,
19+
)
1620

1721

1822
@pytest.fixture
@@ -156,3 +160,81 @@ def test_load_dash_env_vars_refects_to_os_environ(empty_environ):
156160
def test_app_name_server(empty_environ, name, server, expected):
157161
app = Dash(name=name, server=server)
158162
assert app.config.name == expected
163+
164+
165+
@pytest.mark.parametrize(
166+
"prefix, partial_path, expected",
167+
[
168+
("/", "", "/"),
169+
("/my-dash-app/", "", "/my-dash-app/"),
170+
171+
("/", "/", "/"),
172+
("/my-dash-app/", "/", "/my-dash-app/"),
173+
174+
("/", "/page-1", "/page-1"),
175+
("/my-dash-app/", "/page-1", "/my-dash-app/page-1"),
176+
177+
("/", "/page-1/", "/page-1/"),
178+
("/my-dash-app/", "/page-1/", "/my-dash-app/page-1/"),
179+
180+
("/", "/page-1/sub-page-1", "/page-1/sub-page-1"),
181+
("/my-dash-app/", "/page-1/sub-page-1", "/my-dash-app/page-1/sub-page-1"),
182+
]
183+
)
184+
def test_pathname_prefix_relative_url(prefix, partial_path, expected):
185+
path = get_relative_path(prefix, partial_path)
186+
assert path == expected
187+
188+
@pytest.mark.parametrize(
189+
"prefix, partial_path",
190+
[
191+
("/", "relative-page-1"),
192+
("/my-dash-app/", "relative-page-1"),
193+
]
194+
)
195+
def test_invalid_get_relative_path(prefix, partial_path):
196+
with pytest.raises(_exc.UnsupportedRelativePath):
197+
get_relative_path(prefix, partial_path)
198+
199+
@pytest.mark.parametrize(
200+
"prefix, partial_path, expected",
201+
[
202+
("/", None, None),
203+
("/my-dash-app/", None, None),
204+
205+
("/", "/", ""),
206+
("/my-dash-app/", "/my-dash-app", ""),
207+
("/my-dash-app/", "/my-dash-app/", ""),
208+
209+
("/", "/page-1", "page-1"),
210+
("/my-dash-app/", "/my-dash-app/page-1", "page-1"),
211+
212+
("/", "/page-1/", "page-1"),
213+
("/my-dash-app/", "/my-dash-app/page-1/", "page-1"),
214+
215+
("/", "/page-1/sub-page-1", "page-1/sub-page-1"),
216+
("/my-dash-app/", "/my-dash-app/page-1/sub-page-1", "page-1/sub-page-1"),
217+
218+
("/", "/page-1/sub-page-1/", "page-1/sub-page-1"),
219+
("/my-dash-app/", "/my-dash-app/page-1/sub-page-1/", "page-1/sub-page-1"),
220+
221+
("/my-dash-app/", "/my-dash-app/my-dash-app/", "my-dash-app"),
222+
("/my-dash-app/", "/my-dash-app/something-else/my-dash-app/", "something-else/my-dash-app"),
223+
]
224+
)
225+
def test_strip_relative_path(prefix, partial_path, expected):
226+
path = strip_relative_path(prefix, partial_path)
227+
assert path == expected
228+
229+
230+
@pytest.mark.parametrize(
231+
"prefix, partial_path",
232+
[
233+
("/", "relative-page-1"),
234+
("/my-dash-app", "relative-page-1"),
235+
("/my-dash-app", "/some-other-path")
236+
]
237+
)
238+
def test_invalid_strip_relative_path(prefix, partial_path):
239+
with pytest.raises(_exc.UnsupportedRelativePath):
240+
strip_relative_path(prefix, partial_path)

0 commit comments

Comments
 (0)