From 2f236eb1b1216be6dec74df6456a4a65edb597b1 Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 21 Aug 2026 00:26:50 +0500 Subject: [PATCH 1/8] 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/8] 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/8] 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 From 8ce5049c00bb495f62389b7a9b98a65ed1fb765d Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 26 Aug 2026 21:37:46 +0500 Subject: [PATCH 4/8] ENG-11433 refactor(cli): keep the shared click options out of reflex-base Reverts the reflex_base.utils.cli_options move (and the click dependency it added to reflex-base). reflex/utils/cli_options.py holds the implementation again, and the hosting CLI carries its own copy in reflex_cli.utils.cli_options so deploy.py no longer imports reflex_base at module scope; the framework bits it needs at runtime moved into the lazy import block in the command body. --- .../news/+shared-cli-options.misc.md | 1 - packages/reflex-base/pyproject.toml | 1 - .../src/reflex_cli}/utils/cli_options.py | 10 +-- .../src/reflex_cli/v2/deploy.py | 9 +- reflex/constants/__init__.py | 1 + reflex/custom_components/custom_components.py | 2 +- reflex/reflex.py | 2 +- reflex/utils/cli_options.py | 83 ++++++++++++++++--- tests/units/reflex_cli/v2/test_deploy.py | 8 +- uv.lock | 2 - 10 files changed, 92 insertions(+), 27 deletions(-) delete mode 100644 packages/reflex-base/news/+shared-cli-options.misc.md rename packages/{reflex-base/src/reflex_base => reflex-hosting-cli/src/reflex_cli}/utils/cli_options.py (86%) diff --git a/packages/reflex-base/news/+shared-cli-options.misc.md b/packages/reflex-base/news/+shared-cli-options.misc.md deleted file mode 100644 index a2b1d62513c..00000000000 --- a/packages/reflex-base/news/+shared-cli-options.misc.md +++ /dev/null @@ -1 +0,0 @@ -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 7b156e50c2a..7e744790a18 100644 --- a/packages/reflex-base/pyproject.toml +++ b/packages/reflex-base/pyproject.toml @@ -8,7 +8,6 @@ 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-hosting-cli/src/reflex_cli/utils/cli_options.py similarity index 86% rename from packages/reflex-base/src/reflex_base/utils/cli_options.py rename to packages/reflex-hosting-cli/src/reflex_cli/utils/cli_options.py index 0d7d828e4d4..f2a2b3e2a3d 100644 --- a/packages/reflex-base/src/reflex_base/utils/cli_options.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/cli_options.py @@ -1,13 +1,14 @@ -"""Shared click options for the reflex CLIs.""" +"""Shared click options for the hosting CLI commands.""" from __future__ import annotations from typing import TYPE_CHECKING import click +from reflex_base.utils import log -from reflex_base import constants -from reflex_base.utils import console, log +from reflex_cli import constants +from reflex_cli.utils import console if TYPE_CHECKING: from collections.abc import Callable @@ -22,8 +23,7 @@ def set_loglevel(ctx: click.Context, self: click.Parameter, value: str | None): value: The log level to set. """ if value is not None: - loglevel = constants.LogLevel.from_string(value) - console.set_log_level(loglevel) + console.set_log_level(value) loglevel_option = click.option( 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 30deadbe1d8..c3c5f864567 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py @@ -15,10 +15,8 @@ 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_base.utils.cli_options import log_options + +from reflex_cli.utils.cli_options import log_options @click.command(name="deploy") @@ -163,6 +161,9 @@ def deploy( ssr: bool = True, ): """Deploy the app to the Reflex hosting service.""" + from reflex import constants + from reflex.config import get_config + from reflex.environment import environment from reflex.reflex import _init from reflex.utils import export as export_utils from reflex.utils import prerequisites diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index cc708858292..fe2ab035dbb 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -110,6 +110,7 @@ "PyprojectToml", "ReactRouter", "Reflex", + "ReflexHostingCLI", "RequirementsTxt", "RouteArgType", "RouteRegex", diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py index a2e0e78271d..35dd3bcb4da 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 4ddb6043cc9..d8259715b54 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -12,9 +12,9 @@ from reflex_base.config import get_config, reload_config from reflex_base.environment import environment from reflex_base.utils import console, log -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__) diff --git a/reflex/utils/cli_options.py b/reflex/utils/cli_options.py index 0d46aecf665..c1312a970a2 100644 --- a/reflex/utils/cli_options.py +++ b/reflex/utils/cli_options.py @@ -1,14 +1,75 @@ -"""Shared click options for the reflex CLIs. - -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. -""" +"""Shared click options for the reflex CLIs.""" from __future__ import annotations -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 +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/tests/units/reflex_cli/v2/test_deploy.py b/tests/units/reflex_cli/v2/test_deploy.py index 37088fe2eae..362de8f373c 100644 --- a/tests/units/reflex_cli/v2/test_deploy.py +++ b/tests/units/reflex_cli/v2/test_deploy.py @@ -42,7 +42,7 @@ 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. + the module needs at import time must come from the hosting CLI itself. """ probe = """ import sys @@ -69,6 +69,12 @@ def test_deploy_flag_surface_unchanged(): assert param_names == EXPECTED_DEPLOY_PARAMS +def test_deploy_keeps_log_options(): + """The shared logging flags stay on the command after the cli_options move.""" + option_names = {opt for param in deploy.params for opt in param.opts} + assert {"--loglevel", "--log-level", "--json"} <= option_names + + def test_deploy_help(): """`reflex deploy --help` renders without importing the reflex runtime.""" result = click.testing.CliRunner().invoke(cli, ["deploy", "--help"]) diff --git a/uv.lock b/uv.lock index ecbd12de14c..ab4dfd64387 100644 --- a/uv.lock +++ b/uv.lock @@ -3811,7 +3811,6 @@ dev = [ name = "reflex-base" source = { editable = "packages/reflex-base" } dependencies = [ - { name = "click" }, { name = "packaging" }, { name = "platformdirs" }, { name = "rich" }, @@ -3825,7 +3824,6 @@ 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 2e7374af97f6cc9c7501ec222dc314fc99d6e7d9 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 27 Aug 2026 00:30:29 +0500 Subject: [PATCH 5/8] ENG-11433 refactor(hosting): expose reflex.hosting as the supported deploy interface The deploy command body needed six reflex internals (constants, config, environment, _init, export, prerequisites). Fold them into two supported functions in a new reflex.hosting module -- prepare_deploy() and export_for_deploy() -- so the framework can reshuffle its internals without breaking the hosting CLI. The shared --ssr/REFLEX_SSR arbitration moves to exec.arbitrate_ssr(), deduplicating the copy in the export command. A new test guards that deploy.py imports the framework only through reflex.hosting. --- .../src/reflex_cli/v2/deploy.py | 45 +++------ reflex/hosting.py | 96 +++++++++++++++++++ reflex/reflex.py | 6 +- reflex/utils/exec.py | 18 ++++ tests/units/reflex_cli/v2/test_deploy.py | 26 +++++ tests/units/test_hosting.py | 85 ++++++++++++++++ tests/units/utils/test_exec.py | 15 +++ 7 files changed, 254 insertions(+), 37 deletions(-) create mode 100644 reflex/hosting.py create mode 100644 tests/units/test_hosting.py 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 c3c5f864567..5e19452f6e2 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py @@ -4,10 +4,11 @@ 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 -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. +`reflex` is deliberately not a dependency of reflex-hosting-cli. Everything it +needs from the framework comes through `reflex.hosting`, the interface reflex +supports for this package, imported 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 @@ -161,57 +162,35 @@ def deploy( ssr: bool = True, ): """Deploy the app to the Reflex hosting service.""" - from reflex import constants - from reflex.config import get_config - from reflex.environment import environment - from reflex.reflex import _init - from reflex.utils import export as export_utils - from reflex.utils import prerequisites + from reflex.hosting import export_for_deploy, prepare_deploy 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) + prep = prepare_deploy(ssr=ssr) hosting_cli.deploy( - app_name=app_name, + app_name=app_name or prep.app_name, app_id=app_id, export_fn=( lambda zip_dest_dir, api_url, deploy_url, frontend, backend, upload_db, zipping: ( - export_utils.export( + export_for_deploy( 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, + zipping=zipping, backend_excluded_dirs=backend_excluded_dirs, - prerender_routes=ssr, + prerender_routes=prep.ssr, ) ) ), @@ -223,7 +202,7 @@ def deploy( envfile=envfile, hostname=hostname, interactive=interactive, - loglevel=config.loglevel, + loglevel=prep.loglevel, token=token, project=project, project_name=project_name, diff --git a/reflex/hosting.py b/reflex/hosting.py new file mode 100644 index 00000000000..a0b79dcd4ec --- /dev/null +++ b/reflex/hosting.py @@ -0,0 +1,96 @@ +"""The framework interface for the Reflex hosting CLI. + +reflex-hosting-cli implements `reflex deploy` but does not depend on the +framework. Everything the deploy command needs from reflex goes through the +functions in this module, which the framework supports as a stable interface; +keep their signatures backward compatible. +""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path + +from reflex_base import constants +from reflex_base.config import get_config +from reflex_base.environment import environment + +from reflex.utils import prerequisites +from reflex.utils.exec import arbitrate_ssr +from reflex.utils.export import export + + +@dataclasses.dataclass(frozen=True) +class DeployPrep: + """What the hosting CLI needs from the framework to run a deploy.""" + + app_name: str + loglevel: constants.LogLevel + ssr: bool + + +def prepare_deploy(*, ssr: bool = True) -> DeployPrep: + """Prepare the current app directory for a deploy. + + Sets the DEPLOY compile context, reconciles the ssr flag with the + REFLEX_SSR environment variable, ensures the cwd is an initialized reflex + app, and warns when the hosting CLI package is outdated. + + Args: + ssr: Whether the frontend should be exported with server side rendering. + + Returns: + The app config values and effective SSR setting the deploy should use. + """ + from reflex.reflex import _init + + config = get_config() + + environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.DEPLOY) + ssr = arbitrate_ssr(ssr) + + prerequisites.assert_in_reflex_dir() + if prerequisites.needs_reinit(): + _init(name=config.app_name) + prerequisites.check_latest_package_version(constants.ReflexHostingCLI.MODULE_NAME) + + return DeployPrep(app_name=config.app_name, loglevel=config.loglevel, ssr=ssr) + + +def export_for_deploy( + *, + zip_dest_dir: str, + api_url: str, + deploy_url: str, + frontend: bool, + backend: bool, + upload_db_file: bool, + zipping: bool, + backend_excluded_dirs: tuple[Path, ...] = (), + prerender_routes: bool = True, +) -> None: + """Export the app as deploy artifacts for the hosting service. + + Args: + zip_dest_dir: The directory to export the zip files to. + api_url: The API URL the deployed backend will be served from. + deploy_url: The URL the deployed frontend will be served from. + frontend: Whether to export the frontend. + backend: Whether to export the backend. + upload_db_file: Whether to include the sqlite db file in the backend zip. + zipping: Whether to zip the exported app. + backend_excluded_dirs: Files or directories to exclude from the backend zip. + prerender_routes: Whether to prerender the routes. + """ + export( + zip_dest_dir=zip_dest_dir, + api_url=api_url, + deploy_url=deploy_url, + frontend=frontend, + backend=backend, + zipping=zipping, + loglevel=get_config().loglevel.subprocess_level(), + upload_db_file=upload_db_file, + backend_excluded_dirs=backend_excluded_dirs, + prerender_routes=prerender_routes, + ) diff --git a/reflex/reflex.py b/reflex/reflex.py index e40d8761c1e..182f1bf3940 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -662,11 +662,9 @@ def export( """Export the app to a zip file.""" from reflex.utils import export as export_utils from reflex.utils import prerequisites + from reflex.utils.exec import arbitrate_ssr - if not environment.REFLEX_SSR.is_set(): - environment.REFLEX_SSR.set(ssr) - elif environment.REFLEX_SSR.get() != ssr: - ssr = environment.REFLEX_SSR.get() + ssr = arbitrate_ssr(ssr) environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.EXPORT) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 780e86d7473..c93b11949c3 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -902,6 +902,24 @@ def should_prerender_routes() -> bool: return environment.REFLEX_SSR.get() +def arbitrate_ssr(ssr: bool) -> bool: + """Reconcile an --ssr flag value with the REFLEX_SSR environment variable. + + The environment variable wins when already set; otherwise the flag value + is stored in the environment so worker subprocesses inherit it. + + Args: + ssr: The flag value from the command line. + + Returns: + The effective SSR setting. + """ + if not environment.REFLEX_SSR.is_set(): + environment.REFLEX_SSR.set(ssr) + return ssr + return environment.REFLEX_SSR.get() + + def get_compile_context() -> constants.CompileContext: """Check if the app is compiled for deploy. diff --git a/tests/units/reflex_cli/v2/test_deploy.py b/tests/units/reflex_cli/v2/test_deploy.py index 362de8f373c..24860e7f12e 100644 --- a/tests/units/reflex_cli/v2/test_deploy.py +++ b/tests/units/reflex_cli/v2/test_deploy.py @@ -1,5 +1,7 @@ """Tests for the `reflex deploy` command hosted in reflex_cli.v2.deploy.""" +import ast +import inspect import subprocess import sys @@ -75,6 +77,30 @@ def test_deploy_keeps_log_options(): assert {"--loglevel", "--log-level", "--json"} <= option_names +def test_deploy_uses_only_the_supported_framework_interface(): + """The command imports the framework only through `reflex.hosting`. + + That module is the interface reflex explicitly supports for the hosting + CLI; anything else is a reflex internal that may change without notice. + """ + import reflex_cli.v2.deploy as deploy_module + + tree = ast.parse(inspect.getsource(deploy_module)) + framework_imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module == "reflex" or module.startswith("reflex."): + framework_imports.add(module) + elif isinstance(node, ast.Import): + framework_imports.update( + alias.name + for alias in node.names + if alias.name == "reflex" or alias.name.startswith("reflex.") + ) + assert framework_imports == {"reflex.hosting"} + + def test_deploy_help(): """`reflex deploy --help` renders without importing the reflex runtime.""" result = click.testing.CliRunner().invoke(cli, ["deploy", "--help"]) diff --git a/tests/units/test_hosting.py b/tests/units/test_hosting.py new file mode 100644 index 00000000000..8ab1ff56b43 --- /dev/null +++ b/tests/units/test_hosting.py @@ -0,0 +1,85 @@ +"""Tests for the hosting CLI interface in ``reflex.hosting``.""" + +import pytest +from pytest_mock import MockerFixture +from reflex_base import constants +from reflex_base.config import get_config +from reflex_base.environment import environment + +from reflex import hosting + + +@pytest.fixture +def deploy_env(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture): + """Isolate deploy env vars and stub the app-dir prerequisites. + + Args: + monkeypatch: The pytest monkeypatch fixture. + mocker: The pytest-mock fixture. + """ + monkeypatch.setenv(environment.REFLEX_SSR.name, "") + monkeypatch.setenv(environment.REFLEX_COMPILE_CONTEXT.name, "") + mocker.patch.object(hosting.prerequisites, "assert_in_reflex_dir") + mocker.patch.object(hosting.prerequisites, "needs_reinit", return_value=False) + mocker.patch.object(hosting.prerequisites, "check_latest_package_version") + + +def test_prepare_deploy_sets_deploy_context(deploy_env): + """prepare_deploy sets the DEPLOY compile context and returns config values.""" + prep = hosting.prepare_deploy(ssr=False) + + assert environment.REFLEX_COMPILE_CONTEXT.get() == constants.CompileContext.DEPLOY + assert prep.ssr is False + assert environment.REFLEX_SSR.get() is False + config = get_config() + assert prep.app_name == config.app_name + assert prep.loglevel == config.loglevel + + +def test_prepare_deploy_env_var_overrides_flag( + deploy_env, monkeypatch: pytest.MonkeyPatch +): + """An already-set REFLEX_SSR env var wins over the flag value.""" + monkeypatch.setenv(environment.REFLEX_SSR.name, "False") + + prep = hosting.prepare_deploy(ssr=True) + + assert prep.ssr is False + + +def test_prepare_deploy_reinits_when_needed(deploy_env, mocker: MockerFixture): + """prepare_deploy initializes the app when the app dir needs reinit.""" + mocker.patch.object(hosting.prerequisites, "needs_reinit", return_value=True) + init = mocker.patch("reflex.reflex._init") + + hosting.prepare_deploy() + + init.assert_called_once_with(name=get_config().app_name) + + +def test_export_for_deploy_fills_loglevel(mocker: MockerFixture): + """export_for_deploy forwards its arguments and supplies the loglevel.""" + export = mocker.patch.object(hosting, "export") + + hosting.export_for_deploy( + zip_dest_dir="/tmp/deploy", + api_url="https://api.example.com", + deploy_url="https://app.example.com", + frontend=True, + backend=False, + upload_db_file=False, + zipping=True, + ) + + export.assert_called_once_with( + zip_dest_dir="/tmp/deploy", + api_url="https://api.example.com", + deploy_url="https://app.example.com", + frontend=True, + backend=False, + zipping=True, + loglevel=get_config().loglevel.subprocess_level(), + upload_db_file=False, + backend_excluded_dirs=(), + prerender_routes=True, + ) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 61af7dc88b0..5dfc677c094 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -101,3 +101,18 @@ def test_with_development_condition_preserves_existing_options(): assert env["BUN_OPTIONS"] == "--conditions=development" # The dev condition must not leak into the parent environment. assert environ["NODE_OPTIONS"] == "--max-old-space-size=4096" + + +def test_arbitrate_ssr_stores_flag_when_env_unset(monkeypatch: pytest.MonkeyPatch): + """The flag value is stored in the environment when REFLEX_SSR is unset.""" + monkeypatch.setenv(environment.REFLEX_SSR.name, "") + + assert exec_utils.arbitrate_ssr(False) is False + assert environment.REFLEX_SSR.get() is False + + +def test_arbitrate_ssr_env_var_wins(monkeypatch: pytest.MonkeyPatch): + """An already-set REFLEX_SSR env var overrides the flag value.""" + monkeypatch.setenv(environment.REFLEX_SSR.name, "False") + + assert exec_utils.arbitrate_ssr(True) is False From 2f256b3350eb84944ace34b84d899ce9246366e3 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 27 Aug 2026 01:01:25 +0500 Subject: [PATCH 6/8] fix(hosting-cli): repair the reflex-base coupling #6918 merged into main auth.py imported reflex_base.utils.log directly, which breaks the hosting CLI on reflex versions that predate reflex-base (guarded by test_cli_imports_without_reflex_base, added in #6939 after #6918's last CI run). Route it through the reflex_cli.utils.log shim instead. test_auth.py tripped pyright after the typer upgrade: typer now vendors click, so get_command()'s annotations are incompatible with click.testing.CliRunner. Cast the unwrapped group to click.Group, which is what get_command actually returns at runtime via _patch_typer. --- packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py | 3 +-- tests/units/reflex_cli/v2/test_auth.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py index c9a4a7b0f09..e96d80bb79d 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -9,10 +9,9 @@ import sys import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import TokenValidationError logger = logging.getLogger(__name__) diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index 7b83854af27..506238c6bef 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -2,7 +2,9 @@ import json import logging +from typing import cast +import click import pytest from click.testing import CliRunner from pytest_mock import MockFixture @@ -15,8 +17,12 @@ from typer import Typer from typer.main import get_command -hosting_cli = ( - get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +# deployments._patch_typer wraps the click group in a fake Typer when typer is +# installed; get_command unwraps it back to the underlying click group. The +# cast bridges typer's vendored-click annotations, which pyright rejects. +hosting_cli = cast( + "click.Group", + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli, ) runner = CliRunner() From 1c6ae2c3604124012290443011f1c3f77e1c5beb Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 27 Aug 2026 14:17:14 -0700 Subject: [PATCH 7/8] check_min_deps.py: build local wheelhouse for dev dependencies When reflex-hosting-cli inserts a dev dependency into reflex's own pyproject.toml, the _build environment_ with `require-runtime-dependencies = true` demands that all dependencies be installed from packages. This mechanism satisfies that requirement by pre-building the dev dependencies as their own wheels. --- scripts/check_min_deps.py | 87 +++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/scripts/check_min_deps.py b/scripts/check_min_deps.py index 23fbe2918d7..723633c9ed8 100644 --- a/scripts/check_min_deps.py +++ b/scripts/check_min_deps.py @@ -18,9 +18,13 @@ Development-release pins are the exception to ``--no-sources``. A package may pin a sibling workspace package to an unreleased ``*.dev`` version (e.g. ``reflex-base >= 0.9.5.dev1``) while that version is still unpublished, which would otherwise make resolution from PyPI -impossible. For such pins — and only those — the depended-on package is installed editable -from its local workspace checkout in both environments, so every *non-dev* dependency is -still required to resolve from PyPI. +impossible. For such pins — and only those — a wheel is built from the sibling's local +checkout into a temporary directory that is offered to the resolver as an extra +``--find-links`` index, so every *non-dev* dependency is still required to resolve from +PyPI. A local index is used rather than an extra editable install target because build +environments (a package whose build backend sets ``require-runtime-dependencies`` resolves +its own runtime dependencies to build) are resolved separately from the install targets and +would otherwise not see the unpublished sibling at all. Run with ``uv run python scripts/check_min_deps.py [package ...]``. With no arguments, every checkable package is validated. ``--check-dev-pins [package ...]`` instead scans the @@ -133,8 +137,8 @@ class Package: local_dev_sources: tuple[Path, ...] = () """Project dirs of sibling workspace packages this package pins to a ``*.dev`` release. - These are installed editable from the local checkout (rather than PyPI) in both - resolutions, because the pinned development version is not published. + These are built into a local wheelhouse and made available to the resolver (rather than + PyPI) in both resolutions, because the pinned development version is not published. """ def install_target(self) -> str: @@ -365,11 +369,44 @@ def _pyright_errors(report: dict) -> dict[tuple[str, int, int, str], str]: return errors +def _build_dev_wheelhouse(package: Package, wheelhouse: Path) -> str | None: + """Build wheels for the package's unpublished ``*.dev`` siblings into a local index. + + Args: + package: The package whose dev-pinned siblings should be built. + wheelhouse: Directory to write the wheels into. + + Returns: + ``None`` on success, otherwise the captured output of the failing build. + """ + wheelhouse.mkdir(parents=True, exist_ok=True) + for source in package.local_dev_sources: + build = _run( + [ + "uv", + "build", + "--no-sources", + "--wheel", + # An earlier sibling's wheel may satisfy a later one's own dev pin. + "--find-links", + str(wheelhouse), + "--out-dir", + str(wheelhouse), + str(source), + ], + cwd=REPO_ROOT, + ) + if build.returncode != 0: + return build.stdout + return None + + def _resolve_and_check( package: Package, python_version: str, venv: Path, config: Path, + wheelhouse: Path | None, lowest: bool, ) -> tuple[dict[tuple[str, int, int, str], str] | None, str]: """Install a package into an isolated venv and run pyright against its source. @@ -379,6 +416,8 @@ def _resolve_and_check( python_version: The interpreter version for the venv. venv: Directory in which to create the virtualenv. config: Path to the pyright options config. + wheelhouse: Local index holding wheels for the package's unpublished ``*.dev`` + siblings, or ``None`` when the package has no such pins. lowest: Whether to pin direct dependencies to their declared minimums. Returns: @@ -399,14 +438,16 @@ def _resolve_and_check( venv_python, "--no-sources", ] + # ``--no-sources`` forces every dependency to resolve from PyPI; the lone exception is a + # sibling pinned to an unpublished ``*.dev`` release, whose locally built wheel is offered + # as an extra index. Unlike an editable install target, an index is also consulted while + # resolving build environments, which a ``require-runtime-dependencies`` build hook makes + # subject to the same unpublished pin. + if wheelhouse is not None: + install_cmd += ["--find-links", str(wheelhouse)] if lowest: install_cmd += ["--resolution", "lowest-direct"] install_cmd += ["-e", package.install_target()] - # ``--no-sources`` forces every dependency to resolve from PyPI; the lone exception is a - # sibling pinned to an unpublished ``*.dev`` release, which is provided here as an explicit - # editable from its local checkout so resolution can succeed without reaching PyPI for it. - for source in package.local_dev_sources: - install_cmd += ["-e", str(source)] install = _run(install_cmd, cwd=REPO_ROOT) if install.returncode != 0: return None, install.stdout @@ -450,8 +491,25 @@ def check_package(package: Package, python_version: str) -> Result: config = tmp_path / "pyrightconfig.json" config.write_text(json.dumps({"reportIncompatibleMethodOverride": False})) + wheelhouse = None + if package.local_dev_sources: + wheelhouse = tmp_path / "wheelhouse" + detail = _build_dev_wheelhouse(package, wheelhouse) + if detail is not None: + return Result( + package.name, + False, + "resolution", + f"building unpublished sibling wheels failed:\n{detail}", + ) + baseline, detail = _resolve_and_check( - package, python_version, tmp_path / ".venv-latest", config, lowest=False + package, + python_version, + tmp_path / ".venv-latest", + config, + wheelhouse, + lowest=False, ) if baseline is None: return Result( @@ -462,7 +520,12 @@ def check_package(package: Package, python_version: str) -> Result: ) minimum, detail = _resolve_and_check( - package, python_version, tmp_path / ".venv-lowest", config, lowest=True + package, + python_version, + tmp_path / ".venv-lowest", + config, + wheelhouse, + lowest=True, ) if minimum is None: return Result( From 32a7c89f81cdc4585543bb0a14e289f93c7b2b1a Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 27 Aug 2026 14:20:30 -0700 Subject: [PATCH 8/8] Revert "fix(hosting-cli): repair the reflex-base coupling #6918 merged into main" This reverts commit 2f256b3350eb84944ace34b84d899ce9246366e3. --- packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py | 3 ++- tests/units/reflex_cli/v2/test_auth.py | 10 ++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py index e96d80bb79d..c9a4a7b0f09 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -9,9 +9,10 @@ import sys import click +from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console, log +from reflex_cli.utils import console from reflex_cli.utils.exceptions import TokenValidationError logger = logging.getLogger(__name__) diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index 506238c6bef..7b83854af27 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -2,9 +2,7 @@ import json import logging -from typing import cast -import click import pytest from click.testing import CliRunner from pytest_mock import MockFixture @@ -17,12 +15,8 @@ from typer import Typer from typer.main import get_command -# deployments._patch_typer wraps the click group in a fake Typer when typer is -# installed; get_command unwraps it back to the underlying click group. The -# cast bridges typer's vendored-click annotations, which pyright rejects. -hosting_cli = cast( - "click.Group", - get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli, +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli ) runner = CliRunner()