Separate out the hosting CLI from main repo (#2165)
This commit is contained in:
parent
3deb2cec93
commit
f8395b1fd6
52
poetry.lock
generated
52
poetry.lock
generated
@ -135,13 +135,13 @@ uvloop = ["uvloop (>=0.15.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2023.7.22"
|
||||
version = "2023.11.17"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
|
||||
{file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
|
||||
{file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
|
||||
{file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -509,40 +509,39 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.2"
|
||||
version = "0.17.3"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
|
||||
{file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
|
||||
{file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"},
|
||||
{file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.0,<5.0"
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
sniffio = "==1.*"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
trio = ["trio (>=0.22.0,<0.23.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.25.1"
|
||||
version = "0.24.1"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"},
|
||||
{file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"},
|
||||
{file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"},
|
||||
{file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = "*"
|
||||
certifi = "*"
|
||||
httpcore = "*"
|
||||
httpcore = ">=0.15.0,<0.18.0"
|
||||
idna = "*"
|
||||
sniffio = "*"
|
||||
|
||||
@ -1540,6 +1539,27 @@ async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2
|
||||
hiredis = ["hiredis (>=1.0.0)"]
|
||||
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "reflex-hosting-cli"
|
||||
version = "0.1.0"
|
||||
description = "Reflex Hosting CLI"
|
||||
optional = false
|
||||
python-versions = ">=3.8,<4.0"
|
||||
files = [
|
||||
{file = "reflex_hosting_cli-0.1.0-py3-none-any.whl", hash = "sha256:0853a8cbd0ba77a0b419aafccf2af1bdbdddada9aee5235c335444036f63999a"},
|
||||
{file = "reflex_hosting_cli-0.1.0.tar.gz", hash = "sha256:53e895f952aedbd9af48e4244cd2d9ad17ac684327097df5784e63b149608e62"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
coverage = ">=7.3.2,<8.0.0"
|
||||
httpx = ">=0.24.0,<0.25.0"
|
||||
platformdirs = ">=3.10.0,<4.0.0"
|
||||
pydantic = ">=1.10.2,<2.0.0"
|
||||
rich = ">=13.0.0,<14.0.0"
|
||||
tabulate = ">=0.9.0,<0.10.0"
|
||||
typer = ">=0.4.2,<1"
|
||||
websockets = ">=10.4"
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.7.0"
|
||||
@ -2292,4 +2312,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.8"
|
||||
content-hash = "f338f335598c2ca5993e30bd1e61d4fb8cefa8698d294c322dc03c972dd3f046"
|
||||
content-hash = "20b29357fd945cdc767a86204ac24d2e363bb75801c039e0820f4030e8c243d1"
|
||||
|
@ -56,9 +56,8 @@ wrapt = [
|
||||
{version = "^1.11.0", python = "<3.11"},
|
||||
]
|
||||
packaging = "^23.1"
|
||||
tabulate = "^0.9.0"
|
||||
pipdeptree = "^2.13.0"
|
||||
websockets = ">=10.4"
|
||||
reflex-hosting-cli = ">=0.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^7.1.2"
|
||||
|
@ -9,6 +9,7 @@ import urllib.parse
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import pydantic
|
||||
from reflex_cli.constants.hosting import Hosting
|
||||
|
||||
from reflex import constants
|
||||
from reflex.base import Base
|
||||
@ -186,9 +187,9 @@ class Config(Base):
|
||||
frontend_packages: List[str] = []
|
||||
|
||||
# The hosting service backend URL.
|
||||
cp_backend_url: str = constants.Hosting.CP_BACKEND_URL
|
||||
cp_backend_url: str = Hosting.CP_BACKEND_URL
|
||||
# The hosting service frontend URL.
|
||||
cp_web_url: str = constants.Hosting.CP_WEB_URL
|
||||
cp_web_url: str = Hosting.CP_WEB_URL
|
||||
|
||||
# The worker class used in production mode
|
||||
gunicorn_worker_class: str = "uvicorn.workers.UvicornH11Worker"
|
||||
|
@ -35,7 +35,6 @@ from .config import (
|
||||
RequirementsTxt,
|
||||
)
|
||||
from .event import Endpoint, EventTriggers, SocketEvent
|
||||
from .hosting import Hosting
|
||||
from .installer import (
|
||||
Bun,
|
||||
Fnm,
|
||||
@ -71,7 +70,6 @@ __ALL__ = [
|
||||
Fnm,
|
||||
GitIgnore,
|
||||
Hooks,
|
||||
RequirementsTxt,
|
||||
Imports,
|
||||
IS_WINDOWS,
|
||||
LOCAL_STORAGE,
|
||||
@ -87,6 +85,7 @@ __ALL__ = [
|
||||
PYTEST_CURRENT_TEST,
|
||||
PRODUCTION_BACKEND_URL,
|
||||
Reflex,
|
||||
RequirementsTxt,
|
||||
RouteArgType,
|
||||
RouteRegex,
|
||||
RouteVar,
|
||||
@ -100,5 +99,4 @@ __ALL__ = [
|
||||
Tailwind,
|
||||
Templates,
|
||||
CompileVars,
|
||||
Hosting,
|
||||
]
|
||||
|
@ -1,24 +0,0 @@
|
||||
"""Constants related to hosting."""
|
||||
import os
|
||||
|
||||
from reflex.constants.base import Reflex
|
||||
|
||||
|
||||
class Hosting:
|
||||
"""Constants related to hosting."""
|
||||
|
||||
# The hosting config json file
|
||||
HOSTING_JSON = os.path.join(Reflex.DIR, "hosting_v0.json")
|
||||
# The hosting service backend URL
|
||||
CP_BACKEND_URL = "https://rxcp-prod-control-plane.fly.dev"
|
||||
# The hosting service webpage URL
|
||||
CP_WEB_URL = "https://control-plane.reflex.run"
|
||||
|
||||
# The number of times to try and wait for the user to complete web authentication.
|
||||
WEB_AUTH_RETRIES = 60
|
||||
# The time to sleep between requests to check if for authentication completion. In seconds.
|
||||
WEB_AUTH_SLEEP_DURATION = 5
|
||||
# The time to wait for the backend to come up after user initiates deployment. In seconds.
|
||||
BACKEND_POLL_RETRIES = 45
|
||||
# The time to wait for the frontend to come up after user initiates deployment. In seconds.
|
||||
FRONTEND_POLL_RETRIES = 30
|
413
reflex/reflex.py
413
reflex/reflex.py
@ -2,25 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import typer
|
||||
import typer.core
|
||||
from tabulate import tabulate
|
||||
from reflex_cli.deployments import deployments_cli
|
||||
from reflex_cli.utils import dependency
|
||||
|
||||
from reflex import constants
|
||||
from reflex.config import get_config
|
||||
from reflex.utils import console, dependency, telemetry
|
||||
from reflex.utils import console, telemetry
|
||||
|
||||
# Disable typer+rich integration for help panels
|
||||
typer.core.rich = False # type: ignore
|
||||
@ -277,41 +272,17 @@ def export(
|
||||
),
|
||||
):
|
||||
"""Export the app to a zip file."""
|
||||
from reflex.utils import build, exec, prerequisites
|
||||
from reflex.utils import export as export_utils
|
||||
|
||||
# Set the log level.
|
||||
console.set_log_level(loglevel)
|
||||
|
||||
# Show system info
|
||||
exec.output_system_info()
|
||||
|
||||
# Check that the app is initialized.
|
||||
prerequisites.check_initialized(frontend=frontend)
|
||||
|
||||
# Compile the app in production mode and export it.
|
||||
console.rule("[bold]Compiling production app and preparing for export.")
|
||||
|
||||
if frontend:
|
||||
# Update some parameters for export
|
||||
prerequisites.update_next_config(export=True)
|
||||
# Ensure module can be imported and app.compile() is called.
|
||||
prerequisites.get_app()
|
||||
# Set up .web directory and install frontend dependencies.
|
||||
build.setup_frontend(Path.cwd())
|
||||
|
||||
# Export the app.
|
||||
build.export(
|
||||
backend=backend,
|
||||
export_utils.export(
|
||||
zipping=zipping,
|
||||
frontend=frontend,
|
||||
zip=zipping,
|
||||
backend=backend,
|
||||
zip_dest_dir=zip_dest_dir,
|
||||
deploy_url=config.deploy_url,
|
||||
upload_db_file=upload_db_file,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
# Post a telemetry event.
|
||||
telemetry.send("export")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def login(
|
||||
@ -320,7 +291,7 @@ def login(
|
||||
),
|
||||
):
|
||||
"""Authenticate with Reflex hosting service."""
|
||||
from reflex.utils import hosting
|
||||
from reflex_cli.utils import hosting
|
||||
|
||||
# Set the log level.
|
||||
console.set_log_level(loglevel)
|
||||
@ -347,7 +318,7 @@ def logout(
|
||||
),
|
||||
):
|
||||
"""Log out of access to Reflex hosting service."""
|
||||
from reflex.utils import hosting
|
||||
from reflex_cli.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
|
||||
@ -504,207 +475,45 @@ def deploy(
|
||||
),
|
||||
):
|
||||
"""Deploy the app to the Reflex hosting service."""
|
||||
from reflex.utils import hosting, prerequisites
|
||||
from reflex_cli import cli as hosting_cli
|
||||
|
||||
from reflex.utils import export as export_utils
|
||||
from reflex.utils import prerequisites
|
||||
|
||||
# Set the log level.
|
||||
console.set_log_level(loglevel)
|
||||
|
||||
if not interactive and not key:
|
||||
console.error(
|
||||
"Please provide a name for the deployed instance when not in interactive mode."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
dependency.check_requirements()
|
||||
|
||||
# Check if we are set up.
|
||||
prerequisites.check_initialized(frontend=True)
|
||||
enabled_regions = None
|
||||
# If there is already a key, then it is passed in from CLI option in the non-interactive mode
|
||||
if key is not None and not hosting.is_valid_deployment_key(key):
|
||||
console.error(
|
||||
f"Deployment key {key} is not valid. Please use only domain name safe characters."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
# Send a request to server to obtain necessary information
|
||||
# in preparation of a deployment. For example,
|
||||
# server can return confirmation of a particular deployment key,
|
||||
# is available, or suggest a new key, or return an existing deployment.
|
||||
# Some of these are used in the interactive mode.
|
||||
pre_deploy_response = hosting.prepare_deploy(
|
||||
app_name, key=key, frontend_hostname=frontend_hostname
|
||||
)
|
||||
# Note: we likely won't need to fetch this twice
|
||||
if pre_deploy_response.enabled_regions is not None:
|
||||
enabled_regions = pre_deploy_response.enabled_regions
|
||||
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to prepare deployment due to: {ex}")
|
||||
raise typer.Exit(1) from ex
|
||||
|
||||
# The app prefix should not change during the time of preparation
|
||||
app_prefix = pre_deploy_response.app_prefix
|
||||
if not interactive:
|
||||
# in this case, the key was supplied for the pre_deploy call, at this point the reply is expected
|
||||
if (reply := pre_deploy_response.reply) is None:
|
||||
console.error(f"Unable to deploy at this name {key}.")
|
||||
raise typer.Exit(1)
|
||||
api_url = reply.api_url
|
||||
deploy_url = reply.deploy_url
|
||||
else:
|
||||
(
|
||||
key_candidate,
|
||||
api_url,
|
||||
deploy_url,
|
||||
) = hosting.interactive_get_deployment_key_from_user_input(
|
||||
pre_deploy_response, app_name, frontend_hostname=frontend_hostname
|
||||
)
|
||||
if not key_candidate or not api_url or not deploy_url:
|
||||
console.error("Unable to find a suitable deployment key.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Now copy over the candidate to the key
|
||||
key = key_candidate
|
||||
|
||||
# Then CP needs to know the user's location, which requires user permission
|
||||
while True:
|
||||
region_input = console.ask(
|
||||
"Region to deploy to. Enter to use default.",
|
||||
default=regions[0] if regions else "sjc",
|
||||
)
|
||||
|
||||
if enabled_regions is None or region_input in enabled_regions:
|
||||
break
|
||||
else:
|
||||
console.warn(
|
||||
f"{region_input} is not a valid region. Must be one of {enabled_regions}"
|
||||
)
|
||||
console.warn("Run `reflex deploymemts regions` to see details.")
|
||||
regions = regions or [region_input]
|
||||
|
||||
# process the envs
|
||||
envs = hosting.interactive_prompt_for_envs()
|
||||
|
||||
# Check the required params are valid
|
||||
console.debug(f"{key=}, {regions=}, {app_name=}, {app_prefix=}, {api_url}")
|
||||
if not key or not regions or not app_name or not app_prefix or not api_url:
|
||||
console.error("Please provide all the required parameters.")
|
||||
raise typer.Exit(1)
|
||||
# Note: if the user uses --no-interactive mode, there was no prepare_deploy call
|
||||
# so we do not check the regions until the call to hosting server
|
||||
|
||||
processed_envs = hosting.process_envs(envs) if envs else None
|
||||
|
||||
# Compile the app in production mode.
|
||||
config.api_url = api_url
|
||||
config.deploy_url = deploy_url
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
export(
|
||||
hosting_cli.deploy(
|
||||
app_name=app_name,
|
||||
export_fn=lambda zip_dest_dir, api_url, deploy_url: export_utils.export(
|
||||
zip_dest_dir=zip_dest_dir,
|
||||
api_url=api_url,
|
||||
deploy_url=deploy_url,
|
||||
frontend=True,
|
||||
backend=True,
|
||||
zipping=True,
|
||||
zip_dest_dir=tmp_dir,
|
||||
loglevel=loglevel,
|
||||
upload_db_file=upload_db_file,
|
||||
)
|
||||
except ImportError as ie:
|
||||
console.error(
|
||||
f"Encountered ImportError, did you install all the dependencies? {ie}"
|
||||
)
|
||||
if os.path.exists(tmp_dir):
|
||||
shutil.rmtree(tmp_dir)
|
||||
raise typer.Exit(1) from ie
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to export due to: {ex}")
|
||||
if os.path.exists(tmp_dir):
|
||||
shutil.rmtree(tmp_dir)
|
||||
raise typer.Exit(1) from ex
|
||||
|
||||
frontend_file_name = constants.ComponentName.FRONTEND.zip()
|
||||
backend_file_name = constants.ComponentName.BACKEND.zip()
|
||||
|
||||
console.print("Uploading code and sending request ...")
|
||||
deploy_requested_at = datetime.now().astimezone()
|
||||
try:
|
||||
deploy_response = hosting.deploy(
|
||||
frontend_file_name=frontend_file_name,
|
||||
backend_file_name=backend_file_name,
|
||||
export_dir=tmp_dir,
|
||||
key=key,
|
||||
app_name=app_name,
|
||||
regions=regions,
|
||||
app_prefix=app_prefix,
|
||||
cpus=cpus,
|
||||
memory_mb=memory_mb,
|
||||
auto_start=auto_start,
|
||||
auto_stop=auto_stop,
|
||||
frontend_hostname=frontend_hostname,
|
||||
envs=processed_envs,
|
||||
with_metrics=with_metrics,
|
||||
with_tracing=with_tracing,
|
||||
)
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to deploy due to: {ex}")
|
||||
raise typer.Exit(1) from ex
|
||||
finally:
|
||||
if os.path.exists(tmp_dir):
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
# Deployment will actually start when data plane reconciles this request
|
||||
console.debug(f"deploy_response: {deploy_response}")
|
||||
console.rule("[bold]Deploying production app.")
|
||||
console.print(
|
||||
"[bold]Deployment will start shortly. Closing this command now will not affect your deployment."
|
||||
),
|
||||
key=key,
|
||||
regions=regions,
|
||||
envs=envs,
|
||||
cpus=cpus,
|
||||
memory_mb=memory_mb,
|
||||
auto_start=auto_start,
|
||||
auto_stop=auto_stop,
|
||||
frontend_hostname=frontend_hostname,
|
||||
interactive=interactive,
|
||||
with_metrics=with_metrics,
|
||||
with_tracing=with_tracing,
|
||||
loglevel=loglevel.value,
|
||||
)
|
||||
|
||||
# It takes a few seconds for the deployment request to be picked up by server
|
||||
hosting.wait_for_server_to_pick_up_request()
|
||||
|
||||
console.print("Waiting for server to report progress ...")
|
||||
# Display the key events such as build, deploy, etc
|
||||
server_report_deploy_success = hosting.poll_deploy_milestones(
|
||||
key, from_iso_timestamp=deploy_requested_at
|
||||
)
|
||||
|
||||
if server_report_deploy_success is None:
|
||||
console.warn("Hosting server timed out.")
|
||||
console.warn("The deployment may still be in progress. Proceeding ...")
|
||||
elif not server_report_deploy_success:
|
||||
console.error("Hosting server reports failure.")
|
||||
console.error(
|
||||
f"Check the server logs using `reflex deployments build-logs {key}`"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print("Waiting for the new deployment to come up")
|
||||
backend_up = frontend_up = False
|
||||
|
||||
with console.status("Checking backend ..."):
|
||||
for _ in range(constants.Hosting.BACKEND_POLL_RETRIES):
|
||||
if backend_up := hosting.poll_backend(deploy_response.backend_url):
|
||||
break
|
||||
time.sleep(1)
|
||||
if not backend_up:
|
||||
console.print("Backend unreachable")
|
||||
|
||||
with console.status("Checking frontend ..."):
|
||||
for _ in range(constants.Hosting.FRONTEND_POLL_RETRIES):
|
||||
if frontend_up := hosting.poll_frontend(deploy_response.frontend_url):
|
||||
break
|
||||
time.sleep(1)
|
||||
if not frontend_up:
|
||||
console.print("frontend is unreachable")
|
||||
|
||||
if frontend_up and backend_up:
|
||||
console.print(
|
||||
f"Your site [ {key} ] at {regions} is up: {deploy_response.frontend_url}"
|
||||
)
|
||||
return
|
||||
console.warn(f"Your deployment is taking time.")
|
||||
console.warn(f"Check back later on its status: `reflex deployments status {key}`")
|
||||
console.warn(f"For logs: `reflex deployments logs {key}`")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def demo(
|
||||
@ -732,162 +541,6 @@ def demo(
|
||||
# )
|
||||
|
||||
|
||||
deployments_cli = typer.Typer()
|
||||
|
||||
|
||||
@deployments_cli.command(name="list")
|
||||
def list_deployments(
|
||||
loglevel: constants.LogLevel = typer.Option(
|
||||
config.loglevel, help="The log level to use."
|
||||
),
|
||||
as_json: bool = typer.Option(
|
||||
False, "-j", "--json", help="Whether to output the result in json format."
|
||||
),
|
||||
):
|
||||
"""List all the hosted deployments of the authenticated user."""
|
||||
from reflex.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
try:
|
||||
deployments = hosting.list_deployments()
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to list deployments")
|
||||
raise typer.Exit(1) from ex
|
||||
|
||||
if as_json:
|
||||
console.print(json.dumps(deployments))
|
||||
return
|
||||
if deployments:
|
||||
headers = list(deployments[0].keys())
|
||||
table = [list(deployment.values()) for deployment in deployments]
|
||||
console.print(tabulate(table, headers=headers))
|
||||
else:
|
||||
# If returned empty list, print the empty
|
||||
console.print(str(deployments))
|
||||
|
||||
|
||||
@deployments_cli.command(name="delete")
|
||||
def delete_deployment(
|
||||
key: str = typer.Argument(..., help="The name of the deployment."),
|
||||
loglevel: constants.LogLevel = typer.Option(
|
||||
config.loglevel, help="The log level to use."
|
||||
),
|
||||
):
|
||||
"""Delete a hosted instance."""
|
||||
from reflex.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
try:
|
||||
hosting.delete_deployment(key)
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to delete deployment")
|
||||
raise typer.Exit(1) from ex
|
||||
console.print(f"Successfully deleted [ {key} ].")
|
||||
|
||||
|
||||
@deployments_cli.command(name="status")
|
||||
def get_deployment_status(
|
||||
key: str = typer.Argument(..., help="The name of the deployment."),
|
||||
loglevel: constants.LogLevel = typer.Option(
|
||||
config.loglevel, help="The log level to use."
|
||||
),
|
||||
):
|
||||
"""Check the status of a deployment."""
|
||||
from reflex.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
|
||||
try:
|
||||
console.print(f"Getting status for [ {key} ] ...\n")
|
||||
status = hosting.get_deployment_status(key)
|
||||
|
||||
# TODO: refactor all these tabulate calls
|
||||
status.backend.updated_at = hosting.convert_to_local_time_str(
|
||||
status.backend.updated_at or "N/A"
|
||||
)
|
||||
backend_status = status.backend.dict(exclude_none=True)
|
||||
headers = list(backend_status.keys())
|
||||
table = list(backend_status.values())
|
||||
console.print(tabulate([table], headers=headers))
|
||||
# Add a new line in console
|
||||
console.print("\n")
|
||||
status.frontend.updated_at = hosting.convert_to_local_time_str(
|
||||
status.frontend.updated_at or "N/A"
|
||||
)
|
||||
frontend_status = status.frontend.dict(exclude_none=True)
|
||||
headers = list(frontend_status.keys())
|
||||
table = list(frontend_status.values())
|
||||
console.print(tabulate([table], headers=headers))
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to get deployment status")
|
||||
raise typer.Exit(1) from ex
|
||||
|
||||
|
||||
@deployments_cli.command(name="logs")
|
||||
def get_deployment_logs(
|
||||
key: str = typer.Argument(..., help="The name of the deployment."),
|
||||
loglevel: constants.LogLevel = typer.Option(
|
||||
config.loglevel, help="The log level to use."
|
||||
),
|
||||
):
|
||||
"""Get the logs for a deployment."""
|
||||
from reflex.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
console.print("Note: there is a few seconds delay for logs to be available.")
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(hosting.get_logs(key))
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to get deployment logs")
|
||||
raise typer.Exit(1) from ex
|
||||
|
||||
|
||||
@deployments_cli.command(name="build-logs")
|
||||
def get_deployment_build_logs(
|
||||
key: str = typer.Argument(..., help="The name of the deployment."),
|
||||
loglevel: constants.LogLevel = typer.Option(
|
||||
config.loglevel, help="The log level to use."
|
||||
),
|
||||
):
|
||||
"""Get the logs for a deployment."""
|
||||
from reflex.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
|
||||
console.print("Note: there is a few seconds delay for logs to be available.")
|
||||
try:
|
||||
# TODO: we need to find a way not to fetch logs
|
||||
# that match the deployed app name but not previously of a different owner
|
||||
# This should not happen often
|
||||
asyncio.run(hosting.get_logs(key, log_type=hosting.LogType.BUILD_LOG))
|
||||
except Exception as ex:
|
||||
console.error(f"Unable to get deployment logs")
|
||||
raise typer.Exit(1) from ex
|
||||
|
||||
|
||||
@deployments_cli.command(name="regions")
|
||||
def get_deployment_regions(
|
||||
loglevel: constants.LogLevel = typer.Option(
|
||||
config.loglevel, help="The log level to use."
|
||||
),
|
||||
as_json: bool = typer.Option(
|
||||
False, "-j", "--json", help="Whether to output the result in json format."
|
||||
),
|
||||
):
|
||||
"""List all the regions of the hosting service."""
|
||||
from reflex.utils import hosting
|
||||
|
||||
console.set_log_level(loglevel)
|
||||
list_regions_info = hosting.get_regions()
|
||||
if as_json:
|
||||
console.print(json.dumps(list_regions_info))
|
||||
return
|
||||
if list_regions_info:
|
||||
headers = list(list_regions_info[0].keys())
|
||||
table = [list(deployment.values()) for deployment in list_regions_info]
|
||||
console.print(tabulate(table, headers=headers))
|
||||
|
||||
|
||||
cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
|
||||
cli.add_typer(
|
||||
deployments_cli,
|
||||
|
@ -1,48 +0,0 @@
|
||||
"""Building the app and initializing all prerequisites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from reflex import constants
|
||||
from reflex.utils import console
|
||||
|
||||
|
||||
def generate_requirements():
|
||||
"""Generate a requirements.txt file based on the current environment."""
|
||||
# Run the command and get the output
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pipdeptree", "--warn", "silence"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Filter the output lines using a regular expression
|
||||
lines = result.stdout.split("\n")
|
||||
filtered_lines = [line for line in lines if re.match(r"^\w+", line)]
|
||||
|
||||
# Write the filtered lines to requirements.txt
|
||||
with open("requirements.txt", "w") as f:
|
||||
for line in filtered_lines:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def check_requirements():
|
||||
"""Check if the requirements are installed."""
|
||||
if not os.path.exists(constants.RequirementsTxt.FILE):
|
||||
console.warn("It seems like there's no requirements.txt in your project.")
|
||||
response = console.ask(
|
||||
"Would you like us to auto-generate one based on your current environment?",
|
||||
choices=["y", "n"],
|
||||
)
|
||||
|
||||
if response == "y":
|
||||
generate_requirements()
|
||||
else:
|
||||
console.error(
|
||||
"Please create a requirements.txt file in your project's root directory and try again."
|
||||
)
|
||||
exit()
|
74
reflex/utils/export.py
Normal file
74
reflex/utils/export.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Export utilities."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from reflex import constants
|
||||
from reflex.config import get_config
|
||||
from reflex.utils import build, console, exec, prerequisites, telemetry
|
||||
|
||||
config = get_config()
|
||||
|
||||
|
||||
def export(
|
||||
zipping: bool = True,
|
||||
frontend: bool = True,
|
||||
backend: bool = True,
|
||||
zip_dest_dir: str = os.getcwd(),
|
||||
upload_db_file: bool = False,
|
||||
api_url: Optional[str] = None,
|
||||
deploy_url: Optional[str] = None,
|
||||
loglevel: constants.LogLevel = console._LOG_LEVEL,
|
||||
):
|
||||
"""Export the app to a zip file.
|
||||
|
||||
Args:
|
||||
zipping: Whether to zip the exported app. Defaults to True.
|
||||
frontend: Whether to export the frontend. Defaults to True.
|
||||
backend: Whether to export the backend. Defaults to True.
|
||||
zip_dest_dir: The directory to export the zip file to. Defaults to os.getcwd().
|
||||
upload_db_file: Whether to upload the database file. Defaults to False.
|
||||
api_url: The API URL to use. Defaults to None.
|
||||
deploy_url: The deploy URL to use. Defaults to None.
|
||||
loglevel: The log level to use. Defaults to console._LOG_LEVEL.
|
||||
"""
|
||||
# Set the log level.
|
||||
console.set_log_level(loglevel)
|
||||
|
||||
# Override the config url values if provided.
|
||||
if api_url is not None:
|
||||
config.api_url = str(api_url)
|
||||
console.debug(f"overriding API URL: {config.api_url}")
|
||||
if deploy_url is not None:
|
||||
config.deploy_url = str(deploy_url)
|
||||
console.debug(f"overriding deploy URL: {config.deploy_url}")
|
||||
|
||||
# Show system info
|
||||
exec.output_system_info()
|
||||
|
||||
# Check that the app is initialized.
|
||||
prerequisites.check_initialized(frontend=frontend)
|
||||
|
||||
# Compile the app in production mode and export it.
|
||||
console.rule("[bold]Compiling production app and preparing for export.")
|
||||
|
||||
if frontend:
|
||||
# Update some parameters for export
|
||||
prerequisites.update_next_config(export=True)
|
||||
# Ensure module can be imported and app.compile() is called.
|
||||
prerequisites.get_app()
|
||||
# Set up .web directory and install frontend dependencies.
|
||||
build.setup_frontend(Path.cwd())
|
||||
|
||||
# Export the app.
|
||||
build.export(
|
||||
backend=backend,
|
||||
frontend=frontend,
|
||||
zip=zipping,
|
||||
zip_dest_dir=zip_dest_dir,
|
||||
deploy_url=config.deploy_url,
|
||||
upload_db_file=upload_db_file,
|
||||
)
|
||||
|
||||
# Post a telemetry event.
|
||||
telemetry.send("export")
|
File diff suppressed because it is too large
Load Diff
@ -1,387 +0,0 @@
|
||||
from functools import reduce
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reflex.reflex import cli
|
||||
from reflex.utils.hosting import DeploymentPrepInfo
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_login_success_existing_token(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.authenticated_token",
|
||||
return_value=("fake-token", "fake-code"),
|
||||
)
|
||||
result = runner.invoke(cli, ["login"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_login_success_on_browser(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.authenticated_token",
|
||||
return_value=("", "fake-code"),
|
||||
)
|
||||
mock_authenticate_on_browser = mocker.patch(
|
||||
"reflex.utils.hosting.authenticate_on_browser", return_value="fake-token"
|
||||
)
|
||||
result = runner.invoke(cli, ["login"])
|
||||
assert result.exit_code == 0
|
||||
mock_authenticate_on_browser.assert_called_once_with("fake-code")
|
||||
|
||||
|
||||
def test_login_fail(mocker):
|
||||
# Access token does not exist, but user authenticates successfully on browser.
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.get_existing_access_token", return_value=("", "")
|
||||
)
|
||||
mocker.patch("reflex.utils.hosting.authenticate_on_browser", return_value="")
|
||||
result = runner.invoke(cli, ["login"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["--no-interactive", "-k", "chatroom"],
|
||||
["--no-interactive", "--deployment-key", "chatroom"],
|
||||
["--no-interactive", "-r", "sjc"],
|
||||
["--no-interactive", "--region", "sjc"],
|
||||
["--no-interactive", "-r", "sjc", "-r", "lax"],
|
||||
["--no-interactive", "-r", "sjc", "--region", "lax"],
|
||||
],
|
||||
)
|
||||
def test_deploy_required_args_missing(args):
|
||||
result = runner.invoke(cli, ["deploy", *args])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_env_authentication(mocker):
|
||||
mocker.patch("reflex.utils.prerequisites.check_initialized")
|
||||
mocker.patch("reflex.utils.dependency.check_requirements")
|
||||
mocker.patch("reflex.utils.hosting.authenticated_token", return_value="fake-token")
|
||||
mocker.patch("time.sleep")
|
||||
|
||||
|
||||
def test_deploy_non_interactive_prepare_failed(
|
||||
mocker,
|
||||
setup_env_authentication,
|
||||
):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.prepare_deploy",
|
||||
side_effect=Exception("server did not like params in prepare"),
|
||||
)
|
||||
result = runner.invoke(
|
||||
cli, ["deploy", "--no-interactive", "-k", "chatroom", "-r", "sjc"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"optional_args,values",
|
||||
[
|
||||
([], None),
|
||||
(["--env", "k1=v1"], {"envs": {"k1": "v1"}}),
|
||||
(["--cpus", 2], {"cpus": 2}),
|
||||
(["--memory-mb", 2048], {"memory_mb": 2048}),
|
||||
(["--no-auto-start"], {"auto_start": False}),
|
||||
(["--no-auto-stop"], {"auto_stop": False}),
|
||||
(
|
||||
["--frontend-hostname", "myfrontend.com"],
|
||||
{"frontend_hostname": "myfrontend.com"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_deploy_non_interactive_success(
|
||||
mocker, setup_env_authentication, optional_args, values
|
||||
):
|
||||
mocker.patch("reflex.utils.console.ask")
|
||||
app_prefix = "fake-prefix"
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.prepare_deploy",
|
||||
return_value=Mock(
|
||||
app_prefix=app_prefix,
|
||||
reply=Mock(
|
||||
api_url="fake-api-url", deploy_url="fake-deploy-url", key="fake-key"
|
||||
),
|
||||
),
|
||||
)
|
||||
fake_export_dir = "fake-export-dir"
|
||||
mocker.patch("tempfile.mkdtemp", return_value=fake_export_dir)
|
||||
mocker.patch("reflex.reflex.export")
|
||||
mock_deploy = mocker.patch(
|
||||
"reflex.utils.hosting.deploy",
|
||||
return_value=Mock(
|
||||
frontend_url="fake-frontend-url", backend_url="fake-backend-url"
|
||||
),
|
||||
)
|
||||
mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request")
|
||||
mocker.patch("reflex.utils.hosting.poll_deploy_milestones")
|
||||
mocker.patch("reflex.utils.hosting.poll_backend", return_value=True)
|
||||
mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True)
|
||||
# TODO: typer option default not working in test for app name
|
||||
deployment_key = "chatroom-0"
|
||||
app_name = "chatroom"
|
||||
regions = ["sjc"]
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"--no-interactive",
|
||||
"-k",
|
||||
deployment_key,
|
||||
*reduce(lambda x, y: x + ["-r", y], regions, []),
|
||||
"--app-name",
|
||||
app_name,
|
||||
*optional_args,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
expected_call_args = dict(
|
||||
frontend_file_name="frontend.zip",
|
||||
backend_file_name="backend.zip",
|
||||
export_dir=fake_export_dir,
|
||||
key=deployment_key,
|
||||
app_name=app_name,
|
||||
regions=regions,
|
||||
app_prefix=app_prefix,
|
||||
cpus=None,
|
||||
memory_mb=None,
|
||||
auto_start=None,
|
||||
auto_stop=None,
|
||||
frontend_hostname=None,
|
||||
envs=None,
|
||||
with_metrics=None,
|
||||
with_tracing=None,
|
||||
)
|
||||
expected_call_args.update(values or {})
|
||||
assert mock_deploy.call_args.kwargs == expected_call_args
|
||||
|
||||
|
||||
def get_app_prefix():
|
||||
return "fake-prefix"
|
||||
|
||||
|
||||
def get_deployment_key():
|
||||
return "i-want-this-site"
|
||||
|
||||
|
||||
def get_suggested_key():
|
||||
return "suggested-key"
|
||||
|
||||
|
||||
def test_deploy_interactive_prepare_failed(
|
||||
mocker,
|
||||
setup_env_authentication,
|
||||
):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.prepare_deploy",
|
||||
side_effect=Exception("server did not like params in prepare"),
|
||||
)
|
||||
result = runner.invoke(cli, ["deploy"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"app_prefix,deployment_key,prepare_responses,user_input_region,user_input_envs,expected_key,args_patch",
|
||||
[
|
||||
# CLI provides suggestion and but user enters a different key
|
||||
(
|
||||
get_app_prefix(),
|
||||
get_deployment_key(),
|
||||
Mock(
|
||||
app_prefix=get_app_prefix(),
|
||||
reply=None,
|
||||
suggestion=Mock(
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
key=get_suggested_key(),
|
||||
),
|
||||
existing=None,
|
||||
enabled_regions=["sjc"],
|
||||
),
|
||||
["sjc"],
|
||||
[],
|
||||
get_deployment_key(),
|
||||
None,
|
||||
),
|
||||
# CLI provides suggestion and but user enters a different key and enters envs
|
||||
(
|
||||
get_app_prefix(),
|
||||
get_deployment_key(),
|
||||
Mock(
|
||||
app_prefix=get_app_prefix(),
|
||||
reply=None,
|
||||
suggestion=Mock(
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
key=get_suggested_key(),
|
||||
),
|
||||
existing=None,
|
||||
enabled_regions=["sjc"],
|
||||
),
|
||||
["sjc"],
|
||||
["k1=v1", "k2=v2"],
|
||||
get_deployment_key(),
|
||||
{"envs": {"k1": "v1", "k2": "v2"}},
|
||||
),
|
||||
# CLI provides suggestion and but user takes it
|
||||
(
|
||||
get_app_prefix(),
|
||||
get_deployment_key(),
|
||||
Mock(
|
||||
app_prefix=get_app_prefix(),
|
||||
reply=None,
|
||||
suggestion=Mock(
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
key=get_suggested_key(),
|
||||
),
|
||||
existing=None,
|
||||
enabled_regions=["sjc"],
|
||||
),
|
||||
["sjc"],
|
||||
[],
|
||||
get_suggested_key(),
|
||||
None,
|
||||
),
|
||||
# CLI provides suggestion and but user takes it and enters envs
|
||||
(
|
||||
get_app_prefix(),
|
||||
get_deployment_key(),
|
||||
Mock(
|
||||
app_prefix=get_app_prefix(),
|
||||
reply=None,
|
||||
suggestion=Mock(
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
key=get_suggested_key(),
|
||||
),
|
||||
existing=None,
|
||||
enabled_regions=["sjc"],
|
||||
),
|
||||
["sjc"],
|
||||
["k1=v1", "k3=v3"],
|
||||
get_suggested_key(),
|
||||
{"envs": {"k1": "v1", "k3": "v3"}},
|
||||
),
|
||||
# User has an existing deployment
|
||||
(
|
||||
get_app_prefix(),
|
||||
get_deployment_key(),
|
||||
Mock(
|
||||
app_prefix=get_app_prefix(),
|
||||
reply=None,
|
||||
existing=Mock(
|
||||
__getitem__=lambda _, __: DeploymentPrepInfo(
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
key=get_deployment_key(),
|
||||
)
|
||||
),
|
||||
suggestion=None,
|
||||
enabled_regions=["sjc"],
|
||||
),
|
||||
["sjc"],
|
||||
[],
|
||||
get_deployment_key(),
|
||||
None,
|
||||
),
|
||||
# User has an existing deployment then updates the envs
|
||||
(
|
||||
get_app_prefix(),
|
||||
get_deployment_key(),
|
||||
Mock(
|
||||
app_prefix=get_app_prefix(),
|
||||
reply=None,
|
||||
existing=Mock(
|
||||
__getitem__=lambda _, __: DeploymentPrepInfo(
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
key=get_deployment_key(),
|
||||
)
|
||||
),
|
||||
suggestion=None,
|
||||
enabled_regions=["sjc"],
|
||||
),
|
||||
["sjc"],
|
||||
["k4=v4"],
|
||||
get_deployment_key(),
|
||||
{"envs": {"k4": "v4"}},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_deploy_interactive(
|
||||
mocker,
|
||||
setup_env_authentication,
|
||||
app_prefix,
|
||||
deployment_key,
|
||||
prepare_responses,
|
||||
user_input_region,
|
||||
user_input_envs,
|
||||
expected_key,
|
||||
args_patch,
|
||||
):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.check_requirements_for_non_reflex_packages",
|
||||
return_value=True,
|
||||
)
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.prepare_deploy",
|
||||
return_value=prepare_responses,
|
||||
)
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.interactive_get_deployment_key_from_user_input",
|
||||
return_value=(expected_key, "fake-api-url", "fake-deploy-url"),
|
||||
)
|
||||
mocker.patch("reflex.utils.console.ask", side_effect=user_input_region)
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.interactive_prompt_for_envs", return_value=user_input_envs
|
||||
)
|
||||
fake_export_dir = "fake-export-dir"
|
||||
mocker.patch("tempfile.mkdtemp", return_value=fake_export_dir)
|
||||
mocker.patch("reflex.reflex.export")
|
||||
mock_deploy = mocker.patch(
|
||||
"reflex.utils.hosting.deploy",
|
||||
return_value=Mock(
|
||||
frontend_url="fake-frontend-url", backend_url="fake-backend-url"
|
||||
),
|
||||
)
|
||||
mocker.patch("reflex.utils.hosting.wait_for_server_to_pick_up_request")
|
||||
mocker.patch("reflex.utils.hosting.poll_deploy_milestones")
|
||||
mocker.patch("reflex.utils.hosting.poll_backend", return_value=True)
|
||||
mocker.patch("reflex.utils.hosting.poll_frontend", return_value=True)
|
||||
|
||||
# TODO: typer option default not working in test for app name
|
||||
app_name = "fake-app-workaround"
|
||||
regions = ["sjc"]
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["deploy", "--app-name", app_name],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
expected_call_args = dict(
|
||||
frontend_file_name="frontend.zip",
|
||||
backend_file_name="backend.zip",
|
||||
export_dir=fake_export_dir,
|
||||
key=expected_key,
|
||||
app_name=app_name,
|
||||
regions=regions,
|
||||
app_prefix=app_prefix,
|
||||
cpus=None,
|
||||
memory_mb=None,
|
||||
auto_start=None,
|
||||
auto_stop=None,
|
||||
frontend_hostname=None,
|
||||
envs=None,
|
||||
with_metrics=None,
|
||||
with_tracing=None,
|
||||
)
|
||||
expected_call_args.update(args_patch or {})
|
||||
|
||||
assert mock_deploy.call_args.kwargs == expected_call_args
|
@ -1,369 +0,0 @@
|
||||
import json
|
||||
from unittest.mock import Mock, mock_open
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from reflex import constants
|
||||
from reflex.utils import hosting
|
||||
|
||||
|
||||
def test_get_existing_access_token_and_no_invitation_code(mocker):
|
||||
# Config file has token only
|
||||
mock_hosting_config = {"access_token": "ejJhfake_token"}
|
||||
mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config)))
|
||||
token, code = hosting.get_existing_access_token()
|
||||
assert token == mock_hosting_config["access_token"]
|
||||
assert code == ""
|
||||
|
||||
|
||||
def test_get_existing_access_token_and_invitation_code(mocker):
|
||||
# Config file has both access token and the invitation code
|
||||
mock_hosting_config = {"access_token": "ejJhfake_token", "code": "fake_code"}
|
||||
mocker.patch("builtins.open", mock_open(read_data=json.dumps(mock_hosting_config)))
|
||||
token, code = hosting.get_existing_access_token()
|
||||
assert token == mock_hosting_config["access_token"]
|
||||
assert code == mock_hosting_config["code"]
|
||||
|
||||
|
||||
def test_no_existing_access_token(mocker):
|
||||
# Config file does not have access token
|
||||
mocker.patch(
|
||||
"builtins.open",
|
||||
mock_open(read_data=json.dumps({"no-token": "here", "no-code": "here"})),
|
||||
)
|
||||
access_token, invitation_code = hosting.get_existing_access_token()
|
||||
assert access_token == ""
|
||||
assert invitation_code == ""
|
||||
|
||||
|
||||
def test_no_config_file(mocker):
|
||||
# Config file not exist
|
||||
mocker.patch("builtins.open", side_effect=FileNotFoundError)
|
||||
access_token, invitation_code = hosting.get_existing_access_token()
|
||||
assert access_token == ""
|
||||
assert invitation_code == ""
|
||||
|
||||
|
||||
def test_empty_config_file(mocker):
|
||||
# Config file is empty
|
||||
mocker.patch("builtins.open", mock_open(read_data=""))
|
||||
access_token, invitation_code = hosting.get_existing_access_token()
|
||||
assert access_token == ""
|
||||
assert invitation_code == ""
|
||||
|
||||
|
||||
def test_invalid_json_config_file(mocker):
|
||||
# Config file content is not valid json
|
||||
mocker.patch("builtins.open", mock_open(read_data="im not json content"))
|
||||
access_token, invitation_code = hosting.get_existing_access_token()
|
||||
assert access_token == ""
|
||||
assert invitation_code == ""
|
||||
|
||||
|
||||
def test_validate_token_success(mocker):
|
||||
# Valid token passes without raising any exceptions
|
||||
mocker.patch("httpx.post")
|
||||
hosting.validate_token("fake_token")
|
||||
|
||||
|
||||
def test_invalid_token_access_denied(mocker):
|
||||
# Invalid token raises an exception
|
||||
mocker.patch("httpx.post", return_value=httpx.Response(403))
|
||||
with pytest.raises(ValueError) as ex:
|
||||
hosting.validate_token("invalid_token")
|
||||
assert ex.value == "access denied"
|
||||
|
||||
|
||||
def test_unable_to_validate_token(mocker):
|
||||
# Unable to validate token raises an exception, but not access denied
|
||||
mocker.patch("httpx.post", return_value=httpx.Response(500))
|
||||
with pytest.raises(Exception):
|
||||
hosting.validate_token("invalid_token")
|
||||
|
||||
|
||||
def test_delete_access_token_from_config(mocker):
|
||||
config_json = {
|
||||
"access_token": "fake_token",
|
||||
"code": "fake_code",
|
||||
"future": "some value",
|
||||
}
|
||||
mock_f = mock_open(read_data=json.dumps(config_json))
|
||||
mocker.patch("builtins.open", mock_f)
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mock_json_dump = mocker.patch("json.dump")
|
||||
hosting.delete_token_from_config()
|
||||
config_json.pop("access_token")
|
||||
assert mock_json_dump.call_args[0][0] == config_json
|
||||
|
||||
|
||||
def test_save_access_token_and_invitation_code_to_config(mocker):
|
||||
access_token = "fake_token"
|
||||
invitation_code = "fake_code"
|
||||
expected_config_json = {
|
||||
"access_token": access_token,
|
||||
"code": invitation_code,
|
||||
}
|
||||
mocker.patch("builtins.open")
|
||||
mock_json_dump = mocker.patch("json.dump")
|
||||
hosting.save_token_to_config(access_token, invitation_code)
|
||||
assert mock_json_dump.call_args[0][0] == expected_config_json
|
||||
|
||||
|
||||
def test_save_access_code_but_none_invitation_code_to_config(mocker):
|
||||
access_token = "fake_token"
|
||||
invitation_code = None
|
||||
expected_config_json = {
|
||||
"access_token": access_token,
|
||||
"code": invitation_code,
|
||||
}
|
||||
mocker.patch("builtins.open")
|
||||
mock_json_dump = mocker.patch("json.dump")
|
||||
hosting.save_token_to_config(access_token, invitation_code)
|
||||
expected_config_json.pop("code")
|
||||
assert mock_json_dump.call_args[0][0] == expected_config_json
|
||||
|
||||
|
||||
def test_authenticated_token_success(mocker):
|
||||
access_token = "fake_token"
|
||||
invitation_code = "fake_code"
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.get_existing_access_token",
|
||||
return_value=(access_token, invitation_code),
|
||||
)
|
||||
mocker.patch("reflex.utils.hosting.validate_token_with_retries", return_value=True)
|
||||
assert hosting.authenticated_token() == (access_token, invitation_code)
|
||||
|
||||
|
||||
def test_no_authenticated_token(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.get_existing_access_token",
|
||||
return_value=("", "code-does-not-matter"),
|
||||
)
|
||||
assert hosting.authenticated_token()[0] == ""
|
||||
|
||||
|
||||
def test_maybe_authenticated_token_is_invalid(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.get_existing_access_token",
|
||||
return_value=("invalid_token", "fake_code"),
|
||||
)
|
||||
mocker.patch("reflex.utils.hosting.validate_token_with_retries", return_value=False)
|
||||
assert hosting.authenticated_token()[0] == ""
|
||||
|
||||
|
||||
def test_prepare_deploy_not_authenticated(mocker):
|
||||
mocker.patch("reflex.utils.hosting.requires_authenticated", return_value=None)
|
||||
with pytest.raises(Exception) as ex:
|
||||
hosting.prepare_deploy("fake-app")
|
||||
assert ex.value == "Not authenticated"
|
||||
|
||||
|
||||
def test_server_unable_to_prepare_deploy(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.requires_authenticated", return_value="fake_token"
|
||||
)
|
||||
mocker.patch("httpx.post", return_value=httpx.Response(500))
|
||||
with pytest.raises(Exception):
|
||||
hosting.prepare_deploy("fake-app")
|
||||
|
||||
|
||||
def test_prepare_deploy_success(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.requires_authenticated", return_value="fake_token"
|
||||
)
|
||||
mocker.patch(
|
||||
"httpx.post",
|
||||
return_value=Mock(
|
||||
status_code=200,
|
||||
json=lambda: dict(
|
||||
app_prefix="fake-app-prefix",
|
||||
reply=dict(
|
||||
key="fake-key",
|
||||
api_url="fake-api-url",
|
||||
deploy_url="fake-deploy-url",
|
||||
),
|
||||
suggestion=None,
|
||||
existing=[],
|
||||
),
|
||||
),
|
||||
)
|
||||
# server returns valid response (format is checked by pydantic model validation)
|
||||
hosting.prepare_deploy("fake-app")
|
||||
|
||||
|
||||
def test_deploy(mocker):
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.requires_access_token", return_value="fake_token"
|
||||
)
|
||||
mocker.patch("builtins.open")
|
||||
mocker.patch(
|
||||
"httpx.post",
|
||||
return_value=Mock(
|
||||
status_code=200,
|
||||
json=lambda: dict(
|
||||
frontend_url="https://fake-url", backend_url="https://fake-url"
|
||||
),
|
||||
),
|
||||
)
|
||||
hosting.deploy(
|
||||
frontend_file_name="fake-frontend-path",
|
||||
backend_file_name="fake-backend-path",
|
||||
export_dir="fake-export-dir",
|
||||
key="fake-key",
|
||||
app_name="fake-app-name",
|
||||
regions=["fake-region"],
|
||||
app_prefix="fake-app-prefix",
|
||||
)
|
||||
|
||||
|
||||
def test_validate_token_with_retries_failed(mocker):
|
||||
mock_validate_token = mocker.patch(
|
||||
"reflex.utils.hosting.validate_token", side_effect=Exception
|
||||
)
|
||||
mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config")
|
||||
mocker.patch("time.sleep")
|
||||
|
||||
assert hosting.validate_token_with_retries("fake-token") is False
|
||||
assert mock_validate_token.call_count == constants.Hosting.WEB_AUTH_RETRIES
|
||||
assert mock_delete_token.call_count == 0
|
||||
|
||||
|
||||
def test_validate_token_with_retries_access_denied(mocker):
|
||||
mock_validate_token = mocker.patch(
|
||||
"reflex.utils.hosting.validate_token", side_effect=ValueError
|
||||
)
|
||||
mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config")
|
||||
mocker.patch("time.sleep")
|
||||
assert hosting.validate_token_with_retries("fake-token") is False
|
||||
assert mock_validate_token.call_count == 1
|
||||
assert mock_delete_token.call_count == 1
|
||||
|
||||
|
||||
def test_validate_token_with_retries_success(mocker):
|
||||
validate_token_returns = [Exception, Exception, None]
|
||||
mock_validate_token = mocker.patch(
|
||||
"reflex.utils.hosting.validate_token", side_effect=validate_token_returns
|
||||
)
|
||||
mock_delete_token = mocker.patch("reflex.utils.hosting.delete_token_from_config")
|
||||
mocker.patch("time.sleep")
|
||||
|
||||
assert hosting.validate_token_with_retries("fake-token") is True
|
||||
assert mock_validate_token.call_count == len(validate_token_returns)
|
||||
assert mock_delete_token.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prepare_response, expected",
|
||||
[
|
||||
(
|
||||
hosting.DeploymentPrepareResponse(
|
||||
app_prefix="fake-prefix",
|
||||
reply=hosting.DeploymentPrepInfo(
|
||||
key="key1", api_url="url11", deploy_url="url12"
|
||||
),
|
||||
existing=None,
|
||||
suggestion=None,
|
||||
),
|
||||
("key1", "url11", "url12"),
|
||||
),
|
||||
(
|
||||
hosting.DeploymentPrepareResponse(
|
||||
app_prefix="fake-prefix",
|
||||
reply=None,
|
||||
existing=[
|
||||
hosting.DeploymentPrepInfo(
|
||||
key="key21", api_url="url211", deploy_url="url212"
|
||||
),
|
||||
hosting.DeploymentPrepInfo(
|
||||
key="key22", api_url="url21", deploy_url="url22"
|
||||
),
|
||||
],
|
||||
suggestion=None,
|
||||
),
|
||||
("key21", "url211", "url212"),
|
||||
),
|
||||
(
|
||||
hosting.DeploymentPrepareResponse(
|
||||
app_prefix="fake-prefix",
|
||||
reply=None,
|
||||
existing=None,
|
||||
suggestion=hosting.DeploymentPrepInfo(
|
||||
key="key31", api_url="url31", deploy_url="url31"
|
||||
),
|
||||
),
|
||||
("key31", "url31", "url31"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_interactive_get_deployment_key_user_accepts_defaults(
|
||||
mocker, prepare_response, expected
|
||||
):
|
||||
mocker.patch("reflex.utils.console.ask", side_effect=[""])
|
||||
assert (
|
||||
hosting.interactive_get_deployment_key_from_user_input(
|
||||
prepare_response, "fake-app"
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_get_deployment_key_user_input_accepted(mocker):
|
||||
mocker.patch("reflex.utils.console.ask", side_effect=["my-site"])
|
||||
mocker.patch(
|
||||
"reflex.utils.hosting.prepare_deploy",
|
||||
return_value=hosting.DeploymentPrepareResponse(
|
||||
app_prefix="fake-prefix",
|
||||
reply=hosting.DeploymentPrepInfo(
|
||||
key="my-site", api_url="url211", deploy_url="url212"
|
||||
),
|
||||
),
|
||||
)
|
||||
assert hosting.interactive_get_deployment_key_from_user_input(
|
||||
hosting.DeploymentPrepareResponse(
|
||||
app_prefix="fake-prefix",
|
||||
reply=None,
|
||||
existing=None,
|
||||
suggestion=hosting.DeploymentPrepInfo(
|
||||
key="rejected-key", api_url="rejected-url", deploy_url="rejected-url"
|
||||
),
|
||||
),
|
||||
"fake-app",
|
||||
) == ("my-site", "url211", "url212")
|
||||
|
||||
|
||||
def test_process_envs():
|
||||
assert hosting.process_envs(["a=b", "c=d"]) == {"a": "b", "c": "d"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"inputs, expected",
|
||||
[
|
||||
# enters two envs then enter
|
||||
(
|
||||
["a", "b", "c", "d", ""],
|
||||
["a=b", "c=d"],
|
||||
),
|
||||
# No envs
|
||||
([""], []),
|
||||
# enters one env with value, one without, then enter
|
||||
(["a", "b", "c", "", ""], ["a=b", "c="]),
|
||||
],
|
||||
)
|
||||
def test_interactive_prompt_for_envs(mocker, inputs, expected):
|
||||
mocker.patch("reflex.utils.console.ask", side_effect=inputs)
|
||||
assert hosting.interactive_prompt_for_envs() == expected
|
||||
|
||||
|
||||
def test_requirements_txt_only_contains_reflex(mocker):
|
||||
mocker.patch("reflex.utils.hosting.check_requirements_txt_exist", return_value=True)
|
||||
mocker.patch("builtins.open", mock_open(read_data="\nreflex=1.2.3\n\n"))
|
||||
assert hosting.check_requirements_for_non_reflex_packages() is False
|
||||
|
||||
|
||||
def test_requirements_txt_only_contains_other_packages(mocker):
|
||||
mocker.patch("reflex.utils.hosting.check_requirements_txt_exist", return_value=True)
|
||||
mocker.patch(
|
||||
"builtins.open", mock_open(read_data="\nreflex=1.2.3\n\npynonexist=3.2.1")
|
||||
)
|
||||
assert hosting.check_requirements_for_non_reflex_packages() is True
|
Loading…
Reference in New Issue
Block a user