mirror of
https://github.com/rio-labs/rio.git
synced 2026-05-07 20:09:40 -05:00
609 lines
19 KiB
Python
609 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import typing as t
|
|
import warnings
|
|
from dataclasses import KW_ONLY, dataclass, field
|
|
from pathlib import Path
|
|
|
|
import introspection
|
|
import path_imports
|
|
from introspection import convert_case
|
|
|
|
import rio.components.error_placeholder
|
|
import rio.docs
|
|
|
|
from . import deprecations, utils
|
|
from .errors import NavigationFailed
|
|
|
|
__all__ = [
|
|
"Redirect",
|
|
"ComponentPage",
|
|
"Page",
|
|
"page",
|
|
"GuardEvent",
|
|
]
|
|
|
|
|
|
DEFAULT_ICON = "rio/logo:color"
|
|
|
|
|
|
@t.final
|
|
@dataclass(frozen=True)
|
|
class Redirect:
|
|
"""
|
|
Redirects the user to a different page.
|
|
|
|
Redirects can be added to the app in place of "real" pages. These are
|
|
useful for cases where you want to add valid links to the app, but don't
|
|
want to display a page at that URL. They redirect users to another page
|
|
the instant they would be opened.
|
|
|
|
Redirects are passed directly to the app during construction, like so:
|
|
|
|
```python
|
|
import rio
|
|
|
|
app = rio.App(
|
|
build=lambda: rio.Column(
|
|
rio.Text("Welcome to my app!"),
|
|
rio.PageView(grow_y=True),
|
|
),
|
|
pages=[
|
|
rio.ComponentPage(
|
|
name="Home",
|
|
url_segment="",
|
|
build=lambda: rio.Text("This is the home page"),
|
|
),
|
|
rio.ComponentPage(
|
|
name="Subpage",
|
|
url_segment="subpage",
|
|
build=lambda: rio.Text("This is a subpage"),
|
|
),
|
|
rio.Redirect(
|
|
url_segment="old-page",
|
|
target="/subpage",
|
|
),
|
|
],
|
|
)
|
|
|
|
app.run_in_browser()
|
|
```
|
|
"""
|
|
|
|
url_segment: str
|
|
target: str | rio.URL
|
|
|
|
|
|
@t.final
|
|
@dataclass(frozen=True)
|
|
class ComponentPage:
|
|
"""
|
|
A routable page in a Rio app.
|
|
|
|
Rio apps can consist of many pages. You might have a welcome page, a
|
|
settings page, a login, and so on. `ComponentPage` components contain all
|
|
information needed to display those pages, as well as to navigate between
|
|
them.
|
|
|
|
This is not just specific to websites. Apps might, for example, have
|
|
a settings page, a profile page, a help page, and so on.
|
|
|
|
Pages are passed directly to the app during construction, like so:
|
|
|
|
```python
|
|
import rio
|
|
|
|
app = rio.App(
|
|
build=lambda: rio.Column(
|
|
rio.Text("Welcome to my app!"),
|
|
rio.PageView(grow_y=True),
|
|
),
|
|
pages=[
|
|
rio.ComponentPage(
|
|
name="Home",
|
|
url_segment="",
|
|
build=lambda: rio.Text("This is the home page"),
|
|
),
|
|
rio.ComponentPage(
|
|
name="Subpage",
|
|
url_segment="subpage",
|
|
build=lambda: rio.Text("This is a subpage"),
|
|
),
|
|
],
|
|
)
|
|
|
|
app.run_in_browser()
|
|
```
|
|
|
|
This will display "This is the home page" when navigating to the root URL,
|
|
but "This is a subpage" when navigating to "/subpage". Note that on both
|
|
pages the text "Welcome to my page!" is displayed above the page content.
|
|
That's because it's not part of the `PageView`.
|
|
|
|
For additional details, please refer to the how-to guide:
|
|
`https://rio.dev/docs/howto/multiple-pages`.
|
|
|
|
## Attributes
|
|
|
|
`name`: A human-readable name for the page. While the page itself doesn't
|
|
use this value directly, it serves as important information for
|
|
debugging, as well as other components such as navigation bars.
|
|
|
|
`url_segment`: The URL segment at which this page should be displayed. For
|
|
example, if this is "subpage", then the page will be displayed at
|
|
"https://yourapp.com/subpage". If this is "", then the page will be
|
|
displayed at the root URL.
|
|
|
|
`build`: A callback that is called when this page is displayed. It should
|
|
return a Rio component.
|
|
|
|
`icon`: The name of an icon to associate with the page. While the page
|
|
itself doesn't use this value directly, it serves as additional
|
|
information for other components such as navigation bars.
|
|
|
|
`children`: A list of child pages. These pages will be displayed when
|
|
navigating to a sub-URL of this page. For example, if this page's
|
|
`url_segment` is "page1", and it has a child page with `url_segment`
|
|
"page2", then the child page will be displayed at
|
|
"https://yourapp.com/page1/page2".
|
|
|
|
`meta_tags`: A dictionary of meta tags to include in the page's HTML. These
|
|
are used by search engines and social media sites to display
|
|
information about your page.
|
|
|
|
`guard`: A callback that is called before this page is displayed. It
|
|
can prevent users from accessing pages which they are not allowed to
|
|
see. For example, you may want to redirect users to your login page
|
|
if they are trying to access their profile page without being
|
|
logged in.
|
|
|
|
The callback should return `None` if the user is allowed to access
|
|
the page, or a string or `rio.URL` if the user should be redirected
|
|
to a different page.
|
|
"""
|
|
|
|
name: str
|
|
url_segment: str
|
|
build: t.Callable[[], rio.Component]
|
|
_: KW_ONLY
|
|
icon: str = DEFAULT_ICON
|
|
children: t.Sequence[ComponentPage | Redirect] = field(default_factory=list)
|
|
guard: t.Callable[[rio.GuardEvent], None | rio.URL | str] | None = None
|
|
meta_tags: dict[str, str] = field(default_factory=dict)
|
|
|
|
# This is used to allow users to order pages when using the `rio.page`
|
|
# decorator. It's not public, but simply a convenient place to store this.
|
|
_page_order_: int | None = field(default=None, init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
# In Rio, URLs are case insensitive. An easy way to enforce this, and
|
|
# also prevent casing issues in the user code is to make sure the page's
|
|
# URL fragment is lowercase.
|
|
if self.url_segment != self.url_segment.lower():
|
|
raise ValueError(
|
|
f"Page URLs have to be lowercase, but `{self.url_segment}` is not"
|
|
)
|
|
|
|
if "/" in self.url_segment:
|
|
raise ValueError(f"URL segments may not contain slashes")
|
|
|
|
|
|
# Allow using the old `page_url` parameter instead of the new `url_segment`
|
|
_old_component_page_init = ComponentPage.__init__
|
|
|
|
|
|
def _new_component_page_init(self, *args, **kwargs) -> None:
|
|
# Rename the parameter
|
|
if "page_url" in kwargs:
|
|
deprecations.warn_parameter_renamed(
|
|
since="0.10",
|
|
old_name="page_url",
|
|
new_name="url_segment",
|
|
owner="rio.ComponentPage",
|
|
)
|
|
kwargs["url_segment"] = kwargs.pop("page_url")
|
|
|
|
# Call the original constructor
|
|
_old_component_page_init(self, *args, **kwargs)
|
|
|
|
|
|
ComponentPage.__init__ = _new_component_page_init
|
|
|
|
|
|
@introspection.set_signature(ComponentPage)
|
|
def Page(*args, **kwargs):
|
|
deprecations.warn(
|
|
since="0.10",
|
|
message="`rio.Page` has been renamed to `rio.ComponentPage`",
|
|
)
|
|
return ComponentPage(*args, **kwargs)
|
|
|
|
|
|
def _get_active_page_instances(
|
|
available_pages: t.Iterable[rio.ComponentPage | rio.Redirect],
|
|
remaining_segments: tuple[str, ...],
|
|
) -> list[rio.ComponentPage | rio.Redirect]:
|
|
"""
|
|
Given a list of available pages, and a URL, return the list of pages that
|
|
would be active if navigating to that URL.
|
|
"""
|
|
# Get the page responsible for this segment
|
|
try:
|
|
page_segment = remaining_segments[0]
|
|
except IndexError:
|
|
page_segment = ""
|
|
|
|
for page in available_pages:
|
|
if page.url_segment == page_segment:
|
|
break
|
|
else:
|
|
return []
|
|
|
|
active_pages = [page]
|
|
|
|
# Recurse into the children
|
|
if isinstance(page, rio.ComponentPage):
|
|
active_pages += _get_active_page_instances(
|
|
page.children,
|
|
remaining_segments[1:],
|
|
)
|
|
|
|
return active_pages
|
|
|
|
|
|
@t.final
|
|
@rio.docs.mark_constructor_as_private
|
|
@dataclass(frozen=True)
|
|
class GuardEvent:
|
|
"""
|
|
Holds information regarding a guard event.
|
|
|
|
This is a simple dataclass that stores useful information for the guard
|
|
event. They can prevent users from accessing pages which they are not
|
|
allowed to see.
|
|
|
|
## Attributes
|
|
|
|
`session`: The current session.
|
|
|
|
`active_pages`: All pages that will be active if the navigation succeeds.
|
|
"""
|
|
|
|
# The current session
|
|
session: rio.Session
|
|
|
|
# The pages that would be activated by this navigation
|
|
#
|
|
# This is an `Sequence` rather than `list`, because the same event instance
|
|
# is reused for multiple event handlers. This allows to assign a tuple, thus
|
|
# preventing modifications.
|
|
active_pages: t.Sequence[ComponentPage | Redirect]
|
|
|
|
|
|
def check_page_guards(
|
|
sess: rio.Session,
|
|
target_url_absolute: rio.URL,
|
|
) -> tuple[tuple[ComponentPage, ...], rio.URL]:
|
|
"""
|
|
Check whether navigation to the given target URL is possible.
|
|
|
|
This finds the pages that would be activated by this navigation and runs
|
|
their guards. If the guards effect a redirect, it instead attempts to
|
|
navigate to the redirect target, and so on.
|
|
|
|
Raises `NavigationFailed` if navigation to the target URL is not possible
|
|
because of an error, such as an exception in a guard.
|
|
|
|
If the URL points to a page which doesn't exist that is not considered an
|
|
error. The result will still be valid. That is because navigation is
|
|
possible, it's just that some PageViews will display a 404 page.
|
|
|
|
This function does not perform any actual navigation. It simply checks
|
|
whether navigation to the target page is possible.
|
|
"""
|
|
|
|
assert target_url_absolute.is_absolute(), target_url_absolute
|
|
|
|
# Is any guard opposed to this page?
|
|
initial_target_url = target_url_absolute
|
|
visited_redirects = {target_url_absolute}
|
|
past_redirects = [target_url_absolute]
|
|
|
|
while True:
|
|
# TODO: What if the URL is not a child of the base URL? i.e. redirecting
|
|
# to a completely different site
|
|
target_url_relative = utils.make_url_relative(
|
|
sess._base_url, target_url_absolute
|
|
)
|
|
|
|
# Find all pages which would by activated by this navigation
|
|
active_page_instances = tuple(
|
|
_get_active_page_instances(
|
|
sess.app.pages, target_url_relative.parts
|
|
)
|
|
)
|
|
|
|
# Check the guards for each activated page
|
|
redirect = None
|
|
event = GuardEvent(
|
|
session=sess,
|
|
active_pages=active_page_instances,
|
|
)
|
|
|
|
for page in active_page_instances:
|
|
if isinstance(page, Redirect):
|
|
redirect = page.target
|
|
else:
|
|
if page.guard is None:
|
|
continue
|
|
|
|
try:
|
|
redirect = page.guard(event)
|
|
except Exception as err:
|
|
message = f"Rejecting navigation to `{initial_target_url}` because of an error in a page guard of `{page.url_segment}`: {err}"
|
|
logging.exception(message)
|
|
raise NavigationFailed(message)
|
|
|
|
if redirect is not None:
|
|
break
|
|
|
|
# All guards are satisfied - done
|
|
if redirect is None:
|
|
assert all(
|
|
isinstance(page, rio.ComponentPage)
|
|
for page in active_page_instances
|
|
), active_page_instances
|
|
|
|
active_page_instances = t.cast(
|
|
tuple[ComponentPage, ...], active_page_instances
|
|
)
|
|
|
|
return active_page_instances, target_url_absolute
|
|
|
|
# A guard wants to redirect to a different page
|
|
if isinstance(redirect, str):
|
|
redirect = rio.URL(redirect)
|
|
|
|
redirect = sess._active_page_url.join(redirect)
|
|
|
|
assert redirect.is_absolute(), redirect
|
|
|
|
# Detect infinite loops and break them
|
|
if redirect in visited_redirects:
|
|
page_strings = [
|
|
str(url_segment) for url_segment in past_redirects + [redirect]
|
|
]
|
|
page_strings_list = "\n -> ".join(page_strings)
|
|
|
|
message = f"Rejecting navigation to `{initial_target_url}` because page guards have created an infinite loop:\n\n {page_strings_list}"
|
|
logging.warning(message)
|
|
raise NavigationFailed(message)
|
|
|
|
# Remember that this page has been visited before
|
|
visited_redirects.add(redirect)
|
|
past_redirects.append(redirect)
|
|
|
|
# Rinse and repeat
|
|
target_url_absolute = redirect
|
|
|
|
|
|
BuildFunction = t.Callable[[], "rio.Component"]
|
|
C = t.TypeVar("C", bound=BuildFunction)
|
|
|
|
|
|
BUILD_FUNCTIONS_FOR_PAGES = dict[BuildFunction, ComponentPage]()
|
|
|
|
|
|
def page(
|
|
*,
|
|
url_segment: str | None = None,
|
|
name: str | None = None,
|
|
icon: str = DEFAULT_ICON,
|
|
guard: t.Callable[[GuardEvent], None | rio.URL | str] | None = None,
|
|
meta_tags: dict[str, str] | None = None,
|
|
order: int | None = None,
|
|
):
|
|
"""
|
|
This decorator creates a page (complete with URL, icon, etc) that displays
|
|
the decorated component. All parameters are optional, and if omitted,
|
|
sensible defaults will be inferred based on the name of the decorated class.
|
|
|
|
In order to create a "root" page, set the `url_segment` to an empty string:
|
|
|
|
@rio.page(
|
|
url_segment="",
|
|
)
|
|
class HomePage(rio.Component):
|
|
def build(self):
|
|
return rio.Text(
|
|
"Welcome to my website",
|
|
style="heading1",
|
|
)
|
|
|
|
For additional details, please refer to the how-to guide [Multiple
|
|
Pages](https://rio.dev/docs/howto/multiple-pages).
|
|
|
|
|
|
## Parameters
|
|
|
|
`url_segment`: The URL segment at which this page should be displayed. For
|
|
example, if this is "subpage", then the page will be displayed at
|
|
"https://yourapp.com/subpage". If this is "", then the page will be
|
|
displayed at the root URL.
|
|
|
|
`name`: A human-readable name for the page. While the page itself doesn't
|
|
use this value directly, it serves as important information for
|
|
debugging, as well as other components such as navigation bars.
|
|
|
|
`icon`: The name of an icon to associate with the page. While the page
|
|
itself doesn't use this value directly, it serves as additional
|
|
information for other components such as navigation bars.
|
|
|
|
`meta_tags`: A dictionary of meta tags to include in the page's HTML. These
|
|
are used by search engines and social media sites to display
|
|
information about your page.
|
|
|
|
`guard`: A callback that is called before this page is displayed. It
|
|
can prevent users from accessing pages which they are not allowed to
|
|
see. For example, you may want to redirect users to your login page
|
|
if they are trying to access their profile page without being
|
|
logged in.
|
|
|
|
The callback should return `None` if the user is allowed to access
|
|
the page, or a string or `rio.URL` if the user should be redirected
|
|
to a different page.
|
|
|
|
`order`: An int that controls the order of this page relative to its
|
|
siblings. Similar to the `name`, this is relevant for navigation bars.
|
|
"""
|
|
|
|
def decorator(build: C) -> C:
|
|
nonlocal name, url_segment
|
|
|
|
# Derive a default name
|
|
if name is None:
|
|
name = (
|
|
convert_case(build.__name__, "snake").replace("_", " ").title()
|
|
)
|
|
|
|
# Derive a default URL segment
|
|
if url_segment is None:
|
|
url_segment = convert_case(build.__name__, "kebab").lower()
|
|
|
|
# Create the result
|
|
page = ComponentPage(
|
|
name=name,
|
|
url_segment=url_segment,
|
|
build=build,
|
|
icon=icon,
|
|
guard=guard,
|
|
meta_tags=meta_tags or {},
|
|
)
|
|
|
|
# The component page has a field specifically so this decorator can
|
|
# store the page order. However, since this is a frozen dataclass,
|
|
# contortions are needed
|
|
page.__dict__["_page_order_"] = order
|
|
|
|
# Store the result
|
|
BUILD_FUNCTIONS_FOR_PAGES[build] = page
|
|
|
|
# Return the original class
|
|
return build
|
|
|
|
return decorator
|
|
|
|
|
|
def _page_sort_key(page: rio.ComponentPage) -> tuple:
|
|
"""
|
|
Returns a key that can be used to sort pages.
|
|
"""
|
|
return (
|
|
# Put the home page first
|
|
not page.url_segment == "",
|
|
# Then sort by name
|
|
page.name,
|
|
)
|
|
|
|
|
|
def auto_detect_pages(
|
|
directory: Path,
|
|
*,
|
|
package: str | None = None,
|
|
) -> list[rio.ComponentPage]:
|
|
# Find all pages using the iterator method
|
|
pages = _auto_detect_pages_iter(directory, package=package)
|
|
|
|
# Sort them, ignoring any user-specified ordering for now
|
|
pages = sorted(pages, key=_page_sort_key)
|
|
|
|
# Now apply the user-specified ordering. This sorting is stable, hence the
|
|
# previous step.
|
|
utils.soft_sort(pages, key=lambda page: page._page_order_)
|
|
|
|
# Done
|
|
return pages
|
|
|
|
|
|
def _auto_detect_pages_iter(
|
|
directory: Path,
|
|
*,
|
|
package: str | None = None,
|
|
) -> t.Iterable[rio.ComponentPage]:
|
|
try:
|
|
contents = list(directory.iterdir())
|
|
except FileNotFoundError:
|
|
return
|
|
|
|
for file_path in contents:
|
|
if not utils.is_python_script(file_path):
|
|
continue
|
|
|
|
yield _page_from_python_file(file_path, package)
|
|
|
|
|
|
def _page_from_python_file(
|
|
file_path: Path, package: str | None
|
|
) -> ComponentPage:
|
|
module_name = file_path.stem
|
|
if package is not None:
|
|
module_name = package + "." + module_name
|
|
|
|
try:
|
|
module = path_imports.import_from_path(
|
|
file_path, module_name=module_name
|
|
)
|
|
except BaseException as error:
|
|
# Can't import the module? Display a warning and a placeholder component
|
|
warnings.warn(
|
|
f"Failed to import file '{file_path}': {type(error)} {error}"
|
|
)
|
|
page = _error_page_from_file_name(
|
|
file_path,
|
|
error_summary=f"Failed to import '{file_path}'",
|
|
error_details=f"{type(error).__name__}: {error}",
|
|
)
|
|
else:
|
|
# Search the module for the callable decorated with `@rio.page`
|
|
for obj in vars(module).values():
|
|
try:
|
|
page = BUILD_FUNCTIONS_FOR_PAGES[obj]
|
|
break
|
|
except (TypeError, KeyError):
|
|
continue
|
|
else:
|
|
# Nothing found? Display a warning and a placeholder component
|
|
warnings.warn(
|
|
f"The file {file_path} doesn't seem to contain a page"
|
|
f" definition. Did you forget to decorate your component/build"
|
|
f" function with `@rio.page(...)`?"
|
|
)
|
|
page = _error_page_from_file_name(
|
|
file_path,
|
|
error_summary=f"No page found in '{file_path}'",
|
|
error_details=f"No component in this file was decorated with `@rio.page(...)`",
|
|
)
|
|
|
|
# Add sub-pages, if any
|
|
sub_pages = t.cast(list, page.children)
|
|
sub_pages.clear() # Avoid duplicate entries if this function is called twice
|
|
sub_pages += auto_detect_pages(
|
|
file_path.with_suffix(""),
|
|
package=module_name,
|
|
)
|
|
|
|
return page
|
|
|
|
|
|
def _error_page_from_file_name(
|
|
file_path: Path, error_summary: str, error_details: str
|
|
) -> ComponentPage:
|
|
return ComponentPage(
|
|
name=convert_case(file_path.stem, "snake").replace("_", " ").title(),
|
|
url_segment=convert_case(file_path.stem, "kebab").lower(),
|
|
build=lambda: rio.components.error_placeholder.ErrorPlaceholder(
|
|
error_summary, error_details
|
|
),
|
|
)
|