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

This commit is contained in:
Martin Xu 2023-11-28 15:20:06 -08:00 committed by GitHub
parent 3deb2cec93
commit f8395b1fd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 148 additions and 2488 deletions

52
poetry.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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,
]

View File

@ -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

View File

@ -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."
),
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,
)
# 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
)
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,

View File

@ -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
reflex/utils/export.py Normal file
View File

@ -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")

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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