From 2f236eb1b1216be6dec74df6456a4a65edb597b1 Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 21 Aug 2026 00:26:50 +0500 Subject: [PATCH 1/3] ENG-11433 refactor(cli): move the `reflex deploy` command into the hosting CLI The managed-platform deploy command (options and body) now lives in reflex_cli.v2.deploy; the reflex CLI registers it via cli.add_command. Flags and behavior are unchanged. The module lazily imports reflex internals in the command body since it only runs through the reflex CLI. --- news/+move-deploy-to-hosting-cli.misc.md | 1 + .../news/+deploy-command.misc.md | 1 + .../src/reflex_cli/v2/deploy.py | 232 ++++++++++++++++++ reflex/reflex.py | 216 +--------------- tests/units/reflex_cli/v2/test_deploy.py | 50 ++++ 5 files changed, 286 insertions(+), 214 deletions(-) create mode 100644 news/+move-deploy-to-hosting-cli.misc.md create mode 100644 packages/reflex-hosting-cli/news/+deploy-command.misc.md create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py create mode 100644 tests/units/reflex_cli/v2/test_deploy.py diff --git a/news/+move-deploy-to-hosting-cli.misc.md b/news/+move-deploy-to-hosting-cli.misc.md new file mode 100644 index 00000000000..21cbd4d009c --- /dev/null +++ b/news/+move-deploy-to-hosting-cli.misc.md @@ -0,0 +1 @@ +The `reflex deploy` command implementation moved out of the `reflex` package into `reflex-hosting-cli` (`reflex_cli.v2.deploy`). The command, its flags, and its behavior are unchanged. diff --git a/packages/reflex-hosting-cli/news/+deploy-command.misc.md b/packages/reflex-hosting-cli/news/+deploy-command.misc.md new file mode 100644 index 00000000000..9f48ca07325 --- /dev/null +++ b/packages/reflex-hosting-cli/news/+deploy-command.misc.md @@ -0,0 +1 @@ +The `reflex deploy` command implementation now lives in `reflex_cli.v2.deploy` (moved from the `reflex` package); the `reflex` CLI registers it from here. Flags and behavior are unchanged. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py new file mode 100644 index 00000000000..2f4d4bd2c22 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py @@ -0,0 +1,232 @@ +"""The `reflex deploy` command. + +This module hosts the managed-platform deploy command that the `reflex` CLI +registers as `reflex deploy`. It is only ever invoked through that CLI, so it +may import the `reflex` package (which is not a declared dependency of +reflex-hosting-cli) at runtime. +""" + +from __future__ import annotations + +from pathlib import Path + +import click +from reflex_base import constants +from reflex_base.config import get_config +from reflex_base.environment import environment + +from reflex.utils.cli_options import log_options + + +@click.command(name="deploy") +@log_options +@click.option( + "--app-name", + help="The name of the app to deploy.", +) +@click.option( + "--app-id", + help="The ID of the app to deploy.", +) +@click.option( + "-r", + "--region", + multiple=True, + help="The regions to deploy to. `reflex cloud regions` For multiple envs, repeat this option, e.g. --region sjc --region iad", +) +@click.option( + "--env", + multiple=True, + help="The environment variables to set: =. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.", +) +@click.option( + "--vmtype", + help="Vm type id. Run `reflex cloud vmtypes` to get options.", +) +@click.option( + "--min-instances", + type=int, + help="The minimum number of instances to keep running. Left unchanged when " + "omitted. Only supported on apps deployed to Google Cloud.", +) +@click.option( + "--max-instances", + type=int, + help="The maximum number of instances to scale out to. Left unchanged when " + "omitted. Only supported on apps deployed to Google Cloud.", +) +@click.option( + "--hostname", + help="The hostname of the frontend.", +) +@click.option( + "--provider", + help="The hosting provider to deploy to: 'reflex-cloud' (default) or 'gcp' " + "(a GCP account connected to your org, Enterprise tier). When omitted and " + "GCP is connected, you'll be prompted in interactive mode. Deploys through " + "Reflex Cloud either way; for an unmanaged deploy run under your own " + "gcloud credentials, see `reflex cloud gcp-standalone`.", +) +@click.option( + "--gcp-connection", + help="Which of your organization's GCP connections to deploy through, by " + "name. Run `reflex cloud providers connections` to list them. Only valid " + "with --provider gcp; omitted keeps the app on the connection it already " + "has, or your organization's default the first time it deploys to GCP.", +) +@click.option( + "--full-deploy/--no-full-deploy", + "full_deploy", + default=None, + help="Serve the frontend from the provider's own container, on the same " + "origin as the backend, instead of Reflex's CDN. GCP only, Enterprise " + "tier. Omitted leaves the app's hosting mode unchanged; changing it stops " + "a running app so this deploy brings it back up in the new mode.", +) +@click.option( + "--strategy", + type=click.Choice(["immediate", "rolling", "bluegreen", "canary"]), + help="How the new version rolls out. Defaults to the app's last strategy, " + "or 'immediate'.", +) +@click.option( + "--description", + help="An optional note recorded on this deployment and shown in " + "`reflex cloud apps history`.", +) +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to list configuration options and ask for confirmation.", +) +@click.option( + "--envfile", + help="The path to an env file to use. Will override any envs set manually.", +) +@click.option( + "--project", + help="project id to deploy to", +) +@click.option( + "--project-name", + help="The name of the project to deploy to.", +) +@click.option( + "--token", + help="token to use for auth", +) +@click.option( + "--config-path", + "--config", + help="path to the config file", +) +@click.option( + "--exclude-from-backend", + "backend_excluded_dirs", + multiple=True, + type=click.Path(exists=True, path_type=Path, resolve_path=True), + help="Files or directories to exclude from the backend zip. Can be used multiple times.", +) +@click.option( + "--server-side-rendering/--no-server-side-rendering", + "--ssr/--no-ssr", + "ssr", + default=True, + is_flag=True, + help="Whether to enable server side rendering for the frontend.", +) +def deploy( + app_name: str | None, + app_id: str | None, + region: tuple[str, ...], + env: tuple[str], + vmtype: str | None, + min_instances: int | None, + max_instances: int | None, + hostname: str | None, + provider: str | None, + gcp_connection: str | None, + full_deploy: bool | None, + strategy: str | None, + description: str | None, + interactive: bool, + envfile: str | None, + project: str | None, + project_name: str | None, + token: str | None, + config_path: str | None, + backend_excluded_dirs: tuple[Path, ...] = (), + ssr: bool = True, +): + """Deploy the app to the Reflex hosting service.""" + from reflex.reflex import _init + from reflex.utils import export as export_utils + from reflex.utils import prerequisites + from reflex_cli.utils import dependency + from reflex_cli.v2 import cli as hosting_cli + from reflex_cli.v2.deployments import check_version + + config = get_config() + + app_name = app_name or config.app_name + + check_version() + + environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.DEPLOY) + + if not environment.REFLEX_SSR.is_set(): + environment.REFLEX_SSR.set(ssr) + elif environment.REFLEX_SSR.get() != ssr: + ssr = environment.REFLEX_SSR.get() + + # Only check requirements if interactive. + # There is user interaction for requirements update. + if interactive: + dependency.check_requirements() + + prerequisites.assert_in_reflex_dir() + + # Check if we are set up. + if prerequisites.needs_reinit(): + _init(name=config.app_name) + prerequisites.check_latest_package_version(constants.ReflexHostingCLI.MODULE_NAME) + + hosting_cli.deploy( + app_name=app_name, + app_id=app_id, + export_fn=( + lambda zip_dest_dir, api_url, deploy_url, frontend, backend, upload_db, zipping: ( + export_utils.export( + zip_dest_dir=zip_dest_dir, + api_url=api_url, + deploy_url=deploy_url, + frontend=frontend, + backend=backend, + zipping=zipping, + loglevel=config.loglevel.subprocess_level(), + upload_db_file=upload_db, + backend_excluded_dirs=backend_excluded_dirs, + prerender_routes=ssr, + ) + ) + ), + regions=list(region), + envs=list(env), + vmtype=vmtype, + min_instances=min_instances, + max_instances=max_instances, + envfile=envfile, + hostname=hostname, + interactive=interactive, + loglevel=config.loglevel, + token=token, + project=project, + project_name=project_name, + provider=provider, + gcp_connection=gcp_connection, + full_deploy=full_deploy, + strategy=strategy, + deployment_description=description, + **({"config_path": config_path} if config_path is not None else {}), + ) diff --git a/reflex/reflex.py b/reflex/reflex.py index 3d44a60a34d..883999d959a 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -12,6 +12,7 @@ from reflex_base.config import get_config, reload_config from reflex_base.environment import environment from reflex_base.utils import console, log +from reflex_cli.v2.deploy import deploy from reflex_cli.v2.deployments import hosting_cli from reflex.custom_components.custom_components import custom_components_cli @@ -812,220 +813,6 @@ def makemigrations(message: str | None): ) -@cli.command() -@log_options -@click.option( - "--app-name", - help="The name of the app to deploy.", -) -@click.option( - "--app-id", - help="The ID of the app to deploy.", -) -@click.option( - "-r", - "--region", - multiple=True, - help="The regions to deploy to. `reflex cloud regions` For multiple envs, repeat this option, e.g. --region sjc --region iad", -) -@click.option( - "--env", - multiple=True, - help="The environment variables to set: =. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.", -) -@click.option( - "--vmtype", - help="Vm type id. Run `reflex cloud vmtypes` to get options.", -) -@click.option( - "--min-instances", - type=int, - help="The minimum number of instances to keep running. Left unchanged when " - "omitted. Only supported on apps deployed to Google Cloud.", -) -@click.option( - "--max-instances", - type=int, - help="The maximum number of instances to scale out to. Left unchanged when " - "omitted. Only supported on apps deployed to Google Cloud.", -) -@click.option( - "--hostname", - help="The hostname of the frontend.", -) -@click.option( - "--provider", - help="The hosting provider to deploy to: 'reflex-cloud' (default) or 'gcp' " - "(a GCP account connected to your org, Enterprise tier). When omitted and " - "GCP is connected, you'll be prompted in interactive mode. Deploys through " - "Reflex Cloud either way; for an unmanaged deploy run under your own " - "gcloud credentials, see `reflex cloud gcp-standalone`.", -) -@click.option( - "--gcp-connection", - help="Which of your organization's GCP connections to deploy through, by " - "name. Run `reflex cloud providers connections` to list them. Only valid " - "with --provider gcp; omitted keeps the app on the connection it already " - "has, or your organization's default the first time it deploys to GCP.", -) -@click.option( - "--full-deploy/--no-full-deploy", - "full_deploy", - default=None, - help="Serve the frontend from the provider's own container, on the same " - "origin as the backend, instead of Reflex's CDN. GCP only, Enterprise " - "tier. Omitted leaves the app's hosting mode unchanged; changing it stops " - "a running app so this deploy brings it back up in the new mode.", -) -@click.option( - "--strategy", - type=click.Choice(["immediate", "rolling", "bluegreen", "canary"]), - help="How the new version rolls out. Defaults to the app's last strategy, " - "or 'immediate'.", -) -@click.option( - "--description", - help="An optional note recorded on this deployment and shown in " - "`reflex cloud apps history`.", -) -@click.option( - "--interactive/--no-interactive", - is_flag=True, - default=True, - help="Whether to list configuration options and ask for confirmation.", -) -@click.option( - "--envfile", - help="The path to an env file to use. Will override any envs set manually.", -) -@click.option( - "--project", - help="project id to deploy to", -) -@click.option( - "--project-name", - help="The name of the project to deploy to.", -) -@click.option( - "--token", - help="token to use for auth", -) -@click.option( - "--config-path", - "--config", - help="path to the config file", -) -@click.option( - "--exclude-from-backend", - "backend_excluded_dirs", - multiple=True, - type=click.Path(exists=True, path_type=Path, resolve_path=True), - help="Files or directories to exclude from the backend zip. Can be used multiple times.", -) -@click.option( - "--server-side-rendering/--no-server-side-rendering", - "--ssr/--no-ssr", - "ssr", - default=True, - is_flag=True, - help="Whether to enable server side rendering for the frontend.", -) -def deploy( - app_name: str | None, - app_id: str | None, - region: tuple[str, ...], - env: tuple[str], - vmtype: str | None, - min_instances: int | None, - max_instances: int | None, - hostname: str | None, - provider: str | None, - gcp_connection: str | None, - full_deploy: bool | None, - strategy: str | None, - description: str | None, - interactive: bool, - envfile: str | None, - project: str | None, - project_name: str | None, - token: str | None, - config_path: str | None, - backend_excluded_dirs: tuple[Path, ...] = (), - ssr: bool = True, -): - """Deploy the app to the Reflex hosting service.""" - from reflex_cli.utils import dependency - from reflex_cli.v2 import cli as hosting_cli - from reflex_cli.v2.deployments import check_version - - from reflex.utils import export as export_utils - from reflex.utils import prerequisites - - config = get_config() - - app_name = app_name or config.app_name - - check_version() - - environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.DEPLOY) - - if not environment.REFLEX_SSR.is_set(): - environment.REFLEX_SSR.set(ssr) - elif environment.REFLEX_SSR.get() != ssr: - ssr = environment.REFLEX_SSR.get() - - # Only check requirements if interactive. - # There is user interaction for requirements update. - if interactive: - dependency.check_requirements() - - prerequisites.assert_in_reflex_dir() - - # Check if we are set up. - if prerequisites.needs_reinit(): - _init(name=config.app_name) - prerequisites.check_latest_package_version(constants.ReflexHostingCLI.MODULE_NAME) - - hosting_cli.deploy( - app_name=app_name, - app_id=app_id, - export_fn=( - lambda zip_dest_dir, api_url, deploy_url, frontend, backend, upload_db, zipping: ( - export_utils.export( - zip_dest_dir=zip_dest_dir, - api_url=api_url, - deploy_url=deploy_url, - frontend=frontend, - backend=backend, - zipping=zipping, - loglevel=config.loglevel.subprocess_level(), - upload_db_file=upload_db, - backend_excluded_dirs=backend_excluded_dirs, - prerender_routes=ssr, - ) - ) - ), - regions=list(region), - envs=list(env), - vmtype=vmtype, - min_instances=min_instances, - max_instances=max_instances, - envfile=envfile, - hostname=hostname, - interactive=interactive, - loglevel=config.loglevel, - token=token, - project=project, - project_name=project_name, - provider=provider, - gcp_connection=gcp_connection, - full_deploy=full_deploy, - strategy=strategy, - deployment_description=description, - **({"config_path": config_path} if config_path is not None else {}), - ) - - @cli.command() @log_options @click.argument("new_name") @@ -1050,6 +837,7 @@ def rename(new_name: str): else: hosting_cli_command = hosting_cli +cli.add_command(deploy, name="deploy") cli.add_command(hosting_cli_command, name="cloud") cli.add_command(db_cli, name="db") cli.add_command(script_cli, name="script") diff --git a/tests/units/reflex_cli/v2/test_deploy.py b/tests/units/reflex_cli/v2/test_deploy.py new file mode 100644 index 00000000000..0c28c811444 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_deploy.py @@ -0,0 +1,50 @@ +"""Tests for the `reflex deploy` command hosted in reflex_cli.v2.deploy.""" + +import click.testing +from reflex_cli.v2.deploy import deploy + +from reflex.reflex import cli + +EXPECTED_DEPLOY_PARAMS = { + "app_name", + "app_id", + "region", + "env", + "vmtype", + "min_instances", + "max_instances", + "hostname", + "provider", + "gcp_connection", + "full_deploy", + "strategy", + "description", + "interactive", + "envfile", + "project", + "project_name", + "token", + "config_path", + "backend_excluded_dirs", + "ssr", +} + + +def test_deploy_registered_on_reflex_cli(): + """`reflex deploy` resolves to the command hosted in the hosting CLI.""" + assert cli.commands["deploy"] is deploy + + +def test_deploy_flag_surface_unchanged(): + """The moved command keeps the exact set of CLI parameters it shipped with.""" + param_names = { + param.name for param in deploy.params if param.expose_value and param.name + } + assert param_names == EXPECTED_DEPLOY_PARAMS + + +def test_deploy_help(): + """`reflex deploy --help` renders without importing the reflex runtime.""" + result = click.testing.CliRunner().invoke(cli, ["deploy", "--help"]) + assert result.exit_code == 0 + assert "Deploy the app to the Reflex hosting service." in result.output From 03a451cbec79123e7e1e27ea986ff9b11fea5e50 Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 21 Aug 2026 23:07:09 +0500 Subject: [PATCH 2/3] ENG-11433 feat(cli): keep `reflex deploy` working from the hosting CLI The command body now lives in reflex_cli.v2.deploy, so reflex/reflex.py imports it instead of defining it. That import is guarded: when the hosting CLI is absent, a stand-in of the same name is registered. It accepts any flags, so the user is told which package to install rather than getting a usage error about an option the real command understands. `login` and `logout` report the same way. deploy.py imported log_options from `reflex`, which is not a dependency of reflex-hosting-cli, so the package failed to import on its own. The shared click options move to reflex_base.utils.cli_options, which both packages already depend on; reflex/utils/cli_options.py re-exports them. The hosting CLI floor moves to the release carrying the moved module, held at the workspace development version until that ships. --- news/+move-deploy-to-hosting-cli.misc.md | 2 +- .../news/+shared-cli-options.misc.md | 1 + packages/reflex-base/pyproject.toml | 1 + .../src/reflex_base/utils/cli_options.py | 76 ++++++++++++++++ .../news/+deploy-command.misc.md | 2 +- .../src/reflex_cli/v2/deploy.py | 16 ++-- pyproject.toml | 6 +- reflex/custom_components/custom_components.py | 2 +- reflex/reflex.py | 89 +++++++++++++++---- reflex/utils/cli_options.py | 83 +++-------------- tests/units/reflex_cli/v2/test_deploy.py | 26 ++++++ tests/units/test_reflex.py | 41 +++++++++ uv.lock | 2 + 13 files changed, 247 insertions(+), 100 deletions(-) create mode 100644 packages/reflex-base/news/+shared-cli-options.misc.md create mode 100644 packages/reflex-base/src/reflex_base/utils/cli_options.py create mode 100644 tests/units/test_reflex.py diff --git a/news/+move-deploy-to-hosting-cli.misc.md b/news/+move-deploy-to-hosting-cli.misc.md index 21cbd4d009c..a129d35d8ff 100644 --- a/news/+move-deploy-to-hosting-cli.misc.md +++ b/news/+move-deploy-to-hosting-cli.misc.md @@ -1 +1 @@ -The `reflex deploy` command implementation moved out of the `reflex` package into `reflex-hosting-cli` (`reflex_cli.v2.deploy`). The command, its flags, and its behavior are unchanged. +The `reflex deploy` command implementation moved out of the `reflex` package into `reflex-hosting-cli`, so cloud code is no longer shipped inside the framework. Flags and behavior are unchanged, and `reflex-hosting-cli` remains a dependency of `reflex`, so `reflex deploy` and `reflex cloud` stay available out of the box. If the package is not installed, these commands now report which package to install instead of failing with a missing-command error. diff --git a/packages/reflex-base/news/+shared-cli-options.misc.md b/packages/reflex-base/news/+shared-cli-options.misc.md new file mode 100644 index 00000000000..a2b1d62513c --- /dev/null +++ b/packages/reflex-base/news/+shared-cli-options.misc.md @@ -0,0 +1 @@ +The shared click options for the reflex CLIs (`--loglevel`, `--json`) moved here as `reflex_base.utils.cli_options`, so CLI packages that do not depend on `reflex` can use them. `reflex.utils.cli_options` re-exports them. diff --git a/packages/reflex-base/pyproject.toml b/packages/reflex-base/pyproject.toml index 7e744790a18..7b156e50c2a 100644 --- a/packages/reflex-base/pyproject.toml +++ b/packages/reflex-base/pyproject.toml @@ -8,6 +8,7 @@ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" dependencies = [ + "click >=8.2", "packaging >=24.2,<27", "rich >=13,<16", "typing_extensions >=4.13.0", diff --git a/packages/reflex-base/src/reflex_base/utils/cli_options.py b/packages/reflex-base/src/reflex_base/utils/cli_options.py new file mode 100644 index 00000000000..0d7d828e4d4 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/utils/cli_options.py @@ -0,0 +1,76 @@ +"""Shared click options for the reflex CLIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from reflex_base import constants +from reflex_base.utils import console, log + +if TYPE_CHECKING: + from collections.abc import Callable + + +def set_loglevel(ctx: click.Context, self: click.Parameter, value: str | None): + """Set the log level. + + Args: + ctx: The click context. + self: The click command. + value: The log level to set. + """ + if value is not None: + loglevel = constants.LogLevel.from_string(value) + console.set_log_level(loglevel) + + +loglevel_option = click.option( + "--loglevel", + "--log-level", + "loglevel", + type=click.Choice( + [loglevel.value for loglevel in constants.LogLevel], + case_sensitive=False, + ), + is_eager=True, + callback=set_loglevel, + expose_value=False, + help="The log level to use.", +) + + +def set_log_json(ctx: click.Context, self: click.Parameter, value: bool): + """Enable machine-readable JSON log output. + + Args: + ctx: The click context. + self: The click command. + value: Whether --json was passed. + """ + if value: + log.set_json_mode(True) + + +json_option = click.option( + "--json", + "log_json", + is_flag=True, + is_eager=True, + callback=set_log_json, + expose_value=False, + help="Output logs as machine-readable JSON records.", +) + + +def log_options(func: Callable) -> Callable: + """Apply the shared logging CLI options (--loglevel, --json). + + Args: + func: The click command callback. + + Returns: + The decorated callback. + """ + return loglevel_option(json_option(func)) diff --git a/packages/reflex-hosting-cli/news/+deploy-command.misc.md b/packages/reflex-hosting-cli/news/+deploy-command.misc.md index 9f48ca07325..51324fd5b3d 100644 --- a/packages/reflex-hosting-cli/news/+deploy-command.misc.md +++ b/packages/reflex-hosting-cli/news/+deploy-command.misc.md @@ -1 +1 @@ -The `reflex deploy` command implementation now lives in `reflex_cli.v2.deploy` (moved from the `reflex` package); the `reflex` CLI registers it from here. Flags and behavior are unchanged. +The `reflex deploy` command implementation now lives here, in `reflex_cli.v2.deploy`. The package no longer imports the `reflex` framework at module scope, so it stays importable on its own. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py index 2f4d4bd2c22..c745e6e53a1 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py @@ -1,9 +1,14 @@ """The `reflex deploy` command. -This module hosts the managed-platform deploy command that the `reflex` CLI -registers as `reflex deploy`. It is only ever invoked through that CLI, so it -may import the `reflex` package (which is not a declared dependency of -reflex-hosting-cli) at runtime. +This module hosts the managed-platform deploy command. The `reflex` CLI picks it +up through the `reflex.cli_commands` entry point and registers it as +`reflex deploy`; the framework itself does not import this package. + +The command body needs the reflex framework to compile and export the app, but +`reflex` is deliberately not a dependency of reflex-hosting-cli. Those imports +therefore stay inside the command body, which only ever runs under the reflex +CLI. Nothing at module scope may import `reflex`, so that this package stays +importable on its own. """ from __future__ import annotations @@ -14,8 +19,7 @@ from reflex_base import constants from reflex_base.config import get_config from reflex_base.environment import environment - -from reflex.utils.cli_options import log_options +from reflex_base.utils.cli_options import log_options @click.command(name="deploy") diff --git a/pyproject.toml b/pyproject.toml index 2638de9c41b..ba8ab2e0023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,11 @@ dependencies = [ "reflex-components-react-player >= 0.9.0", "reflex-components-recharts >= 0.9.0", "reflex-components-sonner >= 0.9.0", - "reflex-hosting-cli >= 0.1.66", + # `reflex deploy` now lives in reflex_cli.v2.deploy, which older releases do + # not carry. Until the release that adds it ships, this is the workspace + # development version, following the same convention as the other unreleased + # sibling pins; replace it with the published version at release. + "reflex-hosting-cli >= 0.1.70.post18.dev0", ] classifiers = [ diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py index 35dd3bcb4da..a2e0e78271d 100644 --- a/reflex/custom_components/custom_components.py +++ b/reflex/custom_components/custom_components.py @@ -14,9 +14,9 @@ import click from reflex_base import constants from reflex_base.constants import CustomComponents +from reflex_base.utils.cli_options import log_options from reflex.utils import console, frontend_skeleton -from reflex.utils.cli_options import log_options logger = logging.getLogger(__name__) diff --git a/reflex/reflex.py b/reflex/reflex.py index 883999d959a..4ddb6043cc9 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -5,18 +5,16 @@ import logging from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NoReturn import click from reflex_base import constants from reflex_base.config import get_config, reload_config from reflex_base.environment import environment from reflex_base.utils import console, log -from reflex_cli.v2.deploy import deploy -from reflex_cli.v2.deployments import hosting_cli +from reflex_base.utils.cli_options import log_options from reflex.custom_components.custom_components import custom_components_cli -from reflex.utils.cli_options import log_options logger = logging.getLogger(__name__) @@ -35,6 +33,49 @@ def cli(): log.enable_managed_logging() +def raise_missing_package(name: str) -> NoReturn: + """Report that the hosting CLI is not installed. + + Args: + name: The `reflex` subcommand the user ran. + + Raises: + Exit: Always, after reporting what to install. + """ + package = constants.ReflexHostingCLI.MODULE_NAME + logger.error( + f"`reflex {name}` requires the {package} package, which is not " + f"installed.\nInstall it with: pip install {package}" + ) + raise click.exceptions.Exit(1) + + +def _missing_command(name: str) -> click.Command: + """Build a stand-in for a cloud command whose package is unusable. + + The stand-in accepts any flags, so the user sees what to install rather than + a usage error about an option the real command would have understood. + + Args: + name: The command name to register. + + Returns: + A command that reports how to install the hosting CLI. + """ + package = constants.ReflexHostingCLI.MODULE_NAME + + @click.command( + name=name, + context_settings={"ignore_unknown_options": True}, + help=f"Requires the {package} package.", + ) + @click.argument("args", nargs=-1, type=click.UNPROCESSED) + def placeholder(args: tuple[str, ...]): + raise_missing_package(name) + + return placeholder + + def _init( name: str, template: str | None = None, @@ -655,8 +696,11 @@ def export( @log_options def login(): """Authenticate with experimental Reflex hosting service.""" - from reflex_cli.v2 import cli as hosting_cli - from reflex_cli.v2.deployments import check_version + try: + from reflex_cli.v2 import cli as hosting_cli + from reflex_cli.v2.deployments import check_version + except ImportError: + raise_missing_package("login") check_version() @@ -684,8 +728,11 @@ def login(): @log_options def logout(): """Log out of access to Reflex hosting service.""" - from reflex_cli.v2.cli import logout - from reflex_cli.v2.deployments import check_version + try: + from reflex_cli.v2.cli import logout + from reflex_cli.v2.deployments import check_version + except ImportError: + raise_missing_package("logout") check_version() @@ -827,18 +874,24 @@ def rename(new_name: str): rename_app(new_name, get_config().loglevel) -if find_spec("typer") and find_spec("typer.main"): - import typer # pyright: ignore[reportMissingImports] - - if isinstance(hosting_cli, typer.Typer): - hosting_cli_command = typer.main.get_command(hosting_cli) - else: - hosting_cli_command = hosting_cli +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: - hosting_cli_command = hosting_cli + if find_spec("typer") and find_spec("typer.main"): + import typer # pyright: ignore[reportMissingImports] + + if isinstance(hosting_cli, typer.Typer): + hosting_cli = typer.main.get_command(hosting_cli) + + cli.add_command(deploy, name="deploy") + cli.add_command(hosting_cli, name="cloud") -cli.add_command(deploy, name="deploy") -cli.add_command(hosting_cli_command, name="cloud") cli.add_command(db_cli, name="db") cli.add_command(script_cli, name="script") cli.add_command(custom_components_cli, name="component") diff --git a/reflex/utils/cli_options.py b/reflex/utils/cli_options.py index c1312a970a2..0d46aecf665 100644 --- a/reflex/utils/cli_options.py +++ b/reflex/utils/cli_options.py @@ -1,75 +1,14 @@ -"""Shared click options for the reflex CLIs.""" +"""Shared click options for the reflex CLIs. -from __future__ import annotations - -from typing import TYPE_CHECKING - -import click -from reflex_base import constants -from reflex_base.utils import console, log - -if TYPE_CHECKING: - from collections.abc import Callable - - -def set_loglevel(ctx: click.Context, self: click.Parameter, value: str | None): - """Set the log level. - - Args: - ctx: The click context. - self: The click command. - value: The log level to set. - """ - if value is not None: - loglevel = constants.LogLevel.from_string(value) - console.set_log_level(loglevel) - - -loglevel_option = click.option( - "--loglevel", - "--log-level", - "loglevel", - type=click.Choice( - [loglevel.value for loglevel in constants.LogLevel], - case_sensitive=False, - ), - is_eager=True, - callback=set_loglevel, - expose_value=False, - help="The log level to use.", -) +The implementation moved to `reflex_base.utils.cli_options` so that CLI packages +which do not depend on `reflex`, such as `reflex-hosting-cli`, can use it. This +module re-exports it for existing importers. +""" +from __future__ import annotations -def set_log_json(ctx: click.Context, self: click.Parameter, value: bool): - """Enable machine-readable JSON log output. - - Args: - ctx: The click context. - self: The click command. - value: Whether --json was passed. - """ - if value: - log.set_json_mode(True) - - -json_option = click.option( - "--json", - "log_json", - is_flag=True, - is_eager=True, - callback=set_log_json, - expose_value=False, - help="Output logs as machine-readable JSON records.", -) - - -def log_options(func: Callable) -> Callable: - """Apply the shared logging CLI options (--loglevel, --json). - - Args: - func: The click command callback. - - Returns: - The decorated callback. - """ - return loglevel_option(json_option(func)) +from reflex_base.utils.cli_options import json_option as json_option +from reflex_base.utils.cli_options import log_options as log_options +from reflex_base.utils.cli_options import loglevel_option as loglevel_option +from reflex_base.utils.cli_options import set_log_json as set_log_json +from reflex_base.utils.cli_options import set_loglevel as set_loglevel diff --git a/tests/units/reflex_cli/v2/test_deploy.py b/tests/units/reflex_cli/v2/test_deploy.py index 0c28c811444..37088fe2eae 100644 --- a/tests/units/reflex_cli/v2/test_deploy.py +++ b/tests/units/reflex_cli/v2/test_deploy.py @@ -1,5 +1,8 @@ """Tests for the `reflex deploy` command hosted in reflex_cli.v2.deploy.""" +import subprocess +import sys + import click.testing from reflex_cli.v2.deploy import deploy @@ -35,6 +38,29 @@ def test_deploy_registered_on_reflex_cli(): assert cli.commands["deploy"] is deploy +def test_hosting_cli_deploy_imports_without_the_framework(): + """The deploy module imports with the reflex framework unavailable. + + `reflex` is deliberately not a dependency of reflex-hosting-cli, so anything + the module needs at import time must come from reflex_base instead. + """ + probe = """ +import sys +class Blocked: + def find_spec(self, name, path=None, target=None): + if name == "reflex" or name.startswith("reflex."): + raise ImportError(name) +sys.meta_path.insert(0, Blocked()) +import reflex_cli.v2.deploy +print("ok") +""" + result = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "ok" + + def test_deploy_flag_surface_unchanged(): """The moved command keeps the exact set of CLI parameters it shipped with.""" param_names = { diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py new file mode 100644 index 00000000000..a2caba1741a --- /dev/null +++ b/tests/units/test_reflex.py @@ -0,0 +1,41 @@ +"""Tests for the reflex CLI command tree.""" + +from __future__ import annotations + +import click +import click.testing +import pytest + +from reflex import reflex + + +def test_cloud_commands_registered(): + """The hosting CLI is installed, so the real commands are registered.""" + from reflex_cli.v2.deploy import deploy + + assert reflex.cli.commands["deploy"] is deploy + assert isinstance(reflex.cli.commands["cloud"], click.Command) + + +def test_missing_command_reports_the_package(caplog: pytest.LogCaptureFixture): + """Without the hosting CLI, the command says which package to install.""" + result = click.testing.CliRunner().invoke(reflex._missing_command("deploy")) + + assert result.exit_code == 1 + assert "is not installed" in caplog.text + assert "pip install reflex-hosting-cli" in caplog.text + + +def test_missing_command_tolerates_flags(caplog: pytest.LogCaptureFixture): + """The stand-in reports the missing package instead of a usage error. + + The real command's flags must not produce "No such option", which would hide + the actual cause from the user. + """ + result = click.testing.CliRunner().invoke( + reflex._missing_command("deploy"), ["--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 diff --git a/uv.lock b/uv.lock index ab4dfd64387..ecbd12de14c 100644 --- a/uv.lock +++ b/uv.lock @@ -3811,6 +3811,7 @@ dev = [ name = "reflex-base" source = { editable = "packages/reflex-base" } dependencies = [ + { name = "click" }, { name = "packaging" }, { name = "platformdirs" }, { name = "rich" }, @@ -3824,6 +3825,7 @@ pydantic = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.2" }, { name = "packaging", specifier = ">=24.2,<27" }, { name = "platformdirs", specifier = ">=4.3.7,<5.0" }, { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12.0,<3.0" }, From f6945cae9d679a1b6968ee6be181523ed1120b93 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 22 Aug 2026 01:20:52 +0500 Subject: [PATCH 3/3] ENG-11433 docs(cli): drop the stale entry-point wording from deploy.py --- packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py index c745e6e53a1..30deadbe1d8 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py @@ -1,8 +1,7 @@ """The `reflex deploy` command. -This module hosts the managed-platform deploy command. The `reflex` CLI picks it -up through the `reflex.cli_commands` entry point and registers it as -`reflex deploy`; the framework itself does not import this package. +This module hosts the managed-platform deploy command. The `reflex` CLI imports +it and registers it as `reflex deploy`. The command body needs the reflex framework to compile and export the app, but `reflex` is deliberately not a dependency of reflex-hosting-cli. Those imports