reflex/scripts/pyi_generator.py
Masen Furer 67606561d3
[REF-668] Wrap MyApp with radix Theme component (#1867)
* partly add some radix-ui/themes based components

* add @radix-ui/themes integration to top-level app

* WiP: compile _app_wrap based on which component library is used

TODO: working color mode

* WiP get color mode working with agnostic provider

still not perfect, as the RadixColorModeProvider seems to trip hydration errors
when using color_mode_cond component, but for now, this provides a nice balance
between the two libraries and allows them to interoperate.

* WiP template _app.js instead of making a separate wrap file

* WiP: use next-themes for consistent darkmode switching

* strict pin chakra deps

* Move ChakraColorModeProvider to separate js file

* move nasty radix themes js code into js files

* remove chakra from default imports

* chakra fixup import to use .js extension

* Add radix theme typography and layout components

* do NOT special case the radix theme...

avoid templating json and applying it, avoid non-customizable logic

just add the radix Theme component as an app wrap if the user specifies it to
rx.App, and any other app-wrap theme-like component could _also_ be used
without having to change the code.

this also allows different themes for different sections of the app by simply
placing elements inside a different rdxt.theme wrapper.

* Theme uses "radius" not "borderRadius"

* move next-themes to main packages.json

this is always used, regardless of the component library

* test_app: test cases for app_wrap interface

* Finish wrapping Button, Switch, and TextField components

* docstring, comments, static fixups

* debounce: use alias or tag when passing child Element

Fix REF-830

* test_app: ruin my beautiful indentation

* py38 compatibility

* Add event triggers for switch and TextField

* Add type hints for radix theme components

* radix themes fixups from writing the tests

* Add integration test for radix themes components

* test_app: mock out package installation

we only need the compile result, we're not actually trying to install packages

* avoid incompatible version of @emotion/react

* test_radix_themes: include theme_panel component

* next-themes default scheme: "light"

until all of our components look good in dark mode, need to keep the default as
light mode regardless of the system setting.
2023-10-16 15:31:50 -07:00

409 lines
13 KiB
Python

"""The pyi generator module."""
import importlib
import inspect
import os
import re
import sys
from inspect import getfullargspec
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Set, Union, get_args # NOQA
import black
from reflex.components.component import Component
# NOQA
from reflex.components.graphing.recharts.recharts import (
LiteralAnimationEasing,
LiteralAreaType,
LiteralComposedChartBaseValue,
LiteralDirection,
LiteralGridType,
LiteralIconType,
LiteralIfOverflow,
LiteralInterval,
LiteralLayout,
LiteralLegendAlign,
LiteralLineType,
LiteralOrientationTopBottom,
LiteralOrientationTopBottomLeftRight,
LiteralPolarRadiusType,
LiteralPosition,
LiteralScale,
LiteralShape,
LiteralStackOffset,
LiteralSyncMethod,
LiteralVerticalAlign,
)
from reflex.components.libs.chakra import (
LiteralAlertDialogSize,
LiteralAvatarSize,
LiteralChakraDirection,
LiteralColorScheme,
LiteralDrawerSize,
LiteralImageLoading,
LiteralInputVariant,
LiteralMenuOption,
LiteralMenuStrategy,
LiteralTagSize,
)
# NOQA
from reflex.event import EventChain
from reflex.style import Style
from reflex.utils import format
from reflex.utils import types as rx_types
from reflex.vars import Var
ruff_dont_remove = [
Var,
Optional,
Dict,
List,
EventChain,
Style,
LiteralInputVariant,
LiteralColorScheme,
LiteralChakraDirection,
LiteralTagSize,
LiteralDrawerSize,
LiteralMenuStrategy,
LiteralMenuOption,
LiteralAlertDialogSize,
LiteralAvatarSize,
LiteralImageLoading,
LiteralLayout,
LiteralAnimationEasing,
LiteralGridType,
LiteralPolarRadiusType,
LiteralScale,
LiteralSyncMethod,
LiteralStackOffset,
LiteralComposedChartBaseValue,
LiteralOrientationTopBottom,
LiteralAreaType,
LiteralShape,
LiteralLineType,
LiteralDirection,
LiteralIfOverflow,
LiteralOrientationTopBottomLeftRight,
LiteralInterval,
LiteralLegendAlign,
LiteralVerticalAlign,
LiteralIconType,
LiteralPosition,
]
EXCLUDED_FILES = [
"__init__.py",
"component.py",
"bare.py",
"foreach.py",
"cond.py",
"multiselect.py",
]
# These props exist on the base component, but should not be exposed in create methods.
EXCLUDED_PROPS = [
"alias",
"children",
"event_triggers",
"invalid_children",
"library",
"lib_dependencies",
"tag",
"is_default",
"special_props",
"valid_children",
]
DEFAULT_TYPING_IMPORTS = {"overload", "Any", "Dict", "List", "Optional", "Union"}
def _get_type_hint(value, top_level=True, no_union=False):
res = ""
args = get_args(value)
if args:
inner_container_type_args = (
[format.wrap(arg, '"') for arg in args]
if rx_types.is_literal(value)
else [
_get_type_hint(arg, top_level=False)
for arg in args
if arg is not type(None)
]
)
res = f"{value.__name__}[{', '.join(inner_container_type_args)}]"
if value.__name__ == "Var":
types = [res] + [
_get_type_hint(arg, top_level=False)
for arg in args
if arg is not type(None)
]
if len(types) > 1 and not no_union:
res = ", ".join(types)
res = f"Union[{res}]"
elif isinstance(value, str):
ev = eval(value)
res = _get_type_hint(ev, top_level=False) if ev.__name__ == "Var" else value
else:
res = value.__name__
if top_level and not res.startswith("Optional"):
res = f"Optional[{res}]"
return res
def _get_typing_import(_module):
src = [
line
for line in inspect.getsource(_module).split("\n")
if line.startswith("from typing")
]
if len(src):
return set(src[0].rpartition("from typing import ")[-1].split(", "))
return set()
def _get_var_definition(_module, _var_name):
return [
line.split(" = ")[0]
for line in inspect.getsource(_module).splitlines()
if line.startswith(_var_name)
]
class PyiGenerator:
"""A .pyi file generator that will scan all defined Component in Reflex and
generate the approriate stub.
"""
modules: list = []
root: str = ""
current_module: Any = {}
default_typing_imports: set = DEFAULT_TYPING_IMPORTS
def _generate_imports(self, variables, classes):
variables_imports = {
type(_var) for _, _var in variables if isinstance(_var, Component)
}
bases = {
base
for _, _class in classes
for base in _class.__bases__
if inspect.getmodule(base) != self.current_module
} | variables_imports
bases.add(Component)
typing_imports = self.default_typing_imports | _get_typing_import(
self.current_module
)
bases = sorted(bases, key=lambda base: base.__name__)
return [
f"from typing import {','.join(sorted(typing_imports))}",
*[f"from {base.__module__} import {base.__name__}" for base in bases],
"from reflex.vars import Var, BaseVar, ComputedVar",
"from reflex.event import EventHandler, EventChain, EventSpec",
"from reflex.style import Style",
]
def _generate_pyi_class(self, _class: type[Component]):
create_spec = getfullargspec(_class.create)
lines = [
"",
f"class {_class.__name__}({', '.join([base.__name__ for base in _class.__bases__])}):",
]
definition = f" @overload\n @classmethod\n def create( # type: ignore\n cls, *children, "
for kwarg in create_spec.kwonlyargs:
if kwarg in create_spec.annotations:
definition += f"{kwarg}: {_get_type_hint(create_spec.annotations[kwarg])} = None, "
else:
definition += f"{kwarg}, "
all_classes = [c for c in _class.__mro__ if issubclass(c, Component)]
all_props = []
for target_class in all_classes:
for name, value in target_class.__annotations__.items():
if (
name in create_spec.kwonlyargs
or name in EXCLUDED_PROPS
or name in all_props
):
continue
all_props.append(name)
definition += f"{name}: {_get_type_hint(value)} = None, "
for trigger in sorted(_class().get_event_triggers().keys()):
definition += f"{trigger}: Optional[Union[EventHandler, EventSpec, List, function, BaseVar]] = None, "
definition = definition.rstrip(", ")
definition += f", **props) -> '{_class.__name__}':\n"
definition += self._generate_docstrings(all_classes, all_props)
lines.append(definition)
lines.append(" ...")
return lines
def _generate_docstrings(self, _classes, _props):
props_comments = {}
comments = []
for _class in _classes:
for _i, line in enumerate(inspect.getsource(_class).splitlines()):
reached_functions = re.search("def ", line)
if reached_functions:
# We've reached the functions, so stop.
break
# Get comments for prop
if line.strip().startswith("#"):
comments.append(line)
continue
# Check if this line has a prop.
match = re.search("\\w+:", line)
if match is None:
# This line doesn't have a var, so continue.
continue
# Get the prop.
prop = match.group(0).strip(":")
if prop in _props:
if not comments: # do not include undocumented props
continue
props_comments[prop] = "\n".join(
[comment.strip().strip("#") for comment in comments]
)
comments.clear()
continue
if prop in EXCLUDED_PROPS:
comments.clear() # throw away comments for excluded props
_class = _classes[0]
new_docstring = []
for i, line in enumerate(_class.create.__doc__.splitlines()):
if i == 0:
new_docstring.append(" " * 8 + '"""' + line)
else:
new_docstring.append(line)
if "*children" in line:
for nline in [
f"{line.split('*')[0]}{n}:{c}" for n, c in props_comments.items()
]:
new_docstring.append(nline)
new_docstring += ['"""']
return "\n".join(new_docstring)
def _generate_pyi_variable(self, _name, _var):
return _get_var_definition(self.current_module, _name)
def _generate_function(self, _name, _func):
import textwrap
# Don't generate indented functions.
source = inspect.getsource(_func)
if textwrap.dedent(source) != source:
return []
definition = "".join([line for line in source.split(":\n")[0].split("\n")])
return [f"{definition}:", " ..."]
def _write_pyi_file(self, variables, functions, classes):
pyi_content = [
f'"""Stub file for {self.current_module_path}.py"""',
"# ------------------- DO NOT EDIT ----------------------",
"# This file was generated by `scripts/pyi_generator.py`!",
"# ------------------------------------------------------",
"",
]
pyi_content.extend(self._generate_imports(variables, classes))
for _name, _var in variables:
pyi_content.extend(self._generate_pyi_variable(_name, _var))
for _fname, _func in functions:
pyi_content.extend(self._generate_function(_fname, _func))
for _, _class in classes:
pyi_content.extend(self._generate_pyi_class(_class))
pyi_filename = f"{self.current_module_path}.pyi"
pyi_path = os.path.join(self.root, pyi_filename)
with open(pyi_path, "w") as pyi_file:
pyi_file.write("\n".join(pyi_content))
black.format_file_in_place(
src=Path(pyi_path),
fast=True,
mode=black.FileMode(),
write_back=black.WriteBack.YES,
)
def _scan_file(self, file):
self.current_module_path = os.path.splitext(file)[0]
module_import = os.path.splitext(os.path.join(self.root, file))[0].replace(
"/", "."
)
self.current_module = importlib.import_module(module_import)
local_variables = [
(name, obj)
for name, obj in vars(self.current_module).items()
if not name.startswith("_")
and not inspect.isclass(obj)
and not inspect.isfunction(obj)
]
functions = [
(name, obj)
for name, obj in vars(self.current_module).items()
if not name.startswith("__")
and (
not inspect.getmodule(obj)
or inspect.getmodule(obj) == self.current_module
)
and inspect.isfunction(obj)
]
class_names = [
(name, obj)
for name, obj in vars(self.current_module).items()
if inspect.isclass(obj)
and issubclass(obj, Component)
and obj != Component
and inspect.getmodule(obj) == self.current_module
]
if not class_names:
return
print(f"Parsed {file}: Found {[n for n, _ in class_names]}")
self._write_pyi_file(local_variables, functions, class_names)
def _scan_folder(self, folder):
for root, _, files in os.walk(folder):
self.root = root
for file in files:
if file in EXCLUDED_FILES:
continue
if file.endswith(".py"):
self._scan_file(file)
def scan_all(self, targets):
"""Scan all targets for class inheriting Component and generate the .pyi files.
Args:
targets: the list of file/folders to scan.
"""
for target in targets:
if target.endswith(".py"):
self.root, _, file = target.rpartition("/")
self._scan_file(file)
else:
self._scan_folder(target)
if __name__ == "__main__":
targets = sys.argv[1:] if len(sys.argv) > 1 else ["reflex/components"]
print(f"Running .pyi generator for {targets}")
gen = PyiGenerator()
gen.scan_all(targets)