diff --git a/poetry.lock b/poetry.lock index 13c2c259a..8232da752 100644 --- a/poetry.lock +++ b/poetry.lock @@ -135,13 +135,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.7.22" +version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] [[package]] @@ -509,40 +509,39 @@ files = [ [[package]] name = "httpcore" -version = "1.0.2" +version = "0.17.3" description = "A minimal low-level HTTP client." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, - {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, + {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, + {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, ] [package.dependencies] +anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" +sniffio = "==1.*" [package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.23.0)"] [[package]] name = "httpx" -version = "0.25.1" +version = "0.24.1" description = "The next generation HTTP client." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, - {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, + {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, + {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, ] [package.dependencies] -anyio = "*" certifi = "*" -httpcore = "*" +httpcore = ">=0.15.0,<0.18.0" idna = "*" sniffio = "*" @@ -1540,6 +1539,27 @@ async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2 hiredis = ["hiredis (>=1.0.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] +[[package]] +name = "reflex-hosting-cli" +version = "0.1.0" +description = "Reflex Hosting CLI" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "reflex_hosting_cli-0.1.0-py3-none-any.whl", hash = "sha256:0853a8cbd0ba77a0b419aafccf2af1bdbdddada9aee5235c335444036f63999a"}, + {file = "reflex_hosting_cli-0.1.0.tar.gz", hash = "sha256:53e895f952aedbd9af48e4244cd2d9ad17ac684327097df5784e63b149608e62"}, +] + +[package.dependencies] +coverage = ">=7.3.2,<8.0.0" +httpx = ">=0.24.0,<0.25.0" +platformdirs = ">=3.10.0,<4.0.0" +pydantic = ">=1.10.2,<2.0.0" +rich = ">=13.0.0,<14.0.0" +tabulate = ">=0.9.0,<0.10.0" +typer = ">=0.4.2,<1" +websockets = ">=10.4" + [[package]] name = "rich" version = "13.7.0" @@ -2292,4 +2312,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "f338f335598c2ca5993e30bd1e61d4fb8cefa8698d294c322dc03c972dd3f046" +content-hash = "20b29357fd945cdc767a86204ac24d2e363bb75801c039e0820f4030e8c243d1" diff --git a/pyproject.toml b/pyproject.toml index a68faf55c..95b0c28aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,9 +56,8 @@ wrapt = [ {version = "^1.11.0", python = "<3.11"}, ] packaging = "^23.1" -tabulate = "^0.9.0" pipdeptree = "^2.13.0" -websockets = ">=10.4" +reflex-hosting-cli = ">=0.1.0" [tool.poetry.group.dev.dependencies] pytest = "^7.1.2" diff --git a/reflex/config.py b/reflex/config.py index ebcde8ab0..c3149adb2 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -9,6 +9,7 @@ import urllib.parse from typing import Any, Dict, List, Optional, Set import pydantic +from reflex_cli.constants.hosting import Hosting from reflex import constants from reflex.base import Base @@ -186,9 +187,9 @@ class Config(Base): frontend_packages: List[str] = [] # The hosting service backend URL. - cp_backend_url: str = constants.Hosting.CP_BACKEND_URL + cp_backend_url: str = Hosting.CP_BACKEND_URL # The hosting service frontend URL. - cp_web_url: str = constants.Hosting.CP_WEB_URL + cp_web_url: str = Hosting.CP_WEB_URL # The worker class used in production mode gunicorn_worker_class: str = "uvicorn.workers.UvicornH11Worker" diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index ca408c6a6..bfe112e63 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -35,7 +35,6 @@ from .config import ( RequirementsTxt, ) from .event import Endpoint, EventTriggers, SocketEvent -from .hosting import Hosting from .installer import ( Bun, Fnm, @@ -71,7 +70,6 @@ __ALL__ = [ Fnm, GitIgnore, Hooks, - RequirementsTxt, Imports, IS_WINDOWS, LOCAL_STORAGE, @@ -87,6 +85,7 @@ __ALL__ = [ PYTEST_CURRENT_TEST, PRODUCTION_BACKEND_URL, Reflex, + RequirementsTxt, RouteArgType, RouteRegex, RouteVar, @@ -100,5 +99,4 @@ __ALL__ = [ Tailwind, Templates, CompileVars, - Hosting, ] diff --git a/reflex/constants/hosting.py b/reflex/constants/hosting.py deleted file mode 100644 index 349f696fb..000000000 --- a/reflex/constants/hosting.py +++ /dev/null @@ -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 diff --git a/reflex/reflex.py b/reflex/reflex.py index 91bcec60c..dbf727762 100644 --- a/reflex/reflex.py +++ b/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, diff --git a/reflex/utils/dependency.py b/reflex/utils/dependency.py deleted file mode 100644 index bfd76652d..000000000 --- a/reflex/utils/dependency.py +++ /dev/null @@ -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() diff --git a/reflex/utils/export.py b/reflex/utils/export.py new file mode 100644 index 000000000..f2b8e69a8 --- /dev/null +++ b/reflex/utils/export.py @@ -0,0 +1,74 @@ +"""Export utilities.""" +import os +from pathlib import Path +from typing import Optional + +from reflex import constants +from reflex.config import get_config +from reflex.utils import build, console, exec, prerequisites, telemetry + +config = get_config() + + +def export( + zipping: bool = True, + frontend: bool = True, + backend: bool = True, + zip_dest_dir: str = os.getcwd(), + upload_db_file: bool = False, + api_url: Optional[str] = None, + deploy_url: Optional[str] = None, + loglevel: constants.LogLevel = console._LOG_LEVEL, +): + """Export the app to a zip file. + + Args: + zipping: Whether to zip the exported app. Defaults to True. + frontend: Whether to export the frontend. Defaults to True. + backend: Whether to export the backend. Defaults to True. + zip_dest_dir: The directory to export the zip file to. Defaults to os.getcwd(). + upload_db_file: Whether to upload the database file. Defaults to False. + api_url: The API URL to use. Defaults to None. + deploy_url: The deploy URL to use. Defaults to None. + loglevel: The log level to use. Defaults to console._LOG_LEVEL. + """ + # Set the log level. + console.set_log_level(loglevel) + + # Override the config url values if provided. + if api_url is not None: + config.api_url = str(api_url) + console.debug(f"overriding API URL: {config.api_url}") + if deploy_url is not None: + config.deploy_url = str(deploy_url) + console.debug(f"overriding deploy URL: {config.deploy_url}") + + # Show system info + exec.output_system_info() + + # Check that the app is initialized. + prerequisites.check_initialized(frontend=frontend) + + # Compile the app in production mode and export it. + console.rule("[bold]Compiling production app and preparing for export.") + + if frontend: + # Update some parameters for export + prerequisites.update_next_config(export=True) + # Ensure module can be imported and app.compile() is called. + prerequisites.get_app() + # Set up .web directory and install frontend dependencies. + build.setup_frontend(Path.cwd()) + + # Export the app. + build.export( + backend=backend, + frontend=frontend, + zip=zipping, + zip_dest_dir=zip_dest_dir, + deploy_url=config.deploy_url, + upload_db_file=upload_db_file, + ) + + # Post a telemetry event. + telemetry.send("export") diff --git a/reflex/utils/hosting.py b/reflex/utils/hosting.py deleted file mode 100644 index 6e333994f..000000000 --- a/reflex/utils/hosting.py +++ /dev/null @@ -1,1257 +0,0 @@ -"""Hosting service related utilities.""" -from __future__ import annotations - -import contextlib -import enum -import json -import os -import re -import time -import uuid -import webbrowser -from datetime import datetime, timedelta -from http import HTTPStatus -from typing import List, Optional - -import httpx -import websockets -from pydantic import Field, ValidationError, root_validator - -from reflex import constants -from reflex.base import Base -from reflex.config import get_config -from reflex.utils import console - -config = get_config() -# Endpoint to create or update a deployment -POST_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments" -# Endpoint to get all deployments for the user -GET_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments" -# Endpoint to fetch information from backend in preparation of a deployment -POST_DEPLOYMENTS_PREPARE_ENDPOINT = f"{config.cp_backend_url}/deployments/prepare" -# Endpoint to authenticate current user -POST_VALIDATE_ME_ENDPOINT = f"{config.cp_backend_url}/authenticate/me" -# Endpoint to fetch a login token after user completes authentication on web -FETCH_TOKEN_ENDPOINT = f"{config.cp_backend_url}/authenticate" -# Endpoint to delete a deployment -DELETE_DEPLOYMENTS_ENDPOINT = f"{config.cp_backend_url}/deployments" -# Endpoint to get deployment status -GET_DEPLOYMENT_STATUS_ENDPOINT = f"{config.cp_backend_url}/deployments" -# Public endpoint to get the list of supported regions in hosting service -GET_REGIONS_ENDPOINT = f"{config.cp_backend_url}/deployments/regions" -# Websocket endpoint to stream logs of a deployment -DEPLOYMENT_LOGS_ENDPOINT = f'{config.cp_backend_url.replace("http", "ws")}/deployments' -# The HTTP endpoint to fetch logs of a deployment -POST_DEPLOYMENT_LOGS_ENDPOINT = f"{config.cp_backend_url}/deployments/logs" -# Expected server response time to new deployment request. In seconds. -DEPLOYMENT_PICKUP_DELAY = 30 -# End of deployment workflow message. Used to determine if it is the last message from server. -END_OF_DEPLOYMENT_MESSAGES = ["deploy success"] -# How many iterations to try and print the deployment event messages from server during deployment. -DEPLOYMENT_EVENT_MESSAGES_RETRIES = 120 -# Timeout limit for http requests -HTTP_REQUEST_TIMEOUT = 60 # seconds - - -def get_existing_access_token() -> tuple[str, str]: - """Fetch the access token from the existing config if applicable. - - Returns: - The access token and the invitation code. - If either is not found, return empty string for it instead. - """ - console.debug("Fetching token from existing config...") - access_token = invitation_code = "" - try: - with open(constants.Hosting.HOSTING_JSON, "r") as config_file: - hosting_config = json.load(config_file) - access_token = hosting_config.get("access_token", "") - invitation_code = hosting_config.get("code", "") - except Exception as ex: - console.debug( - f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" - ) - return access_token, invitation_code - - -def validate_token(token: str): - """Validate the token with the control plane. - - Args: - token: The access token to validate. - - Raises: - ValueError: if access denied. - Exception: if runs into timeout, failed requests, unexpected errors. These should be tried again. - """ - try: - response = httpx.post( - POST_VALIDATE_ME_ENDPOINT, - headers=authorization_header(token), - timeout=HTTP_REQUEST_TIMEOUT, - ) - if response.status_code == HTTPStatus.FORBIDDEN: - raise ValueError - response.raise_for_status() - except httpx.RequestError as re: - console.debug(f"Request to auth server failed due to {re}") - raise Exception(str(re)) from re - except httpx.HTTPError as ex: - console.debug(f"Unable to validate the token due to: {ex}") - raise Exception("server error") from ex - except ValueError as ve: - console.debug(f"Access denied for {token}") - raise ValueError("access denied") from ve - except Exception as ex: - console.debug(f"Unexpected error: {ex}") - raise Exception("internal errors") from ex - - -def delete_token_from_config(include_invitation_code: bool = False): - """Delete the invalid token from the config file if applicable. - - Args: - include_invitation_code: - Whether to delete the invitation code as well. - When user logs out, we delete the invitation code together. - """ - if os.path.exists(constants.Hosting.HOSTING_JSON): - hosting_config = {} - try: - with open(constants.Hosting.HOSTING_JSON, "w") as config_file: - hosting_config = json.load(config_file) - del hosting_config["access_token"] - if include_invitation_code: - del hosting_config["code"] - json.dump(hosting_config, config_file) - except Exception as ex: - # Best efforts removing invalid token is OK - console.debug( - f"Unable to delete the invalid token from config file, err: {ex}" - ) - - -def save_token_to_config(token: str, code: str | None = None): - """Best efforts cache the token, and optionally invitation code to the config file. - - Args: - token: The access token to save. - code: The invitation code to save if exists. - """ - hosting_config: dict[str, str] = {"access_token": token} - if code: - hosting_config["code"] = code - try: - if not os.path.exists(constants.Reflex.DIR): - os.makedirs(constants.Reflex.DIR) - with open(constants.Hosting.HOSTING_JSON, "w") as config_file: - json.dump(hosting_config, config_file) - except Exception as ex: - console.warn( - f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}" - ) - - -def requires_access_token() -> str: - """Fetch the access token from the existing config if applicable. - - Returns: - The access token. If not found, return empty string for it instead. - """ - # Check if the user is authenticated - - access_token, _ = get_existing_access_token() - if not access_token: - console.debug("No access token found from the existing config.") - - return access_token - - -def authenticated_token() -> tuple[str, str]: - """Fetch the access token from the existing config if applicable and validate it. - - Returns: - The access token and the invitation code. - If either is not found, return empty string for it instead. - """ - # Check if the user is authenticated - - access_token, invitation_code = get_existing_access_token() - if not access_token: - console.debug("No access token found from the existing config.") - access_token = "" - elif not validate_token_with_retries(access_token): - access_token = "" - - return access_token, invitation_code - - -def authorization_header(token: str) -> dict[str, str]: - """Construct an authorization header with the specified token as bearer token. - - Args: - token: The access token to use. - - Returns: - The authorization header in dict format. - """ - return {"Authorization": f"Bearer {token}"} - - -def requires_authenticated() -> str: - """Check if the user is authenticated. - - Returns: - The validated access token or empty string if not authenticated. - """ - access_token, invitation_code = authenticated_token() - if access_token: - return access_token - return authenticate_on_browser(invitation_code) - - -class DeploymentPrepInfo(Base): - """The params/settings returned from the prepare endpoint - including the deployment key and the frontend/backend URLs once deployed. - The key becomes part of both frontend and backend URLs. - """ - - # The deployment key - key: str - # The backend URL - api_url: str - # The frontend URL - deploy_url: str - - -class DeploymentPrepareResponse(Base): - """The params/settings returned from the prepare endpoint, - used in the CLI for the subsequent launch request. - """ - - # The app prefix, used on the server side only - app_prefix: str - # The reply from the server for a prepare request to deploy over a particular key - # If reply is not None, it means server confirms the key is available for use. - reply: Optional[DeploymentPrepInfo] = None - # The list of existing deployments by the user under the same app name. - # This is used to allow easy upgrade case when user attempts to deploy - # in the same named app directory, user intends to upgrade the existing deployment. - existing: Optional[List[DeploymentPrepInfo]] = None - # The suggested key name based on the app name. - # This is for a new deployment, user has not deployed this app before. - # The server returns key suggestion based on the app name. - suggestion: Optional[DeploymentPrepInfo] = None - enabled_regions: Optional[List[str]] = None - - @root_validator(pre=True) - def ensure_at_least_one_deploy_params(cls, values): - """Ensure at least one set of param is returned for any of the cases we try to prepare. - - Args: - values: The values passed in. - - Raises: - ValueError: If all of the optional fields are None. - - Returns: - The values passed in. - """ - if ( - values.get("reply") is None - and not values.get("existing") # existing cannot be an empty list either - and values.get("suggestion") is None - ): - raise ValueError( - "At least one set of params for deploy is required from control plane." - ) - return values - - -class DeploymentsPreparePostParam(Base): - """Params for app API URL creation backend API.""" - - # The app name which is found in the config - app_name: str - # The deployment key - key: Optional[str] = None # name of the deployment - # The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain. - frontend_hostname: Optional[str] = None - - -def prepare_deploy( - app_name: str, - key: str | None = None, - frontend_hostname: str | None = None, -) -> DeploymentPrepareResponse: - """Send a POST request to Control Plane to prepare a new deployment. - Control Plane checks if there is conflict with the key if provided. - If the key is absent, it will return existing deployments and a suggested name based on the app_name in the request. - - Args: - key: The deployment name. - app_name: The app name. - frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain. - - Raises: - Exception: If the operation fails. The exception message is the reason. - - Returns: - The response containing the backend URLs if successful, None otherwise. - """ - # Check if the user is authenticated - if not (token := requires_authenticated()): - raise Exception("not authenticated") - try: - response = httpx.post( - POST_DEPLOYMENTS_PREPARE_ENDPOINT, - headers=authorization_header(token), - json=DeploymentsPreparePostParam( - app_name=app_name, key=key, frontend_hostname=frontend_hostname - ).dict(exclude_none=True), - timeout=HTTP_REQUEST_TIMEOUT, - ) - - response_json = response.json() - console.debug(f"Response from prepare endpoint: {response_json}") - if response.status_code == HTTPStatus.FORBIDDEN: - console.debug(f'Server responded with 403: {response_json.get("detail")}') - raise ValueError(f'{response_json.get("detail", "forbidden")}') - response.raise_for_status() - return DeploymentPrepareResponse( - app_prefix=response_json["app_prefix"], - reply=response_json["reply"], - suggestion=response_json["suggestion"], - existing=response_json["existing"], - enabled_regions=response_json.get("enabled_regions"), - ) - except httpx.RequestError as re: - console.debug(f"Unable to prepare launch due to {re}.") - raise Exception(str(re)) from re - except httpx.HTTPError as he: - console.debug(f"Unable to prepare deploy due to {he}.") - raise Exception(f"{he}") from he - except json.JSONDecodeError as jde: - console.debug(f"Server did not respond with valid json: {jde}") - raise Exception("internal errors") from jde - except (KeyError, ValidationError) as kve: - console.debug(f"The server response format is unexpected {kve}") - raise Exception("internal errors") from kve - except ValueError as ve: - # This is a recognized client error, currently indicates forbidden - raise Exception(f"{ve}") from ve - except Exception as ex: - console.debug(f"Unexpected error: {ex}.") - raise Exception("internal errors") from ex - - -class DeploymentPostResponse(Base): - """The URL for the deployed site.""" - - # The frontend URL - frontend_url: str = Field(..., regex=r"^https?://", min_length=8) - # The backend URL - backend_url: str = Field(..., regex=r"^https?://", min_length=8) - - -class DeploymentsPostParam(Base): - """Params for hosted instance deployment POST request.""" - - # Key is the name of the deployment, it becomes part of the URL - key: str = Field(..., regex=r"^[a-z0-9-]+$") - # Name of the app - app_name: str = Field(..., min_length=1) - # json encoded list of regions to deploy to - regions_json: str = Field(..., min_length=1) - # The app prefix, used on the server side only - app_prefix: str = Field(..., min_length=1) - # The version of reflex CLI used to deploy - reflex_version: str = Field(..., min_length=1) - # The number of CPUs - cpus: Optional[int] = None - # The memory in MB - memory_mb: Optional[int] = None - # Whether to auto start the hosted deployment - auto_start: Optional[bool] = None - # Whether to auto stop the hosted deployment when idling - auto_stop: Optional[bool] = None - # The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain. - frontend_hostname: Optional[str] = None - # The description of the deployment - description: Optional[str] = None - # The json encoded list of environment variables - envs_json: Optional[str] = None - # The command line prefix for tracing - reflex_cli_entrypoint: Optional[str] = None - # The metrics endpoint - metrics_endpoint: Optional[str] = None - - -def deploy( - frontend_file_name: str, - backend_file_name: str, - export_dir: str, - key: str, - app_name: str, - regions: list[str], - app_prefix: str, - vm_type: str | None = None, - cpus: int | None = None, - memory_mb: int | None = None, - auto_start: bool | None = None, - auto_stop: bool | None = None, - frontend_hostname: str | None = None, - envs: dict[str, str] | None = None, - with_tracing: str | None = None, - with_metrics: str | None = None, -) -> DeploymentPostResponse: - """Send a POST request to Control Plane to launch a new deployment. - - Args: - frontend_file_name: The frontend file name. - backend_file_name: The backend file name. - export_dir: The directory where the frontend/backend zip files are exported. - key: The deployment name. - app_name: The app name. - regions: The list of regions to deploy to. - app_prefix: The app prefix. - vm_type: The VM type. - cpus: The number of CPUs. - memory_mb: The memory in MB. - auto_start: Whether to auto start. - auto_stop: Whether to auto stop. - frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain. - envs: The environment variables. - with_tracing: A string indicating the command line prefix for tracing. - with_metrics: A string indicating the metrics endpoint. - - Raises: - AssertionError: If the request is rejected by the hosting server. - Exception: If the operation fails. The exception message is the reason. - - Returns: - The response containing the URL of the site to be deployed if successful, None otherwise. - """ - # Check if the user is authenticated - if not (token := requires_access_token()): - raise Exception("not authenticated") - - try: - params = DeploymentsPostParam( - key=key, - app_name=app_name, - regions_json=json.dumps(regions), - app_prefix=app_prefix, - cpus=cpus, - memory_mb=memory_mb, - auto_start=auto_start, - auto_stop=auto_stop, - envs_json=json.dumps(envs) if envs else None, - frontend_hostname=frontend_hostname, - reflex_version=constants.Reflex.VERSION, - reflex_cli_entrypoint=with_tracing, - metrics_endpoint=with_metrics, - ) - with open( - os.path.join(export_dir, frontend_file_name), "rb" - ) as frontend_file, open( - os.path.join(export_dir, backend_file_name), "rb" - ) as backend_file: - # https://docs.python-requests.org/en/latest/user/advanced/#post-multiple-multipart-encoded-files - files = [ - ("files", (frontend_file_name, frontend_file)), - ("files", (backend_file_name, backend_file)), - ] - response = httpx.post( - POST_DEPLOYMENTS_ENDPOINT, - headers=authorization_header(token), - data=params.dict(exclude_none=True), - files=files, - timeout=HTTP_REQUEST_TIMEOUT, - ) - # If the server explicitly states bad request, - # display a different error - if response.status_code == HTTPStatus.BAD_REQUEST: - raise AssertionError(f"Server rejected this request: {response.text}") - response.raise_for_status() - response_json = response.json() - return DeploymentPostResponse( - frontend_url=response_json["frontend_url"], - backend_url=response_json["backend_url"], - ) - except OSError as oe: - console.error(f"Client side error related to file operation: {oe}") - raise - except httpx.RequestError as re: - console.error(f"Unable to deploy due to request error: {re}") - raise Exception("request error") from re - except httpx.HTTPError as he: - console.error(f"Unable to deploy due to {he}.") - raise Exception(str) from he - except json.JSONDecodeError as jde: - console.error(f"Server did not respond with valid json: {jde}") - raise Exception("internal errors") from jde - except (KeyError, ValidationError) as kve: - console.error(f"Post params or server response format unexpected: {kve}") - raise Exception("internal errors") from kve - except AssertionError as ve: - console.error(f"Unable to deploy due to request error: {ve}") - # re-raise the error back to the user as client side error - raise - except Exception as ex: - console.error(f"Unable to deploy due to internal errors: {ex}.") - raise Exception("internal errors") from ex - - -class DeploymentsGetParam(Base): - """Params for hosted instance GET request.""" - - # The app name which is found in the config - app_name: Optional[str] - - -def list_deployments( - app_name: str | None = None, -) -> list[dict]: - """Send a GET request to Control Plane to list deployments. - - Args: - app_name: the app name as an optional filter when listing deployments. - - Raises: - Exception: If the operation fails. The exception message shows the reason. - - Returns: - The list of deployments if successful, None otherwise. - """ - if not (token := requires_authenticated()): - raise Exception("not authenticated") - - params = DeploymentsGetParam(app_name=app_name) - - try: - response = httpx.get( - GET_DEPLOYMENTS_ENDPOINT, - headers=authorization_header(token), - params=params.dict(exclude_none=True), - timeout=HTTP_REQUEST_TIMEOUT, - ) - response.raise_for_status() - return response.json() - except httpx.RequestError as re: - console.error(f"Unable to list deployments due to request error: {re}") - raise Exception("request timeout") from re - except httpx.HTTPError as he: - console.error(f"Unable to list deployments due to {he}.") - raise Exception("internal errors") from he - except (ValidationError, KeyError, json.JSONDecodeError) as vkje: - console.error(f"Server response format unexpected: {vkje}") - raise Exception("internal errors") from vkje - except Exception as ex: - console.error(f"Unexpected error: {ex}.") - raise Exception("internal errors") from ex - - -def fetch_token(request_id: str) -> tuple[str, str]: - """Fetch the access token for the request_id from Control Plane. - - Args: - request_id: The request ID used when the user opens the browser for authentication. - - Returns: - The access token if it exists, None otherwise. - """ - access_token = invitation_code = "" - try: - resp = httpx.get( - f"{FETCH_TOKEN_ENDPOINT}/{request_id}", - timeout=HTTP_REQUEST_TIMEOUT, - ) - resp.raise_for_status() - access_token = (resp_json := resp.json()).get("access_token", "") - invitation_code = resp_json.get("code", "") - except httpx.RequestError as re: - console.debug(f"Unable to fetch token due to request error: {re}") - except httpx.HTTPError as he: - console.debug(f"Unable to fetch token due to {he}") - except json.JSONDecodeError as jde: - console.debug(f"Server did not respond with valid json: {jde}") - except KeyError as ke: - console.debug(f"Server response format unexpected: {ke}") - except Exception: - console.debug("Unexpected errors: {ex}") - - return access_token, invitation_code - - -def poll_backend(backend_url: str) -> bool: - """Poll the backend to check if it is up. - - Args: - backend_url: The URL of the backend to poll. - - Returns: - True if the backend is up, False otherwise. - """ - try: - console.debug(f"Polling backend at {backend_url}") - resp = httpx.get(f"{backend_url}/ping", timeout=1) - resp.raise_for_status() - return True - except httpx.HTTPError: - return False - - -def poll_frontend(frontend_url: str) -> bool: - """Poll the frontend to check if it is up. - - Args: - frontend_url: The URL of the frontend to poll. - - Returns: - True if the frontend is up, False otherwise. - """ - try: - console.debug(f"Polling frontend at {frontend_url}") - resp = httpx.get(f"{frontend_url}", timeout=1) - resp.raise_for_status() - return True - except httpx.HTTPError: - return False - - -class DeploymentDeleteParam(Base): - """Params for hosted instance DELETE request.""" - - # key is the name of the deployment, it becomes part of the site URL - key: str - - -def delete_deployment(key: str): - """Send a DELETE request to Control Plane to delete a deployment. - - Args: - key: The deployment name. - - Raises: - ValueError: If the key is not provided. - Exception: If the operation fails. The exception message is the reason. - """ - if not (token := requires_authenticated()): - raise Exception("not authenticated") - if not key: - raise ValueError("Valid key is required for the delete.") - - try: - response = httpx.delete( - f"{DELETE_DEPLOYMENTS_ENDPOINT}/{key}", - headers=authorization_header(token), - timeout=HTTP_REQUEST_TIMEOUT, - ) - response.raise_for_status() - - except httpx.TimeoutException as te: - console.error("Unable to delete deployment due to request timeout.") - raise Exception("request timeout") from te - except httpx.HTTPError as he: - console.error(f"Unable to delete deployment due to {he}.") - raise Exception("internal errors") from he - except Exception as ex: - console.error(f"Unexpected errors {ex}.") - raise Exception("internal errors") from ex - - -class SiteStatus(Base): - """Deployment status info.""" - - # The frontend URL - frontend_url: Optional[str] = None - # The backend URL - backend_url: Optional[str] = None - # Whether the frontend/backend URL is reachable - reachable: bool - # The last updated iso formatted timestamp if site is reachable - updated_at: Optional[str] = None - - @root_validator(pre=True) - def ensure_one_of_urls(cls, values): - """Ensure at least one of the frontend/backend URLs is provided. - - Args: - values: The values passed in. - - Raises: - ValueError: If none of the URLs is provided. - - Returns: - The values passed in. - """ - if values.get("frontend_url") is None and values.get("backend_url") is None: - raise ValueError("At least one of the URLs is required.") - return values - - -class DeploymentStatusResponse(Base): - """Response for deployment status request.""" - - # The frontend status - frontend: SiteStatus - # The backend status - backend: SiteStatus - - -def get_deployment_status(key: str) -> DeploymentStatusResponse: - """Get the deployment status. - - Args: - key: The deployment name. - - Raises: - ValueError: If the key is not provided. - Exception: If the operation fails. The exception message is the reason. - - Returns: - The deployment status response including backend and frontend. - """ - if not key: - raise ValueError( - "A non empty key is required for querying the deployment status." - ) - - if not (token := requires_authenticated()): - raise Exception("not authenticated") - - try: - response = httpx.get( - f"{GET_DEPLOYMENT_STATUS_ENDPOINT}/{key}/status", - headers=authorization_header(token), - timeout=HTTP_REQUEST_TIMEOUT, - ) - response.raise_for_status() - response_json = response.json() - return DeploymentStatusResponse( - frontend=SiteStatus( - frontend_url=response_json["frontend"]["url"], - reachable=response_json["frontend"]["reachable"], - updated_at=response_json["frontend"]["updated_at"], - ), - backend=SiteStatus( - backend_url=response_json["backend"]["url"], - reachable=response_json["backend"]["reachable"], - updated_at=response_json["backend"]["updated_at"], - ), - ) - except Exception as ex: - console.error(f"Unable to get deployment status due to {ex}.") - raise Exception("internal errors") from ex - - -def convert_to_local_time_with_tz(iso_timestamp: str) -> datetime | None: - """Helper function to convert the iso timestamp to local time. - - Args: - iso_timestamp: The iso timestamp to convert. - - Returns: - The converted timestamp with timezone. - """ - try: - return datetime.fromisoformat(iso_timestamp).astimezone() - except (TypeError, ValueError) as ex: - console.error(f"Unable to convert iso timestamp {iso_timestamp} due to {ex}.") - return None - - -def convert_to_local_time_str(iso_timestamp: str) -> str: - """Convert the iso timestamp to local time. - - Args: - iso_timestamp: The iso timestamp to convert. - - Returns: - The converted timestamp string. - """ - if (local_dt := convert_to_local_time_with_tz(iso_timestamp)) is None: - return iso_timestamp - return local_dt.strftime("%Y-%m-%d %H:%M:%S.%f %Z") - - -class LogType(str, enum.Enum): - """Enum for log types.""" - - # Logs printed from the user code, the "app" - APP_LOG = "app" - # Build logs are the server messages while building/running user deployment - BUILD_LOG = "build" - # Deploy logs are specifically for the messages at deploy time - # returned to the user the current stage of the deployment, such as building, uploading. - DEPLOY_LOG = "deploy" - # All the logs which can be printed by all above types. - ALL_LOG = "all" - - -async def get_logs( - key: str, - log_type: LogType = LogType.APP_LOG, - from_iso_timestamp: datetime | None = None, -): - """Get the deployment logs and stream on console. - - Args: - key: The deployment name. - log_type: The type of logs to query from server. - See the LogType definitions for how they are used. - from_iso_timestamp: An optional timestamp with timezone info to limit - where the log queries should start from. - - Raises: - ValueError: If the key is not provided. - Exception: If the operation fails. The exception message is the reason. - - """ - if not (token := requires_authenticated()): - raise Exception("not authenticated") - if not key: - raise ValueError("Valid key is required for querying logs.") - try: - logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={log_type.value}" - console.debug(f"log server endpoint: {logs_endpoint}") - if from_iso_timestamp is not None: - logs_endpoint += ( - f"&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}" - ) - _ws = websockets.connect(logs_endpoint) # type: ignore - async with _ws as ws: - while True: - row_json = json.loads(await ws.recv()) - console.debug(f"Server responded with logs: {row_json}") - if row_json and isinstance(row_json, dict): - row_to_print = {} - for k, v in row_json.items(): - if v is None: - row_to_print[k] = str(v) - elif k == "timestamp": - row_to_print[k] = convert_to_local_time_str(v) - else: - row_to_print[k] = v - print(" | ".join(row_to_print.values())) - else: - console.debug("Server responded, no new logs, this is normal") - except Exception as ex: - console.debug(f"Unable to get more deployment logs due to {ex}.") - console.print("Log server disconnected ...") - console.print( - "Note that the server has limit to only stream logs for several minutes" - ) - - -def check_requirements_txt_exist() -> bool: - """Check if requirements.txt exists in the top level app directory. - - Returns: - True if requirements.txt exists, False otherwise. - """ - return os.path.exists(constants.RequirementsTxt.FILE) - - -def check_requirements_for_non_reflex_packages() -> bool: - """Check the requirements.txt file for packages other than reflex. - - Returns: - True if packages other than reflex are found, False otherwise. - """ - if not check_requirements_txt_exist(): - return False - try: - with open(constants.RequirementsTxt.FILE) as fp: - for req_line in fp.readlines(): - package_name = re.search(r"^([^=<>!~]+)", req_line.lstrip()) - # If we find a package that is not reflex - if ( - package_name - and package_name.group(1) != constants.Reflex.MODULE_NAME - ): - return True - except Exception as ex: - console.warn(f"Unable to scan requirements.txt for dependencies due to {ex}") - - return False - - -def authenticate_on_browser(invitation_code: str) -> str: - """Open the browser to authenticate the user. - - Args: - invitation_code: The invitation code if it exists. - - Returns: - The access token if valid, empty otherwise. - """ - console.print(f"Opening {config.cp_web_url} ...") - request_id = uuid.uuid4().hex - auth_url = f"{config.cp_web_url}?request-id={request_id}&code={invitation_code}" - if not webbrowser.open(auth_url): - console.warn( - f"Unable to automatically open the browser. Please go to {auth_url} to authenticate." - ) - access_token = invitation_code = "" - with console.status("Waiting for access token ..."): - for _ in range(constants.Hosting.WEB_AUTH_RETRIES): - access_token, invitation_code = fetch_token(request_id) - if access_token: - break - else: - time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION) - - if access_token and validate_token_with_retries(access_token): - save_token_to_config(access_token, invitation_code) - else: - access_token = "" - return access_token - - -def validate_token_with_retries(access_token: str) -> bool: - """Validate the access token with retries. - - Args: - access_token: The access token to validate. - - Returns: - True if the token is valid, - False if invalid or unable to validate. - """ - with console.status("Validating access token ..."): - for _ in range(constants.Hosting.WEB_AUTH_RETRIES): - try: - validate_token(access_token) - return True - except ValueError: - console.error(f"Access denied") - delete_token_from_config() - break - except Exception as ex: - console.debug(f"Unable to validate token due to: {ex}, trying again") - time.sleep(constants.Hosting.WEB_AUTH_SLEEP_DURATION) - return False - - -def is_valid_deployment_key(key: str): - """Helper function to check if the deployment key is valid. Must be a domain name safe string. - - Args: - key: The deployment key to check. - - Returns: - True if the key contains only domain name safe characters, False otherwise. - """ - return re.match(r"^[a-zA-Z0-9-]*$", key) - - -def interactive_get_deployment_key_from_user_input( - pre_deploy_response: DeploymentPrepareResponse, - app_name: str, - frontend_hostname: str | None = None, -) -> tuple[str, str, str]: - """Interactive get the deployment key from user input. - - Args: - pre_deploy_response: The response from the initial prepare call to server. - app_name: The app name. - frontend_hostname: The frontend hostname to deploy to. This is used to deploy at hostname not in the regular domain. - - Returns: - The deployment key, backend URL, frontend URL. - """ - key_candidate = api_url = deploy_url = "" - if reply := pre_deploy_response.reply: - api_url = reply.api_url - deploy_url = reply.deploy_url - key_candidate = reply.key - elif pre_deploy_response.existing: - # validator already checks existing field is not empty list - # Note: keeping this simple as we only allow one deployment per app - existing = pre_deploy_response.existing[0] - console.print(f"Overwrite deployment [ {existing.key} ] ...") - key_candidate = existing.key - api_url = existing.api_url - deploy_url = existing.deploy_url - elif suggestion := pre_deploy_response.suggestion: - key_candidate = suggestion.key - api_url = suggestion.api_url - deploy_url = suggestion.deploy_url - - # If user takes the suggestion, we will use the suggested key and proceed - while key_input := console.ask( - f"Choose a name for your deployed app. Enter to use default.", - default=key_candidate, - ): - if not is_valid_deployment_key(key_input): - console.error( - "Invalid key input, should only contain domain name safe characters: letters, digits, or hyphens." - ) - continue - - elif any(x.isupper() for x in key_input): - key_input = key_input.lower() - console.info( - f"Domain name is case insensitive, automatically converting to all lower cases: {key_input}" - ) - - try: - pre_deploy_response = prepare_deploy( - app_name, - key=key_input, - frontend_hostname=frontend_hostname, - ) - if ( - pre_deploy_response.reply is None - or key_input != pre_deploy_response.reply.key - ): - # Rejected by server, try again - continue - key_candidate = pre_deploy_response.reply.key - api_url = pre_deploy_response.reply.api_url - deploy_url = pre_deploy_response.reply.deploy_url - # we get the confirmation, so break from the loop - break - except Exception: - console.error( - "Cannot deploy at this name, try picking a different name" - ) - - return key_candidate, api_url, deploy_url - - -def process_envs(envs: list[str]) -> dict[str, str]: - """Process the environment variables. - - Args: - envs: The environment variables expected in key=value format. - - Raises: - SystemExit: If the envs are not in valid format. - - Returns: - The processed environment variables in a dict. - """ - processed_envs = {} - for env in envs: - kv = env.split("=", maxsplit=1) - if len(kv) != 2: - raise SystemExit("Invalid env format: should be =.") - - if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", kv[0]): - raise SystemExit( - "Invalid env name: should start with a letter or underscore, followed by letters, digits, or underscores." - ) - processed_envs[kv[0]] = kv[1] - return processed_envs - - -def log_out_on_browser(): - """Open the browser to authenticate the user.""" - # Fetching existing invitation code so user sees the log out page without having to enter it - invitation_code = None - with contextlib.suppress(Exception): - _, invitation_code = get_existing_access_token() - console.debug("Found existing invitation code in config") - delete_token_from_config() - console.print(f"Opening {config.cp_web_url} ...") - if not webbrowser.open(f"{config.cp_web_url}?code={invitation_code}"): - console.warn( - f"Unable to open the browser automatically. Please go to {config.cp_web_url} to log out." - ) - - -def poll_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool | None: - """Periodically poll the hosting server for deploy milestones. - - Args: - key: The deployment key. - from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query. - - Raises: - ValueError: If a non-empty key is not provided. - Exception: If the user is not authenticated. - - Returns: - False if server reports back failure, True otherwise. None if do not receive the end of deployment message. - """ - if not key: - raise ValueError("Non-empty key is required for querying deploy status.") - if not (token := requires_authenticated()): - raise Exception("not authenticated") - - for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES): - try: - response = httpx.post( - POST_DEPLOYMENT_LOGS_ENDPOINT, - json={ - "key": key, - "log_type": LogType.DEPLOY_LOG.value, - "from_iso_timestamp": from_iso_timestamp.astimezone().isoformat(), - }, - headers=authorization_header(token), - ) - response.raise_for_status() - # The return is expected to be a list of dicts - response_json = response.json() - for row in response_json: - console.print( - " | ".join( - [ - convert_to_local_time_str(row["timestamp"]), - row["message"], - ] - ) - ) - # update the from timestamp to the last timestamp of received message - if ( - maybe_timestamp := convert_to_local_time_with_tz(row["timestamp"]) - ) is not None: - console.debug( - f"Updating from {from_iso_timestamp} to {maybe_timestamp}" - ) - # Add a small delta so does not poll the same logs - from_iso_timestamp = maybe_timestamp + timedelta(microseconds=1e5) - else: - console.warn(f"Unable to parse timestamp {row['timestamp']}") - server_message = row["message"].lower() - if "fail" in server_message: - console.debug( - "Received failure message, stop event message streaming" - ) - return False - if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES): - console.debug( - "Received end of deployment message, stop event message streaming" - ) - return True - time.sleep(1) - except httpx.HTTPError as he: - # This includes HTTP server and client error - console.debug(f"Unable to get more deployment events due to {he}.") - except Exception as ex: - console.warn(f"Unable to parse server response due to {ex}.") - - -async def display_deploy_milestones(key: str, from_iso_timestamp: datetime) -> bool: - """Display the deploy milestone messages reported back from the hosting server. - - Args: - key: The deployment key. - from_iso_timestamp: The timestamp of the deployment request time, this helps with the milestone query. - - Raises: - ValueError: If a non-empty key is not provided. - Exception: If the user is not authenticated. - - Returns: - False if server reports back failure, True otherwise. - """ - if not key: - raise ValueError("Non-empty key is required for querying deploy status.") - if not (token := requires_authenticated()): - raise Exception("not authenticated") - - try: - logs_endpoint = f"{DEPLOYMENT_LOGS_ENDPOINT}/{key}/logs?access_token={token}&log_type={LogType.DEPLOY_LOG.value}&from_iso_timestamp={from_iso_timestamp.astimezone().isoformat()}" - console.debug(f"log server endpoint: {logs_endpoint}") - _ws = websockets.connect(logs_endpoint) # type: ignore - async with _ws as ws: - # Stream back the deploy events reported back from the server - for _ in range(DEPLOYMENT_EVENT_MESSAGES_RETRIES): - row_json = json.loads(await ws.recv()) - console.debug(f"Server responded with: {row_json}") - if row_json and isinstance(row_json, dict): - # Only show the timestamp and actual message - console.print( - " | ".join( - [ - convert_to_local_time_str(row_json["timestamp"]), - row_json["message"], - ] - ) - ) - server_message = row_json["message"].lower() - if "fail" in server_message: - console.debug( - "Received failure message, stop event message streaming" - ) - return False - if any(msg in server_message for msg in END_OF_DEPLOYMENT_MESSAGES): - console.debug( - "Received end of deployment message, stop event message streaming" - ) - return True - else: - console.debug("Server responded, no new events yet, this is normal") - except Exception as ex: - console.debug(f"Unable to get more deployment events due to {ex}.") - return False - - -def wait_for_server_to_pick_up_request(): - """Wait for server to pick up the request. Right now is just sleep.""" - with console.status( - f"Waiting for server to pick up request ~ {DEPLOYMENT_PICKUP_DELAY} seconds ..." - ): - for _ in range(DEPLOYMENT_PICKUP_DELAY): - time.sleep(1) - - -def interactive_prompt_for_envs() -> list[str]: - """Interactive prompt for environment variables. - - Returns: - The list of environment variables in key=value string format. - """ - envs = [] - envs_finished = False - env_count = 1 - env_key_prompt = f" * env-{env_count} name (enter to skip)" - console.print("Environment variables for your production App ...") - while not envs_finished: - env_key = console.ask(env_key_prompt) - if not env_key: - envs_finished = True - if envs: - console.print("Finished adding envs.") - else: - console.print("No envs added. Continuing ...") - break - # If it possible to have empty values for env, so we do not check here - env_value = console.ask(f" env-{env_count} value") - envs.append(f"{env_key}={env_value}") - env_count += 1 - env_key_prompt = f" * env-{env_count} name (enter to skip)" - return envs - - -def get_regions() -> list[dict]: - """Get the supported regions from the hosting server. - - Returns: - A list of dict representation of the region information. - """ - try: - response = httpx.get( - GET_REGIONS_ENDPOINT, - timeout=HTTP_REQUEST_TIMEOUT, - ) - response.raise_for_status() - response_json = response.json() - if response_json is None or not isinstance(response_json, list): - console.error("Expect server to return a list ") - return [] - if ( - response_json - and response_json[0] is not None - and not isinstance(response_json[0], dict) - ): - console.error("Expect return values are dict's") - return [] - return response_json - except Exception as ex: - console.error(f"Unable to get regions due to {ex}.") - return [] diff --git a/tests/test_reflex.py b/tests/test_reflex.py deleted file mode 100644 index dd6f6a142..000000000 --- a/tests/test_reflex.py +++ /dev/null @@ -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 diff --git a/tests/utils/test_hosting.py b/tests/utils/test_hosting.py deleted file mode 100644 index 7f6e2fda3..000000000 --- a/tests/utils/test_hosting.py +++ /dev/null @@ -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