reflex.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913
  1. """Reflex CLI to create, run, and deploy apps."""
  2. from __future__ import annotations
  3. import asyncio
  4. import atexit
  5. import json
  6. import os
  7. import shutil
  8. import tempfile
  9. import time
  10. import webbrowser
  11. from datetime import datetime
  12. from pathlib import Path
  13. from typing import List, Optional
  14. import httpx
  15. import typer
  16. import typer.core
  17. from alembic.util.exc import CommandError
  18. from tabulate import tabulate
  19. from reflex import constants, model
  20. from reflex.config import get_config
  21. from reflex.utils import (
  22. build,
  23. console,
  24. dependency,
  25. exec,
  26. hosting,
  27. prerequisites,
  28. processes,
  29. telemetry,
  30. )
  31. # Disable typer+rich integration for help panels
  32. typer.core.rich = False # type: ignore
  33. # Create the app.
  34. try:
  35. cli = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
  36. except TypeError:
  37. # Fallback for older typer versions.
  38. cli = typer.Typer(add_completion=False)
  39. # Get the config.
  40. config = get_config()
  41. def version(value: bool):
  42. """Get the Reflex version.
  43. Args:
  44. value: Whether the version flag was passed.
  45. Raises:
  46. typer.Exit: If the version flag was passed.
  47. """
  48. if value:
  49. console.print(constants.Reflex.VERSION)
  50. raise typer.Exit()
  51. @cli.callback()
  52. def main(
  53. version: bool = typer.Option(
  54. None,
  55. "-v",
  56. "--version",
  57. callback=version,
  58. help="Get the Reflex version.",
  59. is_eager=True,
  60. ),
  61. ):
  62. """Reflex CLI to create, run, and deploy apps."""
  63. pass
  64. def _init(
  65. name: str,
  66. template: constants.Templates.Kind | None = constants.Templates.Kind.BLANK,
  67. loglevel: constants.LogLevel = config.loglevel,
  68. ):
  69. """Initialize a new Reflex app in the given directory."""
  70. # Set the log level.
  71. console.set_log_level(loglevel)
  72. # Show system info
  73. exec.output_system_info()
  74. # Get the app name.
  75. app_name = prerequisites.get_default_app_name() if name is None else name
  76. console.rule(f"[bold]Initializing {app_name}")
  77. # Set up the web project.
  78. prerequisites.initialize_frontend_dependencies()
  79. # Migrate Pynecone projects to Reflex.
  80. prerequisites.migrate_to_reflex()
  81. # Set up the app directory, only if the config doesn't exist.
  82. if not os.path.exists(constants.Config.FILE):
  83. if template is None:
  84. template = prerequisites.prompt_for_template()
  85. prerequisites.create_config(app_name)
  86. prerequisites.initialize_app_directory(app_name, template)
  87. telemetry.send("init")
  88. else:
  89. telemetry.send("reinit")
  90. # Initialize the .gitignore.
  91. prerequisites.initialize_gitignore()
  92. # Initialize the requirements.txt.
  93. prerequisites.initialize_requirements_txt()
  94. # Finish initializing the app.
  95. console.success(f"Initialized {app_name}")
  96. @cli.command()
  97. def init(
  98. name: str = typer.Option(
  99. None, metavar="APP_NAME", help="The name of the app to initialize."
  100. ),
  101. template: constants.Templates.Kind = typer.Option(
  102. None,
  103. help="The template to initialize the app with.",
  104. ),
  105. loglevel: constants.LogLevel = typer.Option(
  106. config.loglevel, help="The log level to use."
  107. ),
  108. ):
  109. """Initialize a new Reflex app in the current directory."""
  110. _init(name, template, loglevel)
  111. def _run(
  112. env: constants.Env = constants.Env.DEV,
  113. frontend: bool = True,
  114. backend: bool = True,
  115. frontend_port: str = str(get_config().frontend_port),
  116. backend_port: str = str(get_config().backend_port),
  117. backend_host: str = config.backend_host,
  118. loglevel: constants.LogLevel = config.loglevel,
  119. ):
  120. """Run the app in the given directory."""
  121. # Set the log level.
  122. console.set_log_level(loglevel)
  123. # Set env mode in the environment
  124. os.environ["REFLEX_ENV_MODE"] = env.value
  125. # Show system info
  126. exec.output_system_info()
  127. # If no --frontend-only and no --backend-only, then turn on frontend and backend both
  128. if not frontend and not backend:
  129. frontend = True
  130. backend = True
  131. if not frontend and backend:
  132. _skip_compile()
  133. # Check that the app is initialized.
  134. prerequisites.check_initialized(frontend=frontend)
  135. # If something is running on the ports, ask the user if they want to kill or change it.
  136. if frontend and processes.is_process_on_port(frontend_port):
  137. frontend_port = processes.change_or_terminate_port(frontend_port, "frontend")
  138. if backend and processes.is_process_on_port(backend_port):
  139. backend_port = processes.change_or_terminate_port(backend_port, "backend")
  140. # Apply the new ports to the config.
  141. if frontend_port != str(config.frontend_port):
  142. config._set_persistent(frontend_port=frontend_port)
  143. if backend_port != str(config.backend_port):
  144. config._set_persistent(backend_port=backend_port)
  145. # Reload the config to make sure the env vars are persistent.
  146. get_config(reload=True)
  147. console.rule("[bold]Starting Reflex App")
  148. if frontend:
  149. # Get the app module.
  150. prerequisites.get_app()
  151. # Warn if schema is not up to date.
  152. prerequisites.check_schema_up_to_date()
  153. # Get the frontend and backend commands, based on the environment.
  154. setup_frontend = frontend_cmd = backend_cmd = None
  155. if env == constants.Env.DEV:
  156. setup_frontend, frontend_cmd, backend_cmd = (
  157. build.setup_frontend,
  158. exec.run_frontend,
  159. exec.run_backend,
  160. )
  161. if env == constants.Env.PROD:
  162. setup_frontend, frontend_cmd, backend_cmd = (
  163. build.setup_frontend_prod,
  164. exec.run_frontend_prod,
  165. exec.run_backend_prod,
  166. )
  167. assert setup_frontend and frontend_cmd and backend_cmd, "Invalid env"
  168. # Post a telemetry event.
  169. telemetry.send(f"run-{env.value}")
  170. # Display custom message when there is a keyboard interrupt.
  171. atexit.register(processes.atexit_handler)
  172. # Run the frontend and backend together.
  173. commands = []
  174. # Run the frontend on a separate thread.
  175. if frontend:
  176. setup_frontend(Path.cwd())
  177. commands.append((frontend_cmd, Path.cwd(), frontend_port))
  178. # In prod mode, run the backend on a separate thread.
  179. if backend and env == constants.Env.PROD:
  180. commands.append((backend_cmd, backend_host, backend_port))
  181. # Start the frontend and backend.
  182. with processes.run_concurrently_context(*commands):
  183. # In dev mode, run the backend on the main thread.
  184. if backend and env == constants.Env.DEV:
  185. backend_cmd(backend_host, int(backend_port))
  186. @cli.command()
  187. def run(
  188. env: constants.Env = typer.Option(
  189. constants.Env.DEV, help="The environment to run the app in."
  190. ),
  191. frontend: bool = typer.Option(
  192. False, "--frontend-only", help="Execute only frontend."
  193. ),
  194. backend: bool = typer.Option(False, "--backend-only", help="Execute only backend."),
  195. frontend_port: str = typer.Option(
  196. config.frontend_port, help="Specify a different frontend port."
  197. ),
  198. backend_port: str = typer.Option(
  199. config.backend_port, help="Specify a different backend port."
  200. ),
  201. backend_host: str = typer.Option(
  202. config.backend_host, help="Specify the backend host."
  203. ),
  204. loglevel: constants.LogLevel = typer.Option(
  205. config.loglevel, help="The log level to use."
  206. ),
  207. ):
  208. """Run the app in the current directory."""
  209. _run(env, frontend, backend, frontend_port, backend_port, backend_host, loglevel)
  210. @cli.command()
  211. def deploy_legacy(
  212. dry_run: bool = typer.Option(False, help="Whether to run a dry run."),
  213. loglevel: constants.LogLevel = typer.Option(
  214. console._LOG_LEVEL, help="The log level to use."
  215. ),
  216. ):
  217. """Deploy the app to the Reflex hosting service."""
  218. # Set the log level.
  219. console.set_log_level(loglevel)
  220. # Show system info
  221. exec.output_system_info()
  222. # Check if the deploy url is set.
  223. if config.rxdeploy_url is None:
  224. console.info("This feature is coming soon!")
  225. return
  226. # Compile the app in production mode.
  227. export(loglevel=loglevel)
  228. # Exit early if this is a dry run.
  229. if dry_run:
  230. return
  231. # Deploy the app.
  232. data = {"userId": config.username, "projectId": config.app_name}
  233. original_response = httpx.get(config.rxdeploy_url, params=data)
  234. response = original_response.json()
  235. frontend = response["frontend_resources_url"]
  236. backend = response["backend_resources_url"]
  237. # Upload the frontend and backend.
  238. with open(constants.ComponentName.FRONTEND.zip(), "rb") as f:
  239. httpx.put(frontend, data=f) # type: ignore
  240. with open(constants.ComponentName.BACKEND.zip(), "rb") as f:
  241. httpx.put(backend, data=f) # type: ignore
  242. @cli.command()
  243. def export(
  244. zipping: bool = typer.Option(
  245. True, "--no-zip", help="Disable zip for backend and frontend exports."
  246. ),
  247. frontend: bool = typer.Option(
  248. True, "--backend-only", help="Export only backend.", show_default=False
  249. ),
  250. backend: bool = typer.Option(
  251. True, "--frontend-only", help="Export only frontend.", show_default=False
  252. ),
  253. zip_dest_dir: str = typer.Option(
  254. os.getcwd(),
  255. help="The directory to export the zip files to.",
  256. show_default=False,
  257. ),
  258. upload_db_file: bool = typer.Option(
  259. False,
  260. help="Whether to exclude sqlite db files when exporting backend.",
  261. hidden=True,
  262. ),
  263. loglevel: constants.LogLevel = typer.Option(
  264. console._LOG_LEVEL, help="The log level to use."
  265. ),
  266. ):
  267. """Export the app to a zip file."""
  268. # Set the log level.
  269. console.set_log_level(loglevel)
  270. # Show system info
  271. exec.output_system_info()
  272. # Check that the app is initialized.
  273. prerequisites.check_initialized(frontend=frontend)
  274. # Compile the app in production mode and export it.
  275. console.rule("[bold]Compiling production app and preparing for export.")
  276. if frontend:
  277. # Ensure module can be imported and app.compile() is called.
  278. prerequisites.get_app()
  279. # Set up .web directory and install frontend dependencies.
  280. build.setup_frontend(Path.cwd())
  281. # Export the app.
  282. build.export(
  283. backend=backend,
  284. frontend=frontend,
  285. zip=zipping,
  286. zip_dest_dir=zip_dest_dir,
  287. deploy_url=config.deploy_url,
  288. upload_db_file=upload_db_file,
  289. )
  290. # Post a telemetry event.
  291. telemetry.send("export")
  292. @cli.command()
  293. def login(
  294. loglevel: constants.LogLevel = typer.Option(
  295. config.loglevel, help="The log level to use."
  296. ),
  297. ):
  298. """Authenticate with Reflex hosting service."""
  299. # Set the log level.
  300. console.set_log_level(loglevel)
  301. access_token, invitation_code = hosting.authenticated_token()
  302. if access_token:
  303. console.print("You already logged in.")
  304. return
  305. # If not already logged in, open a browser window/tab to the login page.
  306. access_token = hosting.authenticate_on_browser(invitation_code)
  307. if not access_token:
  308. console.error(f"Unable to authenticate. Please try again or contact support.")
  309. raise typer.Exit(1)
  310. console.print("Successfully logged in.")
  311. @cli.command()
  312. def logout(
  313. loglevel: constants.LogLevel = typer.Option(
  314. config.loglevel, help="The log level to use."
  315. ),
  316. ):
  317. """Log out of access to Reflex hosting service."""
  318. console.set_log_level(loglevel)
  319. hosting.log_out_on_browser()
  320. console.debug("Deleting access token from config locally")
  321. hosting.delete_token_from_config(include_invitation_code=True)
  322. db_cli = typer.Typer()
  323. def _skip_compile():
  324. """Skip the compile step."""
  325. os.environ[constants.SKIP_COMPILE_ENV_VAR] = "yes"
  326. @db_cli.command(name="init")
  327. def db_init():
  328. """Create database schema and migration configuration."""
  329. # Check the database url.
  330. if config.db_url is None:
  331. console.error("db_url is not configured, cannot initialize.")
  332. return
  333. # Check the alembic config.
  334. if Path(constants.ALEMBIC_CONFIG).exists():
  335. console.error(
  336. "Database is already initialized. Use "
  337. "[bold]reflex db makemigrations[/bold] to create schema change "
  338. "scripts and [bold]reflex db migrate[/bold] to apply migrations "
  339. "to a new or existing database.",
  340. )
  341. return
  342. # Initialize the database.
  343. _skip_compile()
  344. prerequisites.get_app()
  345. model.Model.alembic_init()
  346. model.Model.migrate(autogenerate=True)
  347. @db_cli.command()
  348. def migrate():
  349. """Create or update database schema from migration scripts."""
  350. _skip_compile()
  351. prerequisites.get_app()
  352. if not prerequisites.check_db_initialized():
  353. return
  354. model.Model.migrate()
  355. prerequisites.check_schema_up_to_date()
  356. @db_cli.command()
  357. def makemigrations(
  358. message: str = typer.Option(
  359. None, help="Human readable identifier for the generated revision."
  360. ),
  361. ):
  362. """Create autogenerated alembic migration scripts."""
  363. _skip_compile()
  364. prerequisites.get_app()
  365. if not prerequisites.check_db_initialized():
  366. return
  367. with model.Model.get_db_engine().connect() as connection:
  368. try:
  369. model.Model.alembic_autogenerate(connection=connection, message=message)
  370. except CommandError as command_error:
  371. if "Target database is not up to date." not in str(command_error):
  372. raise
  373. console.error(
  374. f"{command_error} Run [bold]reflex db migrate[/bold] to update database."
  375. )
  376. @cli.command()
  377. def deploy(
  378. key: Optional[str] = typer.Option(
  379. None,
  380. "-k",
  381. "--deployment-key",
  382. help="The name of the deployment. Domain name safe characters only.",
  383. ),
  384. app_name: str = typer.Option(
  385. config.app_name,
  386. "--app-name",
  387. help="The name of the App to deploy under.",
  388. hidden=True,
  389. ),
  390. regions: List[str] = typer.Option(
  391. list(),
  392. "-r",
  393. "--region",
  394. help="The regions to deploy to.",
  395. ),
  396. envs: List[str] = typer.Option(
  397. list(),
  398. "--env",
  399. help="The environment variables to set: <key>=<value>. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.",
  400. ),
  401. cpus: Optional[int] = typer.Option(
  402. None, help="The number of CPUs to allocate.", hidden=True
  403. ),
  404. memory_mb: Optional[int] = typer.Option(
  405. None, help="The amount of memory to allocate.", hidden=True
  406. ),
  407. auto_start: Optional[bool] = typer.Option(
  408. None,
  409. help="Whether to auto start the instance.",
  410. hidden=True,
  411. ),
  412. auto_stop: Optional[bool] = typer.Option(
  413. None,
  414. help="Whether to auto stop the instance.",
  415. hidden=True,
  416. ),
  417. frontend_hostname: Optional[str] = typer.Option(
  418. None,
  419. "--frontend-hostname",
  420. help="The hostname of the frontend.",
  421. hidden=True,
  422. ),
  423. interactive: Optional[bool] = typer.Option(
  424. True,
  425. help="Whether to list configuration options and ask for confirmation.",
  426. ),
  427. with_metrics: Optional[str] = typer.Option(
  428. None,
  429. help="Setting for metrics scraping for the deployment. Setup required in user code.",
  430. hidden=True,
  431. ),
  432. with_tracing: Optional[str] = typer.Option(
  433. None,
  434. help="Setting to export tracing for the deployment. Setup required in user code.",
  435. hidden=True,
  436. ),
  437. upload_db_file: bool = typer.Option(
  438. False,
  439. help="Whether to include local sqlite db files when uploading to hosting service.",
  440. hidden=True,
  441. ),
  442. loglevel: constants.LogLevel = typer.Option(
  443. config.loglevel, help="The log level to use."
  444. ),
  445. ):
  446. """Deploy the app to the Reflex hosting service."""
  447. # Set the log level.
  448. console.set_log_level(loglevel)
  449. if not interactive and not key:
  450. console.error(
  451. "Please provide a name for the deployed instance when not in interactive mode."
  452. )
  453. raise typer.Exit(1)
  454. dependency.check_requirements()
  455. # Check if we are set up.
  456. prerequisites.check_initialized(frontend=True)
  457. enabled_regions = None
  458. # If there is already a key, then it is passed in from CLI option in the non-interactive mode
  459. if key is not None and not hosting.is_valid_deployment_key(key):
  460. console.error(
  461. f"Deployment key {key} is not valid. Please use only domain name safe characters."
  462. )
  463. raise typer.Exit(1)
  464. try:
  465. # Send a request to server to obtain necessary information
  466. # in preparation of a deployment. For example,
  467. # server can return confirmation of a particular deployment key,
  468. # is available, or suggest a new key, or return an existing deployment.
  469. # Some of these are used in the interactive mode.
  470. pre_deploy_response = hosting.prepare_deploy(
  471. app_name, key=key, frontend_hostname=frontend_hostname
  472. )
  473. # Note: we likely won't need to fetch this twice
  474. if pre_deploy_response.enabled_regions is not None:
  475. enabled_regions = pre_deploy_response.enabled_regions
  476. except Exception as ex:
  477. console.error(f"Unable to prepare deployment")
  478. raise typer.Exit(1) from ex
  479. # The app prefix should not change during the time of preparation
  480. app_prefix = pre_deploy_response.app_prefix
  481. if not interactive:
  482. # in this case, the key was supplied for the pre_deploy call, at this point the reply is expected
  483. if (reply := pre_deploy_response.reply) is None:
  484. console.error(f"Unable to deploy at this name {key}.")
  485. raise typer.Exit(1)
  486. api_url = reply.api_url
  487. deploy_url = reply.deploy_url
  488. else:
  489. (
  490. key_candidate,
  491. api_url,
  492. deploy_url,
  493. ) = hosting.interactive_get_deployment_key_from_user_input(
  494. pre_deploy_response, app_name, frontend_hostname=frontend_hostname
  495. )
  496. if not key_candidate or not api_url or not deploy_url:
  497. console.error("Unable to find a suitable deployment key.")
  498. raise typer.Exit(1)
  499. # Now copy over the candidate to the key
  500. key = key_candidate
  501. # Then CP needs to know the user's location, which requires user permission
  502. while True:
  503. region_input = console.ask(
  504. "Region to deploy to. Enter to use default.",
  505. default=regions[0] if regions else "sjc",
  506. )
  507. if enabled_regions is None or region_input in enabled_regions:
  508. break
  509. else:
  510. console.warn(
  511. f"{region_input} is not a valid region. Must be one of {enabled_regions}"
  512. )
  513. console.warn("Run `reflex deploymemts regions` to see details.")
  514. regions = regions or [region_input]
  515. # process the envs
  516. envs = hosting.interactive_prompt_for_envs()
  517. # Check the required params are valid
  518. console.debug(f"{key=}, {regions=}, {app_name=}, {app_prefix=}, {api_url}")
  519. if not key or not regions or not app_name or not app_prefix or not api_url:
  520. console.error("Please provide all the required parameters.")
  521. raise typer.Exit(1)
  522. # Note: if the user uses --no-interactive mode, there was no prepare_deploy call
  523. # so we do not check the regions until the call to hosting server
  524. processed_envs = hosting.process_envs(envs) if envs else None
  525. # Compile the app in production mode.
  526. config.api_url = api_url
  527. config.deploy_url = deploy_url
  528. tmp_dir = tempfile.mkdtemp()
  529. try:
  530. export(
  531. frontend=True,
  532. backend=True,
  533. zipping=True,
  534. zip_dest_dir=tmp_dir,
  535. loglevel=loglevel,
  536. upload_db_file=upload_db_file,
  537. )
  538. except ImportError as ie:
  539. console.error(
  540. f"Encountered ImportError, did you install all the dependencies? {ie}"
  541. )
  542. if os.path.exists(tmp_dir):
  543. shutil.rmtree(tmp_dir)
  544. raise typer.Exit(1) from ie
  545. except Exception as ex:
  546. console.error(f"Unable to export due to: {ex}")
  547. if os.path.exists(tmp_dir):
  548. shutil.rmtree(tmp_dir)
  549. raise typer.Exit(1) from ex
  550. frontend_file_name = constants.ComponentName.FRONTEND.zip()
  551. backend_file_name = constants.ComponentName.BACKEND.zip()
  552. console.print("Uploading code and sending request ...")
  553. deploy_requested_at = datetime.now().astimezone()
  554. try:
  555. deploy_response = hosting.deploy(
  556. frontend_file_name=frontend_file_name,
  557. backend_file_name=backend_file_name,
  558. export_dir=tmp_dir,
  559. key=key,
  560. app_name=app_name,
  561. regions=regions,
  562. app_prefix=app_prefix,
  563. cpus=cpus,
  564. memory_mb=memory_mb,
  565. auto_start=auto_start,
  566. auto_stop=auto_stop,
  567. frontend_hostname=frontend_hostname,
  568. envs=processed_envs,
  569. with_metrics=with_metrics,
  570. with_tracing=with_tracing,
  571. )
  572. except Exception as ex:
  573. console.error(f"Unable to deploy due to: {ex}")
  574. raise typer.Exit(1) from ex
  575. finally:
  576. if os.path.exists(tmp_dir):
  577. shutil.rmtree(tmp_dir)
  578. # Deployment will actually start when data plane reconciles this request
  579. console.debug(f"deploy_response: {deploy_response}")
  580. console.rule("[bold]Deploying production app.")
  581. console.print(
  582. "[bold]Deployment will start shortly. Closing this command now will not affect your deployment."
  583. )
  584. # It takes a few seconds for the deployment request to be picked up by server
  585. hosting.wait_for_server_to_pick_up_request()
  586. console.print("Waiting for server to report progress ...")
  587. # Display the key events such as build, deploy, etc
  588. server_report_deploy_success = hosting.poll_deploy_milestones(
  589. key, from_iso_timestamp=deploy_requested_at
  590. )
  591. if server_report_deploy_success is None:
  592. console.warn("Hosting server timed out.")
  593. console.warn("The deployment may still be in progress. Proceeding ...")
  594. elif not server_report_deploy_success:
  595. console.error("Hosting server reports failure.")
  596. console.error(
  597. f"Check the server logs using `reflex deployments build-logs {key}`"
  598. )
  599. raise typer.Exit(1)
  600. console.print("Waiting for the new deployment to come up")
  601. backend_up = frontend_up = False
  602. with console.status("Checking backend ..."):
  603. for _ in range(constants.Hosting.BACKEND_POLL_RETRIES):
  604. if backend_up := hosting.poll_backend(deploy_response.backend_url):
  605. break
  606. time.sleep(1)
  607. if not backend_up:
  608. console.print("Backend unreachable")
  609. with console.status("Checking frontend ..."):
  610. for _ in range(constants.Hosting.FRONTEND_POLL_RETRIES):
  611. if frontend_up := hosting.poll_frontend(deploy_response.frontend_url):
  612. break
  613. time.sleep(1)
  614. if not frontend_up:
  615. console.print("frontend is unreachable")
  616. if frontend_up and backend_up:
  617. console.print(
  618. f"Your site [ {key} ] at {regions} is up: {deploy_response.frontend_url}"
  619. )
  620. return
  621. console.warn(f"Your deployment is taking time.")
  622. console.warn(f"Check back later on its status: `reflex deployments status {key}`")
  623. console.warn(f"For logs: `reflex deployments logs {key}`")
  624. @cli.command()
  625. def demo(
  626. frontend_port: str = typer.Option(
  627. "3001", help="Specify a different frontend port."
  628. ),
  629. backend_port: str = typer.Option("8001", help="Specify a different backend port."),
  630. ):
  631. """Run the demo app."""
  632. # Open the demo app in a terminal.
  633. webbrowser.open("https://demo.reflex.run")
  634. # Later: open the demo app locally.
  635. # with tempfile.TemporaryDirectory() as tmp_dir:
  636. # os.chdir(tmp_dir)
  637. # _init(
  638. # name="reflex_demo",
  639. # template=constants.Templates.Kind.DEMO,
  640. # loglevel=constants.LogLevel.DEBUG,
  641. # )
  642. # _run(
  643. # frontend_port=frontend_port,
  644. # backend_port=backend_port,
  645. # loglevel=constants.LogLevel.DEBUG,
  646. # )
  647. deployments_cli = typer.Typer()
  648. @deployments_cli.command(name="list")
  649. def list_deployments(
  650. loglevel: constants.LogLevel = typer.Option(
  651. config.loglevel, help="The log level to use."
  652. ),
  653. as_json: bool = typer.Option(
  654. False, "-j", "--json", help="Whether to output the result in json format."
  655. ),
  656. ):
  657. """List all the hosted deployments of the authenticated user."""
  658. console.set_log_level(loglevel)
  659. try:
  660. deployments = hosting.list_deployments()
  661. except Exception as ex:
  662. console.error(f"Unable to list deployments")
  663. raise typer.Exit(1) from ex
  664. if as_json:
  665. console.print(json.dumps(deployments))
  666. return
  667. if deployments:
  668. headers = list(deployments[0].keys())
  669. table = [list(deployment.values()) for deployment in deployments]
  670. console.print(tabulate(table, headers=headers))
  671. else:
  672. # If returned empty list, print the empty
  673. console.print(str(deployments))
  674. @deployments_cli.command(name="delete")
  675. def delete_deployment(
  676. key: str = typer.Argument(..., help="The name of the deployment."),
  677. loglevel: constants.LogLevel = typer.Option(
  678. config.loglevel, help="The log level to use."
  679. ),
  680. ):
  681. """Delete a hosted instance."""
  682. console.set_log_level(loglevel)
  683. try:
  684. hosting.delete_deployment(key)
  685. except Exception as ex:
  686. console.error(f"Unable to delete deployment")
  687. raise typer.Exit(1) from ex
  688. console.print(f"Successfully deleted [ {key} ].")
  689. @deployments_cli.command(name="status")
  690. def get_deployment_status(
  691. key: str = typer.Argument(..., help="The name of the deployment."),
  692. loglevel: constants.LogLevel = typer.Option(
  693. config.loglevel, help="The log level to use."
  694. ),
  695. ):
  696. """Check the status of a deployment."""
  697. console.set_log_level(loglevel)
  698. try:
  699. console.print(f"Getting status for [ {key} ] ...\n")
  700. status = hosting.get_deployment_status(key)
  701. # TODO: refactor all these tabulate calls
  702. status.backend.updated_at = hosting.convert_to_local_time_str(
  703. status.backend.updated_at or "N/A"
  704. )
  705. backend_status = status.backend.dict(exclude_none=True)
  706. headers = list(backend_status.keys())
  707. table = list(backend_status.values())
  708. console.print(tabulate([table], headers=headers))
  709. # Add a new line in console
  710. console.print("\n")
  711. status.frontend.updated_at = hosting.convert_to_local_time_str(
  712. status.frontend.updated_at or "N/A"
  713. )
  714. frontend_status = status.frontend.dict(exclude_none=True)
  715. headers = list(frontend_status.keys())
  716. table = list(frontend_status.values())
  717. console.print(tabulate([table], headers=headers))
  718. except Exception as ex:
  719. console.error(f"Unable to get deployment status")
  720. raise typer.Exit(1) from ex
  721. @deployments_cli.command(name="logs")
  722. def get_deployment_logs(
  723. key: str = typer.Argument(..., help="The name of the deployment."),
  724. loglevel: constants.LogLevel = typer.Option(
  725. config.loglevel, help="The log level to use."
  726. ),
  727. ):
  728. """Get the logs for a deployment."""
  729. console.set_log_level(loglevel)
  730. console.print("Note: there is a few seconds delay for logs to be available.")
  731. try:
  732. asyncio.get_event_loop().run_until_complete(hosting.get_logs(key))
  733. except Exception as ex:
  734. console.error(f"Unable to get deployment logs")
  735. raise typer.Exit(1) from ex
  736. @deployments_cli.command(name="build-logs")
  737. def get_deployment_build_logs(
  738. key: str = typer.Argument(..., help="The name of the deployment."),
  739. loglevel: constants.LogLevel = typer.Option(
  740. config.loglevel, help="The log level to use."
  741. ),
  742. ):
  743. """Get the logs for a deployment."""
  744. console.set_log_level(loglevel)
  745. console.print("Note: there is a few seconds delay for logs to be available.")
  746. try:
  747. # TODO: we need to find a way not to fetch logs
  748. # that match the deployed app name but not previously of a different owner
  749. # This should not happen often
  750. asyncio.run(hosting.get_logs(key, log_type=hosting.LogType.BUILD_LOG))
  751. except Exception as ex:
  752. console.error(f"Unable to get deployment logs")
  753. raise typer.Exit(1) from ex
  754. @deployments_cli.command(name="regions")
  755. def get_deployment_regions(
  756. loglevel: constants.LogLevel = typer.Option(
  757. config.loglevel, help="The log level to use."
  758. ),
  759. as_json: bool = typer.Option(
  760. False, "-j", "--json", help="Whether to output the result in json format."
  761. ),
  762. ):
  763. """List all the regions of the hosting service."""
  764. console.set_log_level(loglevel)
  765. list_regions_info = hosting.get_regions()
  766. if as_json:
  767. console.print(json.dumps(list_regions_info))
  768. return
  769. if list_regions_info:
  770. headers = list(list_regions_info[0].keys())
  771. table = [list(deployment.values()) for deployment in list_regions_info]
  772. console.print(tabulate(table, headers=headers))
  773. cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
  774. cli.add_typer(
  775. deployments_cli,
  776. name="deployments",
  777. help="Subcommands for managing the Deployments.",
  778. )
  779. if __name__ == "__main__":
  780. cli()