소스 검색

Separate out the hosting CLI from main repo (#2165)

Martin Xu 1 년 전
부모
커밋
f8395b1fd6
11개의 변경된 파일148개의 추가작업 그리고 2488개의 파일을 삭제
  1. 36 16
      poetry.lock
  2. 1 2
      pyproject.toml
  3. 3 2
      reflex/config.py
  4. 1 3
      reflex/constants/__init__.py
  5. 0 24
      reflex/constants/hosting.py
  6. 33 380
      reflex/reflex.py
  7. 0 48
      reflex/utils/dependency.py
  8. 74 0
      reflex/utils/export.py
  9. 0 1257
      reflex/utils/hosting.py
  10. 0 387
      tests/test_reflex.py
  11. 0 369
      tests/utils/test_hosting.py

+ 36 - 16
poetry.lock

@@ -135,13 +135,13 @@ uvloop = ["uvloop (>=0.15.2)"]
 
 [[package]]
 name = "certifi"
-version = "2023.7.22"
+version = "2023.11.17"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
-    {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
+    {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
+    {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
 ]
 
 [[package]]
@@ -509,40 +509,39 @@ files = [
 
 [[package]]
 name = "httpcore"
-version = "1.0.2"
+version = "0.17.3"
 description = "A minimal low-level HTTP client."
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.7"
 files = [
-    {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
-    {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
+    {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"},
+    {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"},
 ]
 
 [package.dependencies]
+anyio = ">=3.0,<5.0"
 certifi = "*"
 h11 = ">=0.13,<0.15"
+sniffio = "==1.*"
 
 [package.extras]
-asyncio = ["anyio (>=4.0,<5.0)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
-trio = ["trio (>=0.22.0,<0.23.0)"]
 
 [[package]]
 name = "httpx"
-version = "0.25.1"
+version = "0.24.1"
 description = "The next generation HTTP client."
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.7"
 files = [
-    {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"},
-    {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"},
+    {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"},
+    {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"},
 ]
 
 [package.dependencies]
-anyio = "*"
 certifi = "*"
-httpcore = "*"
+httpcore = ">=0.15.0,<0.18.0"
 idna = "*"
 sniffio = "*"
 
@@ -1540,6 +1539,27 @@ async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2
 hiredis = ["hiredis (>=1.0.0)"]
 ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
 
+[[package]]
+name = "reflex-hosting-cli"
+version = "0.1.0"
+description = "Reflex Hosting CLI"
+optional = false
+python-versions = ">=3.8,<4.0"
+files = [
+    {file = "reflex_hosting_cli-0.1.0-py3-none-any.whl", hash = "sha256:0853a8cbd0ba77a0b419aafccf2af1bdbdddada9aee5235c335444036f63999a"},
+    {file = "reflex_hosting_cli-0.1.0.tar.gz", hash = "sha256:53e895f952aedbd9af48e4244cd2d9ad17ac684327097df5784e63b149608e62"},
+]
+
+[package.dependencies]
+coverage = ">=7.3.2,<8.0.0"
+httpx = ">=0.24.0,<0.25.0"
+platformdirs = ">=3.10.0,<4.0.0"
+pydantic = ">=1.10.2,<2.0.0"
+rich = ">=13.0.0,<14.0.0"
+tabulate = ">=0.9.0,<0.10.0"
+typer = ">=0.4.2,<1"
+websockets = ">=10.4"
+
 [[package]]
 name = "rich"
 version = "13.7.0"
@@ -2292,4 +2312,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "f338f335598c2ca5993e30bd1e61d4fb8cefa8698d294c322dc03c972dd3f046"
+content-hash = "20b29357fd945cdc767a86204ac24d2e363bb75801c039e0820f4030e8c243d1"

+ 1 - 2
pyproject.toml

@@ -56,9 +56,8 @@ wrapt = [
     {version = "^1.11.0", python = "<3.11"},
 ]
 packaging = "^23.1"
-tabulate = "^0.9.0"
 pipdeptree = "^2.13.0"
-websockets = ">=10.4"
+reflex-hosting-cli = ">=0.1.0"
 
 [tool.poetry.group.dev.dependencies]
 pytest = "^7.1.2"

+ 3 - 2
reflex/config.py

@@ -9,6 +9,7 @@ import urllib.parse
 from typing import Any, Dict, List, Optional, Set
 
 import pydantic
+from reflex_cli.constants.hosting import Hosting
 
 from reflex import constants
 from reflex.base import Base
@@ -186,9 +187,9 @@ class Config(Base):
     frontend_packages: List[str] = []
 
     # The hosting service backend URL.
-    cp_backend_url: str = constants.Hosting.CP_BACKEND_URL
+    cp_backend_url: str = Hosting.CP_BACKEND_URL
     # The hosting service frontend URL.
-    cp_web_url: str = constants.Hosting.CP_WEB_URL
+    cp_web_url: str = Hosting.CP_WEB_URL
 
     # The worker class used in production mode
     gunicorn_worker_class: str = "uvicorn.workers.UvicornH11Worker"

+ 1 - 3
reflex/constants/__init__.py

@@ -35,7 +35,6 @@ from .config import (
     RequirementsTxt,
 )
 from .event import Endpoint, EventTriggers, SocketEvent
-from .hosting import Hosting
 from .installer import (
     Bun,
     Fnm,
@@ -71,7 +70,6 @@ __ALL__ = [
     Fnm,
     GitIgnore,
     Hooks,
-    RequirementsTxt,
     Imports,
     IS_WINDOWS,
     LOCAL_STORAGE,
@@ -87,6 +85,7 @@ __ALL__ = [
     PYTEST_CURRENT_TEST,
     PRODUCTION_BACKEND_URL,
     Reflex,
+    RequirementsTxt,
     RouteArgType,
     RouteRegex,
     RouteVar,
@@ -100,5 +99,4 @@ __ALL__ = [
     Tailwind,
     Templates,
     CompileVars,
-    Hosting,
 ]

+ 0 - 24
reflex/constants/hosting.py

@@ -1,24 +0,0 @@
-"""Constants related to hosting."""
-import os
-
-from reflex.constants.base import Reflex
-
-
-class Hosting:
-    """Constants related to hosting."""
-
-    # The hosting config json file
-    HOSTING_JSON = os.path.join(Reflex.DIR, "hosting_v0.json")
-    # The hosting service backend URL
-    CP_BACKEND_URL = "https://rxcp-prod-control-plane.fly.dev"
-    # The hosting service webpage URL
-    CP_WEB_URL = "https://control-plane.reflex.run"
-
-    # The number of times to try and wait for the user to complete web authentication.
-    WEB_AUTH_RETRIES = 60
-    # The time to sleep between requests to check if for authentication completion. In seconds.
-    WEB_AUTH_SLEEP_DURATION = 5
-    # The time to wait for the backend to come up after user initiates deployment. In seconds.
-    BACKEND_POLL_RETRIES = 45
-    # The time to wait for the frontend to come up after user initiates deployment. In seconds.
-    FRONTEND_POLL_RETRIES = 30

+ 33 - 380
reflex/reflex.py

@@ -2,25 +2,20 @@
 
 from __future__ import annotations
 
-import asyncio
 import atexit
-import json
 import os
-import shutil
-import tempfile
-import time
 import webbrowser
-from datetime import datetime
 from pathlib import Path
 from typing import List, Optional
 
 import typer
 import typer.core
-from tabulate import tabulate
+from reflex_cli.deployments import deployments_cli
+from reflex_cli.utils import dependency
 
 from reflex import constants
 from reflex.config import get_config
-from reflex.utils import console, dependency, telemetry
+from reflex.utils import console, telemetry
 
 # Disable typer+rich integration for help panels
 typer.core.rich = False  # type: ignore
@@ -277,41 +272,17 @@ def export(
     ),
 ):
     """Export the app to a zip file."""
-    from reflex.utils import build, exec, prerequisites
+    from reflex.utils import export as export_utils
 
-    # Set the log level.
-    console.set_log_level(loglevel)
-
-    # Show system info
-    exec.output_system_info()
-
-    # Check that the app is initialized.
-    prerequisites.check_initialized(frontend=frontend)
-
-    # Compile the app in production mode and export it.
-    console.rule("[bold]Compiling production app and preparing for export.")
-
-    if frontend:
-        # Update some parameters for export
-        prerequisites.update_next_config(export=True)
-        # Ensure module can be imported and app.compile() is called.
-        prerequisites.get_app()
-        # Set up .web directory and install frontend dependencies.
-        build.setup_frontend(Path.cwd())
-
-    # Export the app.
-    build.export(
-        backend=backend,
+    export_utils.export(
+        zipping=zipping,
         frontend=frontend,
-        zip=zipping,
+        backend=backend,
         zip_dest_dir=zip_dest_dir,
-        deploy_url=config.deploy_url,
         upload_db_file=upload_db_file,
+        loglevel=loglevel,
     )
 
-    # Post a telemetry event.
-    telemetry.send("export")
-
 
 @cli.command()
 def login(
@@ -320,7 +291,7 @@ def login(
     ),
 ):
     """Authenticate with Reflex hosting service."""
-    from reflex.utils import hosting
+    from reflex_cli.utils import hosting
 
     # Set the log level.
     console.set_log_level(loglevel)
@@ -347,7 +318,7 @@ def logout(
     ),
 ):
     """Log out of access to Reflex hosting service."""
-    from reflex.utils import hosting
+    from reflex_cli.utils import hosting
 
     console.set_log_level(loglevel)
 
@@ -504,207 +475,45 @@ def deploy(
     ),
 ):
     """Deploy the app to the Reflex hosting service."""
-    from reflex.utils import hosting, prerequisites
+    from reflex_cli import cli as hosting_cli
+
+    from reflex.utils import export as export_utils
+    from reflex.utils import prerequisites
 
     # Set the log level.
     console.set_log_level(loglevel)
 
-    if not interactive and not key:
-        console.error(
-            "Please provide a name for the deployed instance when not in interactive mode."
-        )
-        raise typer.Exit(1)
-
     dependency.check_requirements()
 
     # Check if we are set up.
     prerequisites.check_initialized(frontend=True)
-    enabled_regions = None
-    # If there is already a key, then it is passed in from CLI option in the non-interactive mode
-    if key is not None and not hosting.is_valid_deployment_key(key):
-        console.error(
-            f"Deployment key {key} is not valid. Please use only domain name safe characters."
-        )
-        raise typer.Exit(1)
-    try:
-        # Send a request to server to obtain necessary information
-        # in preparation of a deployment. For example,
-        # server can return confirmation of a particular deployment key,
-        # is available, or suggest a new key, or return an existing deployment.
-        # Some of these are used in the interactive mode.
-        pre_deploy_response = hosting.prepare_deploy(
-            app_name, key=key, frontend_hostname=frontend_hostname
-        )
-        # Note: we likely won't need to fetch this twice
-        if pre_deploy_response.enabled_regions is not None:
-            enabled_regions = pre_deploy_response.enabled_regions
-
-    except Exception as ex:
-        console.error(f"Unable to prepare deployment due to: {ex}")
-        raise typer.Exit(1) from ex
-
-    # The app prefix should not change during the time of preparation
-    app_prefix = pre_deploy_response.app_prefix
-    if not interactive:
-        # in this case, the key was supplied for the pre_deploy call, at this point the reply is expected
-        if (reply := pre_deploy_response.reply) is None:
-            console.error(f"Unable to deploy at this name {key}.")
-            raise typer.Exit(1)
-        api_url = reply.api_url
-        deploy_url = reply.deploy_url
-    else:
-        (
-            key_candidate,
-            api_url,
-            deploy_url,
-        ) = hosting.interactive_get_deployment_key_from_user_input(
-            pre_deploy_response, app_name, frontend_hostname=frontend_hostname
-        )
-        if not key_candidate or not api_url or not deploy_url:
-            console.error("Unable to find a suitable deployment key.")
-            raise typer.Exit(1)
-
-        # Now copy over the candidate to the key
-        key = key_candidate
-
-        # Then CP needs to know the user's location, which requires user permission
-        while True:
-            region_input = console.ask(
-                "Region to deploy to. Enter to use default.",
-                default=regions[0] if regions else "sjc",
-            )
-
-            if enabled_regions is None or region_input in enabled_regions:
-                break
-            else:
-                console.warn(
-                    f"{region_input} is not a valid region. Must be one of {enabled_regions}"
-                )
-                console.warn("Run `reflex deploymemts regions` to see details.")
-        regions = regions or [region_input]
-
-        # process the envs
-        envs = hosting.interactive_prompt_for_envs()
-
-    # Check the required params are valid
-    console.debug(f"{key=}, {regions=}, {app_name=}, {app_prefix=}, {api_url}")
-    if not key or not regions or not app_name or not app_prefix or not api_url:
-        console.error("Please provide all the required parameters.")
-        raise typer.Exit(1)
-    # Note: if the user uses --no-interactive mode, there was no prepare_deploy call
-    # so we do not check the regions until the call to hosting server
-
-    processed_envs = hosting.process_envs(envs) if envs else None
 
-    # Compile the app in production mode.
-    config.api_url = api_url
-    config.deploy_url = deploy_url
-    tmp_dir = tempfile.mkdtemp()
-    try:
-        export(
+    hosting_cli.deploy(
+        app_name=app_name,
+        export_fn=lambda zip_dest_dir, api_url, deploy_url: export_utils.export(
+            zip_dest_dir=zip_dest_dir,
+            api_url=api_url,
+            deploy_url=deploy_url,
             frontend=True,
             backend=True,
             zipping=True,
-            zip_dest_dir=tmp_dir,
             loglevel=loglevel,
             upload_db_file=upload_db_file,
-        )
-    except ImportError as ie:
-        console.error(
-            f"Encountered ImportError, did you install all the dependencies? {ie}"
-        )
-        if os.path.exists(tmp_dir):
-            shutil.rmtree(tmp_dir)
-        raise typer.Exit(1) from ie
-    except Exception as ex:
-        console.error(f"Unable to export due to: {ex}")
-        if os.path.exists(tmp_dir):
-            shutil.rmtree(tmp_dir)
-        raise typer.Exit(1) from ex
-
-    frontend_file_name = constants.ComponentName.FRONTEND.zip()
-    backend_file_name = constants.ComponentName.BACKEND.zip()
-
-    console.print("Uploading code and sending request ...")
-    deploy_requested_at = datetime.now().astimezone()
-    try:
-        deploy_response = hosting.deploy(
-            frontend_file_name=frontend_file_name,
-            backend_file_name=backend_file_name,
-            export_dir=tmp_dir,
-            key=key,
-            app_name=app_name,
-            regions=regions,
-            app_prefix=app_prefix,
-            cpus=cpus,
-            memory_mb=memory_mb,
-            auto_start=auto_start,
-            auto_stop=auto_stop,
-            frontend_hostname=frontend_hostname,
-            envs=processed_envs,
-            with_metrics=with_metrics,
-            with_tracing=with_tracing,
-        )
-    except Exception as ex:
-        console.error(f"Unable to deploy due to: {ex}")
-        raise typer.Exit(1) from ex
-    finally:
-        if os.path.exists(tmp_dir):
-            shutil.rmtree(tmp_dir)
-
-    # Deployment will actually start when data plane reconciles this request
-    console.debug(f"deploy_response: {deploy_response}")
-    console.rule("[bold]Deploying production app.")
-    console.print(
-        "[bold]Deployment will start shortly. Closing this command now will not affect your deployment."
-    )
-
-    # It takes a few seconds for the deployment request to be picked up by server
-    hosting.wait_for_server_to_pick_up_request()
-
-    console.print("Waiting for server to report progress ...")
-    # Display the key events such as build, deploy, etc
-    server_report_deploy_success = hosting.poll_deploy_milestones(
-        key, from_iso_timestamp=deploy_requested_at
+        ),
+        key=key,
+        regions=regions,
+        envs=envs,
+        cpus=cpus,
+        memory_mb=memory_mb,
+        auto_start=auto_start,
+        auto_stop=auto_stop,
+        frontend_hostname=frontend_hostname,
+        interactive=interactive,
+        with_metrics=with_metrics,
+        with_tracing=with_tracing,
+        loglevel=loglevel.value,
     )
 
-    if server_report_deploy_success is None:
-        console.warn("Hosting server timed out.")
-        console.warn("The deployment may still be in progress. Proceeding ...")
-    elif not server_report_deploy_success:
-        console.error("Hosting server reports failure.")
-        console.error(
-            f"Check the server logs using `reflex deployments build-logs {key}`"
-        )
-        raise typer.Exit(1)
-    console.print("Waiting for the new deployment to come up")
-    backend_up = frontend_up = False
-
-    with console.status("Checking backend ..."):
-        for _ in range(constants.Hosting.BACKEND_POLL_RETRIES):
-            if backend_up := hosting.poll_backend(deploy_response.backend_url):
-                break
-            time.sleep(1)
-    if not backend_up:
-        console.print("Backend unreachable")
-
-    with console.status("Checking frontend ..."):
-        for _ in range(constants.Hosting.FRONTEND_POLL_RETRIES):
-            if frontend_up := hosting.poll_frontend(deploy_response.frontend_url):
-                break
-            time.sleep(1)
-    if not frontend_up:
-        console.print("frontend is unreachable")
-
-    if frontend_up and backend_up:
-        console.print(
-            f"Your site [ {key} ] at {regions} is up: {deploy_response.frontend_url}"
-        )
-        return
-    console.warn(f"Your deployment is taking time.")
-    console.warn(f"Check back later on its status: `reflex deployments status {key}`")
-    console.warn(f"For logs: `reflex deployments logs {key}`")
-
 
 @cli.command()
 def demo(
@@ -732,162 +541,6 @@ def demo(
     #     )
 
 
-deployments_cli = typer.Typer()
-
-
-@deployments_cli.command(name="list")
-def list_deployments(
-    loglevel: constants.LogLevel = typer.Option(
-        config.loglevel, help="The log level to use."
-    ),
-    as_json: bool = typer.Option(
-        False, "-j", "--json", help="Whether to output the result in json format."
-    ),
-):
-    """List all the hosted deployments of the authenticated user."""
-    from reflex.utils import hosting
-
-    console.set_log_level(loglevel)
-    try:
-        deployments = hosting.list_deployments()
-    except Exception as ex:
-        console.error(f"Unable to list deployments")
-        raise typer.Exit(1) from ex
-
-    if as_json:
-        console.print(json.dumps(deployments))
-        return
-    if deployments:
-        headers = list(deployments[0].keys())
-        table = [list(deployment.values()) for deployment in deployments]
-        console.print(tabulate(table, headers=headers))
-    else:
-        # If returned empty list, print the empty
-        console.print(str(deployments))
-
-
-@deployments_cli.command(name="delete")
-def delete_deployment(
-    key: str = typer.Argument(..., help="The name of the deployment."),
-    loglevel: constants.LogLevel = typer.Option(
-        config.loglevel, help="The log level to use."
-    ),
-):
-    """Delete a hosted instance."""
-    from reflex.utils import hosting
-
-    console.set_log_level(loglevel)
-    try:
-        hosting.delete_deployment(key)
-    except Exception as ex:
-        console.error(f"Unable to delete deployment")
-        raise typer.Exit(1) from ex
-    console.print(f"Successfully deleted [ {key} ].")
-
-
-@deployments_cli.command(name="status")
-def get_deployment_status(
-    key: str = typer.Argument(..., help="The name of the deployment."),
-    loglevel: constants.LogLevel = typer.Option(
-        config.loglevel, help="The log level to use."
-    ),
-):
-    """Check the status of a deployment."""
-    from reflex.utils import hosting
-
-    console.set_log_level(loglevel)
-
-    try:
-        console.print(f"Getting status for [ {key} ] ...\n")
-        status = hosting.get_deployment_status(key)
-
-        # TODO: refactor all these tabulate calls
-        status.backend.updated_at = hosting.convert_to_local_time_str(
-            status.backend.updated_at or "N/A"
-        )
-        backend_status = status.backend.dict(exclude_none=True)
-        headers = list(backend_status.keys())
-        table = list(backend_status.values())
-        console.print(tabulate([table], headers=headers))
-        # Add a new line in console
-        console.print("\n")
-        status.frontend.updated_at = hosting.convert_to_local_time_str(
-            status.frontend.updated_at or "N/A"
-        )
-        frontend_status = status.frontend.dict(exclude_none=True)
-        headers = list(frontend_status.keys())
-        table = list(frontend_status.values())
-        console.print(tabulate([table], headers=headers))
-    except Exception as ex:
-        console.error(f"Unable to get deployment status")
-        raise typer.Exit(1) from ex
-
-
-@deployments_cli.command(name="logs")
-def get_deployment_logs(
-    key: str = typer.Argument(..., help="The name of the deployment."),
-    loglevel: constants.LogLevel = typer.Option(
-        config.loglevel, help="The log level to use."
-    ),
-):
-    """Get the logs for a deployment."""
-    from reflex.utils import hosting
-
-    console.set_log_level(loglevel)
-    console.print("Note: there is a few seconds delay for logs to be available.")
-    try:
-        asyncio.get_event_loop().run_until_complete(hosting.get_logs(key))
-    except Exception as ex:
-        console.error(f"Unable to get deployment logs")
-        raise typer.Exit(1) from ex
-
-
-@deployments_cli.command(name="build-logs")
-def get_deployment_build_logs(
-    key: str = typer.Argument(..., help="The name of the deployment."),
-    loglevel: constants.LogLevel = typer.Option(
-        config.loglevel, help="The log level to use."
-    ),
-):
-    """Get the logs for a deployment."""
-    from reflex.utils import hosting
-
-    console.set_log_level(loglevel)
-
-    console.print("Note: there is a few seconds delay for logs to be available.")
-    try:
-        # TODO: we need to find a way not to fetch logs
-        # that match the deployed app name but not previously of a different owner
-        # This should not happen often
-        asyncio.run(hosting.get_logs(key, log_type=hosting.LogType.BUILD_LOG))
-    except Exception as ex:
-        console.error(f"Unable to get deployment logs")
-        raise typer.Exit(1) from ex
-
-
-@deployments_cli.command(name="regions")
-def get_deployment_regions(
-    loglevel: constants.LogLevel = typer.Option(
-        config.loglevel, help="The log level to use."
-    ),
-    as_json: bool = typer.Option(
-        False, "-j", "--json", help="Whether to output the result in json format."
-    ),
-):
-    """List all the regions of the hosting service."""
-    from reflex.utils import hosting
-
-    console.set_log_level(loglevel)
-    list_regions_info = hosting.get_regions()
-    if as_json:
-        console.print(json.dumps(list_regions_info))
-        return
-    if list_regions_info:
-        headers = list(list_regions_info[0].keys())
-        table = [list(deployment.values()) for deployment in list_regions_info]
-        console.print(tabulate(table, headers=headers))
-
-
 cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
 cli.add_typer(
     deployments_cli,

+ 0 - 48
reflex/utils/dependency.py

@@ -1,48 +0,0 @@
-"""Building the app and initializing all prerequisites."""
-
-from __future__ import annotations
-
-import os
-import re
-import subprocess
-import sys
-
-from reflex import constants
-from reflex.utils import console
-
-
-def generate_requirements():
-    """Generate a requirements.txt file based on the current environment."""
-    # Run the command and get the output
-    result = subprocess.run(
-        [sys.executable, "-m", "pipdeptree", "--warn", "silence"],
-        capture_output=True,
-        text=True,
-    )
-
-    # Filter the output lines using a regular expression
-    lines = result.stdout.split("\n")
-    filtered_lines = [line for line in lines if re.match(r"^\w+", line)]
-
-    # Write the filtered lines to requirements.txt
-    with open("requirements.txt", "w") as f:
-        for line in filtered_lines:
-            f.write(line + "\n")
-
-
-def check_requirements():
-    """Check if the requirements are installed."""
-    if not os.path.exists(constants.RequirementsTxt.FILE):
-        console.warn("It seems like there's no requirements.txt in your project.")
-        response = console.ask(
-            "Would you like us to auto-generate one based on your current environment?",
-            choices=["y", "n"],
-        )
-
-        if response == "y":
-            generate_requirements()
-        else:
-            console.error(
-                "Please create a requirements.txt file in your project's root directory and try again."
-            )
-            exit()

+ 74 - 0
reflex/utils/export.py

@@ -0,0 +1,74 @@
+"""Export utilities."""
+import os
+from pathlib import Path
+from typing import Optional
+
+from reflex import constants
+from reflex.config import get_config
+from reflex.utils import build, console, exec, prerequisites, telemetry
+
+config = get_config()
+
+
+def export(
+    zipping: bool = True,
+    frontend: bool = True,
+    backend: bool = True,
+    zip_dest_dir: str = os.getcwd(),
+    upload_db_file: bool = False,
+    api_url: Optional[str] = None,
+    deploy_url: Optional[str] = None,
+    loglevel: constants.LogLevel = console._LOG_LEVEL,
+):
+    """Export the app to a zip file.
+
+    Args:
+        zipping: Whether to zip the exported app. Defaults to True.
+        frontend: Whether to export the frontend. Defaults to True.
+        backend: Whether to export the backend. Defaults to True.
+        zip_dest_dir: The directory to export the zip file to. Defaults to os.getcwd().
+        upload_db_file: Whether to upload the database file. Defaults to False.
+        api_url: The API URL to use. Defaults to None.
+        deploy_url: The deploy URL to use. Defaults to None.
+        loglevel: The log level to use. Defaults to console._LOG_LEVEL.
+    """
+    # Set the log level.
+    console.set_log_level(loglevel)
+
+    # Override the config url values if provided.
+    if api_url is not None:
+        config.api_url = str(api_url)
+        console.debug(f"overriding API URL: {config.api_url}")
+    if deploy_url is not None:
+        config.deploy_url = str(deploy_url)
+        console.debug(f"overriding deploy URL: {config.deploy_url}")
+
+    # Show system info
+    exec.output_system_info()
+
+    # Check that the app is initialized.
+    prerequisites.check_initialized(frontend=frontend)
+
+    # Compile the app in production mode and export it.
+    console.rule("[bold]Compiling production app and preparing for export.")
+
+    if frontend:
+        # Update some parameters for export
+        prerequisites.update_next_config(export=True)
+        # Ensure module can be imported and app.compile() is called.
+        prerequisites.get_app()
+        # Set up .web directory and install frontend dependencies.
+        build.setup_frontend(Path.cwd())
+
+    # Export the app.
+    build.export(
+        backend=backend,
+        frontend=frontend,
+        zip=zipping,
+        zip_dest_dir=zip_dest_dir,
+        deploy_url=config.deploy_url,
+        upload_db_file=upload_db_file,
+    )
+
+    # Post a telemetry event.
+    telemetry.send("export")

+ 0 - 1257
reflex/utils/hosting.py

@@ -1,1257 +0,0 @@
-"""Hosting service related utilities."""
-from __future__ import annotations
-
-import contextlib
-import enum
-import json
-import os
-import re
-import time
-import uuid
-import webbrowser
-from datetime import datetime, timedelta
-from http import HTTPStatus
-from typing import List, Optional
-
-import httpx
-import websockets
-from pydantic import Field, ValidationError, root_validator
-
-from reflex import constants
-from reflex.base import Base
-from reflex.config import get_config
-from reflex.utils import console
-
-config = get_config()
-# Endpoint to create or update a deployment
-POST_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments"
-# Endpoint to get all deployments for the user
-GET_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments"
-# Endpoint to fetch information from backend in preparation of a deployment
-POST_DEPLOYMENTS_PREPARE_ENDPOINT = f"{config.cp_backend_url}/deployments/prepare"
-# Endpoint to authenticate current user
-POST_VALIDATE_ME_ENDPOINT = f"{config.cp_backend_url}/authenticate/me"
-# Endpoint to fetch a login token after user completes authentication on web
-FETCH_TOKEN_ENDPOINT = f"{config.cp_backend_url}/authenticate"
-# Endpoint to delete a deployment
-DELETE_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments"
-# Endpoint to get deployment status
-GET_DEPLOYMENT_STATUS_ENDPOINT = f"{config.cp_backend_url}/deployments"
-# Public endpoint to get the list of supported regions in hosting service
-GET_REGIONS_ENDPOINT = f"{config.cp_backend_url}/deployments/regions"
-# Websocket endpoint to stream logs of a deployment
-DEPLOYMENT_LOGS_ENDPOINT = f'{config.cp_backend_url.replace("http", "ws")}/deployments'
-# The HTTP endpoint to fetch logs of a deployment
-POST_DEPLOYMENT_LOGS_ENDPOINT = f"{config.cp_backend_url}/deployments/logs"
-# Expected server response time to new deployment request. In seconds.
-DEPLOYMENT_PICKUP_DELAY = 30
-# End of deployment workflow message. Used to determine if it is the last message from server.
-END_OF_DEPLOYMENT_MESSAGES = ["deploy success"]
-# How many iterations to try and print the deployment event messages from server during deployment.
-DEPLOYMENT_EVENT_MESSAGES_RETRIES = 120
-# Timeout limit for http requests
-HTTP_REQUEST_TIMEOUT = 60  # seconds
-
-
-def get_existing_access_token() -> tuple[str, str]:
-    """Fetch the access token from the existing config if applicable.
-
-    Returns:
-        The access token and the invitation code.
-        If either is not found, return empty string for it instead.
-    """
-    console.debug("Fetching token from existing config...")
-    access_token = invitation_code = ""
-    try:
-        with open(constants.Hosting.HOSTING_JSON, "r") as config_file:
-            hosting_config = json.load(config_file)
-            access_token = hosting_config.get("access_token", "")
-            invitation_code = hosting_config.get("code", "")
-    except Exception as ex:
-        console.debug(
-            f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}"
-        )
-    return access_token, invitation_code
-
-
-def validate_token(token: str):
-    """Validate the token with the control plane.
-
-    Args:
-        token: The access token to validate.
-
-    Raises:
-        ValueError: if access denied.
-        Exception: if runs into timeout, failed requests, unexpected errors. These should be tried again.
-    """
-    try:
-        response = httpx.post(
-            POST_VALIDATE_ME_ENDPOINT,
-            headers=authorization_header(token),
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-        if response.status_code == HTTPStatus.FORBIDDEN:
-            raise ValueError
-        response.raise_for_status()
-    except httpx.RequestError as re:
-        console.debug(f"Request to auth server failed due to {re}")
-        raise Exception(str(re)) from re
-    except httpx.HTTPError as ex:
-        console.debug(f"Unable to validate the token due to: {ex}")
-        raise Exception("server error") from ex
-    except ValueError as ve:
-        console.debug(f"Access denied for {token}")
-        raise ValueError("access denied") from ve
-    except Exception as ex:
-        console.debug(f"Unexpected error: {ex}")
-        raise Exception("internal errors") from ex
-
-
-def delete_token_from_config(include_invitation_code: bool = False):
-    """Delete the invalid token from the config file if applicable.
-
-    Args:
-        include_invitation_code:
-            Whether to delete the invitation code as well.
-            When user logs out, we delete the invitation code together.
-    """
-    if os.path.exists(constants.Hosting.HOSTING_JSON):
-        hosting_config = {}
-        try:
-            with open(constants.Hosting.HOSTING_JSON, "w") as config_file:
-                hosting_config = json.load(config_file)
-                del hosting_config["access_token"]
-                if include_invitation_code:
-                    del hosting_config["code"]
-                json.dump(hosting_config, config_file)
-        except Exception as ex:
-            # Best efforts removing invalid token is OK
-            console.debug(
-                f"Unable to delete the invalid token from config file, err: {ex}"
-            )
-
-
-def save_token_to_config(token: str, code: str | None = None):
-    """Best efforts cache the token, and optionally invitation code to the config file.
-
-    Args:
-        token: The access token to save.
-        code: The invitation code to save if exists.
-    """
-    hosting_config: dict[str, str] = {"access_token": token}
-    if code:
-        hosting_config["code"] = code
-    try:
-        if not os.path.exists(constants.Reflex.DIR):
-            os.makedirs(constants.Reflex.DIR)
-        with open(constants.Hosting.HOSTING_JSON, "w") as config_file:
-            json.dump(hosting_config, config_file)
-    except Exception as ex:
-        console.warn(
-            f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}"
-        )
-
-
-def requires_access_token() -> str:
-    """Fetch the access token from the existing config if applicable.
-
-    Returns:
-        The access token. If not found, return empty string for it instead.
-    """
-    # Check if the user is authenticated
-
-    access_token, _ = get_existing_access_token()
-    if not access_token:
-        console.debug("No access token found from the existing config.")
-
-    return access_token
-
-
-def authenticated_token() -> tuple[str, str]:
-    """Fetch the access token from the existing config if applicable and validate it.
-
-    Returns:
-        The access token and the invitation code.
-        If either is not found, return empty string for it instead.
-    """
-    # Check if the user is authenticated
-
-    access_token, invitation_code = get_existing_access_token()
-    if not access_token:
-        console.debug("No access token found from the existing config.")
-        access_token = ""
-    elif not validate_token_with_retries(access_token):
-        access_token = ""
-
-    return access_token, invitation_code
-
-
-def authorization_header(token: str) -> dict[str, str]:
-    """Construct an authorization header with the specified token as bearer token.
-
-    Args:
-        token: The access token to use.
-
-    Returns:
-        The authorization header in dict format.
-    """
-    return {"Authorization": f"Bearer {token}"}
-
-
-def requires_authenticated() -> str:
-    """Check if the user is authenticated.
-
-    Returns:
-        The validated access token or empty string if not authenticated.
-    """
-    access_token, invitation_code = authenticated_token()
-    if access_token:
-        return access_token
-    return authenticate_on_browser(invitation_code)
-
-
-class DeploymentPrepInfo(Base):
-    """The params/settings returned from the prepare endpoint
-    including the deployment key and the frontend/backend URLs once deployed.
-    The key becomes part of both frontend and backend URLs.
-    """
-
-    # The deployment key
-    key: str
-    # The backend URL
-    api_url: str
-    # The frontend URL
-    deploy_url: str
-
-
-class DeploymentPrepareResponse(Base):
-    """The params/settings returned from the prepare endpoint,
-    used in the CLI for the subsequent launch request.
-    """
-
-    # The app prefix, used on the server side only
-    app_prefix: str
-    # The reply from the server for a prepare request to deploy over a particular key
-    # If reply is not None, it means server confirms the key is available for use.
-    reply: Optional[DeploymentPrepInfo] = None
-    # The list of existing deployments by the user under the same app name.
-    # This is used to allow easy upgrade case when user attempts to deploy
-    # in the same named app directory, user intends to upgrade the existing deployment.
-    existing: Optional[List[DeploymentPrepInfo]] = None
-    # The suggested key name based on the app name.
-    # This is for a new deployment, user has not deployed this app before.
-    # The server returns key suggestion based on the app name.
-    suggestion: Optional[DeploymentPrepInfo] = None
-    enabled_regions: Optional[List[str]] = None
-
-    @root_validator(pre=True)
-    def ensure_at_least_one_deploy_params(cls, values):
-        """Ensure at least one set of param is returned for any of the cases we try to prepare.
-
-        Args:
-            values: The values passed in.
-
-        Raises:
-            ValueError: If all of the optional fields are None.
-
-        Returns:
-            The values passed in.
-        """
-        if (
-            values.get("reply") is None
-            and not values.get("existing")  # existing cannot be an empty list either
-            and values.get("suggestion") is None
-        ):
-            raise ValueError(
-                "At least one set of params for deploy is required from control plane."
-            )
-        return values
-
-
-class DeploymentsPreparePostParam(Base):
-    """Params for app API URL creation backend API."""
-
-    # The app name which is found in the config
-    app_name: str
-    # The deployment key
-    key: Optional[str] = None  #  name of the deployment
-    # The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
-    frontend_hostname: Optional[str] = None
-
-
-def prepare_deploy(
-    app_name: str,
-    key: str | None = None,
-    frontend_hostname: str | None = None,
-) -> DeploymentPrepareResponse:
-    """Send a POST request to Control Plane to prepare a new deployment.
-    Control Plane checks if there is conflict with the key if provided.
-    If the key is absent, it will return existing deployments and a suggested name based on the app_name in the request.
-
-    Args:
-        key: The deployment name.
-        app_name: The app name.
-        frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
-
-    Raises:
-        Exception: If the operation fails. The exception message is the reason.
-
-    Returns:
-        The response containing the backend URLs if successful, None otherwise.
-    """
-    # Check if the user is authenticated
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-    try:
-        response = httpx.post(
-            POST_DEPLOYMENTS_PREPARE_ENDPOINT,
-            headers=authorization_header(token),
-            json=DeploymentsPreparePostParam(
-                app_name=app_name, key=key, frontend_hostname=frontend_hostname
-            ).dict(exclude_none=True),
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-
-        response_json = response.json()
-        console.debug(f"Response from prepare endpoint: {response_json}")
-        if response.status_code == HTTPStatus.FORBIDDEN:
-            console.debug(f'Server responded with 403: {response_json.get("detail")}')
-            raise ValueError(f'{response_json.get("detail", "forbidden")}')
-        response.raise_for_status()
-        return DeploymentPrepareResponse(
-            app_prefix=response_json["app_prefix"],
-            reply=response_json["reply"],
-            suggestion=response_json["suggestion"],
-            existing=response_json["existing"],
-            enabled_regions=response_json.get("enabled_regions"),
-        )
-    except httpx.RequestError as re:
-        console.debug(f"Unable to prepare launch due to {re}.")
-        raise Exception(str(re)) from re
-    except httpx.HTTPError as he:
-        console.debug(f"Unable to prepare deploy due to {he}.")
-        raise Exception(f"{he}") from he
-    except json.JSONDecodeError as jde:
-        console.debug(f"Server did not respond with valid json: {jde}")
-        raise Exception("internal errors") from jde
-    except (KeyError, ValidationError) as kve:
-        console.debug(f"The server response format is unexpected {kve}")
-        raise Exception("internal errors") from kve
-    except ValueError as ve:
-        # This is a recognized client error, currently indicates forbidden
-        raise Exception(f"{ve}") from ve
-    except Exception as ex:
-        console.debug(f"Unexpected error: {ex}.")
-        raise Exception("internal errors") from ex
-
-
-class DeploymentPostResponse(Base):
-    """The URL for the deployed site."""
-
-    # The frontend URL
-    frontend_url: str = Field(..., regex=r"^https?://", min_length=8)
-    # The backend URL
-    backend_url: str = Field(..., regex=r"^https?://", min_length=8)
-
-
-class DeploymentsPostParam(Base):
-    """Params for hosted instance deployment POST request."""
-
-    # Key is the name of the deployment, it becomes part of the URL
-    key: str = Field(..., regex=r"^[a-z0-9-]+$")
-    # Name of the app
-    app_name: str = Field(..., min_length=1)
-    # json encoded list of regions to deploy to
-    regions_json: str = Field(..., min_length=1)
-    # The app prefix, used on the server side only
-    app_prefix: str = Field(..., min_length=1)
-    # The version of reflex CLI used to deploy
-    reflex_version: str = Field(..., min_length=1)
-    # The number of CPUs
-    cpus: Optional[int] = None
-    # The memory in MB
-    memory_mb: Optional[int] = None
-    # Whether to auto start the hosted deployment
-    auto_start: Optional[bool] = None
-    # Whether to auto stop the hosted deployment when idling
-    auto_stop: Optional[bool] = None
-    # The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
-    frontend_hostname: Optional[str] = None
-    # The description of the deployment
-    description: Optional[str] = None
-    # The json encoded list of environment variables
-    envs_json: Optional[str] = None
-    # The command line prefix for tracing
-    reflex_cli_entrypoint: Optional[str] = None
-    # The metrics endpoint
-    metrics_endpoint: Optional[str] = None
-
-
-def deploy(
-    frontend_file_name: str,
-    backend_file_name: str,
-    export_dir: str,
-    key: str,
-    app_name: str,
-    regions: list[str],
-    app_prefix: str,
-    vm_type: str | None = None,
-    cpus: int | None = None,
-    memory_mb: int | None = None,
-    auto_start: bool | None = None,
-    auto_stop: bool | None = None,
-    frontend_hostname: str | None = None,
-    envs: dict[str, str] | None = None,
-    with_tracing: str | None = None,
-    with_metrics: str | None = None,
-) -> DeploymentPostResponse:
-    """Send a POST request to Control Plane to launch a new deployment.
-
-    Args:
-        frontend_file_name: The frontend file name.
-        backend_file_name: The backend file name.
-        export_dir: The directory where the frontend/backend zip files are exported.
-        key: The deployment name.
-        app_name: The app name.
-        regions: The list of regions to deploy to.
-        app_prefix: The app prefix.
-        vm_type: The VM type.
-        cpus: The number of CPUs.
-        memory_mb: The memory in MB.
-        auto_start: Whether to auto start.
-        auto_stop: Whether to auto stop.
-        frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
-        envs: The environment variables.
-        with_tracing: A string indicating the command line prefix for tracing.
-        with_metrics: A string indicating the metrics endpoint.
-
-    Raises:
-        AssertionError: If the request is rejected by the hosting server.
-        Exception: If the operation fails. The exception message is the reason.
-
-    Returns:
-        The response containing the URL of the site to be deployed if successful, None otherwise.
-    """
-    # Check if the user is authenticated
-    if not (token := requires_access_token()):
-        raise Exception("not authenticated")
-
-    try:
-        params = DeploymentsPostParam(
-            key=key,
-            app_name=app_name,
-            regions_json=json.dumps(regions),
-            app_prefix=app_prefix,
-            cpus=cpus,
-            memory_mb=memory_mb,
-            auto_start=auto_start,
-            auto_stop=auto_stop,
-            envs_json=json.dumps(envs) if envs else None,
-            frontend_hostname=frontend_hostname,
-            reflex_version=constants.Reflex.VERSION,
-            reflex_cli_entrypoint=with_tracing,
-            metrics_endpoint=with_metrics,
-        )
-        with open(
-            os.path.join(export_dir, frontend_file_name), "rb"
-        ) as frontend_file, open(
-            os.path.join(export_dir, backend_file_name), "rb"
-        ) as backend_file:
-            # https://docs.python-requests.org/en/latest/user/advanced/#post-multiple-multipart-encoded-files
-            files = [
-                ("files", (frontend_file_name, frontend_file)),
-                ("files", (backend_file_name, backend_file)),
-            ]
-            response = httpx.post(
-                POST_DEPLOYMENTS_ENDPOINT,
-                headers=authorization_header(token),
-                data=params.dict(exclude_none=True),
-                files=files,
-                timeout=HTTP_REQUEST_TIMEOUT,
-            )
-        # If the server explicitly states bad request,
-        # display a different error
-        if response.status_code == HTTPStatus.BAD_REQUEST:
-            raise AssertionError(f"Server rejected this request: {response.text}")
-        response.raise_for_status()
-        response_json = response.json()
-        return DeploymentPostResponse(
-            frontend_url=response_json["frontend_url"],
-            backend_url=response_json["backend_url"],
-        )
-    except OSError as oe:
-        console.error(f"Client side error related to file operation: {oe}")
-        raise
-    except httpx.RequestError as re:
-        console.error(f"Unable to deploy due to request error: {re}")
-        raise Exception("request error") from re
-    except httpx.HTTPError as he:
-        console.error(f"Unable to deploy due to {he}.")
-        raise Exception(str) from he
-    except json.JSONDecodeError as jde:
-        console.error(f"Server did not respond with valid json: {jde}")
-        raise Exception("internal errors") from jde
-    except (KeyError, ValidationError) as kve:
-        console.error(f"Post params or server response format unexpected: {kve}")
-        raise Exception("internal errors") from kve
-    except AssertionError as ve:
-        console.error(f"Unable to deploy due to request error: {ve}")
-        # re-raise the error back to the user as client side error
-        raise
-    except Exception as ex:
-        console.error(f"Unable to deploy due to internal errors: {ex}.")
-        raise Exception("internal errors") from ex
-
-
-class DeploymentsGetParam(Base):
-    """Params for hosted instance GET request."""
-
-    # The app name which is found in the config
-    app_name: Optional[str]
-
-
-def list_deployments(
-    app_name: str | None = None,
-) -> list[dict]:
-    """Send a GET request to Control Plane to list deployments.
-
-    Args:
-        app_name: the app name as an optional filter when listing deployments.
-
-    Raises:
-        Exception: If the operation fails. The exception message shows the reason.
-
-    Returns:
-        The list of deployments if successful, None otherwise.
-    """
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-
-    params = DeploymentsGetParam(app_name=app_name)
-
-    try:
-        response = httpx.get(
-            GET_DEPLOYMENTS_ENDPOINT,
-            headers=authorization_header(token),
-            params=params.dict(exclude_none=True),
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-        response.raise_for_status()
-        return response.json()
-    except httpx.RequestError as re:
-        console.error(f"Unable to list deployments due to request error: {re}")
-        raise Exception("request timeout") from re
-    except httpx.HTTPError as he:
-        console.error(f"Unable to list deployments due to {he}.")
-        raise Exception("internal errors") from he
-    except (ValidationError, KeyError, json.JSONDecodeError) as vkje:
-        console.error(f"Server response format unexpected: {vkje}")
-        raise Exception("internal errors") from vkje
-    except Exception as ex:
-        console.error(f"Unexpected error: {ex}.")
-        raise Exception("internal errors") from ex
-
-
-def fetch_token(request_id: str) -> tuple[str, str]:
-    """Fetch the access token for the request_id from Control Plane.
-
-    Args:
-        request_id: The request ID used when the user opens the browser for authentication.
-
-    Returns:
-        The access token if it exists, None otherwise.
-    """
-    access_token = invitation_code = ""
-    try:
-        resp = httpx.get(
-            f"{FETCH_TOKEN_ENDPOINT}/{request_id}",
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-        resp.raise_for_status()
-        access_token = (resp_json := resp.json()).get("access_token", "")
-        invitation_code = resp_json.get("code", "")
-    except httpx.RequestError as re:
-        console.debug(f"Unable to fetch token due to request error: {re}")
-    except httpx.HTTPError as he:
-        console.debug(f"Unable to fetch token due to {he}")
-    except json.JSONDecodeError as jde:
-        console.debug(f"Server did not respond with valid json: {jde}")
-    except KeyError as ke:
-        console.debug(f"Server response format unexpected: {ke}")
-    except Exception:
-        console.debug("Unexpected errors: {ex}")
-
-    return access_token, invitation_code
-
-
-def poll_backend(backend_url: str) -> bool:
-    """Poll the backend to check if it is up.
-
-    Args:
-        backend_url: The URL of the backend to poll.
-
-    Returns:
-        True if the backend is up, False otherwise.
-    """
-    try:
-        console.debug(f"Polling backend at {backend_url}")
-        resp = httpx.get(f"{backend_url}/ping", timeout=1)
-        resp.raise_for_status()
-        return True
-    except httpx.HTTPError:
-        return False
-
-
-def poll_frontend(frontend_url: str) -> bool:
-    """Poll the frontend to check if it is up.
-
-    Args:
-        frontend_url: The URL of the frontend to poll.
-
-    Returns:
-        True if the frontend is up, False otherwise.
-    """
-    try:
-        console.debug(f"Polling frontend at {frontend_url}")
-        resp = httpx.get(f"{frontend_url}", timeout=1)
-        resp.raise_for_status()
-        return True
-    except httpx.HTTPError:
-        return False
-
-
-class DeploymentDeleteParam(Base):
-    """Params for hosted instance DELETE request."""
-
-    # key is the name of the deployment, it becomes part of the site URL
-    key: str
-
-
-def delete_deployment(key: str):
-    """Send a DELETE request to Control Plane to delete a deployment.
-
-    Args:
-        key: The deployment name.
-
-    Raises:
-        ValueError: If the key is not provided.
-        Exception: If the operation fails. The exception message is the reason.
-    """
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-    if not key:
-        raise ValueError("Valid key is required for the delete.")
-
-    try:
-        response = httpx.delete(
-            f"{DELETE_DEPLOYMENTS_ENDPOINT}/{key}",
-            headers=authorization_header(token),
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-        response.raise_for_status()
-
-    except httpx.TimeoutException as te:
-        console.error("Unable to delete deployment due to request timeout.")
-        raise Exception("request timeout") from te
-    except httpx.HTTPError as he:
-        console.error(f"Unable to delete deployment due to {he}.")
-        raise Exception("internal errors") from he
-    except Exception as ex:
-        console.error(f"Unexpected errors {ex}.")
-        raise Exception("internal errors") from ex
-
-
-class SiteStatus(Base):
-    """Deployment status info."""
-
-    # The frontend URL
-    frontend_url: Optional[str] = None
-    # The backend URL
-    backend_url: Optional[str] = None
-    # Whether the frontend/backend URL is reachable
-    reachable: bool
-    # The last updated iso formatted timestamp if site is reachable
-    updated_at: Optional[str] = None
-
-    @root_validator(pre=True)
-    def ensure_one_of_urls(cls, values):
-        """Ensure at least one of the frontend/backend URLs is provided.
-
-        Args:
-            values: The values passed in.
-
-        Raises:
-            ValueError: If none of the URLs is provided.
-
-        Returns:
-            The values passed in.
-        """
-        if values.get("frontend_url") is None and values.get("backend_url") is None:
-            raise ValueError("At least one of the URLs is required.")
-        return values
-
-
-class DeploymentStatusResponse(Base):
-    """Response for deployment status request."""
-
-    # The frontend status
-    frontend: SiteStatus
-    # The backend status
-    backend: SiteStatus
-
-
-def get_deployment_status(key: str) -> DeploymentStatusResponse:
-    """Get the deployment status.
-
-    Args:
-        key: The deployment name.
-
-    Raises:
-        ValueError: If the key is not provided.
-        Exception: If the operation fails. The exception message is the reason.
-
-    Returns:
-        The deployment status response including backend and frontend.
-    """
-    if not key:
-        raise ValueError(
-            "A non empty key is required for querying the deployment status."
-        )
-
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-
-    try:
-        response = httpx.get(
-            f"{GET_DEPLOYMENT_STATUS_ENDPOINT}/{key}/status",
-            headers=authorization_header(token),
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-        response.raise_for_status()
-        response_json = response.json()
-        return DeploymentStatusResponse(
-            frontend=SiteStatus(
-                frontend_url=response_json["frontend"]["url"],
-                reachable=response_json["frontend"]["reachable"],
-                updated_at=response_json["frontend"]["updated_at"],
-            ),
-            backend=SiteStatus(
-                backend_url=response_json["backend"]["url"],
-                reachable=response_json["backend"]["reachable"],
-                updated_at=response_json["backend"]["updated_at"],
-            ),
-        )
-    except Exception as ex:
-        console.error(f"Unable to get deployment status due to {ex}.")
-        raise Exception("internal errors") from ex
-
-
-def convert_to_local_time_with_tz(iso_timestamp: str) -> datetime | None:
-    """Helper function to convert the iso timestamp to local time.
-
-    Args:
-        iso_timestamp: The iso timestamp to convert.
-
-    Returns:
-        The converted timestamp with timezone.
-    """
-    try:
-        return datetime.fromisoformat(iso_timestamp).astimezone()
-    except (TypeError, ValueError) as ex:
-        console.error(f"Unable to convert iso timestamp {iso_timestamp} due to {ex}.")
-        return None
-
-
-def convert_to_local_time_str(iso_timestamp: str) -> str:
-    """Convert the iso timestamp to local time.
-
-    Args:
-        iso_timestamp: The iso timestamp to convert.
-
-    Returns:
-        The converted timestamp string.
-    """
-    if (local_dt := convert_to_local_time_with_tz(iso_timestamp)) is None:
-        return iso_timestamp
-    return local_dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z")
-
-
-class LogType(str, enum.Enum):
-    """Enum for log types."""
-
-    # Logs printed from the user code, the "app"
-    APP_LOG = "app"
-    # Build logs are the server messages while building/running user deployment
-    BUILD_LOG = "build"
-    # Deploy logs are specifically for the messages at deploy time
-    # returned to the user the current stage of the deployment, such as building, uploading.
-    DEPLOY_LOG = "deploy"
-    # All the logs which can be printed by all above types.
-    ALL_LOG = "all"
-
-
-async def get_logs(
-    key: str,
-    log_type: LogType = LogType.APP_LOG,
-    from_iso_timestamp: datetime | None = None,
-):
-    """Get the deployment logs and stream on console.
-
-    Args:
-        key: The deployment name.
-        log_type: The type of logs to query from server.
-                  See the LogType definitions for how they are used.
-        from_iso_timestamp: An optional timestamp with timezone info to limit
-                            where the log queries should start from.
-
-    Raises:
-        ValueError: If the key is not provided.
-        Exception: If the operation fails. The exception message is the reason.
-
-    """
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-    if not key:
-        raise ValueError("Valid key is required for querying logs.")
-    try:
-        logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={log_type.value}"
-        console.debug(f"log server endpoint: {logs_endpoint}")
-        if from_iso_timestamp is not None:
-            logs_endpoint += (
-                f"&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}"
-            )
-        _ws = websockets.connect(logs_endpoint)  # type: ignore
-        async with _ws as ws:
-            while True:
-                row_json = json.loads(await ws.recv())
-                console.debug(f"Server responded with logs: {row_json}")
-                if row_json and isinstance(row_json, dict):
-                    row_to_print = {}
-                    for k, v in row_json.items():
-                        if v is None:
-                            row_to_print[k] = str(v)
-                        elif k == "timestamp":
-                            row_to_print[k] = convert_to_local_time_str(v)
-                        else:
-                            row_to_print[k] = v
-                    print(" | ".join(row_to_print.values()))
-                else:
-                    console.debug("Server responded, no new logs, this is normal")
-    except Exception as ex:
-        console.debug(f"Unable to get more deployment logs due to {ex}.")
-        console.print("Log server disconnected ...")
-        console.print(
-            "Note that the server has limit to only stream logs for several minutes"
-        )
-
-
-def check_requirements_txt_exist() -> bool:
-    """Check if requirements.txt exists in the top level app directory.
-
-    Returns:
-        True if requirements.txt exists, False otherwise.
-    """
-    return os.path.exists(constants.RequirementsTxt.FILE)
-
-
-def check_requirements_for_non_reflex_packages() -> bool:
-    """Check the requirements.txt file for packages other than reflex.
-
-    Returns:
-        True if packages other than reflex are found, False otherwise.
-    """
-    if not check_requirements_txt_exist():
-        return False
-    try:
-        with open(constants.RequirementsTxt.FILE) as fp:
-            for req_line in fp.readlines():
-                package_name = re.search(r"^([^=<>!~]+)", req_line.lstrip())
-                # If we find a package that is not reflex
-                if (
-                    package_name
-                    and package_name.group(1) != constants.Reflex.MODULE_NAME
-                ):
-                    return True
-    except Exception as ex:
-        console.warn(f"Unable to scan requirements.txt for dependencies due to {ex}")
-
-    return False
-
-
-def authenticate_on_browser(invitation_code: str) -> str:
-    """Open the browser to authenticate the user.
-
-    Args:
-        invitation_code: The invitation code if it exists.
-
-    Returns:
-        The access token if valid, empty otherwise.
-    """
-    console.print(f"Opening {config.cp_web_url} ...")
-    request_id = uuid.uuid4().hex
-    auth_url = f"{config.cp_web_url}?request-id={request_id}&code={invitation_code}"
-    if not webbrowser.open(auth_url):
-        console.warn(
-            f"Unable to automatically open the browser. Please go to {auth_url} to authenticate."
-        )
-    access_token = invitation_code = ""
-    with console.status("Waiting for access token ..."):
-        for _ in range(constants.Hosting.WEB_AUTH_RETRIES):
-            access_token, invitation_code = fetch_token(request_id)
-            if access_token:
-                break
-            else:
-                time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION)
-
-    if access_token and validate_token_with_retries(access_token):
-        save_token_to_config(access_token, invitation_code)
-    else:
-        access_token = ""
-    return access_token
-
-
-def validate_token_with_retries(access_token: str) -> bool:
-    """Validate the access token with retries.
-
-    Args:
-        access_token: The access token to validate.
-
-    Returns:
-        True if the token is valid,
-        False if invalid or unable to validate.
-    """
-    with console.status("Validating access token ..."):
-        for _ in range(constants.Hosting.WEB_AUTH_RETRIES):
-            try:
-                validate_token(access_token)
-                return True
-            except ValueError:
-                console.error(f"Access denied")
-                delete_token_from_config()
-                break
-            except Exception as ex:
-                console.debug(f"Unable to validate token due to: {ex}, trying again")
-                time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION)
-    return False
-
-
-def is_valid_deployment_key(key: str):
-    """Helper function to check if the deployment key is valid. Must be a domain name safe string.
-
-    Args:
-        key: The deployment key to check.
-
-    Returns:
-        True if the key contains only domain name safe characters, False otherwise.
-    """
-    return re.match(r"^[a-zA-Z0-9-]*$", key)
-
-
-def interactive_get_deployment_key_from_user_input(
-    pre_deploy_response: DeploymentPrepareResponse,
-    app_name: str,
-    frontend_hostname: str | None = None,
-) -> tuple[str, str, str]:
-    """Interactive get the deployment key from user input.
-
-    Args:
-        pre_deploy_response: The response from the initial prepare call to server.
-        app_name: The app name.
-        frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain.
-
-    Returns:
-        The deployment key, backend URL, frontend URL.
-    """
-    key_candidate = api_url = deploy_url = ""
-    if reply := pre_deploy_response.reply:
-        api_url = reply.api_url
-        deploy_url = reply.deploy_url
-        key_candidate = reply.key
-    elif pre_deploy_response.existing:
-        # validator already checks existing field is not empty list
-        # Note: keeping this simple as we only allow one deployment per app
-        existing = pre_deploy_response.existing[0]
-        console.print(f"Overwrite deployment [ {existing.key} ] ...")
-        key_candidate = existing.key
-        api_url = existing.api_url
-        deploy_url = existing.deploy_url
-    elif suggestion := pre_deploy_response.suggestion:
-        key_candidate = suggestion.key
-        api_url = suggestion.api_url
-        deploy_url = suggestion.deploy_url
-
-        # If user takes the suggestion, we will use the suggested key and proceed
-        while key_input := console.ask(
-            f"Choose a name for your deployed app. Enter to use default.",
-            default=key_candidate,
-        ):
-            if not is_valid_deployment_key(key_input):
-                console.error(
-                    "Invalid key input, should only contain domain name safe characters: letters, digits, or hyphens."
-                )
-                continue
-
-            elif any(x.isupper() for x in key_input):
-                key_input = key_input.lower()
-                console.info(
-                    f"Domain name is case insensitive, automatically converting to all lower cases: {key_input}"
-                )
-
-            try:
-                pre_deploy_response = prepare_deploy(
-                    app_name,
-                    key=key_input,
-                    frontend_hostname=frontend_hostname,
-                )
-                if (
-                    pre_deploy_response.reply is None
-                    or key_input != pre_deploy_response.reply.key
-                ):
-                    # Rejected by server, try again
-                    continue
-                key_candidate = pre_deploy_response.reply.key
-                api_url = pre_deploy_response.reply.api_url
-                deploy_url = pre_deploy_response.reply.deploy_url
-                # we get the confirmation, so break from the loop
-                break
-            except Exception:
-                console.error(
-                    "Cannot deploy at this name, try picking a different name"
-                )
-
-    return key_candidate, api_url, deploy_url
-
-
-def process_envs(envs: list[str]) -> dict[str, str]:
-    """Process the environment variables.
-
-    Args:
-        envs: The environment variables expected in key=value format.
-
-    Raises:
-        SystemExit: If the envs are not in valid format.
-
-    Returns:
-        The processed environment variables in a dict.
-    """
-    processed_envs = {}
-    for env in envs:
-        kv = env.split("=", maxsplit=1)
-        if len(kv) != 2:
-            raise SystemExit("Invalid env format: should be <key>=<value>.")
-
-        if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", kv[0]):
-            raise SystemExit(
-                "Invalid env name: should start with a letter or underscore, followed by letters, digits, or underscores."
-            )
-        processed_envs[kv[0]] = kv[1]
-    return processed_envs
-
-
-def log_out_on_browser():
-    """Open the browser to authenticate the user."""
-    # Fetching existing invitation code so user sees the log out page without having to enter it
-    invitation_code = None
-    with contextlib.suppress(Exception):
-        _, invitation_code = get_existing_access_token()
-        console.debug("Found existing invitation code in config")
-        delete_token_from_config()
-    console.print(f"Opening {config.cp_web_url} ...")
-    if not webbrowser.open(f"{config.cp_web_url}?code={invitation_code}"):
-        console.warn(
-            f"Unable to open the browser automatically. Please go to {config.cp_web_url} to log out."
-        )
-
-
-def poll_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool | None:
-    """Periodically poll the hosting server for deploy milestones.
-
-    Args:
-        key: The deployment key.
-        from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
-
-    Raises:
-        ValueError: If a non-empty key is not provided.
-        Exception: If the user is not authenticated.
-
-    Returns:
-        False if server reports back failure, True otherwise. None if do not receive the end of deployment message.
-    """
-    if not key:
-        raise ValueError("Non-empty key is required for querying deploy status.")
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-
-    for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
-        try:
-            response = httpx.post(
-                POST_DEPLOYMENT_LOGS_ENDPOINT,
-                json={
-                    "key": key,
-                    "log_type": LogType.DEPLOY_LOG.value,
-                    "from_iso_timestamp": from_iso_timestamp.astimezone().isoformat(),
-                },
-                headers=authorization_header(token),
-            )
-            response.raise_for_status()
-            # The return is expected to be a list of dicts
-            response_json = response.json()
-            for row in response_json:
-                console.print(
-                    " | ".join(
-                        [
-                            convert_to_local_time_str(row["timestamp"]),
-                            row["message"],
-                        ]
-                    )
-                )
-                # update the from timestamp to the last timestamp of received message
-                if (
-                    maybe_timestamp := convert_to_local_time_with_tz(row["timestamp"])
-                ) is not None:
-                    console.debug(
-                        f"Updating from {from_iso_timestamp} to {maybe_timestamp}"
-                    )
-                    # Add a small delta so does not poll the same logs
-                    from_iso_timestamp = maybe_timestamp + timedelta(microseconds=1e5)
-                else:
-                    console.warn(f"Unable to parse timestamp {row['timestamp']}")
-                server_message = row["message"].lower()
-                if "fail" in server_message:
-                    console.debug(
-                        "Received failure message, stop event message streaming"
-                    )
-                    return False
-                if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
-                    console.debug(
-                        "Received end of deployment message, stop event message streaming"
-                    )
-                    return True
-            time.sleep(1)
-        except httpx.HTTPError as he:
-            # This includes HTTP server and client error
-            console.debug(f"Unable to get more deployment events due to {he}.")
-        except Exception as ex:
-            console.warn(f"Unable to parse server response due to {ex}.")
-
-
-async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool:
-    """Display the deploy milestone messages reported back from the hosting server.
-
-    Args:
-        key: The deployment key.
-        from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query.
-
-    Raises:
-        ValueError: If a non-empty key is not provided.
-        Exception: If the user is not authenticated.
-
-    Returns:
-        False if server reports back failure, True otherwise.
-    """
-    if not key:
-        raise ValueError("Non-empty key is required for querying deploy status.")
-    if not (token := requires_authenticated()):
-        raise Exception("not authenticated")
-
-    try:
-        logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={LogType.DEPLOY_LOG.value}&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}"
-        console.debug(f"log server endpoint: {logs_endpoint}")
-        _ws = websockets.connect(logs_endpoint)  # type: ignore
-        async with _ws as ws:
-            # Stream back the deploy events reported back from the server
-            for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES):
-                row_json = json.loads(await ws.recv())
-                console.debug(f"Server responded with: {row_json}")
-                if row_json and isinstance(row_json, dict):
-                    # Only show the timestamp and actual message
-                    console.print(
-                        " | ".join(
-                            [
-                                convert_to_local_time_str(row_json["timestamp"]),
-                                row_json["message"],
-                            ]
-                        )
-                    )
-                    server_message = row_json["message"].lower()
-                    if "fail" in server_message:
-                        console.debug(
-                            "Received failure message, stop event message streaming"
-                        )
-                        return False
-                    if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES):
-                        console.debug(
-                            "Received end of deployment message, stop event message streaming"
-                        )
-                        return True
-                else:
-                    console.debug("Server responded, no new events yet, this is normal")
-    except Exception as ex:
-        console.debug(f"Unable to get more deployment events due to {ex}.")
-    return False
-
-
-def wait_for_server_to_pick_up_request():
-    """Wait for server to pick up the request. Right now is just sleep."""
-    with console.status(
-        f"Waiting for server to pick up request ~ {DEPLOYMENT_PICKUP_DELAY} seconds ..."
-    ):
-        for _ in range(DEPLOYMENT_PICKUP_DELAY):
-            time.sleep(1)
-
-
-def interactive_prompt_for_envs() -> list[str]:
-    """Interactive prompt for environment variables.
-
-    Returns:
-        The list of environment variables in key=value string format.
-    """
-    envs = []
-    envs_finished = False
-    env_count = 1
-    env_key_prompt = f" * env-{env_count} name (enter to skip)"
-    console.print("Environment variables for your production App ...")
-    while not envs_finished:
-        env_key = console.ask(env_key_prompt)
-        if not env_key:
-            envs_finished = True
-            if envs:
-                console.print("Finished adding envs.")
-            else:
-                console.print("No envs added. Continuing ...")
-            break
-        # If it possible to have empty values for env, so we do not check here
-        env_value = console.ask(f"   env-{env_count} value")
-        envs.append(f"{env_key}={env_value}")
-        env_count += 1
-        env_key_prompt = f" * env-{env_count} name (enter to skip)"
-    return envs
-
-
-def get_regions() -> list[dict]:
-    """Get the supported regions from the hosting server.
-
-    Returns:
-        A list of dict representation of the region information.
-    """
-    try:
-        response = httpx.get(
-            GET_REGIONS_ENDPOINT,
-            timeout=HTTP_REQUEST_TIMEOUT,
-        )
-        response.raise_for_status()
-        response_json = response.json()
-        if response_json is None or not isinstance(response_json, list):
-            console.error("Expect server to return a list ")
-            return []
-        if (
-            response_json
-            and response_json[0] is not None
-            and not isinstance(response_json[0], dict)
-        ):
-            console.error("Expect return values are dict's")
-            return []
-        return response_json
-    except Exception as ex:
-        console.error(f"Unable to get regions due to {ex}.")
-        return []

+ 0 - 387
tests/test_reflex.py

@@ -1,387 +0,0 @@
-from functools import reduce
-from unittest.mock import Mock
-
-import pytest
-from typer.testing import CliRunner
-
-from reflex.reflex import cli
-from reflex.utils.hosting import DeploymentPrepInfo
-
-runner = CliRunner()
-
-
-def test_login_success_existing_token(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.authenticated_token",
-        return_value=("fake-token", "fake-code"),
-    )
-    result = runner.invoke(cli, ["login"])
-    assert result.exit_code == 0
-
-
-def test_login_success_on_browser(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.authenticated_token",
-        return_value=("", "fake-code"),
-    )
-    mock_authenticate_on_browser = mocker.patch(
-        "reflex.utils.hosting.authenticate_on_browser", return_value="fake-token"
-    )
-    result = runner.invoke(cli, ["login"])
-    assert result.exit_code == 0
-    mock_authenticate_on_browser.assert_called_once_with("fake-code")
-
-
-def test_login_fail(mocker):
-    # Access token does not exist, but user authenticates successfully on browser.
-    mocker.patch(
-        "reflex.utils.hosting.get_existing_access_token", return_value=("", "")
-    )
-    mocker.patch("reflex.utils.hosting.authenticate_on_browser", return_value="")
-    result = runner.invoke(cli, ["login"])
-    assert result.exit_code == 1
-
-
-@pytest.mark.parametrize(
-    "args",
-    [
-        ["--no-interactive", "-k", "chatroom"],
-        ["--no-interactive", "--deployment-key", "chatroom"],
-        ["--no-interactive", "-r", "sjc"],
-        ["--no-interactive", "--region", "sjc"],
-        ["--no-interactive", "-r", "sjc", "-r", "lax"],
-        ["--no-interactive", "-r", "sjc", "--region", "lax"],
-    ],
-)
-def test_deploy_required_args_missing(args):
-    result = runner.invoke(cli, ["deploy", *args])
-    assert result.exit_code == 1
-
-
-@pytest.fixture
-def setup_env_authentication(mocker):
-    mocker.patch("reflex.utils.prerequisites.check_initialized")
-    mocker.patch("reflex.utils.dependency.check_requirements")
-    mocker.patch("reflex.utils.hosting.authenticated_token", return_value="fake-token")
-    mocker.patch("time.sleep")
-
-
-def test_deploy_non_interactive_prepare_failed(
-    mocker,
-    setup_env_authentication,
-):
-    mocker.patch(
-        "reflex.utils.hosting.prepare_deploy",
-        side_effect=Exception("server did not like params in prepare"),
-    )
-    result = runner.invoke(
-        cli, ["deploy", "--no-interactive", "-k", "chatroom", "-r", "sjc"]
-    )
-    assert result.exit_code == 1
-
-
-@pytest.mark.parametrize(
-    "optional_args,values",
-    [
-        ([], None),
-        (["--env", "k1=v1"], {"envs": {"k1": "v1"}}),
-        (["--cpus", 2], {"cpus": 2}),
-        (["--memory-mb", 2048], {"memory_mb": 2048}),
-        (["--no-auto-start"], {"auto_start": False}),
-        (["--no-auto-stop"], {"auto_stop": False}),
-        (
-            ["--frontend-hostname", "myfrontend.com"],
-            {"frontend_hostname": "myfrontend.com"},
-        ),
-    ],
-)
-def test_deploy_non_interactive_success(
-    mocker, setup_env_authentication, optional_args, values
-):
-    mocker.patch("reflex.utils.console.ask")
-    app_prefix = "fake-prefix"
-    mocker.patch(
-        "reflex.utils.hosting.prepare_deploy",
-        return_value=Mock(
-            app_prefix=app_prefix,
-            reply=Mock(
-                api_url="fake-api-url", deploy_url="fake-deploy-url", key="fake-key"
-            ),
-        ),
-    )
-    fake_export_dir = "fake-export-dir"
-    mocker.patch("tempfile.mkdtemp", return_value=fake_export_dir)
-    mocker.patch("reflex.reflex.export")
-    mock_deploy = mocker.patch(
-        "reflex.utils.hosting.deploy",
-        return_value=Mock(
-            frontend_url="fake-frontend-url", backend_url="fake-backend-url"
-        ),
-    )
-    mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request")
-    mocker.patch("reflex.utils.hosting.poll_deploy_milestones")
-    mocker.patch("reflex.utils.hosting.poll_backend", return_value=True)
-    mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True)
-    # TODO: typer option default not working in test for app name
-    deployment_key = "chatroom-0"
-    app_name = "chatroom"
-    regions = ["sjc"]
-    result = runner.invoke(
-        cli,
-        [
-            "deploy",
-            "--no-interactive",
-            "-k",
-            deployment_key,
-            *reduce(lambda x, y: x + ["-r", y], regions, []),
-            "--app-name",
-            app_name,
-            *optional_args,
-        ],
-    )
-    assert result.exit_code == 0
-
-    expected_call_args = dict(
-        frontend_file_name="frontend.zip",
-        backend_file_name="backend.zip",
-        export_dir=fake_export_dir,
-        key=deployment_key,
-        app_name=app_name,
-        regions=regions,
-        app_prefix=app_prefix,
-        cpus=None,
-        memory_mb=None,
-        auto_start=None,
-        auto_stop=None,
-        frontend_hostname=None,
-        envs=None,
-        with_metrics=None,
-        with_tracing=None,
-    )
-    expected_call_args.update(values or {})
-    assert mock_deploy.call_args.kwargs == expected_call_args
-
-
-def get_app_prefix():
-    return "fake-prefix"
-
-
-def get_deployment_key():
-    return "i-want-this-site"
-
-
-def get_suggested_key():
-    return "suggested-key"
-
-
-def test_deploy_interactive_prepare_failed(
-    mocker,
-    setup_env_authentication,
-):
-    mocker.patch(
-        "reflex.utils.hosting.prepare_deploy",
-        side_effect=Exception("server did not like params in prepare"),
-    )
-    result = runner.invoke(cli, ["deploy"])
-    assert result.exit_code == 1
-
-
-@pytest.mark.parametrize(
-    "app_prefix,deployment_key,prepare_responses,user_input_region,user_input_envs,expected_key,args_patch",
-    [
-        # CLI provides suggestion and but user enters a different key
-        (
-            get_app_prefix(),
-            get_deployment_key(),
-            Mock(
-                app_prefix=get_app_prefix(),
-                reply=None,
-                suggestion=Mock(
-                    api_url="fake-api-url",
-                    deploy_url="fake-deploy-url",
-                    key=get_suggested_key(),
-                ),
-                existing=None,
-                enabled_regions=["sjc"],
-            ),
-            ["sjc"],
-            [],
-            get_deployment_key(),
-            None,
-        ),
-        # CLI provides suggestion and but user enters a different key and enters envs
-        (
-            get_app_prefix(),
-            get_deployment_key(),
-            Mock(
-                app_prefix=get_app_prefix(),
-                reply=None,
-                suggestion=Mock(
-                    api_url="fake-api-url",
-                    deploy_url="fake-deploy-url",
-                    key=get_suggested_key(),
-                ),
-                existing=None,
-                enabled_regions=["sjc"],
-            ),
-            ["sjc"],
-            ["k1=v1", "k2=v2"],
-            get_deployment_key(),
-            {"envs": {"k1": "v1", "k2": "v2"}},
-        ),
-        # CLI provides suggestion and but user takes it
-        (
-            get_app_prefix(),
-            get_deployment_key(),
-            Mock(
-                app_prefix=get_app_prefix(),
-                reply=None,
-                suggestion=Mock(
-                    api_url="fake-api-url",
-                    deploy_url="fake-deploy-url",
-                    key=get_suggested_key(),
-                ),
-                existing=None,
-                enabled_regions=["sjc"],
-            ),
-            ["sjc"],
-            [],
-            get_suggested_key(),
-            None,
-        ),
-        # CLI provides suggestion and but user takes it and enters envs
-        (
-            get_app_prefix(),
-            get_deployment_key(),
-            Mock(
-                app_prefix=get_app_prefix(),
-                reply=None,
-                suggestion=Mock(
-                    api_url="fake-api-url",
-                    deploy_url="fake-deploy-url",
-                    key=get_suggested_key(),
-                ),
-                existing=None,
-                enabled_regions=["sjc"],
-            ),
-            ["sjc"],
-            ["k1=v1", "k3=v3"],
-            get_suggested_key(),
-            {"envs": {"k1": "v1", "k3": "v3"}},
-        ),
-        # User has an existing deployment
-        (
-            get_app_prefix(),
-            get_deployment_key(),
-            Mock(
-                app_prefix=get_app_prefix(),
-                reply=None,
-                existing=Mock(
-                    __getitem__=lambda _, __: DeploymentPrepInfo(
-                        api_url="fake-api-url",
-                        deploy_url="fake-deploy-url",
-                        key=get_deployment_key(),
-                    )
-                ),
-                suggestion=None,
-                enabled_regions=["sjc"],
-            ),
-            ["sjc"],
-            [],
-            get_deployment_key(),
-            None,
-        ),
-        # User has an existing deployment then updates the envs
-        (
-            get_app_prefix(),
-            get_deployment_key(),
-            Mock(
-                app_prefix=get_app_prefix(),
-                reply=None,
-                existing=Mock(
-                    __getitem__=lambda _, __: DeploymentPrepInfo(
-                        api_url="fake-api-url",
-                        deploy_url="fake-deploy-url",
-                        key=get_deployment_key(),
-                    )
-                ),
-                suggestion=None,
-                enabled_regions=["sjc"],
-            ),
-            ["sjc"],
-            ["k4=v4"],
-            get_deployment_key(),
-            {"envs": {"k4": "v4"}},
-        ),
-    ],
-)
-def test_deploy_interactive(
-    mocker,
-    setup_env_authentication,
-    app_prefix,
-    deployment_key,
-    prepare_responses,
-    user_input_region,
-    user_input_envs,
-    expected_key,
-    args_patch,
-):
-    mocker.patch(
-        "reflex.utils.hosting.check_requirements_for_non_reflex_packages",
-        return_value=True,
-    )
-    mocker.patch(
-        "reflex.utils.hosting.prepare_deploy",
-        return_value=prepare_responses,
-    )
-    mocker.patch(
-        "reflex.utils.hosting.interactive_get_deployment_key_from_user_input",
-        return_value=(expected_key, "fake-api-url", "fake-deploy-url"),
-    )
-    mocker.patch("reflex.utils.console.ask", side_effect=user_input_region)
-    mocker.patch(
-        "reflex.utils.hosting.interactive_prompt_for_envs", return_value=user_input_envs
-    )
-    fake_export_dir = "fake-export-dir"
-    mocker.patch("tempfile.mkdtemp", return_value=fake_export_dir)
-    mocker.patch("reflex.reflex.export")
-    mock_deploy = mocker.patch(
-        "reflex.utils.hosting.deploy",
-        return_value=Mock(
-            frontend_url="fake-frontend-url", backend_url="fake-backend-url"
-        ),
-    )
-    mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request")
-    mocker.patch("reflex.utils.hosting.poll_deploy_milestones")
-    mocker.patch("reflex.utils.hosting.poll_backend", return_value=True)
-    mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True)
-
-    # TODO: typer option default not working in test for app name
-    app_name = "fake-app-workaround"
-    regions = ["sjc"]
-    result = runner.invoke(
-        cli,
-        ["deploy", "--app-name", app_name],
-    )
-    assert result.exit_code == 0
-
-    expected_call_args = dict(
-        frontend_file_name="frontend.zip",
-        backend_file_name="backend.zip",
-        export_dir=fake_export_dir,
-        key=expected_key,
-        app_name=app_name,
-        regions=regions,
-        app_prefix=app_prefix,
-        cpus=None,
-        memory_mb=None,
-        auto_start=None,
-        auto_stop=None,
-        frontend_hostname=None,
-        envs=None,
-        with_metrics=None,
-        with_tracing=None,
-    )
-    expected_call_args.update(args_patch or {})
-
-    assert mock_deploy.call_args.kwargs == expected_call_args

+ 0 - 369
tests/utils/test_hosting.py

@@ -1,369 +0,0 @@
-import json
-from unittest.mock import Mock, mock_open
-
-import httpx
-import pytest
-
-from reflex import constants
-from reflex.utils import hosting
-
-
-def test_get_existing_access_token_and_no_invitation_code(mocker):
-    # Config file has token only
-    mock_hosting_config = {"access_token": "ejJhfake_token"}
-    mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config)))
-    token, code = hosting.get_existing_access_token()
-    assert token == mock_hosting_config["access_token"]
-    assert code == ""
-
-
-def test_get_existing_access_token_and_invitation_code(mocker):
-    # Config file has both access token and the invitation code
-    mock_hosting_config = {"access_token": "ejJhfake_token", "code": "fake_code"}
-    mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config)))
-    token, code = hosting.get_existing_access_token()
-    assert token == mock_hosting_config["access_token"]
-    assert code == mock_hosting_config["code"]
-
-
-def test_no_existing_access_token(mocker):
-    # Config file does not have access token
-    mocker.patch(
-        "builtins.open",
-        mock_open(read_data=json.dumps({"no-token": "here", "no-code": "here"})),
-    )
-    access_token, invitation_code = hosting.get_existing_access_token()
-    assert access_token == ""
-    assert invitation_code == ""
-
-
-def test_no_config_file(mocker):
-    # Config file not exist
-    mocker.patch("builtins.open", side_effect=FileNotFoundError)
-    access_token, invitation_code = hosting.get_existing_access_token()
-    assert access_token == ""
-    assert invitation_code == ""
-
-
-def test_empty_config_file(mocker):
-    # Config file is empty
-    mocker.patch("builtins.open", mock_open(read_data=""))
-    access_token, invitation_code = hosting.get_existing_access_token()
-    assert access_token == ""
-    assert invitation_code == ""
-
-
-def test_invalid_json_config_file(mocker):
-    # Config file content is not valid json
-    mocker.patch("builtins.open", mock_open(read_data="im not json content"))
-    access_token, invitation_code = hosting.get_existing_access_token()
-    assert access_token == ""
-    assert invitation_code == ""
-
-
-def test_validate_token_success(mocker):
-    # Valid token passes without raising any exceptions
-    mocker.patch("httpx.post")
-    hosting.validate_token("fake_token")
-
-
-def test_invalid_token_access_denied(mocker):
-    # Invalid token raises an exception
-    mocker.patch("httpx.post", return_value=httpx.Response(403))
-    with pytest.raises(ValueError) as ex:
-        hosting.validate_token("invalid_token")
-        assert ex.value == "access denied"
-
-
-def test_unable_to_validate_token(mocker):
-    # Unable to validate token raises an exception, but not access denied
-    mocker.patch("httpx.post", return_value=httpx.Response(500))
-    with pytest.raises(Exception):
-        hosting.validate_token("invalid_token")
-
-
-def test_delete_access_token_from_config(mocker):
-    config_json = {
-        "access_token": "fake_token",
-        "code": "fake_code",
-        "future": "some value",
-    }
-    mock_f = mock_open(read_data=json.dumps(config_json))
-    mocker.patch("builtins.open", mock_f)
-    mocker.patch("os.path.exists", return_value=True)
-    mock_json_dump = mocker.patch("json.dump")
-    hosting.delete_token_from_config()
-    config_json.pop("access_token")
-    assert mock_json_dump.call_args[0][0] == config_json
-
-
-def test_save_access_token_and_invitation_code_to_config(mocker):
-    access_token = "fake_token"
-    invitation_code = "fake_code"
-    expected_config_json = {
-        "access_token": access_token,
-        "code": invitation_code,
-    }
-    mocker.patch("builtins.open")
-    mock_json_dump = mocker.patch("json.dump")
-    hosting.save_token_to_config(access_token, invitation_code)
-    assert mock_json_dump.call_args[0][0] == expected_config_json
-
-
-def test_save_access_code_but_none_invitation_code_to_config(mocker):
-    access_token = "fake_token"
-    invitation_code = None
-    expected_config_json = {
-        "access_token": access_token,
-        "code": invitation_code,
-    }
-    mocker.patch("builtins.open")
-    mock_json_dump = mocker.patch("json.dump")
-    hosting.save_token_to_config(access_token, invitation_code)
-    expected_config_json.pop("code")
-    assert mock_json_dump.call_args[0][0] == expected_config_json
-
-
-def test_authenticated_token_success(mocker):
-    access_token = "fake_token"
-    invitation_code = "fake_code"
-    mocker.patch(
-        "reflex.utils.hosting.get_existing_access_token",
-        return_value=(access_token, invitation_code),
-    )
-    mocker.patch("reflex.utils.hosting.validate_token_with_retries", return_value=True)
-    assert hosting.authenticated_token() == (access_token, invitation_code)
-
-
-def test_no_authenticated_token(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.get_existing_access_token",
-        return_value=("", "code-does-not-matter"),
-    )
-    assert hosting.authenticated_token()[0] == ""
-
-
-def test_maybe_authenticated_token_is_invalid(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.get_existing_access_token",
-        return_value=("invalid_token", "fake_code"),
-    )
-    mocker.patch("reflex.utils.hosting.validate_token_with_retries", return_value=False)
-    assert hosting.authenticated_token()[0] == ""
-
-
-def test_prepare_deploy_not_authenticated(mocker):
-    mocker.patch("reflex.utils.hosting.requires_authenticated", return_value=None)
-    with pytest.raises(Exception) as ex:
-        hosting.prepare_deploy("fake-app")
-        assert ex.value == "Not authenticated"
-
-
-def test_server_unable_to_prepare_deploy(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.requires_authenticated", return_value="fake_token"
-    )
-    mocker.patch("httpx.post", return_value=httpx.Response(500))
-    with pytest.raises(Exception):
-        hosting.prepare_deploy("fake-app")
-
-
-def test_prepare_deploy_success(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.requires_authenticated", return_value="fake_token"
-    )
-    mocker.patch(
-        "httpx.post",
-        return_value=Mock(
-            status_code=200,
-            json=lambda: dict(
-                app_prefix="fake-app-prefix",
-                reply=dict(
-                    key="fake-key",
-                    api_url="fake-api-url",
-                    deploy_url="fake-deploy-url",
-                ),
-                suggestion=None,
-                existing=[],
-            ),
-        ),
-    )
-    # server returns valid response (format is checked by pydantic model validation)
-    hosting.prepare_deploy("fake-app")
-
-
-def test_deploy(mocker):
-    mocker.patch(
-        "reflex.utils.hosting.requires_access_token", return_value="fake_token"
-    )
-    mocker.patch("builtins.open")
-    mocker.patch(
-        "httpx.post",
-        return_value=Mock(
-            status_code=200,
-            json=lambda: dict(
-                frontend_url="https://fake-url", backend_url="https://fake-url"
-            ),
-        ),
-    )
-    hosting.deploy(
-        frontend_file_name="fake-frontend-path",
-        backend_file_name="fake-backend-path",
-        export_dir="fake-export-dir",
-        key="fake-key",
-        app_name="fake-app-name",
-        regions=["fake-region"],
-        app_prefix="fake-app-prefix",
-    )
-
-
-def test_validate_token_with_retries_failed(mocker):
-    mock_validate_token = mocker.patch(
-        "reflex.utils.hosting.validate_token", side_effect=Exception
-    )
-    mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config")
-    mocker.patch("time.sleep")
-
-    assert hosting.validate_token_with_retries("fake-token") is False
-    assert mock_validate_token.call_count == constants.Hosting.WEB_AUTH_RETRIES
-    assert mock_delete_token.call_count == 0
-
-
-def test_validate_token_with_retries_access_denied(mocker):
-    mock_validate_token = mocker.patch(
-        "reflex.utils.hosting.validate_token", side_effect=ValueError
-    )
-    mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config")
-    mocker.patch("time.sleep")
-    assert hosting.validate_token_with_retries("fake-token") is False
-    assert mock_validate_token.call_count == 1
-    assert mock_delete_token.call_count == 1
-
-
-def test_validate_token_with_retries_success(mocker):
-    validate_token_returns = [Exception, Exception, None]
-    mock_validate_token = mocker.patch(
-        "reflex.utils.hosting.validate_token", side_effect=validate_token_returns
-    )
-    mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config")
-    mocker.patch("time.sleep")
-
-    assert hosting.validate_token_with_retries("fake-token") is True
-    assert mock_validate_token.call_count == len(validate_token_returns)
-    assert mock_delete_token.call_count == 0
-
-
-@pytest.mark.parametrize(
-    "prepare_response, expected",
-    [
-        (
-            hosting.DeploymentPrepareResponse(
-                app_prefix="fake-prefix",
-                reply=hosting.DeploymentPrepInfo(
-                    key="key1", api_url="url11", deploy_url="url12"
-                ),
-                existing=None,
-                suggestion=None,
-            ),
-            ("key1", "url11", "url12"),
-        ),
-        (
-            hosting.DeploymentPrepareResponse(
-                app_prefix="fake-prefix",
-                reply=None,
-                existing=[
-                    hosting.DeploymentPrepInfo(
-                        key="key21", api_url="url211", deploy_url="url212"
-                    ),
-                    hosting.DeploymentPrepInfo(
-                        key="key22", api_url="url21", deploy_url="url22"
-                    ),
-                ],
-                suggestion=None,
-            ),
-            ("key21", "url211", "url212"),
-        ),
-        (
-            hosting.DeploymentPrepareResponse(
-                app_prefix="fake-prefix",
-                reply=None,
-                existing=None,
-                suggestion=hosting.DeploymentPrepInfo(
-                    key="key31", api_url="url31", deploy_url="url31"
-                ),
-            ),
-            ("key31", "url31", "url31"),
-        ),
-    ],
-)
-def test_interactive_get_deployment_key_user_accepts_defaults(
-    mocker, prepare_response, expected
-):
-    mocker.patch("reflex.utils.console.ask", side_effect=[""])
-    assert (
-        hosting.interactive_get_deployment_key_from_user_input(
-            prepare_response, "fake-app"
-        )
-        == expected
-    )
-
-
-def test_interactive_get_deployment_key_user_input_accepted(mocker):
-    mocker.patch("reflex.utils.console.ask", side_effect=["my-site"])
-    mocker.patch(
-        "reflex.utils.hosting.prepare_deploy",
-        return_value=hosting.DeploymentPrepareResponse(
-            app_prefix="fake-prefix",
-            reply=hosting.DeploymentPrepInfo(
-                key="my-site", api_url="url211", deploy_url="url212"
-            ),
-        ),
-    )
-    assert hosting.interactive_get_deployment_key_from_user_input(
-        hosting.DeploymentPrepareResponse(
-            app_prefix="fake-prefix",
-            reply=None,
-            existing=None,
-            suggestion=hosting.DeploymentPrepInfo(
-                key="rejected-key", api_url="rejected-url", deploy_url="rejected-url"
-            ),
-        ),
-        "fake-app",
-    ) == ("my-site", "url211", "url212")
-
-
-def test_process_envs():
-    assert hosting.process_envs(["a=b", "c=d"]) == {"a": "b", "c": "d"}
-
-
-@pytest.mark.parametrize(
-    "inputs, expected",
-    [
-        # enters two envs then enter
-        (
-            ["a", "b", "c", "d", ""],
-            ["a=b", "c=d"],
-        ),
-        # No envs
-        ([""], []),
-        # enters one env with value, one without, then enter
-        (["a", "b", "c", "", ""], ["a=b", "c="]),
-    ],
-)
-def test_interactive_prompt_for_envs(mocker, inputs, expected):
-    mocker.patch("reflex.utils.console.ask", side_effect=inputs)
-    assert hosting.interactive_prompt_for_envs() == expected
-
-
-def test_requirements_txt_only_contains_reflex(mocker):
-    mocker.patch("reflex.utils.hosting.check_requirements_txt_exist", return_value=True)
-    mocker.patch("builtins.open", mock_open(read_data="\nreflex=1.2.3\n\n"))
-    assert hosting.check_requirements_for_non_reflex_packages() is False
-
-
-def test_requirements_txt_only_contains_other_packages(mocker):
-    mocker.patch("reflex.utils.hosting.check_requirements_txt_exist", return_value=True)
-    mocker.patch(
-        "builtins.open", mock_open(read_data="\nreflex=1.2.3\n\npynonexist=3.2.1")
-    )
-    assert hosting.check_requirements_for_non_reflex_packages() is True