mirror of
https://github.com/rio-labs/rio.git
synced 2026-05-07 03:49:30 -05:00
support submodule as main_module
This commit is contained in:
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"crawlerdetect~=0.1.7",
|
||||
"ordered-set>=4.1.0",
|
||||
"imy[docstrings]>=0.4.0",
|
||||
"path-imports>=1.1.1",
|
||||
]
|
||||
requires-python = ">= 3.10"
|
||||
readme = "README.md"
|
||||
@@ -106,6 +107,7 @@ dev-dependencies = [
|
||||
"ruff>=0.4.7",
|
||||
"selenium>=4.22",
|
||||
"hatch>=1.11.1",
|
||||
"pyfakefs>=5.7.1",
|
||||
]
|
||||
managed = true
|
||||
|
||||
|
||||
+179
-83
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -14,7 +15,7 @@ import uvicorn
|
||||
import __main__
|
||||
import rio
|
||||
|
||||
from . import assets, maybes, routing, utils
|
||||
from . import assets, global_state, maybes, routing, utils
|
||||
from .app_server import fastapi_server
|
||||
from .utils import ImageLike
|
||||
|
||||
@@ -129,8 +130,8 @@ class App:
|
||||
# Type hints so the documentation generator knows which fields exist
|
||||
name: str
|
||||
description: str
|
||||
pages: tuple[rio.ComponentPage | rio.Redirect, ...]
|
||||
assets_dir: Path
|
||||
pages: t.Sequence[rio.ComponentPage | rio.Redirect]
|
||||
meta_tags: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
@@ -150,7 +151,7 @@ class App:
|
||||
on_session_close: rio.EventHandler[rio.Session] = None,
|
||||
default_attachments: t.Iterable[t.Any] = (),
|
||||
ping_pong_interval: int | float | timedelta = timedelta(seconds=50),
|
||||
assets_dir: str | os.PathLike = "assets",
|
||||
assets_dir: str | os.PathLike | None = None,
|
||||
theme: rio.Theme | tuple[rio.Theme, rio.Theme] | None = None,
|
||||
build_connection_lost_message: t.Callable[
|
||||
[], rio.Component
|
||||
@@ -252,10 +253,13 @@ class App:
|
||||
media sites to display information about your page, such as the
|
||||
title and a short description.
|
||||
"""
|
||||
self._main_file = _get_main_file()
|
||||
|
||||
if name is None:
|
||||
name = _get_default_app_name(self._main_file)
|
||||
# A common mistake is to pass types instead of instances to
|
||||
# `default_attachments`. Catch that, scream and die.
|
||||
for attachment in default_attachments:
|
||||
if isinstance(attachment, type):
|
||||
raise TypeError(
|
||||
f"Default attachments should be instances, not types. Did you mean to type `{attachment.__name__}()`?"
|
||||
)
|
||||
|
||||
if description is None:
|
||||
description = "A Rio web-app written in 100% Python"
|
||||
@@ -269,36 +273,32 @@ class App:
|
||||
if theme is None:
|
||||
theme = rio.Theme.from_colors()
|
||||
|
||||
# A common mistake is to pass types instead of instances to
|
||||
# `default_attachments`. Catch that, scream and die.
|
||||
for attachment in default_attachments:
|
||||
if isinstance(attachment, type):
|
||||
raise TypeError(
|
||||
f"Default attachments should be instances, not types. Did you mean to type `{attachment.__name__}()`?"
|
||||
)
|
||||
if name is None:
|
||||
name = self._infer_app_name()
|
||||
|
||||
# The `main_file` isn't detected correctly if the app is launched via
|
||||
# `rio run`. We'll store the user input so that `rio run` can fix the
|
||||
# assets dir.
|
||||
self._assets_dir = assets_dir
|
||||
self._compute_assets_dir()
|
||||
if assets_dir is None:
|
||||
assets_dir = self._infer_assets_dir()
|
||||
else:
|
||||
assets_dir = Path(assets_dir)
|
||||
|
||||
# Similarly, we can't auto-detect the pages until we know where the
|
||||
# user's project is located.
|
||||
self._raw_pages = pages
|
||||
self.pages = ()
|
||||
if pages is None:
|
||||
pages = self._infer_pages()
|
||||
elif isinstance(pages, (os.PathLike, str)):
|
||||
pages = routing.auto_detect_pages(Path(pages))
|
||||
else:
|
||||
pages = list(pages)
|
||||
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.assets_dir = assets_dir
|
||||
self.pages = pages
|
||||
self._build = build
|
||||
self._icon = assets.Asset.from_image(icon)
|
||||
self._on_app_start = on_app_start
|
||||
self._on_app_close = on_app_close
|
||||
self._on_session_start = on_session_start
|
||||
self._on_session_close = on_session_close
|
||||
self.default_attachments: t.MutableSequence[t.Any] = list(
|
||||
default_attachments
|
||||
)
|
||||
self.default_attachments = list(default_attachments)
|
||||
self._theme = theme
|
||||
self._build_connection_lost_message = build_connection_lost_message
|
||||
self._custom_meta_tags = meta_tags
|
||||
@@ -308,30 +308,165 @@ class App:
|
||||
else:
|
||||
self._ping_pong_interval = timedelta(seconds=ping_pong_interval)
|
||||
|
||||
@property
|
||||
def _module_path(self) -> Path:
|
||||
if utils.is_python_script(self._main_file):
|
||||
return self._main_file.parent
|
||||
def _infer_app_name(self) -> str:
|
||||
main_file_path = self._main_file_path
|
||||
|
||||
name = main_file_path.stem
|
||||
if name in ("main", "__main__", "__init__"):
|
||||
name = main_file_path.absolute().parent.name
|
||||
|
||||
return name.replace("_", " ").title()
|
||||
|
||||
def _infer_assets_dir(self) -> Path:
|
||||
# If the "main_file" is a sub-module, then it's unclear where the assets
|
||||
# directory would be located - it could be a sibling of the main file,
|
||||
# or it could be at the root of the package hierarchy. I don't want to
|
||||
# enforce a specific location, so we'll just loop through all the
|
||||
# packages and see if any of them contain an "assets" folder.
|
||||
|
||||
for directory in self._packages_in_project:
|
||||
assets_dir = directory / "assets"
|
||||
|
||||
if assets_dir.is_dir():
|
||||
return assets_dir
|
||||
|
||||
# No luck in any of the package folders? Try the project folder as well
|
||||
assets_dir = self._project_dir / "assets"
|
||||
if assets_dir.is_dir():
|
||||
return assets_dir
|
||||
|
||||
# No "assets" folder in the project directory? Then just use the project
|
||||
# directory itself.
|
||||
return self._project_dir
|
||||
|
||||
def _infer_pages(self) -> list[rio.ComponentPage]:
|
||||
# Similar to the assets_dir, we don't want to enforce a specific
|
||||
# location for the `pages` folder. Scan all the packages in the project.
|
||||
#
|
||||
# As a failsafe, we also allow a `pages` package directly in the project
|
||||
# directory.
|
||||
for directory in (*self._packages_in_project, self._project_dir):
|
||||
pages_dir = directory / "pages"
|
||||
if not pages_dir.exists():
|
||||
continue
|
||||
|
||||
# Now we know the location of the `pages` folder, but in order to
|
||||
# import it correctly we must also know its module name.
|
||||
location_in_package = pages_dir.relative_to(self._project_dir)
|
||||
module_name = ".".join(location_in_package.parts)
|
||||
|
||||
return routing.auto_detect_pages(pages_dir, package=module_name)
|
||||
|
||||
# No `pages` folder found? No pages, then.
|
||||
|
||||
# TODO: Throw an error? Display a warning?
|
||||
return []
|
||||
|
||||
@functools.cached_property
|
||||
def _main_file_path(self) -> Path:
|
||||
if global_state.rio_run_app_module_path is not None:
|
||||
main_file = global_state.rio_run_app_module_path
|
||||
else:
|
||||
return self._main_file
|
||||
try:
|
||||
main_file = Path(__main__.__file__)
|
||||
except AttributeError:
|
||||
main_file = Path(sys.argv[0])
|
||||
|
||||
def _compute_assets_dir(self) -> None:
|
||||
self.assets_dir = self._module_path / self._assets_dir
|
||||
# Find out if we're being executed by uvicorn
|
||||
if (
|
||||
main_file.name != "__main__.py"
|
||||
or main_file.parent != Path(uvicorn.__file__).parent
|
||||
):
|
||||
return main_file
|
||||
|
||||
def _load_pages(self) -> None:
|
||||
pages: t.Iterable[rio.ComponentPage | rio.Redirect]
|
||||
# Find out from which module uvicorn imported the app
|
||||
try:
|
||||
app_location = next(arg for arg in sys.argv[1:] if ":" in arg)
|
||||
except StopIteration:
|
||||
return main_file
|
||||
|
||||
if self._raw_pages is None:
|
||||
pages = routing.auto_detect_pages(
|
||||
self._module_path / "pages",
|
||||
package=f"{self._module_path.stem}.pages",
|
||||
)
|
||||
elif isinstance(self._raw_pages, (os.PathLike, str)):
|
||||
pages = routing.auto_detect_pages(Path(self._raw_pages))
|
||||
module_name, _, _ = app_location.partition(":")
|
||||
module = sys.modules[module_name]
|
||||
|
||||
if module.__file__ is not None:
|
||||
return Path(module.__file__)
|
||||
|
||||
return main_file
|
||||
|
||||
@functools.cached_property
|
||||
def _packages_in_project(self) -> tuple[Path, ...]:
|
||||
"""
|
||||
Returns a list of all package directories from the "main_file" up to the
|
||||
topmost package that contains it.
|
||||
"""
|
||||
result = list[Path]()
|
||||
|
||||
if utils.is_python_script(self._main_file_path):
|
||||
package_path = self._main_file_path.parent
|
||||
else:
|
||||
pages = self._raw_pages # type: ignore (wtf?)
|
||||
package_path = self._main_file_path
|
||||
|
||||
self.pages = tuple(pages)
|
||||
# The "main file" might be a sub-module. Try to find the package root.
|
||||
while True:
|
||||
result.append(package_path)
|
||||
|
||||
parent_dir = package_path.parent
|
||||
|
||||
# If we've reached the root of the file system, stop looping
|
||||
if parent_dir == package_path:
|
||||
break
|
||||
|
||||
# If the parent folder contains an `__init__.py` file, go up another
|
||||
# level
|
||||
if not (parent_dir / "__init__.py").is_file():
|
||||
break
|
||||
|
||||
package_path = parent_dir
|
||||
|
||||
return tuple(result)
|
||||
|
||||
@functools.cached_property
|
||||
def _package_root_path(self) -> Path:
|
||||
"""
|
||||
This is the path to the project's topmost package directory, or if no
|
||||
package exists, the directory that contains the "main file".
|
||||
"""
|
||||
if utils.is_python_script(self._main_file_path):
|
||||
package_path = self._main_file_path.parent
|
||||
else:
|
||||
package_path = self._main_file_path
|
||||
|
||||
# The "main file" might be a sub-module. Try to find the package root.
|
||||
while True:
|
||||
parent_dir = package_path.parent
|
||||
|
||||
# If we've reached the root of the file system, stop looping
|
||||
if parent_dir == package_path:
|
||||
break
|
||||
|
||||
# If the parent folder contains an `__init__.py` file, go up another
|
||||
# level
|
||||
if not (parent_dir / "__init__.py").is_file():
|
||||
break
|
||||
|
||||
package_path = parent_dir
|
||||
|
||||
return package_path
|
||||
|
||||
@functools.cached_property
|
||||
def _project_dir(self) -> Path:
|
||||
# Careful: `self._package_root_path` may or may not be a package. If
|
||||
# it's not a package, then it's actually the project directory (or
|
||||
# `src`).
|
||||
if (self._package_root_path / "__init__.py").is_file():
|
||||
project_dir = self._package_root_path.parent
|
||||
else:
|
||||
project_dir = self._package_root_path
|
||||
|
||||
if project_dir.name == "src":
|
||||
project_dir = project_dir.parent
|
||||
|
||||
return project_dir
|
||||
|
||||
def _as_fastapi(
|
||||
self,
|
||||
@@ -354,9 +489,6 @@ class App:
|
||||
if isinstance(base_url, str):
|
||||
base_url = rio.URL(base_url)
|
||||
|
||||
# We're starting! We can't delay loading the pages any longer.
|
||||
self._load_pages()
|
||||
|
||||
# Build the fastapi instance
|
||||
return fastapi_server.FastapiServer(
|
||||
self,
|
||||
@@ -699,39 +831,3 @@ pixels_per_rem
|
||||
|
||||
server.should_exit = True
|
||||
server_thread.join()
|
||||
|
||||
|
||||
def _get_main_file() -> Path:
|
||||
try:
|
||||
main_file = Path(__main__.__file__)
|
||||
except AttributeError:
|
||||
main_file = Path(sys.argv[0])
|
||||
|
||||
# Find out if we're being executed by uvicorn
|
||||
if (
|
||||
main_file.name != "__main__.py"
|
||||
or main_file.parent != Path(uvicorn.__file__).parent
|
||||
):
|
||||
return main_file
|
||||
|
||||
# Find out from which module uvicorn imported the app
|
||||
try:
|
||||
app_location = next(arg for arg in sys.argv[1:] if ":" in arg)
|
||||
except StopIteration:
|
||||
return main_file
|
||||
|
||||
module_name, _, _ = app_location.partition(":")
|
||||
module = sys.modules[module_name]
|
||||
|
||||
if module.__file__ is None:
|
||||
return main_file
|
||||
|
||||
return Path(module.__file__)
|
||||
|
||||
|
||||
def _get_default_app_name(main_file: Path) -> str:
|
||||
name = main_file.stem
|
||||
if name in ("main", "__main__", "__init__"):
|
||||
name = main_file.absolute().parent.stem
|
||||
|
||||
return name.replace("_", " ").title()
|
||||
|
||||
@@ -60,9 +60,7 @@ CRAWLER_DETECTOR = crawlerdetect.CrawlerDetect()
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _build_sitemap(base_url: rio.URL, app: rio.App) -> str:
|
||||
# Find all pages to add
|
||||
page_urls = {
|
||||
rio.URL(""),
|
||||
}
|
||||
page_urls = {rio.URL("")}
|
||||
|
||||
def worker(
|
||||
parent_url: rio.URL,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import functools
|
||||
import html
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
@@ -8,9 +7,11 @@ import types
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
import path_imports
|
||||
import revel
|
||||
|
||||
import rio
|
||||
import rio.global_state
|
||||
from rio import icon_registry
|
||||
|
||||
from ... import project_config
|
||||
@@ -189,21 +190,24 @@ def import_app_module(
|
||||
for module_name in modules_in_directory(proj.project_directory):
|
||||
del sys.modules[module_name]
|
||||
|
||||
# Inject the module path into `sys.path`. We add it at the start so that it
|
||||
# takes priority over all other modules. (Example: If someone names their
|
||||
# project "test", we don't end up importing python's builtin `test` module
|
||||
# on accident.)
|
||||
main_module_path = str(proj.app_main_module_path.parent)
|
||||
sys.path.insert(0, main_module_path)
|
||||
# Explicitly tell the app what the "main file" is, because otherwise it
|
||||
# would be detected incorrectly.
|
||||
rio.global_state.rio_run_app_module_path = proj.app_main_module_path
|
||||
|
||||
# Now (re-)import the app module. There is no need to import all the other
|
||||
# modules here, since they'll be re-imported as needed by the app module.
|
||||
try:
|
||||
return importlib.import_module(proj.app_main_module)
|
||||
|
||||
# Finally, clean up `sys.path` again
|
||||
return path_imports.import_from_path(
|
||||
proj.app_main_module_path,
|
||||
proj.app_main_module,
|
||||
import_parent_modules=True,
|
||||
# Newbies often don't organize their code as a single module, so to
|
||||
# guarantee that all their files can be imported, we'll add the
|
||||
# relevant directory to `sys.path`
|
||||
add_parent_directory_to_sys_path=True,
|
||||
)
|
||||
finally:
|
||||
sys.path.remove(main_module_path)
|
||||
rio.global_state.rio_run_app_module_path = None
|
||||
|
||||
|
||||
def load_user_app(proj: project_config.RioProjectConfig) -> rio.App:
|
||||
@@ -252,19 +256,4 @@ def load_user_app(proj: project_config.RioProjectConfig) -> rio.App:
|
||||
f"{main_file_reference} defines multiple Rio apps: {variables_string}. Please make sure there is exactly one."
|
||||
)
|
||||
|
||||
app = apps[0][1]
|
||||
|
||||
# Explicitly set the project location because it can't reliably be
|
||||
# auto-detected. This also affects the assets_dir and the implicit page
|
||||
# loading.
|
||||
try:
|
||||
app._main_file = proj.app_main_module_path
|
||||
except FileNotFoundError as error:
|
||||
raise AppLoadError(str(error))
|
||||
|
||||
app._compute_assets_dir()
|
||||
|
||||
app._load_pages()
|
||||
app._raw_pages = app.pages # Prevent auto_detect_pages() from running twice
|
||||
|
||||
return app
|
||||
return apps[0][1]
|
||||
|
||||
@@ -100,9 +100,10 @@ class Arbiter:
|
||||
|
||||
# The app to use for creating apps. This keeps the theme consistent if
|
||||
# for-example the user's app crashes and then a mock-app is injected.
|
||||
self._app_theme: t.Union[rio.Theme, tuple[rio.Theme, rio.Theme]] = (
|
||||
rio.Theme.pair_from_colors()
|
||||
)
|
||||
self._app_theme: t.Union[
|
||||
rio.Theme,
|
||||
tuple[rio.Theme, rio.Theme],
|
||||
] = rio.Theme.pair_from_colors()
|
||||
|
||||
# Prefer to consistently run on the same port, as that makes it easier
|
||||
# to connect to - this way old browser tabs don't get invalidated
|
||||
|
||||
@@ -93,7 +93,7 @@ class ComponentMeta(RioDataclassMeta):
|
||||
continue
|
||||
|
||||
try:
|
||||
events = member._rio_events_
|
||||
events = member._rio_events_ # type: ignore
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def default_fallback_build(sess: rio.Session) -> rio.Component:
|
||||
def default_fallback_build(
|
||||
sess: rio.Session,
|
||||
) -> rio.Component:
|
||||
thm = sess.theme
|
||||
|
||||
return rio.Column(
|
||||
|
||||
@@ -70,7 +70,7 @@ class LayoutExplainer:
|
||||
increase_height: list[str]
|
||||
|
||||
_layout: rio.data_models.ComponentLayout
|
||||
_parent_layout: Optional[rio.data_models.ComponentLayout]
|
||||
_parent_layout: rio.data_models.ComponentLayout | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
raise ValueError(
|
||||
|
||||
@@ -47,7 +47,8 @@ def specialized(func: t.Callable[P, R]) -> t.Callable[P, R]:
|
||||
# Special case: A lot of containers behave in the same way - they pass
|
||||
# on all space. Avoid having to implement them all separately.
|
||||
if type(component) in FULL_SIZE_SINGLE_CONTAINERS or not isinstance(
|
||||
component, rio.components.fundamental_component.FundamentalComponent
|
||||
component,
|
||||
rio.components.fundamental_component.FundamentalComponent,
|
||||
):
|
||||
function_name = f"{func.__name__}_SingleContainer"
|
||||
else:
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ from .warnings import *
|
||||
# The alias here is necessary to avoid ruff stupidly replacing the import with
|
||||
# a `pass`.
|
||||
if t.TYPE_CHECKING:
|
||||
import rio as rio
|
||||
import rio
|
||||
|
||||
__all__ = [
|
||||
"deprecated",
|
||||
|
||||
+6
-3
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import rio
|
||||
|
||||
__all__ = [
|
||||
"currently_building_component",
|
||||
]
|
||||
# When launched via `rio run`, the usual ways to detect the "main file" (`import
|
||||
# __main__` and `sys.argv`) don't work. So `rio run` explicitly tells us what
|
||||
# the "main file" is by setting this variable.
|
||||
rio_run_app_module_path: Path | None = None
|
||||
|
||||
|
||||
# Before a component is built, this value is set to that component. This allows
|
||||
|
||||
@@ -36,7 +36,7 @@ class SocketWrapper:
|
||||
def _call_connection_lost(
|
||||
wrapped_func, self: _ProactorBasePipeTransport, *args, **kwargs
|
||||
):
|
||||
if not hasattr(self._sock, "shutdown"):
|
||||
if not hasattr(self._sock, "shutdown"): # type: ignore
|
||||
wrapped_func(self, *args, **kwargs)
|
||||
return
|
||||
|
||||
|
||||
@@ -186,18 +186,20 @@ class RioProjectConfig:
|
||||
@functools.cached_property
|
||||
def app_main_module_path(self) -> Path:
|
||||
"""
|
||||
The path to the project's root Python module. This is the module which
|
||||
The path to the project's main Python module. This is the module which
|
||||
exposes a `rio.App` instance which is used to start the app.
|
||||
"""
|
||||
# If a `src` folder exists, look there first
|
||||
for folder in (self.project_directory / "src", self.project_directory):
|
||||
*parent_modules, module_name = self.app_main_module.split(".")
|
||||
|
||||
# If a `src` folder exists, look there as well
|
||||
for folder in (self.project_directory, self.project_directory / "src"):
|
||||
# If a package (folder) exists, use that
|
||||
module_path = folder / self.app_main_module
|
||||
module_path = folder.joinpath(*parent_modules, module_name)
|
||||
if module_path.is_dir():
|
||||
return module_path
|
||||
|
||||
# If a .py file exists, use that
|
||||
module_path = folder / (self.app_main_module + ".py")
|
||||
module_path = folder.joinpath(*parent_modules, module_name + ".py")
|
||||
if module_path.is_file():
|
||||
return module_path
|
||||
|
||||
|
||||
+6
-8
@@ -7,6 +7,7 @@ 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
|
||||
@@ -512,15 +513,10 @@ def auto_detect_pages(
|
||||
package: str | None = None,
|
||||
) -> list[rio.ComponentPage]:
|
||||
# Find all pages using the iterator method
|
||||
pages = list(
|
||||
_auto_detect_pages_iter(
|
||||
directory,
|
||||
package=package,
|
||||
)
|
||||
)
|
||||
pages = _auto_detect_pages_iter(directory, package=package)
|
||||
|
||||
# Sort them, ignoring any user-specified ordering for now
|
||||
pages.sort(key=_page_sort_key)
|
||||
pages = sorted(pages, key=_page_sort_key)
|
||||
|
||||
# Now apply the user-specified ordering. This sorting is stable, hence the
|
||||
# previous step.
|
||||
@@ -555,7 +551,9 @@ def _page_from_python_file(
|
||||
module_name = package + "." + module_name
|
||||
|
||||
try:
|
||||
module = utils.load_module_from_path(file_path, module_name=module_name)
|
||||
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(
|
||||
|
||||
+12
-5
@@ -218,13 +218,15 @@ class Session(unicall.Unicall):
|
||||
# The methods don't have the component bound yet, so they don't unduly
|
||||
# prevent the component from being garbage collected.
|
||||
self._page_change_callbacks: weakref.WeakKeyDictionary[
|
||||
rio.Component, tuple[t.Callable[[rio.Component], None], ...]
|
||||
rio.Component,
|
||||
tuple[t.Callable[[rio.Component], None], ...],
|
||||
] = weakref.WeakKeyDictionary()
|
||||
|
||||
# All components / methods which should be called when the session's
|
||||
# window size has changed.
|
||||
self._on_window_size_change_callbacks: weakref.WeakKeyDictionary[
|
||||
rio.Component, tuple[t.Callable[[rio.Component], None], ...]
|
||||
rio.Component,
|
||||
tuple[t.Callable[[rio.Component], None], ...],
|
||||
] = weakref.WeakKeyDictionary()
|
||||
|
||||
# All fonts which have been registered with the session. This maps the
|
||||
@@ -988,7 +990,9 @@ window.history.{method}(null, "", {json.dumps(str(active_page_url.relative()))})
|
||||
def _refresh_sync(
|
||||
self,
|
||||
) -> tuple[
|
||||
set[rio.Component], t.Iterable[rio.Component], t.Iterable[rio.Component]
|
||||
set[rio.Component],
|
||||
t.Iterable[rio.Component],
|
||||
t.Iterable[rio.Component],
|
||||
]:
|
||||
"""
|
||||
See `refresh` for details on what this function does.
|
||||
@@ -1635,7 +1639,9 @@ window.history.{method}(null, "", {json.dumps(str(active_page_url.relative()))})
|
||||
old_component: rio.Component,
|
||||
new_component: rio.Component,
|
||||
) -> None:
|
||||
def _extract_components(attr: object) -> list[rio.Component]:
|
||||
def _extract_components(
|
||||
attr: object,
|
||||
) -> list[rio.Component]:
|
||||
if isinstance(attr, rio.Component):
|
||||
return [attr]
|
||||
|
||||
@@ -1678,7 +1684,8 @@ window.history.{method}(null, "", {json.dumps(str(active_page_url.relative()))})
|
||||
)
|
||||
|
||||
def worker(
|
||||
old_component: rio.Component, new_component: rio.Component
|
||||
old_component: rio.Component,
|
||||
new_component: rio.Component,
|
||||
) -> None:
|
||||
# If a component was passed to a container, it is possible that the
|
||||
# container returns the same instance of that component in multiple
|
||||
|
||||
+5
-1
@@ -157,7 +157,11 @@ class CryptoCard(rio.Component):
|
||||
)
|
||||
# Text with coin amount in USD and grid height 1
|
||||
usd_amount = self.coin_amount * self.data[self.coin].iloc[-1]
|
||||
grid.add(rio.Text(f"{usd_amount:,.2f} USD", align_x=0), row=3, column=1)
|
||||
grid.add(
|
||||
rio.Text(f"{usd_amount:,.2f} USD", align_x=0),
|
||||
row=3,
|
||||
column=1,
|
||||
)
|
||||
|
||||
return rio.Card(
|
||||
grid,
|
||||
|
||||
@@ -194,7 +194,9 @@ class CrudPage(rio.Component):
|
||||
"""
|
||||
selected_menu_item_copied.name = ev.text
|
||||
|
||||
def on_change_description(ev: rio.TextInputChangeEvent) -> None:
|
||||
def on_change_description(
|
||||
ev: rio.TextInputChangeEvent,
|
||||
) -> None:
|
||||
"""
|
||||
Changes the description of the currently selected menu item. And updates
|
||||
the description attribute of our data model.
|
||||
|
||||
+6
-30
@@ -1,13 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import sys
|
||||
import typing as t
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO, StringIO
|
||||
@@ -311,7 +309,9 @@ def first_non_null(*values: T | None) -> T:
|
||||
raise ValueError("At least one value must be non-`None`")
|
||||
|
||||
|
||||
def _repr_build_function(build_function: t.Callable[[], rio.Component]) -> str:
|
||||
def _repr_build_function(
|
||||
build_function: t.Callable[[], rio.Component],
|
||||
) -> str:
|
||||
"""
|
||||
Return a recognizable name for the provided function such as
|
||||
`Component.build`.
|
||||
@@ -328,7 +328,9 @@ def _repr_build_function(build_function: t.Callable[[], rio.Component]) -> str:
|
||||
return f"{type(self).__name__}.{build_function.__name__}"
|
||||
|
||||
|
||||
def safe_build(build_function: t.Callable[[], rio.Component]) -> rio.Component:
|
||||
def safe_build(
|
||||
build_function: t.Callable[[], rio.Component],
|
||||
) -> rio.Component:
|
||||
"""
|
||||
Calls a build function and returns its result. This differs from just
|
||||
calling the function directly, because it catches any exceptions and returns
|
||||
@@ -398,32 +400,6 @@ def normalize_url(url: rio.URL) -> rio.URL:
|
||||
return url.with_path(path)
|
||||
|
||||
|
||||
def load_module_from_path(file_path: Path, *, module_name: str | None = None):
|
||||
if module_name is None:
|
||||
module_name = file_path.stem
|
||||
|
||||
try:
|
||||
module = sys.modules[module_name]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if module.__file__ == str(file_path.absolute()):
|
||||
return module
|
||||
|
||||
raise ImportError(
|
||||
f"The file {file_path} cannot be imported because a module named"
|
||||
f" {module_name!r} already exists."
|
||||
)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
module = importlib.util.module_from_spec(spec) # type: ignore (wtf?)
|
||||
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module) # type: ignore (wtf?)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def is_python_script(path: Path) -> bool:
|
||||
return path.suffix in (".py", ".pyc", ".pyd", ".pyo", ".pyw")
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pyfakefs.fake_filesystem import FakeFilesystem, PatchMode
|
||||
from pyfakefs.fake_filesystem_unittest import Patcher
|
||||
|
||||
from rio.cli.run_project.app_loading import load_user_app
|
||||
from rio.project_config import RioProjectConfig
|
||||
|
||||
|
||||
# Per default, importing from the fake file system doesn't work. So we must
|
||||
# define a custom fixture which enables that.
|
||||
@pytest.fixture
|
||||
def fs():
|
||||
with Patcher(patch_open_code=PatchMode.AUTO) as p:
|
||||
yield p.fs
|
||||
|
||||
|
||||
def create_project(
|
||||
fs: FakeFilesystem,
|
||||
file_hierarchy: str,
|
||||
*,
|
||||
app_file: str,
|
||||
main_module: str,
|
||||
) -> RioProjectConfig:
|
||||
FILE_CONTENTS_BY_NAME = {
|
||||
"foo_page.py": """
|
||||
import rio
|
||||
|
||||
|
||||
@rio.page(url_segment='foo-was-loaded-correctly')
|
||||
class FooPage(rio.Component):
|
||||
def build(self):
|
||||
return rio.Text('foo')
|
||||
"""
|
||||
}
|
||||
|
||||
file_hierarchy = textwrap.dedent(file_hierarchy).strip()
|
||||
|
||||
directories_by_indent = {0: Path()}
|
||||
rio_toml_location: Path | None = None
|
||||
|
||||
for line in file_hierarchy.splitlines():
|
||||
file_name = line.lstrip()
|
||||
indent = (len(line) - len(file_name)) // 4
|
||||
directory = directories_by_indent[indent]
|
||||
|
||||
# Create the file/folder
|
||||
if file_name.endswith("/"):
|
||||
new_directory = directory / file_name.rstrip("/")
|
||||
directories_by_indent[indent + 1] = new_directory
|
||||
|
||||
# new_directory.mkdir()
|
||||
fs.create_dir(new_directory)
|
||||
else:
|
||||
new_file = directory / file_name
|
||||
|
||||
fs.create_file(
|
||||
new_file, contents=FILE_CONTENTS_BY_NAME.get(file_name, "")
|
||||
)
|
||||
|
||||
if file_name == "rio.toml":
|
||||
rio_toml_location = new_file
|
||||
|
||||
(directories_by_indent[0] / app_file).write_text("""
|
||||
import rio
|
||||
|
||||
app = rio.App(build=rio.Spacer)
|
||||
""")
|
||||
|
||||
assert rio_toml_location is not None
|
||||
|
||||
return RioProjectConfig(
|
||||
file_path=rio_toml_location.absolute(),
|
||||
toml_dict={
|
||||
"app": {
|
||||
"app-type": "website",
|
||||
"main-module": main_module,
|
||||
}
|
||||
},
|
||||
dirty_keys=set(),
|
||||
)
|
||||
|
||||
|
||||
def test_project_file(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
my_app.py
|
||||
pages/
|
||||
foo_page.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/my_app.py",
|
||||
main_module="my_app",
|
||||
)
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "My App"
|
||||
assert app.assets_dir == Path("my-project").absolute()
|
||||
assert app.pages[0].url_segment == "foo-was-loaded-correctly"
|
||||
|
||||
|
||||
def test_src_folder(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
src/
|
||||
assets/
|
||||
my_app.py
|
||||
pages/
|
||||
foo_page.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/src/my_app.py",
|
||||
main_module="my_app",
|
||||
)
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "My App"
|
||||
assert app.assets_dir == Path("my-project/src/assets").absolute()
|
||||
assert app.pages[0].url_segment == "foo-was-loaded-correctly"
|
||||
|
||||
|
||||
def test_simple_project_dir(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
my_project/
|
||||
__init__.py
|
||||
assets/
|
||||
pages/
|
||||
foo_page.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/my_project/__init__.py",
|
||||
main_module="my_project",
|
||||
)
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "My Project"
|
||||
assert app.assets_dir == Path("my-project/my_project/assets").absolute()
|
||||
assert app.pages[0].url_segment == "foo-was-loaded-correctly"
|
||||
|
||||
|
||||
def test_submodule(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
my_project/
|
||||
__init__.py
|
||||
app.py
|
||||
assets/
|
||||
pages/
|
||||
foo_page.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/my_project/app.py",
|
||||
main_module="my_project.app",
|
||||
)
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "App"
|
||||
assert app.assets_dir == Path("my-project/my_project/assets").absolute()
|
||||
assert app.pages[0].url_segment == "foo-was-loaded-correctly"
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Waiting for new pyfakefs version to be published")
|
||||
def test_import_sibling_module(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
foo.py
|
||||
bar.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/foo.py",
|
||||
main_module="bar",
|
||||
)
|
||||
Path("my-project/bar.py").write_text("""
|
||||
# Note: The import statement doesn't work with pyfakefs, so we have to use
|
||||
# importlib.
|
||||
import importlib
|
||||
app = importlib.import_module('foo').app
|
||||
""")
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "Bar"
|
||||
assert app.assets_dir == Path("my-project").absolute()
|
||||
assert not app.pages
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Waiting for new pyfakefs version to be published")
|
||||
def test_import_from_submodule(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
my_project/
|
||||
__init__.py
|
||||
app.py
|
||||
assets/
|
||||
pages/
|
||||
foo_page.py
|
||||
helper.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/helper.py",
|
||||
main_module="my_project.app",
|
||||
)
|
||||
Path("my-project/my_project/app.py").write_text("""
|
||||
# Note: The import statement doesn't work with pyfakefs, so we have to use
|
||||
# importlib.
|
||||
import importlib
|
||||
app = importlib.import_module('helper').app
|
||||
""")
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "App"
|
||||
assert app.assets_dir == Path("my-project/my_project/assets").absolute()
|
||||
assert app.pages[0].url_segment == "foo-was-loaded-correctly"
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Waiting for new pyfakefs version to be published")
|
||||
def test_relative_import_from_pages(fs: FakeFilesystem):
|
||||
config = create_project(
|
||||
fs,
|
||||
"""
|
||||
my-project/
|
||||
my_project/
|
||||
__init__.py
|
||||
app.py
|
||||
assets/
|
||||
pages/
|
||||
fancy_page.py
|
||||
rio.toml
|
||||
""",
|
||||
app_file="my-project/my_project/app.py",
|
||||
main_module="my_project.app",
|
||||
)
|
||||
Path("my-project/my_project/pages/fancy_page.py").write_text("""
|
||||
# Note: The import statement doesn't work with pyfakefs, so we have to use
|
||||
# importlib.
|
||||
import importlib.util
|
||||
app_module = importlib.import_module(importlib.util.resolve_name('..app', __package__))
|
||||
_ = app_module.app # Just to make sure the right file was imported
|
||||
|
||||
|
||||
import rio
|
||||
|
||||
@rio.page(url_segment='fancy-page-was-loaded-correctly')
|
||||
class FancyPage(rio.Component):
|
||||
def build(self):
|
||||
return rio.Text('fancy')
|
||||
""")
|
||||
app = load_user_app(config)
|
||||
|
||||
assert app.name == "App"
|
||||
assert app.assets_dir == Path("my-project/my_project/assets").absolute()
|
||||
assert app.pages[0].url_segment == "fancy-page-was-loaded-correctly"
|
||||
@@ -132,7 +132,11 @@ def test_analyze_code_block(obj: type | t.Callable) -> None:
|
||||
# A lot of snippets are missing context, so it's only natural that ruff will
|
||||
# find issues with the code. There isn't really anything we can do about it,
|
||||
# so we'll just skip those object.
|
||||
if obj in (rio.App, rio.Color, rio.UserSettings):
|
||||
if obj in (
|
||||
rio.App,
|
||||
rio.Color,
|
||||
rio.UserSettings,
|
||||
):
|
||||
pytest.xfail()
|
||||
|
||||
# Make sure ruff is happy with all code blocks
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
import rio
|
||||
import rio.utils
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -21,7 +21,9 @@ __all__ = ["verify_layout", "cleanup"]
|
||||
layouter_factory: LayouterFactory | None = None
|
||||
|
||||
|
||||
async def verify_layout(build: t.Callable[[], rio.Component]) -> Layouter:
|
||||
async def verify_layout(
|
||||
build: t.Callable[[], rio.Component],
|
||||
) -> Layouter:
|
||||
"""
|
||||
Rio contains two layout implementations: One on the client side, which
|
||||
determines the real layout of components, and a second one on the server
|
||||
|
||||
Reference in New Issue
Block a user