From 820a0c3ab67dc8503b580b6655dfbfb1e1b19ad5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 9 Jun 2026 14:01:05 +0500 Subject: [PATCH 1/3] feat: render concise, readable types in docgen class documentation Rework reflex-docgen's type/signature rendering so generated class docs read cleanly instead of leaking implementation noise. - Add a public format_type() that strips module qualifiers, preserves type-alias names, and renders unions (Optional[...]), literals, and callables recursively. - Define the ASGI aliases (Scope, Message, Receive, Send, ASGIApp) via TypeAliasType so their short names survive introspection and docs show e.g. ASGIApp rather than a fully expanded MutableMapping/Awaitable blob. - Render method signatures without self/cls, with forward-ref strings kept verbatim (each annotation independently, so one TYPE_CHECKING-only name doesn't poison the rest) and optionality normalized to match format_type. - Clean field/parameter defaults: show function names or empty-collection literals instead of volatile reprs, and exclude function-valued field defaults from the methods table. - Polish App attribute docstrings with doc links and a fuller api_transformer description. --- .../src/reflex_base/utils/types.py | 14 +- .../src/reflex_docgen/__init__.py | 2 + .../reflex-docgen/src/reflex_docgen/_class.py | 290 ++++++++++++++++-- reflex/app.py | 14 +- .../units/docgen/test_class_and_component.py | 271 +++++++++++++++- tests/units/reflex_base/utils/test_types.py | 16 + 6 files changed, 572 insertions(+), 35 deletions(-) create mode 100644 tests/units/reflex_base/utils/test_types.py 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..6f225b754b2 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. """ @@ -96,18 +100,108 @@ def _parse_docstring_attributes(cls: type) -> dict[str, str]: } -def _type_display(type_: Any) -> str: - """Return a human-readable type string. +def _literal_value(value: Any) -> str: + """Return a readable display value for a Literal option. Args: - type_: The type to display. + value: The literal option value. Returns: - A human-readable type string. + The display string for the value. """ - if get_args(type_): - return str(type_) - return getattr(type_, "__name__", str(type_)) + return f'"{value}"' if isinstance(value, str) else repr(value) + + +def _type_name(type_: Any) -> str: + """Return the short, unqualified name for a leaf type. + + Args: + type_: The leaf type to name. + + Returns: + The unqualified type name (e.g. ``Starlette`` for ``starlette.applications.Starlette``). + """ + 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: + return f"Literal[{', '.join(_literal_value(arg) for arg in args)}]" + 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_) + + +_EMPTY_FACTORY_DISPLAY = { + dict: "{}", + list: "[]", + set: "set()", + tuple: "()", + frozenset: "frozenset()", +} + + +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). + """ + if is_factory: + literal = _EMPTY_FACTORY_DISPLAY.get(value) + if literal is not None: + return literal + 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,7 +252,7 @@ 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, ) @@ -181,9 +275,9 @@ def _get_dataclass_fields(cls: type) -> tuple[FieldDocumentation, ...]: 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 @@ -213,9 +307,9 @@ def _get_state_fields(cls: BaseStateMeta) -> tuple[FieldDocumentation, ...]: 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 @@ -255,6 +349,156 @@ 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 _strip_module_qualifiers(annotation: str) -> str: + """Collapse dotted module paths in a forward-ref string to bare names. + + Mirrors what format_type does for resolved types: ``contextlib.AbstractContextManager`` + becomes ``AbstractContextManager``. Names inside quoted Literal values are + preserved. + + Args: + annotation: The annotation string. + + Returns: + The annotation with module qualifiers removed. + """ + return _QUALIFIED_NAME.sub( + lambda match: match.group(1) if match.group(1) is not None else match.group(2), + annotation, + ) + + +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 _optional_from_string(annotation: str) -> str: + """Rewrite a forward-ref union containing ``None`` as Optional[...]. + + Method-parameter annotations referencing a TYPE_CHECKING-only name cannot be + resolved to real types, so they are rendered from the forward-ref string + directly. When the top-level union includes ``None`` in any position, the + remaining members are wrapped in Optional[...] so optionality reads the same + way format_type renders it. ``None`` nested inside a subscript is left alone. + + Args: + annotation: The annotation string. + + Returns: + The annotation, with top-level ``None`` rewritten as Optional[...]. + """ + members = _split_top_level_union(annotation) + if len(members) > 1 and "None" in members: + inner = " | ".join(member for member in members if member != "None") + return f"Optional[{inner}]" + return annotation + + +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, with module qualifiers stripped and + optionality normalized to match format_type's output. + + Args: + annotation: The annotation, either a real type or a forward-ref string. + + Returns: + The rendered annotation string. + """ + if isinstance(annotation, str): + return _optional_from_string(_strip_module_qualifiers(annotation)) + return format_type(annotation) + + +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 +508,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 +541,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, ) ) diff --git a/reflex/app.py b/reflex/app.py index 267b02c3ec1..b7b03f3cce5 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -285,8 +285,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. @@ -295,12 +295,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. + 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..6723dad7981 100644 --- a/tests/units/docgen/test_class_and_component.py +++ b/tests/units/docgen/test_class_and_component.py @@ -3,7 +3,9 @@ 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 +17,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_signature, _optional_from_string +from typing_extensions import TypeAliasType def test_default_triggers_have_descriptions(): @@ -204,11 +209,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 @@ -320,3 +326,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_optional_from_string(annotation, expected): + """Forward-ref unions normalize to Optional[...] wherever None sits, top-level only.""" + assert _optional_from_string(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" From 67e572ab8a020dba6c3325e34dbb2569734c671a Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 9 Jun 2026 14:19:21 +0500 Subject: [PATCH 2/3] refactor: inline single-use docgen type-formatting helpers Fold _literal_value, _EMPTY_FACTORY_DISPLAY, _strip_module_qualifiers, and _optional_from_string into their lone call sites. The string-only forward-ref handling now lives directly in _format_annotation, so the qualifier-stripping and Optional[...] rewrite read top-to-bottom in one place instead of hopping through tiny indirections. No behavior change; tests updated to exercise _format_annotation directly. --- .../reflex-docgen/src/reflex_docgen/_class.py | 93 +++++-------------- .../units/docgen/test_class_and_component.py | 6 +- 2 files changed, 24 insertions(+), 75 deletions(-) diff --git a/packages/reflex-docgen/src/reflex_docgen/_class.py b/packages/reflex-docgen/src/reflex_docgen/_class.py index 6f225b754b2..b22f68c0b05 100644 --- a/packages/reflex-docgen/src/reflex_docgen/_class.py +++ b/packages/reflex-docgen/src/reflex_docgen/_class.py @@ -100,18 +100,6 @@ def _parse_docstring_attributes(cls: type) -> dict[str, str]: } -def _literal_value(value: Any) -> str: - """Return a readable display value for a Literal option. - - Args: - value: The literal option value. - - Returns: - The display string for the value. - """ - return f'"{value}"' if isinstance(value, str) else repr(value) - - def _type_name(type_: Any) -> str: """Return the short, unqualified name for a leaf type. @@ -148,7 +136,8 @@ def format_type(type_: Any) -> str: args = get_args(type_) if origin is Literal: - return f"Literal[{', '.join(_literal_value(arg) for arg in args)}]" + 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) @@ -169,15 +158,6 @@ def format_type(type_: Any) -> str: return _type_name(type_) -_EMPTY_FACTORY_DISPLAY = { - dict: "{}", - list: "[]", - set: "set()", - tuple: "()", - frozenset: "frozenset()", -} - - def _format_default(value: Any, *, is_factory: bool) -> str | None: """Return a stable, readable display for a field default, or None to omit it. @@ -192,10 +172,9 @@ def _format_default(value: Any, *, is_factory: bool) -> str | None: Returns: A display string, or None when the default is opaque (e.g. a lambda). """ - if is_factory: - literal = _EMPTY_FACTORY_DISPLAY.get(value) - if literal is not None: - return literal + # 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): @@ -357,25 +336,6 @@ def _get_class_vars(cls: type) -> tuple[FieldDocumentation, ...]: ) -def _strip_module_qualifiers(annotation: str) -> str: - """Collapse dotted module paths in a forward-ref string to bare names. - - Mirrors what format_type does for resolved types: ``contextlib.AbstractContextManager`` - becomes ``AbstractContextManager``. Names inside quoted Literal values are - preserved. - - Args: - annotation: The annotation string. - - Returns: - The annotation with module qualifiers removed. - """ - return _QUALIFIED_NAME.sub( - lambda match: match.group(1) if match.group(1) is not None else match.group(2), - annotation, - ) - - def _split_top_level_union(annotation: str) -> list[str]: """Split a forward-ref annotation into its top-level ``|`` union members. @@ -403,35 +363,16 @@ def _split_top_level_union(annotation: str) -> list[str]: return members -def _optional_from_string(annotation: str) -> str: - """Rewrite a forward-ref union containing ``None`` as Optional[...]. - - Method-parameter annotations referencing a TYPE_CHECKING-only name cannot be - resolved to real types, so they are rendered from the forward-ref string - directly. When the top-level union includes ``None`` in any position, the - remaining members are wrapped in Optional[...] so optionality reads the same - way format_type renders it. ``None`` nested inside a subscript is left alone. - - Args: - annotation: The annotation string. - - Returns: - The annotation, with top-level ``None`` rewritten as Optional[...]. - """ - members = _split_top_level_union(annotation) - if len(members) > 1 and "None" in members: - inner = " | ".join(member for member in members if member != "None") - return f"Optional[{inner}]" - return annotation - - 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, with module qualifiers stripped and - optionality normalized to match format_type's output. + 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. @@ -439,9 +380,17 @@ def _format_annotation(annotation: Any) -> str: Returns: The rendered annotation string. """ - if isinstance(annotation, str): - return _optional_from_string(_strip_module_qualifiers(annotation)) - return format_type(annotation) + 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: diff --git a/tests/units/docgen/test_class_and_component.py b/tests/units/docgen/test_class_and_component.py index 6723dad7981..967368052a4 100644 --- a/tests/units/docgen/test_class_and_component.py +++ b/tests/units/docgen/test_class_and_component.py @@ -22,7 +22,7 @@ generate_documentation, get_component_event_handlers, ) -from reflex_docgen._class import _format_signature, _optional_from_string +from reflex_docgen._class import _format_annotation, _format_signature from typing_extensions import TypeAliasType @@ -536,9 +536,9 @@ def test_method_signatures_are_readable(): ("SomeAlias | None", "Optional[SomeAlias]"), ], ) -def test_optional_from_string(annotation, expected): +def test_format_annotation_from_string(annotation, expected): """Forward-ref unions normalize to Optional[...] wherever None sits, top-level only.""" - assert _optional_from_string(annotation) == expected + assert _format_annotation(annotation) == expected def test_signature_renders_each_annotation_independently(): From a05ff4d4becdbce8b3ba9106403c451aaa54326f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 17 Jun 2026 20:19:31 +0500 Subject: [PATCH 3/3] fix: stop docgen duplicating Attributes as both prose and fields table The class description was the raw cleandoc'd docstring, so a Google-style Attributes section rendered both as body text and in the fields table, duplicating every attribute on the API reference page. Parse the docstring into griffe sections once and derive the prose description (sans Attributes) and the attribute mapping from the same result. Also drop the deprecated rx.Model from the generated API reference. --- docs/app/agent_files/_plugin.py | 2 +- docs/app/reflex_docs/pages/docs/apiref.py | 2 +- .../reflex-docgen/src/reflex_docgen/_class.py | 75 +++++++++++++++---- .../units/docgen/test_class_and_component.py | 38 +++++++++- 4 files changed, 95 insertions(+), 22 deletions(-) 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-docgen/src/reflex_docgen/_class.py b/packages/reflex-docgen/src/reflex_docgen/_class.py index b22f68c0b05..ae3bba1ac94 100644 --- a/packages/reflex-docgen/src/reflex_docgen/_class.py +++ b/packages/reflex-docgen/src/reflex_docgen/_class.py @@ -76,30 +76,65 @@ 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 _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. @@ -237,17 +272,19 @@ def _build_field_documentation( ) -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) @@ -269,17 +306,19 @@ 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(): @@ -301,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. @@ -315,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)) @@ -513,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/tests/units/docgen/test_class_and_component.py b/tests/units/docgen/test_class_and_component.py index 967368052a4..6353da70bbf 100644 --- a/tests/units/docgen/test_class_and_component.py +++ b/tests/units/docgen/test_class_and_component.py @@ -1,7 +1,6 @@ """Tests for reflex-docgen.""" import dataclasses -import inspect import sys from collections.abc import Callable from importlib.util import find_spec @@ -233,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():