components now refresh immediately, not just after event handlers

This commit is contained in:
Aran-Fey
2025-04-07 11:08:56 +02:00
parent 302a6c9c03
commit 627da22866
33 changed files with 233 additions and 163 deletions
+4
View File
@@ -2,6 +2,10 @@
## unreleased
- Components that depend on session attributes (like `active_page_url`) or
session attachments are now automatically rebuilt when those values change
- Components are now rebuilt immediately after a state change, not just after
event handlers
- `NumberInput` can now evaluate math expressions
- `PointerEventListener` can now listen to only specific button events
- `KeyEventListener` can now listen to only specifc hotkeys
-3
View File
@@ -261,8 +261,5 @@ class _ButtonInternal(FundamentalComponent):
# Trigger the press event
await self.call_event_handler(self.on_press)
# Refresh the session
await self.session._refresh()
_ButtonInternal._unique_id_ = "Button-builtin"
-3
View File
@@ -148,8 +148,5 @@ class Calendar(FundamentalComponent):
DateChangeEvent(self.value),
)
# Refresh the session
await self.session._refresh()
Calendar._unique_id_ = "Calendar-builtin"
-3
View File
@@ -115,9 +115,6 @@ class Card(FundamentalComponent):
# Trigger the press event
await self.call_event_handler(self.on_press)
# Refresh the session
await self.session._refresh()
def _custom_serialize_(self) -> JsonDoc:
thm = self.session.theme
color = thm._serialize_colorset(self.color)
-3
View File
@@ -117,9 +117,6 @@ class ColorPicker(FundamentalComponent):
ColorChangeEvent(color),
)
# Refresh the session
await self.session._refresh()
def _custom_serialize_(self) -> JsonDoc:
return {
"color": self.color.srgba,
+8 -2
View File
@@ -490,6 +490,12 @@ class Component(abc.ABC, metaclass=ComponentMeta):
"""
Return any additional properties to be serialized, which cannot be
deduced automatically from the type annotations.
Do *not* use this for values that can change rapidly on the client side,
like the `text` of a `TextInput`! Because of network latency, it's
important that such values are only sent to the client if they have
actually changed. But values returned from `_custom_serialize_` are
*always* sent to the client.
"""
return {}
@@ -706,7 +712,7 @@ class Component(abc.ABC, metaclass=ComponentMeta):
is still running. This allows you to e.g. update a progress bar while
the operation is still running.
"""
self.session.create_task(self._force_refresh_())
self._mark_all_properties_as_changed_()
# We need to return a custom Awaitable. We can't use a Task because that
# would run regardless of whether the user awaits it or not, and we
@@ -728,7 +734,7 @@ class Component(abc.ABC, metaclass=ComponentMeta):
from .. import serialization # Avoid circular import
properties = set(serialization.get_attribute_serializers(type(self)))
self.session._changed_properties[self].update(properties)
self.session._changed_attributes[self].update(properties)
async def _force_refresh_(self) -> None:
"""
+1 -1
View File
@@ -48,7 +48,7 @@ class DialogContainer(Component):
# Done
return content
# Note that this is NOT `_custom_serialize`. Dialog containers are
# Note that this is NOT `_custom_serialize_`. Dialog containers are
# high-level on the Python side, but sent to the client as though they were
# fundamental. To prevent a whole bunch of custom code in the serializer,
# this function handles the serialization of dialog containers.
-3
View File
@@ -259,8 +259,5 @@ class Dropdown(KeyboardFocusableFundamentalComponent, t.Generic[T]):
self.on_change, DropdownChangeEvent(self.selected_value)
)
# Refresh the session
await self.session._refresh()
Dropdown._unique_id_ = "Dropdown-builtin"
+3 -3
View File
@@ -320,10 +320,10 @@ class FilePickerArea(FundamentalComponent):
event_data,
)
# Refresh
# If files were added or removed, reassign the `files` property so that
# everyone depending on it gets rebuilt
if actually_added_files or actually_removed_files:
self.force_refresh()
await self.session._refresh()
self.files = self.files
FilePickerArea._unique_id_ = "FilePickerArea-builtin"
+2 -2
View File
@@ -170,10 +170,10 @@ class FundamentalComponent(Component):
# 2. There's no need to re-build this component, since it's a
# FundamentalComponent
# That means we should avoid marking this component as dirty.
dirty_properties = set(self.session._changed_properties[self])
dirty_properties = set(self.session._changed_attributes[self])
# Update all state properties to reflect the new state
for attr_name, attr_value in delta_state.items():
setattr(self, attr_name, attr_value)
self.session._changed_properties[self] = dirty_properties
self.session._changed_attributes[self] = dirty_properties
-3
View File
@@ -241,8 +241,5 @@ class _IconButtonInternal(FundamentalComponent):
# Trigger the press event
await self.call_event_handler(self.on_press)
# Refresh the session
await self.session._refresh()
_IconButtonInternal._unique_id_ = "IconButton-builtin"
-3
View File
@@ -741,9 +741,6 @@ class KeyEventListener(KeyboardFocusableFundamentalComponent):
f"{__class__.__name__} encountered unknown message: {msg}"
)
# Refresh the session
await self.session._refresh()
async def _call_key_event_handler(
self,
handler: rio.EventHandler[E]
-3
View File
@@ -435,8 +435,5 @@ class CustomListItem(FundamentalComponent):
# Trigger the press event
await self.call_event_handler(self.on_press)
# Refresh the session
await self.session._refresh()
CustomListItem._unique_id_ = "CustomListItem-builtin"
-3
View File
@@ -240,8 +240,5 @@ class ListView(FundamentalComponent):
{"selected_items": selected_items}
)
# Refresh the session
await self.session._refresh()
ListView._unique_id_ = "ListView-builtin"
-3
View File
@@ -390,8 +390,5 @@ class MouseEventListener(FundamentalComponent):
f"{__class__.__name__} encountered unknown message: {msg}"
)
# Refresh the session
await self.session._refresh()
MouseEventListener._unique_id_ = "MouseEventListener-builtin"
-3
View File
@@ -201,8 +201,5 @@ class MultiLineTextInput(KeyboardFocusableFundamentalComponent):
MultiLineTextInputConfirmEvent(text),
)
# Refresh the session
await self.session._refresh()
MultiLineTextInput._unique_id_ = "MultiLineTextInput-builtin"
-3
View File
@@ -310,8 +310,5 @@ class NumberInput(KeyboardFocusableFundamentalComponent):
f"Received invalid event from the frontend: {msg}"
)
# Refresh the session
await self.session._refresh()
NumberInput._unique_id_ = "NumberInput-builtin"
-3
View File
@@ -279,9 +279,6 @@ class PointerEventListener(FundamentalComponent):
f"{__class__.__name__} encountered unknown message: {msg}"
)
# Refresh the session
await self.session._refresh()
async def _call_appropriate_event_handler(
self,
handler: rio.EventHandler[PointerEvent]
-3
View File
@@ -297,8 +297,5 @@ class SwitcherBar(FundamentalComponent, t.Generic[T]):
self.on_change, SwitcherBarChangeEvent(selected_value)
)
# Refresh the session
await self.session._refresh()
SwitcherBar._unique_id_ = "SwitcherBar-builtin"
-1
View File
@@ -394,7 +394,6 @@ class Table(FundamentalComponent):
self.on_press,
TablePressEvent._from_message(msg, self),
)
await self.session._refresh()
else:
raise ValueError(f"Table encountered an unknown message: {msg}")
-3
View File
@@ -268,8 +268,5 @@ class TextInput(KeyboardFocusableFundamentalComponent):
f"Received invalid event from the frontend: {msg}"
)
# Refresh the session
await self.session._refresh()
TextInput._unique_id_ = "TextInput-builtin"
-3
View File
@@ -32,9 +32,6 @@ class LayoutDisplay(FundamentalComponent):
# Trigger the event handler
await self.call_event_handler(self.on_layout_change)
# Refresh the session
await self.session._refresh()
def _validate_delta_state_from_frontend(self, delta_state: JsonDoc) -> None:
if not set(delta_state) <= {"component_id"}:
raise AssertionError(
+1 -2
View File
@@ -223,8 +223,7 @@ class LayoutSubpage(rio.Component):
# Assign the new value to the Python instance
setattr(target, name, value)
# Components will automatically mark themselves as dirty, but won't
# trigger a resync. Do that now.
# Wait until the GUI is updated before we update our explanations
await self.session._refresh()
# Update the explanations
-3
View File
@@ -123,9 +123,6 @@ async def update_and_apply_theme(
# Apply it
await session._apply_theme(new_theme)
# Refresh
await session._refresh()
def get_source_for_theme(theme: rio.Theme, *, create_theme_pair: bool) -> str:
"""
+9 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import collections
from pathlib import Path
import rio
@@ -34,7 +35,14 @@ currently_building_component: rio.Component | None = None
currently_building_session: rio.Session | None = None
# Keeps track of components that have a `key`` (and were instantiated during this
# Keeps track of components that have a `key` (and were instantiated during this
# `build`). The reconciler needs to know about every component with a `key`, and
# this is the fastest way to do it.
key_to_component: dict[component.Key, rio.Component] = {}
# These keep track of attributes, items, and objects that have been accessed.
# (Typically these are reset before a `build` function is called)
accessed_objects = set[object]()
accessed_attributes = collections.defaultdict[object, set[str]](set)
accessed_items = collections.defaultdict[object, set[object]](set)
+6 -4
View File
@@ -11,6 +11,8 @@ import revel
import rio
from .. import global_state
__all__ = [
"ObservableProperty",
"AttributeBinding",
@@ -65,7 +67,7 @@ class ObservableProperty(abc.ABC, t.Generic[T]):
return introspection.typing.annotation_to_string(self._raw_annotation)
@abc.abstractmethod
def _get_affected_sessions(self, instance: T) -> t.Iterable[rio.Session]:
def _get_affected_sessions(self, instance: T, /) -> t.Iterable[rio.Session]:
raise NotImplementedError
def __set_name__(self, owner: type, name: str):
@@ -81,8 +83,7 @@ class ObservableProperty(abc.ABC, t.Generic[T]):
if instance is None:
return self
for session in self._get_affected_sessions(instance):
session._accessed_properties[instance].add(self.name)
global_state.accessed_attributes[instance].add(self.name)
# Otherwise get the value assigned to the property in the component
# instance
@@ -100,7 +101,8 @@ class ObservableProperty(abc.ABC, t.Generic[T]):
def _on_value_change(self, instance: T, /) -> None:
for session in self._get_affected_sessions(instance):
session._changed_properties[instance].add(self.name)
session._changed_attributes[instance].add(self.name)
session._refresh_required_event.set()
def __set__(self, instance: T, value: object) -> None:
if self.readonly:
+4 -4
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import typing as t
from .. import session, user_settings_module
from .. import global_state, session, user_settings_module
from . import dataclass
__all__ = ["SessionAttachments"]
@@ -23,7 +23,7 @@ class SessionAttachments:
return typ in self._attachments
def __getitem__(self, typ: type[T]) -> T:
self._session._accessed_attachments.add(typ)
global_state.accessed_items[self._session].add(typ)
try:
return self._attachments[typ] # type: ignore
@@ -33,7 +33,7 @@ class SessionAttachments:
def _add(self, value: object, synchronize: bool) -> None:
cls = type(value)
self._session._changed_attachments.add(cls)
self._session._changed_items[self._session].add(cls)
# If the value isn't a UserSettings instance, just assign it and we're
# done
@@ -77,7 +77,7 @@ class SessionAttachments:
# Remove the attachment, propagating any `KeyError`
old_value = self._attachments.pop(typ)
self._session._changed_attachments.add(typ)
self._session._changed_items[self._session].add(typ)
# User settings need special care
if not isinstance(old_value, user_settings_module.UserSettings):
+9 -9
View File
@@ -1,19 +1,19 @@
from __future__ import annotations
import typing as t
import rio
from .observable_property import ObservableProperty
__all__ = ["SessionProperty"]
class SessionProperty:
class SessionProperty(ObservableProperty["rio.Session"]):
def __set_name__(self, owner: type, name: str):
self.name = name
def __get__(
self, session: rio.Session, owner: type | None = None
) -> object:
return vars(session)[self.name]
def __set__(self, session: rio.Session, value: object):
vars(session)[self.name] = value
session._changed_properties[session].add(self.name)
def _get_affected_sessions(
self, session: rio.Session
) -> t.Iterable[rio.Session]:
return [session]
+110 -37
View File
@@ -48,6 +48,7 @@ from .data_models import BuildData, UnittestComponentLayout
from .observables import Dataclass
from .observables.observable_property import AttributeBinding
from .observables.session_attachments import SessionAttachments
from .observables.session_property import SessionProperty
from .transports import (
AbstractTransport,
TransportClosedIntentionally,
@@ -158,6 +159,8 @@ class Session(unicall.Unicall, Dataclass):
[RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646).
"""
_observable_property_factory_ = SessionProperty
# Type hints so the documentation generator knows which fields exist
timezone: tzinfo
preferred_languages: t.Sequence[str]
@@ -220,28 +223,31 @@ class Session(unicall.Unicall, Dataclass):
)
)
# Store which properties and attachments were accessed by `build`
# functions
self._accessed_properties = utils.WeakKeyDefaultDict[object, set[str]](
set
)
self._accessed_attachments = set[type]()
# Sessions automatically rebuild components whenever necessary. To make
# this possible, we need to create a background task that waits for a
# component to become dirty. It can do that by waiting for this event,
# which is triggered by our observables.
self._refresh_required_event = asyncio.Event()
# Map observables to components that accessed them. Whenever an
# observable's value changes, the corresponding components will be
# rebuilt.
self._components_by_accessed_object: weakref.WeakKeyDictionary[
object, weakref.WeakSet[rio.Component]
] = weakref.WeakKeyDictionary()
self._components_by_accessed_property: weakref.WeakKeyDictionary[
object, dict[str, weakref.WeakSet[rio.Component]]
] = weakref.WeakKeyDictionary()
self._components_by_accessed_attachment: collections.defaultdict[
type, weakref.WeakSet[rio.Component]
] = collections.defaultdict(weakref.WeakSet)
self._components_by_accessed_item: weakref.WeakKeyDictionary[
object, dict[object, weakref.WeakSet[rio.Component]]
] = weakref.WeakKeyDictionary()
# Keep track of all observables that have changed since the last refresh
self._changed_properties = utils.WeakKeyDefaultDict[object, set[str]](
self._changed_objects = set[object]()
self._changed_attributes = collections.defaultdict[object, set[str]](
set
)
self._changed_attachments = set[type]()
self._changed_items = collections.defaultdict[object, set[object]](set)
# Store the app server
self._app_server = app_server_
@@ -258,8 +264,8 @@ class Session(unicall.Unicall, Dataclass):
self._active_page_url = active_page_url
self._active_page_instances_and_path_arguments: tuple[
tuple[rio.ComponentPage, dict[str, object]], ...
] = tuple()
self._active_page_instances: tuple[rio.ComponentPage, ...] = tuple()
] = ()
self._active_page_instances: tuple[rio.ComponentPage, ...] = ()
# Components need unique ids, but we don't want them to be globally unique
# because then people could guesstimate the approximate number of
@@ -423,7 +429,7 @@ class Session(unicall.Unicall, Dataclass):
self.http_headers: t.Mapping[str, str] = http_headers
# Clear the Session properties "changed" by the constructor
self._changed_properties.clear()
self._changed_attributes.clear()
# Instantiate the root component
global_state.currently_building_component = None
@@ -438,6 +444,13 @@ class Session(unicall.Unicall, Dataclass):
global_state.currently_building_session = None
self.create_task(self._refresh_whenever_necessary())
async def _refresh_whenever_necessary(self) -> None:
while True:
await self._refresh_required_event.wait()
await self._refresh()
async def __send_message(self, message: str) -> None:
await self._rio_transport.send_if_possible(message)
@@ -1192,7 +1205,14 @@ window.location.href = {json.dumps(str(active_page_url))};
if component in self._newly_created_components:
results.append("newly created")
for obj, changed_attrs in self._changed_properties.items():
for obj in self._changed_objects:
try:
if component in self._components_by_accessed_object[obj]:
results.append(f"object {obj!r} changed")
except KeyError:
pass
for obj, changed_attrs in self._changed_attributes.items():
if obj is component and isinstance(
obj, fundamental_component.FundamentalComponent
):
@@ -1214,12 +1234,22 @@ window.location.href = {json.dumps(str(active_page_url))};
except KeyError:
pass
for typ in self._changed_attachments:
for obj, changed_items in self._changed_attributes.items():
try:
if component in self._components_by_accessed_attachment[typ]:
results.append(f"session attachment {typ!r} changed")
dependents_by_changed_item = self._components_by_accessed_item[
obj
]
except KeyError:
pass
continue
for changed_item in changed_items:
try:
if component in dependents_by_changed_item[changed_item]:
results.append(
f"item {changed_item!r} of {obj} changed"
)
except KeyError:
pass
return results
@@ -1233,8 +1263,12 @@ window.location.href = {json.dumps(str(active_page_url))};
# Add newly instantiated components
components_to_build.update(self._newly_created_components)
# Add components that depend on observable objects that have changed
for obj in self._changed_objects:
components_to_build.update(self._components_by_accessed_object[obj])
# Add components that depend on properties that have changed
for obj, changed_attrs in self._changed_properties.items():
for obj, changed_attrs in self._changed_attributes.items():
if not changed_attrs:
continue
@@ -1260,14 +1294,32 @@ window.location.href = {json.dumps(str(active_page_url))};
except KeyError:
pass
# Add components that depend on attachments that have changed
for typ in self._changed_attachments:
# Add components that depend on items that have changed
for obj, changed_items in self._changed_items.items():
if not changed_items:
continue
# If the object is a FundamentalComponent, add it too. It doesn't
# have a `build` method, but it obviously does depend on its own
# properties.
if isinstance(obj, fundamental_component.FundamentalComponent):
components_to_build.add(obj)
# Add all components that depend on this attribute
try:
components_to_build.update(
self._components_by_accessed_attachment[typ]
)
dependents_by_changed_item = self._components_by_accessed_item[
obj
]
except KeyError:
pass
continue
for changed_item in changed_items:
try:
components_to_build.update(
dependents_by_changed_item[changed_item]
)
except KeyError:
pass
return components_to_build
@@ -1286,13 +1338,14 @@ window.location.href = {json.dumps(str(active_page_url))};
# If the event handler made the component dirty again, undo
# it
self._changed_properties.pop(component, None)
self._changed_attributes.pop(component, None)
# Call the `build` method
global_state.currently_building_component = component
global_state.currently_building_session = self
self._accessed_properties.clear()
self._accessed_attachments.clear()
global_state.accessed_objects.clear()
global_state.accessed_attributes.clear()
global_state.accessed_items.clear()
build_result = utils.safe_build(component.build)
@@ -1303,7 +1356,10 @@ window.location.href = {json.dumps(str(active_page_url))};
global_state.key_to_component = {}
# Process the state that was accessed by the `build` method
for obj, accessed_attrs in self._accessed_properties.items():
for obj in global_state.accessed_objects:
self._components_by_accessed_object[obj].add(component)
for obj, accessed_attrs in global_state.accessed_attributes.items():
# Sometimes a `build` method indirectly accesses the state of a
# child component. For example, calling `Row.add()` "accesses"
# `Row.children`. This can lead to an infinite loop: Because the
@@ -1333,10 +1389,25 @@ window.location.href = {json.dumps(str(active_page_url))};
weakref.WeakSet([component])
)
for key in self._accessed_attachments:
self._components_by_accessed_attachment[key].add(component)
for obj, accessed_items in global_state.accessed_items.items():
try:
components_by_accessed_item = self._components_by_accessed_item[
obj
]
except KeyError:
components_by_accessed_item = (
self._components_by_accessed_item.setdefault(obj, {})
)
if component in self._changed_properties:
for accessed_item in accessed_items:
try:
components_by_accessed_item[accessed_item].add(component)
except KeyError:
components_by_accessed_item[accessed_item] = (
weakref.WeakSet([component])
)
if component in self._changed_attributes:
raise RuntimeError(
f"The `build()` method of the component `{component}`"
f" has changed the component's state. This isn't"
@@ -1437,14 +1508,16 @@ window.location.href = {json.dumps(str(active_page_url))};
# Build all dirty components
while True:
# Update the properties_to_serialize
for obj, changed_properties in self._changed_properties.items():
for obj, changed_properties in self._changed_attributes.items():
properties_to_serialize[obj].update(changed_properties)
# Collect all dirty components
components_to_build = self._collect_components_to_build()
self._newly_created_components.clear()
self._changed_properties.clear()
self._changed_attachments.clear()
self._changed_objects.clear()
self._changed_attributes.clear()
self._changed_items.clear()
self._refresh_required_event.clear()
# If there are no components to build, we're done
if not components_to_build:
@@ -1984,7 +2057,7 @@ window.location.href = {json.dumps(str(active_page_url))};
if not values_equal(old_value, new_value):
changed_properties.add(prop_name)
self._changed_properties[old_component].update(changed_properties)
self._changed_attributes[old_component].update(changed_properties)
# Now combine the old and new dictionaries
#
+1 -1
View File
@@ -337,4 +337,4 @@ async def test_binding_to_differently_named_attribute():
text_input.text = "hi"
assert "foo" in test_client.session._changed_properties[root_component]
assert "foo" in test_client.session._changed_attributes[root_component]
+6 -6
View File
@@ -8,9 +8,10 @@ import rio.testing
[
("window_width", 61.23),
("window_height", 61.23),
("_active_page_url", "https://foo.bar"),
("_active_page_url", rio.URL("https://foo.bar")),
("_active_page_instances", ()),
],
ids=str,
)
async def test_session_property_change(attr_name: str, new_value: object):
class TestComponent(rio.Component):
@@ -20,16 +21,15 @@ async def test_session_property_change(attr_name: str, new_value: object):
async with rio.testing.DummyClient(TestComponent) as test_client:
test_component = test_client.get_component(TestComponent)
text_component = test_client.get_component(rio.Text)
test_client._received_messages.clear()
setattr(test_client.session, attr_name, new_value)
await test_client.refresh()
assert test_client._last_updated_components == {
test_component,
text_component,
}
# Note: The `Text` component isn't necessarily updated, because the
# value we assigned might be the same as before, so the reconciler
# doesn't consider it dirty
assert test_component in test_client._last_updated_components
async def test_session_attachment_change():
+35 -1
View File
@@ -1,3 +1,5 @@
import asyncio
import rio.testing
@@ -207,9 +209,9 @@ async def test_binding_doesnt_update_children() -> None:
text_input = test_client.get_component(rio.TextInput)
text = test_client.get_component(rio.Text)
# Note: `text_input._on_message_` automatically triggers a refresh
test_client._received_messages.clear()
await text_input._on_message_({"type": "confirm", "text": "hello"})
await test_client.refresh()
# Only the Text component has changed in this rebuild
assert test_client._last_updated_components == {root_component, text}
@@ -234,6 +236,38 @@ async def test_add_method_doesnt_count_as_attribute_access():
pass # If we made it this far, then there's no infinite loop.
async def test_automatic_refresh():
"""
Test whether Rio automatically refreshes after a state change
"""
updated_event = asyncio.Event()
class TestComponent(rio.Component):
text: str = "hi"
@rio.event.on_mount
async def on_mount(self):
await asyncio.sleep(0.1)
self.text = "bye"
test_client._received_messages.clear()
updated_event.set()
await asyncio.sleep(0.5)
def build(self):
return rio.Text(self.text)
async with rio.testing.DummyClient(TestComponent) as test_client:
await updated_event.wait()
# Yield control so that Rio has a chance to refresh
await asyncio.sleep(0)
text_component = test_client.get_component(rio.Text)
assert text_component in test_client._last_updated_components
async def test_value_change_from_frontend():
"""
When the frontend changes the state of a FundamentalComponent, we don't want
+34 -38
View File
@@ -2,7 +2,7 @@ import typing as t
import pytest
import rio.app_server
import rio.testing
class FakeComponent(rio.Component):
@@ -177,7 +177,7 @@ PAGES = [
# URLs
],
)
def test_redirects(
async def test_redirects(
relative_url_before_redirects: str,
relative_url_after_redirects_should: str,
) -> None:
@@ -187,28 +187,21 @@ def test_redirects(
"""
# Create a fake session. It contains everything used by the routing system.
app = rio.App(pages=PAGES)
app_server = rio.app_server.TestingServer(app)
session = app_server.create_dummy_session()
# Determine the final URL
active_pages_and_path_arguments, absolute_url_after_redirects_is = (
rio.routing.check_page_guards(
session,
session._base_url.join(rio.URL(relative_url_before_redirects)),
async with rio.testing.DummyClient(app) as client:
client.session.navigate_to(relative_url_before_redirects)
# Verify the final URL
absolute_url_after_redirects_should = client.session._base_url.join(
rio.URL(relative_url_after_redirects_should)
)
assert (
client.session.active_page_url
== absolute_url_after_redirects_should
)
)
# Verify the final URL
absolute_url_after_redirects_should = session._base_url.join(
rio.URL(relative_url_after_redirects_should)
)
assert (
absolute_url_after_redirects_is == absolute_url_after_redirects_should
)
def test_url_parameter_parsing_failure() -> None:
async def test_url_parameter_parsing_failure() -> None:
def build_int_page(path_param: int):
return rio.Text(str(path_param))
@@ -228,21 +221,25 @@ def test_url_parameter_parsing_failure() -> None:
)
app = rio.App(pages=(int_page, float_page))
app_server = rio.app_server.TestingServer(app)
session = app_server.create_dummy_session()
async with rio.testing.DummyClient(app) as client:
session = client.session
active_pages_and_path_arguments, _ = rio.routing.check_page_guards(
session,
session._base_url.join(rio.URL("/3.5")),
)
session.navigate_to("/3.5")
assert active_pages_and_path_arguments is not None
assert len(active_pages_and_path_arguments) == 1
assert active_pages_and_path_arguments[0][0] == float_page
assert active_pages_and_path_arguments[0][1] == {"path_param": 3.5}
expected_url = session._base_url.join(rio.URL("/3.5"))
assert session.active_page_url == expected_url
assert len(session._active_page_instances_and_path_arguments) == 1
assert (
session._active_page_instances_and_path_arguments[0][0]
== float_page
)
assert session._active_page_instances_and_path_arguments[0][1] == {
"path_param": 3.5
}
def test_redirect_offsite() -> None:
async def test_redirect_offsite() -> None:
"""
Redirect to a site other than this app.
"""
@@ -253,17 +250,16 @@ def test_redirect_offsite() -> None:
)
app = rio.App(pages=(page,))
app_server = rio.app_server.TestingServer(app)
session = app_server.create_dummy_session()
external_url = rio.URL("http://example.com")
active_pages_and_path_arguments, absolute_url_after_redirects = (
rio.routing.check_page_guards(
session,
external_url,
async with rio.testing.DummyClient(app) as client:
active_pages_and_path_arguments, absolute_url_after_redirects = (
rio.routing.check_page_guards(
client.session,
external_url,
)
)
)
assert active_pages_and_path_arguments is None
assert absolute_url_after_redirects == external_url