diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 7eb6f37440e..8a31e5fd759 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -24,6 +24,8 @@ jobs: benchmarks: name: Run benchmarks runs-on: ubuntu-latest + env: + REFLEX_CLI_BENCHMARK_TIMEOUT_SECONDS: "120" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -43,9 +45,10 @@ jobs: run: uv sync --all-extras --dev - name: Run benchmarks - uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 + uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 with: - mode: instrumentation + mode: simulation + simulation-track-subprocess: true run: uv run pytest -v tests/benchmarks --codspeed lighthouse: diff --git a/news/+cli-startup.performance.md b/news/+cli-startup.performance.md new file mode 100644 index 00000000000..b99ff771599 --- /dev/null +++ b/news/+cli-startup.performance.md @@ -0,0 +1 @@ +Reduce CLI startup time by loading component and cloud command implementations only when invoked, and avoid frontend package reinstalls after backend-only config changes. diff --git a/news/+version-check-ttl.performance.md b/news/+version-check-ttl.performance.md new file mode 100644 index 00000000000..930887f0256 --- /dev/null +++ b/news/+version-check-ttl.performance.md @@ -0,0 +1 @@ +Avoid repeated PyPI requests by caching successful latest-version checks for 24 hours and throttling failed checks for one hour. diff --git a/packages/reflex-base/news/+lazy-plugin-imports.performance.md b/packages/reflex-base/news/+lazy-plugin-imports.performance.md new file mode 100644 index 00000000000..0b0a97ccacd --- /dev/null +++ b/packages/reflex-base/news/+lazy-plugin-imports.performance.md @@ -0,0 +1 @@ +Reduce CLI startup time and memory by loading compiler plugins and optional SQLAlchemy property support only when used. diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index e0790d3ab8c..c82a36d559f 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -1,50 +1,112 @@ """Reflex Plugin System.""" -from . import embed, sitemap, tailwind_v3, tailwind_v4 -from ._screenshot import ScreenshotPlugin as _ScreenshotPlugin -from .base import ( - CommonContext, - Plugin, - PostBuildContext, - PostCompileContext, - PreCompileContext, - RegisterRouteContext, - get_plugin, -) -from .compiler import ( - BaseContext, - CompileContext, - CompilerHooks, - ComponentAndChildren, - PageContext, - PageDefinition, -) -from .embed import EmbedPlugin -from .sitemap import SitemapPlugin -from .tailwind_v3 import TailwindV3Plugin -from .tailwind_v4 import TailwindV4Plugin +from typing import TYPE_CHECKING -__all__ = [ - "BaseContext", - "CommonContext", - "CompileContext", - "CompilerHooks", - "ComponentAndChildren", - "EmbedPlugin", - "PageContext", - "PageDefinition", - "Plugin", - "PostBuildContext", - "PostCompileContext", - "PreCompileContext", - "RegisterRouteContext", - "SitemapPlugin", - "TailwindV3Plugin", - "TailwindV4Plugin", - "_ScreenshotPlugin", +from reflex_base.utils import lazy_loader + +if TYPE_CHECKING: + from . import ( + _screenshot, + base, + compiler, + embed, + shared_tailwind, + sitemap, + tailwind_v3, + tailwind_v4, + ) + from ._screenshot import ScreenshotPlugin as _ScreenshotPlugin + from .base import ( + CommonContext, + Plugin, + PostBuildContext, + PostCompileContext, + PreCompileContext, + RegisterRouteContext, + get_plugin, + ) + from .compiler import ( + BaseContext, + CompileContext, + CompilerHooks, + ComponentAndChildren, + PageContext, + PageDefinition, + ) + from .embed import EmbedPlugin + from .sitemap import SitemapPlugin + from .tailwind_v3 import TailwindV3Plugin + from .tailwind_v4 import TailwindV4Plugin + + __all__ = [ + "BaseContext", + "CommonContext", + "CompileContext", + "CompilerHooks", + "ComponentAndChildren", + "EmbedPlugin", + "PageContext", + "PageDefinition", + "Plugin", + "PostBuildContext", + "PostCompileContext", + "PreCompileContext", + "RegisterRouteContext", + "SitemapPlugin", + "TailwindV3Plugin", + "TailwindV4Plugin", + "_ScreenshotPlugin", + "embed", + "get_plugin", + "sitemap", + "tailwind_v3", + "tailwind_v4", + ] + +_SUBMODULES: set[str] = { + "_screenshot", + "base", + "compiler", "embed", - "get_plugin", + "shared_tailwind", "sitemap", "tailwind_v3", "tailwind_v4", -] +} + +_SUBMOD_ATTRS: lazy_loader.SubmodAttrsType = { + "_screenshot": [("ScreenshotPlugin", "_ScreenshotPlugin")], + "base": [ + "CommonContext", + "Plugin", + "PostBuildContext", + "PostCompileContext", + "PreCompileContext", + "RegisterRouteContext", + "get_plugin", + ], + "compiler": [ + "BaseContext", + "CompileContext", + "CompilerHooks", + "ComponentAndChildren", + "PageContext", + "PageDefinition", + ], + "embed": ["EmbedPlugin"], + "sitemap": ["SitemapPlugin"], + "tailwind_v3": ["TailwindV3Plugin"], + "tailwind_v4": ["TailwindV4Plugin"], +} + +if not TYPE_CHECKING: + __getattr__, __dir__, _lazy_all = lazy_loader.attach( + __name__, + submodules=_SUBMODULES, + submod_attrs=_SUBMOD_ATTRS, + ) + __all__ = [ + name + for name in _lazy_all + if name not in {"_screenshot", "base", "compiler", "shared_tailwind"} + ] diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index bb421851ad7..82e1ce5a79e 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -44,6 +44,9 @@ logger = logging.getLogger(__name__) +if TYPE_CHECKING: + PROPERTY_CLASSES: tuple[type, ...] + # Potential GenericAlias types for isinstance checks. GenericAliasTypes = (_GenericAlias, GenericAlias, _SpecialGenericAlias) @@ -570,11 +573,46 @@ def get_field_type(cls: GenericType, field_name: str) -> GenericType | None: return type_hints.get(field_name, None) -PROPERTY_CLASSES = (property,) -if find_spec("sqlalchemy") and find_spec("sqlalchemy.ext"): - from sqlalchemy.ext.hybrid import hybrid_property +@lru_cache +def _get_property_classes() -> tuple[type, ...]: + """Resolve the legacy property-class tuple on explicit access. + + Returns: + Python's property class and SQLAlchemy's hybrid property class when + SQLAlchemy is installed. + """ + if find_spec("sqlalchemy") and find_spec("sqlalchemy.ext"): + from sqlalchemy.ext.hybrid import hybrid_property + + return property, hybrid_property + return (property,) + + +def __getattr__(name: str) -> object: + """Resolve compatibility attributes without importing optional runtimes. - PROPERTY_CLASSES += (hybrid_property,) + Args: + name: The module attribute being requested. + + Returns: + The lazily resolved compatibility value. + + Raises: + AttributeError: If the module does not define the requested attribute. + """ + if name == "PROPERTY_CLASSES": + return _get_property_classes() + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + +def __dir__() -> list[str]: + """List module attributes, including lazy compatibility exports. + + Returns: + The module's attribute names. + """ + return sorted({*globals(), "PROPERTY_CLASSES"}) def get_property_hint(attr: Any | None) -> GenericType | None: @@ -586,9 +624,15 @@ def get_property_hint(attr: Any | None) -> GenericType | None: Returns: The type hint of the property, if it is a property, else None. """ - if not isinstance(attr, PROPERTY_CLASSES) or attr.fget is None: + if not isinstance(attr, property): + sqlalchemy_hybrid = sys.modules.get("sqlalchemy.ext.hybrid") + if sqlalchemy_hybrid is None or not isinstance( + attr, sqlalchemy_hybrid.hybrid_property + ): + return None + if (getter := getattr(attr, "fget", None)) is None: return None - hints = get_type_hints(attr.fget) + hints = get_type_hints(getter) return hints.get("return", None) @@ -1523,3 +1567,10 @@ def is_immutable(i: Any) -> bool: Whether the value is immutable. """ return isinstance(i, IMMUTABLE_TYPES) + + +if not TYPE_CHECKING: + # Keep the historical wildcard-import surface while allowing the optional + # SQLAlchemy descriptor class to resolve only when that export is used. + __all__ = [name for name in globals() if not name.startswith("_")] + __all__.append("PROPERTY_CLASSES") diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py index 35dd3bcb4da..25515831358 100644 --- a/reflex/custom_components/custom_components.py +++ b/reflex/custom_components/custom_components.py @@ -15,7 +15,7 @@ from reflex_base import constants from reflex_base.constants import CustomComponents -from reflex.utils import console, frontend_skeleton +from reflex.utils import console from reflex.utils.cli_options import log_options logger = logging.getLogger(__name__) @@ -494,7 +494,7 @@ def init( Raises: SystemExit: If the pyproject.toml already exists. """ - from reflex.utils import exec + from reflex.utils import exec, frontend_skeleton if CustomComponents.PYPROJECT_TOML.exists(): logger.error(f"A {CustomComponents.PYPROJECT_TOML} already exists. Aborting.") diff --git a/reflex/reflex.py b/reflex/reflex.py index 182f1bf3940..aa7b7c14b1d 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging +from importlib import import_module from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING, NoReturn, cast +from typing import TYPE_CHECKING, Any, NoReturn, cast import click from reflex_base import constants @@ -13,7 +14,6 @@ from reflex_base.environment import environment from reflex_base.utils import console, log -from reflex.custom_components.custom_components import custom_components_cli from reflex.utils.cli_options import log_options logger = logging.getLogger(__name__) @@ -21,6 +21,7 @@ if TYPE_CHECKING: from typing import Literal + from click.shell_completion import CompletionItem from reflex_base.constants.base import LITERAL_ENV @@ -76,6 +77,183 @@ def placeholder(args: tuple[str, ...]): return placeholder +def _as_click_command(command: object) -> click.Command: + """Convert an optional Typer command tree to a Click command. + + Args: + command: The command object exported by the hosting CLI. + + Returns: + A Click-compatible command. + """ + if find_spec("typer") and find_spec("typer.main"): + import typer # pyright: ignore[reportMissingImports] + + if isinstance(command, typer.Typer): + # typer >=0.27 vendors click, so its commands are structurally but + # not nominally click commands. + return cast("click.Command", typer.main.get_command(command)) + return cast("click.Command", command) + + +class _LazyCommand(click.Command): + """A lightweight command placeholder that imports its implementation on use.""" + + def __getattribute__(self, name: str) -> Any: + """Resolve public command metadata when it is read directly. + + Returns: + The requested proxy or resolved-command attribute. + """ + if name in ("callback", "no_args_is_help", "params"): + attributes = object.__getattribute__(self, "__dict__") + if "_import_path" in attributes: + command = object.__getattribute__(self, "_resolve")() + return getattr(command, name) + return super().__getattribute__(name) + + def __init__( + self, + name: str, + import_path: str, + *, + help: str, + optional: bool = False, + convert_typer: bool = False, + ) -> None: + """Initialize a lazy command. + + Args: + name: The command name exposed by the parent group. + import_path: The ``module:attribute`` containing the real command. + help: The short help rendered by the parent without importing it. + optional: Whether an import failure should produce an install hint. + convert_typer: Whether to adapt a Typer command tree to Click. + """ + if optional: + module_name = import_path.split(":", 1)[0] + try: + module_available = find_spec(module_name) is not None + except (AttributeError, ImportError, ValueError): + module_available = False + if not module_available: + help = f"Requires the {constants.ReflexHostingCLI.MODULE_NAME} package." + super().__init__(name=name, help=help) + self._import_path = import_path + self._optional = optional + self._convert_typer = convert_typer + self._resolved_command: click.Command | None = None + + def _resolve(self) -> click.Command: + """Import and cache the real command implementation. + + Returns: + The resolved command. + """ + if self._resolved_command is not None: + return self._resolved_command + + module_name, attribute = self._import_path.split(":", 1) + try: + module = import_module(module_name) + except ImportError: + if not self._optional: + raise + command = _missing_command(self.name or attribute) + else: + try: + command = getattr(module, attribute) + except AttributeError: + if not self._optional: + raise + command = _missing_command(self.name or attribute) + + if self._convert_typer: + command = _as_click_command(command) + self._resolved_command = cast("click.Command", command) + return self._resolved_command + + def main(self, *args, **kwargs): + """Resolve the command before a direct standalone invocation. + + Returns: + The result of the resolved command. + """ + return self._resolve().main(*args, **kwargs) + + def get_help(self, ctx: click.Context) -> str: + """Return help from the resolved command. + + Returns: + The resolved command's formatted help. + """ + return self._resolve().get_help(ctx) + + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Write help from the resolved command to a formatter.""" + self._resolve().format_help(ctx, formatter) + + def get_usage(self, ctx: click.Context) -> str: + """Return usage from the resolved command. + + Returns: + The resolved command's formatted usage. + """ + return self._resolve().get_usage(ctx) + + def format_usage(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Write usage from the resolved command to a formatter.""" + self._resolve().format_usage(ctx, formatter) + + def get_params(self, ctx: click.Context) -> list[click.Parameter]: + """Return parameters from the resolved command. + + Returns: + The resolved command's parameters. + """ + return self._resolve().get_params(ctx) + + def invoke(self, ctx: click.Context): + """Invoke the resolved command through Click's public API. + + Returns: + The resolved command's callback result. + """ + return self._resolve().invoke(ctx) + + def make_context( + self, + info_name: str | None, + args: list[str], + parent: click.Context | None = None, + **extra, + ) -> click.Context: + """Resolve the command before Click parses its arguments. + + Returns: + The context created by the resolved command. + """ + return self._resolve().make_context(info_name, args, parent=parent, **extra) + + def shell_complete( + self, ctx: click.Context, incomplete: str + ) -> list[CompletionItem]: + """Resolve the command before completing its arguments. + + Returns: + Completion items from the resolved command. + """ + return self._resolve().shell_complete(ctx, incomplete) + + def to_info_dict(self, ctx: click.Context) -> dict[str, object]: + """Resolve the command before exporting its metadata. + + Returns: + Metadata from the resolved command. + """ + return self._resolve().to_info_dict(ctx) + + def _init( name: str, template: str | None = None, @@ -99,12 +277,12 @@ def _init( console.rule(f"[bold]Initializing {app_name}") # Check prerequisites. - prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) prerequisites.initialize_reflex_user_directory() prerequisites.ensure_reflex_installation_id() # Set up the web project. prerequisites.initialize_frontend_dependencies() + prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) # Initialize the app. template = templates.initialize_app(app_name, template) @@ -872,35 +1050,33 @@ def rename(new_name: str): rename_app(new_name, get_config().loglevel) -try: - from reflex_cli.v2.deploy import deploy - from reflex_cli.v2.deployments import hosting_cli -except ImportError: - # The cloud commands still answer, so the failure names the package to - # install instead of looking like a typo in the command name. - cli.add_command(_missing_command("deploy"), name="deploy") - cli.add_command(_missing_command("cloud"), name="cloud") -else: - if find_spec("typer") and find_spec("typer.main"): - import typer # pyright: ignore[reportMissingImports] - - if isinstance(hosting_cli, typer.Typer): - # typer >=0.27 vendors click, so its commands are structurally but - # not nominally click commands. - hosting_cli_command = cast( - "click.Command", typer.main.get_command(hosting_cli) - ) - else: - hosting_cli_command = hosting_cli - else: - hosting_cli_command = hosting_cli - - cli.add_command(deploy, name="deploy") - cli.add_command(hosting_cli_command, name="cloud") +cli.add_command( + _LazyCommand( + "deploy", + "reflex_cli.v2.deploy:deploy", + help="Deploy the app to the Reflex hosting service.", + optional=True, + ) +) +cli.add_command( + _LazyCommand( + "cloud", + "reflex_cli.v2.deployments:hosting_cli", + help="The Hosting CLI.", + optional=True, + convert_typer=True, + ) +) cli.add_command(db_cli, name="db") cli.add_command(script_cli, name="script") -cli.add_command(custom_components_cli, name="component") +cli.add_command( + _LazyCommand( + "component", + "reflex.custom_components.custom_components:custom_components_cli", + help="CLI for creating custom components.", + ) +) if __name__ == "__main__": cli() diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index 2f5b9a5188d..9ebbf029bd3 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -585,21 +585,24 @@ def _pinned_args_from_constants(deps: dict[str, str]) -> set[str]: def _frontend_packages_cache_payload( packages: set[str], - config: Config, + development_dependencies: set[str], + frozen_lockfile: bool, install_package_managers: Sequence[str], ) -> str: """Cache fingerprint for frontend package installs. Args: packages: Custom packages requested by the caller. - config: The active Reflex config. + development_dependencies: Development packages requested by plugins. + frozen_lockfile: Whether bun should enforce the existing lockfile. install_package_managers: The package manager paths in priority order. Returns: Stable fingerprint string for the cached procedure. """ return ( - f"{sorted(packages)!r},{config.json()},{list(install_package_managers)!r}," + f"{sorted(packages)!r},{sorted(development_dependencies)!r}," + f"{frozen_lockfile!r},{list(install_package_managers)!r}," f"{sorted(constants.PackageJson.DEPENDENCIES.items())!r}," f"{sorted(constants.PackageJson.DEV_DEPENDENCIES.items())!r}," f"{sorted(constants.PackageJson.OVERRIDES.items())!r}" @@ -612,7 +615,8 @@ def _frontend_packages_cache_payload( ) def _install_frontend_packages( packages: set[str], - config: Config, + development_dependencies: set[str], + frozen_lockfile: bool, install_package_managers: Sequence[str], ): """Installs the base and custom frontend packages. @@ -637,7 +641,8 @@ def _install_frontend_packages( Args: packages: Custom packages requested by the caller (from ``Config.frontend_packages`` and inferred component imports). - config: The active Reflex config. + development_dependencies: Development packages requested by plugins. + frozen_lockfile: Whether bun should enforce the existing lockfile. install_package_managers: The package manager paths in priority order (primary plus fallbacks). @@ -667,13 +672,6 @@ def _install_frontend_packages( env=env, ) - # Resolve plugin-contributed deps up front so we know the full needed - # set before deciding which entries in package.json are stale. - development_deps: set[str] = set() - for plugin in config.plugins: - development_deps.update(plugin.get_frontend_development_dependencies()) - packages.update(plugin.get_frontend_dependencies()) - wanted_dep_names = set(constants.PackageJson.DEPENDENCIES.keys()) | { _extract_package_name(p) for p in packages } @@ -684,7 +682,7 @@ def _install_frontend_packages( # add calls. wanted_dev_dep_names = ( set(constants.PackageJson.DEV_DEPENDENCIES.keys()) - | {_extract_package_name(p) for p in development_deps} + | {_extract_package_name(p) for p in development_dependencies} ) - wanted_dep_names needed_names = wanted_dep_names | wanted_dev_dep_names @@ -717,7 +715,7 @@ def _install_frontend_packages( frontend_skeleton.get_web_lockfile_path(name).exists() for name in frontend_skeleton.LOCKFILE_NAMES ): - _run_initial_install(primary_package_manager, env, config.frozen_lockfile) + _run_initial_install(primary_package_manager, env, frozen_lockfile) # Framework overrides are withheld while the persisted package.json is # restored so the frozen install above sees exactly the file that produced @@ -725,7 +723,9 @@ def _install_frontend_packages( overrides_changed = frontend_skeleton.update_package_json_overrides() pinned_packages, unpinned_packages = _split_by_version_specifier(packages) - pinned_dev_deps, unpinned_dev_deps = _split_by_version_specifier(development_deps) + pinned_dev_deps, unpinned_dev_deps = _split_by_version_specifier( + development_dependencies + ) # Skip unpinned entries that already appear in the correct section so # the package manager doesn't churn the previously resolved version. @@ -786,6 +786,17 @@ def install_frontend_packages(packages: set[str], config: Config): install_package_managers = tuple( get_nodejs_compatible_package_managers(raise_on_none=True) ) + packages = set(packages) + development_dependencies: set[str] = set() + for plugin in config.plugins: + development_dependencies.update(plugin.get_frontend_development_dependencies()) + packages.update(plugin.get_frontend_dependencies()) + _sync_root_lockfiles_for_frontend_install() - _install_frontend_packages(set(packages), config, install_package_managers) + _install_frontend_packages( + packages, + development_dependencies, + config.frozen_lockfile, + install_package_managers, + ) frontend_skeleton.sync_web_lockfiles_to_root() diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 57574d7d6bf..38b64ebd5f7 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -12,7 +12,7 @@ import sys import typing import uuid -from datetime import datetime +from datetime import datetime, timedelta from os import getcwd from pathlib import Path from types import ModuleType @@ -33,6 +33,11 @@ logger = logging.getLogger(__name__) +_LATEST_VERSION_CHECK_INTERVAL = timedelta(days=1) +_LATEST_VERSION_CHECK_FAILURE_INTERVAL = timedelta(hours=1) +_LATEST_VERSION_CHECK_DATETIME_KEY = "last_version_check_datetime" +_LATEST_VERSION_CHECK_ATTEMPT_DATETIME_KEY = "last_version_check_attempt_datetime" + if typing.TYPE_CHECKING: from redis import Redis as RedisSync from redis.asyncio import Redis @@ -88,6 +93,8 @@ def check_latest_package_version(package_name: str): if environment.REFLEX_CHECK_LATEST_VERSION.get() is False: return try: + if get_or_set_last_reflex_version_check_datetime(package_name): + return logger.debug(f"Checking for the latest version of {package_name}...") # Get the latest version from PyPI current_version = importlib.metadata.version(package_name) @@ -95,10 +102,13 @@ def check_latest_package_version(package_name: str): response = net.get(url, timeout=2) latest_version = response.json()["info"]["version"] logger.debug(f"Latest version of {package_name}: {latest_version}") - if get_or_set_last_reflex_version_check_datetime(): - # Versions were already checked and saved in reflex.json, no need to warn again - return - if version.parse(current_version) < version.parse(latest_version): + current_version_parsed = version.parse(current_version) + latest_version_parsed = version.parse(latest_version) + path_ops.update_json_file( + get_web_dir() / constants.Reflex.JSON, + {_version_check_timestamp_key(package_name): str(datetime.now())}, + ) + if current_version_parsed < latest_version_parsed: # Show a warning when the host version is older than PyPI version logger.warning( f"Your version ({current_version}) of {package_name} is out of date. Upgrade to {latest_version} with 'pip install {package_name} --upgrade'" @@ -107,24 +117,74 @@ def check_latest_package_version(package_name: str): logger.debug(f"Failed to check for the latest version of {package_name}.") -def get_or_set_last_reflex_version_check_datetime(): - """Get the last time a check was made for the latest reflex version. - This is typically useful for cases where the host reflex version is - less than that on Pypi. +def _version_check_timestamp_key(package_name: str, *, attempt: bool = False) -> str: + """Return the per-package reflex.json key for a version-check timestamp. + + Args: + package_name: The distribution being checked. + attempt: Whether to return the shorter failure-cooldown key. Returns: - The last version check datetime. + The normalized timestamp key, retaining the legacy key for Reflex. + """ + normalized_name = re.sub(r"[-_.]+", "_", package_name).lower() + reflex_name = re.sub(r"[-_.]+", "_", constants.Reflex.MODULE_NAME).lower() + suffix = "" if normalized_name == reflex_name else f"_{normalized_name}" + key = ( + _LATEST_VERSION_CHECK_ATTEMPT_DATETIME_KEY + if attempt + else _LATEST_VERSION_CHECK_DATETIME_KEY + ) + return f"{key}{suffix}" + + +def get_or_set_last_reflex_version_check_datetime( + package_name: str = constants.Reflex.MODULE_NAME, +) -> str | None: + """Return a recent version-check timestamp or record a new attempt. + + Successful checks remain fresh for a day. Failed attempts use a shorter + cooldown so offline commands do not repeatedly wait on the network without + delaying update notices for a full day. + + Args: + package_name: The distribution being checked. + + Returns: + A fresh existing timestamp when the check can be skipped, otherwise None. """ reflex_json_file = get_web_dir() / constants.Reflex.JSON if not reflex_json_file.exists(): return None - # Open and read the file + data = json.loads(reflex_json_file.read_text()) - last_version_check_datetime = data.get("last_version_check_datetime") - if not last_version_check_datetime: - data.update({"last_version_check_datetime": str(datetime.now())}) - path_ops.update_json_file(reflex_json_file, data) - return last_version_check_datetime + now = datetime.now() + for key, interval in ( + ( + _version_check_timestamp_key(package_name), + _LATEST_VERSION_CHECK_INTERVAL, + ), + ( + _version_check_timestamp_key(package_name, attempt=True), + _LATEST_VERSION_CHECK_FAILURE_INTERVAL, + ), + ): + timestamp = data.get(key) + if not isinstance(timestamp, str): + continue + try: + elapsed = now - datetime.fromisoformat(timestamp) + except (TypeError, ValueError): + continue + else: + if timedelta(0) <= elapsed < interval: + return timestamp + + path_ops.update_json_file( + reflex_json_file, + {_version_check_timestamp_key(package_name, attempt=True): str(now)}, + ) + return None def set_last_reflex_run_time(): diff --git a/reflex/utils/types.py b/reflex/utils/types.py index e271a4755a0..b68ba5cf1fe 100644 --- a/reflex/utils/types.py +++ b/reflex/utils/types.py @@ -1,4 +1,39 @@ # pyright: reportWildcardImportFromLibrary=false """Re-export from reflex_base.""" -from reflex_base.utils.types import * # pragma: no cover +from typing import TYPE_CHECKING + +import reflex_base.utils.types as _types + +if TYPE_CHECKING: + from reflex_base.utils.types import * + from reflex_base.utils.types import PROPERTY_CLASSES as PROPERTY_CLASSES +else: + __all__ = list(_types.__all__) + globals().update({ + name: _types.__dict__[name] for name in __all__ if name != "PROPERTY_CLASSES" + }) + + +def __getattr__(name: str) -> object: + """Forward lazy compatibility attributes to reflex_base. + + Args: + name: The module attribute being requested. + + Returns: + The corresponding reflex_base type utility. + + Raises: + AttributeError: If reflex_base does not define the requested attribute. + """ + return getattr(_types, name) + + +def __dir__() -> list[str]: + """List local and reflex_base type utility attributes. + + Returns: + The combined module attribute names. + """ + return sorted(set(globals()) | set(dir(_types))) diff --git a/tests/benchmarks/test_cli.py b/tests/benchmarks/test_cli.py new file mode 100644 index 00000000000..59670dfeed4 --- /dev/null +++ b/tests/benchmarks/test_cli.py @@ -0,0 +1,143 @@ +"""Benchmarks for fresh Reflex CLI processes.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from pytest_codspeed import BenchmarkFixture + +_SUBPROCESS_TIMEOUT_SECONDS = 15 +_SUBPROCESS_TIMEOUT_ENV_VAR = "REFLEX_CLI_BENCHMARK_TIMEOUT_SECONDS" + + +@pytest.fixture(scope="module") +def reflex_executable() -> str: + """Resolve the Reflex console script installed in the active environment. + + Returns: + The path to the Reflex console script. + + Raises: + RuntimeError: If the console script is not installed. + """ + executable = shutil.which("reflex") + if executable is None: + msg = "The reflex console script is not installed in the active environment." + raise RuntimeError(msg) + return executable + + +def _subprocess_env() -> dict[str, str]: + """Build a deterministic environment for CLI subprocesses. + + Returns: + Environment variables for a benchmark subprocess. + """ + return { + **os.environ, + "PYTHONHASHSEED": "0", + "REFLEX_CHECK_LATEST_VERSION": "false", + "REFLEX_TELEMETRY_ENABLED": "false", + } + + +def _run_command( + command: list[str], + cwd: Path, + env: dict[str, str], + *, + require_stdout: bool = False, +) -> subprocess.CompletedProcess[bytes]: + """Run a benchmark command in a fresh process. + + Args: + command: The executable and arguments to run. + cwd: The isolated working directory for the process. + env: Environment variables for the process. + require_stdout: Whether the command must produce user-facing output. + + Returns: + The completed subprocess. + """ + timeout = int( + os.environ.get( + _SUBPROCESS_TIMEOUT_ENV_VAR, + str(_SUBPROCESS_TIMEOUT_SECONDS), + ) + ) + result = subprocess.run( + command, + cwd=cwd, + env=env, + capture_output=True, + check=True, + timeout=timeout, + ) + if require_stdout and not result.stdout: + msg = f"Command produced no output: {command!r}" + raise AssertionError(msg) + return result + + +def test_python_process_startup( + benchmark: BenchmarkFixture, + tmp_path: Path, +): + """Track interpreter startup so CLI results have a stable control.""" + command = [sys.executable, "-c", "pass"] + env = _subprocess_env() + result = benchmark(lambda: _run_command(command, tmp_path, env)) + + assert result.returncode == 0, result.stderr.decode(errors="replace") + + +def test_import_cli( + benchmark: BenchmarkFixture, + tmp_path: Path, +): + """Benchmark importing the command tree in a fresh interpreter.""" + command = [sys.executable, "-c", "import reflex.reflex"] + env = _subprocess_env() + result = benchmark(lambda: _run_command(command, tmp_path, env)) + + assert result.returncode == 0, result.stderr.decode(errors="replace") + + +@pytest.mark.parametrize( + "argv", + [ + pytest.param(["--version"], id="version"), + pytest.param(["--help"], id="help"), + pytest.param(["run", "--help"], id="run_help"), + pytest.param(["component", "--help"], id="component_help"), + pytest.param(["cloud", "--help"], id="cloud_help"), + pytest.param(["deploy", "--help"], id="deploy_help"), + ], +) +def test_cli_startup( + argv: list[str], + reflex_executable: str, + benchmark: BenchmarkFixture, + tmp_path: Path, +): + """Benchmark an informational command in a fresh CLI process. + + Args: + argv: The command-line arguments to benchmark. + reflex_executable: The installed Reflex console script. + benchmark: The CodSpeed benchmark fixture. + tmp_path: An isolated working directory. + """ + command = [reflex_executable, *argv] + env = _subprocess_env() + result = benchmark( + lambda: _run_command(command, tmp_path, env, require_stdout=True) + ) + + assert result.returncode == 0 + assert result.stdout diff --git a/tests/units/reflex_base/plugins/test_imports.py b/tests/units/reflex_base/plugins/test_imports.py new file mode 100644 index 00000000000..d1c3765ea3d --- /dev/null +++ b/tests/units/reflex_base/plugins/test_imports.py @@ -0,0 +1,44 @@ +"""Import behavior tests for the Reflex plugin package.""" + +import json +import subprocess +import sys + +import reflex_base.plugins as plugins + + +def test_plugin_package_keeps_compiler_lazy(): + """Importing the plugin base package does not load compiler components.""" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json, sys; import reflex_base.plugins; " + "print(json.dumps(sorted(name for name in sys.modules " + "if name.startswith(('reflex_base.plugins.compiler', " + "'reflex_base.components')))))" + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout) == [] + + +def test_plugin_package_preserves_submodule_attributes(): + """Submodules exposed by the former eager imports remain available lazily.""" + for name in ( + "_screenshot", + "base", + "compiler", + "embed", + "shared_tailwind", + "sitemap", + "tailwind_v3", + "tailwind_v4", + ): + module = getattr(plugins, name) + assert module.__name__ == f"reflex_base.plugins.{name}" diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index c50f97d2707..2b26bb25259 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -1,5 +1,8 @@ """Tests for reflex_base.utils.types.""" +import json +import subprocess +import sys import typing from collections.abc import Callable from typing import Literal, TypeVar @@ -24,6 +27,58 @@ ) +def test_types_import_keeps_optional_orm_lazy(): + """Importing type helpers does not import the optional SQLAlchemy stack.""" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json, sys; import reflex_base.utils.types; " + "print(json.dumps(sorted(name for name in sys.modules " + "if name == 'sqlalchemy' or name.startswith('sqlalchemy.'))))" + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout) == [] + + +def test_property_classes_compatibility_export(): + """The legacy property-class tuple remains available from both modules.""" + import reflex_base.utils.types as base_types + + hybrid_module = pytest.importorskip("sqlalchemy.ext.hybrid") + + import reflex.utils.types as reflex_types + + expected_property_classes = (property, hybrid_module.hybrid_property) + assert expected_property_classes == base_types.PROPERTY_CLASSES + assert reflex_types.PROPERTY_CLASSES == base_types.PROPERTY_CLASSES + + +@pytest.mark.parametrize( + "module_name", ["reflex_base.utils.types", "reflex.utils.types"] +) +def test_property_classes_wildcard_import_compatibility(module_name: str): + """Wildcard imports retain the legacy property-class export.""" + result = subprocess.run( + [ + sys.executable, + "-c", + f"from {module_name} import *\nprint('PROPERTY_CLASSES' in locals())", + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == "True" + + def _type_alias_types() -> list[type]: """Collect the TypeAliasType classes available on this Python. diff --git a/tests/units/reflex_cli/v2/test_deploy.py b/tests/units/reflex_cli/v2/test_deploy.py index 80d9ec85a74..6c0a12ffa16 100644 --- a/tests/units/reflex_cli/v2/test_deploy.py +++ b/tests/units/reflex_cli/v2/test_deploy.py @@ -13,7 +13,7 @@ from reflex_cli.v2.deploy import deploy from reflex import hosting -from reflex.reflex import cli +from reflex.reflex import _LazyCommand, cli EXPECTED_DEPLOY_PARAMS = { "app_name", @@ -42,7 +42,10 @@ def test_deploy_registered_on_reflex_cli(): """`reflex deploy` resolves to the command hosted in the hosting CLI.""" - assert cli.commands["deploy"] is deploy + command = cli.commands["deploy"] + + assert isinstance(command, _LazyCommand) + assert command._resolve() is deploy def test_hosting_cli_deploy_imports_without_the_framework(): diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 5cba1a236e3..9996d7d37b8 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -4,6 +4,7 @@ import uuid from collections.abc import Callable, Generator from dataclasses import dataclass +from datetime import datetime, timedelta from pathlib import Path from typing import Protocol @@ -11,6 +12,7 @@ from click.testing import CliRunner from reflex_base import constants from reflex_base.config import Config +from reflex_base.environment import environment from reflex_base.utils import log from reflex_base.utils.decorator import cached_procedure @@ -27,6 +29,266 @@ runner = CliRunner() +@pytest.fixture +def version_check_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create an isolated reflex.json for latest-version checks. + + Args: + tmp_path: Temporary test directory. + monkeypatch: Pytest monkeypatch fixture. + + Returns: + The path to the isolated reflex.json file. + """ + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + reflex_json_file = web_dir / constants.Reflex.JSON + reflex_json_file.write_text("{}") + monkeypatch.setattr(prerequisites, "get_web_dir", lambda: web_dir) + monkeypatch.delenv(environment.REFLEX_CHECK_LATEST_VERSION.name, raising=False) + return reflex_json_file + + +def _mock_pypi_versions( + mocker, + current_version: str = "1.0.0", + latest_version: str = "2.0.0", +): + """Mock installed and latest package versions. + + Args: + mocker: Pytest mocker fixture. + current_version: Installed package version. + latest_version: Latest package version returned by PyPI. + + Returns: + The installed-version and network request mocks. + """ + installed_version = mocker.patch.object( + prerequisites.importlib.metadata, + "version", + return_value=current_version, + ) + response = mocker.Mock() + response.json.return_value = {"info": {"version": latest_version}} + request = mocker.patch.object(prerequisites.net, "get", return_value=response) + return installed_version, request + + +def test_check_latest_package_version_skips_fresh_check( + version_check_file: Path, + mocker, +): + """A fresh timestamp avoids package metadata and network work.""" + last_check = datetime.now() - timedelta(hours=12) + version_check_file.write_text( + json.dumps({"last_version_check_datetime": str(last_check)}) + ) + installed_version, request = _mock_pypi_versions(mocker) + + prerequisites.check_latest_package_version("reflex") + + installed_version.assert_not_called() + request.assert_not_called() + assert json.loads(version_check_file.read_text())[ + "last_version_check_datetime" + ] == str(last_check) + + +def test_check_latest_package_version_tracks_packages_independently( + version_check_file: Path, + mocker, +): + """A Reflex check does not suppress a hosting CLI version check.""" + version_check_file.write_text( + json.dumps({"last_version_check_datetime": str(datetime.now())}) + ) + installed_version, request = _mock_pypi_versions(mocker) + + prerequisites.check_latest_package_version("reflex-hosting-cli") + + installed_version.assert_called_once_with("reflex-hosting-cli") + request.assert_called_once_with( + "https://pypi.org/pypi/reflex-hosting-cli/json", timeout=2 + ) + stored = json.loads(version_check_file.read_text()) + assert "last_version_check_datetime_reflex_hosting_cli" in stored + + +def test_check_latest_package_version_refreshes_expired_check( + version_check_file: Path, + mocker, + caplog: pytest.LogCaptureFixture, +): + """An expired timestamp triggers a request, refresh, and update warning.""" + version_check_file.write_text( + json.dumps({ + "last_version_check_datetime": str( + datetime.now() - timedelta(days=1, seconds=1) + ) + }) + ) + installed_version, request = _mock_pypi_versions(mocker) + before_check = datetime.now() + + with caplog.at_level("WARNING"): + prerequisites.check_latest_package_version("reflex") + + checked_at = datetime.fromisoformat( + json.loads(version_check_file.read_text())["last_version_check_datetime"] + ) + installed_version.assert_called_once_with("reflex") + request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) + assert before_check <= checked_at <= datetime.now() + assert caplog.messages == [ + ( + "Your version (1.0.0) of reflex is out of date. Upgrade to 2.0.0 " + "with 'pip install reflex --upgrade'" + ) + ] + + +def test_check_latest_package_version_records_current_version( + version_check_file: Path, + mocker, + caplog: pytest.LogCaptureFixture, +): + """A successful first check is recorded without an unnecessary warning.""" + installed_version, request = _mock_pypi_versions( + mocker, + current_version="2.0.0", + latest_version="2.0.0", + ) + + with caplog.at_level("WARNING"): + prerequisites.check_latest_package_version("reflex") + + installed_version.assert_called_once_with("reflex") + request.assert_called_once() + assert datetime.fromisoformat( + json.loads(version_check_file.read_text())["last_version_check_datetime"] + ) + assert caplog.messages == [] + + +@pytest.mark.parametrize( + "stored_timestamp", + [ + "not-a-datetime", + str(datetime.now() + timedelta(days=1)), + ], +) +def test_check_latest_package_version_repairs_invalid_timestamp( + version_check_file: Path, + mocker, + stored_timestamp: str, +): + """Malformed and future timestamps do not suppress version checks.""" + version_check_file.write_text( + json.dumps({"last_version_check_datetime": stored_timestamp}) + ) + _, request = _mock_pypi_versions(mocker) + + prerequisites.check_latest_package_version("reflex") + + request.assert_called_once() + refreshed_timestamp = json.loads(version_check_file.read_text())[ + "last_version_check_datetime" + ] + assert refreshed_timestamp != stored_timestamp + assert datetime.fromisoformat(refreshed_timestamp) <= datetime.now() + + +def test_check_latest_package_version_throttles_failed_request( + version_check_file: Path, + mocker, +): + """A failed request is not retried by every command within the TTL.""" + mocker.patch.object( + prerequisites.importlib.metadata, + "version", + return_value="1.0.0", + ) + request = mocker.patch.object( + prerequisites.net, + "get", + side_effect=RuntimeError("offline"), + ) + + prerequisites.check_latest_package_version("reflex") + prerequisites.check_latest_package_version("reflex") + + request.assert_called_once() + stored = json.loads(version_check_file.read_text()) + assert "last_version_check_datetime" not in stored + assert datetime.fromisoformat(stored["last_version_check_attempt_datetime"]) + + +def test_check_latest_package_version_retries_after_failure_cooldown( + version_check_file: Path, + mocker, +): + """A failed check retries sooner than the successful-check TTL.""" + last_attempt = ( + datetime.now() + - prerequisites._LATEST_VERSION_CHECK_FAILURE_INTERVAL + - timedelta(seconds=1) + ) + version_check_file.write_text( + json.dumps({"last_version_check_attempt_datetime": str(last_attempt)}) + ) + _, request = _mock_pypi_versions(mocker) + + prerequisites.check_latest_package_version("reflex") + + request.assert_called_once() + stored = json.loads(version_check_file.read_text()) + assert datetime.fromisoformat(stored["last_version_check_datetime"]) + + +def test_check_latest_package_version_preserves_concurrent_json_updates( + version_check_file: Path, + mocker, + monkeypatch: pytest.MonkeyPatch, +): + """Recording a check does not write back a stale reflex.json snapshot.""" + version_check_file.write_text(json.dumps({"last_reflex_run_datetime": "old"})) + _mock_pypi_versions(mocker, current_version="2.0.0", latest_version="2.0.0") + update_json_file = prerequisites.path_ops.update_json_file + + def update_after_concurrent_write(file_path: Path, update: dict[str, object]): + update_json_file(file_path, {"last_reflex_run_datetime": "new"}) + update_json_file(file_path, update) + + monkeypatch.setattr( + prerequisites.path_ops, + "update_json_file", + update_after_concurrent_write, + ) + + prerequisites.check_latest_package_version("reflex") + + assert ( + json.loads(version_check_file.read_text())["last_reflex_run_datetime"] == "new" + ) + + +def test_check_latest_package_version_can_be_disabled( + version_check_file: Path, + monkeypatch: pytest.MonkeyPatch, + mocker, +): + """Disabling latest-version checks avoids both I/O and timestamp updates.""" + monkeypatch.setenv(environment.REFLEX_CHECK_LATEST_VERSION.name, "false") + installed_version, request = _mock_pypi_versions(mocker) + + prerequisites.check_latest_package_version("reflex") + + installed_version.assert_not_called() + request.assert_not_called() + assert json.loads(version_check_file.read_text()) == {} + + def _patch_web_dir(monkeypatch: pytest.MonkeyPatch, web_dir: Path): monkeypatch.setattr(frontend_skeleton, "get_web_dir", lambda: web_dir) monkeypatch.setattr(js_runtimes, "get_web_dir", lambda: web_dir) @@ -486,6 +748,55 @@ def run_package_manager(args, **kwargs): assert env.web_lock.read_text() == "root-lock" +def test_install_frontend_packages_cache_ignores_backend_config( + install_packages_env: InstallPackagesEnv, +): + """Backend-only config changes do not invalidate frontend dependencies.""" + env = install_packages_env + calls = _record_calls(env) + + env.install({"some-pkg@1.0.0"}) + first_run_calls = len(calls) + env.config.backend_port = 8123 + env.config.db_url = "sqlite:///changed.db" + env.install({"some-pkg@1.0.0"}) + + assert len(calls) == first_run_calls + + +def test_install_frontend_packages_cache_tracks_plugin_dependencies( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, +): + """Changing resolved plugin dependencies invalidates the install cache.""" + env = install_packages_env + dependency_calls = 0 + + @dataclass + class FakePlugin: + package: str + + def get_frontend_dependencies(self): + nonlocal dependency_calls + dependency_calls += 1 + return {self.package} + + def get_frontend_development_dependencies(self): + return set() + + plugin = FakePlugin("plugin-pkg@1") + monkeypatch.setattr(env.config, "plugins", [plugin]) + calls = _record_calls(env) + + env.install() + plugin.package = "plugin-pkg@2" + env.install() + + assert dependency_calls == 2 + assert any("plugin-pkg@1" in call for call in calls) + assert any("plugin-pkg@2" in call for call in calls) + + def _record_calls(env: InstallPackagesEnv) -> list[list[str]]: """Record `bun add`/`bun install` invocations into a list. diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index a2caba1741a..bd539ab727b 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -2,19 +2,325 @@ from __future__ import annotations -import click +import json +import os +import subprocess +import sys + import click.testing import pytest from reflex import reflex +_CLI_STARTUP_DENIED_MODULES = frozenset({ + "PIL", + "alembic", + "fastapi", + "granian", + "httpx", + "numpy", + "pandas", + "plotly", + "redis", + "reflex.app", + "reflex.compiler", + "reflex.custom_components.custom_components", + "reflex.model", + "reflex.state", + "reflex.utils.frontend_skeleton", + "reflex.utils.prerequisites", + "reflex_cli.v2.deploy", + "reflex_cli.v2.deployments", + "sqlalchemy", + "sqlmodel", + "starlette", + "uvicorn", +}) +_COMPONENT_HELP_DENIED_MODULES = _CLI_STARTUP_DENIED_MODULES - { + "reflex.custom_components.custom_components" +} + + +def _run_cli_probe(probe: str) -> dict[str, object]: + """Run a CLI import probe in a fresh interpreter. + + Args: + probe: The Python source to execute. + + Returns: + The JSON object written by the probe. + """ + completed = subprocess.run( + [sys.executable, "-c", probe], + check=True, + capture_output=True, + text=True, + timeout=15, + env={ + **os.environ, + "REFLEX_CHECK_LATEST_VERSION": "false", + "REFLEX_TELEMETRY_ENABLED": "false", + }, + ) + return json.loads(completed.stdout) + + +@pytest.mark.parametrize( + ("argv", "denied_modules"), + [ + (["--help"], _CLI_STARTUP_DENIED_MODULES), + (["--version"], _CLI_STARTUP_DENIED_MODULES), + (["run", "--help"], _CLI_STARTUP_DENIED_MODULES), + (["component", "--help"], _COMPONENT_HELP_DENIED_MODULES), + ( + ["deploy", "--help"], + _CLI_STARTUP_DENIED_MODULES - {"reflex_cli.v2.deploy"}, + ), + ( + ["cloud", "--help"], + _CLI_STARTUP_DENIED_MODULES - {"reflex_cli.v2.deployments"}, + ), + ], + ids=[ + "help", + "version", + "run-help", + "component-help", + "deploy-help", + "cloud-help", + ], +) +def test_cli_startup_does_not_import_runtime_modules( + argv: list[str], denied_modules: frozenset[str] +): + """Keep informational CLI paths independent of app and optional runtimes. + + Args: + argv: The informational command-line arguments to invoke. + denied_modules: Modules that the command must not import. + """ + probe = f""" +import json +import sys + +from click.testing import CliRunner +from reflex.reflex import cli + +result = CliRunner().invoke(cli, {argv!r}) +denied = {denied_modules!r} +loaded = sorted( + module + for module in denied + if module in sys.modules + or any(name.startswith(module + ".") for name in sys.modules) +) +print(json.dumps({{"exit_code": result.exit_code, "loaded": loaded}})) +""" + outcome = _run_cli_probe(probe) + + assert outcome["exit_code"] == 0 + assert outcome["loaded"] == [] + def test_cloud_commands_registered(): - """The hosting CLI is installed, so the real commands are registered.""" - from reflex_cli.v2.deploy import deploy + """The hosting CLI commands import, resolve, and dispatch only on demand.""" + probe = """ +import json +import sys + +import click +from click.testing import CliRunner +from reflex import reflex + +deploy_command = reflex.cli.commands["deploy"] +cloud_command = reflex.cli.commands["cloud"] +imported_before = { + "deploy": "reflex_cli.v2.deploy" in sys.modules, + "cloud": "reflex_cli.v2.deployments" in sys.modules, +} +unresolved_before = { + "deploy": deploy_command._resolved_command is None, + "cloud": cloud_command._resolved_command is None, +} + +runner = CliRunner() +deploy_result = runner.invoke(reflex.cli, ["deploy", "--help"]) +cloud_result = runner.invoke(reflex.cli, ["cloud", "--help"]) + +from reflex_cli.v2.deploy import deploy + +print(json.dumps({ + "cloud_is_click": isinstance(cloud_command._resolved_command, click.Command), + "cloud_help_matches": cloud_command.get_short_help_str() + == cloud_command._resolved_command.get_short_help_str(), + "cloud_result": cloud_result.exit_code, + "deploy_help_matches": deploy_command.help == deploy.help, + "deploy_is_real": deploy_command._resolved_command is deploy, + "deploy_result": deploy_result.exit_code, + "imported_before": imported_before, + "lazy_commands": [ + isinstance(deploy_command, reflex._LazyCommand), + isinstance(cloud_command, reflex._LazyCommand), + ], + "unresolved_before": unresolved_before, +})) +""" + outcome = _run_cli_probe(probe) + + assert outcome == { + "cloud_help_matches": True, + "cloud_is_click": True, + "cloud_result": 0, + "deploy_help_matches": True, + "deploy_is_real": True, + "deploy_result": 0, + "imported_before": {"cloud": False, "deploy": False}, + "lazy_commands": [True, True], + "unresolved_before": {"cloud": True, "deploy": True}, + } + + +def test_component_command_registered_lazily(): + """The component command preserves its help while loading on demand.""" + command = reflex.cli.commands["component"] + + assert isinstance(command, reflex._LazyCommand) + result = click.testing.CliRunner().invoke(reflex.cli, ["component", "--help"]) + + assert result.exit_code == 0 + resolved_command = command._resolved_command + assert resolved_command is not None + assert command.help == resolved_command.help + assert "CLI for creating custom components." in result.output + + +def test_lazy_command_delegates_click_introspection(): + """Click integrations inspecting a registered command see its real metadata.""" + command = reflex._LazyCommand( + "component", + "reflex.custom_components.custom_components:custom_components_cli", + help="CLI for creating custom components.", + ) + context = click.Context(command, info_name="component") + + help_text = command.get_help(context) + params = command.get_params(context) + + assert "Commands:" in help_text + assert "build" in help_text + assert command._resolved_command is not None + assert params == command._resolved_command.get_params(context) + + +def test_lazy_command_delegates_direct_invoke(monkeypatch: pytest.MonkeyPatch): + """Calling Click's public invoke method executes the resolved callback.""" + called = False + + @click.command() + def implementation(): + nonlocal called + called = True + + monkeypatch.setattr( + reflex, + "import_module", + lambda name: type("Commands", (), {"implementation": implementation}), + ) + command = reflex._LazyCommand( + "implementation", + "commands:implementation", + help="Test command.", + ) + + command.invoke(click.Context(command)) + + assert called + assert command._resolved_command is implementation + - assert reflex.cli.commands["deploy"] is deploy - assert isinstance(reflex.cli.commands["cloud"], click.Command) +def test_lazy_command_delegates_direct_metadata(monkeypatch: pytest.MonkeyPatch): + """Direct reads of Click's command metadata resolve to the implementation.""" + + @click.group() + @click.option("--value") + def implementation(value: str | None): + pass + + monkeypatch.setattr( + reflex, + "import_module", + lambda name: type("Commands", (), {"implementation": implementation}), + ) + command = reflex._LazyCommand( + "implementation", + "commands:implementation", + help="Test command.", + ) + + assert command.no_args_is_help is implementation.no_args_is_help + assert command.params == implementation.params + assert command.callback is implementation.callback + assert command._resolved_command is implementation + + +def test_lazy_hosting_command_reports_missing_package( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + """An unavailable lazy hosting command keeps the install guidance.""" + + def missing_import(name: str): + raise ImportError(name) + + monkeypatch.setattr(reflex, "import_module", missing_import) + command = reflex._LazyCommand( + "deploy", + "reflex_cli.v2.deploy:deploy", + help="Deploy the app to the Reflex hosting service.", + optional=True, + ) + + result = click.testing.CliRunner().invoke( + command, ["--app-name", "demo", "--no-interactive"] + ) + + assert result.exit_code == 1 + assert "pip install reflex-hosting-cli" in caplog.text + assert "No such option" not in result.output + + +def test_lazy_hosting_command_keeps_missing_package_help( + monkeypatch: pytest.MonkeyPatch, +): + """An unavailable hosting package retains its top-level help description.""" + monkeypatch.setattr(reflex, "find_spec", lambda name: None, raising=False) + + command = reflex._LazyCommand( + "deploy", + "reflex_cli.v2.deploy:deploy", + help="Deploy the app to the Reflex hosting service.", + optional=True, + ) + + assert command.help == "Requires the reflex-hosting-cli package." + + +def test_lazy_hosting_command_reports_incompatible_package( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + """An outdated hosting module missing the command keeps the install guidance.""" + monkeypatch.setattr(reflex, "import_module", lambda name: object()) + command = reflex._LazyCommand( + "deploy", + "reflex_cli.v2.deploy:deploy", + help="Deploy the app to the Reflex hosting service.", + optional=True, + ) + + result = click.testing.CliRunner().invoke(command, ["--app-name", "demo"]) + + assert result.exit_code == 1 + assert "pip install reflex-hosting-cli" in caplog.text + assert not isinstance(result.exception, AttributeError) def test_missing_command_reports_the_package(caplog: pytest.LogCaptureFixture): @@ -39,3 +345,42 @@ def test_missing_command_tolerates_flags(caplog: pytest.LogCaptureFixture): assert result.exit_code == 1 assert "pip install reflex-hosting-cli" in caplog.text assert "No such option" not in result.output + + +def test_init_records_version_check_after_frontend_setup( + tmp_path, monkeypatch: pytest.MonkeyPatch +): + """A new project's version-check timestamp survives web initialization.""" + events: list[str] = [] + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("reflex.utils.exec.output_system_info", lambda: None) + monkeypatch.setattr( + "reflex.utils.prerequisites.validate_app_name", lambda name: name + ) + monkeypatch.setattr( + "reflex.utils.prerequisites.initialize_reflex_user_directory", lambda: None + ) + monkeypatch.setattr( + "reflex.utils.prerequisites.ensure_reflex_installation_id", lambda: None + ) + monkeypatch.setattr( + "reflex.utils.prerequisites.initialize_frontend_dependencies", + lambda: events.append("frontend"), + ) + monkeypatch.setattr( + "reflex.utils.prerequisites.check_latest_package_version", + lambda package: events.append("version"), + ) + monkeypatch.setattr( + "reflex.utils.templates.initialize_app", lambda app_name, template: "blank" + ) + monkeypatch.setattr( + "reflex.utils.frontend_skeleton.initialize_gitignore", lambda: None + ) + monkeypatch.setattr( + "reflex.utils.frontend_skeleton.initialize_requirements_txt", lambda: False + ) + + reflex._init("demo") + + assert events == ["frontend", "version"]