rework docs infrastructure

This commit is contained in:
Aran-Fey
2024-12-17 01:03:52 +01:00
parent 27af41951a
commit d46d671ba1
25 changed files with 537 additions and 647 deletions

View File

@@ -11,7 +11,7 @@ dependencies = [
"fastapi~=0.110",
"fuzzywuzzy~=0.18",
"gitignore-parser==0.1.11",
"imy[docstrings]==0.5.0",
"imy[docstrings,deprecations]==0.6.0rc2",
"introspection~=1.9.2",
"isort~=5.13",
"keyring~=24.3",
@@ -105,7 +105,7 @@ dev-dependencies = [
"ruff>=0.4.7",
"selenium>=4.22",
"hatch>=1.11.1",
"pyfakefs>=5.7.1",
"pyfakefs>=5.7.2",
]
managed = true

View File

@@ -180,9 +180,8 @@ class App:
`name`: The name to display for this app. This can show up in window
titles, error messages and wherever else the app needs to be
referenced in a nice, human-readable way. If not specified,
`Rio` name will try to guess a name based on the name of the
main Python file.
referenced in a nice, human-readable way. If not specified, Rio will
try to guess a name based on the name of the main Python file.
`description`: A short, human-readable description of the app. This
can show up in search engines, social media sites and similar.
@@ -762,7 +761,7 @@ class App:
if you don't want the complexity of running a web server, or wish to
package your app as a standalone executable.
```py
```python
app = rio.App(
name="My App",
build=MyAppRoot,

View File

@@ -125,7 +125,8 @@ def _format_single_exception_raw(
and frame.end_colno is not None
):
if (
frame.end_lineno is not None
hasattr(frame, "end_lineno")
and frame.end_lineno is not None
and frame.end_lineno > frame.lineno
):
end_col = len(source_line) - 1 # -1 to exclude the \n

View File

@@ -125,8 +125,8 @@ def modules_in_directory(project_path: Path) -> t.Iterable[str]:
# Special case: Unloading Rio, while Rio is running is not that smart.
#
# Also make sure "__main__" isn't unloaded. This can happen because the
# project is running as `rio run` (which makes `rio` "__main__") and if
# `rio` is located in the project directory.
# project is running as `rio run` (which makes rio "__main__") and if
# rio is located in the project directory.
if name in ("__main__", "rio") or name.startswith("rio."):
continue

View File

@@ -989,7 +989,6 @@ class Color(SelfSerializing):
# Grays
BLACK: t.ClassVar[Color]
GREY: t.ClassVar[Color] # Deprecated
GRAY: t.ClassVar[Color]
WHITE: t.ClassVar[Color]
@@ -1014,7 +1013,7 @@ class Color(SelfSerializing):
Color.BLACK = Color.from_rgb(0.0, 0.0, 0.0)
Color.GREY = Color.from_rgb(0.5, 0.5, 0.5) # Deprecated
Color.GREY = Color.from_rgb(0.5, 0.5, 0.5) # type: ignore (Deprecated and thus not annotated)
Color.GRAY = Color.from_rgb(0.5, 0.5, 0.5)
Color.WHITE = Color.from_rgb(1.0, 1.0, 1.0)

View File

@@ -377,9 +377,31 @@ class Component(abc.ABC, metaclass=ComponentMeta):
"""
return self._session_
# There isn't really a good type annotation for this... `te.Self` is the closest
# thing
# There isn't really a good type annotation for this... `te.Self` is the
# closest thing
def bind(self) -> te.Self:
"""
Create an attribute binding between this component and one of its
children.
Attribute bindings allow a child component to pass values up to its
parent component. Example:
```python
class AttributeBindingExample(rio.Component):
toggle_is_on: bool = False
def build(self) -> rio.Component:
return rio.Column(
# Thanks to the attribute binding, toggling the Switch will
# also update this component's `toggle_is_on` attribute
rio.Switch(self.bind().toggle_is_on),
rio.Text("ON" if self.toggle_is_on else "OFF"),
)
```
For more details, see [Attribute Bindings](https://rio.dev/docs/howto/howto-get-value-from-child-component).
"""
return AttributeBindingMaker(self) # type: ignore
def _custom_serialize_(self) -> JsonDoc:
@@ -587,9 +609,9 @@ class Component(abc.ABC, metaclass=ComponentMeta):
Force a rebuild of this component.
Most of the time components update automatically when their state
changes. However, some state mutations are invisible to `Rio`: For
changes. However, some state mutations are invisible to Rio: For
example, appending items to a list modifies the list, but since no list
instance was actually assigned to th component, `Rio` will be unaware of
instance was actually assigned to th component, Rio will be unaware of
this change.
In these cases, you can force a rebuild of the component by calling
@@ -597,18 +619,13 @@ class Component(abc.ABC, metaclass=ComponentMeta):
display the updated version on the screen.
Another common use case is if you wish to update an component while an
event handler is still running. `Rio` will automatically detect changes
event handler is still running. Rio will automatically detect changes
after event handlers return, but if you are performing a long-running
operation, you may wish to update the component while the event handler
is still running. This allows you to e.g. update a progress bar while
the operation is still running.
"""
self.session._register_dirty_component(
self,
include_children_recursively=False,
)
self.session.create_task(self.session._refresh())
self.session.create_task(self._force_refresh())
# We need to return a custom Awaitable. We can't use a Task because that
# would run regardless of whether the user awaits it or not, and we
@@ -626,6 +643,19 @@ class Component(abc.ABC, metaclass=ComponentMeta):
return BackwardsCompat() # type: ignore
async def _force_refresh(self) -> None:
"""
This function primarily exists for unit tests. Tests often need to wait
until the GUI is refreshed, and the public `force_refresh()` doesn't
allow that.
"""
self.session._register_dirty_component(
self,
include_children_recursively=False,
)
await self.session._refresh()
def _get_debug_details_(self) -> dict[str, t.Any]:
"""
Used by Rio's dev tools to decide which properties to display to a user,

View File

@@ -60,6 +60,9 @@ class DateInput(Component):
`label`: A short text to display next to the input field.
`accessibility_label`: A short text describing this component for screen
readers. If omitted, the `label` text is used.
`style`: Changes the visual appearance of the date input.
`on_change`: Triggered whenever the user selects a new date.

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import typing as t
import imy.docstrings
import rio
from .. import utils
@@ -13,6 +15,7 @@ __all__ = [
@t.final
@imy.docstrings.mark_as_private
class DialogContainer(Component):
build_content: t.Callable[[], Component]
owning_component_id: int

View File

@@ -47,6 +47,8 @@ class Markdown(FundamentalComponent):
Finally, if `"ellipsize"`, the text will be truncated when there isn't
enough space and an ellipsis (`...`) will be added.
`wrap`: Deprecated. Use `overflow` instead.
## Examples
@@ -79,7 +81,7 @@ class Markdown(FundamentalComponent):
since="0.10",
old_name="wrap",
new_name="overflow",
owner="rio.Markdown",
function="rio.Markdown",
)
if self.wrap is False:

View File

@@ -71,7 +71,7 @@ class ProgressBar(FundamentalComponent):
def build(self):
return rio.Column(
rio.Button(
'start working',
"start working",
on_press=self.do_the_work,
# Make sure the button can't be pressed while we're busy
is_sensitive=not self.working,

View File

@@ -2,9 +2,15 @@ import functools
import typing as t
import warnings
import introspection
import imy.deprecations
from imy.deprecations import (
deprecated,
parameter_remapped,
parameter_renamed,
warn,
warn_parameter_renamed,
)
from .component_meta import ComponentMeta
from .warnings import RioDeprecationWarning
if t.TYPE_CHECKING:
@@ -12,110 +18,44 @@ if t.TYPE_CHECKING:
__all__ = [
"deprecated",
"_remap_kwargs",
"function_kwarg_renamed",
"parameter_renamed",
"parameter_remapped",
"component_kwarg_renamed",
"warn",
"warn_parameter_renamed",
]
def get_public_name(obj: type | t.Callable) -> str:
# Unfortunately, we can't get the *real* public name from
# `rio.docs.get_docs_for(obj)` here because that requires all optional
# dependencies (pandas, polars, etc.) to be installed.
return obj.__qualname__
imy.deprecations.configure(
module="rio",
project_name="Rio",
modules_skipped_in_stacktrace=(
"rio",
"introspection",
"fastapi",
"starlette",
"uvicorn",
"asyncio",
"threading",
),
warning_class=RioDeprecationWarning,
name_for_object=get_public_name,
)
# Python filters DeprecationWarnings per default. Since rio is a framework (and
# not a library), I doubt anyone will have a problem with us forcefully turning
# our warnings back on.
warnings.simplefilter("default", RioDeprecationWarning)
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(
*,
since: str,
message: str,
) -> None:
# Find the first stack frame outside of Rio. Passing the stack level
# manually is error prone because decorators like `@component_kwarg_renamed`
# increase the call depth
with introspection.CallStack.current() as call_stack:
for stacklevel, frame in enumerate(reversed(call_stack), 1):
if frame.globals["__name__"].partition(".")[0] not in (
"rio",
"introspection",
"fastapi",
"starlette",
"uvicorn",
"asyncio",
"threading",
):
break
else:
stacklevel = 0
warnings.warn(
f"Deprecated since Rio version {since}: {message}",
RioDeprecationWarning,
stacklevel=stacklevel,
)
def warn_parameter_renamed(
*,
since: str,
old_name: str,
new_name: str,
owner: str,
):
warn(
since=since,
message=f"The `{old_name}` parameter of `{owner}` has been renamed. Use `{new_name}` instead.",
)
@t.overload
def deprecated(*, since: str, replacement: t.Callable | str): ...
@t.overload
def deprecated(*, since: str, description: str): ...
def deprecated(
*,
since: str,
description: str | None = None,
replacement: t.Callable | str | None = None,
):
def decorator(callable_: C) -> C:
if description is None:
warning_message = f"`{get_public_name(callable_)}`"
else:
warning_message = description
if replacement is not None and not isinstance(replacement, str):
replacement_name = get_public_name(replacement)
else:
replacement_name = replacement
if replacement_name is not None:
warning_message += f". Use `{replacement_name}` instead."
# If it's a class, wrap the constructor. Otherwise, wrap the callable itself.
if isinstance(callable_, type):
wrapped_init = callable_.__init__
@functools.wraps(wrapped_init)
def init_wrapper(*args, **kwargs):
warn(message=warning_message, since=since)
wrapped_init(*args, **kwargs)
callable_.__init__ = init_wrapper
return t.cast(C, callable_)
else:
@functools.wraps(callable_)
def wrapper(*args, **kwargs):
warn(message=warning_message, since=since)
return callable_(*args, **kwargs)
return t.cast(C, wrapper)
return decorator
def component_kwarg_renamed(
@@ -134,7 +74,7 @@ def component_kwarg_renamed(
- The parameter must be a keyword argument (because positional arguments
would require a very slow `inspect.signature` call)
- This must be applied to a component, not a function (because it modifies
the contained `_remap_constructor_arguments` method)
the contained `_remap_constructor_arguments_` method)
"""
def decorator(component_class: t.Type[CO]) -> t.Type[CO]:
@@ -153,7 +93,7 @@ def component_kwarg_renamed(
since=since,
old_name=old_name,
new_name=new_name,
owner=get_public_name(component_class),
function=component_class,
)
# Delegate to the original _remap_constructor_arguments method
@@ -166,118 +106,3 @@ def component_kwarg_renamed(
return component_class
return decorator
def parameters_remapped(
*,
since: str,
**params: t.Callable[[t.Any], dict[str, t.Any]],
):
"""
This is a function decorator that's quite similar to `parameters_renamed`,
but it allows you to change the type and value(s) of the parameter as well
as the name.
The input for the decorator are functions that take the value of the old
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: t.Literal['light', 'dark'] = 'light'`.
class Theme:
@parameters_remapped(
'0.9',
light=lambda light: {"mode": "light" if light else "dark"},
)
def from_colors(..., mode: t.Literal['light', 'dark'] = 'light'):
...
Theme.from_colors(light=False) # Equivalent to `mode='dark'`
"""
def decorator(func: F) -> F:
@functools.wraps(func)
def wrapper(*args, **kwargs):
for old_name, remap_func in params.items():
try:
old_value = kwargs.pop(old_name)
except KeyError:
pass
else:
[[new_name, new_value]] = remap_func(old_value).items()
kwargs[new_name] = new_value
warn_parameter_renamed(
since=since,
old_name=old_name,
new_name=new_name,
owner=get_public_name(func),
)
return func(*args, **kwargs)
return wrapper # type: ignore
return decorator
def _remap_kwargs(
since: str,
func_name: str,
kwargs: dict[str, object],
old_names_to_new_names: t.Mapping[str, str],
) -> None:
for old_name, new_name in old_names_to_new_names.items():
try:
kwargs[new_name] = kwargs.pop(old_name)
except KeyError:
pass
else:
warn_parameter_renamed(
since=since,
old_name=old_name,
new_name=new_name,
owner=f"rio.{func_name}",
)
def function_kwarg_renamed(
since: str,
old_name: str,
new_name: str,
) -> t.Callable[[F], F]:
"""
This decorator helps with renaming a keyword argument of a function, NOT a
component.
"""
def decorator(old_function: F) -> F:
@functools.wraps(old_function)
def new_function(*args: tuple, **kwargs: dict):
# Remap the old parameter to the new one
try:
kwargs[new_name] = kwargs.pop(old_name)
except KeyError:
pass
else:
warn_parameter_renamed(
since=since,
old_name=old_name,
new_name=new_name,
owner=get_public_name(old_function),
)
# Delegate to the original function
return old_function(*args, **kwargs)
# Return the modified function
return new_function # type: ignore
return decorator
def get_public_name(obj: type | t.Callable) -> str:
# Unfortunately, we can't get the *real* public name from
# `rio.docs.get_docs_for(obj)` here because that requires all optional
# dependencies (pandas, polars, etc.) to be installed.
return f"rio.{obj.__qualname__}"

View File

@@ -15,7 +15,8 @@ import rio
__all__ = [
"get_rio_module_docs",
"get_documented_objects",
"get_all_documented_objects",
"get_toplevel_documented_objects",
"get_docs_for",
"get_documentation_url",
"get_documentation_url_segment",
@@ -30,9 +31,9 @@ NAME_TO_DOCS: (
t.Sequence[
imy.docstrings.ClassDocs
| imy.docstrings.FunctionDocs
| imy.docstrings.ClassField
| imy.docstrings.Property
| imy.docstrings.FunctionParameter,
| imy.docstrings.AttributeDocs
| imy.docstrings.PropertyDocs
| imy.docstrings.ParameterDocs,
],
]
| None
@@ -49,7 +50,7 @@ def _prepare_docs():
RIO_MODULE_DOCS.add_member(rio.event)
url_docs = _make_docs_for_rio_url()
url_docs.owning_scope = RIO_MODULE_DOCS
url_docs.owner = RIO_MODULE_DOCS
RIO_MODULE_DOCS.members["URL"] = url_docs
# Apply rio-specific post-processing
@@ -57,7 +58,13 @@ def _prepare_docs():
# Insert links to other documentation pages
all_docs = list(
RIO_MODULE_DOCS.iter_children(include_self=True, recursive=True)
docs
for docs in RIO_MODULE_DOCS.iter_children(
include_self=True, recursive=True
)
# Exclude the getters and setters of properties. We only need the
# properties themselves.
if not (isinstance(docs.owner, imy.docstrings.PropertyDocs))
)
name_to_docs = collections.defaultdict(list)
@@ -85,7 +92,7 @@ def _prepare_docs():
urls_to_ignore = _get_urls_to_ignore(docs)
docs.transform_docstrings(
lambda _, markdown: insert_links_into_markdown(
lambda markdown: insert_links_into_markdown(
markdown, urls_to_ignore=urls_to_ignore
)
)
@@ -98,14 +105,16 @@ def _get_urls_to_ignore(docs) -> t.Sequence[str]:
urls_to_ignore = [url_for_docs(docs)]
# Class members don't need to link their class
if isinstance(docs, (imy.docstrings.Property, imy.docstrings.ClassField)):
urls_to_ignore += _get_urls_to_ignore(docs.owning_class)
if isinstance(
docs, (imy.docstrings.PropertyDocs, imy.docstrings.AttributeDocs)
):
urls_to_ignore += _get_urls_to_ignore(docs.owner)
elif isinstance(docs, imy.docstrings.FunctionDocs):
if isinstance(docs.owning_scope, imy.docstrings.ClassDocs):
urls_to_ignore += _get_urls_to_ignore(docs.owning_scope)
if isinstance(docs.owner, imy.docstrings.ClassDocs):
urls_to_ignore += _get_urls_to_ignore(docs.owner)
# Parameters don't need to link to the function
elif isinstance(docs, imy.docstrings.FunctionParameter):
urls_to_ignore += _get_urls_to_ignore(docs.owning_function)
elif isinstance(docs, imy.docstrings.ParameterDocs):
urls_to_ignore += _get_urls_to_ignore(docs.owner)
return urls_to_ignore
@@ -119,10 +128,12 @@ def get_rio_module_docs() -> imy.docstrings.ModuleDocs:
@functools.cache
def get_documented_objects() -> (
def get_all_documented_objects() -> (
dict[
type | t.Callable,
imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs,
type | t.Callable | property,
imy.docstrings.ClassDocs
| imy.docstrings.FunctionDocs
| imy.docstrings.PropertyDocs,
]
):
all_docs = get_rio_module_docs().iter_children(
@@ -131,9 +142,35 @@ def get_documented_objects() -> (
return {
docs.object: docs
for docs in all_docs
if isinstance(
docs,
(
imy.docstrings.ClassDocs,
imy.docstrings.FunctionDocs,
imy.docstrings.PropertyDocs,
),
)
}
@functools.cache
def get_toplevel_documented_objects() -> (
dict[
type | t.Callable,
imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs,
]
):
"""
Returns only objects that have their own page in our docs. (That means no
methods.)
"""
return {
docs.object: docs
for docs in get_all_documented_objects().values()
if isinstance(
docs, (imy.docstrings.ClassDocs, imy.docstrings.FunctionDocs)
)
and isinstance(docs.owner, imy.docstrings.ModuleDocs)
}
@@ -152,13 +189,13 @@ def get_docs_for(
Parse the docs for a component and return them. The results are cached, so
this function is fast.
"""
return get_documented_objects()[obj]
return get_all_documented_objects()[obj] # type: ignore
def _make_docs_for_rio_url():
docs = imy.docstrings.ClassDocs.from_class(rio.URL)
docs.attributes.clear()
docs.functions.clear()
docs.members.clear()
docs.summary = "Alias for `yarl.URL`."
docs.details = """
Since URLs are a commonly used data type, Rio re-exports `yarl.URL` as
@@ -205,9 +242,9 @@ def get_documentation_url_segment(obj: type | t.Callable) -> str:
def url_for_docs(
docs: imy.docstrings.ClassDocs
| imy.docstrings.FunctionDocs
| imy.docstrings.ClassField
| imy.docstrings.Property
| imy.docstrings.FunctionParameter,
| imy.docstrings.AttributeDocs
| imy.docstrings.PropertyDocs
| imy.docstrings.ParameterDocs,
*,
relative: bool = False,
) -> str:
@@ -219,8 +256,8 @@ def url_for_docs(
if isinstance(docs, imy.docstrings.FunctionDocs):
# Methods are listed on the page of the class, so get the url for the
# class and then add the function name
if isinstance(docs.owning_scope, imy.docstrings.ClassDocs):
url = url_for_docs(docs.owning_scope, relative=relative)
if isinstance(docs.owner, imy.docstrings.ClassDocs):
url = url_for_docs(docs.owner, relative=relative)
# ScrollTargets currently don't work, so don't create urls with
# #fragments
return url # + f"#{docs.name.lower()}"
@@ -231,24 +268,22 @@ def url_for_docs(
"""
# Fields and properties are listed on the page of the class, so get the url
# for the class and then add an url fragment
if isinstance(docs, (imy.docstrings.ClassField, imy.docstrings.Property)):
url = url_for_docs(docs.owning_class, relative=relative)
if isinstance(docs, (imy.docstrings.AttributeDocs, imy.docstrings.PropertyDocs)):
url = url_for_docs(docs.owner, relative=relative)
return url + f"#{docs.name.lower()}"
# Parameters are listed on the page of the function, so get the url for the
# function and then add an url fragment
if isinstance(docs, imy.docstrings.FunctionParameter):
url = url_for_docs(docs.owning_function, relative=relative)
if isinstance(docs, imy.docstrings.ParameterDocs):
url = url_for_docs(docs.owner, relative=relative)
if "#" in url:
return url + f".{docs.name.lower()}"
else:
return url + f"#{docs.name.lower()}"
"""
if isinstance(docs, imy.docstrings.FunctionParameter):
return url_for_docs(docs.owning_function)
return url_for_docs(docs.owning_class)
assert docs.owner is not None
return url_for_docs(docs.owner)
assert False, f"url_for_docs received invalid input: {docs}"
@@ -296,9 +331,11 @@ def postprocess_class_docs(docs: imy.docstrings.ClassDocs) -> None:
# Strip out anything `Session` inherits from `unicall`
if docs.name == "Session":
to_remove = set(dir(unicall.Unicall)).difference(vars(rio.Session))
docs.functions = [
func for func in docs.functions if func.name not in to_remove
]
docs.members = {
name: member
for name, member in docs.members.items()
if name not in to_remove
}
# Strip default docstrings created by dataclasses
if docs.summary is not None and docs.summary.startswith(f"{docs.name}("):
@@ -306,12 +343,7 @@ def postprocess_class_docs(docs: imy.docstrings.ClassDocs) -> None:
docs.details = None
# Skip internal functions
index = 0
while index < len(docs.functions):
func = docs.functions[index]
# Decide whether to keep it
def keep_method(func: imy.docstrings.FunctionDocs) -> bool:
# Internal methods start with an underscore
keep = not func.name.startswith("_")
@@ -353,28 +385,36 @@ def postprocess_class_docs(docs: imy.docstrings.ClassDocs) -> None:
if not func.metadata.public:
keep = False
# Strip it out, if necessary
if keep:
index += 1
else:
del docs.functions[index]
return keep
docs.members = {
name: member
for name, member in docs.members.items()
if not isinstance(member, imy.docstrings.FunctionDocs)
or keep_method(member)
}
# Post-process the constructor
try:
init_function = docs.members["__init__"]
except KeyError:
pass
else:
assert isinstance(init_function, imy.docstrings.FunctionDocs)
# Additional per-function post-processing
for func_docs in docs.functions:
# Strip the ridiculous default docstring created by dataclasses
#
# FIXME: Not working for some reason
if (
func_docs.name == "__init__"
and func_docs.summary
init_function.summary
== "Initialize self. See help(type(self)) for accurate signature."
):
func_docs.summary = None
func_docs.details = None
init_function.summary = None
init_function.details = None
# Inject a short description for `__init__` if there is none.
if func_docs.name == "__init__" and func_docs.summary is None:
func_docs.summary = f"Creates a new `{docs.name}` instance."
if init_function.summary is None:
init_function.summary = f"Creates a new `{docs.name}` instance."
def postprocess_component_docs(docs: imy.docstrings.ClassDocs) -> None:
@@ -383,30 +423,27 @@ def postprocess_component_docs(docs: imy.docstrings.ClassDocs) -> None:
if docs.object is not rio.Component:
# Remove methods that are only useful in custom components
for i, func in enumerate(docs.functions):
if func.name in (
"bind",
"build",
"call_event_handler",
"force_refresh",
):
del docs.functions[i]
break
docs.members = {
name: member
for name, member in docs.members.items()
if name
not in ("bind", "build", "call_event_handler", "force_refresh")
}
# Subclasses of `rio.Component` inherit a load of constructor
# parameters, which clutter the docs. We'll sort the keyword-only
# parameters so that the inherited parameters appear at the end.
try:
init_func = next(
func for func in docs.functions if func.name == "__init__"
)
except StopIteration:
init_func = docs.members["__init__"]
except KeyError:
pass
else:
parameters = list[imy.docstrings.FunctionParameter]()
kwargs = list[imy.docstrings.FunctionParameter]()
assert isinstance(init_func, imy.docstrings.FunctionDocs)
for param in init_func.parameters:
parameters = list[imy.docstrings.ParameterDocs]()
kwargs = list[imy.docstrings.ParameterDocs]()
for param in init_func.parameters.values():
if param.kw_only:
kwargs.append(param)
else:
@@ -418,7 +455,7 @@ def postprocess_component_docs(docs: imy.docstrings.ClassDocs) -> None:
)
parameters += kwargs
init_func.parameters = parameters
init_func.parameters = {param.name: param for param in parameters}
def insert_links_into_markdown(

View File

@@ -45,4 +45,7 @@ class ClipboardError(Exception):
@property
def message(self) -> str:
"""
Returns the error message as a string.
"""
return self.args[0]

View File

@@ -19,7 +19,6 @@ from .errors import NavigationFailed
__all__ = [
"Redirect",
"ComponentPage",
"Page",
"page",
"GuardEvent",
]
@@ -108,6 +107,11 @@ class Redirect:
@t.final
@deprecations.parameter_renamed(
since="0.10",
old_name="page_url",
new_name="url_segment",
)
@dataclass(frozen=True)
class ComponentPage:
"""
@@ -259,34 +263,9 @@ class ComponentPage:
return kwargs
# Allow using the old `page_url` parameter instead of the new `url_segment`
_old_component_page_init = ComponentPage.__init__
def _new_component_page_init(self, *args, **kwargs) -> None:
# Rename the parameter
if "page_url" in kwargs:
deprecations.warn_parameter_renamed(
since="0.10",
old_name="page_url",
new_name="url_segment",
owner="rio.ComponentPage",
)
kwargs["url_segment"] = kwargs.pop("page_url")
# Call the original constructor
_old_component_page_init(self, *args, **kwargs)
ComponentPage.__init__ = _new_component_page_init
@deprecations.deprecated(since="0.10", replacement=ComponentPage)
@introspection.set_signature(ComponentPage)
def Page(*args, **kwargs):
deprecations.warn(
since="0.10",
message="`rio.Page` has been renamed to `rio.ComponentPage`",
)
return ComponentPage(*args, **kwargs)

View File

@@ -2077,7 +2077,7 @@ window.history.{method}(null, "", {json.dumps(str(active_page_url.relative()))})
multiple: t.Literal[True],
) -> list[utils.FileInfo]: ...
@deprecations.function_kwarg_renamed(
@deprecations.parameter_renamed(
since="0.10",
old_name="file_extension",
new_name="file_types",
@@ -2179,25 +2179,17 @@ window.history.{method}(null, "", {json.dumps(str(active_page_url.relative()))})
multiple: t.Literal[True],
) -> list[utils.FileInfo]: ...
@deprecations.function_kwarg_renamed(
@deprecations.parameter_renamed(
since="0.10",
old_name="file_extension",
new_name="file_types",
)
@deprecations.deprecated(since="0.10", replacement=pick_file)
async def file_chooser(
self,
*args,
**kwargs,
) -> utils.FileInfo | list[utils.FileInfo]:
"""
This function has been renamed. Use `pick_file` instead.
"""
# Warn
deprecations.warn(
since="0.10",
message="`file_chooser` has been renamed to `pick_file`. Please use the new name instead.",
)
# Delegate to the new function
return await self.pick_file(*args, **kwargs)
@@ -2477,7 +2469,7 @@ a.remove();
),
)
@deprecations.function_kwarg_renamed(
@deprecations.parameter_renamed(
since="0.10.10",
old_name="user_closeable",
new_name="user_closable",

View File

@@ -102,7 +102,7 @@ class TextStyle(SelfSerializing):
`underlined`: Whether the text is underlined or not.
`strikethrough`: Whether the text should have ~~a line through it~~.
`strikethrough`: Whether the text should have a line through it.
`all_caps`: Whether the text is transformed to ALL CAPS or not.
"""

View File

@@ -257,11 +257,11 @@ class Theme:
return self
@classmethod
@deprecations.parameters_remapped(
@deprecations.parameter_remapped(
since="0.9.0",
light=lambda light: {
"mode": "light" if light else "dark",
},
old_name="light",
new_name="mode",
remap=lambda light: "light" if light else "dark",
)
def from_colors(
cls,

View File

@@ -111,6 +111,10 @@ I_KNOW_WHAT_IM_DOING = set[object]()
def i_know_what_im_doing(thing: t.Callable):
"""
This is a function/class decorator that suppresses certain warnings telling
you that you likely made a mistake.
"""
I_KNOW_WHAT_IM_DOING.add(thing)
return thing

View File

@@ -1,4 +1,4 @@
class RioDeprecationWarning(Warning):
class RioDeprecationWarning(DeprecationWarning):
"""
The user used functionality that has been deprecated.
"""

View File

@@ -195,7 +195,7 @@ async def test_binding_assignment_on_child_after_reconciliation():
assert not test_client._dirty_components
# Rebuild the root component, which reconciles the child
await root_component.force_refresh()
await root_component._force_refresh()
text_component.text = "Hello"
@@ -215,7 +215,7 @@ async def test_binding_assignment_on_parent_after_reconciliation():
assert not test_client._dirty_components
# Rebuild the root component, which reconciles the child
await root_component.force_refresh()
await root_component._force_refresh()
root_component.text = "Hello"
@@ -244,7 +244,7 @@ async def test_binding_assignment_on_sibling_after_reconciliation():
assert not test_client._dirty_components
# Rebuild the root component, which reconciles the children
await root_component.force_refresh()
await root_component._force_refresh()
text1.text = "Hello"
@@ -267,7 +267,7 @@ async def test_binding_assignment_on_grandchild_after_reconciliation():
assert not test_client._dirty_components
# Rebuild the root component, which reconciles the child
await root_component.force_refresh()
await root_component._force_refresh()
text_component.text = "Hello"
@@ -290,7 +290,7 @@ async def test_binding_assignment_on_middle_after_reconciliation():
assert not test_client._dirty_components
# Rebuild the root component, which reconciles the child
await root_component.force_refresh()
await root_component._force_refresh()
parent.text = "Hello"

View File

@@ -1,148 +0,0 @@
import json
import re
import subprocess
import sys
import tempfile
import textwrap
import typing as t
from pathlib import Path
import pytest
import rio.docs
CODE_BLOCK_PATTERN = re.compile(r"```(.*?)```", re.DOTALL)
all_documented_objects = list(rio.docs.get_documented_objects())
all_documented_objects.sort(key=lambda obj: obj.__name__)
def ruff(*args: str | Path) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "ruff", *map(str, args)],
# check=True,
capture_output=True,
text=True,
)
def get_code_blocks(obj: type | t.Callable) -> list[str]:
"""
Returns a list of all code blocks in the docstring of a component.
"""
docstring = obj.__doc__
# No docs?
if not docstring:
return []
docstring = textwrap.dedent(docstring)
# Find any contained code blocks
result: list[str] = []
for match in CODE_BLOCK_PATTERN.finditer(docstring):
block: str = match.group(1)
# Split into language and source
linebreak = block.find("\n")
assert linebreak != -1
language = block[:linebreak]
block = block[linebreak + 1 :]
# Make sure a language is specified
assert language, "The code block has no language specified"
result.append(block)
return result
def ruff_format(source_code: str) -> str:
# Write the source code to a temporary file
temp_file_path = Path(tempfile.gettempdir()) / "rio test suite tempfile.py"
temp_file_path.write_text(source_code, encoding="utf8")
# Run ruff to format the source code in the temporary file
ruff("format", temp_file_path)
# Read the formatted source code
return temp_file_path.read_text(encoding="utf8")
def ruff_check(source_code: str) -> list[str]:
"""
Checks the given source code using `ruff`. Returns any encountered problems.
"""
# Dump the source to a file, and implicitly define/import some stuff.
temp_file_path = Path(tempfile.gettempdir()) / "rio test suite tempfile.py"
temp_file_path.write_text(
f"""
import pathlib
import rio
Path = pathlib.Path
self = rio.Spacer()
{source_code}
""",
encoding="utf8",
)
# Run ruff to format the source code in the temporary file
proc = ruff(
"check",
temp_file_path,
# E402 = Import not at top of file
#
# F811 = Redefinition of a symbol. Happens if the source code already
# includes one of our injected imports.
"--ignore=E402,F811",
"--output-format=json",
)
output = json.loads(proc.stdout)
assert isinstance(output, list), output
# Parse the output
result: list[str] = []
for entry in output:
result.append(entry["message"])
return result
@pytest.mark.parametrize("obj", all_documented_objects)
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)
# Ruff often inserts 2 empty lines between definitions, but that's
# really not necessary in docstrings. Collapse them to a single empty
# line.
source = source.replace("\n\n\n", "\n\n")
formatted_source = formatted_source.replace("\n\n\n", "\n\n")
assert formatted_source == source
@pytest.mark.parametrize("obj", all_documented_objects)
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,
):
pytest.xfail()
# Make sure ruff is happy with all code blocks
for source in get_code_blocks(obj):
errors = ruff_check(source)
assert not errors, errors

View File

@@ -1,150 +1,22 @@
import enum
import re
import textwrap
import typing as t
import imy.docstrings
import pytest
from utils.ruff import ruff_check, ruff_format # type: ignore
import rio.docs
def _create_tests() -> None:
for obj, docs in rio.docs.get_documented_objects().items():
if isinstance(docs, imy.docstrings.FunctionDocs):
test_cls = _create_function_tests(docs)
else:
assert isinstance(obj, type)
test_cls = _create_class_tests(obj, docs)
globals()[test_cls.__name__] = test_cls
def _create_function_tests(docs: imy.docstrings.FunctionDocs) -> type:
# If the function is a decorator, there's no need to document that it takes
# a function/class as an argument
if (
docs.metadata.decorator
and len(docs.parameters) == 1
and docs.parameters[0].name == "handler"
):
parameters = []
else:
parameters = docs.parameters
class Tests: # type: ignore
def test_summary(self) -> None:
assert docs.summary is not None, f"{docs.name} has no summary"
def test_details(self) -> None:
assert docs.details is not None, f"{docs.name} has no details"
@pytest.mark.parametrize(
"param",
parameters,
ids=[param.name for param in parameters],
)
def test_param_description(
self, param: imy.docstrings.FunctionParameter
) -> None:
assert (
param.description is not None
), f"{docs.name}.{param.name} has no description"
Tests.__name__ = f"Test_{docs.name}"
return Tests
def _create_class_tests(cls: type, docs: imy.docstrings.ClassDocs) -> type:
methods = docs.functions
methods_excluding_init = [
func for func in methods if func.name != "__init__"
]
# Components only need their constructor documented, attributes don't matter
attributes = [] if issubclass(cls, rio.Component) else docs.attributes
class Tests:
def test_summary(self) -> None:
assert docs.summary is not None, f"{cls.__name__} has no summary"
def test_details(self) -> None:
assert docs.details is not None, f"{cls.__name__} has no details"
def test_attributes_are_all_public(self):
private_attrs = [
attr.name
for attr in docs.attributes
if attr.name.startswith("_")
]
assert not private_attrs, f"{cls.__name__} has attributes that should be private: {private_attrs}"
# Event and Error classes shouldn't be instantiated by the user, so make
# sure their constructor is marked as private
if docs.name.endswith(("Event", "Error")):
def test_constructor_is_private(self):
assert not any(
func_docs.name == "__init__" for func_docs in docs.functions
), f"Constructor of {docs.name} is not marked as private"
@parametrize_with_name("attr", attributes)
def test_attribute_description(
self, attr: imy.docstrings.ClassField
) -> None:
assert (
attr.description is not None
), f"{cls.__name__}.{attr.name} has no description"
# __init__ methods don't need a summary
@parametrize_with_name("method", methods_excluding_init)
def test_method_summary(
self, method: imy.docstrings.FunctionDocs
) -> None:
assert (
method.summary is not None
), f"{cls.__name__}.{method.name} has no summary"
# __init__ methods don't need details
@parametrize_with_name("method", methods_excluding_init)
def test_method_details(
self, method: imy.docstrings.FunctionDocs
) -> None:
assert (
method.details is not None
), f"{cls.__name__}.{method.name} has no details"
@parametrize_with_name("method", methods)
def test_method_parameters_are_all_public(
self, method: imy.docstrings.FunctionDocs
) -> None:
private_params = [
param.name
for param in method.parameters
if param.name.startswith("_")
]
assert not private_params, f"Function {method.name} has parameters that should be private: {private_params}"
@parametrize_with_name("method", methods)
def test_method_parameter_descriptions(
self, method: imy.docstrings.FunctionDocs
) -> None:
params_without_description = [
param.name
for param in method.parameters[1:]
if not param.description
]
assert not params_without_description, f"These parameters have no description: {params_without_description}"
Tests.__name__ = f"Test{docs.name}"
return Tests
def parametrize_with_name(
param_name: str,
docs: t.Iterable[
imy.docstrings.FunctionDocs
| imy.docstrings.ClassDocs
| imy.docstrings.ClassField
| imy.docstrings.FunctionParameter
| imy.docstrings.AttributeDocs
| imy.docstrings.ParameterDocs
],
):
def decorator(func):
@@ -157,4 +29,220 @@ def parametrize_with_name(
return decorator
def _create_tests() -> None:
for docs in rio.docs.get_toplevel_documented_objects().values():
test_cls = _create_tests_for(docs)
globals()[test_cls.__name__] = test_cls
def _create_tests_for(
docs: imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs,
) -> type:
if isinstance(docs, imy.docstrings.FunctionDocs):
cls = _create_function_tests(docs)
else:
cls = _create_class_tests(docs)
cls.__name__ = _get_name_for_test_class(docs)
return cls
def _get_name_for_test_class(
docs: imy.docstrings.FunctionDocs | imy.docstrings.ClassDocs,
) -> str:
return "Test<" + docs.full_name.removeprefix("rio.").replace(".", "_") + ">"
CODE_BLOCK_PATTERN = re.compile(r"```(.*?)```", flags=re.DOTALL)
def _get_code_blocks(docstring: str | None) -> list[str]:
"""
Returns a list of all code blocks in the docstring of a component.
"""
# No docs?
if not docstring:
return []
docstring = textwrap.dedent(docstring)
# Find any contained code blocks
result: list[str] = []
for match in CODE_BLOCK_PATTERN.finditer(docstring):
block: str = match.group(1)
# Split into language and source
linebreak = block.find("\n")
assert linebreak != -1
language = block[:linebreak]
block = block[linebreak + 1 :]
# Make sure a language is specified
assert language, "The code block has no language specified"
if language in ("py", "python"):
result.append(block)
return result
def _create_tests_for_docstring(
docs: imy.docstrings.ClassDocs
| imy.docstrings.FunctionDocs
| imy.docstrings.PropertyDocs,
) -> type:
code_blocks = _get_code_blocks(docs.summary)
code_blocks += _get_code_blocks(docs.details)
code_block_ids = [f"code block {nr}" for nr, _ in enumerate(code_blocks, 1)]
class DocstringTests:
# __init__ methods don't need a summary or details
if docs.name != "__init__":
def test_summary(self) -> None:
assert docs.summary is not None, f"{docs.name} has no summary"
# Some things don't need details:
# - Properties
# - Exceptions
if not (
isinstance(docs, imy.docstrings.PropertyDocs)
or (
isinstance(docs.object, type)
and issubclass(docs.object, BaseException)
)
):
def test_details(self) -> None:
assert (
docs.details is not None
), f"{docs.name} has no details"
@pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
def test_code_block_is_formatted(self, code: str) -> None:
# Make sure all code blocks are formatted according to ruff
formatted_code = ruff_format(code)
# Ruff often inserts 2 empty lines between definitions, but that's
# really not necessary in docstrings. Collapse them to a single
# empty line.
code = code.replace("\n\n\n", "\n\n")
formatted_code = formatted_code.replace("\n\n\n", "\n\n")
assert formatted_code == code
@pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
def test_analyze_code_block(self, code: str) -> 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 objects.
if docs.object in (
rio.Color,
rio.UserSettings,
):
pytest.xfail()
errors = ruff_check(code)
assert not errors, errors
return DocstringTests
def _create_function_tests(docs: imy.docstrings.FunctionDocs) -> type:
# If the function is a decorator, there's no need to document that it takes
# a function/class as an argument
if docs.metadata.decorator and list(docs.parameters) == ["handler"]:
parameters = []
elif docs.has_implicit_first_parameter:
parameters = list(docs.parameters.values())[1:]
else:
parameters = docs.parameters.values()
DocstringTests = _create_tests_for_docstring(docs)
class FunctionTests(DocstringTests):
def test_parameters_are_all_public(self) -> None:
private_params = [
param.name for param in parameters if param.name.startswith("_")
]
assert (
not private_params
), f"These parameters should be private: {private_params}"
def test_parameter_descriptions(self) -> None:
params_without_description = [
param.name for param in parameters if not param.description
]
assert not params_without_description, f"These parameters have no description: {params_without_description}"
return FunctionTests
def _create_property_tests(docs: imy.docstrings.PropertyDocs) -> type:
DocstringTests = _create_tests_for_docstring(docs)
class PropertyTests(DocstringTests):
pass
return PropertyTests
def _create_class_tests(docs: imy.docstrings.ClassDocs) -> type:
# Components only need their constructor documented, attributes don't matter
attributes = (
[]
if issubclass(docs.object, rio.Component)
else docs.attributes.values()
)
DocstringTests = _create_tests_for_docstring(docs)
class ClassTests(DocstringTests):
# Event and Error classes shouldn't be instantiated by the user, so make
# sure their constructor is marked as private
if docs.name.endswith("Event") or issubclass(
docs.object, (BaseException, enum.Enum)
):
def test_constructor_is_private(self):
assert (
"__init__" not in docs.members
), f"Constructor of {docs.name} is not marked as private"
def test_attributes_are_all_public(self):
private_attrs = [
attr.name
for attr in docs.attributes.values()
if attr.name.startswith("_")
]
assert (
not private_attrs
), f"These attributes should be private: {private_attrs}"
@parametrize_with_name("attr", attributes)
def test_attribute_description(
self, attr: imy.docstrings.AttributeDocs
) -> None:
assert (
attr.description is not None
), f"Attribute {attr.name!r} has no description"
# Create tests for all members of this class
for member in docs.members.values():
if isinstance(member, imy.docstrings.FunctionDocs):
test = _create_function_tests(member)
elif isinstance(member, imy.docstrings.PropertyDocs):
test = _create_property_tests(member)
else:
raise Exception(
f"Don't know how to create tests for a {type(member).__name__} object"
)
test.__name__ = f"Test<{member.name}>"
vars()[test.__name__] = test
return ClassTests
_create_tests()

View File

@@ -1,11 +1,11 @@
import typing as t
import pytest
from utils.layouting import cleanup, setup, verify_layout # type: ignore
import rio.data_models
import rio.debug.layouter
import rio.testing
from tests.utils.layouting import cleanup, setup, verify_layout
# pytestmark = pytest.mark.async_timeout(30)

View File

@@ -89,7 +89,7 @@ async def test_reconcile_same_component_instance():
test_client._outgoing_messages.clear()
root_component = test_client.get_component(rio.Container)
await root_component.force_refresh()
await root_component._force_refresh()
# Nothing changed, so there's no need to send any data to JS. But in
# order to know that nothing changed, the framework would have to track
@@ -164,7 +164,7 @@ async def test_reconcile_unusual_types():
root_component = test_client.get_component(Container)
# As long as this doesn't crash, it's fine
await root_component.force_refresh()
await root_component._force_refresh()
async def test_reconcile_by_key():

73
tests/utils/ruff.py Normal file
View File

@@ -0,0 +1,73 @@
import json
import subprocess
import sys
import tempfile
from pathlib import Path
__all__ = ["ruff_check", "ruff_format"]
def ruff_format(source_code: str) -> str:
# Write the source code to a temporary file
temp_file_path = Path(tempfile.gettempdir()) / "rio test suite tempfile.py"
temp_file_path.write_text(source_code, encoding="utf8")
# Run ruff to format the source code in the temporary file
ruff("format", temp_file_path)
# Read the formatted source code
return temp_file_path.read_text(encoding="utf8")
def ruff_check(source_code: str) -> list[str]:
"""
Checks the given source code using `ruff`. Returns any encountered problems.
"""
# Dump the source to a file, and implicitly define/import some stuff.
temp_file_path = Path(tempfile.gettempdir()) / "rio test suite tempfile.py"
temp_file_path.write_text(
f"""
import asyncio
import pathlib
import rio
Path = pathlib.Path
app = rio.App(build=rio.Spacer)
MyAppRoot = rio.Spacer
self = child1 = child2 = rio.Spacer()
{source_code}
""",
encoding="utf8",
)
# Run ruff to format the source code in the temporary file
proc = ruff(
"check",
temp_file_path,
# E402 = Import not at top of file
#
# F401 = Unused import
#
# F811 = Redefinition of a symbol. Happens if the source code already
# includes one of our injected imports.
"--ignore=E402,F401,F811",
"--output-format=json",
)
output = json.loads(proc.stdout)
assert isinstance(output, list), output
# Parse the output
return [entry["message"] for entry in output]
def ruff(*args: str | Path) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "ruff", *map(str, args)],
# check=True,
capture_output=True,
text=True,
)