support submodule as main_module

This commit is contained in:
Aran-Fey
2024-10-21 22:53:20 +02:00
parent 4ef7bdc961
commit 4893579936
22 changed files with 531 additions and 178 deletions
+2
View File
@@ -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
View File
@@ -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()
+1 -3
View File
@@ -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,
+16 -27
View File
@@ -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]
+4 -3
View File
@@ -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
+1 -1
View File
@@ -93,7 +93,7 @@ class ComponentMeta(RioDataclassMeta):
continue
try:
events = member._rio_events_
events = member._rio_events_ # type: ignore
except AttributeError:
continue
+3 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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")
+266
View File
@@ -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"
+5 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
import pytest
import rio
import rio.utils
@pytest.mark.parametrize(
+3 -1
View File
@@ -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