mirror of
https://github.com/rio-labs/rio.git
synced 2025-12-30 01:39:42 -06:00
rework docs infrastructure
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__}"
|
||||
|
||||
177
rio/docs.py
177
rio/docs.py
@@ -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(
|
||||
|
||||
@@ -45,4 +45,7 @@ class ClipboardError(Exception):
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
"""
|
||||
Returns the error message as a string.
|
||||
"""
|
||||
return self.args[0]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class RioDeprecationWarning(Warning):
|
||||
class RioDeprecationWarning(DeprecationWarning):
|
||||
"""
|
||||
The user used functionality that has been deprecated.
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
73
tests/utils/ruff.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user