Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pages for multi-page app #1947

Merged
merged 66 commits into from
Jun 2, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
9b60f92
Move pages plug in from dash-labs
AnnMarieW Feb 24, 2022
4dbf472
Change how pages feature is enabled
AnnMarieW Feb 25, 2022
d304e67
tests
AnnMarieW Feb 25, 2022
2599921
new usage page - need to add a test
AnnMarieW Mar 7, 2022
4764c9c
fixed bug: page_registry can now be updated from a callback
AnnMarieW Mar 7, 2022
18655e7
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Mar 7, 2022
e95f6a9
changelog
AnnMarieW Mar 7, 2022
12c7bf7
update-1 after review
AnnMarieW Mar 18, 2022
63abbe4
Update dash/dash.py
AnnMarieW Mar 18, 2022
1f38e4f
Merge branch 'add-pages' of https://github.com/AnnMarieW/dash into ad…
AnnMarieW Mar 18, 2022
9ee5ac0
added check for dash.page_container
AnnMarieW Mar 19, 2022
5f790cf
move validate pathnames
AnnMarieW Mar 20, 2022
fdf72b4
refactored interpolate_index
AnnMarieW Mar 20, 2022
9802893
fix so pages_folder can be renamed
AnnMarieW Mar 21, 2022
9d622ac
update 2 after review
AnnMarieW Mar 22, 2022
135be02
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Mar 22, 2022
e459d21
fixed page_container
AnnMarieW Mar 24, 2022
a935a06
Re-run tests
AnnMarieW Mar 24, 2022
399caa5
fix tests
AnnMarieW Mar 24, 2022
fcfb61b
use pages_folder name rather than hard-coded "pages"
AnnMarieW Mar 29, 2022
0487158
added default layout with page_container
AnnMarieW Mar 30, 2022
370130b
fixed test
AnnMarieW Apr 1, 2022
ef6f57e
fixed lint
AnnMarieW Apr 1, 2022
fdf1a3d
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Apr 1, 2022
aceeb9b
added test,
AnnMarieW Apr 2, 2022
8c4adc6
oops
AnnMarieW Apr 2, 2022
3bc882a
update after alexj review
AnnMarieW Apr 6, 2022
f0f81aa
Update dash/dash.py
AnnMarieW Apr 6, 2022
d3f2600
Update dash/dash.py
AnnMarieW Apr 6, 2022
c4a0b23
Update dash/dash.py
AnnMarieW Apr 6, 2022
2780bb6
Merge branch 'add-pages' of https://github.com/AnnMarieW/dash into ad…
AnnMarieW Apr 6, 2022
492bc00
added test and lint
AnnMarieW Apr 6, 2022
8f5926c
removed self.page_registry
AnnMarieW Apr 6, 2022
a205e62
more updates after review
AnnMarieW Apr 7, 2022
0ce004e
added page["relative_path"]
AnnMarieW Apr 9, 2022
556b10e
improved template matcher
AnnMarieW Apr 13, 2022
b8834b3
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Apr 13, 2022
07f70ac
refactored template matcher
AnnMarieW Apr 16, 2022
5ef4d04
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Apr 16, 2022
d93bad8
fixed test
AnnMarieW Apr 16, 2022
2e05833
added get_server()
AnnMarieW Apr 16, 2022
58810e2
lint
AnnMarieW Apr 16, 2022
0b1cd14
rerun test and updated examples
AnnMarieW Apr 17, 2022
a58e96b
updated get_server and added test
AnnMarieW Apr 21, 2022
0bc1856
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Apr 21, 2022
053653b
temp commit
AnnMarieW Apr 22, 2022
bfbf307
temp commit and merge dev
AnnMarieW Apr 22, 2022
858e71f
lint and add tests
AnnMarieW Apr 23, 2022
5f669e9
added more error checking
AnnMarieW Apr 23, 2022
56d569b
fix tests
AnnMarieW Apr 23, 2022
09d6316
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Apr 23, 2022
bbc160a
small fix
AnnMarieW Apr 24, 2022
f239b20
Allow app.layout to be callable. Update dash.get_server
AnnMarieW Apr 25, 2022
eb7293d
lint
AnnMarieW Apr 27, 2022
7b0d796
removed dash.get_server() added dash.get_app()
AnnMarieW Apr 30, 2022
5765097
fixe meta-tags
AnnMarieW May 11, 2022
cddea6b
added exception if page_registry() in callback, added tests, update …
AnnMarieW May 17, 2022
42627ca
fixed test
AnnMarieW May 18, 2022
b178e93
fixed another test
AnnMarieW May 18, 2022
e2a3363
Update dash/_configs.py
AnnMarieW Jun 1, 2022
b490523
delete usage folder
AnnMarieW Jun 1, 2022
ae47574
Merge branch 'dev' of https://github.com/plotly/dash into add-pages
AnnMarieW Jun 1, 2022
6044351
added test for no layout, minor refactoring of error messages and fun…
AnnMarieW Jun 1, 2022
a9337d4
fixed tests
AnnMarieW Jun 2, 2022
21daba0
clean up after pages no layout test, instead of before other tests
alexcjohnson Jun 2, 2022
fde2323
Merge branch 'dev' into add-pages
alexcjohnson Jun 2, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- [#1947](https://github.com/plotly/dash/pull/1947) Added `\pages` - a better way to build multi-page apps. For more informations see the [forum post.](https://community.plotly.com/t/introducing-dash-pages-a-dash-2-x-feature-preview/57775)

### Fixed

- [#1963](https://github.com/plotly/dash/pull/1963) Fix [#1780](https://github.com/plotly/dash/issues/1780) flask shutdown deprecation warning when running dashduo threaded tests.
Expand Down
12 changes: 11 additions & 1 deletion dash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# __plotly_dash is for the "make sure you don't have a dash.py" check
# must come before any other imports.
__plotly_dash = True
from .dash import Dash, no_update # noqa: F401,E402
from .dependencies import ( # noqa: F401,E402
Input, # noqa: F401,E402
Output, # noqa: F401,E402
Expand All @@ -26,3 +25,14 @@
get_relative_path,
strip_relative_path,
)

from ._pages import register_page, PAGE_REGISTRY as page_registry # noqa: F401,E402
from .dash import ( # noqa: F401,E402
Dash,
no_update,
page_container,
_ID_DUMMY,
_ID_LOCATION,
_ID_STORE,
_ID_CONTENT,
AnnMarieW marked this conversation as resolved.
Show resolved Hide resolved
)
268 changes: 268 additions & 0 deletions dash/_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import os
from os import listdir
from os.path import isfile, join
import collections
from urllib.parse import parse_qs
from . import _validate
from ._utils import AttributeDict


CONFIG = AttributeDict()
PAGE_REGISTRY = collections.OrderedDict()


def _infer_image(module):
"""
Return:
- A page specific image: `assets/<title>.<extension>` is used, e.g. `assets/weekly_analytics.png`
- A generic app image at `assets/app.<extension>`
- A logo at `assets/logo.<extension>`
"""
assets_folder = CONFIG.assets_folder
valid_extensions = ["apng", "avif", "gif", "jpeg", "png", "webp"]
AnnMarieW marked this conversation as resolved.
Show resolved Hide resolved
page_id = module.split(".")[-1]
files_in_assets = []

if os.path.exists(assets_folder):
files_in_assets = [
f for f in listdir(assets_folder) if isfile(join(assets_folder, f))
]
app_file = None
logo_file = None
for fn in files_in_assets:
fn_without_extension, _, extension = fn.partition(".")
if extension.lower() in valid_extensions:
if (
fn_without_extension == page_id
or fn_without_extension == page_id.replace("_", "-")
):
return fn

if fn_without_extension == "app":
app_file = fn

if fn_without_extension == "logo":
logo_file = fn

if app_file:
return app_file

return logo_file


def _filename_to_name(filename):
return filename.split(".")[-1].replace("_", " ").capitalize()


def _infer_path(filename, template):
if template is None:
path = filename.replace("_", "-").replace(".", "/").lower().split("pages")[-1]
path = "/" + path if not path.startswith("/") else path
return path
# replace the variables in the template with "none" to create a default path if no path is supplied
path_segments = template.split("/")
default_template_path = ["none" if s.startswith("<") else s for s in path_segments]
return "/".join(default_template_path)
AnnMarieW marked this conversation as resolved.
Show resolved Hide resolved


def _parse_query_string(search):
if search and len(search) > 0 and search[0] == "?":
search = search[1:]
else:
return {}

parsed_qs = {}
for (k, v) in parse_qs(search).items():
v = v[0] if len(v) == 1 else v
parsed_qs[k] = v
return parsed_qs


def _parse_path_variables(pathname, path_template):
"""
creates the dict of path variables passed to the layout
e.g. path_template= "/asset/<asset_id>"
if pathname provided by the browser is "/assets/a100"
returns **{"asset_id": "a100"}
"""
path_segments = pathname.split("/")
template_segments = path_template.split("/")

if len(path_segments) != len(template_segments):
return None

path_vars = {}
for path_segment, template_segment in zip(path_segments, template_segments):
if template_segment.startswith("<"):
path_vars[template_segment[1:-1]] = path_segment
elif template_segment != path_segment:
return None
return path_vars


def register_page(
module,
path=None,
path_template=None,
name=None,
order=None,
title=None,
description=None,
image=None,
redirect_from=None,
layout=None,
**kwargs,
):
"""
Assigns the variables to `dash.page_registry` as an `OrderedDict`
(ordered by `order`).

`dash.page_registry` is used by `pages_plugin` to set up the layouts as
a multi-page Dash app. This includes the URL routing callbacks
(using `dcc.Location`) and the HTML templates to include title,
meta description, and the meta description image.

`dash.page_registry` can also be used by Dash developers to create the
page navigation links or by template authors.

- `module`:
The module path where this page's `layout` is defined. Often `__name__`.

- `path`:
URL Path, e.g. `/` or `/home-page`.
If not supplied, will be inferred from the `path_template` or `module`,
e.g. based on path_template: `/asset/<asset_id` to `/asset/none`
e.g. based on module: `pages.weekly_analytics` to `/weekly-analytics`

- `path_template`:
Add variables to a URL by marking sections with <variable_name>. The layout function
then receives the <variable_name> as a keyword argument.
e.g. path_template= "/asset/<asset_id>"
then if pathname in browser is "/assets/a100" then layout will receive **{"asset_id":"a100"}

- `name`:
The name of the link.
If not supplied, will be inferred from `module`,
e.g. `pages.weekly_analytics` to `Weekly analytics`

- `order`:
The order of the pages in `page_registry`.
If not supplied, then the filename is used and the page with path `/` has
order `0`

- `title`:
(string or function) The name of the page <title>. That is, what appears in the browser title.
If not supplied, will use the supplied `name` or will be inferred by module,
e.g. `pages.weekly_analytics` to `Weekly analytics`

- `description`:
(string or function) The <meta type="description"></meta>.
If not supplied, then nothing is supplied.

- `image`:
The meta description image used by social media platforms.
If not supplied, then it looks for the following images in `assets/`:
- A page specific image: `assets/<title>.<extension>` is used, e.g. `assets/weekly_analytics.png`
alexcjohnson marked this conversation as resolved.
Show resolved Hide resolved
- A generic app image at `assets/app.<extension>`
- A logo at `assets/logo.<extension>`
When inferring the image file, it will look for the following extensions: APNG, AVIF, GIF, JPEG, PNG, SVG, WebP.
AnnMarieW marked this conversation as resolved.
Show resolved Hide resolved

- `redirect_from`:
A list of paths that should redirect to this page.
For example: `redirect_from=['/v2', '/v3']`

- `layout`:
The layout function or component for this page.
If not supplied, then looks for `layout` from within the supplied `module`.

- `**kwargs`:
Arbitrary keyword arguments that can be stored

***

`page_registry` stores the original property that was passed in under
`supplied_<property>` and the coerced property under `<property>`.
For example, if this was called:
```
register_page(
'pages.historical_outlook',
name='Our historical view',
custom_key='custom value'
)
```
Then this will appear in `page_registry`:
```
OrderedDict([
(
'pages.historical_outlook',
dict(
module='pages.historical_outlook',

supplied_path=None,
path='/historical-outlook',

supplied_name='Our historical view',
name='Our historical view',

supplied_title=None,
title='Our historical view'

supplied_layout=None,
layout=<function pages.historical_outlook.layout>,

custom_key='custom value'
)
),
])
```
"""
# COERCE
# - Set the order
# - Inferred paths
page = dict(
module=module,
supplied_path=path,
path_template=_validate.validate_template(path_template),
path=path if path is not None else _infer_path(module, path_template),
supplied_name=name,
name=name if name is not None else _filename_to_name(module),
)
page.update(
supplied_title=title,
title=(title if title is not None else page["name"]),
)
page.update(
description=description if description else "",
order=order,
supplied_order=order,
supplied_layout=layout,
**kwargs,
)
page.update(
image=(image if image is not None else _infer_image(module)),
supplied_image=image,
)
page.update(redirect_from=redirect_from)

PAGE_REGISTRY[module] = page

if layout is not None:
# Override the layout found in the file set during `plug`
PAGE_REGISTRY[module]["layout"] = layout

# set home page order
order_supplied = any(
p["supplied_order"] is not None for p in PAGE_REGISTRY.values()
)

for p in PAGE_REGISTRY.values():
p["order"] = (
0 if p["path"] == "/" and not order_supplied else p["supplied_order"]
)

# Sort by order and module, then by module
for page in sorted(
PAGE_REGISTRY.values(),
key=lambda i: (str(i.get("order", i["module"])), i["module"]),
):
PAGE_REGISTRY.move_to_end(page["module"])
53 changes: 53 additions & 0 deletions dash/_validate.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
from collections.abc import MutableSequence
import re
from textwrap import dedent
from keyword import iskeyword
import warnings

from ._grouping import grouping_len, map_grouping
from .development.base_component import Component
from . import exceptions
from ._utils import patch_collections_abc, stringify_id, to_json


def warning_message(message, category, _, __, ___):
return f"{category.__name__}:\n {message} \n"


warnings.formatwarning = warning_message


def validate_callback(outputs, inputs, state, extra_args, types):
Input, Output, State = types
if extra_args:
Expand Down Expand Up @@ -426,3 +435,47 @@ def validate_layout(layout, layout_value):
)
)
component_ids.add(component_id)


def validate_template(template):
if template is None:
return None
template_segments = template.split("/")
for s in template_segments:
if "<" in s or ">" in s:
if not (s.startswith("<") and s.endswith(">")):
raise Exception(
f'Invalid `path_template`: "{template}" Path segments with variables must be formatted as <variable_name>'
)
variable_name = s[1:-1]
if not variable_name.isidentifier() or iskeyword(variable_name):
warnings.warn(
f'`{variable_name}` is not a valid Python variable name in `path_template`: "{template}".',
stacklevel=2,
)
return template


def check_for_duplicate_pathnames(self):
path_to_module = {}
for page in self.page_registry.values():
if page["path"] not in path_to_module:
path_to_module[page["path"]] = [page["module"]]
else:
path_to_module[page["path"]].append(page["module"])

for modules in path_to_module.values():
if len(modules) > 1:
raise Exception(f"modules {modules} have duplicate paths")


def validate_pages_layout(module, page):
try:
getattr(page, "layout")
except AttributeError:
raise exceptions.NoLayoutException(
f"""
No layout found in {module + ".py"}
A variable or a function named "layout" is required.
"""
)
Loading