[REF-99] Add first version of CLI for hosting service (#1810)
This commit is contained in:
parent
fe244b7eec
commit
07ca8fcb3b
@ -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
27
poetry.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
]
|
||||
|
@ -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"
|
||||
|
49
reflex/constants/hosting.py
Normal file
49
reflex/constants/hosting.py
Normal 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
|
447
reflex/reflex.py
447
reflex/reflex.py
@ -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()
|
||||
|
@ -1 +1 @@
|
||||
"""Reflex utiiities."""
|
||||
"""Reflex utilities."""
|
||||
|
1058
reflex/utils/hosting.py
Normal file
1058
reflex/utils/hosting.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
|
@ -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
416
tests/test_reflex.py
Normal 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
351
tests/utils/test_hosting.py
Normal 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
|
Loading…
Reference in New Issue
Block a user