diff --git a/changelog.md b/changelog.md index 3f2da470..436a8829 100644 --- a/changelog.md +++ b/changelog.md @@ -1,94 +1,100 @@ # Changelog -- `rio.Dropdown` will now open a fullscreen popup on mobile devices -- `rio.MediaPlayer` now also triggers the `on_playback_end` event when the +- New styles for input boxes: "rounded" and "pill" +- Improved mobile support: Dragging is now much smoother + +## 0.10 + +- `rio.Dropdown` will now open a fullscreen popup on mobile devices +- `rio.MediaPlayer` now also triggers the `on_playback_end` event when the video loops -- experimental support for base-URL -- dialogs! -- dialogs can now store a result value similar to futures -- `rio.Text.wrap` is now `rio.Text.overflow`. Same for markdown. -- removed `rio.Popup.on_open_or_close`. This event never actually fired. -- `rio.Link` can now optionally display an icon -- Rio will automatically create basic navigation for you, if your app has more +- experimental support for base-URL +- dialogs! +- dialogs can now store a result value similar to futures +- `rio.Text.wrap` is now `rio.Text.overflow`. Same for markdown. +- removed `rio.Popup.on_open_or_close`. This event never actually fired. +- `rio.Link` can now optionally display an icon +- Rio will automatically create basic navigation for you, if your app has more than one page -- Updated button styles: Added `colored-text` and renamed `plain` -> +- Updated button styles: Added `colored-text` and renamed `plain` -> `plain-text` -- Methods for creating dialogs are now in `rio.Session` rather than +- Methods for creating dialogs are now in `rio.Session` rather than `rio.Component`. -- Page rework - - Add `rio.Redirect` - - TODO: Automatic page scan +- Page rework + - Add `rio.Redirect` + - TODO: Automatic page scan +- New experimental `rio.FilePickerArea` component ## 0.9.2 -- restyled `rio.Switch` -- New ~~experimental~~ broken component `AspectRatioContainer` +- restyled `rio.Switch` +- New ~~experimental~~ broken component `AspectRatioContainer` ## 0.9.1 -- added gain_focus / lose_focus events to TextInput and NumberInput -- `.rioignore` has been superseeded by the new `project-files` setting in +- added gain_focus / lose_focus events to TextInput and NumberInput +- `.rioignore` has been superseeded by the new `project-files` setting in `rio.toml` -- values in `rio.toml` are now written in kebab-case instead of +- values in `rio.toml` are now written in kebab-case instead of all_lower_case. Rio will still recognize the old names and automatically fix them for you. -- deprecated `light` parameter of `Theme.from_color`, has been superseded by +- deprecated `light` parameter of `Theme.from_color`, has been superseded by `mode` -- Tooltips now default to `position="auto"` -- Icons now use `_` instead of `-` in their names. This brings them more in line +- Tooltips now default to `position="auto"` +- Icons now use `_` instead of `-` in their names. This brings them more in line with Python naming conventions -- Checkbox restyling +- Checkbox restyling ## 0.9 -- Buttons now have a smaller minimum size when using a `rio.Component` as +- Buttons now have a smaller minimum size when using a `rio.Component` as content -- `FrostedGlassFill` added (Contributed by MiniTT) -- added `@rio.event.on_window_size_change` -- popups now default to the "hud" color -- popups and tooltips are no longer cut off by other components -- Add HTML meta tags -- Add functions for reading and writing clipboard contents to the `Session` +- `FrostedGlassFill` added (Contributed by MiniTT) +- added `@rio.event.on_window_size_change` +- popups now default to the "hud" color +- popups and tooltips are no longer cut off by other components +- Add HTML meta tags +- Add functions for reading and writing clipboard contents to the `Session` (Contributed by MiniTT) -- The color of drawers is now configurable, and also sets the theme context -- added `Calendar` component -- added `DateInput` component -- massive dev-tools overhaul -- new (but experimental) `Switcher` component -- TextInputs now update their text in real-time -- `rio run` no longer opens a browser -- `rio.HTML` components now execute embedded ` diff --git a/pyproject.toml b/pyproject.toml index 14e99aca..ad2486d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/rio/__init__.py b/rio/__init__.py index f77d8da8..95f0241c 100644 --- a/rio/__init__.py +++ b/rio/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.10.2" +__version__ = "0.10.5rc0" # There is an issue with `rye test`. rye passes a `--rootdir` argument to diff --git a/rio/app.py b/rio/app.py index 588e6b1b..17f9249f 100644 --- a/rio/app.py +++ b/rio/app.py @@ -1,13 +1,13 @@ from __future__ import annotations +import functools import os import sys import threading +import typing as t import webbrowser -from collections.abc import Callable, Iterable from datetime import timedelta from pathlib import Path -from typing import * # type: ignore import fastapi import uvicorn @@ -15,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 @@ -60,7 +60,7 @@ def make_default_connection_lost_component() -> rio.Component: return DefaultConnectionLostComponent() -@final +@t.final class App: """ Contains all the information needed to run a Rio app. @@ -130,18 +130,18 @@ 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__( self, *, - build: Callable[[], rio.Component] | None = None, + build: t.Callable[[], rio.Component] | None = None, name: str | None = None, description: str | None = None, icon: ImageLike | None = None, - pages: Iterable[rio.ComponentPage | rio.Redirect] + pages: t.Iterable[rio.ComponentPage | rio.Redirect] | os.PathLike | str | None = None, @@ -149,11 +149,11 @@ class App: on_app_close: rio.EventHandler[App] = None, on_session_start: rio.EventHandler[rio.Session] = None, on_session_close: rio.EventHandler[rio.Session] = None, - default_attachments: Iterable[Any] = (), + 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: Callable[ + build_connection_lost_message: t.Callable[ [], rio.Component ] = make_default_connection_lost_component, meta_tags: dict[str, str] = {}, @@ -253,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" @@ -270,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: MutableSequence[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 @@ -309,37 +308,172 @@ 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: 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, *, debug_mode: bool, running_in_window: bool, - internal_on_app_start: Callable[[], Any] | None, + internal_on_app_start: t.Callable[[], t.Any] | None, base_url: rio.URL | str | None, ) -> fastapi.FastAPI: """ @@ -355,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, @@ -418,8 +549,8 @@ class App: port: int, quiet: bool, running_in_window: bool, - internal_on_app_start: Callable[[], None] | None = None, - internal_on_server_created: Callable[[uvicorn.Server], None] + internal_on_app_start: t.Callable[[], None] | None = None, + internal_on_server_created: t.Callable[[uvicorn.Server], None] | None = None, base_url: rio.URL | str | None = None, ) -> None: @@ -693,46 +824,10 @@ pixels_per_rem ) finally: - server = cast( + server = t.cast( uvicorn.Server, server ) # Prevents "unreachable code" warning assert isinstance(server, uvicorn.Server) 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() diff --git a/rio/app_server/abstract_app_server.py b/rio/app_server/abstract_app_server.py index f09862d3..135ed862 100644 --- a/rio/app_server/abstract_app_server.py +++ b/rio/app_server/abstract_app_server.py @@ -11,7 +11,6 @@ import warnings import weakref from datetime import date from pathlib import Path -from typing import * import langcodes import pytz diff --git a/rio/app_server/fastapi_server.py b/rio/app_server/fastapi_server.py index 04b22d67..1a82d1e6 100644 --- a/rio/app_server/fastapi_server.py +++ b/rio/app_server/fastapi_server.py @@ -8,10 +8,11 @@ import io import json import logging import secrets +import typing as t +import warnings import weakref from datetime import timedelta from pathlib import Path -from typing import * # type: ignore from xml.etree import ElementTree as ET import crawlerdetect @@ -48,7 +49,7 @@ __all__ = [ ] -P = ParamSpec("P") +P = t.ParamSpec("P") # Used to identify search engine crawlers (like googlebot) and serve them @@ -59,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, @@ -110,8 +109,8 @@ def read_frontend_template(template_name: str) -> str: def add_cache_headers( - func: Callable[P, Awaitable[fastapi.Response]], -) -> Callable[P, Coroutine[None, None, fastapi.Response]]: + func: t.Callable[P, t.Awaitable[fastapi.Response]], +) -> t.Callable[P, t.Coroutine[None, None, fastapi.Response]]: """ Decorator for routes that serve static files. Ensures that the response has the `Cache-Control` header set appropriately. @@ -181,7 +180,7 @@ class FastapiServer(fastapi.FastAPI, AbstractAppServer): app_: app.App, debug_mode: bool, running_in_window: bool, - internal_on_app_start: Callable[[], None] | None, + internal_on_app_start: t.Callable[[], None] | None, base_url: rio.URL | None, ) -> None: super().__init__( @@ -327,15 +326,26 @@ class FastapiServer(fastapi.FastAPI, AbstractAppServer): # The route that serves the index.html will be registered later, so that # it has a lower priority than user-created routes. + # + # This keeps track of whether the fallback route has already been + # registered. + self._index_hmtl_route_registered = False async def __call__(self, scope, receive, send) -> None: # Because this is a single page application, all other routes should # serve the index page. The session will determine which components # should be shown. - self.add_api_route( - "/{initial_route_str:path}", self._serve_index, methods=["GET"] - ) + # + # This route is registered last, so that it has the lowest priority. + # This allows the user to add custom routes that take precedence. + if not self._index_hmtl_route_registered: + self._index_hmtl_route_registered = True + self.add_api_route( + "/{initial_route_str:path}", self._serve_index, methods=["GET"] + ) + + # Delegate to FastAPI return await super().__call__(scope, receive, send) @contextlib.asynccontextmanager @@ -526,6 +536,24 @@ class FastapiServer(fastapi.FastAPI, AbstractAppServer): html_base_url, ) + theme = self.app._theme + if isinstance(theme, tuple): + light_theme_background_color = theme[0].background_color + dark_theme_background_color = theme[1].background_color + else: + light_theme_background_color = theme.background_color + dark_theme_background_color = theme.background_color + + html_ = html_.replace( + "{light_theme_background_color}", + f"#{light_theme_background_color.hex}", + ) + + html_ = html_.replace( + "{dark_theme_background_color}", + f"#{dark_theme_background_color.hex}", + ) + # Since the title is user-defined, it might contain placeholders like # `{debug_mode}`. So it's important that user-defined content is # inserted last. @@ -604,6 +632,17 @@ Sitemap: {base_url / "/rio/sitemap"} image.save(output_buffer, format="png") except Exception as err: + if isinstance(self.app._icon, assets.PathAsset): + warnings.warn( + f"Could not fetch the app's icon from {self.app._icon.path.resolve()}" + ) + elif isinstance(self.app._icon, assets.UrlAsset): + warnings.warn( + f"Could not fetch the app's icon from {self.app._icon.url}" + ) + else: + warnings.warn(f"Could not fetch the app's icon from") + raise fastapi.HTTPException( status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not fetch the app's icon.", diff --git a/rio/app_server/testing_server.py b/rio/app_server/testing_server.py index a5f2b423..5d6a40bc 100644 --- a/rio/app_server/testing_server.py +++ b/rio/app_server/testing_server.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * - import rio from .. import assets, utils diff --git a/rio/assets.py b/rio/assets.py index 4c279ab0..b3641f80 100644 --- a/rio/assets.py +++ b/rio/assets.py @@ -4,12 +4,12 @@ import abc import hashlib import io import os +import typing as t from pathlib import Path -from typing import * # type: ignore import httpx +import typing_extensions as te from PIL.Image import Image -from typing_extensions import Self from yarl import URL import rio @@ -65,15 +65,15 @@ class Asset(SelfSerializing): # The MIME type of the asset self.media_type = media_type - @overload + @t.overload @classmethod def new(cls, data: bytes, media_type: str | None = None) -> BytesAsset: ... - @overload + @t.overload @classmethod def new(cls, data: Path, media_type: str | None = None) -> PathAsset: ... - @overload + @t.overload @classmethod def new(cls, data: URL, media_type: str | None = None) -> UrlAsset: ... @@ -150,7 +150,7 @@ class Asset(SelfSerializing): return self._eq(other) @abc.abstractmethod - def _eq(self, other: Self) -> bool: + def _eq(self, other: te.Self) -> bool: raise NotImplementedError @abc.abstractmethod @@ -191,7 +191,7 @@ class HostedAsset(Asset): def __hash__(self) -> int: return hash(self.secret_id) - def _eq(self, other: Self) -> bool: + def _eq(self, other: te.Self) -> bool: return self.secret_id == other.secret_id def _serialize(self, sess: rio.Session) -> str: @@ -277,7 +277,7 @@ class UrlAsset(Asset): def __hash__(self) -> int: return hash(self._url) - def _eq(self, other: Self) -> bool: + def _eq(self, other: te.Self) -> bool: return self._url == other._url def _serialize(self, sess: rio.Session) -> str: diff --git a/rio/byte_serving.py b/rio/byte_serving.py index ebf12268..f3bf6b0b 100644 --- a/rio/byte_serving.py +++ b/rio/byte_serving.py @@ -6,8 +6,9 @@ https://github.com/tiangolo/fastapi/issues/1240#issuecomment-1055396884 """ import mimetypes +import typing as t +import warnings from pathlib import Path -from typing import * # type: ignore import fastapi from fastapi import HTTPException @@ -18,11 +19,11 @@ __all__ = [ def send_bytes_range_requests( - file_obj: BinaryIO, + file_obj: t.BinaryIO, start: int, end: int, chunk_size: int = 16 * 1024 * 1024, -) -> Iterator[bytes]: +) -> t.Iterator[bytes]: """ Send a file in chunks using Range Requests specification RFC7233. `start` and `end` are inclusive as per the spec. @@ -71,13 +72,15 @@ def range_requests_response( Returns a fastapi response which serves the given file, supporting Range Requests as per RFC7233 ("HTTP byte serving"). - Returns a 404 if the file does not exist. + Returns a 404 if the file does not exist. In this case a warning is also + shown in the console. """ # Get the file size. This also verifies the file exists. try: file_size_in_bytes = file_path.stat().st_size except FileNotFoundError: + warnings.warn(f"Cannot find file at {file_path.resolve()}") return fastapi.responses.Response(status_code=404) # Prepare response headers @@ -91,7 +94,16 @@ def range_requests_response( } if media_type is None: - media_type = mimetypes.guess_type(file_path, strict=False)[0] + # There have been issues with javascript files because browsers insist + # on the mime type "text/javascript", but some PCs aren't configured + # correctly and return "text/plain". So we purposely avoid using + # `mimetypes.guess_type` for javascript files. + suffixes = file_path.suffixes + + if suffixes and suffixes[0] == ".js": + media_type = "text/javascript" + else: + media_type = mimetypes.guess_type(file_path, strict=False)[0] if media_type is not None: headers["content-type"] = media_type diff --git a/rio/cli/__init__.py b/rio/cli/__init__.py index da308035..74ad0a1e 100644 --- a/rio/cli/__init__.py +++ b/rio/cli/__init__.py @@ -5,8 +5,8 @@ from .. import project_config _logger = logging.getLogger(__name__) +import typing as t from pathlib import Path -from typing import Literal import introspection import revel @@ -67,7 +67,7 @@ def new( nicename: str, *, # Website is listed first to make it the default - type: Literal["website", "app"], + type: t.Literal["website", "app"], template: rio.snippets.AvailableTemplatesLiteral, ) -> None: project_setup.create_project( @@ -213,9 +213,13 @@ containing some template code will be created in the `pages` or `components` folder of your project. """, ) -def add(what: Literal["page", "component"], /, name: str) -> None: +def add(what: t.Literal["page", "component"], /, name: str) -> None: with project_config.RioProjectConfig.try_locate_and_load() as proj: - module_path = proj.app_main_module_path + try: + module_path = proj.app_main_module_path + except FileNotFoundError as error: + fatal(str(error)) + if not module_path.is_dir(): fatal( f"Cannot add {what}s to a single-file project. Please convert" @@ -241,7 +245,7 @@ def add(what: Literal["page", "component"], /, name: str) -> None: file_path.write_text( f"""from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -269,7 +273,7 @@ culpa qui officia deserunt mollit anim id est laborum. f"""from __future__ import annotations from dataclasses import KW_ONLY, field -from typing import * # type: ignore +import typing as t import rio diff --git a/rio/cli/cli_instance.py b/rio/cli/cli_instance.py index bea73f32..095b5961 100644 --- a/rio/cli/cli_instance.py +++ b/rio/cli/cli_instance.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -from typing import * # type: ignore import keyring import platformdirs diff --git a/rio/cli/nice_traceback.py b/rio/cli/nice_traceback.py index e30c84b7..2af0604c 100644 --- a/rio/cli/nice_traceback.py +++ b/rio/cli/nice_traceback.py @@ -8,8 +8,10 @@ import io import linecache import sys import traceback +import typing as t from pathlib import Path -from typing import Callable, IO, Optional + + import revel from dataclasses import dataclass @@ -52,12 +54,12 @@ def _handle_syntax_error(err: SyntaxError) -> traceback.FrameSummary: ) def _format_single_exception_raw( - out: IO[str], + out: t.IO[str], err: BaseException, *, include_header: bool, style: FormatStyle, - relpath: Optional[Path], + relpath: Path | None, frame_filter: Callable[[traceback.FrameSummary], bool], ) -> None: """ @@ -115,8 +117,8 @@ def format_exception_raw( err: BaseException, *, style: FormatStyle, - relpath: Optional[Path] = None, - frame_filter: Optional[Callable[[traceback.FrameSummary], bool]] = None, + relpath: Path | None = None, + frame_filter: t.Callable[[traceback.FrameSummary], bool] = lambda _: True, ) -> str: """ Format an exception into a pretty string with the given style. @@ -155,8 +157,8 @@ def format_exception_raw( def format_exception_revel( err: BaseException, *, - relpath: Optional[Path] = None, - frame_filter: Optional[Callable[[traceback.FrameSummary], bool]] = None, + relpath: Path | None = None, + frame_filter: t.Callable[[traceback.FrameSummary], bool] = lambda _: True, ) -> str: """ Format an exception using Revel's styling. @@ -183,8 +185,8 @@ def format_exception_revel( def format_exception_html( err: BaseException, *, - relpath: Optional[Path] = None, - frame_filter: Optional[Callable[[traceback.FrameSummary], bool]] = None, + relpath: Path | None = None, + frame_filter: t.Callable[[traceback.FrameSummary], bool] = lambda _: True, ) -> str: """ Format an exception into HTML with appropriate styling. diff --git a/rio/cli/project_setup.py b/rio/cli/project_setup.py index 55cf021e..7be4d048 100644 --- a/rio/cli/project_setup.py +++ b/rio/cli/project_setup.py @@ -2,8 +2,8 @@ import io import re import shutil import string +import typing as t from pathlib import Path -from typing import * # type: ignore import introspection import isort @@ -32,7 +32,9 @@ def class_name_from_snippet(snip: rio.snippets.Snippet) -> str: return "".join(part.capitalize() for part in parts) -def write_init_file(fil: IO, snippets: Iterable[rio.snippets.Snippet]) -> None: +def write_init_file( + fil: t.IO, snippets: t.Iterable[rio.snippets.Snippet] +) -> None: """ Write an `__init__.py` file that imports all of the snippets. @@ -51,10 +53,10 @@ def write_init_file(fil: IO, snippets: Iterable[rio.snippets.Snippet]) -> None: def generate_root_init( - out: TextIO, + out: t.TextIO, *, raw_name: str, - project_type: Literal["app", "website"], + project_type: t.Literal["app", "website"], template: rio.snippets.ProjectTemplate, ) -> None: """ @@ -77,7 +79,7 @@ def generate_root_init( from __future__ import annotations from pathlib import Path -from typing import * # type: ignore +import typing as t import rio @@ -218,7 +220,7 @@ def derive_module_name(raw_name: str) -> str: def generate_readme( - out: TextIO, + out: t.TextIO, raw_name: str, template: rio.snippets.ProjectTemplate, ) -> None: @@ -247,7 +249,7 @@ This project is based on the `{template.name}` template. def write_component_file( - out: TextIO, + out: t.TextIO, snip: rio.snippets.Snippet, import_depth: int, ) -> None: @@ -263,7 +265,7 @@ def write_component_file( f"""from __future__ import annotations from dataclasses import KW_ONLY, field -from typing import * # type: ignore +import typing as t import rio @@ -314,7 +316,7 @@ def generate_dependencies_file( def create_project( *, raw_name: str, - type: Literal["app", "website"], + type: t.Literal["app", "website"], template_name: rio.snippets.AvailableTemplatesLiteral, target_parent_directory: Path, ) -> None: diff --git a/rio/cli/rio_api.py b/rio/cli/rio_api.py index ceb7da58..fbd25eb0 100644 --- a/rio/cli/rio_api.py +++ b/rio/cli/rio_api.py @@ -1,5 +1,5 @@ +import typing as t from datetime import timedelta -from typing import * # type: ignore import httpx @@ -46,7 +46,7 @@ class RioApi: async def __aenter__(self) -> "RioApi": return self - async def __aexit__(self, *args: Any) -> None: + async def __aexit__(self, *args: t.Any) -> None: await self.close() async def close(self) -> None: @@ -65,10 +65,10 @@ class RioApi: self, endpoint: str, *, - method: Literal["get", "post", "delete"] = "get", - json: dict[str, Any] | None = None, - file: BinaryIO | None = None, - ) -> Any: + method: t.Literal["get", "post", "delete"] = "get", + json: dict[str, t.Any] | None = None, + file: t.BinaryIO | None = None, + ) -> t.Any: """ Make a request to the Rio API. """ @@ -137,7 +137,7 @@ class RioApi: await self.request("/auth/expireToken", method="post") - async def get_user(self) -> dict[str, Any]: + async def get_user(self) -> dict[str, t.Any]: """ Return the user's information, if logged in. """ @@ -148,8 +148,8 @@ class RioApi: self, *, name: str, - packed_app: BinaryIO, - realm: Literal["pro", "free", "test"], + packed_app: t.BinaryIO, + realm: t.Literal["pro", "free", "test"], start: bool, ) -> None: assert self.is_logged_in, "Must be logged in to create/update an app" diff --git a/rio/cli/run_project/app_loading.py b/rio/cli/run_project/app_loading.py index 9f96c523..36de3abe 100644 --- a/rio/cli/run_project/app_loading.py +++ b/rio/cli/run_project/app_loading.py @@ -1,16 +1,18 @@ import functools import html -import importlib import os import sys import traceback import types +import typing as t from pathlib import Path -from typing import * # type: ignore +import path_imports import revel import rio +import rio.global_state +import rio.app_server.fastapi_server from rio import icon_registry from ... import project_config @@ -32,7 +34,7 @@ def traceback_frame_filter(frame: traceback.FrameSummary) -> bool: def make_traceback_html( *, - err: Union[str, BaseException], + err: t.Union[str, BaseException], project_directory: Path, ) -> str: error_icon_svg = icon_registry.get_icon_svg("material/error") @@ -70,7 +72,7 @@ def make_traceback_html( def make_error_message_component( - err: Union[str, BaseException], + err: t.Union[str, BaseException], project_directory: Path, ) -> rio.Component: html = make_traceback_html( @@ -86,9 +88,9 @@ def make_error_message_component( def make_error_message_app( - err: Union[str, BaseException], + err: t.Union[str, BaseException], project_directory: Path, - theme: rio.Theme | Tuple[rio.Theme, rio.Theme], + theme: rio.Theme | tuple[rio.Theme, rio.Theme], ) -> rio.App: """ Creates an app that displays the given error message. @@ -101,6 +103,77 @@ def make_error_message_app( ) +def modules_in_directory(project_path: Path) -> t.Iterable[str]: + """ + Returns all currently loaded modules that reside in the given directory (or + any subdirectory thereof). As a second condition, modules located inside of + a virtual environment are not returned. + + The purpose of this is to yield all modules that belong to the user's + project. This is the set of modules that makes sense to reload when the user + makes a change. + """ + # Resolve the path to avoid issues with symlinks + project_path = project_path.resolve() + + # Paths known to be virtual environments, or not. All contained paths are + # absolute. + # + # This acts as a cache to avoid hammering the filesystem. + virtual_environment_paths: dict[Path, bool] = {} + + def is_virtualenv_dir(path: Path) -> bool: + # Resolve the path to make sure we're dealing in absolutes and to avoid + # issues with symlinks + path = path.resolve() + + # Cached? + try: + return virtual_environment_paths[path] + except KeyError: + pass + + # Nope. Is this a venv? + result = (path / "pyvenv.cfg").exists() + + # Cache & return + virtual_environment_paths[path] = result + return result + + # Walk all modules + for name, module in list(sys.modules.items()): + # Special case: Unloading Rio, while Rio is running is not that smart. + if name == "rio" or name.startswith("rio."): + continue + + # Where does the module live? + try: + module_path = getattr(module, "__file__", None) + except AttributeError: + continue + + try: + module_path = Path(module_path).resolve() # type: ignore + except TypeError: + continue + + # If the module isn't inside of the project directory, skip it + if not module_path.is_relative_to(project_path): + continue + + # Check all parent directories for virtual environments, up to the + # project directory + for parent in module_path.parents: + # If we've reached the project directory, stop + if parent == project_path: + yield name + break + + # If this is a virtual environment, skip the module + if is_virtualenv_dir(parent): + break + + def import_app_module( proj: project_config.RioProjectConfig, ) -> types.ModuleType: @@ -108,30 +181,42 @@ def import_app_module( Python's importing is bizarre. This function tries to hide all of that and imports the module, as specified by the user. This can raise a variety of exceptions, since the module's code is evaluated. + + The module will be freshly imported, even if it was already imported before. """ - # Purge the module from the module cache - app_main_module = proj.app_main_module - root_module, _, _ = app_main_module.partition(".") + # Purge all modules that belong to the project. While the main module name + # is known, deleting only that isn't enough in all projects. In complex + # project structures it can be useful to have the UI code live in a module + # that is then just loaded into a top-level Python file. + for module_name in modules_in_directory(proj.project_directory): + del sys.modules[module_name] - for module_name in list(sys.modules): - if module_name.partition(".")[0] == root_module: - del sys.modules[module_name] + # 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 - # 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) - - # Now (re-)import the app module + # 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(app_main_module) + 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: +def load_user_app( + proj: project_config.RioProjectConfig, +) -> tuple[ + rio.App, + rio.app_server.fastapi_server.FastapiServer | None, +]: """ Load and return the user app. Raises `AppLoadError` if the app can't be loaded for whichever reason. @@ -152,41 +237,58 @@ def load_user_app(proj: project_config.RioProjectConfig) -> rio.App: raise AppLoadError() from err - # Find the variable holding the Rio app - apps: list[tuple[str, rio.App]] = [] - for var_name, var in app_module.__dict__.items(): - if isinstance(var, rio.App): - apps.append((var_name, var)) + # Find the variable holding the Rio app. + # + # There are two cases here. Typically, there will be an instance of + # `rio.App` somewhere. However, in order for users to be able to add custom + # routes, there might also be a variable storing a `fastapi.FastAPI`, or, in + # our case, an Rio's subclass thereof. If that is present, prefer it over + # the plain Rio app. + as_fastapi_apps: list[ + tuple[str, rio.app_server.fastapi_server.FastapiServer] + ] = [] + rio_apps: list[tuple[str, rio.App]] = [] + for var_name, var in app_module.__dict__.items(): + if isinstance(var, rio.app_server.fastapi_server.FastapiServer): + as_fastapi_apps.append((var_name, var)) + + elif isinstance(var, rio.App): + rio_apps.append((var_name, var)) + + # Prepare the main file name if app_module.__file__ is None: main_file_reference = f"Your app's main file" else: main_file_reference = f"The file `{Path(app_module.__file__).relative_to(proj.project_directory)}`" - if len(apps) == 0: + # Which type of app do we have? + # + # Case: FastAPI app + if len(as_fastapi_apps) > 0: + app_list = as_fastapi_apps + app_server = as_fastapi_apps[0][1] + app_instance = app_server.app + # Case: Rio app + elif len(rio_apps) > 0: + app_list = rio_apps + app_server = None + app_instance = rio_apps[0][1] + # Case: No app + else: raise AppLoadError( f"Cannot find your app. {main_file_reference} needs to to define a" f" variable that is a Rio app. Something like `app = rio.App(...)`" ) - if len(apps) > 1: + # Make sure there was only one app to choose from, within the chosen + # category + if len(app_list) > 1: variables_string = ( - "`" + "`, `".join(var_name for var_name, _ in apps) + "`" + "`" + "`, `".join(var_name for var_name, _ in app_list) + "`" ) raise AppLoadError( 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. - app._main_file = proj.app_main_module_path - - app._compute_assets_dir() - - app._load_pages() - app._raw_pages = app.pages # Prevent auto_detect_pages() from running twice - - return app + return app_instance, app_server diff --git a/rio/cli/run_project/arbiter.py b/rio/cli/run_project/arbiter.py index a533269a..370e07b0 100644 --- a/rio/cli/run_project/arbiter.py +++ b/rio/cli/run_project/arbiter.py @@ -6,9 +6,9 @@ import socket import sys import threading import time +import typing as t from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import * # type: ignore import httpx import revel @@ -32,7 +32,7 @@ from . import ( try: import webview # type: ignore except ImportError: - if TYPE_CHECKING: + if t.TYPE_CHECKING: import webview # type: ignore else: webview = None @@ -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: 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 @@ -166,7 +167,7 @@ class Arbiter: return f"http://{local_ip}:{self.port}" @property - def running_tasks(self) -> Iterator[asyncio.Task[None]]: + def running_tasks(self) -> t.Iterator[asyncio.Task[None]]: for task in ( self._uvicorn_task, self._file_watcher_task, @@ -288,21 +289,32 @@ class Arbiter: ) self._webview_worker.request_stop() - def try_load_app(self) -> tuple[rio.App, Exception | None]: + def try_load_app( + self, + ) -> tuple[ + rio.App, + rio.app_server.fastapi_server.FastapiServer | None, + Exception | None, + ]: """ Tries to load the user's app. If it fails, a dummy app is created and returned, unless running in release mode. - Returns the new app and the error that occurred, if any. + Returns the app instance, the app's server instance and an exception if + the app could not be loaded. + + The app server is returned in case the user has called `as_fastapi` on + their app instance. In that case the actual fastapi app should be + hosted, so any custom routes take effect. """ rio.cli._logger.debug("Trying to load the app") try: - app = app_loading.load_user_app(self.proj) + app, app_server = app_loading.load_user_app(self.proj) except app_loading.AppLoadError as err: if err.__cause__ is not None: - err = cast(Exception, err.__cause__) + err = t.cast(Exception, err.__cause__) # Announce the problem in the terminal rio.cli._logger.critical(f"The app could not be loaded: {err}") @@ -321,6 +333,7 @@ class Arbiter: self.proj.project_directory, self._app_theme, ), + None, err, ) @@ -328,7 +341,7 @@ class Arbiter: # this theme will be used for it. self._app_theme = app._theme - return app, None + return app, app_server, None def run(self) -> None: assert not self._stop_requested.is_set() @@ -504,7 +517,7 @@ class Arbiter: apply_monkeypatches() # Try to load the app - app, _ = self.try_load_app() + app, app_server, _ = self.try_load_app() # Start the file watcher if self.debug_mode: @@ -527,6 +540,7 @@ class Arbiter: self._uvicorn_worker = uvicorn_worker.UvicornWorker( push_event=self.push_event, app=app, + app_server=app_server, socket=sock, quiet=self.quiet, debug_mode=self.debug_mode, @@ -668,7 +682,7 @@ class Arbiter: else: raise NotImplementedError(f'Unknown event "{event}"') - def _spawn_traceback_popups(self, err: Union[str, BaseException]) -> None: + def _spawn_traceback_popups(self, err: t.Union[str, BaseException]) -> None: """ Displays a popup with the traceback in the rio UI. """ @@ -730,10 +744,10 @@ window.setConnectionLostPopupVisible(true); await app_server._call_on_app_close() # Load the user's app again - new_app, loading_error = self.try_load_app() + new_app, new_app_server, loading_error = self.try_load_app() # Replace the app which is currently hosted by uvicorn - self._uvicorn_worker.replace_app(new_app) + self._uvicorn_worker.replace_app(new_app, new_app_server) # The app has changed, but the uvicorn server is still the same. # Because of this, uvicorn won't call the `on_app_start` function - diff --git a/rio/cli/run_project/file_watcher_worker.py b/rio/cli/run_project/file_watcher_worker.py index c8612c7f..c43c5e70 100644 --- a/rio/cli/run_project/file_watcher_worker.py +++ b/rio/cli/run_project/file_watcher_worker.py @@ -1,6 +1,6 @@ import time +import typing as t from pathlib import Path -from typing import * # type: ignore import watchfiles @@ -12,7 +12,7 @@ class FileWatcherWorker: def __init__( self, *, - push_event: Callable[[run_models.Event], None], + push_event: t.Callable[[run_models.Event], None], proj: project_config.RioProjectConfig, ) -> None: self.push_event = push_event diff --git a/rio/cli/run_project/run_utils.py b/rio/cli/run_project/run_utils.py index f98c0f45..4a34f5fe 100644 --- a/rio/cli/run_project/run_utils.py +++ b/rio/cli/run_project/run_utils.py @@ -1,12 +1,12 @@ import threading -from typing import * # type: ignore +import typing as t -T = TypeVar("T") +T = t.TypeVar("T") -class ThreadsafeFuture(Generic[T]): +class ThreadsafeFuture(t.Generic[T]): def __init__(self) -> None: - self._result_value: Any + self._result_value: t.Any self._event = threading.Event() def set_result(self, result: T) -> None: diff --git a/rio/cli/run_project/uvicorn_worker.py b/rio/cli/run_project/uvicorn_worker.py index ff2a15f4..d55eaf3d 100644 --- a/rio/cli/run_project/uvicorn_worker.py +++ b/rio/cli/run_project/uvicorn_worker.py @@ -1,15 +1,17 @@ import asyncio import socket -from typing import * # type: ignore +import typing as t import revel import uvicorn import uvicorn.lifespan.on +from starlette.types import Receive, Scope, Send import rio import rio.app_server.fastapi_server import rio.cli +from ... import utils from .. import nice_traceback from . import run_models @@ -18,8 +20,9 @@ class UvicornWorker: def __init__( self, *, - push_event: Callable[[run_models.Event], None], + push_event: t.Callable[[run_models.Event], None], app: rio.App, + app_server: rio.app_server.fastapi_server.FastapiServer | None, socket: socket.socket, quiet: bool, debug_mode: bool, @@ -28,7 +31,6 @@ class UvicornWorker: base_url: rio.URL | None, ) -> None: self.push_event = push_event - self.app = app self.socket = socket self.quiet = quiet self.debug_mode = debug_mode @@ -36,15 +38,19 @@ class UvicornWorker: self.on_server_is_ready_or_failed = on_server_is_ready_or_failed self.base_url = base_url - # The app server used to host the app + # The app server used to host the app. + # + # This can optionally be provided to the constructor. If not, it will be + # created when the worker is started. This allows for the app to be + # either a Rio app or a FastAPI app (derived from a Rio app). + self.app = app self.app_server: rio.app_server.fastapi_server.FastapiServer | None = ( None ) - async def run(self) -> None: - rio.cli._logger.debug("Uvicorn worker is starting") + self.replace_app(app, app_server) - # Set up a uvicorn server, but don't start it yet + def _create_and_store_app_server(self) -> None: app_server = self.app._as_fastapi( debug_mode=self.debug_mode, running_in_window=self.run_in_window, @@ -58,8 +64,33 @@ class UvicornWorker: ) self.app_server = app_server + async def run(self) -> None: + rio.cli._logger.debug("Uvicorn worker is starting") + + # Create the app server + if self.app_server is None: + self._create_and_store_app_server() + assert self.app_server is not None + + # Instead of using the ASGI app directly, create a transparent shim that + # redirect's to the worker's currently stored app server. This allows + # replacing the app server at will because the shim always remains the + # same. + # + # ASGI is a bitch about function signatures. This function cannot be a + # simple method, because the added `self` parameter seems to confused + # whoever the caller is. Hence the nested function. + async def _asgi_shim( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + assert self.app_server is not None + await self.app_server(scope, receive, send) + + # Set up a uvicorn server, but don't start it yet config = uvicorn.Config( - self.app_server, + app=_asgi_shim, log_config=None, # Prevent uvicorn from configuring global logging log_level="error" if self.quiet else "info", timeout_graceful_shutdown=1, # Without a timeout the server sometimes deadlocks @@ -84,7 +115,7 @@ class UvicornWorker: # output in the console. This monkeypatch suppresses that. original_receive = uvicorn.lifespan.on.LifespanOn.receive - async def patched_receive(self) -> Any: + async def patched_receive(self) -> t.Any: try: return await original_receive(self) except asyncio.CancelledError: @@ -120,11 +151,39 @@ class UvicornWorker: finally: rio.cli._logger.debug("Uvicorn serve task has ended") - def replace_app(self, app: rio.App) -> None: + def replace_app( + self, + app: rio.App, + app_server: rio.app_server.fastapi_server.FastapiServer | None, + ) -> None: """ Replace the app currently running in the server with a new one. The worker must already be running for this to work. """ + # Store the new app + self.app = app + + # And create a new app server. This is necessary, because the mounted + # sub-apps may have changed. This ensures they're up to date. + if app_server is None: + self._create_and_store_app_server() + else: + self.app_server = app_server + + self.app_server.debug_mode = self.debug_mode + self.app_server.running_in_window = self.run_in_window + self.app_server.internal_on_app_start = ( + lambda: self.on_server_is_ready_or_failed.set_result(None) + ) + + if self.base_url is None: + self.app_server.base_url = None + else: + self.app_server.base_url = utils.normalize_url(self.base_url) + assert self.app_server is not None - rio.cli._logger.debug("Replacing the app in the server") - self.app_server.app = app + assert self.app_server.app is self.app + + # There is no need to inject the new app or server anywhere. Since + # uvicorn was fed a shim function instead of the app directly, any + # requests will automatically be redirected to the new server instance. diff --git a/rio/cli/run_project/webview_worker.py b/rio/cli/run_project/webview_worker.py index 32fd063f..4998d092 100644 --- a/rio/cli/run_project/webview_worker.py +++ b/rio/cli/run_project/webview_worker.py @@ -1,13 +1,13 @@ import threading import time -from typing import * # type: ignore +import typing as t from . import run_models try: import webview # type: ignore except ImportError: - if TYPE_CHECKING: + if t.TYPE_CHECKING: import webview # type: ignore else: webview = None @@ -17,7 +17,7 @@ class WebViewWorker: def __init__( self, *, - push_event: Callable[[run_models.Event], None], + push_event: t.Callable[[run_models.Event], None], debug_mode: bool, url: str, ) -> None: @@ -26,7 +26,7 @@ class WebViewWorker: self.url = url # If running, this is the webview window - self.window: Optional[webview.Window] = None + self.window: webview.Window | None = None def run(self) -> None: """ diff --git a/rio/color.py b/rio/color.py index 118658a6..d2f644de 100644 --- a/rio/color.py +++ b/rio/color.py @@ -2,9 +2,9 @@ from __future__ import annotations import colorsys import math -from typing import * # type: ignore +import typing as t -from typing_extensions import TypeAlias +import typing_extensions as te from uniserde import Jsonable import rio @@ -17,7 +17,7 @@ __all__ = [ ] -@final +@t.final class Color(SelfSerializing): """ A color, optionally with an opacity. @@ -478,7 +478,7 @@ class Color(SelfSerializing): opacity=self.opacity if opacity is None else opacity, ) - def _map_rgb(self, func: Callable[[float], float]) -> "Color": + def _map_rgb(self, func: t.Callable[[float], float]) -> "Color": """ Apply a function to each of the RGB values of this color, and return a new `Color` instance with the result. The opacity value is copied @@ -631,28 +631,28 @@ class Color(SelfSerializing): return hash(self.rgba) # Greys - BLACK: ClassVar["Color"] - GREY: ClassVar["Color"] - WHITE: ClassVar["Color"] + BLACK: t.ClassVar["Color"] + GREY: t.ClassVar["Color"] + WHITE: t.ClassVar["Color"] # Pure colors - RED: ClassVar["Color"] - GREEN: ClassVar["Color"] - BLUE: ClassVar["Color"] + RED: t.ClassVar["Color"] + GREEN: t.ClassVar["Color"] + BLUE: t.ClassVar["Color"] # CMY - CYAN: ClassVar["Color"] - MAGENTA: ClassVar["Color"] - YELLOW: ClassVar["Color"] + CYAN: t.ClassVar["Color"] + MAGENTA: t.ClassVar["Color"] + YELLOW: t.ClassVar["Color"] # Others - PINK: ClassVar["Color"] - PURPLE: ClassVar["Color"] - ORANGE: ClassVar["Color"] - BROWN: ClassVar["Color"] + PINK: t.ClassVar["Color"] + PURPLE: t.ClassVar["Color"] + ORANGE: t.ClassVar["Color"] + BROWN: t.ClassVar["Color"] # Special - TRANSPARENT: ClassVar["Color"] + TRANSPARENT: t.ClassVar["Color"] Color.BLACK = Color.from_rgb(0.0, 0.0, 0.0) @@ -675,9 +675,9 @@ Color.TRANSPARENT = Color.from_rgb(0.0, 0.0, 0.0, 0.0) # Like color, but also allows referencing theme colors -ColorSet: TypeAlias = ( +ColorSet: te.TypeAlias = ( Color - | Literal[ + | t.Literal[ "background", "neutral", "hud", @@ -693,4 +693,4 @@ ColorSet: TypeAlias = ( # Cache so the session can quickly determine whether a type annotation is # `ColorSet` -_color_set_args = set(get_args(ColorSet)) +_color_set_args = set(t.get_args(ColorSet)) diff --git a/rio/component_meta.py b/rio/component_meta.py index bffff9c2..74adf678 100644 --- a/rio/component_meta.py +++ b/rio/component_meta.py @@ -2,14 +2,14 @@ from __future__ import annotations import asyncio import sys +import typing as t import warnings import weakref from collections import defaultdict from dataclasses import field -from typing import * import introspection -from typing_extensions import dataclass_transform +import typing_extensions as te import rio @@ -22,12 +22,12 @@ from .warnings import RioPotentialMistakeWarning __all__ = ["ComponentMeta"] -C = TypeVar("C", bound="rio.Component") +C = t.TypeVar("C", bound="rio.Component") # For some reason vscode doesn't understand that this class is a -# `@dataclass_transform`, so we'll annotate it again... -@dataclass_transform( +# `@te.dataclass_transform`, so we'll annotate it again... +@te.dataclass_transform( eq_default=False, field_specifiers=(internal_field, field), ) @@ -41,7 +41,7 @@ class ComponentMeta(RioDataclassMeta): # The assigned value is needed so that the `Component` class itself has a # valid value. All subclasses override this value in `__init_subclass__`. _rio_event_handlers_: defaultdict[ - event.EventTag, list[tuple[Callable, Any]] + event.EventTag, list[tuple[t.Callable, t.Any]] ] # Whether this component class is built into Rio, rather than user defined, @@ -93,7 +93,7 @@ class ComponentMeta(RioDataclassMeta): continue try: - events = member._rio_events_ + events = member._rio_events_ # type: ignore except AttributeError: continue @@ -238,7 +238,7 @@ class ComponentMeta(RioDataclassMeta): async def _periodic_event_worker( weak_component: weakref.ReferenceType[rio.Component], - handler: Callable, + handler: t.Callable, period: float, ) -> None: # Get a handle on the session @@ -264,7 +264,7 @@ async def _periodic_event_worker( async def call_component_handler_once( weak_component: weakref.ReferenceType[rio.Component], - handler: Callable, + handler: t.Callable, ) -> bool: # Does the component still exist? component = weak_component() diff --git a/rio/components/__init__.py b/rio/components/__init__.py index 84fed045..b2d660a8 100644 --- a/rio/components/__init__.py +++ b/rio/components/__init__.py @@ -39,6 +39,7 @@ from .number_input import * from .overlay import * from .page_view import * from .plot import * +from .pointer_event_listener import * from .popup import * from .progress_bar import * from .progress_circle import * diff --git a/rio/components/app_root.py b/rio/components/app_root.py index 3b4adc1e..de81e10a 100644 --- a/rio/components/app_root.py +++ b/rio/components/app_root.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore import rio @@ -116,7 +116,7 @@ class Sidebar(component.Component): class AppRoot(component.Component): _: KW_ONLY - fallback_build: Callable[[], rio.Component] | None = None + fallback_build: t.Callable[[], rio.Component] | None = None _sidebar_is_open: bool = False diff --git a/rio/components/auto_form.py b/rio/components/auto_form.py index b6e85e1b..7fc03e04 100644 --- a/rio/components/auto_form.py +++ b/rio/components/auto_form.py @@ -2,8 +2,8 @@ from __future__ import annotations import dataclasses import enum +import typing as t from dataclasses import KW_ONLY, dataclass, is_dataclass -from typing import * # type: ignore import rio @@ -20,7 +20,7 @@ def prettify_name(name: str) -> str: @dataclass class AutoFormChangeEvent: field_name: str - value: Any + value: t.Any class AutoForm(component.Component): @@ -32,7 +32,7 @@ class AutoForm(component.Component): `public`: False """ - value: Any + value: t.Any _: KW_ONLY on_change: rio.EventHandler[[AutoFormChangeEvent]] = None @@ -43,7 +43,7 @@ class AutoForm(component.Component): f"The value to `AutoForm` must be a dataclass, not `{type(self.value)}`" ) - async def _update_value(self, field_name: str, value: Any) -> None: + async def _update_value(self, field_name: str, value: t.Any) -> None: # Update the value setattr(self, field_name, value) @@ -62,8 +62,8 @@ class AutoForm(component.Component): field_type: type, ) -> rio.Component: # Get sensible type information - origin = get_origin(field_type) - field_args = get_args(field_type) + origin = t.get_origin(field_type) + field_args = t.get_args(field_type) field_type = field_type if origin is None else origin del origin @@ -100,11 +100,11 @@ class AutoForm(component.Component): ) # `Literal` or `Enum` -> `Dropdown` - if field_type is Literal or issubclass(field_type, enum.Enum): - if field_type is Literal: + if field_type is t.Literal or issubclass(field_type, enum.Enum): + if field_type is t.Literal: mapping = {str(a): a for a in field_args} else: - field_type = cast(Type[enum.Enum], field_type) + field_type = t.cast(t.Type[enum.Enum], field_type) mapping = {prettify_name(f.name): f.value for f in field_type} return rio.Dropdown( diff --git a/rio/components/banner.py b/rio/components/banner.py index 68652bb2..a9725e7b 100644 --- a/rio/components/banner.py +++ b/rio/components/banner.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore import rio @@ -19,7 +19,7 @@ ICONS_AND_COLORS: dict[str, tuple[str, rio.ColorSet]] = { } -@final +@t.final class Banner(component.Component): r""" Displays a short message to the user. @@ -84,7 +84,7 @@ class Banner(component.Component): """ text: str | None - style: Literal["info", "success", "warning", "danger"] + style: t.Literal["info", "success", "warning", "danger"] _: KW_ONLY markdown: bool = False diff --git a/rio/components/button.py b/rio/components/button.py index f3350633..67aa9bb1 100644 --- a/rio/components/button.py +++ b/rio/components/button.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore from uniserde import JsonDoc @@ -19,7 +19,7 @@ CHILD_MARGIN_X = 1.0 CHILD_MARGIN_Y = 0.3 -@final +@t.final class Button(Component): """ A clickable button. @@ -112,10 +112,10 @@ class Button(Component): content: str | rio.Component = "" _: KW_ONLY icon: str | None = None - shape: Literal["pill", "rounded", "rectangle"] = "pill" - style: Literal["major", "minor", "colored-text", "plain-text", "plain"] = ( - "major" - ) + shape: t.Literal["pill", "rounded", "rectangle"] = "pill" + style: t.Literal[ + "major", "minor", "colored-text", "plain-text", "plain" + ] = "major" color: rio.ColorSet = "keep" is_sensitive: bool = True is_loading: bool = False @@ -207,10 +207,10 @@ class _ButtonInternal(FundamentalComponent): _: KW_ONLY on_press: rio.EventHandler[[]] content: rio.Component - shape: Literal["pill", "rounded", "rectangle", "circle"] - style: Literal["major", "minor", "colored-text", "plain-text", "plain"] = ( - "major" - ) + shape: t.Literal["pill", "rounded", "rectangle", "circle"] + style: t.Literal[ + "major", "minor", "colored-text", "plain-text", "plain" + ] = "major" color: rio.ColorSet is_sensitive: bool is_loading: bool @@ -230,7 +230,7 @@ class _ButtonInternal(FundamentalComponent): return {} - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg assert msg["type"] == "press", msg diff --git a/rio/components/calendar.py b/rio/components/calendar.py index e5878323..fe86d71d 100644 --- a/rio/components/calendar.py +++ b/rio/components/calendar.py @@ -1,8 +1,8 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass from datetime import date -from typing import * # type: ignore from uniserde import JsonDoc @@ -16,7 +16,7 @@ __all__ = [ ] -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class DateChangeEvent: @@ -122,7 +122,7 @@ class Calendar(FundamentalComponent): "firstDayOfWeek": self.session._first_day_of_week, } - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg diff --git a/rio/components/card.py b/rio/components/card.py index d8f71bd8..7d393b49 100644 --- a/rio/components/card.py +++ b/rio/components/card.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore from uniserde import JsonDoc @@ -14,7 +14,7 @@ __all__ = [ ] -@final +@t.final class Card(FundamentalComponent): """ A container that visually encompasses its content. @@ -111,7 +111,7 @@ class Card(FundamentalComponent): colorize_on_hover: bool | None = None color: rio.ColorSet = "neutral" - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Trigger the press event await self.call_event_handler(self.on_press) diff --git a/rio/components/checkbox.py b/rio/components/checkbox.py index 98e463f0..d5426f0f 100644 --- a/rio/components/checkbox.py +++ b/rio/components/checkbox.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore from uniserde import JsonDoc @@ -15,13 +15,25 @@ __all__ = [ ] -@final +@t.final @dataclass class CheckboxChangeEvent: + """ + Holds information regarding a checkbox change event. + + This is a simple dataclass that stores useful information for when the user + switches a `CheckBox` on or off. You'll typically receive this as argument + in `on_change` events. + + ## Attributes + + `is_on`: Whether the checkbox is now ticked. + """ + is_on: bool -@final +@t.final class Checkbox(FundamentalComponent): """ An input for `True` / `False` values. diff --git a/rio/components/class_container.py b/rio/components/class_container.py index f4d362a1..5b5a9aa1 100644 --- a/rio/components/class_container.py +++ b/rio/components/class_container.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Sequence +import typing as t import rio @@ -36,9 +36,9 @@ class ClassContainer(FundamentalComponent): """ content: rio.Component | None - classes: Sequence[str] + classes: t.Sequence[str] - def _get_debug_details_(self) -> dict[str, Any]: + def _get_debug_details_(self) -> dict[str, t.Any]: result = super()._get_debug_details_() result.pop("classes") return result diff --git a/rio/components/code_block.py b/rio/components/code_block.py index 3e124f58..d3da3c33 100644 --- a/rio/components/code_block.py +++ b/rio/components/code_block.py @@ -1,5 +1,5 @@ +import typing as t from dataclasses import KW_ONLY -from typing import final from .. import deprecations from .fundamental_component import FundamentalComponent @@ -9,7 +9,7 @@ __all__ = [ ] -@final +@t.final @deprecations.component_kwarg_renamed( since="0.9.2", old_name="display_controls", diff --git a/rio/components/code_explorer.py b/rio/components/code_explorer.py index 1ad76282..bc492ca1 100644 --- a/rio/components/code_explorer.py +++ b/rio/components/code_explorer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, Sequence +import typing as t from uniserde import JsonDoc @@ -22,9 +22,9 @@ class CodeExplorer(FundamentalComponent): source_code: str build_result: rio.Component - line_indices_to_component_keys: Sequence[str | int | None] + line_indices_to_component_keys: t.Sequence[str | int | None] - style: Literal["horizontal", "vertical"] = "horizontal" + style: t.Literal["horizontal", "vertical"] = "horizontal" def _custom_serialize_(self) -> JsonDoc: return { diff --git a/rio/components/color_picker.py b/rio/components/color_picker.py index 37825936..b120a862 100644 --- a/rio/components/color_picker.py +++ b/rio/components/color_picker.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Any, final from uniserde import JsonDoc @@ -15,7 +15,7 @@ __all__ = [ ] -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class ColorChangeEvent: @@ -34,7 +34,7 @@ class ColorChangeEvent: color: rio.Color -@final +@t.final class ColorPicker(FundamentalComponent): """ Allows the user to pick a RGB(A) color. @@ -101,7 +101,7 @@ class ColorPicker(FundamentalComponent): pick_opacity: bool = False on_change: rio.EventHandler[ColorChangeEvent] = None - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg diff --git a/rio/components/component.py b/rio/components/component.py index ae59e86e..e60b5ef6 100644 --- a/rio/components/component.py +++ b/rio/components/component.py @@ -2,13 +2,12 @@ from __future__ import annotations import abc import io +import typing as t from abc import abstractmethod -from collections.abc import Callable, Iterable from dataclasses import KW_ONLY from pathlib import Path -from typing import * # type: ignore -from typing_extensions import Self +import typing_extensions as te from uniserde import Jsonable, JsonDoc import rio @@ -22,7 +21,7 @@ from ..state_properties import AttributeBindingMaker __all__ = ["Component"] -T = TypeVar("T") +T = t.TypeVar("T") # Using `metaclass=ComponentMeta` makes this an abstract class, but since @@ -223,8 +222,8 @@ class Component(abc.ABC, metaclass=ComponentMeta): align_x: float | None = None align_y: float | None = None - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never" - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never" + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never" + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never" margin_left: float | None = None margin_top: float | None = None @@ -242,13 +241,13 @@ class Component(abc.ABC, metaclass=ComponentMeta): # # Dataclasses like to turn this function into a method. Make sure it works # both with and without `self`. - _weak_builder_: Callable[[], Component | None] = internal_field( + _weak_builder_: t.Callable[[], Component | None] = internal_field( default=lambda *args: None, init=False, ) # Weak reference to the component's creator - _weak_creator_: Callable[[], Component | None] = internal_field( + _weak_creator_: t.Callable[[], Component | None] = internal_field( init=False, ) @@ -310,7 +309,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): # Hide this function from type checkers so they don't think that we accept # arbitrary args - if not TYPE_CHECKING: + if not t.TYPE_CHECKING: # Make sure users don't inherit from rio components. Inheriting from # their own components is fine, though. def __init_subclass__(cls, *args, **kwargs): @@ -332,7 +331,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): @staticmethod def _remap_constructor_arguments_(args: tuple, kwargs: dict): - width: float | Literal["grow", "natural"] | None = kwargs.pop( + width: float | t.Literal["grow", "natural"] | None = kwargs.pop( "width", None ) @@ -351,7 +350,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): else: kwargs["min_width"] = width - height: float | Literal["grow", "natural"] | None = kwargs.pop( + height: float | t.Literal["grow", "natural"] | None = kwargs.pop( "height", None ) @@ -379,9 +378,9 @@ class Component(abc.ABC, metaclass=ComponentMeta): """ return self._session_ - # There isn't really a good type annotation for this... Self is the closest + # There isn't really a good type annotation for this... `te.Self` is the closest # thing - def bind(self) -> Self: + def bind(self) -> te.Self: return AttributeBindingMaker(self) # type: ignore def _custom_serialize_(self) -> JsonDoc: @@ -406,7 +405,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): """ raise NotImplementedError() # pragma: no cover - def _iter_direct_children_(self) -> Iterable[Component]: + def _iter_direct_children_(self) -> t.Iterable[Component]: for name in inspection.get_child_component_containing_attribute_names( type(self) ): @@ -419,7 +418,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): yield value if isinstance(value, list): - value = cast(list[object], value) + value = t.cast(list[object], value) for item in value: if isinstance(item, Component): @@ -430,7 +429,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): *, include_self: bool, recurse_into_high_level_components: bool, - ) -> Iterable[Component]: + ) -> t.Iterable[Component]: from . import fundamental_component # Avoid circular import problem # Special case the component itself to handle `include_self` @@ -455,7 +454,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): def _iter_component_tree_( self, *, include_root: bool = True - ) -> Iterable[Component]: + ) -> t.Iterable[Component]: """ Iterate over all components in the component tree, with this component as the root. """ @@ -543,13 +542,13 @@ class Component(abc.ABC, metaclass=ComponentMeta): cache[self] = result return result - @overload + @t.overload async def call_event_handler( self, handler: rio.EventHandler[[]], ) -> None: ... # pragma: no cover - @overload + @t.overload async def call_event_handler( self, handler: rio.EventHandler[[T]], @@ -607,7 +606,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): await self.session._refresh() - def _get_debug_details_(self) -> dict[str, Any]: + def _get_debug_details_(self) -> dict[str, t.Any]: """ Used by Rio's dev tools to decide which properties to display to a user, when they select a component. @@ -636,7 +635,7 @@ class Component(abc.ABC, metaclass=ComponentMeta): return result + ">" - def _repr_tree_worker_(self, file: IO[str], indent: str) -> None: + def _repr_tree_worker_(self, file: t.IO[str], indent: str) -> None: file.write(indent) file.write(repr(self)) diff --git a/rio/components/container.py b/rio/components/container.py index b1ae0988..70e1c746 100644 --- a/rio/components/container.py +++ b/rio/components/container.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class Container(Component): """ An invisible component holding a single child. diff --git a/rio/components/date_input.py b/rio/components/date_input.py index e8d17f3e..4afac750 100644 --- a/rio/components/date_input.py +++ b/rio/components/date_input.py @@ -1,95 +1,51 @@ from __future__ import annotations import random -from dataclasses import KW_ONLY -from datetime import date -from typing import final +import typing as t +from dataclasses import KW_ONLY, dataclass +from datetime import date, datetime import rio +import rio.docs from .. import components as comps from .component import Component __all__ = [ "DateInput", + "DateConfirmEvent", ] -def make_fake_input_box( - *, - theme: rio.Theme, - label: str, - value: str, -) -> rio.Component: - palette = theme.neutral_palette +@t.final +@rio.docs.mark_constructor_as_private +@dataclass +class DateConfirmEvent: + """ + Holds information regarding a date confirm event. - label_style = rio.TextStyle( - fill=theme.secondary_color, - font_size=0.8, - ) + This is a simple dataclass that stores useful information for when the user + confirms the date in `DateInput`. You'll typically receive this as argument + in `on_confirm` events. - return rio.Column( - # The background rectangle - rio.Rectangle( - content=rio.Column( - rio.Text( - label, - selectable=False, - style=label_style, - ), - rio.Row( - rio.Text( - value, - justify="left", - selectable=False, - margin_bottom=0.4, - align_y=1, - grow_x=True, - ), - rio.Icon( - "material/calendar_today:fill", - fill="dim", - min_width=1.5, - min_height=1.5, - margin_bottom=0.3, - align_y=1, - ), - spacing=0.8, - grow_y=True, - ), - # spacing=0.2, - margin_x=1, - margin_top=0.5, - ), - fill=palette.background, - hover_fill=palette.background_active, - corner_radius=( - theme.corner_radius_small, - theme.corner_radius_small, - 0, - 0, - ), - cursor=rio.CursorStyle.POINTER, - grow_y=True, - transition_time=0.1, - ), - # The line at the bottom - rio.Rectangle( - fill=palette.foreground.replace(opacity=0.25), - min_height=0.12, - ), - min_width=9, - ) + ## Attributes + + `value`: The newly selected date. + """ + + value: date -@final +# TODO: Make pop-up optional? Maybe a attribute to disable it? +@t.final class DateInput(Component): """ Allows the user to pick a date from a calendar. DateInputs are similar in appearance to `TextInput` and `NumberInput`, but - allow the user to pick a date from a calendar, rather than text or number. - When pressed, a calendar will pop-up, allowing the user to select a date. + allow the user to pick a date from a calendar or enter a date, rather than + text or number. When pressed, a calendar will pop-up, allowing the user to + select a date. This makes for a compact component, which still allows the user to visually select a date. @@ -104,8 +60,16 @@ class DateInput(Component): `label`: A short text to display next to the input field. + `style`: Changes the visual appearance of the date input. + + `position`: The location at which the popup opens, relative to the anchor. + `on_change`: Triggered whenever the user selects a new date. + `on_confirm`: Triggered when the user explicitly confirms their input, + such as by pressing the "Enter" key. You can use this to trigger + followup actions, such as logging in or submitting a form. + ## Examples @@ -167,10 +131,38 @@ class DateInput(Component): _: KW_ONLY label: str = "" + accessibility_label: str = "" + style: t.Literal["underlined", "rounded", "pill"] = "underlined" + position: t.Literal[ + "auto", "left", "top", "right", "bottom", "center", "fullscreen" + ] = "auto" + on_change: rio.EventHandler[rio.DateChangeEvent] = None + on_confirm: rio.EventHandler[DateConfirmEvent] = None _is_open: bool = False + def _try_set_value(self, raw_value: str) -> bool: + """ + Parse the given string and update the component's value accordingly. + Returns `True` if the value was successfully updated, `False` otherwise. + """ + # try to parse the date string + try: + # Maybe we can do some more advanced parsing here, but OK for now + date_value = datetime.strptime( + raw_value, self.session._date_format_string + ).date() + + # Update the value + self.value = date_value + return True + + except ValueError: + # Force the old value to stay + self.value = self.value + return False + async def _on_value_change(self, event: rio.DateChangeEvent) -> None: # Close the date picker self._is_open = False @@ -178,7 +170,25 @@ class DateInput(Component): # Chain the event handler await self.call_event_handler(self.on_change, event) - def _on_toggle_open(self, _: rio.PressEvent) -> None: + async def _on_confirm(self, ev: rio.TextInputConfirmEvent) -> None: + # Close the date picker + self._is_open = False + + was_updated = self._try_set_value(ev.text) + + # Chain the event handler + if was_updated: + await self.call_event_handler( + self.on_change, + rio.DateChangeEvent(self.value), + ) + + await self.call_event_handler( + self.on_confirm, + DateConfirmEvent(self.value), + ) + + def _on_toggle_open(self, _: rio.TextInputFocusEvent) -> None: self._is_open = not self._is_open def _on_close(self) -> None: @@ -186,15 +196,24 @@ class DateInput(Component): def build(self) -> rio.Component: return rio.Popup( - # Place a fake textbox. It's only used for styling and displaying - # the label, if any - anchor=rio.MouseEventListener( - content=make_fake_input_box( - theme=self.session.theme, + # Display the input field + anchor=rio.Stack( + rio.TextInput( label=self.label, - value=self.value.strftime(self.session._date_format_string), + text=self.value.strftime(self.session._date_format_string), + on_gain_focus=self._on_toggle_open, + on_confirm=self._on_confirm, + style=self.style, + ), + rio.Icon( + "material/calendar_today:fill", + fill="dim", + min_width=1.5, + min_height=1.5, + margin_right=0.5, + align_y=0.5, + align_x=1, ), - on_press=self._on_toggle_open, ), # Display a calendar so the user can pick a date content=rio.Column( @@ -213,7 +232,7 @@ class DateInput(Component): margin=1, ), color="neutral", - position="center", + position=self.position, alignment=0.5, is_open=self._is_open, ) diff --git a/rio/components/default_root_component.py b/rio/components/default_root_component.py index c6f7ea73..4538a5b2 100644 --- a/rio/components/default_root_component.py +++ b/rio/components/default_root_component.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -33,7 +33,7 @@ def with_debug_tooltip( return comp -@final +@t.final class NavButton(component.Component): page: rio.ComponentPage is_current: bool @@ -89,7 +89,7 @@ class NavButton(component.Component): return main_row -@final +@t.final class DefaultRootComponent(component.Component): """ ## Metadata @@ -147,15 +147,12 @@ class DefaultRootComponent(component.Component): ) # Which page is currently active? - # Prepare the URL - segments = self.session.active_page_url.parts - - # Special case: Not deep enough - if len(segments) <= 1: - # This won't match anything because it's not a valid URL segment" - current_page_url = "///" - else: - current_page_url = segments[1] + try: + active_page = self.session.active_page_instances[0] + except IndexError: + # Special case: No page is active. Use a value that won't match any + # page + active_page = None # Add navigation # @@ -174,7 +171,7 @@ class DefaultRootComponent(component.Component): pages.add( NavButton( page, - is_current=page.url_segment == current_page_url, + is_current=page is active_page, ) ) @@ -184,14 +181,18 @@ class DefaultRootComponent(component.Component): # Explain to the user how to get rid of this navigation if self.session._app_server.debug_mode: main_column.add( - rio.Link( - "What's this?", - icon="material/library_books", - target_url="https://rio.dev/TODO/LINK/TO/HOWTO/REMOVE/NAVIGATION", - open_in_new_tab=True, - margin_x=OUTER_MARGIN, - margin_y=1, - align_x=0.5, + rio.Tooltip( + anchor=rio.Link( + "What's this?", + icon="material/library_books", + target_url="https://rio.dev/docs/howto/remove-default-navbar", + open_in_new_tab=True, + margin_x=OUTER_MARGIN, + margin_y=1, + align_x=0.5, + ), + tip="Only visible in debug mode. Follow the link for a guide on how to replace this navigation.", + position="right", ) ) diff --git a/rio/components/devel_component.py b/rio/components/devel_component.py index ef157bc3..1641e5b2 100644 --- a/rio/components/devel_component.py +++ b/rio/components/devel_component.py @@ -3,10 +3,9 @@ from __future__ import annotations import concurrent.futures import subprocess import tempfile -from collections.abc import Iterable +import typing as t from dataclasses import field from pathlib import Path -from typing import Sequence import rio @@ -31,12 +30,12 @@ class DevelComponent(FundamentalComponent): `public`: False """ - children: Sequence[rio.Component] = field(default_factory=list) + children: t.Sequence[rio.Component] = field(default_factory=list) def __init__( self, *, - children: Iterable[rio.Component], + children: t.Iterable[rio.Component], key: str | int | None = None, margin: float | None = None, margin_x: float | None = None, @@ -53,8 +52,8 @@ class DevelComponent(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, diff --git a/rio/components/dialog_container.py b/rio/components/dialog_container.py index eccd529d..830f5f8c 100644 --- a/rio/components/dialog_container.py +++ b/rio/components/dialog_container.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -12,9 +12,9 @@ __all__ = [ ] -@final +@t.final class DialogContainer(Component): - build_content: Callable[[], Component] + build_content: t.Callable[[], Component] owning_component_id: int is_modal: bool is_user_closeable: bool @@ -27,7 +27,7 @@ class DialogContainer(Component): # 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. - def serialize(self) -> dict[str, Any]: + def serialize(self) -> dict[str, t.Any]: return { "owning_component_id": self.owning_component_id, "is_modal": self.is_modal, diff --git a/rio/components/drawer.py b/rio/components/drawer.py index 2e726b20..ecab42fc 100644 --- a/rio/components/drawer.py +++ b/rio/components/drawer.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Literal, final from uniserde import JsonDoc @@ -15,7 +15,7 @@ __all__ = [ ] -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class DrawerOpenOrCloseEvent: @@ -34,7 +34,7 @@ class DrawerOpenOrCloseEvent: is_open: bool -@final +@t.final class Drawer(FundamentalComponent): """ A container which slides in from the edge of the screen. @@ -114,7 +114,7 @@ class Drawer(FundamentalComponent): content: rio.Component _: KW_ONLY on_open_or_close: rio.EventHandler[DrawerOpenOrCloseEvent] = None - side: Literal["left", "right", "top", "bottom"] = "left" + side: t.Literal["left", "right", "top", "bottom"] = "left" is_modal: bool = True is_open: bool = False is_user_openable: bool = True diff --git a/rio/components/dropdown.py b/rio/components/dropdown.py index 305159b1..81f44852 100644 --- a/rio/components/dropdown.py +++ b/rio/components/dropdown.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Any, Generic, TypeVar, final from uniserde import JsonDoc @@ -15,13 +14,13 @@ __all__ = [ "DropdownChangeEvent", ] -T = TypeVar("T") +T = t.TypeVar("T") -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass -class DropdownChangeEvent(Generic[T]): +class DropdownChangeEvent(t.Generic[T]): """ Holds information regarding a dropdown change event. @@ -37,8 +36,8 @@ class DropdownChangeEvent(Generic[T]): value: T -@final -class Dropdown(FundamentalComponent, Generic[T]): +@t.final +class Dropdown(FundamentalComponent, t.Generic[T]): """ A dropdown menu for selecting from one of several options. @@ -55,6 +54,8 @@ class Dropdown(FundamentalComponent, Generic[T]): `label`: A short text to display next to the dropdown. + `style`: Changes the visual appearance of the text input. + `selected_value`: The value of the currently selected option. `is_sensitive`: Whether the dropdown should respond to user input. @@ -122,9 +123,10 @@ class Dropdown(FundamentalComponent, Generic[T]): ``` """ - options: Mapping[str, T] + options: t.Mapping[str, T] _: KW_ONLY label: str + style: t.Literal["underlined", "rounded", "pill"] selected_value: T is_sensitive: bool is_valid: bool @@ -132,9 +134,10 @@ class Dropdown(FundamentalComponent, Generic[T]): def __init__( self, - options: Mapping[str, T] | Sequence[T], + options: t.Mapping[str, T] | t.Sequence[T], *, label: str = "", + style: t.Literal["underlined", "rounded", "pill"] = "underlined", selected_value: T | None = None, on_change: rio.EventHandler[DropdownChangeEvent[T]] = None, is_sensitive: bool = True, @@ -155,8 +158,8 @@ class Dropdown(FundamentalComponent, Generic[T]): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): if not options: raise ValueError("`Dropdown` must have at least one option.") @@ -182,11 +185,12 @@ class Dropdown(FundamentalComponent, Generic[T]): # SCROLLING-REWORK scroll_y=scroll_y, ) - if isinstance(options, Sequence): + if isinstance(options, t.Sequence): options = {str(value): value for value in options} self.options = options self.label = label + self.style = style self.on_change = on_change self.is_sensitive = is_sensitive self.is_valid = is_valid @@ -224,7 +228,7 @@ class Dropdown(FundamentalComponent, Generic[T]): return result - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg diff --git a/rio/components/file_picker_area.py b/rio/components/file_picker_area.py index 4acde56f..b9fbe657 100644 --- a/rio/components/file_picker_area.py +++ b/rio/components/file_picker_area.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore from uniserde import JsonDoc @@ -17,7 +17,7 @@ __all__ = [ ] -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class FilePickEvent: @@ -36,7 +36,7 @@ class FilePickEvent: file: rio.FileInfo -@final +@t.final class FilePickerArea(FundamentalComponent): """ Drag & Drop are for files diff --git a/rio/components/flow_container.py b/rio/components/flow_container.py index 6d58691a..d32b10b8 100644 --- a/rio/components/flow_container.py +++ b/rio/components/flow_container.py @@ -1,9 +1,9 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import Literal, final -from typing_extensions import Self +import typing_extensions as te from uniserde import JsonDoc import rio @@ -14,7 +14,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["FlowContainer"] -@final +@t.final class FlowContainer(FundamentalComponent): """ A container whose children will be rearranged to fill the available space @@ -54,7 +54,7 @@ class FlowContainer(FundamentalComponent): spacing: float | None row_spacing: float | None column_spacing: float | None - justify: Literal["left", "center", "right", "justify", "grow"] + justify: t.Literal["left", "center", "right", "justify", "grow"] def __init__( self, @@ -62,7 +62,9 @@ class FlowContainer(FundamentalComponent): spacing: float | None = None, row_spacing: float | None = None, column_spacing: float | None = None, - justify: Literal["left", "center", "right", "justify", "grow"] = "left", + justify: t.Literal[ + "left", "center", "right", "justify", "grow" + ] = "left", key: str | int | None = None, margin: float | None = None, margin_x: float | None = None, @@ -79,8 +81,8 @@ class FlowContainer(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: assert isinstance(children, tuple), children @@ -111,7 +113,7 @@ class FlowContainer(FundamentalComponent): self.column_spacing = column_spacing self.justify = justify - def add(self, child: rio.Component) -> Self: + def add(self, child: rio.Component) -> te.Self: """ Appends a child component. diff --git a/rio/components/fundamental_component.py b/rio/components/fundamental_component.py index a59be04c..042458f2 100644 --- a/rio/components/fundamental_component.py +++ b/rio/components/fundamental_component.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import * # type: ignore +import typing as t from uniserde import Jsonable, JsonDoc @@ -70,7 +70,7 @@ document.head.appendChild(style); class FundamentalComponent(Component): # Unique id for identifying this class in the frontend. This is initialized # in `Component.__init_subclass__`. - _unique_id_: ClassVar[str] + _unique_id_: t.ClassVar[str] def build(self) -> rio.Component: raise RuntimeError( @@ -162,7 +162,7 @@ class FundamentalComponent(Component): pass def _apply_delta_state_from_frontend( - self, delta_state: dict[str, Any] + self, delta_state: dict[str, t.Any] ) -> None: """ Applies the delta state received from the frontend without marking the diff --git a/rio/components/grid.py b/rio/components/grid.py index 476b1ebc..11073579 100644 --- a/rio/components/grid.py +++ b/rio/components/grid.py @@ -1,11 +1,10 @@ from __future__ import annotations import math -from collections.abc import Iterable +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import final -from typing_extensions import Self +import typing_extensions as te from uniserde import JsonDoc import rio @@ -15,7 +14,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["Grid"] -@final +@t.final @dataclass class GridChildPosition: row: int @@ -24,7 +23,7 @@ class GridChildPosition: height: int = 1 -@final +@t.final class Grid(FundamentalComponent): """ A container which arranges its children in a table-like grid. @@ -102,7 +101,7 @@ class Grid(FundamentalComponent): def __init__( self, - *rows: rio.Component | Iterable[rio.Component], + *rows: rio.Component | t.Iterable[rio.Component], row_spacing: float = 0.0, column_spacing: float = 0.0, key: str | int | None = None, @@ -121,8 +120,8 @@ class Grid(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, @@ -158,7 +157,7 @@ class Grid(FundamentalComponent): def _add_initial_children( self, - children: Iterable[rio.Component | Iterable[rio.Component]], + children: t.Iterable[rio.Component | t.Iterable[rio.Component]], ) -> tuple[list[rio.Component], list[GridChildPosition]]: """ Adds the children added in the constructor to the component. This is @@ -178,11 +177,19 @@ class Grid(FundamentalComponent): else: row = list(row) + # Don't die on empty rows + if not row: + row = t.cast( + list[rio.Component], + [rio.Spacer(grow_x=False, grow_y=False)], + ) + rows.append(row) row_widths.append(len(row)) # Find the target number of columns target_columns = math.lcm(*row_widths) + assert target_columns > 0, (target_columns, row_widths) # Add the children for yy, row_components in enumerate(rows): @@ -211,7 +218,7 @@ class Grid(FundamentalComponent): *, width: int = 1, height: int = 1, - ) -> Self: + ) -> te.Self: """ Add a child to the grid at a specified position. diff --git a/rio/components/html.py b/rio/components/html.py index d8125f10..8a0231b5 100644 --- a/rio/components/html.py +++ b/rio/components/html.py @@ -1,11 +1,11 @@ -from typing import final +import typing as t from .fundamental_component import FundamentalComponent __all__ = ["Html"] -@final +@t.final class Html(FundamentalComponent): """ Displays raw HTML. diff --git a/rio/components/icon.py b/rio/components/icon.py index 8412c6e8..21112d1f 100644 --- a/rio/components/icon.py +++ b/rio/components/icon.py @@ -1,8 +1,8 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY from pathlib import Path -from typing import Literal, Union, final from uniserde import JsonDoc @@ -14,16 +14,16 @@ __all__ = [ ] -_IconFill = Union[ +_IconFill = t.Union[ "fills.SolidFill", "fills.LinearGradientFill", "fills.ImageFill", "color.ColorSet", - Literal["dim"], + t.Literal["dim"], ] -@final +@t.final class Icon(FundamentalComponent): """ Displays one of many pre-bundled icons. @@ -117,7 +117,7 @@ class Icon(FundamentalComponent): `set_name`: The name of the new icon set. This will be used to access the icons. - `icon_set_archive_path`: The path to the `.tar.xz` archive containing the + `set_archive_path`: The path to the `.tar.xz` archive containing the icon set. """ @@ -184,8 +184,8 @@ class Icon(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, diff --git a/rio/components/icon_button.py b/rio/components/icon_button.py index 66ff078d..5726a8b8 100644 --- a/rio/components/icon_button.py +++ b/rio/components/icon_button.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore from uniserde import JsonDoc @@ -14,7 +14,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["IconButton"] -@final +@t.final @deprecations.component_kwarg_renamed( since="0.10", old_name="size", @@ -109,7 +109,7 @@ class IconButton(Component): icon: str _: KW_ONLY - style: Literal["major", "minor", "colored-text", "plain-text", "plain"] + style: t.Literal["major", "minor", "colored-text", "plain-text", "plain"] color: rio.ColorSet is_sensitive: bool min_size: float @@ -119,7 +119,7 @@ class IconButton(Component): self, icon: str, *, - style: Literal[ + style: t.Literal[ "major", "minor", "colored-text", "plain-text", "plain" ] = "major", color: rio.ColorSet = "keep", @@ -138,8 +138,8 @@ class IconButton(Component): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( key=key, @@ -176,7 +176,7 @@ class IconButton(Component): min_height=self.min_size, ) - def _get_debug_details_(self) -> dict[str, Any]: + def _get_debug_details_(self) -> dict[str, t.Any]: result = super()._get_debug_details_() # `min_width` & `min_height` are replaced with `size` @@ -188,11 +188,11 @@ class IconButton(Component): class _IconButtonInternal(FundamentalComponent): content: rio.Component - style: Literal["major", "minor", "colored-text", "plain-text", "plain"] + style: t.Literal["major", "minor", "colored-text", "plain-text", "plain"] color: rio.ColorSet is_sensitive: bool on_press: rio.EventHandler[[]] - shape: Literal["circle"] = "circle" + shape: t.Literal["circle"] = "circle" def _custom_serialize_(self) -> JsonDoc: if self.style == "plain": @@ -209,7 +209,7 @@ class _IconButtonInternal(FundamentalComponent): return {} - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg assert msg["type"] == "press", msg diff --git a/rio/components/image.py b/rio/components/image.py index dfeb43fc..8b776e9f 100644 --- a/rio/components/image.py +++ b/rio/components/image.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import Literal, final from uniserde import Jsonable, JsonDoc @@ -12,7 +12,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["Image"] -@final +@t.final class Image(FundamentalComponent): """ Displays a raster image or SVG. @@ -111,7 +111,7 @@ class Image(FundamentalComponent): image: ImageLike _: KW_ONLY - fill_mode: Literal["fit", "stretch", "zoom"] = "fit" + fill_mode: t.Literal["fit", "stretch", "zoom"] = "fit" on_error: EventHandler[[]] = None corner_radius: float | tuple[float, float, float, float] = 0 accessibility_description: str = "" @@ -120,7 +120,7 @@ class Image(FundamentalComponent): self, image: ImageLike, *, - fill_mode: Literal["fit", "stretch", "zoom"] = "fit", + fill_mode: t.Literal["fit", "stretch", "zoom"] = "fit", on_error: EventHandler[[]] | None = None, corner_radius: float | tuple[float, float, float, float] = 0, accessibility_description: str = "", @@ -140,8 +140,8 @@ class Image(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( key=key, diff --git a/rio/components/key_event_listener.py b/rio/components/key_event_listener.py index 70c3fe2c..5b731983 100644 --- a/rio/components/key_event_listener.py +++ b/rio/components/key_event_listener.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Any, Literal, final from uniserde import Jsonable @@ -19,7 +19,7 @@ __all__ = [ ] -HardwareKey = Literal[ +HardwareKey = t.Literal[ "unknown", # Function keys "f1", @@ -216,7 +216,7 @@ HardwareKey = Literal[ "brightness-down", ] -SoftwareKey = Literal[ +SoftwareKey = t.Literal[ "unknown", # Modifiers "alt", @@ -562,7 +562,7 @@ SoftwareKey = Literal[ "separator", ] -ModifierKey = Literal["alt", "control", "meta", "shift"] +ModifierKey = t.Literal["alt", "control", "meta", "shift"] _MODIFIERS = ("control", "shift", "alt", "meta") @@ -598,7 +598,7 @@ class _KeyUpDownEvent: modifiers: frozenset[ModifierKey] @classmethod - def _from_json(cls, json_data: dict[str, Any]): + def _from_json(cls, json_data: dict[str, t.Any]): return cls( hardware_key=json_data["hardwareKey"], software_key=json_data["softwareKey"], @@ -613,25 +613,25 @@ class _KeyUpDownEvent: return " + ".join(keys) -@final +@t.final @rio.docs.mark_constructor_as_private class KeyDownEvent(_KeyUpDownEvent): pass -@final +@t.final @rio.docs.mark_constructor_as_private class KeyUpEvent(_KeyUpDownEvent): pass -@final +@t.final @rio.docs.mark_constructor_as_private class KeyPressEvent(_KeyUpDownEvent): pass -@final +@t.final class KeyEventListener(KeyboardFocusableFundamentalComponent): """ Calls an event handler when a key is pressed or released. @@ -669,7 +669,7 @@ class KeyEventListener(KeyboardFocusableFundamentalComponent): "reportKeyPress": self.on_key_press is not None, } - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg diff --git a/rio/components/labeled_column.py b/rio/components/labeled_column.py index 07551299..adb96152 100644 --- a/rio/components/labeled_column.py +++ b/rio/components/labeled_column.py @@ -1,10 +1,9 @@ from __future__ import annotations -from collections.abc import Mapping +import typing as t from dataclasses import field -from typing import final -from typing_extensions import Self +import typing_extensions as te import rio @@ -13,7 +12,7 @@ from .component import Component __all__ = ["LabeledColumn"] -@final +@t.final class LabeledColumn(Component): """ A container that lays out its children in a column, with labels for each @@ -47,7 +46,7 @@ class LabeledColumn(Component): def __init__( self, - content: Mapping[str, rio.Component], + content: t.Mapping[str, rio.Component], *, key: str | int | None = None, margin: float | None = None, @@ -65,8 +64,8 @@ class LabeledColumn(Component): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, @@ -92,15 +91,15 @@ class LabeledColumn(Component): self.content = content @property - def content(self) -> Mapping[str, Component]: + def content(self) -> t.Mapping[str, Component]: return self._content @content.setter - def content(self, children: Mapping[str, Component]) -> None: + def content(self, children: t.Mapping[str, Component]) -> None: self._content = dict(children) self._child_list = list(children.values()) - def add(self, label: str, child: rio.Component) -> Self: + def add(self, label: str, child: rio.Component) -> te.Self: """ Appends a child component. diff --git a/rio/components/linear_containers.py b/rio/components/linear_containers.py index 2cdc856c..6d5365ec 100644 --- a/rio/components/linear_containers.py +++ b/rio/components/linear_containers.py @@ -1,9 +1,8 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Literal, final +import typing as t -from typing_extensions import Self +import typing_extensions as te from uniserde import JsonDoc import rio @@ -19,13 +18,13 @@ __all__ = [ class _LinearContainer(FundamentalComponent): children: list[rio.Component] spacing: float = 0.0 - proportions: Literal["homogeneous"] | Sequence[float] | None = None + proportions: t.Literal["homogeneous"] | t.Sequence[float] | None = None # Don't let @dataclass generate a constructor def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def add(self, child: rio.Component) -> Self: + def add(self, child: rio.Component) -> te.Self: self.children.append(child) return self @@ -33,7 +32,7 @@ class _LinearContainer(FundamentalComponent): return {"proportions": self.proportions} # type: ignore (variance) -@final +@t.final class Row(_LinearContainer): """ A container that lays out its children horizontally. @@ -125,7 +124,7 @@ class Row(_LinearContainer): self, *children: rio.Component, spacing: float = 0.0, - proportions: Literal["homogeneous"] | Sequence[float] | None = None, + proportions: t.Literal["homogeneous"] | t.Sequence[float] | None = None, key: str | int | None = None, margin: float | None = None, margin_x: float | None = None, @@ -142,8 +141,8 @@ class Row(_LinearContainer): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( key=key, @@ -170,7 +169,7 @@ class Row(_LinearContainer): self.spacing = spacing self.proportions = proportions - def add(self, child: rio.Component) -> Self: + def add(self, child: rio.Component) -> te.Self: """ Appends a child component. @@ -191,7 +190,7 @@ class Row(_LinearContainer): Row._unique_id_ = "Row-builtin" -@final +@t.final class Column(_LinearContainer): """ A container that lays out its children vertically. @@ -281,7 +280,7 @@ class Column(_LinearContainer): self, *children: rio.Component, spacing: float = 0.0, - proportions: Literal["homogeneous"] | Sequence[float] | None = None, + proportions: t.Literal["homogeneous"] | t.Sequence[float] | None = None, key: str | int | None = None, margin: float | None = None, margin_x: float | None = None, @@ -298,8 +297,8 @@ class Column(_LinearContainer): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, @@ -326,7 +325,7 @@ class Column(_LinearContainer): self.spacing = spacing self.proportions = proportions - def add(self, child: rio.Component) -> Self: + def add(self, child: rio.Component) -> te.Self: """ Appends a child component. diff --git a/rio/components/link.py b/rio/components/link.py index 8677462b..5f391825 100644 --- a/rio/components/link.py +++ b/rio/components/link.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import final +import typing as t from uniserde import JsonDoc @@ -14,7 +14,7 @@ __all__ = [ ] -@final +@t.final class Link(FundamentalComponent): """ Navigates to a page or URL when pressed. @@ -89,8 +89,8 @@ class Link(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: """ ## Parameters diff --git a/rio/components/list_items.py b/rio/components/list_items.py index e7087af7..8e7ae8b8 100644 --- a/rio/components/list_items.py +++ b/rio/components/list_items.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t from uniserde import JsonDoc @@ -17,7 +17,7 @@ __all__ = [ ] -@final +@t.final class HeadingListItem(FundamentalComponent): """ A list item acting as heading. @@ -83,7 +83,7 @@ class HeadingListItem(FundamentalComponent): HeadingListItem._unique_id_ = "HeadingListItem-builtin" -@final +@t.final class SeparatorListItem(FundamentalComponent): """ A visual separator between list items. @@ -134,7 +134,7 @@ class SeparatorListItem(FundamentalComponent): SeparatorListItem._unique_id_ = "SeparatorListItem-builtin" -@final +@t.final class SimpleListItem(Component): """ A simple list item with a header and optional secondary text and children. @@ -228,8 +228,8 @@ class SimpleListItem(Component): # MAX-SIZE-BRANCH max_height: float | None = None, grow_x: bool = False, grow_y: bool = False, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( min_width=min_width, @@ -301,7 +301,7 @@ class SimpleListItem(Component): ) -@final +@t.final class CustomListItem(FundamentalComponent): """ A list item with custom content. @@ -394,8 +394,8 @@ class CustomListItem(FundamentalComponent): # MAX-SIZE-BRANCH max_height: float | None = None, grow_x: bool = False, grow_y: bool = False, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( min_width=min_width, @@ -417,7 +417,7 @@ class CustomListItem(FundamentalComponent): "pressable": self.on_press is not None, } - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg assert msg["type"] == "press", msg diff --git a/rio/components/list_view.py b/rio/components/list_view.py index 2f044a71..f7d7ba76 100644 --- a/rio/components/list_view.py +++ b/rio/components/list_view.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import final +import typing as t -from typing_extensions import Self +import typing_extensions as te import rio @@ -11,7 +11,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["ListView"] -@final +@t.final class ListView(FundamentalComponent): """ Vertically arranges and styles its children. @@ -114,8 +114,8 @@ class ListView(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( key=key, @@ -140,7 +140,7 @@ class ListView(FundamentalComponent): self.children = list(children) - def add(self, child: rio.Component) -> Self: + def add(self, child: rio.Component) -> te.Self: """ Appends a child component. diff --git a/rio/components/markdown.py b/rio/components/markdown.py index 64c6aa75..41297977 100644 --- a/rio/components/markdown.py +++ b/rio/components/markdown.py @@ -1,5 +1,5 @@ +import typing as t from dataclasses import KW_ONLY -from typing import Literal, final from uniserde import JsonDoc @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class Markdown(FundamentalComponent): ''' Displays Markdown-formatted text. @@ -67,9 +67,9 @@ class Markdown(FundamentalComponent): _: KW_ONLY default_language: str | None = None selectable: bool = True - justify: Literal["left", "right", "center", "justify"] = "left" - wrap: bool | Literal["ellipsize"] = True - overflow: Literal["nowrap", "wrap", "ellipsize"] = "wrap" + justify: t.Literal["left", "right", "center", "justify"] = "left" + wrap: bool | t.Literal["ellipsize"] = True + overflow: t.Literal["nowrap", "wrap", "ellipsize"] = "wrap" def _custom_serialize_(self) -> JsonDoc: # The old `wrap` attribute has been replaced with `overflow`. Remap the diff --git a/rio/components/media_player.py b/rio/components/media_player.py index 97037a09..adb35745 100644 --- a/rio/components/media_player.py +++ b/rio/components/media_player.py @@ -1,7 +1,7 @@ from __future__ import annotations import pathlib -from typing import final +import typing as t from uniserde import JsonDoc @@ -14,7 +14,7 @@ from .fundamental_component import KeyboardFocusableFundamentalComponent __all__ = ["MediaPlayer"] -@final +@t.final class MediaPlayer(KeyboardFocusableFundamentalComponent): """ Plays audio and video files. @@ -123,8 +123,8 @@ class MediaPlayer(KeyboardFocusableFundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, diff --git a/rio/components/mouse_event_listener.py b/rio/components/mouse_event_listener.py index 8533494c..9b891999 100644 --- a/rio/components/mouse_event_listener.py +++ b/rio/components/mouse_event_listener.py @@ -1,13 +1,14 @@ from __future__ import annotations import enum +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore from uniserde import JsonDoc import rio.docs +from .. import deprecations from .fundamental_component import FundamentalComponent __all__ = [ @@ -25,7 +26,7 @@ __all__ = [ ] -@final +@t.final class MouseButton(enum.Enum): LEFT = "left" MIDDLE = "middle" @@ -53,7 +54,7 @@ class _PositionedEvent: y: float -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class PressEvent(_ButtonEvent, _PositionedEvent): @@ -70,7 +71,7 @@ class PressEvent(_ButtonEvent, _PositionedEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class MouseDownEvent(_ButtonEvent, _PositionedEvent): @@ -87,7 +88,7 @@ class MouseDownEvent(_ButtonEvent, _PositionedEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class MouseUpEvent(_ButtonEvent, _PositionedEvent): @@ -104,7 +105,7 @@ class MouseUpEvent(_ButtonEvent, _PositionedEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private class MouseMoveEvent(_PositionedEvent): """ @@ -116,7 +117,7 @@ class MouseMoveEvent(_PositionedEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private class MouseEnterEvent(_PositionedEvent): """ @@ -128,7 +129,7 @@ class MouseEnterEvent(_PositionedEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private class MouseLeaveEvent(_PositionedEvent): """ @@ -158,7 +159,7 @@ class _DragEvent(_ButtonEvent, _PositionedEvent): component: rio.Component -@final +@t.final @rio.docs.mark_constructor_as_private class DragStartEvent(_DragEvent): """ @@ -170,7 +171,7 @@ class DragStartEvent(_DragEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private class DragMoveEvent(_DragEvent): """ @@ -182,7 +183,7 @@ class DragMoveEvent(_DragEvent): """ -@final +@t.final @rio.docs.mark_constructor_as_private class DragEndEvent(_DragEvent): """ @@ -194,7 +195,7 @@ class DragEndEvent(_DragEvent): """ -@final +@t.final class MouseEventListener(FundamentalComponent): """ Allows you to listen for mouse events on a component. @@ -232,7 +233,7 @@ class MouseEventListener(FundamentalComponent): `on_drag_move`: Triggered when the user moves the mouse while holding down a mouse button. - `on_drag_end`: Triggered then the user stops dragging the mouse. + `on_drag_end`: Triggered when the user stops dragging the mouse. """ content: rio.Component @@ -247,6 +248,12 @@ class MouseEventListener(FundamentalComponent): on_drag_move: rio.EventHandler[DragMoveEvent] = None on_drag_end: rio.EventHandler[DragEndEvent] = None + def __post_init__(self) -> None: + deprecations.warn( + since="0.10.5", + message=f"`MouseEventListener` has been superseded by `PointerEventListener`. Please use `PointerEventListener` instead.", + ) + def _custom_serialize_(self) -> JsonDoc: return { "reportPress": self.on_press is not None, @@ -260,7 +267,7 @@ class MouseEventListener(FundamentalComponent): "reportDragEnd": self.on_drag_end is not None, } - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg diff --git a/rio/components/multi_line_text_input.py b/rio/components/multi_line_text_input.py index da76a7e7..8cbc9ec2 100644 --- a/rio/components/multi_line_text_input.py +++ b/rio/components/multi_line_text_input.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Any, final from uniserde import JsonDoc @@ -16,19 +16,43 @@ __all__ = [ ] -@final +@t.final @dataclass class MultiLineTextInputChangeEvent: + """ + Holds information regarding a text input change event. + + This is a simple dataclass that stores useful information for when the user + changes the text in a `MultiLineTextInput`. You'll typically receive this as + argument in `on_change` events. + + ## Attributes + + `text`: The new `text` of the `MultiLineTextInput`. + """ + text: str -@final +@t.final @dataclass class MultiLineTextInputConfirmEvent: + """ + Holds information regarding a text input confirm event. + + This is a simple dataclass that stores useful information for when the user + confirms the text in a `MultiLineTextInput`. You'll typically receive this + as argument in `on_confirm` events. + + ## Attributes + + `text`: The new `text` of the `MultiLineTextInput`. + """ + text: str -@final +@t.final class MultiLineTextInput(KeyboardFocusableFundamentalComponent): """ A user-editable text field. @@ -42,6 +66,8 @@ class MultiLineTextInput(KeyboardFocusableFundamentalComponent): `label`: A short text to display next to the text input. + `style`: Changes the visual appearance of the text input. + `is_sensitive`: Whether the text input should respond to user input. `is_valid`: Visually displays to the user whether the current text is @@ -115,6 +141,10 @@ class MultiLineTextInput(KeyboardFocusableFundamentalComponent): on_confirm: rio.EventHandler[MultiLineTextInputConfirmEvent] = None accessibility_label: str = "" + # Note the lack of the `"pill"` style. It looks silly with tall components + # so is intentionally omitted here. + style: t.Literal["underlined", "rounded"] = "underlined" + def _validate_delta_state_from_frontend(self, delta_state: JsonDoc) -> None: if not set(delta_state) <= {"text"}: raise AssertionError( @@ -143,7 +173,7 @@ class MultiLineTextInput(KeyboardFocusableFundamentalComponent): self._apply_delta_state_from_frontend(delta_state) - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Listen for messages indicating the user has confirmed their input # # In addition to notifying the backend, these also include the input's diff --git a/rio/components/node_input.py b/rio/components/node_input.py index 80d37725..8f2b3599 100644 --- a/rio/components/node_input.py +++ b/rio/components/node_input.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class NodeInput(FundamentalComponent): """ ## Metadata @@ -43,8 +43,8 @@ class NodeInput(FundamentalComponent): # MAX-SIZE-BRANCH max_height: float | None = None, grow_x: bool = False, grow_y: bool = False, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): # Make sure the building component is a Node # TODO diff --git a/rio/components/node_output.py b/rio/components/node_output.py index 79b1665a..3bf413a7 100644 --- a/rio/components/node_output.py +++ b/rio/components/node_output.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class NodeOutput(FundamentalComponent): """ ## Metadata @@ -43,8 +43,8 @@ class NodeOutput(FundamentalComponent): # MAX-SIZE-BRANCH max_height: float | None = None, grow_x: bool = False, grow_y: bool = False, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): # Make sure the building component is a Node # TODO diff --git a/rio/components/number_input.py b/rio/components/number_input.py index 0f8aee41..f97d6985 100644 --- a/rio/components/number_input.py +++ b/rio/components/number_input.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import Mapping +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore import rio.docs @@ -18,13 +17,13 @@ __all__ = [ # These must be ints so that `integer * multiplier` returns an int and not a # float -_multiplier_suffixes: Mapping[str, int] = { +_multiplier_suffixes: t.Mapping[str, int] = { "k": 1_000, "m": 1_000_000, } -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class NumberInputChangeEvent: @@ -43,7 +42,7 @@ class NumberInputChangeEvent: value: float -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class NumberInputConfirmEvent: @@ -62,7 +61,7 @@ class NumberInputConfirmEvent: value: float -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class NumberInputFocusEvent: @@ -81,7 +80,7 @@ class NumberInputFocusEvent: value: float -@final +@t.final class NumberInput(Component): """ Like `TextInput`, but specifically for inputting numbers. @@ -104,6 +103,8 @@ class NumberInput(Component): `label`: A short text to display next to the number input. + `style`: Changes the visual appearance of the text input. + `prefix_text`: A short text to display before the number input. Useful for displaying currency symbols or other prefixed units. @@ -189,6 +190,8 @@ class NumberInput(Component): value: float = 0 _: KW_ONLY label: str = "" + accessibility_label: str = "" + style: t.Literal["underlined", "rounded", "pill"] = "underlined" prefix_text: str = "" suffix_text: str = "" minimum: float | None = None @@ -197,7 +200,6 @@ class NumberInput(Component): thousands_separator: bool | str = True is_sensitive: bool = True is_valid: bool = True - accessibility_label: str = "" on_change: rio.EventHandler[NumberInputChangeEvent] = None on_confirm: rio.EventHandler[NumberInputConfirmEvent] = None @@ -340,6 +342,7 @@ class NumberInput(Component): self._text_input = rio.TextInput( text=self._formatted_value(), label=self.label, + style=self.style, prefix_text=self.prefix_text, suffix_text=self.suffix_text, is_sensitive=self.is_sensitive, diff --git a/rio/components/overlay.py b/rio/components/overlay.py index 0a85404c..9945effd 100644 --- a/rio/components/overlay.py +++ b/rio/components/overlay.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class Overlay(FundamentalComponent): """ Displays its content above all other components. @@ -59,7 +59,7 @@ class Overlay(FundamentalComponent): self.content = content - def _get_debug_details_(self) -> dict[str, Any]: + def _get_debug_details_(self) -> dict[str, t.Any]: result = super()._get_debug_details_() # Overlays intentionally remove a lot of common properties, because they diff --git a/rio/components/page_view.py b/rio/components/page_view.py index 213963fa..122e2f8f 100644 --- a/rio/components/page_view.py +++ b/rio/components/page_view.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import Callable +import typing as t from dataclasses import KW_ONLY, field -from typing import * # type: ignore import rio @@ -14,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( @@ -53,7 +54,7 @@ def default_fallback_build(sess: rio.Session) -> rio.Component: ) -@final +@t.final class PageView(Component): """ Placeholders for pages. @@ -111,7 +112,7 @@ class PageView(Component): _: KW_ONLY - fallback_build: Callable[[], rio.Component] | None = None + fallback_build: t.Callable[[], rio.Component] | None = None # How many other PageViews are above this one in the component tree. Zero # for top-level PageViews, 1 for the next level, and so on. diff --git a/rio/components/plot.py b/rio/components/plot.py index 5501a510..744b600f 100644 --- a/rio/components/plot.py +++ b/rio/components/plot.py @@ -1,14 +1,14 @@ from __future__ import annotations import io -from typing import TYPE_CHECKING, cast, final +import typing as t from uniserde import JsonDoc from .. import fills, maybes from .fundamental_component import FundamentalComponent -if TYPE_CHECKING: +if t.TYPE_CHECKING: import matplotlib.axes # type: ignore import matplotlib.figure # type: ignore import plotly.graph_objects # type: ignore @@ -17,7 +17,7 @@ if TYPE_CHECKING: __all__ = ["Plot"] -@final +@t.final class Plot(FundamentalComponent): """ Displays a `matplotlib`, `seaborn` or `plotly` plot. @@ -98,8 +98,8 @@ class Plot(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, @@ -138,7 +138,7 @@ class Plot(FundamentalComponent): # Plotly if isinstance(figure, maybes.PLOTLY_GRAPH_TYPES): # Make the plot transparent, so `self.background` shines through. - figure = cast("plotly.graph_objects.Figure", figure) + figure = t.cast("plotly.graph_objects.Figure", figure) plot = { "type": "plotly", @@ -150,7 +150,7 @@ class Plot(FundamentalComponent): if isinstance(figure, maybes.MATPLOTLIB_AXES_TYPES): figure = figure.figure - figure = cast("matplotlib.figure.Figure", figure) + figure = t.cast("matplotlib.figure.Figure", figure) file = io.BytesIO() figure.savefig( diff --git a/rio/components/pointer_event_listener.py b/rio/components/pointer_event_listener.py new file mode 100644 index 00000000..ac07fe39 --- /dev/null +++ b/rio/components/pointer_event_listener.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import typing as t +from dataclasses import KW_ONLY, dataclass + +from uniserde import JsonDoc + +import rio.docs + +from .fundamental_component import FundamentalComponent + +__all__ = [ + "PointerEvent", + "PointerMoveEvent", + "PointerEventListener", +] + + +@rio.docs.mark_constructor_as_private +@dataclass +class PointerEvent: + """ + Holds information regarding a pointer event. + + This is a simple dataclass that stores useful information for when the user + interacts with a component using a mouse, touch, or other pointer-style + device. You'll receive this as argument in a variety of pointer events. + + + ## Attributes + + `pointer_type`: What sort of pointer triggered the event. Can be either + `"mouse"` or `"touch"`. + + `button`: The mouse button that was pressed, if any. For mouse events + (`pointer_type=="mouse"`), this is either `"left"`, `"middle"`, or + `"right"`. For other events, this is `None`. + + `window_x`: The x coordinate of the pointer relative to the window. The + origin is the top-left corner of the window, with larger `x` values + meaning further to the right. + + `window_y`: The y coordinate of the pointer relative to the window. The + origin is the top-left corner of the window, with larger `y` values + meaning further down. + + `component_x`: The x coordinate of the pointer relative to the + `PointerEventListener` component. The origin is the top-left corner of + the component, with larger `x` values meaning further to the right. + + `component_y`: The y coordinate of the pointer relative to the + `PointerEventListener` component. The origin is the top-left corner of + the component, with larger `y` values meaning further down. + """ + + pointer_type: t.Literal["mouse", "touch"] + + button: t.Literal["left", "middle", "right"] | None + + window_x: float + window_y: float + + component_x: float + component_y: float + + @staticmethod + def _from_message(msg: dict[str, t.Any]) -> PointerEvent: + return PointerEvent( + pointer_type=msg["pointerType"], + button=msg.get("button"), + window_x=msg["windowX"], + window_y=msg["windowY"], + component_x=msg["componentX"], + component_y=msg["componentY"], + ) + + +@t.final +@rio.docs.mark_constructor_as_private +@dataclass +class PointerMoveEvent(PointerEvent): + """ + Holds information regarding a pointer move event. + + This is a simple dataclass that stores useful information for when the user + moves the pointer. You'll typically receive this as argument in + `on_pointer_move` events. + + + ## Attributes + + `relative_x`: How far the pointer has moved horizontally since the last + time the event was triggered. + + `relative_y`: How far the pointer has moved vertically since the last time + the event was triggered. + """ + + relative_x: float + relative_y: float + + @staticmethod + def _from_message(msg: dict[str, t.Any]) -> PointerMoveEvent: + return PointerMoveEvent( + pointer_type=msg["pointerType"], + button=msg.get("button"), + window_x=msg["windowX"], + window_y=msg["windowY"], + component_x=msg["componentX"], + component_y=msg["componentY"], + relative_x=msg["relativeX"], + relative_y=msg["relativeY"], + ) + + +@t.final +class PointerEventListener(FundamentalComponent): + """ + Allows you to listen for mouse & touch events on a component. + + `PointerEventListener` takes a single child component and displays it. It + then listens for any mouse and touch activity on the child component and + reports it through its events. + + + ## Attributes + + `content`: The child component to display and watch. + + `on_press`: Similar to `on_pointer_up`, but performs additional subtle + checks, such as that the pressed mouse button was the left one. + + `on_pointer_down`: Triggered when a pointer button is pressed down while + the pointer is placed over the child component. + + `on_pointer_up`: Triggered when a pointer button is released while the + pointer is placed over the child component. + + `on_pointer_move`: Triggered when the pointer is moved while located over + the child component. + + `on_pointer_enter`: Triggered when the pointer previously was not located + over the child component, but is now. + + `on_pointer_leave`: Triggered when the pointer previously was located over + the child component, but is no longer. + + `on_drag_start`: Triggered when the user starts dragging the pointer, i.e. + moving it while holding down a pointer button. + + `on_drag_move`: Triggered when the user moves the pointer while holding down + a pointer button. Note that once a drag event was triggered on a + component, the move event will continue to fire even if the pointer + leaves the component. + + `on_drag_end`: Triggered when the user stops dragging the pointer. + """ + + content: rio.Component + _: KW_ONLY + on_press: rio.EventHandler[PointerEvent] = None + on_pointer_down: rio.EventHandler[PointerEvent] = None + on_pointer_up: rio.EventHandler[PointerEvent] = None + on_pointer_move: rio.EventHandler[PointerMoveEvent] = None + on_pointer_enter: rio.EventHandler[PointerEvent] = None + on_pointer_leave: rio.EventHandler[PointerEvent] = None + on_drag_start: rio.EventHandler[PointerEvent] = None + on_drag_move: rio.EventHandler[PointerMoveEvent] = None + on_drag_end: rio.EventHandler[PointerEvent] = None + + def _custom_serialize_(self) -> JsonDoc: + return { + "reportPress": self.on_press is not None, + "reportPointerDown": self.on_pointer_down is not None, + "reportPointerUp": self.on_pointer_up is not None, + "reportPointerMove": self.on_pointer_move is not None, + "reportPointerEnter": self.on_pointer_enter is not None, + "reportPointerLeave": self.on_pointer_leave is not None, + "reportDragStart": self.on_drag_start is not None, + "reportDragMove": self.on_drag_move is not None, + "reportDragEnd": self.on_drag_end is not None, + } + + async def _on_message_(self, msg: t.Any) -> None: + # Parse the message + assert isinstance(msg, dict), msg + + msg_type = msg["type"] + assert isinstance(msg_type, str), msg_type + + # Dispatch the correct event + if msg_type == "press": + await self.call_event_handler( + self.on_press, + PointerEvent._from_message(msg), + ) + + elif msg_type == "pointerDown": + await self.call_event_handler( + self.on_pointer_down, + PointerEvent._from_message(msg), + ) + + elif msg_type == "pointerUp": + await self.call_event_handler( + self.on_pointer_up, + PointerEvent._from_message(msg), + ) + + elif msg_type == "pointerMove": + await self.call_event_handler( + self.on_pointer_move, + PointerMoveEvent._from_message(msg), + ) + + elif msg_type == "pointerEnter": + await self.call_event_handler( + self.on_pointer_enter, + PointerEvent._from_message(msg), + ) + + elif msg_type == "pointerLeave": + await self.call_event_handler( + self.on_pointer_leave, + PointerEvent._from_message(msg), + ) + + elif msg_type == "dragStart": + await self.call_event_handler( + self.on_drag_start, + PointerEvent._from_message(msg), + ) + + elif msg_type == "dragMove": + await self.call_event_handler( + self.on_drag_move, + PointerMoveEvent._from_message(msg), + ) + + elif msg_type == "dragEnd": + await self.call_event_handler( + self.on_drag_end, + PointerEvent._from_message(msg), + ) + + else: + raise ValueError( + f"{__class__.__name__} encountered unknown message: {msg}" + ) + + # Refresh the session + await self.session._refresh() + + +PointerEventListener._unique_id_ = "PointerEventListener-builtin" diff --git a/rio/components/popup.py b/rio/components/popup.py index 992c5c66..9371db2c 100644 --- a/rio/components/popup.py +++ b/rio/components/popup.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import Literal, final from uniserde import JsonDoc @@ -14,7 +14,7 @@ __all__ = [ ] -@final +@t.final # @deprecations.component_kwarg_renamed( # since="0.9.2", # old_name="direction", @@ -103,9 +103,9 @@ class Popup(FundamentalComponent): anchor: rio.Component content: rio.Component _: KW_ONLY - color: rio.ColorSet | Literal["none"] = "hud" + color: rio.ColorSet | t.Literal["none"] = "hud" corner_radius: float | tuple[float, float, float, float] | None = None - position: Literal[ + position: t.Literal[ "auto", "left", "top", "right", "bottom", "center", "fullscreen" ] = "center" alignment: float = 0.5 diff --git a/rio/components/progress_bar.py b/rio/components/progress_bar.py index 1f842931..d2e0d018 100644 --- a/rio/components/progress_bar.py +++ b/rio/components/progress_bar.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class ProgressBar(FundamentalComponent): """ A progress indicator in the shape of a horizontal bar. @@ -78,8 +78,8 @@ class ProgressBar(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): """ ## Parameters diff --git a/rio/components/progress_circle.py b/rio/components/progress_circle.py index 97ea5a7f..a7d1d0b5 100644 --- a/rio/components/progress_circle.py +++ b/rio/components/progress_circle.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -12,7 +12,7 @@ __all__ = [ ] -@final +@t.final @deprecations.component_kwarg_renamed( since="0.10", old_name="size", @@ -82,8 +82,8 @@ class ProgressCircle(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: """ ## Parameters diff --git a/rio/components/rectangle.py b/rio/components/rectangle.py index 2c77365b..a2db1dd6 100644 --- a/rio/components/rectangle.py +++ b/rio/components/rectangle.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import final from uniserde import JsonDoc @@ -16,7 +16,7 @@ __all__ = [ ] -@final +@t.final class Rectangle(FundamentalComponent): """ A customizable rectangle shape. diff --git a/rio/components/revealer.py b/rio/components/revealer.py index b2e02dd7..63e1bd1f 100644 --- a/rio/components/revealer.py +++ b/rio/components/revealer.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Literal, TypeVar, final from uniserde import JsonDoc @@ -14,10 +14,10 @@ __all__ = [ "RevealerChangeEvent", ] -T = TypeVar("T") +T = t.TypeVar("T") -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class RevealerChangeEvent: @@ -36,7 +36,7 @@ class RevealerChangeEvent: is_open: bool -@final +@t.final class Revealer(FundamentalComponent): """ A component that can be used to hide and reveal content. @@ -98,7 +98,7 @@ class Revealer(FundamentalComponent): content: rio.Component _: KW_ONLY header_style: ( - Literal["heading1", "heading2", "heading3", "text"] | rio.TextStyle + t.Literal["heading1", "heading2", "heading3", "text"] | rio.TextStyle ) = "text" is_open: bool = False on_change: rio.EventHandler[RevealerChangeEvent] = None diff --git a/rio/components/root_components.py b/rio/components/root_components.py index 51aea177..1310ec80 100644 --- a/rio/components/root_components.py +++ b/rio/components/root_components.py @@ -1,7 +1,6 @@ from __future__ import annotations -from collections.abc import Callable -from typing import * # type: ignore +import typing as t from .. import utils from .component import Component @@ -24,8 +23,8 @@ class HighLevelRootComponent(Component): `public`: False """ - build_function: Callable[[], Component] - build_connection_lost_message_function: Callable[[], Component] + build_function: t.Callable[[], Component] + build_connection_lost_message_function: t.Callable[[], Component] def build(self) -> Component: # Spawn the dev tools if running in debug mode. diff --git a/rio/components/scroll_container.py b/rio/components/scroll_container.py index 50ba3ef0..59a25ee5 100644 --- a/rio/components/scroll_container.py +++ b/rio/components/scroll_container.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import Literal, final import rio @@ -10,7 +10,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["ScrollContainer"] -@final +@t.final class ScrollContainer(FundamentalComponent): """ Displays a scroll bar if its content grows too large. @@ -52,8 +52,8 @@ class ScrollContainer(FundamentalComponent): content: rio.Component _: KW_ONLY - scroll_x: Literal["never", "auto", "always"] = "auto" - scroll_y: Literal["never", "auto", "always"] = "auto" + scroll_x: t.Literal["never", "auto", "always"] = "auto" + scroll_y: t.Literal["never", "auto", "always"] = "auto" initial_x: float = 0 initial_y: float = 0 sticky_bottom: bool = False diff --git a/rio/components/scroll_target.py b/rio/components/scroll_target.py index 4ca7ef04..6d6c945a 100644 --- a/rio/components/scroll_target.py +++ b/rio/components/scroll_target.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import final from uniserde import JsonDoc @@ -12,7 +12,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["ScrollTarget"] -@final +@t.final class ScrollTarget(FundamentalComponent): """ Allows browsers to scroll to a specific component via URL fragment. diff --git a/rio/components/separator.py b/rio/components/separator.py index 79853484..52a06a45 100644 --- a/rio/components/separator.py +++ b/rio/components/separator.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore import rio @@ -12,7 +12,7 @@ __all__ = [ ] -@final +@t.final class Separator(FundamentalComponent): """ A line to separate content. diff --git a/rio/components/slider.py b/rio/components/slider.py index 7aac8cc5..7634487f 100644 --- a/rio/components/slider.py +++ b/rio/components/slider.py @@ -1,8 +1,8 @@ from __future__ import annotations import math +import typing as t from dataclasses import dataclass -from typing import final from uniserde import JsonDoc @@ -16,13 +16,25 @@ __all__ = [ ] -@final +@t.final @dataclass class SliderChangeEvent: + """ + Holds information regarding a slider change event. + + This is a simple dataclass that stores useful information for when the user + switches changes the value of a `Slider`. You'll typically receive this as + argument in `on_change` events. + + ## Attributes + + `value`: The new value of the slider. + """ + value: float -@final +@t.final class Slider(FundamentalComponent): """ A component for selecting a single number from a range. @@ -129,8 +141,8 @@ class Slider(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( key=key, diff --git a/rio/components/slideshow.py b/rio/components/slideshow.py index f3cc7a19..7f950c97 100644 --- a/rio/components/slideshow.py +++ b/rio/components/slideshow.py @@ -1,8 +1,8 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY from datetime import timedelta -from typing import final from uniserde import JsonDoc @@ -15,7 +15,7 @@ __all__ = [ ] -@final +@t.final class Slideshow(FundamentalComponent): """ Repeatedly switches between multiple components based on a timer. @@ -90,8 +90,8 @@ class Slideshow(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): if isinstance(linger_time, timedelta): linger_time = linger_time.total_seconds() diff --git a/rio/components/spacer.py b/rio/components/spacer.py index 645b45f0..a092f52f 100644 --- a/rio/components/spacer.py +++ b/rio/components/spacer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t from . import class_container @@ -9,7 +9,7 @@ __all__ = [ ] -@final +@t.final class Spacer(class_container.ClassContainer): """ An invisible component which grows by default. @@ -60,7 +60,7 @@ class Spacer(class_container.ClassContainer): grow_y=grow_y, ) - def _get_debug_details_(self) -> dict[str, Any]: + def _get_debug_details_(self) -> dict[str, t.Any]: result = super()._get_debug_details_() # Don't inherit the content from `rio.ClassContainer`. diff --git a/rio/components/stack.py b/rio/components/stack.py index 66aa4745..5330abbb 100644 --- a/rio/components/stack.py +++ b/rio/components/stack.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import final +import typing as t -from typing_extensions import Self +import typing_extensions as te import rio @@ -11,7 +11,7 @@ from .fundamental_component import FundamentalComponent __all__ = ["Stack"] -@final +@t.final class Stack(FundamentalComponent): """ A container that stacks its children in the Z direction. @@ -87,8 +87,8 @@ class Stack(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): super().__init__( key=key, @@ -113,7 +113,7 @@ class Stack(FundamentalComponent): self.children = list(children) - def add(self, child: rio.Component) -> Self: + def add(self, child: rio.Component) -> te.Self: """ Appends a child component. diff --git a/rio/components/switch.py b/rio/components/switch.py index 87e5482c..9e961c1c 100644 --- a/rio/components/switch.py +++ b/rio/components/switch.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore from uniserde import JsonDoc @@ -15,13 +15,25 @@ __all__ = [ ] -@final +@t.final @dataclass class SwitchChangeEvent: + """ + Holds information regarding a switch change event. + + This is a simple dataclass that stores useful information for when the user + switches a `Switch` on or off. You'll typically receive this as argument in + `on_change` events. + + ## Attributes + + `is_on`: Whether the switch is now turned on. + """ + is_on: bool -@final +@t.final class Switch(FundamentalComponent): """ An input for `True` / `False` values. diff --git a/rio/components/switcher.py b/rio/components/switcher.py index 4a1dce52..7a427466 100644 --- a/rio/components/switcher.py +++ b/rio/components/switcher.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import final import rio @@ -12,7 +12,7 @@ __all__ = [ ] -@final +@t.final class Switcher(FundamentalComponent): """ Smoothly transitions between components. diff --git a/rio/components/switcher_bar.py b/rio/components/switcher_bar.py index e8b740d9..404a865d 100644 --- a/rio/components/switcher_bar.py +++ b/rio/components/switcher_bar.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import Sequence +import typing as t from dataclasses import dataclass -from typing import Any, Generic, Literal, TypeVar, final from uniserde import JsonDoc @@ -16,17 +15,29 @@ __all__ = [ "SwitcherBar", ] -T = TypeVar("T") +T = t.TypeVar("T") -@final +@t.final @dataclass -class SwitcherBarChangeEvent(Generic[T]): +class SwitcherBarChangeEvent(t.Generic[T]): + """ + Holds information regarding a switcher bar change event. + + This is a simple dataclass that stores useful information for when the user + interacts with a switcher bar. You'll typically receive this as argument in + `on_change` events. + + ## Attributes + + `value`: The new value of the `SwitcherBar`. + """ + value: T | None -@final -class SwitcherBar(FundamentalComponent, Generic[T]): +@t.final +class SwitcherBar(FundamentalComponent, t.Generic[T]): """ Displays a series of options and allows the user to switch between them. @@ -120,11 +131,11 @@ class SwitcherBar(FundamentalComponent, Generic[T]): `experimental`: True """ - names: Sequence[str] - values: Sequence[T] - icons: Sequence[str | None] | None + names: t.Sequence[str] + values: t.Sequence[T] + icons: t.Sequence[str | None] | None color: rio.ColorSet - orientation: Literal["horizontal", "vertical"] + orientation: t.Literal["horizontal", "vertical"] spacing: float selected_value: T | None allow_none: bool @@ -132,12 +143,12 @@ class SwitcherBar(FundamentalComponent, Generic[T]): def __init__( self, - values: Sequence[T], + values: t.Sequence[T], *, - names: Sequence[str] | None = None, - icons: Sequence[str | None] | None = None, + names: t.Sequence[str] | None = None, + icons: t.Sequence[str | None] | None = None, color: rio.ColorSet = "keep", - orientation: Literal["horizontal", "vertical"] = "horizontal", + orientation: t.Literal["horizontal", "vertical"] = "horizontal", spacing: float = 1.0, allow_none: bool = False, selected_value: T | None = None, @@ -158,8 +169,8 @@ class SwitcherBar(FundamentalComponent, Generic[T]): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ): """ ## Parameters @@ -250,7 +261,7 @@ class SwitcherBar(FundamentalComponent, Generic[T]): "selectedName": self._fetch_selected_name(), } - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg diff --git a/rio/components/table.py b/rio/components/table.py index b36a06c0..40c8a3f8 100644 --- a/rio/components/table.py +++ b/rio/components/table.py @@ -1,16 +1,14 @@ from __future__ import annotations -import typing -from collections.abc import Iterable, Mapping +import typing as t from dataclasses import dataclass -from typing import * # type: ignore from uniserde import JsonDoc from .. import maybes from .fundamental_component import FundamentalComponent -if TYPE_CHECKING: +if t.TYPE_CHECKING: import numpy # type: ignore import pandas # type: ignore import polars # type: ignore @@ -22,20 +20,20 @@ __all__ = ["Table"] TableValue = int | float | str -@final +@t.final @dataclass class TableSelection: _left: int - _top: int | Literal["header"] + _top: int | t.Literal["header"] _width: int _height: int - _font_weight: Literal["normal", "bold"] | None = None + _font_weight: t.Literal["normal", "bold"] | None = None def style( self, *, - font_weight: Literal["normal", "bold"] | None = None, + font_weight: t.Literal["normal", "bold"] | None = None, ) -> None: if font_weight is not None: self._font_weight = font_weight @@ -60,9 +58,9 @@ class TableSelection: def _index_to_start_and_extent( index: int | slice | str, size_in_axis: int, - axis: Literal["x", "y"], -) -> Tuple[ - int | Literal["header"], + axis: t.Literal["x", "y"], +) -> tuple[ + int | t.Literal["header"], int, ]: """ @@ -139,9 +137,9 @@ def _string_index_to_start_and_extent( index: str | int | slice, column_names: list[str] | None, size_in_axis: int, - axis: Literal["x", "y"], -) -> Tuple[ - int | Literal["header"], + axis: t.Literal["x", "y"], +) -> tuple[ + int | t.Literal["header"], int, ]: """ @@ -176,7 +174,7 @@ def _indices_to_rectangle( data_height: int, ) -> tuple[ int, - int | Literal["header"], + int | t.Literal["header"], int, int, ]: @@ -215,7 +213,8 @@ def _indices_to_rectangle( return left, top, width, height -@final +# TODO: add more content to docstring +@t.final class Table(FundamentalComponent): """ Display & input for tabular data. @@ -225,8 +224,6 @@ class Table(FundamentalComponent): spreadsheets, databases, or CSV files. Tables can be sorted by clicking on the column headers. - TODO - ## Attributes @@ -270,6 +267,7 @@ class Table(FundamentalComponent): table[1:3, 1:3].style(font_weight="bold") return table + ``` ## Metadata @@ -280,8 +278,8 @@ class Table(FundamentalComponent): data: ( pandas.DataFrame | polars.DataFrame - | Mapping[str, Iterable[TableValue]] - | Iterable[Iterable[TableValue]] + | t.Mapping[str, t.Iterable[TableValue]] + | t.Iterable[t.Iterable[TableValue]] | numpy.ndarray ) show_row_numbers: bool = True @@ -316,8 +314,8 @@ class Table(FundamentalComponent): self._data = self.data.tolist() # Mapping - elif isinstance(self.data, Mapping): - data = typing.cast(Mapping[str, Iterable[TableValue]], self.data) + elif isinstance(self.data, t.Mapping): + data = t.cast(t.Mapping[str, t.Iterable[TableValue]], self.data) self._headers = list(data.keys()) # Verify all columns have the same length @@ -337,7 +335,7 @@ class Table(FundamentalComponent): # Iterable of iterables else: - data = typing.cast(Iterable[Iterable[TableValue]], self.data) + data = t.cast(t.Iterable[t.Iterable[TableValue]], self.data) self._headers = None self._data = [] row_lengths = set() @@ -383,11 +381,13 @@ class Table(FundamentalComponent): def __getitem__( self, - index: str - | Tuple[ - int | slice | str, - int | slice | str, - ], + index: ( + str + | tuple[ + int | slice | str, + int | slice | str, + ] + ), ) -> TableSelection: # Get the index as a tuple (top, left, height, width) data_height, data_width = self._shape() diff --git a/rio/components/text.py b/rio/components/text.py index 2c4690bd..c64b1e20 100644 --- a/rio/components/text.py +++ b/rio/components/text.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore from uniserde import JsonDoc @@ -15,7 +15,7 @@ __all__ = [ ] -@final +@t.final class Text(FundamentalComponent): """ Displays unformatted text. @@ -75,12 +75,12 @@ class Text(FundamentalComponent): _: KW_ONLY selectable: bool = True style: ( - Literal["heading1", "heading2", "heading3", "text", "dim"] + t.Literal["heading1", "heading2", "heading3", "text", "dim"] | rio.TextStyle ) = "text" - justify: Literal["left", "right", "center", "justify"] = "left" - wrap: bool | Literal["ellipsize"] = False - overflow: Literal["nowrap", "wrap", "ellipsize"] = "nowrap" + justify: t.Literal["left", "right", "center", "justify"] = "left" + wrap: bool | t.Literal["ellipsize"] = False + overflow: t.Literal["nowrap", "wrap", "ellipsize"] = "nowrap" def _custom_serialize_(self) -> JsonDoc: # Serialization doesn't handle unions. Hence the custom serialization @@ -97,7 +97,7 @@ class Text(FundamentalComponent): since="0.10", old_name="wrap", new_name="overflow", - owner="rio.Markdown", + owner="rio.Text", ) if self.wrap is True: diff --git a/rio/components/text_input.py b/rio/components/text_input.py index 2506eda0..b629ccc5 100644 --- a/rio/components/text_input.py +++ b/rio/components/text_input.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import Any, final import rio.docs @@ -15,7 +15,7 @@ __all__ = [ ] -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class TextInputChangeEvent: @@ -34,7 +34,7 @@ class TextInputChangeEvent: text: str -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class TextInputConfirmEvent: @@ -53,7 +53,7 @@ class TextInputConfirmEvent: text: str -@final +@t.final @rio.docs.mark_constructor_as_private @dataclass class TextInputFocusEvent: @@ -72,7 +72,7 @@ class TextInputFocusEvent: text: str -@final +@t.final class TextInput(KeyboardFocusableFundamentalComponent): """ A user-editable text field. @@ -88,6 +88,8 @@ class TextInput(KeyboardFocusableFundamentalComponent): `label`: A short text to display next to the text input. + `style`: Changes the visual appearance of the text input. + `prefix_text`: A short text to display before the text input. Useful for displaying currency symbols or other prefixed units. @@ -160,6 +162,7 @@ class TextInput(KeyboardFocusableFundamentalComponent): _: KW_ONLY label: str = "" accessibility_label: str = "" + style: t.Literal["underlined", "rounded", "pill"] = "underlined" prefix_text: str = "" suffix_text: str = "" is_secret: bool = False @@ -172,7 +175,7 @@ class TextInput(KeyboardFocusableFundamentalComponent): on_gain_focus: rio.EventHandler[TextInputFocusEvent] = None on_lose_focus: rio.EventHandler[TextInputFocusEvent] = None - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Listen for messages indicating the user has confirmed their input # # In addition to notifying the backend, these also include the input's diff --git a/rio/components/theme_context_switcher.py b/rio/components/theme_context_switcher.py index b737adf0..918ccd7c 100644 --- a/rio/components/theme_context_switcher.py +++ b/rio/components/theme_context_switcher.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t from uniserde import JsonDoc @@ -13,7 +13,7 @@ __all__ = [ ] -@final +@t.final class ThemeContextSwitcher(FundamentalComponent): """ A container which can switch between theme contexts ("neutral", "warning", diff --git a/rio/components/tooltip.py b/rio/components/tooltip.py index 7599735e..a28e9a28 100644 --- a/rio/components/tooltip.py +++ b/rio/components/tooltip.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal, final +import typing as t import rio @@ -11,7 +11,7 @@ __all__ = [ ] -@final +@t.final class Tooltip(FundamentalComponent): """ A small pop-up window that appears when the user hovers over an element. @@ -50,11 +50,11 @@ class Tooltip(FundamentalComponent): anchor: rio.Component tip: str | rio.Component - position: Literal["auto", "left", "top", "right", "bottom"] + position: t.Literal["auto", "left", "top", "right", "bottom"] gap: float # Hide internal attributes from the IDE - if not TYPE_CHECKING: + if not t.TYPE_CHECKING: _tip_component: rio.Component | None # Impute a Text instance if a string is passed in as the tip @@ -62,7 +62,7 @@ class Tooltip(FundamentalComponent): self, anchor: rio.Component, tip: str | rio.Component, - position: Literal["auto", "left", "top", "right", "bottom"] = "auto", + position: t.Literal["auto", "left", "top", "right", "bottom"] = "auto", *, gap: float = 0.5, key: str | int | None = None, @@ -81,8 +81,8 @@ class Tooltip(FundamentalComponent): grow_y: bool = False, align_x: float | None = None, align_y: float | None = None, - # SCROLLING-REWORK scroll_x: Literal["never", "auto", "always"] = "never", - # SCROLLING-REWORK scroll_y: Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", + # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", ) -> None: super().__init__( key=key, diff --git a/rio/components/website.py b/rio/components/website.py index a235f068..e1f033f5 100644 --- a/rio/components/website.py +++ b/rio/components/website.py @@ -1,4 +1,6 @@ -from typing import final +import typing as t + +from uniserde import JsonDoc from ..utils import URL from .fundamental_component import FundamentalComponent @@ -8,7 +10,7 @@ __all__ = [ ] -@final +@t.final class Website(FundamentalComponent): """ Displays a website. @@ -35,5 +37,10 @@ class Website(FundamentalComponent): url: URL + def _custom_serialize_(self) -> JsonDoc: + return { + "url": str(self.url), + } + Website._unique_id_ = "Website-builtin" diff --git a/rio/data_models.py b/rio/data_models.py index 1695a3fb..44767fb9 100644 --- a/rio/data_models.py +++ b/rio/data_models.py @@ -1,13 +1,12 @@ from __future__ import annotations +import typing as t from dataclasses import dataclass -from typing import * # type: ignore - -import uniserde # Never import * from typing_extensions! It breaks `Any` on 3.10, preventing # users from connecting. Ask me how I know. -from typing_extensions import Self +import typing_extensions as te +import uniserde import rio @@ -35,7 +34,7 @@ class InitialClientMessage(uniserde.Serde): url: str # Don't annotate this as JsonDoc because uniserde doesn't support unions - user_settings: dict[str, Any] + user_settings: dict[str, t.Any] prefers_light_theme: bool @@ -89,7 +88,7 @@ class InitialClientMessage(uniserde.Serde): *, url: str, user_settings: uniserde.JsonDoc = {}, - ) -> Self: + ) -> te.Self: """ Convenience method for creating default settings when they don't really matter: unit tests, crawlers, etc. @@ -183,7 +182,7 @@ class ComponentLayout(uniserde.Serde): @dataclass class UnittestComponentLayout(ComponentLayout): # Additional, component-specific information - aux: dict[str, Any] + aux: dict[str, t.Any] @dataclass diff --git a/rio/dataclass.py b/rio/dataclass.py index c9a14b1c..4f85f668 100644 --- a/rio/dataclass.py +++ b/rio/dataclass.py @@ -5,17 +5,9 @@ import copy import dataclasses import functools import inspect -from collections.abc import Callable -from typing import * # type: ignore +import typing as t -from typing_extensions import ( - Any, - ClassVar, - Self, - TypeVar, - dataclass_transform, - get_origin, -) +import typing_extensions as te from . import inspection @@ -28,18 +20,18 @@ __all__ = [ ] -T = TypeVar("T") +T = t.TypeVar("T") _FIELDS_BY_CLASS: dict[type, dict[str, RioField]] = {} -def class_local_fields(cls: type) -> Mapping[str, RioField]: +def class_local_fields(cls: type) -> t.Mapping[str, RioField]: return _FIELDS_BY_CLASS.get(cls, {}) @functools.cache -def all_class_fields(cls: type) -> Mapping[str, RioField]: +def all_class_fields(cls: type) -> t.Mapping[str, RioField]: result = dict[str, RioField]() for cls in reversed(cls.__mro__): @@ -65,11 +57,11 @@ class RioField(dataclasses.Field): repr: bool = True, hash: bool = False, compare: bool = False, - metadata: Any = None, + metadata: t.Any = None, kw_only: bool | dataclasses._MISSING_TYPE = dataclasses.MISSING, default: object = dataclasses.MISSING, default_factory: ( - Callable[[], object] | dataclasses._MISSING_TYPE + t.Callable[[], object] | dataclasses._MISSING_TYPE ) = dataclasses.MISSING, real_default_value: object = dataclasses.MISSING, state_property: bool = True, @@ -91,7 +83,7 @@ class RioField(dataclasses.Field): self.real_default_value = real_default_value @classmethod - def from_dataclass_field(cls, field: dataclasses.Field) -> Self: + def from_dataclass_field(cls, field: dataclasses.Field) -> te.Self: if field.default is dataclasses.MISSING: default = field.default default_factory = field.default_factory @@ -112,7 +104,7 @@ def internal_field( *, default: object = dataclasses.MISSING, default_factory: ( - Callable[[], object] | dataclasses._MISSING_TYPE + t.Callable[[], object] | dataclasses._MISSING_TYPE ) = dataclasses.MISSING, # vscode doesn't understand default values, so the parameter that affect # static type checking (like `init`) must be explicitly passed in. @@ -120,7 +112,7 @@ def internal_field( repr: bool = False, state_property: bool = False, serialize: bool = False, -) -> Any: +) -> t.Any: return RioField( default=default, default_factory=default_factory, @@ -131,16 +123,16 @@ def internal_field( ) -def _make_default_factory_for_value(value: T) -> Callable[[], T]: +def _make_default_factory_for_value(value: T) -> t.Callable[[], T]: return functools.partial(copy.deepcopy, value) -@dataclass_transform( +@te.dataclass_transform( eq_default=False, field_specifiers=(internal_field, dataclasses.field), ) class RioDataclassMeta(abc.ABCMeta): - def __init__(cls, *args, **kwargs): + def __init__(cls, *args, **kwargs) -> None: super().__init__(*args, **kwargs) cls_vars = vars(cls) @@ -182,7 +174,7 @@ class RioDataclassMeta(abc.ABCMeta): continue # Skip `ClassVar` annotations - if get_origin(annotation) is ClassVar: + if t.get_origin(annotation) is t.ClassVar: continue try: diff --git a/rio/debug/dev_tools/component_attributes.py b/rio/debug/dev_tools/component_attributes.py index 1d3a9868..da897806 100644 --- a/rio/debug/dev_tools/component_attributes.py +++ b/rio/debug/dev_tools/component_attributes.py @@ -1,8 +1,8 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY from pathlib import Path -from typing import * # type: ignore import rio import rio.docs @@ -140,7 +140,7 @@ class ComponentAttributes(rio.Component): self, result: DetailsGrid, target: rio.Component, - debug_details: dict[str, Any], + debug_details: dict[str, t.Any], ) -> None: # Add the component's attributes result.add_heading3("Custom Attributes", margin_top=0) @@ -337,7 +337,7 @@ class DetailsGrid: *, row: int | None = None, column: int, - justify: Literal["left", "center", "right"] = "right", + justify: t.Literal["left", "center", "right"] = "right", ellipsize: bool = False, component_width: float = 0, column_width: int = 1, @@ -362,7 +362,7 @@ class DetailsGrid: row: int | None = None, column: int, width: int = 1, - justify: Literal["left", "center", "right"] = "left", + justify: t.Literal["left", "center", "right"] = "left", ellipsize: bool = False, ) -> None: self.add( diff --git a/rio/debug/dev_tools/dev_tools_sidebar.py b/rio/debug/dev_tools/dev_tools_sidebar.py index d201d820..4baabc6e 100644 --- a/rio/debug/dev_tools/dev_tools_sidebar.py +++ b/rio/debug/dev_tools/dev_tools_sidebar.py @@ -1,6 +1,6 @@ import os +import typing as t from pathlib import Path -from typing import * # type: ignore import rio.components.class_container import rio.debug.dev_tools.dev_tools_connector @@ -20,7 +20,7 @@ class DevToolsSidebar(rio.Component): show_rio_developer_page: bool = False selected_page: ( - Literal[ + t.Literal[ "project", "tree", "docs", diff --git a/rio/debug/dev_tools/icons_page.py b/rio/debug/dev_tools/icons_page.py index b3d54e8b..49bd2d43 100644 --- a/rio/debug/dev_tools/icons_page.py +++ b/rio/debug/dev_tools/icons_page.py @@ -1,8 +1,8 @@ import dataclasses import functools import re +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore import fuzzywuzzy.fuzz @@ -11,7 +11,7 @@ from rio import icon_registry from . import sample_icons_grid -T = TypeVar("T") +T = t.TypeVar("T") # Replace all sequences non-alphanumeric characters with a single dot @@ -41,7 +41,7 @@ def search( *, min_results: int = 5, threshold: float = 0.85, - key: Callable[[T], str] = lambda x: x, # type: ignore + key: t.Callable[[T], str] = lambda x: x, # type: ignore ) -> list[tuple[T, float]]: """ Given a set of candidate strings, return the best matches for the provided @@ -106,7 +106,7 @@ def get_available_icons() -> list[tuple[str, str, tuple[str | None, ...]]]: variants.add(variant_name) # Drop the dict - as_list: list[Any] = list(result_dict.values()) + as_list: list[t.Any] = list(result_dict.values()) # Sort the result as_list.sort(key=lambda x: x[1]) @@ -134,7 +134,7 @@ class IconsPage(rio.Component): dataclasses.field(default_factory=tuple) ) selected_variant: str | None = None - selected_fill: Literal[ + selected_fill: t.Literal[ "primary", "secondary", "success", @@ -344,6 +344,7 @@ Use the `rio.Icon` component like this: children.append( rio.TextInput( label="Search for an icon", + style="pill", text=self.bind().search_text, on_change=self._on_search_text_change, key="search-text-input", diff --git a/rio/debug/dev_tools/layout_display.py b/rio/debug/dev_tools/layout_display.py index 82ae9cad..e9ec25ba 100644 --- a/rio/debug/dev_tools/layout_display.py +++ b/rio/debug/dev_tools/layout_display.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore from uniserde import JsonDoc @@ -14,7 +14,7 @@ __all__ = [ ] -@final +@t.final class LayoutDisplay(FundamentalComponent): component_id: int # This can be invalid. The component must deal with it. @@ -24,7 +24,7 @@ class LayoutDisplay(FundamentalComponent): on_component_change: rio.EventHandler[int] = None on_layout_change: rio.EventHandler[[]] = None - async def _on_message_(self, msg: Any) -> None: + async def _on_message_(self, msg: t.Any) -> None: # Parse the message assert isinstance(msg, dict), msg assert msg["type"] == "layoutChange", msg diff --git a/rio/debug/dev_tools/layout_explainer.py b/rio/debug/dev_tools/layout_explainer.py index 5a5eee98..66080c21 100644 --- a/rio/debug/dev_tools/layout_explainer.py +++ b/rio/debug/dev_tools/layout_explainer.py @@ -1,7 +1,7 @@ from __future__ import annotations import io -from typing import * # type: ignore +import typing as t import rio.data_models @@ -40,7 +40,7 @@ FULL_SIZE_SINGLE_CONTAINERS: set[type[rio.Component]] = { # These components make use of the `grow_...` attributes in at least one axis. -CONTAINERS_SUPPORTING_GROW: Iterable[type[rio.Component]] = { +CONTAINERS_SUPPORTING_GROW: t.Iterable[type[rio.Component]] = { rio.Column, rio.Grid, rio.Row, @@ -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( @@ -133,9 +133,9 @@ class LayoutExplainer: def _explain_allocated_space_before_alignment( self, - axis_name: Literal["width", "height"], - suggest_shrink: Callable[[str], None], - suggest_grow: Callable[[str], None], + axis_name: t.Literal["width", "height"], + suggest_shrink: t.Callable[[str], None], + suggest_grow: t.Callable[[str], None], ) -> str: """ Given a component and its layout, return a human readable explanation @@ -269,9 +269,9 @@ class LayoutExplainer: async def _explain_layout_in_axis( self, - axis_name: Literal["width", "height"], - suggest_shrink: Callable[[str], None], - suggest_grow: Callable[[str], None], + axis_name: t.Literal["width", "height"], + suggest_shrink: t.Callable[[str], None], + suggest_grow: t.Callable[[str], None], ) -> str: """ Given a component, come up with a human readable explanation for why it was diff --git a/rio/debug/dev_tools/layout_subpage.py b/rio/debug/dev_tools/layout_subpage.py index 9b2fe1ea..6c73d337 100644 --- a/rio/debug/dev_tools/layout_subpage.py +++ b/rio/debug/dev_tools/layout_subpage.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY -from typing import * # type: ignore import rio import rio.components.fundamental_component @@ -11,7 +11,7 @@ from . import layout_explainer class SizeControls(rio.Component): - label: Literal["width", "height"] + label: t.Literal["width", "height"] grow: bool min_value: float @@ -216,7 +216,7 @@ class LayoutSubpage(rio.Component): except KeyError: self._layout_explainer = None - async def _update_target_attribute(self, name: str, value: Any) -> None: + async def _update_target_attribute(self, name: str, value: t.Any) -> None: """ Updates an attribute of the target component. diff --git a/rio/debug/dev_tools/rio_developer_page.py b/rio/debug/dev_tools/rio_developer_page.py index ca2643a3..8c18fa9e 100644 --- a/rio/debug/dev_tools/rio_developer_page.py +++ b/rio/debug/dev_tools/rio_developer_page.py @@ -1,8 +1,8 @@ import cProfile import io import marshal +import typing as t from pathlib import Path -from typing import * # type: ignore import rio.debug.dev_tools.component_tree import rio.debug.layouter @@ -124,7 +124,7 @@ for identifying performance bottlenecks in your code. out_dir = Path.cwd() / "rio-layout-dump" out_dir.mkdir(parents=True, exist_ok=True) - def dump(which: Literal["should", "are"]) -> None: + def dump(which: t.Literal["should", "are"]) -> None: # Export the layouts to a JSON file ly.debug_dump_json( which=which, @@ -156,6 +156,7 @@ looks like. on_press=self._on_dump_layout, ), spacing=0.5, + margin=0.5, ) def build(self) -> rio.Component: diff --git a/rio/debug/dev_tools/sample_icons_grid.py b/rio/debug/dev_tools/sample_icons_grid.py index 78368bad..9265b754 100644 --- a/rio/debug/dev_tools/sample_icons_grid.py +++ b/rio/debug/dev_tools/sample_icons_grid.py @@ -1,6 +1,6 @@ import functools import random -from typing import * # type: ignore +import typing as t import rio @@ -12,7 +12,7 @@ GRID_N_COLUMNS = 6 # Choose icons to display -def find_icons_to_display() -> Iterable[str]: +def find_icons_to_display() -> t.Iterable[str]: # Get a list of all available icons names_and_variants: list[tuple[str, str | None]] = list( icon_registry.all_icons_in_set(ICON_SET) diff --git a/rio/debug/dev_tools/theme_picker_page.py b/rio/debug/dev_tools/theme_picker_page.py index 16b28112..27d13be5 100644 --- a/rio/debug/dev_tools/theme_picker_page.py +++ b/rio/debug/dev_tools/theme_picker_page.py @@ -1,6 +1,6 @@ import functools import io -from typing import * # type: ignore +import typing as t import rio @@ -21,7 +21,7 @@ def colors_equal(color1: rio.Color, color2: rio.Color) -> bool: ) -def get_minimum_theme_kwargs(theme: rio.Theme) -> dict[str, Any]: +def get_minimum_theme_kwargs(theme: rio.Theme) -> dict[str, t.Any]: """ Given a theme, returns a dictionary with the minimum set of keyword arguments required to recreate it. @@ -29,7 +29,7 @@ def get_minimum_theme_kwargs(theme: rio.Theme) -> dict[str, Any]: # This is more complex than it might seem at first, because many colors are # derived from other colors. For example, the neutral color is derived from # the primary one. - result: dict[str, Any] = {} + result: dict[str, t.Any] = {} # Light / dark mode can impact some colors. Make sure to get that value # first. @@ -103,7 +103,7 @@ def get_minimum_theme_kwargs(theme: rio.Theme) -> dict[str, Any]: async def update_and_apply_theme( session: rio.Session, - theme_replacements: dict[str, Any], + theme_replacements: dict[str, t.Any], ) -> None: """ Overrides the session's theme with the given one, and makes sure to update @@ -211,7 +211,7 @@ class PalettePicker(rio.Component): # }, ) - def _on_press(self, event: rio.PressEvent) -> None: + def _on_press(self, event: rio.PointerEvent) -> None: # Toggle the popup if self.shared_open_key == self.palette_nicename: self.shared_open_key = "" @@ -229,7 +229,7 @@ class PalettePicker(rio.Component): # ) return rio.Popup( - anchor=rio.MouseEventListener( + anchor=rio.PointerEventListener( # Switches the color of the Rectangle's ripple effect rio.ThemeContextSwitcher( content=rio.Rectangle( diff --git a/rio/debug/dev_tools/tree_page.py b/rio/debug/dev_tools/tree_page.py index 9ec352ed..af9a6516 100644 --- a/rio/debug/dev_tools/tree_page.py +++ b/rio/debug/dev_tools/tree_page.py @@ -1,4 +1,4 @@ -from typing import * # type: ignore +import typing as t import rio.debug.dev_tools.component_picker import rio.debug.dev_tools.component_tree @@ -16,12 +16,12 @@ class TreePage(rio.Component): views. """ - current_view: Literal["tree", "attributes", "layout"] = "tree" + current_view: t.Literal["tree", "attributes", "layout"] = "tree" # This can be invalid. The component must deal with it. selected_component_id: int = -1 - def _switch_to_tree(self, _: rio.PressEvent) -> None: + def _switch_to_tree(self, _: rio.PointerEvent) -> None: self.current_view = "tree" def _switch_to_details(self) -> None: @@ -31,7 +31,7 @@ class TreePage(rio.Component): self.current_view = "layout" def _build_back_menu(self, label: str) -> rio.Component: - return rio.MouseEventListener( + return rio.PointerEventListener( rio.Rectangle( content=rio.Row( rio.Icon( diff --git a/rio/debug/layouter.py b/rio/debug/layouter.py index 7b802c71..b3b65c48 100644 --- a/rio/debug/layouter.py +++ b/rio/debug/layouter.py @@ -2,8 +2,7 @@ from __future__ import annotations import json import sys -from typing import * # type: ignore -from typing import Iterable +import typing as t import PIL.Image import PIL.ImageDraw @@ -15,8 +14,8 @@ import rio.components.fundamental_component import rio.components.root_components from rio.data_models import UnittestComponentLayout -R = TypeVar("R") -P = ParamSpec("P") +R = t.TypeVar("R") +P = t.ParamSpec("P") # These components pass on the entirety of the available space to their @@ -34,7 +33,7 @@ FULL_SIZE_SINGLE_CONTAINERS: set[type[rio.Component]] = { } -def specialized(func: Callable[P, R]) -> Callable[P, R]: +def specialized(func: t.Callable[P, R]) -> t.Callable[P, R]: """ Decorator that defers to a specialized method if one exists. If none is found, calls the original method. @@ -44,11 +43,12 @@ def specialized(func: Callable[P, R]) -> Callable[P, R]: `foo` otherwise. """ - def result(self, component, *args, **kwargs) -> Any: + def result(self, component, *args, **kwargs) -> t.Any: # 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: @@ -68,7 +68,7 @@ def specialized(func: Callable[P, R]) -> Callable[P, R]: def _linear_container_get_major_axis_natural_size( child_requested_sizes: list[float], spacing: float, - proportions: None | Literal["homogeneous"] | Sequence[float], + proportions: None | t.Literal["homogeneous"] | t.Sequence[float], ) -> float: # Spacing result = spacing * (len(child_requested_sizes) - 1) @@ -107,7 +107,7 @@ def _linear_container_get_major_axis_allocated_sizes( child_requested_sizes: list[float], child_growers: list[bool], spacing: float, - proportions: None | Literal["homogeneous"] | Sequence[float], + proportions: None | t.Literal["homogeneous"] | t.Sequence[float], ) -> list[tuple[float, float]]: starts_and_sizes: list[tuple[float, float]] = [] @@ -197,7 +197,7 @@ def calculate_alignment( def iter_direct_tree_children( component: rio.Component, -) -> Iterable[rio.Component]: +) -> t.Iterable[rio.Component]: """ Iterates over the children of a component. In particular, the children which are part of the component tree, rather than those stored in the components @@ -228,7 +228,7 @@ class Layouter: # Function to filter uninteresting components. If this returns `False` for a # given component, that component and all of its children are ignored. - _filter: Callable[[rio.Component], bool] + _filter: t.Callable[[rio.Component], bool] # All components in the session, ordered such that each parent appears # before its children. @@ -244,7 +244,7 @@ class Layouter: async def create( session: rio.Session, *, - filter: Callable[[rio.Component], bool] = lambda _: True, + filter: t.Callable[[rio.Component], bool] = lambda _: True, ) -> Layouter: # Create a new instance self = Layouter.__new__(Layouter) @@ -326,7 +326,7 @@ class Layouter: def _get_toposorted( self, root: rio.Component, - ) -> Iterable[rio.Component]: + ) -> t.Iterable[rio.Component]: """ Returns the component, as well as all direct and indirect children. The results are ordered such that each parent appears before its children. @@ -786,8 +786,8 @@ class Layouter: def debug_dump_json( self, - which: Literal["should", "are"], - out: IO[str], + which: t.Literal["should", "are"], + out: t.IO[str], ) -> None: """ Dumps the layouts to a JSON file. @@ -798,7 +798,7 @@ class Layouter: ) # Convert the class instances to JSON - result: list[dict[str, Any]] = [] + result: list[dict[str, t.Any]] = [] def dump_recursive(component: rio.Component) -> None: # Honor the filter function @@ -836,7 +836,7 @@ class Layouter: def debug_draw( self, - which: Literal["should", "are"], + which: t.Literal["should", "are"], *, pixels_per_unit: float = 10, ) -> PIL.Image.Image: diff --git a/rio/debug/monkeypatches.py b/rio/debug/monkeypatches.py index 259a70ee..4b6d86a3 100644 --- a/rio/debug/monkeypatches.py +++ b/rio/debug/monkeypatches.py @@ -1,5 +1,5 @@ +import typing as t from pathlib import Path -from typing import * # type: ignore import introspection.typing @@ -157,7 +157,7 @@ def LinearContainer_init( wrapped_method, self: components.Row, *children, - proportions: Literal["homogeneous"] | Sequence[float] | None = None, + proportions: t.Literal["homogeneous"] | t.Sequence[float] | None = None, **kwargs, ) -> None: # Proportions related checks diff --git a/rio/debug/typing_utils.py b/rio/debug/typing_utils.py index 39967ba0..f7b2884c 100644 --- a/rio/debug/typing_utils.py +++ b/rio/debug/typing_utils.py @@ -1,6 +1,5 @@ import types from dataclasses import dataclass -from typing import * # type: ignore import introspection.typing diff --git a/rio/debug/validator.py b/rio/debug/validator.py index e45734e2..a552ca42 100644 --- a/rio/debug/validator.py +++ b/rio/debug/validator.py @@ -4,9 +4,9 @@ import collections import copy import json import re +import typing as t from dataclasses import dataclass from pathlib import Path -from typing import * # type: ignore from uniserde import Jsonable, JsonDoc @@ -66,7 +66,7 @@ class ClientComponent: state=delta_state, ) - def _get_child_attribute_names(self) -> Iterable[str]: + def _get_child_attribute_names(self) -> t.Iterable[str]: child_attr_names = inspection.get_child_component_containing_attribute_names_for_builtin_components() try: return child_attr_names[self.type] @@ -104,7 +104,7 @@ class ClientComponent: return result @property - def referenced_child_ids(self) -> Iterable[int]: + def referenced_child_ids(self) -> t.Iterable[int]: for property_value in self.child_containing_properties.values(): if property_value is None: continue @@ -281,7 +281,7 @@ class Validator: return result - def handle_incoming_message(self, msg: Any) -> None: + def handle_incoming_message(self, msg: t.Any) -> None: """ Process a message passed from Client -> Server. @@ -304,7 +304,7 @@ class Validator: handler(msg["params"]) - def handle_outgoing_message(self, msg: Any) -> None: + def handle_outgoing_message(self, msg: t.Any) -> None: """ Process a message passed from Server -> Client. @@ -327,7 +327,7 @@ class Validator: handler(msg["params"]) - def _handle_outgoing_updateComponentStates(self, msg: Any) -> None: + def _handle_outgoing_updateComponentStates(self, msg: t.Any) -> None: # Dump the message, if requested self.dump_message(msg, incoming=False) @@ -438,7 +438,7 @@ class Validator: # Dump the client state if requested self.dump_client_state() - def _handle_outgoing_evaluateJavascript(self, msg: Any): + def _handle_outgoing_evaluateJavascript(self, msg: t.Any): # Is this message registering a new component class? match = re.search( r"window.componentClasses\['(.*)'\]", msg["javaScriptSource"] diff --git a/rio/deprecations.py b/rio/deprecations.py index 25cc633d..8fba2ab9 100644 --- a/rio/deprecations.py +++ b/rio/deprecations.py @@ -1,6 +1,6 @@ import functools +import typing as t import warnings -from typing import * # type: ignore import introspection @@ -9,8 +9,8 @@ from .warnings import * # The alias here is necessary to avoid ruff stupidly replacing the import with # a `pass`. -if TYPE_CHECKING: - import rio as rio +if t.TYPE_CHECKING: + import rio __all__ = [ "deprecated", @@ -20,9 +20,9 @@ __all__ = [ ] -CO = TypeVar("CO", bound="rio.Component") -C = TypeVar("C", bound=Union[Callable, ComponentMeta]) -F = TypeVar("F", bound=Callable) +CO = t.TypeVar("CO", bound="rio.Component") +C = t.TypeVar("C", bound=t.Union[t.Callable, ComponentMeta]) +F = t.TypeVar("F", bound=t.Callable) def warn( @@ -68,11 +68,11 @@ def warn_parameter_renamed( ) -@overload -def deprecated(*, since: str, replacement: Callable | str): ... +@t.overload +def deprecated(*, since: str, replacement: t.Callable | str): ... -@overload +@t.overload def deprecated(*, since: str, description: str): ... @@ -80,7 +80,7 @@ def deprecated( *, since: str, description: str | None = None, - replacement: Callable | str | None = None, + replacement: t.Callable | str | None = None, ): if replacement is not None: if not isinstance(replacement, str): @@ -115,7 +115,7 @@ def component_kwarg_renamed( the contained `_remap_constructor_arguments` method) """ - def decorator(component_class: Type[CO]) -> Type[CO]: + def decorator(component_class: t.Type[CO]) -> t.Type[CO]: old_remap = component_class._remap_constructor_arguments_ @staticmethod @@ -149,7 +149,7 @@ def component_kwarg_renamed( def parameters_remapped( *, since: str, - **params: Callable[[Any], dict[str, Any]], + **params: t.Callable[[t.Any], dict[str, t.Any]], ): """ This is a function decorator that's quite similar to `parameters_renamed`, @@ -160,14 +160,14 @@ def parameters_remapped( parameter as input and return a dict `{'new_parameter_name': value}`. Example: `Theme.from_colors` used to have a `light: bool = True` parameter - which was changed to `mode: Literal['light', 'dark'] = 'light'`. + which was changed to `mode: t.Literal['light', 'dark'] = 'light'`. class Theme: @parameters_remapped( '0.9', light=lambda light: {"mode": "light" if light else "dark"}, ) - def from_colors(..., mode: Literal['light', 'dark'] = 'light'): + def from_colors(..., mode: t.Literal['light', 'dark'] = 'light'): ... Theme.from_colors(light=False) # Equivalent to `mode='dark'` @@ -203,7 +203,7 @@ def _remap_kwargs( since: str, func_name: str, kwargs: dict[str, object], - old_names_to_new_names: Mapping[str, str], + old_names_to_new_names: t.Mapping[str, str], ) -> None: for old_name, new_name in old_names_to_new_names.items(): try: @@ -223,7 +223,7 @@ def function_kwarg_renamed( since: str, old_name: str, new_name: str, -) -> Callable[[F], F]: +) -> t.Callable[[F], F]: """ This decorator helps with renaming a keyword argument of a function, NOT a component. diff --git a/rio/dialog.py b/rio/dialog.py index b320f179..a90af1f8 100644 --- a/rio/dialog.py +++ b/rio/dialog.py @@ -1,14 +1,14 @@ from __future__ import annotations import asyncio -import typing +import typing as t from . import component, dialog_container -T = typing.TypeVar("T") +T = t.TypeVar("T") -class Dialog(typing.Generic[T]): +class Dialog(t.Generic[T]): # The component that has created this dialog _owning_component: component.Component diff --git a/rio/docs.py b/rio/docs.py index a7ffb633..4c315d0a 100644 --- a/rio/docs.py +++ b/rio/docs.py @@ -4,9 +4,10 @@ Contains documentation related tasks specific to the Rio project. import dataclasses import functools +import inspect import re import types -from typing import * # type: ignore +import typing as t import imy.docstrings import introspection @@ -25,8 +26,8 @@ __all__ = [ ] -Class = TypeVar("Class", bound=type) -ClassOrFunction = TypeVar("ClassOrFunction", bound=type | types.FunctionType) +Class = t.TypeVar("Class", bound=type) +ClassOrFunction = t.TypeVar("ClassOrFunction", bound=type | types.FunctionType) _NAME_TO_URL: dict[str, str] | None = None @@ -118,16 +119,16 @@ def mark_constructor_as_private(cls: Class) -> Class: return cls -@overload +@t.overload def get_docs_for(obj: type) -> imy.docstrings.ClassDocs: ... -@overload +@t.overload def get_docs_for(obj: types.FunctionType) -> imy.docstrings.FunctionDocs: ... def get_docs_for( - obj: Callable | Type, + obj: t.Callable | t.Type, ) -> imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs: """ Parse the docs for a component and return them. The results are cached, so @@ -181,7 +182,7 @@ def build_documentation_url( return rio.URL(result_string) -def _find_possibly_public_objects() -> Iterable[Type | Callable]: +def _find_possibly_public_objects() -> t.Iterable[t.Type | t.Callable]: """ Finds all objects in rio that might be public. This uses heuristics to filter out many internal objects, but it's not perfect. @@ -190,42 +191,34 @@ def _find_possibly_public_objects() -> Iterable[Type | Callable]: yield rio.App yield rio.AssetError yield rio.Color - yield rio.ColorChangeEvent yield rio.ComponentPage yield rio.CursorStyle - yield rio.DrawerOpenOrCloseEvent - yield rio.DropdownChangeEvent yield rio.escape_markdown yield rio.escape_markdown_code - yield rio.FilePickEvent yield rio.FileInfo yield rio.Font - yield rio.GuardEvent - yield rio.KeyDownEvent - yield rio.KeyPressEvent - yield rio.KeyUpEvent - yield rio.MouseDownEvent - yield rio.MouseEnterEvent - yield rio.MouseLeaveEvent - yield rio.MouseMoveEvent - yield rio.MouseUpEvent yield rio.NavigationFailed - yield rio.NumberInputChangeEvent - yield rio.NumberInputConfirmEvent yield rio.page yield rio.Redirect - yield rio.RevealerChangeEvent yield rio.Session - yield rio.TextInputChangeEvent - yield rio.TextInputConfirmEvent yield rio.TextStyle yield rio.Theme yield rio.UserSettings + yield rio.DateChangeEvent for module in (rio.event, rio.fills): for name in module.__all__: yield getattr(module, name) + # Yield all events. There is no perfectly safe way to detect these + # automatically, but the name is a good hint. + for name, obj in vars(rio).items(): + if not name.endswith("Event"): + continue + + assert inspect.isclass(obj), obj + yield obj + # Yield classes that also need their children documented to_do = [rio.Component] @@ -260,8 +253,9 @@ def _find_possibly_public_objects() -> Iterable[Type | Callable]: @functools.cache def _get_unprocessed_docs() -> ( - Mapping[ - type | Callable, imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs + t.Mapping[ + type | t.Callable, + imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs, ] ): """ @@ -288,6 +282,12 @@ def _get_unprocessed_docs() -> ( if not docs.metadata.public: continue + # Make the summary into a single line. (This is because the summary is + # sometimes displayed inside a `rio.Text`, which honors newlines. We + # don't want that.) + if docs.summary: + docs.summary = docs.summary.replace("\n", " ") + # This object is public result[obj] = docs @@ -296,8 +296,9 @@ def _get_unprocessed_docs() -> ( @functools.cache def find_documented_objects() -> ( - Mapping[ - type | Callable, imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs + t.Mapping[ + type | t.Callable, + imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs, ] ): """ diff --git a/rio/event.py b/rio/event.py index bbbccc69..61e76f46 100644 --- a/rio/event.py +++ b/rio/event.py @@ -1,8 +1,8 @@ from __future__ import annotations import enum +import typing as t from datetime import timedelta -from typing import * # type: ignore __all__ = [ "on_mount", @@ -14,15 +14,15 @@ __all__ = [ ] -R = TypeVar("R") -SyncOrAsync = R | Awaitable[R] -SyncOrAsyncNone = TypeVar("SyncOrAsyncNone", bound=SyncOrAsync[None]) +R = t.TypeVar("R") +SyncOrAsync = R | t.Awaitable[R] +SyncOrAsyncNone = t.TypeVar("SyncOrAsyncNone", bound=SyncOrAsync[None]) -Func = TypeVar("Func", bound=Callable) -Decorator = Callable[[Func], Func] +Func = t.TypeVar("Func", bound=t.Callable) +Decorator = t.Callable[[Func], Func] -MethodWithNoParametersVar = TypeVar( - "MethodWithNoParametersVar", bound=Callable[[Any], Any] +MethodWithNoParametersVar = t.TypeVar( + "MethodWithNoParametersVar", bound=t.Callable[[t.Any], t.Any] ) @@ -45,16 +45,16 @@ class EventTag(enum.Enum): def _tag_as_event_handler( - function: Callable, + function: t.Callable, tag: EventTag, - args: Any, + args: t.Any, ) -> None: """ Registers the function as an event handler for the given tag. This simply adds a marker to the function's `__dict__` so that it can later be recognized as an event handler. """ - all_events: dict[EventTag, list[Any]] = vars(function).setdefault( + all_events: dict[EventTag, list[t.Any]] = vars(function).setdefault( "_rio_events_", {} ) events_like_this = all_events.setdefault(tag, []) diff --git a/rio/fills.py b/rio/fills.py index f481bd59..4a569505 100644 --- a/rio/fills.py +++ b/rio/fills.py @@ -1,10 +1,10 @@ from __future__ import annotations +import typing as t from abc import ABC from dataclasses import dataclass -from typing import Literal -from typing_extensions import TypeAlias +import typing_extensions as te from uniserde import Jsonable import rio @@ -157,7 +157,7 @@ class ImageFill(Fill): self, image: ImageLike, *, - fill_mode: Literal["fit", "stretch", "zoom"] = "fit", + fill_mode: t.Literal["fit", "stretch", "zoom"] = "fit", ) -> None: """ ## Parameters @@ -239,6 +239,6 @@ class FrostedGlassFill(Fill): } -_FillLike: TypeAlias = ( +_FillLike: te.TypeAlias = ( SolidFill | LinearGradientFill | ImageFill | FrostedGlassFill | Color ) diff --git a/rio/global_state.py b/rio/global_state.py index 50818fc7..d0202150 100644 --- a/rio/global_state.py +++ b/rio/global_state.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import * # type: ignore +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 diff --git a/rio/icon_registry.py b/rio/icon_registry.py index 50ee243d..1202c84b 100644 --- a/rio/icon_registry.py +++ b/rio/icon_registry.py @@ -2,8 +2,8 @@ from __future__ import annotations import logging import tarfile +import typing as t from pathlib import Path -from typing import * # type: ignore from . import utils from .errors import AssetError @@ -189,7 +189,7 @@ def get_icon_svg(icon_name: str) -> str: def _get_variant_directories( icon_set: str, -) -> Iterable[tuple[str | None, Path]]: +) -> t.Iterable[tuple[str | None, Path]]: """ Given the name of an icon set, list the names of all variants in that set along with the directory they are stored in. @@ -214,14 +214,14 @@ def _get_variant_directories( yield (None, icon_set_dir) -def all_icon_sets() -> Iterable[str]: +def all_icon_sets() -> t.Iterable[str]: """ Return the names of all icon set names known to rio. """ return icon_set_archives.keys() -def all_variants_in_set(icon_set: str) -> Iterable[str | None]: +def all_variants_in_set(icon_set: str) -> t.Iterable[str | None]: """ Given the name of an icon set, list the names of all variants in that set. @@ -234,7 +234,7 @@ def all_icons_in_set( icon_set: str, *, variant: str | None = None, -) -> Iterable[tuple[str, str | None]]: +) -> t.Iterable[tuple[str, str | None]]: """ Given the name of an icon set, list all icon names and variants in that set. If `variant` is given, only return icons with that variant. diff --git a/rio/icons.py b/rio/icons.py index e8424aa8..f87dc58d 100644 --- a/rio/icons.py +++ b/rio/icons.py @@ -9,8 +9,8 @@ __all__ = ["register_icon_set", "register_icon"] since="0.9.2", replacement="rio.Icon.register_icon_set" ) def register_icon_set( - icon_set_name: str, - icon_set_archive_path: Path, + set_name: str, + set_archive_path: Path, ) -> None: """ Add an icon set to the global registry. This allows the icons to be accessed @@ -34,24 +34,22 @@ def register_icon_set( ## Parameters - `icon_set_name`: The name of the new icon set. This will be used to access + `set_name`: The name of the new icon set. This will be used to access the icons. - `icon_set_archive_path`: The path to the `.tar.xz` archive containing the + `set_archive_path`: The path to the `.tar.xz` archive containing the icon set. """ - if icon_set_name in icon_registry.icon_set_archives: - raise ValueError( - f"There is already an icon set named `{icon_set_name}`" - ) + if set_name in icon_registry.icon_set_archives: + raise ValueError(f"There is already an icon set named `{set_name}`") - icon_registry.icon_set_archives[icon_set_name] = icon_set_archive_path + icon_registry.icon_set_archives[set_name] = set_archive_path @deprecations.deprecated(since="0.9.2", replacement="rio.Icon.register_icon") def register_icon( icon_source: Path, - icon_set_name: str, + set_name: str, icon_name: str, variant_name: str | None = None, ) -> None: @@ -89,8 +87,8 @@ def register_icon( # Add it to the icon registry's cache if variant_name is None: - name = f"{icon_set_name}/{icon_name}" + name = f"{set_name}/{icon_name}" else: - name = f"{icon_set_name}/{icon_name}:{variant_name}" + name = f"{set_name}/{icon_name}:{variant_name}" icon_registry.cached_icons[name] = svg_source diff --git a/rio/inspection.py b/rio/inspection.py index 3ab256b8..7d192a88 100644 --- a/rio/inspection.py +++ b/rio/inspection.py @@ -4,8 +4,7 @@ import collections import functools import inspect import sys -from collections.abc import Collection, Iterator, Mapping -from typing import Type +import typing as t import introspection.typing @@ -20,7 +19,7 @@ __all__ = [ _EXPLICITLY_SET_STATE_PROPERTY_NAMES_CACHE: dict[ - tuple[Type[rio.Component], int, frozenset[str]], frozenset[str] + tuple[t.Type[rio.Component], int, frozenset[str]], frozenset[str] ] = {} @@ -28,7 +27,7 @@ _EXPLICITLY_SET_STATE_PROPERTY_NAMES_CACHE: dict[ # times can give different outputs. For example, if called immediately after the # input class has been created, some forward references may not be evaluatable # yet. -class get_local_annotations(Mapping[str, introspection.types.TypeAnnotation]): +class get_local_annotations(t.Mapping[str, introspection.types.TypeAnnotation]): def __init__(self, cls: type, *, strict: bool = False) -> None: # Note: Don't use `typing.get_type_hints` because it has a stupid bug in # python 3.10 where it dies if something is annotated as @@ -46,7 +45,7 @@ class get_local_annotations(Mapping[str, introspection.types.TypeAnnotation]): treat_name_errors_as_imports=True, ) - def __iter__(self) -> Iterator[str]: + def __iter__(self) -> t.Iterator[str]: return iter(self._annotations) def __len__(self) -> int: @@ -55,7 +54,7 @@ class get_local_annotations(Mapping[str, introspection.types.TypeAnnotation]): def get_resolved_type_annotations( cls: type, -) -> Mapping[str, type]: +) -> t.Mapping[str, type]: maps = [get_local_annotations(c, strict=True) for c in cls.__mro__] return collections.ChainMap(*maps) # type: ignore @@ -63,7 +62,7 @@ def get_resolved_type_annotations( @functools.lru_cache(maxsize=None) def get_child_component_containing_attribute_names( cls: type[rio.Component], -) -> Collection[str]: +) -> t.Collection[str]: from . import serialization attr_names: list[str] = [] @@ -95,7 +94,7 @@ def get_child_component_containing_attribute_names( @functools.lru_cache(maxsize=None) def get_child_component_containing_attribute_names_for_builtin_components() -> ( - Mapping[str, Collection[str]] + t.Mapping[str, t.Collection[str]] ): from .components.fundamental_component import FundamentalComponent diff --git a/rio/maybes.py b/rio/maybes.py index 2643f61d..ce5b1096 100644 --- a/rio/maybes.py +++ b/rio/maybes.py @@ -10,11 +10,11 @@ used. from __future__ import annotations import sys -from typing import * # type: ignore +import typing as t import introspection -if TYPE_CHECKING: +if t.TYPE_CHECKING: import matplotlib.axes # type: ignore import matplotlib.figure # type: ignore import numpy # type: ignore @@ -24,7 +24,7 @@ if TYPE_CHECKING: _IS_INITIALIZED = False -T = TypeVar("T") +T = t.TypeVar("T") FLOAT_TYPES = () @@ -42,7 +42,7 @@ MATPLOTLIB_GRAPH_TYPES: tuple[type, ...] = () MATPLOTLIB_AXES_TYPES: tuple[type[matplotlib.axes.Axes], ...] = () # This is a mapping of "weird" types to the "canonical" type, like `{np.int8: int}` -TYPE_NORMALIZERS: dict[type[T], Callable[[T], T]] = {} # type: ignore +TYPE_NORMALIZERS: dict[type[T], t.Callable[[T], T]] = {} # type: ignore def initialize(force: bool = False) -> None: diff --git a/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py b/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py index e065c4e2..44bd93da 100644 --- a/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py +++ b/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py @@ -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 diff --git a/rio/path_match.py b/rio/path_match.py index 09d85ba1..f92b7980 100644 --- a/rio/path_match.py +++ b/rio/path_match.py @@ -1,5 +1,5 @@ +import typing as t from pathlib import Path -from typing import * # type: ignore import gitignore_parser @@ -22,7 +22,7 @@ class PathMatch: self, base_dir: Path, *, - rules: Iterable[str] = tuple(), + rules: t.Iterable[str] = tuple(), ) -> None: self._base_dir = base_dir.resolve() self._rules: list[gitignore_parser.IgnoreRule] = [] diff --git a/rio/project_config.py b/rio/project_config.py index 3fa66872..d9d4f71d 100644 --- a/rio/project_config.py +++ b/rio/project_config.py @@ -1,8 +1,8 @@ from __future__ import annotations import functools +import typing as t from pathlib import Path -from typing import * # type: ignore import revel import tomlkit @@ -16,14 +16,14 @@ from . import path_match __all__ = ["RioProjectConfig"] -T = TypeVar("T") +T = t.TypeVar("T") DEFAULT_FATAL = object() DEFAULT_KEYERROR = object() -DEFAULT_PROJECT_FILES_GLOB_PATTERNS: Iterable[str] = ( +DEFAULT_PROJECT_FILES_GLOB_PATTERNS: t.Iterable[str] = ( "*.py", "/assets/", "/rio.toml", @@ -55,7 +55,7 @@ class RioProjectConfig: # { # (section, key): value # } - self._toml_dict: dict[tuple[str, str], Any] = {} + self._toml_dict: dict[tuple[str, str], t.Any] = {} self._replace_from_dictionary(toml_dict) def _replace_from_dictionary(self, raw: uniserde.JsonDoc) -> None: @@ -100,8 +100,8 @@ class RioProjectConfig: self, section_name: str, key_name: str, - key_type: Type[T], - default_value: Any, + key_type: t.Type[T], + default_value: t.Any, ) -> T: """ Fetches the value of a key from the `rio.toml` file. If the key is @@ -142,7 +142,7 @@ class RioProjectConfig: f" {key_type}, got {type(value).__name__}", ) - def set_key(self, section_name: str, key_name: str, value: Any) -> None: + def set_key(self, section_name: str, key_name: str, value: t.Any) -> None: """ Sets the value of a key in the `rio.toml` file. The value is not written to disk until `write()` is called. @@ -162,7 +162,7 @@ class RioProjectConfig: return self.rio_toml_path.parent @property - def app_type(self) -> Literal["app", "website"]: + def app_type(self) -> t.Literal["app", "website"]: """ Whether this project is a website or local app. """ @@ -176,7 +176,7 @@ class RioProjectConfig: return result @app_type.setter - def app_type(self, value: Literal["app", "website"]) -> None: + def app_type(self, value: t.Literal["app", "website"]) -> None: self.set_key("app", "app-type", value) @property @@ -186,26 +186,29 @@ 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. """ - folder = self.project_directory + *parent_modules, module_name = self.app_main_module.split(".") - # If a `src` directory exists, look there - src_dir = folder / "src" - if src_dir.exists(): - folder = src_dir + # 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.joinpath(*parent_modules, module_name) + if module_path.is_dir(): + return module_path - # If a package (folder) exists, use that - module_path = folder / self.app_main_module - if module_path.exists(): - return module_path + # If a .py file exists, use that + module_path = folder.joinpath(*parent_modules, module_name + ".py") + if module_path.is_file(): + return module_path - # Otherwise there must be a file - return module_path.with_name(self.app_main_module + ".py") + raise FileNotFoundError( + f"There is no {self.app_main_module!r} module in {self.project_directory!r}" + ) @property - def project_files_glob_patterns(self) -> Iterable[str]: + def project_files_glob_patterns(self) -> t.Iterable[str]: """ Each project includes a list of files which are considered to be part of the project. These files are specified using glob patterns. This @@ -219,7 +222,7 @@ class RioProjectConfig: ) @project_files_glob_patterns.setter - def project_files_glob_patterns(self, value: Iterable[str]) -> None: + def project_files_glob_patterns(self, value: t.Iterable[str]) -> None: self.set_key("app", "project-files", list(value)) def file_is_path_of_project(self, file_path: Path) -> bool: @@ -326,7 +329,7 @@ class RioProjectConfig: path: Path, *, main_module: str, - project_type: Literal["app", "website"], + project_type: t.Literal["app", "website"], ) -> RioProjectConfig: """ Write a new `rio.toml` file at the given file path. This file will @@ -527,7 +530,7 @@ def find_or_guess_project_directory() -> Path: return Path.cwd() -def iter_directories_upward(path: Path | None = None) -> Iterable[Path]: +def iter_directories_upward(path: Path | None = None) -> t.Iterable[Path]: if path is None: path = Path.cwd().absolute() diff --git a/rio/routing.py b/rio/routing.py index 469d2ca7..84a8942a 100644 --- a/rio/routing.py +++ b/rio/routing.py @@ -3,14 +3,14 @@ from __future__ import annotations import logging import typing as t import warnings -from collections.abc import Callable, Iterable, Sequence from dataclasses import KW_ONLY, dataclass, field from pathlib import Path import introspection +import path_imports from introspection import convert_case -import rio +import rio.components.error_placeholder import rio.docs from . import deprecations, utils @@ -165,11 +165,11 @@ class ComponentPage: name: str url_segment: str - build: Callable[[], rio.Component] + build: t.Callable[[], rio.Component] _: KW_ONLY icon: str = DEFAULT_ICON - children: Sequence[ComponentPage | Redirect] = field(default_factory=list) - guard: Callable[[rio.GuardEvent], None | rio.URL | str] | None = None + children: t.Sequence[ComponentPage | Redirect] = field(default_factory=list) + guard: t.Callable[[rio.GuardEvent], None | rio.URL | str] | None = None meta_tags: dict[str, str] = field(default_factory=dict) # This is used to allow users to order pages when using the `rio.page` @@ -182,11 +182,11 @@ class ComponentPage: # URL fragment is lowercase. if self.url_segment != self.url_segment.lower(): raise ValueError( - f"Page URLs have to be lowercase, but `{self.url_segment}` is not" + f"Page URL segments should be lowercase, but `{self.url_segment}` is not" ) if "/" in self.url_segment: - raise ValueError(f"URL segments may not contain slashes") + raise ValueError(f"Page URL segments cannot contain slashes") # Allow using the old `page_url` parameter instead of the new `url_segment` @@ -221,7 +221,7 @@ def Page(*args, **kwargs): def _get_active_page_instances( - available_pages: Iterable[rio.ComponentPage | rio.Redirect], + available_pages: t.Iterable[rio.ComponentPage | rio.Redirect], remaining_segments: tuple[str, ...], ) -> list[rio.ComponentPage | rio.Redirect]: """ @@ -278,7 +278,7 @@ class GuardEvent: # This is an `Sequence` rather than `list`, because the same event instance # is reused for multiple event handlers. This allows to assign a tuple, thus # preventing modifications. - active_pages: Sequence[ComponentPage | Redirect] + active_pages: t.Sequence[ComponentPage | Redirect] def check_page_guards( @@ -388,7 +388,7 @@ def check_page_guards( target_url_absolute = redirect -BuildFunction = Callable[[], "rio.Component"] +BuildFunction = t.Callable[[], "rio.Component"] C = t.TypeVar("C", bound=BuildFunction) @@ -400,7 +400,7 @@ def page( url_segment: str | None = None, name: str | None = None, icon: str = DEFAULT_ICON, - guard: Callable[[GuardEvent], None | rio.URL | str] | None = None, + guard: t.Callable[[GuardEvent], None | rio.URL | str] | None = None, meta_tags: dict[str, str] | None = None, order: int | None = None, ): @@ -421,8 +421,9 @@ def page( style="heading1", ) - For additional details, please refer to the how-to guide: - `https://rio.dev/docs/howto/multiple-pages`. + For additional details, please refer to the how-to guide [Multiple + Pages](https://rio.dev/docs/howto/multiple-pages). + ## Parameters @@ -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. @@ -534,7 +530,7 @@ def _auto_detect_pages_iter( directory: Path, *, package: str | None = None, -) -> Iterable[rio.ComponentPage]: +) -> t.Iterable[rio.ComponentPage]: try: contents = list(directory.iterdir()) except FileNotFoundError: @@ -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( @@ -564,7 +562,7 @@ def _page_from_python_file( page = _error_page_from_file_name( file_path, error_summary=f"Failed to import '{file_path}'", - error_details=f"{type(error)}: {error}", + error_details=f"{type(error).__name__}: {error}", ) else: # Search the module for the callable decorated with `@rio.page` diff --git a/rio/serialization.py b/rio/serialization.py index 6420c440..ff624a18 100644 --- a/rio/serialization.py +++ b/rio/serialization.py @@ -6,8 +6,7 @@ import functools import inspect import json import types -import typing -from typing import * # type: ignore +import typing as t import introspection.types import uniserde @@ -23,11 +22,11 @@ from .self_serializing import SelfSerializing __all__ = ["serialize_json", "serialize_and_host_component"] -T = TypeVar("T") -Serializer = Callable[["session.Session", T], Jsonable] +T = t.TypeVar("T") +Serializer = t.Callable[["session.Session", T], Jsonable] -FILL_LIKES = {*get_args(fills._FillLike), None, type(None)} +FILL_LIKES = {*t.get_args(fills._FillLike), None, type(None)} def _float_or_zero(obj: object) -> float: @@ -171,8 +170,8 @@ def serialize_and_host_component(component: rio.Component) -> JsonDoc: @functools.lru_cache(maxsize=None) def get_attribute_serializers( - cls: Type[rio.Component], -) -> Mapping[str, Serializer]: + cls: t.Type[rio.Component], +) -> t.Mapping[str, Serializer]: """ Returns a dictionary of attribute names to their types that should be serialized for the given component class. @@ -223,13 +222,15 @@ def _serialize_child_component( def _serialize_sequence( - sess: session.Session, sequence: Sequence[T], item_serializer: Serializer[T] + sess: session.Session, + sequence: t.Sequence[T], + item_serializer: Serializer[T], ) -> Jsonable: return [item_serializer(sess, item) for item in sequence] def _serialize_enum( - sess: session.Session, value: object, as_type: Type[enum.Enum] + sess: session.Session, value: object, as_type: t.Type[enum.Enum] ) -> Jsonable: return uniserde.as_json(value, as_type=as_type) @@ -270,8 +271,8 @@ def _get_serializer_for_annotation( if annotation in (int, float, str, bool, None): return _serialize_basic_json_value - origin = get_origin(annotation) - args = get_args(annotation) + origin = t.get_origin(annotation) + args = t.get_args(annotation) # Python 3.10 crashes if you try `issubclass(list[str], SelfSerializing)`, # so we must make absolutely sure the annotation isn't a generic type @@ -289,7 +290,7 @@ def _get_serializer_for_annotation( return functools.partial(_serialize_enum, as_type=annotation) # Sequences of serializable values - if origin in (list, typing.Sequence, collections.abc.Sequence): + if origin in (list, t.Sequence, collections.abc.Sequence): item_serializer = _get_serializer_for_annotation(args[0]) if item_serializer is None: return None @@ -299,10 +300,10 @@ def _get_serializer_for_annotation( ) # Literal - if origin is Literal: + if origin is t.Literal: return _serialize_basic_json_value - if origin in (Union, types.UnionType): + if origin in (t.Union, types.UnionType): # ColorSet if set(args) == color._color_set_args: return _serialize_colorset diff --git a/rio/session.py b/rio/session.py index dd7ef10e..3d4f79a9 100644 --- a/rio/session.py +++ b/rio/session.py @@ -12,11 +12,9 @@ import shutil import string import time import traceback -import typing +import typing as t import weakref -from collections.abc import Callable, Coroutine, Iterable from datetime import tzinfo -from typing import * # type: ignore import starlette.datastructures import unicall @@ -50,7 +48,7 @@ from .transports import AbstractTransport, TransportInterrupted __all__ = ["Session"] -T = typing.TypeVar("T") +T = t.TypeVar("T") class WontSerialize(Exception): @@ -112,14 +110,14 @@ class Session(unicall.Unicall): # Type hints so the documentation generator knows which fields exist timezone: tzinfo - preferred_languages: Sequence[str] + preferred_languages: t.Sequence[str] window_width: float window_height: float theme: rio.Theme - http_headers: Mapping[str, str] + http_headers: t.Mapping[str, str] def __init__( self, @@ -129,7 +127,7 @@ class Session(unicall.Unicall): client_port: int, http_headers: starlette.datastructures.Headers, timezone: tzinfo, - preferred_languages: Iterable[str], + preferred_languages: t.Iterable[str], month_names_long: tuple[ str, str, str, str, str, str, str, str, str, str, str, str ], @@ -220,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[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[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 @@ -268,7 +268,7 @@ class Session(unicall.Unicall): # A dict of {build_function: error_message}. This is cleared at # the start of every refresh, and tracks which build functions failed. # Used for unit testing. - self._crashed_build_functions = dict[Callable, str]() + self._crashed_build_functions = dict[t.Callable, str]() # Weak dictionaries to hold additional information about components. # These are split in two to avoid the dictionaries keeping the @@ -317,7 +317,7 @@ class Session(unicall.Unicall): # Information about the visitor self._client_ip: str = client_ip self._client_port: int = client_port - self.http_headers: Mapping[str, str] = http_headers + self.http_headers: t.Mapping[str, str] = http_headers # Instantiate the root component global_state.currently_building_component = None @@ -521,7 +521,9 @@ class Session(unicall.Unicall): window = await self._get_webview_window() if is_maximized: - window.maximize() + # Pyright has trouble with the `maximize` method, though it most + # definitely exists. + window.maximize() # type: ignore else: raise NotImplementedError # FIXME else: @@ -563,7 +565,7 @@ window.resizeTo(screen.availWidth, screen.availHeight); """ return self._transport is not None - def attach(self, value: Any) -> None: + def attach(self, value: t.Any) -> None: """ Attaches the given value to the `Session`. It can be retrieved later using `session[...]`. @@ -685,12 +687,12 @@ window.resizeTo(screen.availWidth, screen.availHeight); await asyncio.sleep(0.2) - @overload + @t.overload async def _call_event_handler( self, handler: utils.EventHandler[[]], *, refresh: bool ) -> None: ... - @overload + @t.overload async def _call_event_handler( self, handler: utils.EventHandler[[T]], @@ -732,13 +734,13 @@ window.resizeTo(screen.availWidth, screen.availHeight); if refresh: await self._refresh() - @overload + @t.overload def _call_event_handler_sync( self, handler: utils.EventHandler[[]], ) -> None: ... - @overload + @t.overload def _call_event_handler_sync( self, handler: utils.EventHandler[[T]], @@ -816,7 +818,7 @@ window.resizeTo(screen.availWidth, screen.availHeight); def create_task( self, - coro: Coroutine[Any, None, T], + coro: t.Coroutine[t.Any, None, T], *, name: str | None = None, ) -> asyncio.Task[T]: @@ -931,7 +933,9 @@ let element = { }; element.scrollTo({{ top: 0, behavior: "smooth" }}); -window.history.{method}(null, "", {json.dumps(active_page_url.path)}) +// Sometimes the frontend and backend disagree about the domain or protocol, +// which can cause issues. So to be safe, we only send a relative URL. +window.history.{method}(null, "", {json.dumps(str(active_page_url.relative()))}) """, ) @@ -986,7 +990,9 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) def _refresh_sync( self, ) -> tuple[ - set[rio.Component], Iterable[rio.Component], Iterable[rio.Component] + set[rio.Component], + t.Iterable[rio.Component], + t.Iterable[rio.Component], ]: """ See `refresh` for details on what this function does. @@ -1405,7 +1411,7 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) # List / Collection elif isinstance(attr_value, list): - attr_value = cast(list[object], attr_value) + attr_value = t.cast(list[object], attr_value) for ii, item in enumerate(attr_value): if isinstance(item, rio.Component): @@ -1530,8 +1536,8 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) if not isinstance(old, list): return False - old = cast(list[object], old) - new = cast(list[object], new) + old = t.cast(list[object], old) + new = t.cast(list[object], new) if len(old) != len(new): return False @@ -1580,7 +1586,7 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) self, old_build: rio.Component, new_build: rio.Component, - ) -> Iterable[tuple[rio.Component, rio.Component]]: + ) -> t.Iterable[tuple[rio.Component, rio.Component]]: """ Given two component trees, find pairs of components which can be reconciled, i.e. which represent the "same" component. When exactly @@ -1633,12 +1639,14 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) 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] if isinstance(attr, list): - attr = cast(list[object], attr) + attr = t.cast(list[object], attr) return [ item for item in attr if isinstance(item, rio.Component) @@ -1676,7 +1684,8 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) ) 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 @@ -1851,7 +1860,7 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) section_name, _, key = key.rpartition(":") if section_name: - section = cast( + section = t.cast( JsonDoc, settings_json.setdefault("section:" + section_name, {}), ) @@ -1912,15 +1921,15 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) async def _save_settings_now_in_window( self, - settings_to_save: Iterable[ - tuple[user_settings_module.UserSettings, Iterable[str]] + settings_to_save: t.Iterable[ + tuple[user_settings_module.UserSettings, t.Iterable[str]] ], ) -> None: import aiofiles for settings, dirty_attributes in settings_to_save: if settings.section_name: - section = cast( + section = t.cast( JsonDoc, self._settings_json.setdefault( "section:" + settings.section_name, {} @@ -1953,11 +1962,11 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) async def _save_settings_now_in_browser( self, - settings_to_save: Iterable[ - tuple[user_settings_module.UserSettings, Iterable[str]] + settings_to_save: t.Iterable[ + tuple[user_settings_module.UserSettings, t.Iterable[str]] ], ) -> None: - delta_settings: dict[str, Any] = {} + delta_settings: dict[str, t.Any] = {} for settings, dirty_attributes in settings_to_save: prefix = ( @@ -2025,20 +2034,20 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) else: await self._remote_set_title(title) - @overload + @t.overload async def pick_file( self, *, - file_types: Iterable[str] | None = None, - multiple: Literal[False] = False, + file_types: t.Iterable[str] | None = None, + multiple: t.Literal[False] = False, ) -> utils.FileInfo: ... - @overload + @t.overload async def pick_file( self, *, - file_types: Iterable[str] | None = None, - multiple: Literal[True], + file_types: t.Iterable[str] | None = None, + multiple: t.Literal[True], ) -> list[utils.FileInfo]: ... @deprecations.function_kwarg_renamed( @@ -2049,7 +2058,7 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) async def pick_file( self, *, - file_types: Iterable[str] | None = None, + file_types: t.Iterable[str] | None = None, multiple: bool = False, ) -> utils.FileInfo | list[utils.FileInfo]: """ @@ -2096,20 +2105,20 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)}) multiple=multiple, ) - @overload + @t.overload async def file_chooser( self, *, - file_types: Iterable[str] | None = None, - multiple: Literal[False] = False, + file_types: t.Iterable[str] | None = None, + multiple: t.Literal[False] = False, ) -> utils.FileInfo: ... - @overload + @t.overload async def file_chooser( self, *, - file_types: Iterable[str] | None = None, - multiple: Literal[True], + file_types: t.Iterable[str] | None = None, + multiple: t.Literal[True], ) -> list[utils.FileInfo]: ... @deprecations.function_kwarg_renamed( @@ -2296,17 +2305,13 @@ a.remove(); "backdrop-filter": "none", } - async def _apply_theme(self, thm: theme.Theme) -> None: + def _calculate_theme_css_values(self, thm: theme.Theme) -> dict[str, str]: """ - Updates the client's theme to match the given one. + Determines and returns all CSS values that should be applied to a HTML + element to achieve the given theme. """ - # Store the theme - self.theme = thm - - # Build the set of all CSS variables that must be set - # Miscellaneous - variables: dict[str, str] = { + result: dict[str, str] = { "--rio-global-font": thm.font._serialize(self), "--rio-global-monospace-font": thm.monospace_font._serialize(self), "--rio-global-corner-radius-small": f"{thm.corner_radius_small}rem", @@ -2332,16 +2337,16 @@ a.remove(); palette = getattr(thm, f"{palette_name}_palette") assert isinstance(palette, theme.Palette), palette - variables[f"--rio-global-{palette_name}-bg"] = ( + result[f"--rio-global-{palette_name}-bg"] = ( f"#{palette.background.hex}" ) - variables[f"--rio-global-{palette_name}-bg-variant"] = ( + result[f"--rio-global-{palette_name}-bg-variant"] = ( f"#{palette.background_variant.hex}" ) - variables[f"--rio-global-{palette_name}-bg-active"] = ( + result[f"--rio-global-{palette_name}-bg-active"] = ( f"#{palette.background_active.hex}" ) - variables[f"--rio-global-{palette_name}-fg"] = ( + result[f"--rio-global-{palette_name}-fg"] = ( f"#{palette.foreground.hex}" ) @@ -2358,21 +2363,30 @@ a.remove(); assert isinstance(style, rio.TextStyle), style css_prefix = f"--rio-global-{style_name}" - variables[f"{css_prefix}-font-name"] = ( + result[f"{css_prefix}-font-name"] = ( "inherit" if style.font is None else style.font._serialize(self) ) - variables[f"{css_prefix}-font-size"] = f"{style.font_size}rem" - variables[f"{css_prefix}-italic"] = ( + result[f"{css_prefix}-font-size"] = f"{style.font_size}rem" + result[f"{css_prefix}-italic"] = ( "italic" if style.italic else "normal" ) - variables[f"{css_prefix}-font-weight"] = style.font_weight - variables[f"{css_prefix}-underlined"] = ( - "underline" if style.underlined else "unset" - ) - variables[f"{css_prefix}-all-caps"] = ( + result[f"{css_prefix}-font-weight"] = style.font_weight + result[f"{css_prefix}-all-caps"] = ( "uppercase" if style.all_caps else "unset" ) + text_decorations: list[str] = [] + + if style.underlined: + text_decorations.append("underline") + + if style.strikethrough: + text_decorations.append("line-through") + + result[f"{css_prefix}-text-decoration"] = ( + " ".join(text_decorations) if text_decorations else "none" + ) + # CSS variables for the fill assert ( style.fill is not None @@ -2382,7 +2396,17 @@ a.remove(); ) for var, value in fill_variables.items(): - variables[f"{css_prefix}-{var}"] = value + result[f"{css_prefix}-{var}"] = value + + # Done + return result + + async def _apply_theme(self, thm: theme.Theme) -> None: + # Store the theme in the session + self.theme = thm + + # Get all CSS values to apply + variables = self._calculate_theme_css_values(thm) # Update the variables client-side await self._remote_apply_theme( @@ -2396,7 +2420,7 @@ a.remove(); async def show_custom_dialog( self, - build: Callable[[], rio.Component], + build: t.Callable[[], rio.Component], *, modal: bool = True, user_closeable: bool = True, @@ -2605,7 +2629,7 @@ a.remove(); *, title: str, content: rio.Component | str, - options: Mapping[str, T] | Sequence[T], + options: t.Mapping[str, T] | t.Sequence[T], # default_option: T | None = None, owning_component: rio.Component | None = None, ) -> T: @@ -2682,7 +2706,7 @@ a.remove(); """ # Standardize the options - if isinstance(options, Sequence): + if isinstance(options, t.Sequence): options = {str(value): value for value in options} # Prepare a build function @@ -2763,7 +2787,7 @@ a.remove(); # Wait for the user to select an option result = await dialog.wait_for_close() - result = typing.cast(T, result) + result = t.cast(T, result) # Done! return result @@ -3001,7 +3025,7 @@ a.remove(); async def _remote_apply_theme( self, css_variables: dict[str, str], - theme_variant: Literal["light", "dark"], + theme_variant: t.Literal["light", "dark"], ) -> None: raise NotImplementedError # pragma: no cover @@ -3030,7 +3054,7 @@ a.remove(); self, # Maps component ids to serialized components. The components may be partial, # i.e. any property may be missing. - delta_states: dict[int, Any], + delta_states: dict[int, t.Any], # Tells the client to make the given component the new root component. root_component_id: int | None, ) -> None: @@ -3044,7 +3068,7 @@ a.remove(); parameter_format="dict", await_response=False, ) - async def _evaluate_javascript(self, java_script_source: str) -> Any: + async def _evaluate_javascript(self, java_script_source: str) -> t.Any: """ Evaluate the given JavaScript code on the client. @@ -3065,7 +3089,7 @@ a.remove(); async def _evaluate_javascript_and_get_result( self, java_script_source: str, - ) -> Any: + ) -> t.Any: """ Evaluate the given JavaScript code in the client and return the result. @@ -3099,7 +3123,9 @@ a.remove(); raise NotImplementedError # pragma: no cover @unicall.remote(name="setUserSettings", await_response=False) - async def _set_user_settings(self, delta_settings: dict[str, Any]) -> None: + async def _set_user_settings( + self, delta_settings: dict[str, t.Any] + ) -> None: """ Persistently store the given key-value pairs at the user. The values have to be jsonable. @@ -3144,7 +3170,7 @@ a.remove(); async def _component_state_update( self, component_id: int, - delta_state: Any, + delta_state: t.Any, ) -> None: # Get the component component = self._try_get_component_for_message(component_id) @@ -3169,7 +3195,7 @@ a.remove(); async def _component_message( self, component_id: int, - payload: Any, + payload: t.Any, ) -> None: # Get the component component = self._try_get_component_for_message(component_id) @@ -3400,7 +3426,7 @@ a.remove(); ) async def _remote_get_component_layouts( self, component_ids: list[int] - ) -> list[dict[str, Any] | None]: + ) -> list[dict[str, t.Any] | None]: raise NotImplementedError() # pragma: no cover async def _get_unittest_client_layout_info( @@ -3454,7 +3480,7 @@ a.remove(); ) async def __get_unittest_client_layout_info( self, - ) -> Any: + ) -> t.Any: raise NotImplementedError() # pragma: no cover @unicall.remote( diff --git a/rio/session_attachments.py b/rio/session_attachments.py index 2c17afcd..5f6c2d23 100644 --- a/rio/session_attachments.py +++ b/rio/session_attachments.py @@ -1,14 +1,13 @@ from __future__ import annotations -from collections.abc import Iterator -from typing import Any, TypeVar, cast +import typing as t from . import dataclass, session, user_settings_module __all__ = ["SessionAttachments"] -T = TypeVar("T") +T = t.TypeVar("T") class SessionAttachments: @@ -16,7 +15,7 @@ class SessionAttachments: self._session = sess self._attachments: dict[type, object] = {} - def __iter__(self) -> Iterator[object]: + def __iter__(self) -> t.Iterator[object]: return iter(self._attachments.values()) def __contains__(self, typ: type) -> bool: @@ -40,7 +39,7 @@ class SessionAttachments: # If a UserSettings object is already attached, unlink it from the # session try: - old_value = cast( + old_value = t.cast( user_settings_module.UserSettings, self._attachments[cls] ) except KeyError: @@ -66,7 +65,7 @@ class SessionAttachments: self._session._save_settings_soon() - def add(self, value: Any) -> None: + def add(self, value: t.Any) -> None: self._add(value, synchronize=True) def remove(self, typ: type) -> None: diff --git a/rio/snippets/__init__.py b/rio/snippets/__init__.py index 453bc276..d266528c 100644 --- a/rio/snippets/__init__.py +++ b/rio/snippets/__init__.py @@ -4,11 +4,12 @@ import copy import functools import json import re +import typing as t import urllib.parse from dataclasses import dataclass from pathlib import Path -from typing import * # type: ignore +import typing_extensions as te import uniserde from .. import utils @@ -30,7 +31,7 @@ DEFAULT_META_DICT = { # # THE ORDER MATTERS. `revel` will display the options in the same order as they # appear in the literal -AvailableTemplatesLiteral: TypeAlias = Literal[ +AvailableTemplatesLiteral: te.TypeAlias = t.Literal[ # Keep the empty template first "Empty", # Sort the remainder alphabetically @@ -52,7 +53,7 @@ class _TemplateConfig(uniserde.Serde): """ # Allows displaying the templates in a structured way - level: Literal["beginner", "intermediate", "advanced"] + level: t.Literal["beginner", "intermediate", "advanced"] # Very short, one or two line description of the template summary: str @@ -221,7 +222,7 @@ def get_snippet_groups() -> set[str]: @functools.lru_cache(maxsize=None) -def all_snippets_in_group(group: str) -> Iterable[Snippet]: +def all_snippets_in_group(group: str) -> t.Iterable[Snippet]: """ Returns all snippets in the given group. @@ -266,7 +267,7 @@ class ProjectTemplate: name: AvailableTemplatesLiteral # How difficult the project is - level: Literal["beginner", "intermediate", "advanced"] + level: t.Literal["beginner", "intermediate", "advanced"] # A short description of the project template summary: str @@ -304,13 +305,13 @@ class ProjectTemplate: @staticmethod def _from_snippet_group( snippet_name: str, - snippets: Iterable[Snippet], + snippets: t.Iterable[Snippet], ) -> ProjectTemplate: assert ( - snippet_name in get_args(AvailableTemplatesLiteral) + snippet_name in t.get_args(AvailableTemplatesLiteral) or snippet_name == "Empty" ), snippet_name - name = cast(AvailableTemplatesLiteral, snippet_name) + name = t.cast(AvailableTemplatesLiteral, snippet_name) # Find all snippets needed for the project template readme_snippet: Snippet | None = None @@ -337,7 +338,7 @@ class ProjectTemplate: # And the metadata if snippet.name == "meta.json": - meta_dict: dict[str, Any] = copy.deepcopy(DEFAULT_META_DICT) + meta_dict: dict[str, t.Any] = copy.deepcopy(DEFAULT_META_DICT) meta_dict.update(json.loads(snippet.stripped_code())) metadata = _TemplateConfig.from_json(meta_dict) continue @@ -431,7 +432,7 @@ class ProjectTemplate: @functools.lru_cache(maxsize=None) -def get_project_templates(include_empty: bool) -> Iterable[ProjectTemplate]: +def get_project_templates(include_empty: bool) -> t.Iterable[ProjectTemplate]: """ Iterates over all available project templates. @@ -524,7 +525,7 @@ class HowtoGuide: @functools.lru_cache(maxsize=None) -def get_howto_guides() -> Iterable[HowtoGuide]: +def get_howto_guides() -> t.Iterable[HowtoGuide]: """ Iterates over all available how-to guides. """ diff --git a/rio/snippets/snippet-files/howtos/howto-get-value-from-child-component.md b/rio/snippets/snippet-files/howtos/howto-get-value-from-child-component.md index c7e8a256..e71a94b2 100644 --- a/rio/snippets/snippet-files/howtos/howto-get-value-from-child-component.md +++ b/rio/snippets/snippet-files/howtos/howto-get-value-from-child-component.md @@ -164,14 +164,14 @@ class MyComponent(rio.Component): Common use cases for attachments are: -- **Database connections**: Attach a database connection to the session, so that +- **Database connections**: Attach a database connection to the session, so that all components can access it. -- **User authentication**: When the user logs in, attach the logged in user's +- **User authentication**: When the user logs in, attach the logged in user's name and id to the session. This way, every component always knows which user it's talking to. -- **Per-user Settings**: Any classes which inherit from `rio.UserSettings` will +- **Per-user Settings**: Any classes which inherit from `rio.UserSettings` will be stored persistentnly on the user's device. This means they'll still be present when the user visits your app again later. diff --git a/rio/snippets/snippet-files/howtos/multiple-pages.md b/rio/snippets/snippet-files/howtos/multiple-pages.md index 4211cab2..f8d18ae2 100644 --- a/rio/snippets/snippet-files/howtos/multiple-pages.md +++ b/rio/snippets/snippet-files/howtos/multiple-pages.md @@ -144,18 +144,18 @@ For simpler cases, you can use the `rio.Link` component to create a link to a page: ```python -rio.Link("Home", url="/") +rio.Link("Home", "/") ``` ### Combining Links and Buttons -OYou can also use the `rio.Link` component to wrap other components, such as +You can also use the `rio.Link` component to wrap other components, such as buttons, to create interactive navigation elements: ```python rio.Link( rio.Button("Home"), - url="/", + target_url="/", ) ``` @@ -166,7 +166,7 @@ of a link. Users can navigate directly to a specific page by entering the corresponding URL in their browser. For example, to visit the About Us page, they would go to -`https://MyDomain.com/about-page`. +`https://my-domain.com/about-page`. ## Nested Pages @@ -205,9 +205,13 @@ class AboutPage(rio.Component): """ def build(self) -> rio.Component: - return rio.Markdown("This is the main page of the app section.", - "Explore more about our features and functionalities here." - ) + return rio.Column( + rio.Markdown( + "This is the main page of the app section.\n\n" + "Explore more about our features and functionalities here." + ), + rio.PageView(), + ) ``` ### Example: Creating a Nested About Us Page @@ -229,7 +233,7 @@ class AboutPage(rio.Component): ### Accessing Nested Pages To navigate to these nested pages, users can use URLs like: -`https://MyDomain.com/app/about-page` +`https://my-domain.com/app/about-page` This URL structure reflects the nested hierarchy, making it easier for users to understand the organization of your application. @@ -243,7 +247,7 @@ the necessary permissions to access a page. ### How Guards Work A guard is a function that takes a `GuardEvent` object as an argument. Based on -the logic within the guard, it returns a str (the url_segment) to redirect +the logic within the guard, it returns a str (the `url_segment`) to redirect unauthorized users or `None` to grant access. In more details see our @@ -289,10 +293,6 @@ To protect a page with a guard, simply add the `guard` parameter to the guard=guard, # Apply the guard function ) class AppPage(rio.Component): - """ - A sample login page. - """ - def build(self) -> rio.Component: return rio.Markdown("This page is protected. Only authorized users can view this content.") ``` @@ -313,8 +313,8 @@ guards for access control in a Rio application. To dive deeper into these topics, refer to the following resources: - [Authentication Example](https://rio.dev/examples/authentication) -- [API Documentation for GuardEvent](https://rio.dev/docs/api/guardevent) -- [API Documentation for @rio.page decorator](https://rio.dev/docs/api/page) +- [API Documentation for `GuardEvent`](https://rio.dev/docs/api/guardevent) +- [API Documentation for `@rio.page` decorator](https://rio.dev/docs/api/page) By following these practices, you can build a well-structured, secure, and user-friendly application using the Rio framework. diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_message.py b/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_message.py index f63123ed..874b8d1b 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_message.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_message.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio # diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_suggestion_card.py b/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_suggestion_card.py index 5be49cf2..d2f09451 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_suggestion_card.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/components/chat_suggestion_card.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py b/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py index f05fe422..0abe281a 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/components/empty_chat_placeholder.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from .. import components as comps diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/components/generating_response_placeholder.py b/rio/snippets/snippet-files/project-template-AI Chatbot/components/generating_response_placeholder.py index f2f6f7e7..177109eb 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/components/generating_response_placeholder.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/components/generating_response_placeholder.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/conversation.py b/rio/snippets/snippet-files/project-template-AI Chatbot/conversation.py index be5bcc70..378413ca 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/conversation.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/conversation.py @@ -1,6 +1,6 @@ +import typing as t from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import * # type: ignore import openai # type: ignore (hidden from user) @@ -12,7 +12,7 @@ class ChatMessage: chat message. """ - role: Literal["user", "assistant"] + role: t.Literal["user", "assistant"] timestamp: datetime text: str @@ -25,7 +25,7 @@ class Conversation: """ # The entire message history - messages: List[ChatMessage] = field(default_factory=list) + messages: list[ChatMessage] = field(default_factory=list) async def respond(self, client: openai.AsyncOpenAI) -> ChatMessage: """ @@ -42,7 +42,7 @@ class Conversation: raise ValueError("The most recent message must be by the user") # Convert all messages to the format needed by the API - api_messages: list[Any] = [ + api_messages: list[t.Any] = [ { "role": "system", "content": "You are a helpful assistant. Format your response in markdown, for example by using **bold**, and _italic_ amongst others.", diff --git a/rio/snippets/snippet-files/project-template-AI Chatbot/pages/chat_page.py b/rio/snippets/snippet-files/project-template-AI Chatbot/pages/chat_page.py index f14b7af1..56d0203c 100644 --- a/rio/snippets/snippet-files/project-template-AI Chatbot/pages/chat_page.py +++ b/rio/snippets/snippet-files/project-template-AI Chatbot/pages/chat_page.py @@ -4,7 +4,6 @@ from dataclasses import field # from datetime import datetime, timezone -from typing import * # type: ignore import openai # type: ignore (hidden from user) diff --git a/rio/snippets/snippet-files/project-template-Authentication/components/footer.py b/rio/snippets/snippet-files/project-template-Authentication/components/footer.py index c8c05f9b..016be3a4 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/components/footer.py +++ b/rio/snippets/snippet-files/project-template-Authentication/components/footer.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Authentication/components/navbar.py b/rio/snippets/snippet-files/project-template-Authentication/components/navbar.py index 55c4129c..ebfdb1b2 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/components/navbar.py +++ b/rio/snippets/snippet-files/project-template-Authentication/components/navbar.py @@ -2,7 +2,6 @@ from __future__ import annotations # from datetime import datetime, timezone -from typing import * # type: ignore import rio @@ -56,9 +55,10 @@ class Navbar(rio.Component): # Which page is currently active? This will be used to highlight the # correct navigation button. # - # `active_page_instances` contains the same `rio.Page` instances - # that you've passed the app during creation. Since multiple pages - # can be active at a time (e.g. /foo/bar/baz), this is a list. + # `active_page_instances` contains `rio.ComponentPage` instances + # that that are created during app creation. Since multiple pages + # can be active at a time (e.g. /foo/bar/baz), this is a list rather + # than just a single page. active_page = self.session.active_page_instances[1] active_page_url_segment = active_page.url_segment except IndexError: diff --git a/rio/snippets/snippet-files/project-template-Authentication/components/news_article.py b/rio/snippets/snippet-files/project-template-Authentication/components/news_article.py index 077b6dd9..eb13d7f4 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/components/news_article.py +++ b/rio/snippets/snippet-files/project-template-Authentication/components/news_article.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Authentication/components/root_component.py b/rio/snippets/snippet-files/project-template-Authentication/components/root_component.py index e7996f5a..df40a859 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/components/root_component.py +++ b/rio/snippets/snippet-files/project-template-Authentication/components/root_component.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from .. import components as comps diff --git a/rio/snippets/snippet-files/project-template-Authentication/components/testimonial.py b/rio/snippets/snippet-files/project-template-Authentication/components/testimonial.py index 08041375..ccb0452b 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/components/testimonial.py +++ b/rio/snippets/snippet-files/project-template-Authentication/components/testimonial.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page.py b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page.py index e7d936ea..ff721e61 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page.py +++ b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio # diff --git a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/about_page.py b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/about_page.py index 619f9ac4..56007f64 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/about_page.py +++ b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/about_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/home_page.py b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/home_page.py index 9d934a7d..65c0a2c3 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/home_page.py +++ b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/home_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from ... import components as comps diff --git a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/news_page.py b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/news_page.py index 84a7645e..3f5851f5 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/news_page.py +++ b/rio/snippets/snippet-files/project-template-Authentication/pages/app_page/news_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from ... import components as comps diff --git a/rio/snippets/snippet-files/project-template-Authentication/pages/login_page.py b/rio/snippets/snippet-files/project-template-Authentication/pages/login_page.py index 87fd4bd8..176aff72 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/pages/login_page.py +++ b/rio/snippets/snippet-files/project-template-Authentication/pages/login_page.py @@ -1,8 +1,6 @@ from __future__ import annotations # -from typing import * # type: ignore - import rio from .. import components as comps diff --git a/rio/snippets/snippet-files/project-template-Authentication/root_init.py b/rio/snippets/snippet-files/project-template-Authentication/root_init.py index 600a530b..1b80f999 100644 --- a/rio/snippets/snippet-files/project-template-Authentication/root_init.py +++ b/rio/snippets/snippet-files/project-template-Authentication/root_init.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from typing import * # type: ignore import rio diff --git a/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/balance_card.py b/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/balance_card.py index 4bdf54eb..f5817a32 100644 --- a/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/balance_card.py +++ b/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/balance_card.py @@ -1,5 +1,3 @@ -from typing import * # type:ignore - # import pandas as pd import plotly.express as px diff --git a/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_card.py b/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_card.py index c05fc2d0..daba75e1 100644 --- a/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_card.py +++ b/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_card.py @@ -1,5 +1,3 @@ -from typing import * # type:ignore - # import pandas as pd import plotly.express as px @@ -159,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, diff --git a/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_chart.py b/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_chart.py index 4cfcad06..688a916a 100644 --- a/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_chart.py +++ b/rio/snippets/snippet-files/project-template-Crypto Dashboard/components/crypto_chart.py @@ -1,5 +1,3 @@ -from typing import * # type:ignore - # import pandas as pd import plotly.express as px diff --git a/rio/snippets/snippet-files/project-template-Crypto Dashboard/data_models.py b/rio/snippets/snippet-files/project-template-Crypto Dashboard/data_models.py index 19d0cefd..6563e82b 100644 --- a/rio/snippets/snippet-files/project-template-Crypto Dashboard/data_models.py +++ b/rio/snippets/snippet-files/project-template-Crypto Dashboard/data_models.py @@ -1,5 +1,3 @@ -from typing import * # type:ignore - import pandas as pd # list of cryptocurrencies we want to display in our dashboard @@ -11,7 +9,7 @@ BAR_CHART: pd.DataFrame = pd.DataFrame([4, 5, 6, 5, 4, 6, 7], columns=["data"]) # example data for our crypto portfolio. Our portfolio consists of three coins: # bitcoin, litecoin, and ethereum. Each coin has a value, a ticker, a color, and a logo. -MY_COINS: dict[str, Tuple[float, str, str, str]] = { +MY_COINS: dict[str, tuple[float, str, str, str]] = { "bitcoin": ( 13.344546, "BTC", diff --git a/rio/snippets/snippet-files/project-template-Crypto Dashboard/pages/dashboard_page.py b/rio/snippets/snippet-files/project-template-Crypto Dashboard/pages/dashboard_page.py index ea2a5c13..99e62c4f 100644 --- a/rio/snippets/snippet-files/project-template-Crypto Dashboard/pages/dashboard_page.py +++ b/rio/snippets/snippet-files/project-template-Crypto Dashboard/pages/dashboard_page.py @@ -2,7 +2,6 @@ from dataclasses import field # from pathlib import Path -from typing import * # type: ignore import numpy as np import pandas as pd diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/components/footer.py b/rio/snippets/snippet-files/project-template-Multipage Website/components/footer.py index c8c05f9b..016be3a4 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/components/footer.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/components/footer.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/components/navbar.py b/rio/snippets/snippet-files/project-template-Multipage Website/components/navbar.py index ae576ee4..d822231f 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/components/navbar.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/components/navbar.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio @@ -29,9 +27,10 @@ class Navbar(rio.Component): # Which page is currently active? This will be used to highlight the # correct navigation button. # - # `active_page_instances` contains the same `rio.ComponentPage` - # instances that you've passed the app during creation. Since multiple - # pages can be active at a time (e.g. /foo/bar/baz), this is a list. + # `active_page_instances` contains `rio.ComponentPage` instances that + # that are created during app creation. Since multiple pages can be + # active at a time (e.g. /foo/bar/baz), this is a list rather than just + # a single page. active_page = self.session.active_page_instances[0] active_page_url_segment = active_page.url_segment diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/components/news_article.py b/rio/snippets/snippet-files/project-template-Multipage Website/components/news_article.py index 077b6dd9..eb13d7f4 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/components/news_article.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/components/news_article.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/components/root_component.py b/rio/snippets/snippet-files/project-template-Multipage Website/components/root_component.py index 68179cd5..a74f3d9c 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/components/root_component.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/components/root_component.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from .. import components as comps diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/components/testimonial.py b/rio/snippets/snippet-files/project-template-Multipage Website/components/testimonial.py index 7e0c951e..773f85e6 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/components/testimonial.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/components/testimonial.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/pages/about_page.py b/rio/snippets/snippet-files/project-template-Multipage Website/pages/about_page.py index 50bd1d8d..4100f3de 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/pages/about_page.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/pages/about_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/pages/home_page.py b/rio/snippets/snippet-files/project-template-Multipage Website/pages/home_page.py index a4ca7572..255cf068 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/pages/home_page.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/pages/home_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from .. import components as comps diff --git a/rio/snippets/snippet-files/project-template-Multipage Website/pages/news_page.py b/rio/snippets/snippet-files/project-template-Multipage Website/pages/news_page.py index 072f8bf5..f440a45f 100644 --- a/rio/snippets/snippet-files/project-template-Multipage Website/pages/news_page.py +++ b/rio/snippets/snippet-files/project-template-Multipage Website/pages/news_page.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio from .. import components as comps diff --git a/rio/snippets/snippet-files/project-template-Simple CRUD/data_models.py b/rio/snippets/snippet-files/project-template-Simple CRUD/data_models.py index fb4a0c85..3b60eefb 100644 --- a/rio/snippets/snippet-files/project-template-Simple CRUD/data_models.py +++ b/rio/snippets/snippet-files/project-template-Simple CRUD/data_models.py @@ -2,7 +2,6 @@ from __future__ import annotations import copy from dataclasses import dataclass -from typing import * # type:ignore @dataclass diff --git a/rio/snippets/snippet-files/project-template-Simple CRUD/pages/crud_page.py b/rio/snippets/snippet-files/project-template-Simple CRUD/pages/crud_page.py index b57a7d3e..1a937bb2 100644 --- a/rio/snippets/snippet-files/project-template-Simple CRUD/pages/crud_page.py +++ b/rio/snippets/snippet-files/project-template-Simple CRUD/pages/crud_page.py @@ -1,6 +1,6 @@ # import functools -from typing import * # type:ignore +import typing as t import rio @@ -38,7 +38,7 @@ class CrudPage(rio.Component): menu_items: list[data_models.MenuItem] = [] currently_selected_menu_item: data_models.MenuItem | None = None banner_text: str = "" - banner_style: Literal["success", "danger", "info"] = "success" + banner_style: t.Literal["success", "danger", "info"] = "success" @rio.event.on_populate def on_populate(self) -> None: @@ -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. diff --git a/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/components/field.py b/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/components/field.py index 59dfaaad..90a159fa 100644 --- a/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/components/field.py +++ b/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/components/field.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -14,7 +14,7 @@ class Field(rio.Component): """ # The current value of the field. - value: Literal["X", "O", ""] + value: t.Literal["X", "O", ""] # If this is `True`, the field will dim its content. This allows the game to # dim any fields that are not part of the winning combination when a player diff --git a/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/pages/tic_tac_toe_page.py b/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/pages/tic_tac_toe_page.py index 7fc4a027..dc129aa3 100644 --- a/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/pages/tic_tac_toe_page.py +++ b/rio/snippets/snippet-files/project-template-Tic-Tac-Toe/pages/tic_tac_toe_page.py @@ -2,7 +2,7 @@ from __future__ import annotations # import functools -from typing import * # type: ignore +import typing as t import rio @@ -31,13 +31,13 @@ class TicTacToePage(rio.Component): # # The first value is the top-left field. The remaining fields follow from # left to right, top to bottom. - fields: list[Literal["X", "O", ""]] = [""] * 9 + fields: list[t.Literal["X", "O", ""]] = [""] * 9 # The player who is currently on turn - player: Literal["X", "O"] = "X" + player: t.Literal["X", "O"] = "X" # The winner of the game, if any - winner: Literal["X", "O", "draw"] | None = None + winner: t.Literal["X", "O", "draw"] | None = None # If there is a winner, these are the indices of the fields which made them # win diff --git a/rio/snippets/snippet-files/project-template-Todo App/components/new_todo_item_input.py b/rio/snippets/snippet-files/project-template-Todo App/components/new_todo_item_input.py index 75aa872a..8eae094a 100644 --- a/rio/snippets/snippet-files/project-template-Todo App/components/new_todo_item_input.py +++ b/rio/snippets/snippet-files/project-template-Todo App/components/new_todo_item_input.py @@ -3,7 +3,6 @@ from __future__ import annotations # import datetime from dataclasses import field -from typing import * # type: ignore import rio diff --git a/rio/snippets/snippet-files/project-template-Todo App/components/todo_item_component.py b/rio/snippets/snippet-files/project-template-Todo App/components/todo_item_component.py index a85d8f39..59030a9f 100644 --- a/rio/snippets/snippet-files/project-template-Todo App/components/todo_item_component.py +++ b/rio/snippets/snippet-files/project-template-Todo App/components/todo_item_component.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import * # type: ignore - import rio # diff --git a/rio/snippets/snippet-files/project-template-Todo App/pages/todo_list_page.py b/rio/snippets/snippet-files/project-template-Todo App/pages/todo_list_page.py index 78e507e3..e4ddb2fc 100644 --- a/rio/snippets/snippet-files/project-template-Todo App/pages/todo_list_page.py +++ b/rio/snippets/snippet-files/project-template-Todo App/pages/todo_list_page.py @@ -2,7 +2,6 @@ from __future__ import annotations # import functools -from typing import * # type: ignore import rio diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-2/pages/tic_tac_toe_page.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-2/pages/tic_tac_toe_page.py index 3af465f1..97010ddf 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-2/pages/tic_tac_toe_page.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-2/pages/tic_tac_toe_page.py @@ -1,16 +1,20 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio # #
+@rio.page( + name="Tic Tac Toe", + url_segment="", +) class TicTacToePage(rio.Component): #
# - fields: list[Literal["X", "O", ""]] = [""] * 9 + fields: list[t.Literal["X", "O", ""]] = [""] * 9 # # diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/components/field.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/components/field.py index ce3bde58..268c25e8 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/components/field.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/components/field.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio # class Field(rio.Component): - value: Literal["X", "O", ""] + value: t.Literal["X", "O", ""] on_press: rio.EventHandler[[]] = None diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/pages/tic_tac_toe_page.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/pages/tic_tac_toe_page.py index 4ec569ef..26350990 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/pages/tic_tac_toe_page.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-3/pages/tic_tac_toe_page.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio @@ -8,10 +8,14 @@ from .. import components as comps # +@rio.page( + name="Tic Tac Toe", + url_segment="", +) class TicTacToePage(rio.Component): # The contents of all fields. Each field can contain an X, an O, or be # empty. The initial state is an empty board. - fields: list[Literal["X", "O", ""]] = [""] * 9 + fields: list[t.Literal["X", "O", ""]] = [""] * 9 def build(self) -> rio.Component: # Spawn components for the fields diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/components/field.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/components/field.py index ce3bde58..268c25e8 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/components/field.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/components/field.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio # class Field(rio.Component): - value: Literal["X", "O", ""] + value: t.Literal["X", "O", ""] on_press: rio.EventHandler[[]] = None diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/pages/tic_tac_toe_page.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/pages/tic_tac_toe_page.py index dc87479a..b973eedd 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/pages/tic_tac_toe_page.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-4/pages/tic_tac_toe_page.py @@ -1,7 +1,7 @@ from __future__ import annotations import functools -from typing import * # type: ignore +import typing as t import rio @@ -9,14 +9,18 @@ from .. import components as comps # +@rio.page( + name="Tic Tac Toe", + url_segment="", +) class TicTacToePage(rio.Component): # The contents of all fields. Each field can contain an X, an O, or be # empty. The initial state is an empty board. - fields: list[Literal["X", "O", ""]] = [""] * 9 + fields: list[t.Literal["X", "O", ""]] = [""] * 9 # # The player who is currently on turn - player: Literal["X", "O"] = "X" + player: t.Literal["X", "O"] = "X" # # diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/components/field.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/components/field.py index 2290d592..0c2c704c 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/components/field.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/components/field.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import * # type: ignore +import typing as t import rio # class Field(rio.Component): - value: Literal["X", "O", ""] + value: t.Literal["X", "O", ""] dim: bool on_press: rio.EventHandler[[]] = None diff --git a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/pages/tic_tac_toe_page.py b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/pages/tic_tac_toe_page.py index b6ad3376..daedd9e0 100644 --- a/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/pages/tic_tac_toe_page.py +++ b/rio/snippets/snippet-files/tutorial-tic-tac-toe-part-5/pages/tic_tac_toe_page.py @@ -1,7 +1,7 @@ from __future__ import annotations import functools -from typing import * # type: ignore +import typing as t import rio @@ -9,17 +9,21 @@ from .. import components as comps # +@rio.page( + name="Tic Tac Toe", + url_segment="", +) class TicTacToePage(rio.Component): # The contents of all fields. Each field can contain an X, an O, or be # empty. The initial state is an empty board. - fields: list[Literal["X", "O", ""]] = [""] * 9 + fields: list[t.Literal["X", "O", ""]] = [""] * 9 # The player who is currently on turn - player: Literal["X", "O"] = "X" + player: t.Literal["X", "O"] = "X" # # The winner of the game, if any - winner: Literal["X", "O", "draw"] | None = None + winner: t.Literal["X", "O", "draw"] | None = None # If there is a winner, these are the indices of the fields which made them # win diff --git a/rio/state_properties.py b/rio/state_properties.py index 9ac14554..773185a9 100644 --- a/rio/state_properties.py +++ b/rio/state_properties.py @@ -2,15 +2,14 @@ from __future__ import annotations import dataclasses import types +import typing as t import weakref -from collections.abc import Callable -from typing import TYPE_CHECKING import introspection.typing from . import global_state -if TYPE_CHECKING: +if t.TYPE_CHECKING: from .components import Component @@ -175,7 +174,7 @@ class StateProperty: @dataclasses.dataclass(eq=False) class AttributeBinding: # Weak reference to the component containing this binding - owning_component_weak: Callable[[], Component | None] + owning_component_weak: t.Callable[[], Component | None] # The state property whose value this binding is owning_property: StateProperty diff --git a/rio/testing.py b/rio/testing.py index a5862a1e..191861ad 100644 --- a/rio/testing.py +++ b/rio/testing.py @@ -1,9 +1,9 @@ import asyncio -from collections.abc import Callable, Iterable, Iterator, Mapping +import typing as t import ordered_set import starlette.datastructures -from typing_extensions import Self, TypeVar, overload +import typing_extensions as te from uniserde import JsonDoc import rio @@ -15,12 +15,12 @@ from .transports import MessageRecorderTransport, TransportInterrupted __all__ = ["TestClient"] -T = TypeVar("T") -C = TypeVar("C", bound=rio.Component) +T = t.TypeVar("T") +C = t.TypeVar("C", bound=rio.Component) class TestClient: - @overload + @t.overload def __init__( self, app: rio.App, @@ -31,13 +31,13 @@ class TestClient: use_ordered_dirty_set: bool = False, ): ... - @overload + @t.overload def __init__( self, - build: Callable[[], rio.Component] = rio.Spacer, + build: t.Callable[[], rio.Component] = rio.Spacer, *, app_name: str = "mock-app", - default_attachments: Iterable[object] = (), + default_attachments: t.Iterable[object] = (), running_in_window: bool = False, user_settings: JsonDoc = {}, active_url: str = "/", @@ -46,12 +46,12 @@ class TestClient: def __init__( # type: ignore self, - app_or_build: rio.App | Callable[[], rio.Component] | None = None, + app_or_build: rio.App | t.Callable[[], rio.Component] | None = None, *, app: rio.App | None = None, - build: Callable[[], rio.Component] | None = None, + build: t.Callable[[], rio.Component] | None = None, app_name: str = "test-app", - default_attachments: Iterable[object] = (), + default_attachments: t.Iterable[object] = (), running_in_window: bool = False, user_settings: JsonDoc = {}, active_url: str = "/", @@ -102,7 +102,7 @@ class TestClient: if message["method"] == "updateComponentStates": self._first_refresh_completed.set() - async def __aenter__(self) -> Self: + async def __aenter__(self) -> te.Self: url = str(rio.URL("http://unit.test") / self._active_url.lstrip("/")) self._session = await self._app_server.create_session( @@ -164,7 +164,7 @@ class TestClient: @property def _last_component_state_changes( self, - ) -> Mapping[rio.Component, Mapping[str, object]]: + ) -> t.Mapping[rio.Component, t.Mapping[str, object]]: for message in reversed(self._transport.sent_messages): if message["method"] == "updateComponentStates": delta_states: dict = message["params"]["deltaStates"] # type: ignore @@ -199,14 +199,14 @@ class TestClient: return self._session @property - def crashed_build_functions(self) -> Mapping[Callable, str]: + def crashed_build_functions(self) -> t.Mapping[t.Callable, str]: return self.session._crashed_build_functions @property def root_component(self) -> rio.Component: return self.session._get_user_root_component() - def get_components(self, component_type: type[C]) -> Iterator[C]: + def get_components(self, component_type: type[C]) -> t.Iterator[C]: root_component = self.root_component for component in root_component._iter_component_tree_(): diff --git a/rio/text_style.py b/rio/text_style.py index 7445d301..7e342e97 100644 --- a/rio/text_style.py +++ b/rio/text_style.py @@ -1,8 +1,8 @@ from __future__ import annotations import pathlib +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore from uniserde import JsonDoc @@ -66,8 +66,8 @@ class Font(SelfSerializing): return sess._register_font(self) # Predefined fonts - ROBOTO: ClassVar[Font] - ROBOTO_MONO: ClassVar[Font] + ROBOTO: t.ClassVar[Font] + ROBOTO_MONO: t.ClassVar[Font] Font.ROBOTO = Font( @@ -107,7 +107,9 @@ class TextStyle(SelfSerializing): `font_weight`: Whether the text is normal or **bold**. - `underlined`: Whether the text is u̲n̲d̲e̲r̲l̲i̲n̲e̲d or not. + `underlined`: Whether the text is underlined or not. + + `strikethrough`: Whether the text should have ~~a line through it~~. `all_caps`: Whether the text is transformed to ALL CAPS or not. """ @@ -117,8 +119,9 @@ class TextStyle(SelfSerializing): fill: _TextFill | None = None font_size: float = 1.0 italic: bool = False - font_weight: Literal["normal", "bold"] = "normal" + font_weight: t.Literal["normal", "bold"] = "normal" underlined: bool = False + strikethrough: bool = False all_caps: bool = False def replace( @@ -128,8 +131,9 @@ class TextStyle(SelfSerializing): fill: _TextFill | None | UnsetType = UNSET, font_size: float | None = None, italic: bool | None = None, - font_weight: Literal["normal", "bold"] | None = None, + font_weight: t.Literal["normal", "bold"] | None = None, underlined: bool | None = None, + strikethrough: bool | None = None, all_caps: bool | None = None, ) -> TextStyle: return type(self)( @@ -137,10 +141,13 @@ class TextStyle(SelfSerializing): fill=self.fill if isinstance(fill, UnsetType) else fill, font_size=self.font_size if font_size is None else font_size, italic=self.italic if italic is None else italic, - font_weight=self.font_weight - if font_weight is None - else font_weight, + font_weight=( + self.font_weight if font_weight is None else font_weight + ), underlined=self.underlined if underlined is None else underlined, + strikethrough=( + self.strikethrough if strikethrough is None else strikethrough + ), all_caps=self.all_caps if all_caps is None else all_caps, ) @@ -154,5 +161,6 @@ class TextStyle(SelfSerializing): "italic": self.italic, "fontWeight": self.font_weight, "underlined": self.underlined, + "strikethrough": self.strikethrough, "allCaps": self.all_caps, } diff --git a/rio/theme.py b/rio/theme.py index b30892e7..0c77fe18 100644 --- a/rio/theme.py +++ b/rio/theme.py @@ -1,7 +1,7 @@ from __future__ import annotations +import typing as t from dataclasses import KW_ONLY, dataclass -from typing import * # type: ignore from uniserde import Jsonable @@ -16,7 +16,7 @@ __all__ = [ ] -T = TypeVar("T") +T = t.TypeVar("T") def _derive_color( @@ -41,7 +41,7 @@ def _derive_color( if difference < 0.01: offset_scale = 1 else: - offset_scale = min(1.0 / difference, 1) + offset_scale = min(1.5 / difference, 1) result = color.blend(target_color, offset * offset_scale) @@ -59,7 +59,7 @@ def _derive_color( # Desaturate the color slightly hue, saturation, value = result.hsv - saturation = max(saturation - offset * 0.6, 0) + saturation = max(saturation * 0.9, 0) return rio.Color.from_hsv( hue=hue, @@ -89,7 +89,7 @@ def _make_semantic_palette(color: rio.Color) -> Palette: ) -@final +@t.final @dataclass() class Palette: background: rio.Color @@ -131,7 +131,7 @@ class Palette: ) -@final +@t.final @dataclass() class Theme: """ @@ -260,15 +260,15 @@ class Theme: success_color: rio.Color | None = None, warning_color: rio.Color | None = None, danger_color: rio.Color | None = None, - corner_radius_small: float = 0.5, - corner_radius_medium: float = 1.0, - corner_radius_large: float = 2.0, - heading_fill: Literal["primary", "plain", "auto"] + corner_radius_small: float = 0.4, + corner_radius_medium: float = 0.8, + corner_radius_large: float = 1.8, + heading_fill: t.Literal["primary", "plain", "auto"] | text_style_module._TextFill = "auto", text_color: rio.Color | None = None, font: text_style_module.Font = text_style_module.Font.ROBOTO, monospace_font: text_style_module.Font = text_style_module.Font.ROBOTO_MONO, - mode: Literal["light", "dark"] = "light", + mode: t.Literal["light", "dark"] = "light", ) -> Theme: """ Creates a new theme based on the provided colors. @@ -414,14 +414,16 @@ class Theme: ) else: background_color = rio.Color.from_grey(0.08).blend( - primary_color, 0.05 + primary_color, 0.02 ) if text_color is None: neutral_and_background_text_color = ( - rio.Color.from_grey(0.2) + # Grey tones look good on bright themes + rio.Color.from_grey(0.3) if background_color.perceived_brightness > 0.5 - else rio.Color.from_grey(0.8) + # ... but not on dark ones. Go very bright here. + else rio.Color.from_grey(0.85) ) else: neutral_and_background_text_color = text_color @@ -432,13 +434,13 @@ class Theme: background=background_color, background_variant=_derive_color( background_color, - 0.25, + 0.15, bias_to_bright=-0.15, target_color=primary_color, ), background_active=_derive_color( background_color, - 0.4, + 0.25, bias_to_bright=0.15, target_color=primary_color, ), @@ -456,13 +458,13 @@ class Theme: background=neutral_color, background_variant=_derive_color( neutral_color, - 0.35, + 0.15, bias_to_bright=-0.15, target_color=primary_color, ), background_active=_derive_color( neutral_color, - 0.5, + 0.25, bias_to_bright=0.15, target_color=primary_color, ), @@ -589,15 +591,15 @@ class Theme: success_color: rio.Color | None = None, warning_color: rio.Color | None = None, danger_color: rio.Color | None = None, - corner_radius_small: float = 0.6, - corner_radius_medium: float = 1.6, - corner_radius_large: float = 2.6, + corner_radius_small: float = 0.4, + corner_radius_medium: float = 0.8, + corner_radius_large: float = 1.8, text_color: rio.Color | tuple[rio.Color | None, rio.Color | None] | None = None, font: text_style_module.Font = text_style_module.Font.ROBOTO, monospace_font: text_style_module.Font = text_style_module.Font.ROBOTO_MONO, - heading_fill: Literal["primary", "plain", "auto"] + heading_fill: t.Literal["primary", "plain", "auto"] | text_style_module._TextFill = "auto", ) -> tuple[Theme, Theme]: """ diff --git a/rio/tools/create_code_for_icon_set.py b/rio/tools/create_code_for_icon_set.py index 76919471..d54c9a5a 100644 --- a/rio/tools/create_code_for_icon_set.py +++ b/rio/tools/create_code_for_icon_set.py @@ -27,12 +27,12 @@ def create(archive_file: str, output_file: str | None = None) -> None: icons = collect_icons(archive_file) with outfile_ctx as outfile: - outfile.write("from typing import Literal\n\n") + outfile.write("import typing as t\n\n") icon_set_name_pascal = introspection.convert_case( icon_set_name, "pascal" ) - outfile.write(f"{icon_set_name_pascal}Icon = Literal[\n") + outfile.write(f"{icon_set_name_pascal}Icon = t.Literal[\n") for icon_name in sorted(icons): variants = sorted( diff --git a/rio/transports/message_recorder_transport.py b/rio/transports/message_recorder_transport.py index c7d4ae74..ef00562b 100644 --- a/rio/transports/message_recorder_transport.py +++ b/rio/transports/message_recorder_transport.py @@ -1,6 +1,6 @@ import asyncio import json -from collections.abc import Callable +import typing as t from uniserde import JsonDoc @@ -11,7 +11,7 @@ __all__ = ["MessageRecorderTransport"] class MessageRecorderTransport(AbstractTransport): def __init__( - self, *, process_sent_message: Callable[[JsonDoc], None] | None = None + self, *, process_sent_message: t.Callable[[JsonDoc], None] | None = None ): super().__init__() diff --git a/rio/user_settings_module.py b/rio/user_settings_module.py index ff7a1047..1ced12d2 100644 --- a/rio/user_settings_module.py +++ b/rio/user_settings_module.py @@ -1,11 +1,11 @@ from __future__ import annotations import copy +import typing as t from dataclasses import field -from typing import * # type: ignore +import typing_extensions as te import uniserde -from typing_extensions import Self from . import inspection, session from .dataclass import RioDataclassMeta, all_class_fields @@ -81,7 +81,7 @@ class UserSettings(metaclass=RioDataclassMeta): # Any values from this class will be stored in the configuration file under # this section. This has to be set to a string. If empty, the values will be # set outside of any sections. - section_name: ClassVar[str] = "" + section_name: t.ClassVar[str] = "" _rio_session_: session.Session | None = field( default=None, init=False, repr=False, compare=False @@ -102,15 +102,15 @@ class UserSettings(metaclass=RioDataclassMeta): def _from_json( cls, settings_json: uniserde.JsonDoc, - defaults: Self, - ) -> Self: + defaults: te.Self, + ) -> te.Self: # Create the instance for this attachment. Bypass the constructor so the # instance doesn't immediately try to synchronize with the frontend. self = object.__new__(cls) settings_vars = vars(self) if cls.section_name: - section = cast( + section = t.cast( dict[str, object], settings_json.get("section:" + cls.section_name, {}), ) @@ -139,7 +139,7 @@ class UserSettings(metaclass=RioDataclassMeta): return self # This function kinda ruins linting, so we'll hide it from the IDE - def __setattr(self, name: str, value: Any) -> None: + def __setattr(self, name: str, value: t.Any) -> None: # These attributes doesn't exist yet during the constructor dct = vars(self) dirty_attribute_names = dct.setdefault( @@ -160,10 +160,10 @@ class UserSettings(metaclass=RioDataclassMeta): # if self._rio_session_ is not None: # self._rio_session_._save_settings_soon() - if not TYPE_CHECKING: + if not t.TYPE_CHECKING: __setattr__ = __setattr - def _equals(self, other: Self) -> bool: + def _equals(self, other: te.Self) -> bool: if type(self) != type(other): return False diff --git a/rio/utils.py b/rio/utils.py index 8562aa58..7ab97c77 100644 --- a/rio/utils.py +++ b/rio/utils.py @@ -1,21 +1,19 @@ 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 from pathlib import Path -from typing import * # type: ignore import imy.assets +import typing_extensions as te from PIL.Image import Image -from typing_extensions import Annotated from yarl import URL import rio @@ -56,8 +54,8 @@ else: # Constants & types _READONLY = object() -T = TypeVar("T") -Readonly = Annotated[T, _READONLY] +T = t.TypeVar("T") +Readonly = te.Annotated[T, _READONLY] ImageLike = Path | Image | URL | bytes @@ -77,7 +75,7 @@ MARKDOWN_CODE_ESCAPE = re.compile(r"([\\`])") I_KNOW_WHAT_IM_DOING = set[object]() -def i_know_what_im_doing(thing: Callable): +def i_know_what_im_doing(thing: t.Callable): I_KNOW_WHAT_IM_DOING.add(thing) return thing @@ -158,13 +156,15 @@ class FileInfo: """ return self._contents.decode(encoding) - @overload - async def open(self, type: Literal["r"]) -> StringIO: ... + @t.overload + async def open(self, type: t.Literal["r"]) -> StringIO: ... - @overload - async def open(self, type: Literal["rb"]) -> BytesIO: ... + @t.overload + async def open(self, type: t.Literal["rb"]) -> BytesIO: ... - async def open(self, type: Literal["r", "rb"] = "r") -> StringIO | BytesIO: + async def open( + self, type: t.Literal["r", "rb"] = "r" + ) -> StringIO | BytesIO: """ Asynchronously opens the file, as though it were a regular file on this device. @@ -191,10 +191,10 @@ class FileInfo: raise ValueError("Invalid type. Expected 'r' or 'rb'.") -T = TypeVar("T") -P = ParamSpec("P") +T = t.TypeVar("T") +P = t.ParamSpec("P") -EventHandler = Callable[P, Any | Awaitable[Any]] | None +EventHandler = t.Callable[P, t.Any | t.Awaitable[t.Any]] | None def make_url_relative(base: URL, other: URL) -> URL: @@ -309,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: 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`. @@ -326,7 +328,9 @@ def _repr_build_function(build_function: Callable[[], rio.Component]) -> str: return f"{type(self).__name__}.{build_function.__name__}" -def safe_build(build_function: 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 @@ -396,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") @@ -483,7 +461,7 @@ def normalize_file_type(file_type: str) -> str: return file_type -def soft_sort(elements: list[T], key: Callable[[T], int | None]) -> None: +def soft_sort(elements: list[T], key: t.Callable[[T], int | None]) -> None: """ Sorts the given list in-place, allowing for `None` values in the key. diff --git a/scripts/benchmark.py b/scripts/benchmark.py index cc3ec716..e417e718 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -3,7 +3,7 @@ import cProfile import itertools import pstats import time -from typing import Callable +import typing as t import rio @@ -30,7 +30,9 @@ class ComplexComponent(rio.Component): class BenchmarkComponent(rio.Component): - child_factory: Callable[[], rio.Component] = lambda: rio.Text("Starting...") + child_factory: t.Callable[[], rio.Component] = lambda: rio.Text( + "Starting..." + ) def __post_init__(self): self.benchmark_start_time = time.monotonic() diff --git a/scripts/build_material_icon_set.py b/scripts/build_material_icon_set.py index 0ccd859b..5d7a73a9 100644 --- a/scripts/build_material_icon_set.py +++ b/scripts/build_material_icon_set.py @@ -10,7 +10,6 @@ import re import tarfile import tempfile from pathlib import Path -from typing import * # type: ignore from xml.etree import ElementTree as ET import revel diff --git a/tests/test_attribute_bindings.py b/tests/test_attribute_bindings.py index d954c2de..ae3561d4 100644 --- a/tests/test_attribute_bindings.py +++ b/tests/test_attribute_bindings.py @@ -1,4 +1,4 @@ -from typing import cast +import typing as t import rio.testing from rio.state_properties import PleaseTurnThisIntoAnAttributeBinding @@ -128,7 +128,7 @@ async def test_binding_assignment_on_sibling(): async with rio.testing.TestClient(Root) as test_client: root_component = test_client.get_component(Root) - text1, text2 = cast( + text1, text2 = t.cast( list[rio.Text], test_client._get_build_output(root_component, rio.Column).children, ) @@ -150,7 +150,7 @@ async def test_binding_assignment_on_sibling(): async def test_binding_assignment_on_grandchild(): async with rio.testing.TestClient(Grandparent) as test_client: root_component = test_client.get_component(Grandparent) - parent = cast(Parent, test_client._get_build_output(root_component)) + parent = t.cast(Parent, test_client._get_build_output(root_component)) text_component: rio.Text = test_client._get_build_output(parent) assert not test_client._dirty_components diff --git a/tests/test_cli/test_app_loading.py b/tests/test_cli/test_app_loading.py new file mode 100644 index 00000000..7fdc7acc --- /dev/null +++ b/tests/test_cli/test_app_loading.py @@ -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" diff --git a/tests/test_docstring_code_blocks.py b/tests/test_docstring_code_blocks.py index 93ccba53..79b4b3c4 100644 --- a/tests/test_docstring_code_blocks.py +++ b/tests/test_docstring_code_blocks.py @@ -4,8 +4,8 @@ import subprocess import sys import tempfile import textwrap +import typing as t from pathlib import Path -from typing import * # type: ignore import pytest @@ -27,7 +27,7 @@ def ruff(*args: str | Path) -> subprocess.CompletedProcess: ) -def get_code_blocks(obj: type | Callable) -> list[str]: +def get_code_blocks(obj: type | t.Callable) -> list[str]: """ Returns a list of all code blocks in the docstring of a component. """ @@ -113,7 +113,7 @@ self = rio.Spacer() @pytest.mark.parametrize("obj", all_documented_objects) -def test_code_block_is_formatted(obj: type | Callable) -> None: +def test_code_block_is_formatted(obj: type | t.Callable) -> None: # Make sure all code blocks are formatted according to ruff for source in get_code_blocks(obj): formatted_source = ruff_format(source) @@ -128,11 +128,15 @@ def test_code_block_is_formatted(obj: type | Callable) -> None: @pytest.mark.parametrize("obj", all_documented_objects) -def test_analyze_code_block(obj: type | Callable) -> None: +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 diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 34e9c80c..5e43a913 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +import typing as t import imy.docstrings import pytest @@ -6,7 +6,7 @@ import pytest import rio.docs -def _create_tests(): +def _create_tests() -> None: for obj, docs in rio.docs.find_documented_objects().items(): if isinstance(docs, imy.docstrings.FunctionDocs): test_cls = _create_function_tests(docs) @@ -114,7 +114,7 @@ def _create_class_tests(cls: type, docs: imy.docstrings.ClassDocs) -> type: def parametrize_with_name( param_name: str, - docs: Iterable[ + docs: t.Iterable[ imy.docstrings.FunctionDocs | imy.docstrings.ClassDocs | imy.docstrings.ClassField diff --git a/tests/test_layouting.py b/tests/test_layouting.py index 113a97f7..3d476fed 100644 --- a/tests/test_layouting.py +++ b/tests/test_layouting.py @@ -1,5 +1,5 @@ import math -from typing import * # type: ignore +import typing as t import pytest @@ -37,7 +37,7 @@ async def test_single_component(text: str) -> None: [rio.Row, rio.Column], ) async def test_linear_container_with_no_extra_width( - container_type: Type, + container_type: t.Type, ) -> None: await verify_layout( lambda: container_type( @@ -72,7 +72,7 @@ async def test_linear_container_with_extra_width( horizontal: bool, first_child_grows: bool, second_child_grows: bool, - proportions: None | Literal["homogeneous"] | List[int], + proportions: None | t.Literal["homogeneous"] | list[int], ) -> None: """ A battery of scenarios to test the most common containers - Rows & Columns. @@ -297,8 +297,8 @@ async def test_aspect_ratio_container_large_child( ], ) async def test_scrolling( - scroll_x: Literal["never", "always", "auto"], - scroll_y: Literal["never", "always", "auto"], + scroll_x: t.Literal["never", "always", "auto"], + scroll_y: t.Literal["never", "always", "auto"], ) -> None: await verify_layout( lambda: rio.ScrollContainer( diff --git a/tests/test_project_templates.py b/tests/test_project_templates.py index 2e4e4ab0..5f8a6498 100644 --- a/tests/test_project_templates.py +++ b/tests/test_project_templates.py @@ -7,8 +7,8 @@ This file ensures that the snippets for project templates match expectations. from __future__ import annotations import tempfile +import typing as t from pathlib import Path -from typing import * # type: ignore import pytest @@ -47,7 +47,7 @@ def test_available_template_literal_matches_templates() -> None: """ # Find all templates according to the literal templates_according_to_literal = set( - get_args(rio.snippets.AvailableTemplatesLiteral) + t.get_args(rio.snippets.AvailableTemplatesLiteral) ) | {"Empty"} # Find all defined templates diff --git a/tests/test_reconciliation.py b/tests/test_reconciliation.py index efd8be1a..306549b6 100644 --- a/tests/test_reconciliation.py +++ b/tests/test_reconciliation.py @@ -9,7 +9,7 @@ async def test_reconciliation(): if self.toggle: return rio.TextInput("hi", min_width=10, min_height=10) else: - return rio.TextInput(grow_y=True, is_secret=True) + return rio.TextInput(min_height=15, is_secret=True) async with rio.testing.TestClient(Toggler) as test_client: toggler = test_client.get_component(Toggler) @@ -17,8 +17,8 @@ async def test_reconciliation(): text_input.text = "bye" text_input.min_height = 5 - toggler.toggle = False + toggler.toggle = False await test_client.refresh() # The text should carry over because it was assigned after the component @@ -28,15 +28,15 @@ async def test_reconciliation(): # The height that was explicitly passed into the constructor of the new # component should win out over the late assignment - assert text_input.min_height == "grow" + assert text_input.min_height == 15 - # The width and is_secret should be taken from the new component - assert text_input.min_width == "natural" + # The min_width and is_secret should be taken from the new component + assert text_input.min_width == 0 assert text_input.is_secret # If we toggle again, make sure the `is_secret` is back to its default - # value. (Not really sure how this is different from the `width`, but - # there was once a bug like this) + # value. (Not really sure how this is different from the `min_width`, + # but there was once a bug like this) toggler.toggle = True await test_client.refresh() diff --git a/tests/test_table_indexing.py b/tests/test_table_indexing.py index f2dc06ba..e01a8afc 100644 --- a/tests/test_table_indexing.py +++ b/tests/test_table_indexing.py @@ -3,7 +3,7 @@ Tables support numpy-style 2D indexing. This is rather complex, hence the tests here. """ -from typing import * +import typing as t import pytest @@ -12,7 +12,7 @@ import rio # Helper class for easily creating indices class MakeIndex: - def __getitem__(self, index) -> Any: + def __getitem__(self, index) -> t.Any: return index @@ -260,15 +260,15 @@ make_index = MakeIndex() ], ) def test_indices( - index: Any, + index: t.Any, enable_column_names: bool, result_should: tuple[ int, - int | Literal["header"], + int | t.Literal["header"], int, int, ] - | Type[Exception], + | t.Type[Exception], ) -> None: if enable_column_names: column_names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] diff --git a/tests/test_utils.py b/tests/test_utils.py index c9dede3b..aecd1e9e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import pytest -import rio +import rio.utils @pytest.mark.parametrize( diff --git a/tests/utils/layouting.py b/tests/utils/layouting.py index bc92d32f..c4a89960 100644 --- a/tests/utils/layouting.py +++ b/tests/utils/layouting.py @@ -1,8 +1,7 @@ from __future__ import annotations import asyncio -import typing -from collections.abc import Callable +import typing as t import playwright.async_api import playwright.sync_api @@ -22,7 +21,9 @@ __all__ = ["verify_layout", "cleanup"] layouter_factory: LayouterFactory | None = None -async def verify_layout(build: 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 @@ -112,7 +113,7 @@ class LayouterFactory: await self._uvicorn_serve_task async def create_layouter( - self, build: Callable[[], rio.Component] + self, build: t.Callable[[], rio.Component] ) -> Layouter: self._app._build = build session, page = await self._create_session() @@ -189,7 +190,7 @@ class LayouterFactory: **playwright_obj.devices["Desktop Chrome"] ) - async def _create_session(self) -> tuple[Session, typing.Any]: + async def _create_session(self) -> tuple[Session, t.Any]: assert ( self._app_server is not None ), "Uvicorn isn't running for some reason"