diff --git a/docs/app/agent_files/_plugin.py b/docs/app/agent_files/_plugin.py index c3e3aaba506..1d59d7dc32d 100644 --- a/docs/app/agent_files/_plugin.py +++ b/docs/app/agent_files/_plugin.py @@ -686,7 +686,7 @@ def generate_dynamic_api_reference_files() -> tuple[tuple[Path, str], ...]: rx.event.Event, rx.event.EventHandler, rx.event.EventSpec, - rx.Model, + # rx.Model excluded: deprecated in 0.9.2, removed in 1.0. StateManager, rx.State, ImportVar, diff --git a/docs/app/reflex_docs/pages/docs/apiref.py b/docs/app/reflex_docs/pages/docs/apiref.py index 5b09f7047c6..7c2a85a1748 100644 --- a/docs/app/reflex_docs/pages/docs/apiref.py +++ b/docs/app/reflex_docs/pages/docs/apiref.py @@ -15,7 +15,7 @@ rx.event.Event, rx.event.EventHandler, rx.event.EventSpec, - rx.Model, + # rx.Model excluded: deprecated in 0.9.2, removed in 1.0. # rx.testing.AppHarness, StateManager, # rx.state.BaseState, diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index feeb3b0da50..a0331d332c3 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -35,6 +35,7 @@ from typing import get_type_hints as get_type_hints_og from typing_extensions import Self as Self +from typing_extensions import TypeAliasType from typing_extensions import override as override from reflex_base import constants @@ -143,13 +144,16 @@ def __call__( | _ArgsSpec7 ) -Scope = MutableMapping[str, Any] -Message = MutableMapping[str, Any] +# Defined via TypeAliasType so the alias name survives type introspection +# (e.g. get_type_hints) instead of expanding to its full definition; docs render +# the short name. Reverting to plain assignment would regress that display. +Scope = TypeAliasType("Scope", MutableMapping[str, Any]) +Message = TypeAliasType("Message", MutableMapping[str, Any]) -Receive = Callable[[], Awaitable[Message]] -Send = Callable[[Message], Awaitable[None]] +Receive = TypeAliasType("Receive", Callable[[], Awaitable[Message]]) +Send = TypeAliasType("Send", Callable[[Message], Awaitable[None]]) -ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] +ASGIApp = TypeAliasType("ASGIApp", Callable[[Scope, Receive, Send], Awaitable[None]]) PrimitiveToAnnotation = { list: List, # noqa: UP006 diff --git a/packages/reflex-docgen/src/reflex_docgen/__init__.py b/packages/reflex-docgen/src/reflex_docgen/__init__.py index bd22e88d501..bd48152ab9b 100644 --- a/packages/reflex-docgen/src/reflex_docgen/__init__.py +++ b/packages/reflex-docgen/src/reflex_docgen/__init__.py @@ -4,6 +4,7 @@ from reflex_docgen._class import ClassDocumentation as ClassDocumentation from reflex_docgen._class import FieldDocumentation as FieldDocumentation from reflex_docgen._class import MethodDocumentation as MethodDocumentation +from reflex_docgen._class import format_type as format_type from reflex_docgen._class import ( generate_class_documentation as generate_class_documentation, ) @@ -25,6 +26,7 @@ "FieldDocumentation", "MethodDocumentation", "PropDocumentation", + "format_type", "generate_class_documentation", "generate_documentation", "get_component_event_handlers", diff --git a/packages/reflex-docgen/src/reflex_docgen/_class.py b/packages/reflex-docgen/src/reflex_docgen/_class.py index 27b7ac5dc42..ae3bba1ac94 100644 --- a/packages/reflex-docgen/src/reflex_docgen/_class.py +++ b/packages/reflex-docgen/src/reflex_docgen/_class.py @@ -1,11 +1,15 @@ """Generate documentation for arbitrary Python classes.""" +import collections.abc import dataclasses import inspect +import re from dataclasses import dataclass -from typing import Any, get_args, get_type_hints +from typing import Any, Literal, get_args, get_origin, get_type_hints +from reflex_base.utils.types import is_union from reflex_base.vars.base import BaseStateMeta +from typing_extensions import TypeAliasType from typing_inspection.introspection import AnnotationSource, inspect_annotation @@ -16,7 +20,7 @@ class FieldDocumentation: Attributes: name: The name of the field. type: The resolved type of the field. - type_display: Human-readable type string (no Var wrapper). Uses __name__ for simple types, str() for generics. + type_display: Concise human-readable type string with module qualifiers stripped and type-alias names preserved. description: The description extracted from the class docstring or field.doc. default: The repr() of the default value, or None if no default. """ @@ -72,42 +76,146 @@ class ClassDocumentation: methods: tuple[MethodDocumentation, ...] = () -def _parse_docstring_attributes(cls: type) -> dict[str, str]: - """Parse an Attributes section from a class docstring using griffe. +def _parse_docstring_sections(cls: type) -> list[Any]: + """Parse a class docstring into griffe sections. + + Parsing once lets the description and the Attributes mapping be derived from the + same result instead of re-parsing the docstring for each. Args: cls: The class whose docstring to parse. Returns: - A mapping from attribute name to description string. + The parsed docstring sections, or an empty list when there is no docstring. """ from griffe import Docstring, Parser # provided by griffelib doc = cls.__doc__ if not doc: - return {} + return [] + + return Docstring(inspect.cleandoc(doc)).parse(Parser.auto) + + +def _attributes_from_sections(sections: list[Any]) -> dict[str, str]: + """Extract the Attributes section as a name-to-description mapping. - parsed = Docstring(inspect.cleandoc(doc)).parse(Parser.auto) + Args: + sections: The parsed docstring sections. + + Returns: + A mapping from attribute name to description string. + """ return { attr.name: attr.description - for section in parsed + for section in sections if section.kind.value == "attributes" for attr in section.value } -def _type_display(type_: Any) -> str: - """Return a human-readable type string. +def _description_from_sections(sections: list[Any]) -> str | None: + """Join the prose body of a docstring, excluding the Attributes section. + + The Attributes section is rendered separately as a fields table, so keeping it in + the description would duplicate every attribute as body text. Griffe splits the + docstring into sections; the free-text sections (summary, detail, code blocks) are + the prose body. Documented classes use Google-style ``Attributes:`` plus inline + fenced code (kept as text), so no other section kind appears in practice. + + Args: + sections: The parsed docstring sections. + + Returns: + The prose description, or None if there is no prose. + """ + text = "\n\n".join( + section.value for section in sections if section.kind.value == "text" + ) + return text or None + + +def _type_name(type_: Any) -> str: + """Return the short, unqualified name for a leaf type. Args: - type_: The type to display. + type_: The leaf type to name. Returns: - A human-readable type string. + The unqualified type name (e.g. ``Starlette`` for ``starlette.applications.Starlette``). """ - if get_args(type_): - return str(type_) - return getattr(type_, "__name__", str(type_)) + name = getattr(type_, "__name__", None) + if name: + return name + return str(type_).rsplit(".", maxsplit=1)[-1] + + +def format_type(type_: Any) -> str: + """Return a concise, human-readable string for a type annotation. + + Strips module qualifiers, preserves type-alias names, and renders unions, + literals, callables, and other generics recursively. + + Args: + type_: The type annotation to format. + + Returns: + A concise, human-readable type string. + """ + if type_ is None or type_ is type(None): + return "None" + if isinstance(type_, TypeAliasType): + return type_.__name__ + + origin = get_origin(type_) + args = get_args(type_) + + if origin is Literal: + values = [f'"{arg}"' if isinstance(arg, str) else repr(arg) for arg in args] + return f"Literal[{', '.join(values)}]" + if is_union(type_): + members = [arg for arg in args if arg is not type(None)] + rendered = " | ".join(format_type(arg) for arg in members) + # Surface optionality explicitly: ``X | None`` reads as ``Optional[X]``. + return f"Optional[{rendered}]" if len(members) != len(args) else rendered + if origin is collections.abc.Callable: + *param_part, return_type = args + params = param_part[0] if param_part else [] + if params is Ellipsis: + inner = "..." + elif isinstance(params, list): + inner = f"[{', '.join(format_type(param) for param in params)}]" + else: + inner = format_type(params) + return f"Callable[{inner}, {format_type(return_type)}]" + if origin is not None and args: + return f"{_type_name(origin)}[{', '.join(format_type(arg) for arg in args)}]" + return _type_name(type_) + + +def _format_default(value: Any, *, is_factory: bool) -> str | None: + """Return a stable, readable display for a field default, or None to omit it. + + A raw repr of a function/lambda/factory default carries a volatile memory + address (e.g. ````); render a clean name or an + empty-collection literal instead so the output is readable and deterministic. + + Args: + value: The default value, or the default_factory when is_factory is True. + is_factory: Whether value is a dataclass/pydantic default_factory. + + Returns: + A display string, or None when the default is opaque (e.g. a lambda). + """ + # An empty-collection factory renders as its literal (``list`` -> ``[]``). + if is_factory and value in (dict, list, set, tuple, frozenset): + return repr(value()) + if isinstance(value, type): + return value.__name__ + if callable(value): + name = getattr(value, "__qualname__", "") or getattr(value, "__name__", "") + return None if not name or "<" in name else name + return repr(value) def _extract_field_doc(hint: Any, field_doc: str | None) -> tuple[Any, str | None]: @@ -158,32 +266,34 @@ def _build_field_documentation( return FieldDocumentation( name=name, type=unwrapped_type, - type_display=_type_display(unwrapped_type), + type_display=format_type(unwrapped_type), description=description, default=default_value, ) -def _get_dataclass_fields(cls: type) -> tuple[FieldDocumentation, ...]: +def _get_dataclass_fields( + cls: type, docstring_attrs: dict[str, str] +) -> tuple[FieldDocumentation, ...]: """Extract fields from a dataclass. Args: cls: The dataclass to extract fields from. + docstring_attrs: Attribute descriptions from the class docstring. Returns: A tuple of FieldDocumentation objects. """ hints = get_type_hints(cls, include_extras=True) - docstring_attrs = _parse_docstring_attributes(cls) result = [] for f in dataclasses.fields(cls): hint = hints.get(f.name, f.type) field_doc = getattr(f, "doc", None) if f.default is not dataclasses.MISSING: - default_str = repr(f.default) + default_str = _format_default(f.default, is_factory=False) elif f.default_factory is not dataclasses.MISSING: - default_str = repr(f.default_factory) + default_str = _format_default(f.default_factory, is_factory=True) else: default_str = None @@ -196,26 +306,28 @@ def _get_dataclass_fields(cls: type) -> tuple[FieldDocumentation, ...]: return tuple(result) -def _get_state_fields(cls: BaseStateMeta) -> tuple[FieldDocumentation, ...]: +def _get_state_fields( + cls: BaseStateMeta, docstring_attrs: dict[str, str] +) -> tuple[FieldDocumentation, ...]: """Extract instance fields from an rx.State subclass via __fields__. Args: cls: The class to extract fields from. + docstring_attrs: Attribute descriptions from the class docstring. Returns: A tuple of FieldDocumentation objects. """ hints = get_type_hints(cls, include_extras=True) - docstring_attrs = _parse_docstring_attributes(cls) fields_dict = cls.__fields__ result = [] for name, field in fields_dict.items(): hint = hints.get(name, field.outer_type_) if field.default is not dataclasses.MISSING: - default_str = repr(field.default) + default_str = _format_default(field.default, is_factory=False) elif field.default_factory is not None: - default_str = repr(field.default_factory) + default_str = _format_default(field.default_factory, is_factory=True) else: default_str = None @@ -228,11 +340,14 @@ def _get_state_fields(cls: BaseStateMeta) -> tuple[FieldDocumentation, ...]: return tuple(result) -def _get_class_vars(cls: type) -> tuple[FieldDocumentation, ...]: +def _get_class_vars( + cls: type, docstring_attrs: dict[str, str] +) -> tuple[FieldDocumentation, ...]: """Extract class variables from __class_vars__. Args: cls: The class to extract class variables from. + docstring_attrs: Attribute descriptions from the class docstring. Returns: A tuple of FieldDocumentation objects. @@ -242,7 +357,6 @@ def _get_class_vars(cls: type) -> tuple[FieldDocumentation, ...]: return () hints = get_type_hints(cls, include_extras=True) - docstring_attrs = _parse_docstring_attributes(cls) result = [] for name in class_vars: hint = hints.get(name, type(None)) @@ -255,6 +369,126 @@ def _get_class_vars(cls: type) -> tuple[FieldDocumentation, ...]: return tuple(result) +# Matches a quoted span (group 1) or a dotted module path (group 2 captures the +# final name). Quoted spans are matched first so a dotted value inside a Literal +# (e.g. ``Literal["a.b"]``) is left untouched. +_QUALIFIED_NAME = re.compile( + r"""("[^"]*"|'[^']*')|\b(?:[A-Za-z_]\w*\.)+([A-Za-z_]\w*)""" +) + + +def _split_top_level_union(annotation: str) -> list[str]: + """Split a forward-ref annotation into its top-level ``|`` union members. + + Splits only at ``|`` outside any brackets, so a union nested inside a + subscript (e.g. the ``int | None`` in ``dict[str, int | None]``) stays intact. + + Args: + annotation: The annotation string. + + Returns: + The top-level union members, each stripped of surrounding whitespace. + """ + members: list[str] = [] + depth = 0 + start = 0 + for i, char in enumerate(annotation): + if char in "[(": + depth += 1 + elif char in ")]": + depth -= 1 + elif char == "|" and depth == 0: + members.append(annotation[start:i].strip()) + start = i + 1 + members.append(annotation[start:].strip()) + return members + + +def _format_annotation(annotation: Any) -> str: + """Render a parameter or return annotation as a concise type string. + + Real type objects go through format_type. Forward-ref strings (from + ``from __future__ import annotations``) keep the author's readable names + rather than expanding aliases: module qualifiers are stripped (matching + format_type) and a top-level ``None`` is rewritten as Optional[...]. This is + done on the string, not by resolving it, so a TYPE_CHECKING-only name in one + member does not poison the rest. ``None`` nested inside a subscript is left + alone, and dotted values inside quoted Literals are preserved. + + Args: + annotation: The annotation, either a real type or a forward-ref string. + + Returns: + The rendered annotation string. + """ + if not isinstance(annotation, str): + return format_type(annotation) + + stripped = _QUALIFIED_NAME.sub( + lambda m: m.group(1) if m.group(1) is not None else m.group(2), annotation + ) + members = _split_top_level_union(stripped) + if len(members) > 1 and "None" in members: + inner = " | ".join(member for member in members if member != "None") + return f"Optional[{inner}]" + return stripped + + +def _format_signature(fn: Any) -> str: + """Return a readable call signature for a function or method. + + Drops self/cls, renders each annotation independently (real types through + format_type, forward-ref strings kept verbatim with optionality normalized), + and cleans default values. Annotations are taken as written rather than + bulk-resolved, so one TYPE_CHECKING-only name does not affect the others. + + Args: + fn: The function or method to render. + + Returns: + A signature string such as ``(route: Optional[str] = None) -> None``. + """ + try: + sig = inspect.signature(fn) + except (ValueError, TypeError): + return "(...)" + + empty = inspect.Parameter.empty + parts: list[str] = [] + keyword_separated = False + for name, param in sig.parameters.items(): + if name in ("self", "cls"): + continue + + if param.kind is inspect.Parameter.VAR_POSITIONAL: + prefix, keyword_separated = "*", True + elif param.kind is inspect.Parameter.VAR_KEYWORD: + prefix = "**" + elif param.kind is inspect.Parameter.KEYWORD_ONLY and not keyword_separated: + parts.append("*") + keyword_separated, prefix = True, "" + else: + prefix = "" + + text = prefix + name + + if param.annotation is not empty: + text += f": {_format_annotation(param.annotation)}" + + if param.default is not empty: + default = _format_default(param.default, is_factory=False) + text += f" = {default if default is not None else '...'}" + + parts.append(text) + + rendered = f"({', '.join(parts)})" + + if sig.return_annotation is not inspect.Signature.empty: + rendered += f" -> {_format_annotation(sig.return_annotation)}" + + return rendered + + def _get_methods(cls: type) -> tuple[MethodDocumentation, ...]: """Extract public documented methods from a class. @@ -264,9 +498,18 @@ def _get_methods(cls: type) -> tuple[MethodDocumentation, ...]: Returns: A tuple of MethodDocumentation objects. """ + # Dataclass/state fields whose default is a function live in __dict__ as that + # function; they are fields, not methods, so exclude them. + if dataclasses.is_dataclass(cls): + field_names = {f.name for f in dataclasses.fields(cls)} + elif isinstance(cls, BaseStateMeta): + field_names = set(cls.__fields__) + else: + field_names = set() + result = [] for name, obj in cls.__dict__.items(): - if name.startswith("_") or name == "Config": + if name.startswith("_") or name == "Config" or name in field_names: continue fn = obj @@ -288,15 +531,10 @@ def _get_methods(cls: type) -> tuple[MethodDocumentation, ...]: break docstring = docstring.strip() or None - try: - sig = str(inspect.signature(fn)) - except (ValueError, TypeError): - sig = "(...)" - result.append( MethodDocumentation( name=name, - signature=sig, + signature=_format_signature(fn), description=docstring, ) ) @@ -316,16 +554,18 @@ def generate_class_documentation(cls: type) -> ClassDocumentation: The generated documentation for the class. """ try: - description = inspect.cleandoc(cls.__doc__) if cls.__doc__ else None + sections = _parse_docstring_sections(cls) + description = _description_from_sections(sections) + docstring_attrs = _attributes_from_sections(sections) if dataclasses.is_dataclass(cls): - fields = _get_dataclass_fields(cls) + fields = _get_dataclass_fields(cls, docstring_attrs) elif isinstance(cls, BaseStateMeta): - fields = _get_state_fields(cls) + fields = _get_state_fields(cls, docstring_attrs) else: fields = () - class_fields = _get_class_vars(cls) + class_fields = _get_class_vars(cls, docstring_attrs) methods = _get_methods(cls) return ClassDocumentation( diff --git a/reflex/app.py b/reflex/app.py index 9c0d2af8db0..5116bd35e04 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -326,8 +326,8 @@ class App(MiddlewareMixin, LifespanMixin): ``` Attributes: - theme: Deprecated legacy shortcut for configuring the app-level Radix theme. - style: The [global style](https://reflex.dev/docs/styling/overview/#global-styles}) for the app. + theme: Deprecated legacy shortcut for configuring the app-level Radix [theme](https://reflex.dev/docs/styling/theming/). + style: The [global style](https://reflex.dev/docs/styling/overview/#global-styles) for the app. stylesheets: A list of URLs to [stylesheets](https://reflex.dev/docs/styling/custom-stylesheets/) to include in the app. reset_style: Whether to include CSS reset for margin and padding. Defaults to True. app_wraps: App wraps to be applied to the whole app. Expected to be a dictionary of (order, name) to a function that takes whether the state is enabled and optionally returns a component. @@ -336,13 +336,12 @@ class App(MiddlewareMixin, LifespanMixin): sio: The Socket.IO AsyncServer instance. html_lang: The language to add to the html root tag of every page. html_custom_attrs: Attributes to add to the html root tag of every page. - enable_state: Whether to enable state for the app. If False, the app will not use state. + enable_state: Whether to enable [state](https://reflex.dev/docs/state/overview/) for the app. If False, the app will not use state. admin_dash: Admin dashboard to view and manage the database. - frontend_exception_handler: Frontend error handler function. - backend_exception_handler: Backend error handler function. - toaster: Put the toast provider in the app wrap. - api_transformer: Transform the ASGI app before running it. - hydrate_fallback: Component to render while the page is hydrating (React Router's HydrateFallback). Takes precedence over the hydrate_fallback config (REFLEX_HYDRATE_FALLBACK). + frontend_exception_handler: Frontend [error handler](https://reflex.dev/docs/utility-methods/exception-handlers/) function. + backend_exception_handler: Backend [error handler](https://reflex.dev/docs/utility-methods/exception-handlers/) function. + toaster: Put the [toast](https://reflex.dev/docs/library/overlay/toast/) provider in the app wrap. + api_transformer: One or more transforms applied to the backend ASGI app before it runs — mount a FastAPI/Starlette app or wrap it in ASGI middleware. See the [API Transformer docs](https://reflex.dev/docs/api-routes/overview/) for examples. """ theme: Component | None = dataclasses.field(default=None) diff --git a/tests/units/docgen/test_class_and_component.py b/tests/units/docgen/test_class_and_component.py index 215f6875793..6353da70bbf 100644 --- a/tests/units/docgen/test_class_and_component.py +++ b/tests/units/docgen/test_class_and_component.py @@ -1,9 +1,10 @@ """Tests for reflex-docgen.""" import dataclasses -import inspect import sys +from collections.abc import Callable from importlib.util import find_spec +from typing import Any, Literal import pytest from reflex_base.components.component import ( @@ -15,10 +16,13 @@ from reflex_base.constants import EventTriggers from reflex_base.event import EventHandler, no_args_event_spec from reflex_docgen import ( + format_type, generate_class_documentation, generate_documentation, get_component_event_handlers, ) +from reflex_docgen._class import _format_annotation, _format_signature +from typing_extensions import TypeAliasType def test_default_triggers_have_descriptions(): @@ -204,11 +208,12 @@ def test_dataclass_methods(): doc = generate_class_documentation(_SampleDataclass) methods_by_name = {m.name: m for m in doc.methods} - # public_method + # public_method: self is dropped from the rendered signature assert "public_method" in methods_by_name pub = methods_by_name["public_method"] assert pub.description == "Do something useful." - assert "self" in pub.signature + assert "self" not in pub.signature + assert pub.signature == "() -> None" # class_method assert "class_method" in methods_by_name @@ -227,11 +232,42 @@ def test_dataclass_methods(): def test_dataclass_class_name_and_description(): - """Name matches module.qualname and description matches cleandoc.""" + """Name matches module.qualname; description is the prose body sans Attributes section.""" doc = generate_class_documentation(_SampleDataclass) assert doc.name == f"{_SampleDataclass.__module__}.{_SampleDataclass.__qualname__}" - assert _SampleDataclass.__doc__ is not None - assert doc.description == inspect.cleandoc(_SampleDataclass.__doc__) + # The Attributes section is rendered as a fields table, so it is stripped from the + # prose description to avoid duplicating every attribute as body text. + assert doc.description == "A sample dataclass for testing." + + +def test_class_description_excludes_attributes_section(): + """The class description omits the Attributes section, which renders as a fields table. + + Regression: the Attributes block was rendered both as body text and in the fields + table, duplicating every attribute description on the API reference page. + """ + doc = generate_class_documentation(_SampleDataclass) + assert doc.description is not None + assert "Attributes:" not in doc.description + assert "The name of the item." not in doc.description + assert "A list of items." not in doc.description + + +def test_class_description_keeps_prose_and_code_blocks(): + """Stripping the Attributes section preserves the prose body but not the attribute text.""" + import reflex as rx + + doc = generate_class_documentation(rx.App) + assert doc.description is not None + # A fenced code example from the docstring body is retained. + assert "```python" in doc.description + assert "Attributes:" not in doc.description + # An attribute shown in the fields table must not be duplicated in the prose. Pull + # the description from the parsed field so the check can't pass vacuously if the + # attribute is renamed or reworded (next() raises instead). + sio = next(f for f in doc.fields if f.name == "sio") + assert sio.description + assert sio.description not in doc.description def test_string_annotations_resolve(): @@ -320,3 +356,264 @@ class _DataclassWithFieldDoc: doc = generate_class_documentation(_DataclassWithFieldDoc) fields_by_name = {f.name: f for f in doc.fields} assert fields_by_name["name"].description == "From field.doc" + + +_SampleAlias = TypeAliasType("_SampleAlias", Callable[[int], int]) + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (str, "str"), + (None, "None"), + (type(None), "None"), + (int | None, "Optional[int]"), + (int | str, "int | str"), + (int | str | None, "Optional[int | str]"), + (list[str], "list[str]"), + (dict[str, Any], "dict[str, Any]"), + (dict[str, int | None], "dict[str, Optional[int]]"), + (Literal["a", "b"], 'Literal["a", "b"]'), + (Literal[1, True], "Literal[1, True]"), + (Callable[[int, str], bool], "Callable[[int, str], bool]"), + (Callable[..., None], "Callable[..., None]"), + (Callable[[], int] | None, "Optional[Callable[[], int]]"), + (_SampleAlias, "_SampleAlias"), + (list[_SampleAlias], "list[_SampleAlias]"), + ], +) +def test_format_type(type_, expected): + """format_type renders concise, unqualified type strings.""" + assert format_type(type_) == expected + + +def test_format_type_strips_module_qualifiers(): + """Module-qualified classes render with their bare name only, with optionality explicit.""" + from reflex_base.components.component import Component + + assert format_type(Component | None) == "Optional[Component]" + + +def test_format_type_callable_with_paramspec(): + """A Callable parameterized by a ParamSpec renders without crashing.""" + from typing import ParamSpec + + P = ParamSpec("P") + # Subscript dynamically: a bare ``Callable[P, int]`` has no static meaning + # with an unbound ParamSpec, but format_type must still render it at runtime. + callable_ctor: Any = Callable + assert format_type(callable_ctor[P, int]) == "Callable[P, int]" + + +def test_api_transformer_type_display_is_readable(): + """The App.api_transformer field renders via its ASGIApp alias, not a fully expanded blob.""" + import reflex as rx + + doc = generate_class_documentation(rx.App) + api_transformer = next(f for f in doc.fields if f.name == "api_transformer") + + display = api_transformer.type_display + assert display.startswith("Optional[") + assert "ASGIApp" in display + assert "Starlette" in display + for noise in ("collections.abc", "typing.", "MutableMapping", "Awaitable"): + assert noise not in display, f"{noise!r} leaked into {display!r}" + + +def _sample_default_handler() -> None: + """A module-level callable used as a default value in tests.""" + + +def _sample_list_factory() -> list: + """A module-level named factory used as a default_factory in tests. + + Returns: + An empty list. + """ + return [] + + +@dataclasses.dataclass +class _DataclassWithCallableDefaults: + """A dataclass whose defaults are callables and factories. + + Attributes: + handler: A function default. + items: A list factory default. + data: A dict factory default. + built: A named-function factory default. + made: An opaque (lambda) factory default. + """ + + handler: Callable[[], None] = _sample_default_handler + items: list = dataclasses.field(default_factory=list) + data: dict = dataclasses.field(default_factory=dict) + built: list = dataclasses.field(default_factory=_sample_list_factory) + made: object = dataclasses.field(default_factory=lambda: object()) + + +def test_callable_and_factory_defaults_are_clean(): + """Callable/factory defaults render as names or empty literals, never volatile reprs.""" + doc = generate_class_documentation(_DataclassWithCallableDefaults) + defaults = {f.name: f.default for f in doc.fields} + + assert defaults["handler"] == "_sample_default_handler" + assert defaults["items"] == "[]" + assert defaults["data"] == "{}" + # A named factory shows its name so the field reads as having a default. + assert defaults["built"] == "_sample_list_factory" + # Opaque factories (lambdas) are omitted rather than shown as . + assert defaults["made"] is None + + +def test_app_field_defaults_have_no_memory_addresses(): + """No App field default leaks a function object repr with a memory address.""" + import reflex as rx + + doc = generate_class_documentation(rx.App) + for f in doc.fields: + if f.default is None: + continue + assert "0x" not in f.default, f"{f.name} default has an address: {f.default!r}" + assert " None: + """An actual method that should appear in the methods list.""" + + +def test_callable_field_default_not_listed_as_method(): + """A field whose default is a function is a field, not a method.""" + doc = generate_class_documentation(_DataclassWithDocumentedCallableField) + field_names = {f.name for f in doc.fields} + method_names = {m.name for m in doc.methods} + + assert "handler" in field_names + assert "handler" not in method_names + assert "real_method" in method_names + + +def test_app_methods_exclude_fields(): + """App field-defaults that are functions don't leak into the methods table.""" + import dataclasses as dc + + import reflex as rx + + doc = generate_class_documentation(rx.App) + field_names = {f.name for f in dc.fields(rx.App)} + method_names = {m.name for m in doc.methods} + + assert not (method_names & field_names), ( + f"fields leaked into methods: {sorted(method_names & field_names)}" + ) + # The real methods are still present. + assert {"add_page", "modify_state"} <= method_names + + +def test_method_signatures_are_readable(): + """Method signatures drop self, unquote annotations, and keep clean defaults.""" + import reflex as rx + + methods = { + m.name: m.signature for m in generate_class_documentation(rx.App).methods + } + + add_page = methods["add_page"] + assert "self" not in add_page + # Forward-ref annotation strings are unquoted and optionality is explicit... + assert "route: Optional[str] = None" in add_page + assert "'str | None'" not in add_page + assert "component: Optional[Component | ComponentCallable] = None" in add_page + # Bracketed types wrap correctly, and params without None are left untouched. + assert "on_load: Optional[EventType[()]] = None" in add_page + assert "meta: Sequence[Mapping[str, Any] | Component] = []" in add_page + # ...while genuine string-literal defaults keep their quotes. + assert "image: str = 'favicon.ico'" in add_page + + +@pytest.mark.parametrize( + ("annotation", "expected"), + [ + ("str | None", "Optional[str]"), + ("None | str", "Optional[str]"), + ("int | None | str", "Optional[int | str]"), + ("str", "str"), + ("int | str", "int | str"), + # None nested inside a subscript is not top-level optionality. + ("Callable[[int], None]", "Callable[[int], None]"), + ("dict[str, int | None]", "dict[str, int | None]"), + ("EventType[()] | None", "Optional[EventType[()]]"), + # An unresolvable forward-ref name is preserved verbatim. + ("SomeAlias | None", "Optional[SomeAlias]"), + ], +) +def test_format_annotation_from_string(annotation, expected): + """Forward-ref unions normalize to Optional[...] wherever None sits, top-level only.""" + assert _format_annotation(annotation) == expected + + +def test_signature_renders_each_annotation_independently(): + """One unresolvable forward-ref annotation doesn't poison the rest of the signature.""" + + def fn(resolvable, unresolvable, plain): ... + + # Forward-ref strings (as produced by ``from __future__ import annotations``); + # ``Missing`` is a TYPE_CHECKING-only name that cannot be resolved. + fn.__annotations__ = { + "resolvable": "int | None", + "unresolvable": "str | Missing | None", + "plain": "list[str]", + "return": "Missing | None", + } + + assert _format_signature(fn) == ( + "(resolvable: Optional[int], " + "unresolvable: Optional[str | Missing], " + "plain: list[str]) -> Optional[Missing]" + ) + + +def test_signature_strips_module_qualifiers_from_forward_refs(): + """Forward-ref annotations get module qualifiers stripped, like resolved types do.""" + + def fn(a, b): ... + + fn.__annotations__ = { + "a": "contextlib.AbstractContextManager | None", + "b": "Sequence[collections.abc.Mapping]", + "return": "contextlib.AbstractContextManager", + } + + assert _format_signature(fn) == ( + "(a: Optional[AbstractContextManager], b: Sequence[Mapping])" + " -> AbstractContextManager" + ) + + +def test_signature_qualifier_stripping_is_quote_safe(): + """A dotted value inside a Literal string is not mistaken for a module path.""" + + def fn(mode): ... + + fn.__annotations__ = {"mode": 'Literal["a.b.c"] | None'} + + assert _format_signature(fn) == '(mode: Optional[Literal["a.b.c"]])' diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py new file mode 100644 index 00000000000..e4ab9dece45 --- /dev/null +++ b/tests/units/reflex_base/utils/test_types.py @@ -0,0 +1,16 @@ +"""Tests for reflex_base.utils.types.""" + +from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send +from typing_extensions import TypeAliasType + + +def test_asgi_aliases_keep_their_names(): + """The ASGI type aliases are TypeAliasTypes so docs render them by name, not expanded.""" + for alias in (Scope, Message, Receive, Send, ASGIApp): + assert isinstance(alias, TypeAliasType) + + assert Scope.__name__ == "Scope" + assert Message.__name__ == "Message" + assert Receive.__name__ == "Receive" + assert Send.__name__ == "Send" + assert ASGIApp.__name__ == "ASGIApp"