Skip to content
4 changes: 3 additions & 1 deletion packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,9 @@ class Component(BaseComponent, ABC):
_rename_props: ClassVar[dict[str, str]] = {}

custom_attrs: dict[str, Var | Any] = field(
doc="custom attribute", default_factory=dict, is_javascript_property=False
doc="Attributes passed directly to the component.",
default_factory=dict,
is_javascript_property=False,
)

_memoization_mode: MemoizationMode = field(
Expand Down
80 changes: 61 additions & 19 deletions packages/reflex-base/src/reflex_base/utils/lazy_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,18 @@

from __future__ import annotations

import copy
import importlib
import os
import sys
from collections.abc import Mapping, Sequence

SubmodAttrsType = Mapping[str, Sequence[str | tuple[str, str]]]


def attach(
package_name: str,
submodules: set[str] | None = None,
submod_attrs: dict[str, list[str]] | None = None,
submod_attrs: SubmodAttrsType | None = None,
**extra_mappings: str,
):
"""Replaces a package's __getattr__, __dir__, and __all__ attributes using lazy.attach.
Expand All @@ -45,24 +47,21 @@ def attach(
Returns:
__getattr__, __dir__, __all__
"""
submod_attrs = copy.deepcopy(submod_attrs)
if submod_attrs:
for k, v in submod_attrs.items():
# when flattening the list, only keep the alias in the tuple(mod[1])
submod_attrs[k] = [
mod if not isinstance(mod, tuple) else mod[1] for mod in v
]

if submod_attrs is None:
submod_attrs = {}

submodules = set(submodules) if submodules is not None else set()

attr_to_modules = {
attr: mod for mod, attrs in submod_attrs.items() for attr in attrs
alias_to_module_and_attr = {
comp_alias(attr): (mod, comp_name(attr))
for mod, attrs in submod_attrs.items()
for attr in attrs
}

__all__ = sorted([*(submodules | attr_to_modules.keys()), *(extra_mappings or [])])
__all__ = sorted([
*(submodules | alias_to_module_and_attr.keys()),
*(extra_mappings or []),
])

def __getattr__(name: str): # noqa: N807
if name in extra_mappings:
Expand All @@ -74,15 +73,15 @@ def __getattr__(name: str): # noqa: N807
return getattr(submod, attr)
if name in submodules:
return importlib.import_module(f"{package_name}.{name}")
if name in attr_to_modules:
submod_path = f"{package_name}.{attr_to_modules[name]}"
submod = importlib.import_module(submod_path)
attr = getattr(submod, name)
if name in alias_to_module_and_attr:
module, attr_name = alias_to_module_and_attr[name]
submod = importlib.import_module(f"{package_name}.{module}")
attr = getattr(submod, attr_name)

# If the attribute lives in a file (module) with the same
# name as the attribute, ensure that the attribute and *not*
# the module is accessible on the package.
if name == attr_to_modules[name]:
if name == module:
pkg = sys.modules[package_name]
pkg.__dict__[name] = attr

Expand All @@ -94,7 +93,50 @@ def __dir__(): # noqa: N807
return __all__

if os.environ.get("EAGER_IMPORT", ""):
for attr in set(attr_to_modules.keys()) | submodules:
for attr in set(alias_to_module_and_attr.keys()) | submodules:
__getattr__(attr)

return __getattr__, __dir__, list(__all__)


def comp_name(comp: str | tuple[str, str]) -> str:
"""Get the component name from the mapping value.

This is the name used internally in the codebase.

Args:
comp: The component name or a tuple of (component name, alias).

Returns:
The component name.
"""
return comp[0] if isinstance(comp, tuple) else comp


def comp_alias(comp: str | tuple[str, str]) -> str:
"""Get the component alias from the mapping value.

This is the name external users will import.

Args:
comp: The component name or a tuple of (component name, alias).

Returns:
The component alias, or the compoenent name if there is no alias.
"""
return comp[1] if isinstance(comp, tuple) else comp


def comp_path(path: str, comp: str | tuple[str, str]) -> str:
"""Get the component path from the mapping key and value.

This is the internal path that will be imported.

Args:
path: The base path of the component.
comp: The component name or a tuple of (component name, alias).

Returns:
The component path.
"""
return path + "." + comp_name(comp)
65 changes: 52 additions & 13 deletions packages/reflex-base/src/reflex_base/utils/pyi_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import subprocess
import sys
import typing
from collections import deque
from collections.abc import Callable, Iterable, Mapping, Sequence
from concurrent.futures import ProcessPoolExecutor
from functools import cache
Expand All @@ -21,9 +22,9 @@
from itertools import chain
from pathlib import Path
from types import MappingProxyType, ModuleType, SimpleNamespace, UnionType
from typing import Any, get_args, get_origin
from typing import Any, ClassVar, get_args, get_origin

from reflex_base.components.component import Component
from reflex_base.components.component import DEFAULT_TRIGGERS_AND_DESC, Component
from reflex_base.vars.base import Var


Expand Down Expand Up @@ -270,8 +271,21 @@ def _get_type_hint(
_get_type_hint(arg, type_hint_globals, _is_optional(arg)) for arg in value
]
return f"[{', '.join(res)}]"
elif (visible_name := _get_visible_type_name(value, type_hint_globals)) is not None:
res = visible_name
else:
res = value.__name__
# Best effort to find a submodule path in the globals.
for ix, part in enumerate(value.__module__.split(".")):
if part in type_hint_globals:
res = ".".join([
part,
*value.__module__.split(".")[ix + 1 :],
value.__name__,
])
break
else:
# Fallback to the type name.
res = value.__name__
if is_optional and not res.startswith("Optional") and not res.endswith("| None"):
res = f"{res} | None"
return res
Expand Down Expand Up @@ -330,7 +344,7 @@ def _get_class_prop_comments(clz: type[Component]) -> Mapping[str, tuple[str, ..
prop = match.group(0).strip(":")
if comments:
props_comments[prop] = tuple(
comment.strip().strip("#") for comment in comments
comment.strip().lstrip("#").strip() for comment in comments
)
comments.clear()

Expand Down Expand Up @@ -450,6 +464,24 @@ def _generate_imports(
]


def _maybe_default_event_handler_docstring(
prop_name: str, fallback: str = "no description"
) -> tuple[str, ...]:
"""Add a docstring for default event handler prop.

Args:
prop_name: The name of the prop.
fallback: The fallback docstring to use if the prop is not a default event handler and has no description.

Returns:
The event handler description or the fallback if the prop is not a default event handler.
"""
try:
return (DEFAULT_TRIGGERS_AND_DESC[prop_name].description,)
except KeyError:
return (fallback,)


def _generate_docstrings(clzs: list[type[Component]], props: list[str]) -> str:
"""Generate the docstrings for the create method.

Expand All @@ -465,13 +497,17 @@ def _generate_docstrings(clzs: list[type[Component]], props: list[str]) -> str:
for prop, comment_lines in _get_class_prop_comments(clz).items():
if prop in props:
props_comments[prop] = list(comment_lines)
for prop, field in clz._fields.items():
if prop in props and field.doc:
props_comments[prop] = [field.doc]
clz = clzs[0]
new_docstring = []
for line in (clz.create.__doc__ or "").splitlines():
if "**" in line:
indent = line.split("**")[0]
new_docstring.extend([
f"{indent}{n}:{' '.join(c)}" for n, c in props_comments.items()
f"{indent}{prop_name}: {' '.join(props_comments.get(prop_name, _maybe_default_event_handler_docstring(prop_name)))}"
for prop_name in props
])
new_docstring.append(line)
return "\n".join(new_docstring)
Expand Down Expand Up @@ -511,7 +547,7 @@ def _extract_class_props_as_ast_nodes(
clzs: list[type],
type_hint_globals: dict[str, Any],
extract_real_default: bool = False,
) -> list[tuple[ast.arg, ast.Constant | None]]:
) -> Sequence[tuple[ast.arg, ast.Constant | None]]:
"""Get the props defined on the class and all parents.

Args:
Expand All @@ -522,27 +558,30 @@ def _extract_class_props_as_ast_nodes(
pydantic field definition.

Returns:
The list of props as ast arg nodes
The sequence of props as ast arg nodes
"""
spec = _get_full_argspec(func)
func_kwonlyargs = set(spec.kwonlyargs)
all_props: set[str] = set()
kwargs = []
for target_class in clzs:
kwargs = deque()
for target_class in reversed(clzs):
event_triggers = _get_class_event_triggers(target_class)
# Import from the target class to ensure type hints are resolvable.
type_hint_globals.update(_get_module_star_imports(target_class.__module__))
annotation_globals = {
**type_hint_globals,
**_get_class_annotation_globals(target_class),
}
for name, value in target_class.__annotations__.items():
# State attr isn't really a prop and cannot be resolved, so pop it off.
target_class.__annotations__.pop("State", None)
type_hints = typing.get_type_hints(target_class, globalns=annotation_globals)
for name, value in reversed(type_hints.items()):
if (
name in func_kwonlyargs
or name in EXCLUDED_PROPS
or name in all_props
or name in event_triggers
or (isinstance(value, str) and "ClassVar" in value)
or get_origin(value) is ClassVar
):
continue
all_props.add(name)
Expand All @@ -557,7 +596,7 @@ def _extract_class_props_as_ast_nodes(
if isinstance(default, Var):
default = default._decode()

kwargs.append((
kwargs.appendleft((
ast.arg(
arg=name,
annotation=ast.Name(
Expand Down Expand Up @@ -1596,8 +1635,8 @@ def scan_all(

# Fix generated pyi files with ruff.
if file_paths:
subprocess.run(["ruff", "check", "--fix", *file_paths])
subprocess.run(["ruff", "format", *file_paths])
subprocess.run(["ruff", "check", "--fix", *file_paths])

if use_json:
if file_paths and changed_files is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ def create(cls, *children: Component, **props: Any) -> Component:
the child, and then neuter the child's render method so it produces no output.

Args:
children: The child component to wrap.
props: The component props.
*children: The child component to wrap.
**props: The component props.

Returns:
The DebounceInput component.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import dataclasses
from collections.abc import Sequence
from typing import ClassVar

from reflex_base.vars.base import Var, VarData
from reflex_base.vars.function import ArgsFunctionOperation, DestructuredArg
Expand All @@ -17,7 +18,7 @@
class MarkdownComponentMap:
"""Mixin class for handling custom component maps in Markdown components."""

_explicit_return: bool = dataclasses.field(default=False)
_explicit_return: ClassVar[bool] = False

@classmethod
def get_component_map_custom_code(cls) -> Var:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import dataclasses
from datetime import date, datetime, time, timedelta
import datetime

from reflex_base.components.component import NoSSRComponent, field
from reflex_base.event import EventHandler, passthrough_event_spec
Expand Down Expand Up @@ -94,9 +94,9 @@ class Moment(NoSSRComponent):
doc="Shows the duration (elapsed time) between two dates. duration property should be behind date property time-wise."
)

date: Var[str | datetime | date | time | timedelta] = field(
doc="The date to display (also work if passed as children)."
)
date: Var[
str | datetime.datetime | datetime.date | datetime.time | datetime.timedelta
] = field(doc="The date to display (also work if passed as children).")

duration_from_now: Var[bool] = field(
doc="Shows the duration (elapsed time) between now and the provided datetime."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

_SUBMODULES: set[str] = {"themes", "primitives"}

_SUBMOD_ATTRS: dict[str, list[str]] = {
_SUBMOD_ATTRS: lazy_loader.SubmodAttrsType = {
"".join(k.split("reflex_components_radix.")[-1]): v
for k, v in RADIX_MAPPING.items()
}
Expand Down
Loading
Loading