[REF-99] Add first version of CLI for hosting service (#1810)

This commit is contained in:
Martin Xu 2023-10-21 13:09:56 -07:00 committed by GitHub
parent fe244b7eec
commit 07ca8fcb3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 2429 additions and 6 deletions

View File

@ -5,7 +5,7 @@ branch = true
[report]
show_missing = true
# TODO bump back to 79
fail_under = 75
fail_under = 70
precision = 2
# Regexes for lines to exclude from consideration

27
poetry.lock generated
View File

@ -1735,6 +1735,20 @@ doc = ["mkdocs (>=1.4.2,<2.0.0)", "mkdocs-material (>=9.0.0,<10.0.0)", "mkdocs-s
i18n = ["babel (>=2.12.1)"]
test = ["aiomysql (>=0.1.1,<0.2.0)", "aiosqlite (>=0.17.0,<0.20.0)", "arrow (>=1.2.3,<1.3.0)", "asyncpg (>=0.27.0,<0.28.0)", "backports-zoneinfo", "black (==23.3.0)", "colour (>=0.1.5,<0.2.0)", "coverage (>=7.0.0,<7.3.0)", "fasteners (==0.18)", "httpx (>=0.23.3,<0.25.0)", "itsdangerous (>=2.1.2,<2.2.0)", "mongoengine (>=0.25.0,<0.28.0)", "mypy (==1.3.0)", "odmantic (>=0.9.0,<0.10.0)", "passlib (>=1.7.4,<1.8.0)", "phonenumbers (>=8.13.3,<8.14.0)", "pillow (>=9.4.0,<9.6.0)", "psycopg2-binary (>=2.9.5,<3.0.0)", "pydantic[email] (>=1.10.2,<2.0.0)", "pymysql[rsa] (>=1.0.2,<1.1.0)", "pytest (>=7.2.0,<7.4.0)", "pytest-asyncio (>=0.20.2,<0.22.0)", "ruff (==0.0.261)", "sqlalchemy-file (>=0.4.0,<0.5.0)", "sqlalchemy-utils (>=0.40.0,<0.42.0)", "tinydb (>=4.7.0,<4.8.0)"]
[[package]]
name = "tabulate"
version = "0.9.0"
description = "Pretty-print tabular data"
optional = false
python-versions = ">=3.7"
files = [
{file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"},
{file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"},
]
[package.extras]
widechars = ["wcwidth"]
[[package]]
name = "tenacity"
version = "8.2.3"
@ -1827,6 +1841,17 @@ dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2
doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)"]
test = ["black (>=22.3.0,<23.0.0)", "coverage (>=5.2,<6.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
[[package]]
name = "types-tabulate"
version = "0.9.0.3"
description = "Typing stubs for tabulate"
optional = false
python-versions = "*"
files = [
{file = "types-tabulate-0.9.0.3.tar.gz", hash = "sha256:197651f9d6467193cd166d8500116a6d3a26f2a4eb2db093bc9535ee1c0be55e"},
{file = "types_tabulate-0.9.0.3-py3-none-any.whl", hash = "sha256:462d1b62e01728416e8277614d6a3eb172d53a8efaf04a04a973ff2dd45238f6"},
]
[[package]]
name = "typing-extensions"
version = "4.8.0"
@ -2164,4 +2189,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "a579df3d00492395ace9be92e56b5a2c43f86124522ec1e2fc3151033797eee7"
content-hash = "caa7f188341094c43f7f9b5239ebd75096509ae880be95648b2fd79bf0c84110"

View File

@ -51,6 +51,7 @@ distro = {version = "^1.8.0", platform = "linux"}
python-engineio = "!=4.6.0"
wrapt = "^1.15.0"
packaging = "^23.1"
tabulate = "^0.9.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.1.2"
@ -72,6 +73,7 @@ plotly = "^5.13.0"
asynctest = "^0.13.0"
pre-commit = {version = "^3.2.1", python = ">=3.8,<4.0"}
selenium = "^4.11.0"
types-tabulate = "^0.9.0.3"
[tool.poetry.scripts]
reflex = "reflex.reflex:cli"

View File

@ -30,8 +30,10 @@ from .config import (
Config,
Expiration,
GitIgnore,
RequirementsTxt,
)
from .event import Endpoint, EventTriggers, SocketEvent
from .hosting import Hosting
from .installer import (
Bun,
Fnm,
@ -66,6 +68,7 @@ __ALL__ = [
Ext,
Fnm,
GitIgnore,
RequirementsTxt,
IS_WINDOWS,
LOCAL_STORAGE,
LogLevel,
@ -93,4 +96,5 @@ __ALL__ = [
Tailwind,
Templates,
CompileVars,
Hosting,
]

View File

@ -41,5 +41,14 @@ class GitIgnore(SimpleNamespace):
DEFAULTS = {Dirs.WEB, "*.db", "__pycache__/", "*.py[cod]"}
class RequirementsTxt(SimpleNamespace):
"""Requirements.txt constants."""
# The requirements.txt file.
FILE = "requirements.txt"
# The partial text used to form requirement that pins a reflex version
DEFAULTS_STUB = "reflex=="
# The deployment URL.
PRODUCTION_BACKEND_URL = "https://{username}-{app_name}.api.pynecone.app"

View File

@ -0,0 +1,49 @@
"""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-dev-control-plane.fly.dev"
# The hosting service webpage URL
CP_WEB_URL = "https://control-plane.dev.reflexcorp.run"
# Endpoint to create or update a deployment
POST_DEPLOYMENTS_ENDPOINT = f"{CP_BACKEND_URL}/deployments"
# Endpoint to get all deployments for the user
GET_DEPLOYMENTS_ENDPOINT = f"{CP_BACKEND_URL}/deployments"
# Endpoint to fetch information from backend in preparation of a deployment
POST_DEPLOYMENTS_PREPARE_ENDPOINT = f"{CP_BACKEND_URL}/deployments/prepare"
# Endpoint to authenticate current user
POST_VALIDATE_ME_ENDPOINT = f"{CP_BACKEND_URL}/authenticate/me"
# Endpoint to fetch a login token after user completes authentication on web
FETCH_TOKEN_ENDPOINT = f"{CP_BACKEND_URL}/authenticate"
# Endpoint to delete a deployment
DELETE_DEPLOYMENTS_ENDPOINT = f"{CP_BACKEND_URL}/deployments"
# Endpoint to get deployment status
GET_DEPLOYMENT_STATUS_ENDPOINT = f"{CP_BACKEND_URL}/deployments"
# Websocket endpoint to stream logs of a deployment
DEPLOYMENT_LOGS_ENDPOINT = f'{CP_BACKEND_URL.replace("http", "ws")}/deployments'
# 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 expected number of milestones
MILESTONES_COUNT = 6
# Expected server response time to new deployment request. In seconds.
DEPLOYMENT_PICKUP_DELAY = 30
# The time to wait for the backend to come up after user initiates deployment. In seconds.
BACKEND_POLL_RETRIES = 30
# The time to wait for the frontend to come up after user initiates deployment. In seconds.
FRONTEND_POLL_RETRIES = 30
# End of deployment workflow message. Used to determine if it is the last message from server.
END_OF_DEPLOYMENT_MESSAGES = ["deploy success", "deploy failed"]
# How many iterations to try and print the deployment event messages from server during deployment.
DEPLOYMENT_EVENT_MESSAGES_RETRIES = 30
# Timeout limit for http requests
HTTP_REQUEST_TIMEOUT = 5 # seconds

View File

@ -1,16 +1,32 @@
"""Reflex CLI to create, run, and deploy apps."""
import asyncio
import atexit
import contextlib
import json
import os
import tempfile
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import httpx
import typer
from alembic.util.exc import CommandError
from tabulate import tabulate
from reflex import constants, model
from reflex.config import get_config
from reflex.utils import build, console, exec, prerequisites, processes, telemetry
from reflex.utils import (
build,
console,
exec,
hosting,
prerequisites,
processes,
telemetry,
)
# Create the app.
cli = typer.Typer(add_completion=False)
@ -89,6 +105,9 @@ def init(
# Initialize the .gitignore.
prerequisites.initialize_gitignore()
# Initialize the requirements.txt.
prerequisites.initialize_requirements_txt()
# Finish initializing the app.
console.success(f"Initialized {app_name}")
@ -200,7 +219,7 @@ def run(
@cli.command()
def deploy(
def deploy_legacy(
dry_run: bool = typer.Option(False, help="Whether to run a dry run."),
loglevel: constants.LogLevel = typer.Option(
console._LOG_LEVEL, help="The log level to use."
@ -251,6 +270,11 @@ def export(
backend: bool = typer.Option(
True, "--frontend-only", help="Export only frontend.", show_default=False
),
zip_dest_dir: str = typer.Option(
os.getcwd(),
help="The directory to export the zip files to.",
show_default=False,
),
loglevel: constants.LogLevel = typer.Option(
console._LOG_LEVEL, help="The log level to use."
),
@ -279,6 +303,7 @@ def export(
backend=backend,
frontend=frontend,
zip=zipping,
zip_dest_dir=zip_dest_dir,
deploy_url=config.deploy_url,
)
@ -286,6 +311,65 @@ def export(
telemetry.send("export")
@cli.command()
def login(
loglevel: constants.LogLevel = typer.Option(
config.loglevel, help="The log level to use."
),
):
"""Authenticate with Reflex hosting service."""
# Set the log level.
console.set_log_level(loglevel)
# Check if the user is already logged in.
# Token is the access token, a JWT token obtained from auth provider
# after user completes authentication on web
access_token = None
# For initial hosting offering, it is by invitation only
# The login page is enabled only after a valid invitation code is entered
invitation_code = ""
using_existing_token = False
with contextlib.suppress(Exception):
access_token, invitation_code = hosting.get_existing_access_token()
using_existing_token = True
console.debug("Existing token found, proceed to validate")
# If not already logged in, open a browser window/tab to the login page.
if not using_existing_token:
access_token, invitation_code = hosting.authenticate_on_browser(invitation_code)
if not access_token:
console.error(
f"Unable to fetch access token. Please try again or contact support."
)
raise typer.Exit(1)
if not hosting.validate_token_with_retries(access_token):
console.error(f"Unable to validate token. Please try again or contact support.")
raise typer.Exit(1)
if not using_existing_token:
hosting.save_token_to_config(access_token, invitation_code)
console.print("Successfully logged in.")
else:
console.print("You already logged in.")
@cli.command()
def logout(
loglevel: constants.LogLevel = typer.Option(
config.loglevel, help="The log level to use."
),
):
"""Log out of access to Reflex hosting service."""
console.set_log_level(loglevel)
hosting.log_out_on_browser()
console.debug("Deleting access token from config locally")
hosting.delete_token_from_config()
db_cli = typer.Typer()
@ -352,7 +436,366 @@ def makemigrations(
)
@cli.command()
def deploy(
key: Optional[str] = typer.Option(
None, "-k", "--deployment-key", help="The name of the deployment."
),
app_name: str = typer.Option(
config.app_name,
"--app-name",
help="The name of the App to deploy under.",
),
regions: List[str] = typer.Option(
list(),
"-r",
"--region",
help="The regions to deploy to.",
),
envs: List[str] = typer.Option(
list(),
"--env",
help="The environment variables to set: <key>=<value>. For multiple envs, repeat this option followed by the env name.",
),
cpus: Optional[int] = typer.Option(None, help="The number of CPUs to allocate."),
memory_mb: Optional[int] = typer.Option(
None, help="The amount of memory to allocate."
),
auto_start: Optional[bool] = typer.Option(
True, help="Whether to auto start the instance."
),
auto_stop: Optional[bool] = typer.Option(
True, help="Whether to auto stop the instance."
),
frontend_hostname: Optional[str] = typer.Option(
None, "--frontend-hostname", help="The hostname of the frontend."
),
interactive: Optional[bool] = typer.Option(
True,
help="Whether to list configuration options and ask for confirmation.",
),
with_metrics: Optional[str] = typer.Option(
None,
help="Setting for metrics scraping for the deployment. Setup required in user code.",
),
with_tracing: Optional[str] = typer.Option(
None,
help="Setting to export tracing for the deployment. Setup required in user code.",
),
loglevel: constants.LogLevel = typer.Option(
config.loglevel, help="The log level to use."
),
):
"""Deploy the app to the Reflex hosting service."""
# Set the log level.
console.set_log_level(loglevel)
if not interactive and not key:
console.error("Please provide a deployment key when not in interactive mode.")
raise typer.Exit(1)
try:
hosting.check_requirements_txt_exist()
except Exception as ex:
console.error(f"{constants.RequirementsTxt.FILE} required for deployment")
raise typer.Exit(1) from ex
# Check if we are set up.
prerequisites.check_initialized(frontend=True)
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
)
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
region_input = console.ask(
"Region to deploy to", default=regions[0] if regions else "sjc"
)
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)
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
try:
tmp_dir = tempfile.mkdtemp()
export(
frontend=True,
backend=True,
zipping=True,
zip_dest_dir=tmp_dir,
loglevel=loglevel,
)
except ImportError as ie:
console.error(
f"Encountered ImportError, did you install all the dependencies? {ie}"
)
raise typer.Exit(1) from ie
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
# 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
asyncio.get_event_loop().run_until_complete(
hosting.display_deploy_milestones(key, from_iso_timestamp=deploy_requested_at)
)
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")
else:
console.print("Backend is up")
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")
else:
console.print("frontend is up")
if frontend_up and backend_up:
console.print(
f"Your site [ {key} ] at {regions} is up: {deploy_response.frontend_url}"
)
return
console.warn(
"Your deployment is taking unusually long. Check back later on its status: `reflex deployments status`"
)
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."""
console.set_log_level(loglevel)
try:
deployments = hosting.list_deployments()
except Exception as ex:
console.error(f"Unable to list deployments due to: {ex}")
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."""
console.set_log_level(loglevel)
try:
hosting.delete_deployment(key)
except Exception as ex:
console.error(f"Unable to delete deployment due to: {ex}")
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."""
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(
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(
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 due to: {ex}")
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."""
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 due to: {ex}")
raise typer.Exit(1) from ex
@deployments_cli.command(name="all-logs")
def get_deployment_all_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."""
console.set_log_level(loglevel)
console.print("Note: there is a few seconds delay for logs to be available.")
try:
asyncio.run(hosting.get_logs(key, log_type=hosting.LogType.ALL_LOG))
except Exception as ex:
console.error(f"Unable to get deployment logs due to: {ex}")
raise typer.Exit(1) from ex
@deployments_cli.command(name="deploy-logs")
def get_deployment_deploy_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."""
console.set_log_level(loglevel)
console.print("Note: there is a few seconds delay for logs to be available.")
try:
# TODO: we need to pass in the from time stamp
asyncio.run(hosting.get_logs(key, log_type=hosting.LogType.DEPLOY_LOG))
except Exception as ex:
console.error(f"Unable to get deployment logs due to: {ex}")
raise typer.Exit(1) from ex
cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
cli.add_typer(
deployments_cli,
name="deployments",
help="Subcommands for managing the Deployments.",
)
if __name__ == "__main__":
cli()

View File

@ -1 +1 @@
"""Reflex utiiities."""
"""Reflex utilities."""

1058
reflex/utils/hosting.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -200,6 +200,31 @@ def initialize_gitignore():
f.write(f"{(path_ops.join(sorted(files))).lstrip()}")
def initialize_requirements_txt():
"""Initialize the requirements.txt file.
If absent, generate one for the user.
If the requirements.txt does not have reflex as dependency,
generate a requirement pinning current version and append to
the requirements.txt file.
"""
fp = Path(constants.RequirementsTxt.FILE)
fp.touch(exist_ok=True)
try:
with open(fp, "r") as f:
for req in f.readlines():
# Check if we have a package name that is reflex
if re.match(r"^reflex[^a-zA-Z0-9]", req):
console.debug(f"{fp} already has reflex as dependency.")
return
with open(fp, "a") as f:
f.write(
f"\n{constants.RequirementsTxt.DEFAULTS_STUB}{constants.Reflex.VERSION}\n"
)
except Exception:
console.info(f"Unable to check {fp} for reflex dependency.")
def initialize_app_directory(app_name: str, template: constants.Templates.Kind):
"""Initialize the app directory on reflex init.

View File

@ -1,7 +1,10 @@
from unittest.mock import mock_open
import pytest
from reflex import constants
from reflex.config import Config
from reflex.utils.prerequisites import update_next_config
from reflex.utils.prerequisites import initialize_requirements_txt, update_next_config
@pytest.mark.parametrize(
@ -101,3 +104,41 @@ def test_update_next_config(template_next_config, reflex_config, expected_next_c
assert (
update_next_config(template_next_config, reflex_config) == expected_next_config
)
def test_initialize_requirements_txt(mocker):
# File exists, reflex is included, do nothing
mocker.patch("os.path.exists", return_value=True)
open_mock = mock_open(read_data="reflex==0.2.9")
mocker.patch("builtins.open", open_mock)
initialize_requirements_txt()
assert open_mock.call_count == 1
assert open_mock().write.call_count == 0
def test_initialize_requirements_txt_missing_reflex(mocker):
# File exists, reflex is not included, add reflex
open_mock = mock_open(read_data="random-package=1.2.3")
mocker.patch("builtins.open", open_mock)
initialize_requirements_txt()
# Currently open for read, then open for append
assert open_mock.call_count == 2
assert open_mock().write.call_count == 1
assert (
open_mock().write.call_args[0][0]
== f"\n{constants.RequirementsTxt.DEFAULTS_STUB}{constants.Reflex.VERSION}\n"
)
def test_initialize_requirements_txt_not_exist(mocker):
# File does not exist, create file with reflex
mocker.patch("os.path.exists", return_value=False)
open_mock = mock_open()
mocker.patch("builtins.open", open_mock)
initialize_requirements_txt()
assert open_mock.call_count == 2
assert open_mock().write.call_count == 1
assert (
open_mock().write.call_args[0][0]
== f"\n{constants.RequirementsTxt.DEFAULTS_STUB}{constants.Reflex.VERSION}\n"
)

416
tests/test_reflex.py Normal file
View File

@ -0,0 +1,416 @@
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(mocker):
mock_get_existing_access_token = mocker.patch(
"reflex.utils.hosting.get_existing_access_token",
return_value=("fake-token", "fake-code"),
)
mock_validate_token = mocker.patch(
"reflex.utils.hosting.validate_token_with_retries"
)
result = runner.invoke(cli, ["login"])
assert result.exit_code == 0
mock_get_existing_access_token.assert_called_once()
mock_validate_token.assert_called_once_with("fake-token")
def test_login_existing_token_but_invalid(mocker):
mocker.patch(
"reflex.utils.hosting.get_existing_access_token",
return_value=("fake-token", "fake-code"),
)
mocker.patch(
"reflex.utils.hosting.validate_token",
side_effect=ValueError("token not valid"),
)
mock_delete_token_from_config = mocker.patch(
"reflex.utils.hosting.delete_token_from_config"
)
result = runner.invoke(cli, ["login"])
assert result.exit_code == 1
# Make sure the invalid token delete is performed
mock_delete_token_from_config.assert_called_once()
def test_login_no_existing_token_fetched_valid(mocker):
# Access token does not exist, but user authenticates successfully on browser.
mocker.patch(
"reflex.utils.hosting.get_existing_access_token",
side_effect=Exception("no token found"),
)
# Token is fetched successfully
mocker.patch(
"reflex.utils.hosting.authenticate_on_browser",
return_value=("fake-token2", "fake-code2"),
)
mock_validate_token = mocker.patch(
"reflex.utils.hosting.validate_token_with_retries"
)
mock_save_token_to_config = mocker.patch(
"reflex.utils.hosting.save_token_to_config"
)
result = runner.invoke(cli, ["login"])
assert result.exit_code == 0
mock_validate_token.assert_called_once_with(
"fake-token2",
)
mock_save_token_to_config.assert_called_once_with("fake-token2", "fake-code2")
def test_login_no_existing_token_fetch_none(mocker):
# Access token does not exist, but user authenticates successfully on browser.
mocker.patch(
"reflex.utils.hosting.get_existing_access_token",
side_effect=Exception("no token found"),
)
# Token is not fetched
mocker.patch(
"reflex.utils.hosting.authenticate_on_browser", return_value=(None, None)
)
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.hosting.authenticated_token", return_value="fake-token")
mocker.patch("time.sleep")
mocker.patch("reflex.utils.hosting.check_requirements_txt_exist")
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
):
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.display_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=True,
auto_stop=True,
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,
),
["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,
),
["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,
),
["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,
),
["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,
),
["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,
),
["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.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.display_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=True,
auto_stop=True,
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

351
tests/utils/test_hosting.py Normal file
View File

@ -0,0 +1,351 @@
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 is None
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
mock_hosting_config = {"code": "fake_code"}
mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config)))
with pytest.raises(Exception):
token, _ = hosting.get_existing_access_token()
assert token is None
def test_no_config_file(mocker):
# Config file not exist
mocker.patch("builtins.open", side_effect=FileNotFoundError)
with pytest.raises(Exception) as ex:
hosting.get_existing_access_token()
assert ex.value == "No existing login found"
def test_empty_config_file(mocker):
# Config file is empty
mocker.patch("builtins.open", mock_open(read_data=""))
with pytest.raises(Exception) as ex:
hosting.get_existing_access_token()
assert ex.value == "No existing login found"
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"))
with pytest.raises(Exception) as ex:
hosting.get_existing_access_token()
assert ex.value == "No existing login found"
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"
mocker.patch(
"reflex.utils.hosting.get_existing_access_token",
return_value=(access_token, "fake_code"),
)
mocker.patch("reflex.utils.hosting.validate_token")
assert hosting.authenticated_token() == access_token
def test_no_authenticated_token(mocker):
mocker.patch(
"reflex.utils.hosting.get_existing_access_token",
return_value=(None, None),
)
assert hosting.authenticated_token() is None
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", side_effect=ValueError)
mocker.patch("builtins.open")
mocker.patch("json.load")
mock_json_dump = mocker.patch("json.dump")
assert hosting.authenticated_token() is None
mock_json_dump.assert_called_once()
def test_prepare_deploy_not_authenticated(mocker):
mocker.patch("reflex.utils.hosting.authenticated_token", 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.authenticated_token", 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.authenticated_token", 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.authenticated_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_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")
with pytest.raises(SystemExit):
hosting.validate_token_with_retries("fake-token")
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