diff --git a/rio/app_server/abstract_app_server.py b/rio/app_server/abstract_app_server.py index 4b5b0537..8890d434 100644 --- a/rio/app_server/abstract_app_server.py +++ b/rio/app_server/abstract_app_server.py @@ -15,6 +15,7 @@ from pathlib import Path import langcodes import pytz +import revel import starlette.datastructures import rio @@ -23,6 +24,7 @@ from .. import ( assets, data_models, language_info, + nice_traceback, routing, session, text_style, @@ -253,9 +255,13 @@ class AbstractAppServer(abc.ABC): ) -> t.Iterable[text_style.FontFace]: font_faces = list[text_style.FontFace]() - async for font_face in font._get_faces(): - self.weakly_host_asset(font_face.file) - font_faces.append(font_face) + try: + async for font_face in font._get_faces(): + self.weakly_host_asset(font_face.file) + font_faces.append(font_face) + except Exception as error: + revel.error(f"Failed to load font faces of {font!r}:") + nice_traceback.print_exception(error) return font_faces diff --git a/rio/components/component.py b/rio/components/component.py index 563b77a9..c107a7e9 100644 --- a/rio/components/component.py +++ b/rio/components/component.py @@ -97,6 +97,20 @@ AccessibilityRole = t.Literal[ @dataclasses.dataclass class ComponentResizeEvent: + """ + Holds information regarding a component resize event. + + This is a simple dataclass that stores useful information for when the size + of a component changes. You'll typically receive this as argument in + `@rio.event.on_resize` events. + + ## Attributes + + `width`: The new width of the component. + + `height`: The new height of the component. + """ + width: float height: float diff --git a/rio/session.py b/rio/session.py index 4a3c9c2c..482477d0 100644 --- a/rio/session.py +++ b/rio/session.py @@ -20,7 +20,6 @@ import introspection import ordered_set import revel import starlette.datastructures -import typing_extensions as te import unicall import unicall.json_rpc from identity_containers import IdentityDefaultDict, IdentitySet @@ -48,7 +47,7 @@ from . import ( ) from .components import dialog_container, fundamental_component, root_components from .data_models import BuildData, UnittestComponentLayout -from .observables.dataclass import Dataclass +from .observables.dataclass import RioDataclassMeta from .observables.observable_property import AttributeBinding from .observables.session_attachments import SessionAttachments from .observables.session_property import SessionProperty @@ -69,7 +68,7 @@ class WontSerialize(Exception): pass -class Session(unicall.Unicall, Dataclass): +class Session(unicall.Unicall, metaclass=RioDataclassMeta): """ Represents a single client connection to the app. @@ -467,17 +466,6 @@ class Session(unicall.Unicall, Dataclass): # *after* all the other Session initialization (like loading user # settings) is done. - # This method is inherited from dataclasses but not meant to be public - @te.override - def bind(self, *args, **kwargs) -> t.NoReturn: - """ - ## Metadata - - `public`: False - """ - - raise AttributeError() - async def _refresh_whenever_necessary(self) -> None: while True: await self._refresh_required_event.wait() diff --git a/rio/text_style.py b/rio/text_style.py index fff6ebba..9840cd17 100644 --- a/rio/text_style.py +++ b/rio/text_style.py @@ -97,7 +97,23 @@ class Font(SelfSerializing): self._css_file: pathlib.Path | rio.URL | str | None = None @staticmethod - def from_css_file(css_file: pathlib.Path | rio.URL | str) -> Font: + def from_css_file(css_file: pathlib.Path | rio.URL | str, /) -> Font: + """ + Loads a font from a CSS file. Any content other than `@font-face` + declarations is ignored. + + The Rio server will download the font files and rehost them. This means + clients can use the font even if they don't have an internet connection. + + Note that this method only creates a `Font` object, the CSS is only + loaded and parsed once your application uses the font. If an error + occurs during this process, it is printed to stderr. + + ## Parameters + + `css_file`: The CSS file to load. Can be a path, a URL, or a string + containing the CSS text. + """ font = Font(b"") font._faces.clear() font._css_file = css_file @@ -105,6 +121,23 @@ class Font(SelfSerializing): @staticmethod def from_google_fonts(font_name: str) -> Font: + """ + Loads a font from Google Fonts. + + The Rio server will download the font files and rehost them. This means + clients can use the font even if they don't have an internet connection, + since they don't need to access Google Fonts themselves. + + Note that this method only creates a `Font` object; the font files are + only downloaded from Google Fonts once your application uses the font. + If an error occurs during this process (for example because the font + name is misspelled), it is printed to stderr. + + ## Parameters + + `font_name`: The name of the font to load. Case-sensitive. + """ + css_url = rio.URL("https://fonts.googleapis.com/css2").with_query( family=font_name ) diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 1bbbe738..6221da1c 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -217,25 +217,28 @@ def _create_class_tests(docs: imy.docstrings.ClassDocs) -> type: f"Attribute {attr.name!r} has no details" ) - # Create tests for all members of this class - for member in docs.members.values(): - if isinstance(member, imy.docstrings.FunctionDocs): - # Ignore the constructor of Enums - if member.name == "__init__" and issubclass( - docs.object, enum.Enum - ): - continue + # List, Set, and Dict methods don't need to be documented, since they're + # just clones of well-known classes. + if docs.object not in (rio.List, rio.Set, rio.Dict): + # Create tests for all members of this class + for member in docs.members.values(): + if isinstance(member, imy.docstrings.FunctionDocs): + # Ignore the constructor of Enums + if member.name == "__init__" and issubclass( + docs.object, enum.Enum + ): + continue - test = _create_function_tests(member) - elif isinstance(member, imy.docstrings.PropertyDocs): - test = _create_property_tests(member) - else: - raise Exception( - f"Don't know how to create tests for a {type(member).__name__} object" - ) + test = _create_function_tests(member) + elif isinstance(member, imy.docstrings.PropertyDocs): + test = _create_property_tests(member) + else: + raise Exception( + f"Don't know how to create tests for a {type(member).__name__} object" + ) - test.__name__ = f"Test<{member.name}>" - vars()[test.__name__] = test + test.__name__ = f"Test<{member.name}>" + locals()[test.__name__] = test return ClassTests diff --git a/tests/test_events.py b/tests/test_events.py index 0dbd4072..d2904685 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -2,7 +2,22 @@ import asyncio import typing as t import rio.testing -from rio.components import Component +from rio.debug.layouter import Layouter + + +class ResizeEventRecorder(rio.Component): + recorded_events: list[rio.ComponentResizeEvent] = [] + + @rio.event.on_resize + def on_resize(self, resize_event: rio.ComponentResizeEvent) -> None: + self.recorded_events.append(resize_event) + + def build(self): + return rio.Rectangle( + fill=rio.Color.BLUE, + min_width=5.0, + min_height=10.0, + ) class ChildMounter(rio.Component): @@ -40,7 +55,7 @@ class EventCounter(rio.Component): def _on_unmount(self): self.unmount_count += 1 - def build(self) -> Component: + def build(self) -> rio.Component: return self.child @@ -259,3 +274,25 @@ async def test_populate_dead_child(): assert not test_client._last_updated_components, ( "Unmounted component was sent to the frontend" ) + + +async def test_size_observer_reports_content_dimensions(): + async with rio.testing.BrowserClient(ResizeEventRecorder) as client: + resize_event_recorder = client.get_component(ResizeEventRecorder) + rectangle = client.get_component(rio.Rectangle) + + layouter = await Layouter.create(client.session) + recorder_layout = layouter.get_layout_is(resize_event_recorder) + rectangle_layout = layouter.get_layout_is(rectangle) + assert ( + recorder_layout.allocated_outer_width + == rectangle_layout.allocated_outer_width + ) + assert ( + recorder_layout.allocated_outer_height + == rectangle_layout.allocated_outer_height + ) + + event = resize_event_recorder.recorded_events[-1] + assert event.width == recorder_layout.allocated_outer_width + assert event.height == recorder_layout.allocated_outer_height diff --git a/tests/test_frontend/test_layouting/test_on_resize.py b/tests/test_frontend/test_layouting/test_on_resize.py deleted file mode 100644 index bdeefc05..00000000 --- a/tests/test_frontend/test_layouting/test_on_resize.py +++ /dev/null @@ -1,39 +0,0 @@ -import rio.testing -from rio.debug.layouter import Layouter - - -class ResizeEventRecorder(rio.Component): - recorded_events: list[rio.ComponentResizeEvent] = [] - - @rio.event.on_resize - def on_resize(self, resize_event: rio.ComponentResizeEvent) -> None: - self.recorded_events.append(resize_event) - - def build(self): - return rio.Rectangle( - fill=rio.Color.BLUE, - min_width=5.0, - min_height=10.0, - ) - - -async def test_size_observer_reports_content_dimensions(): - async with rio.testing.BrowserClient(ResizeEventRecorder) as client: - resize_event_recorder = client.get_component(ResizeEventRecorder) - rectangle = client.get_component(rio.Rectangle) - - layouter = await Layouter.create(client.session) - recorder_layout = layouter.get_layout_is(resize_event_recorder) - rectangle_layout = layouter.get_layout_is(rectangle) - assert ( - recorder_layout.allocated_outer_width - == rectangle_layout.allocated_outer_width - ) - assert ( - recorder_layout.allocated_outer_height - == rectangle_layout.allocated_outer_height - ) - - event = resize_event_recorder.recorded_events[-1] - assert event.width == recorder_layout.allocated_outer_width - assert event.height == recorder_layout.allocated_outer_height