reflex.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """Reflex CLI to create, run, and deploy apps."""
  2. import atexit
  3. import os
  4. from pathlib import Path
  5. import httpx
  6. import typer
  7. from alembic.util.exc import CommandError
  8. from reflex import constants, model
  9. from reflex.config import get_config
  10. from reflex.utils import build, console, exec, prerequisites, processes, telemetry
  11. # Create the app.
  12. cli = typer.Typer(add_completion=False)
  13. # Get the config.
  14. config = get_config()
  15. def version(value: bool):
  16. """Get the Reflex version.
  17. Args:
  18. value: Whether the version flag was passed.
  19. Raises:
  20. typer.Exit: If the version flag was passed.
  21. """
  22. if value:
  23. console.print(constants.VERSION)
  24. raise typer.Exit()
  25. @cli.callback()
  26. def main(
  27. version: bool = typer.Option(
  28. None,
  29. "-v",
  30. "--version",
  31. callback=version,
  32. help="Get the Reflex version.",
  33. is_eager=True,
  34. ),
  35. ):
  36. """Reflex CLI to create, run, and deploy apps."""
  37. pass
  38. @cli.command()
  39. def init(
  40. name: str = typer.Option(
  41. None, metavar="APP_NAME", help="The name of the app to initialize."
  42. ),
  43. template: constants.Template = typer.Option(
  44. constants.Template.DEFAULT, help="The template to initialize the app with."
  45. ),
  46. loglevel: constants.LogLevel = typer.Option(
  47. config.loglevel, help="The log level to use."
  48. ),
  49. ):
  50. """Initialize a new Reflex app in the current directory."""
  51. # Set the log level.
  52. console.set_log_level(loglevel)
  53. # Show system info
  54. exec.output_system_info()
  55. # Get the app name.
  56. app_name = prerequisites.get_default_app_name() if name is None else name
  57. console.rule(f"[bold]Initializing {app_name}")
  58. # Set up the web project.
  59. prerequisites.initialize_frontend_dependencies()
  60. # Migrate Pynecone projects to Reflex.
  61. prerequisites.migrate_to_reflex()
  62. # Set up the app directory, only if the config doesn't exist.
  63. if not os.path.exists(constants.CONFIG_FILE):
  64. prerequisites.create_config(app_name)
  65. prerequisites.initialize_app_directory(app_name, template)
  66. telemetry.send("init", config.telemetry_enabled)
  67. else:
  68. telemetry.send("reinit", config.telemetry_enabled)
  69. # Initialize the .gitignore.
  70. prerequisites.initialize_gitignore()
  71. # Finish initializing the app.
  72. console.success(f"Initialized {app_name}")
  73. @cli.command()
  74. def run(
  75. env: constants.Env = typer.Option(
  76. constants.Env.DEV, help="The environment to run the app in."
  77. ),
  78. frontend: bool = typer.Option(
  79. False, "--frontend-only", help="Execute only frontend."
  80. ),
  81. backend: bool = typer.Option(False, "--backend-only", help="Execute only backend."),
  82. frontend_port: str = typer.Option(
  83. config.frontend_port, help="Specify a different frontend port."
  84. ),
  85. backend_port: str = typer.Option(
  86. config.backend_port, help="Specify a different backend port."
  87. ),
  88. backend_host: str = typer.Option(
  89. config.backend_host, help="Specify the backend host."
  90. ),
  91. loglevel: constants.LogLevel = typer.Option(
  92. config.loglevel, help="The log level to use."
  93. ),
  94. ):
  95. """Run the app in the current directory."""
  96. # Set the log level.
  97. console.set_log_level(loglevel)
  98. # Show system info
  99. exec.output_system_info()
  100. # If no --frontend-only and no --backend-only, then turn on frontend and backend both
  101. if not frontend and not backend:
  102. frontend = True
  103. backend = True
  104. if not frontend and backend:
  105. _skip_compile()
  106. # Check that the app is initialized.
  107. prerequisites.check_initialized(frontend=frontend)
  108. # If something is running on the ports, ask the user if they want to kill or change it.
  109. if frontend and processes.is_process_on_port(frontend_port):
  110. frontend_port = processes.change_or_terminate_port(frontend_port, "frontend")
  111. if backend and processes.is_process_on_port(backend_port):
  112. backend_port = processes.change_or_terminate_port(backend_port, "backend")
  113. console.rule("[bold]Starting Reflex App")
  114. if frontend:
  115. # Get the app module.
  116. prerequisites.get_app()
  117. # Warn if schema is not up to date.
  118. prerequisites.check_schema_up_to_date()
  119. # Get the frontend and backend commands, based on the environment.
  120. setup_frontend = frontend_cmd = backend_cmd = None
  121. if env == constants.Env.DEV:
  122. setup_frontend, frontend_cmd, backend_cmd = (
  123. build.setup_frontend,
  124. exec.run_frontend,
  125. exec.run_backend,
  126. )
  127. if env == constants.Env.PROD:
  128. setup_frontend, frontend_cmd, backend_cmd = (
  129. build.setup_frontend_prod,
  130. exec.run_frontend_prod,
  131. exec.run_backend_prod,
  132. )
  133. assert setup_frontend and frontend_cmd and backend_cmd, "Invalid env"
  134. # Post a telemetry event.
  135. telemetry.send(f"run-{env.value}", config.telemetry_enabled)
  136. # Display custom message when there is a keyboard interrupt.
  137. atexit.register(processes.atexit_handler)
  138. # Run the frontend and backend together.
  139. commands = []
  140. # Run the frontend on a separate thread.
  141. if frontend:
  142. setup_frontend(Path.cwd())
  143. commands.append((frontend_cmd, Path.cwd(), frontend_port))
  144. # In prod mode, run the backend on a separate thread.
  145. if backend and env == constants.Env.PROD:
  146. commands.append((backend_cmd, backend_host, backend_port))
  147. # Start the frontend and backend.
  148. with processes.run_concurrently_context(*commands):
  149. # In dev mode, run the backend on the main thread.
  150. if backend and env == constants.Env.DEV:
  151. backend_cmd(backend_host, int(backend_port))
  152. @cli.command()
  153. def deploy(
  154. dry_run: bool = typer.Option(False, help="Whether to run a dry run."),
  155. loglevel: constants.LogLevel = typer.Option(
  156. console.LOG_LEVEL, help="The log level to use."
  157. ),
  158. ):
  159. """Deploy the app to the Reflex hosting service."""
  160. # Set the log level.
  161. console.set_log_level(loglevel)
  162. # Show system info
  163. exec.output_system_info()
  164. # Check if the deploy url is set.
  165. if config.rxdeploy_url is None:
  166. console.info("This feature is coming soon!")
  167. return
  168. # Compile the app in production mode.
  169. export(loglevel=loglevel)
  170. # Exit early if this is a dry run.
  171. if dry_run:
  172. return
  173. # Deploy the app.
  174. data = {"userId": config.username, "projectId": config.app_name}
  175. original_response = httpx.get(config.rxdeploy_url, params=data)
  176. response = original_response.json()
  177. frontend = response["frontend_resources_url"]
  178. backend = response["backend_resources_url"]
  179. # Upload the frontend and backend.
  180. with open(constants.FRONTEND_ZIP, "rb") as f:
  181. httpx.put(frontend, data=f) # type: ignore
  182. with open(constants.BACKEND_ZIP, "rb") as f:
  183. httpx.put(backend, data=f) # type: ignore
  184. @cli.command()
  185. def export(
  186. zipping: bool = typer.Option(
  187. True, "--no-zip", help="Disable zip for backend and frontend exports."
  188. ),
  189. frontend: bool = typer.Option(
  190. True, "--backend-only", help="Export only backend.", show_default=False
  191. ),
  192. backend: bool = typer.Option(
  193. True, "--frontend-only", help="Export only frontend.", show_default=False
  194. ),
  195. loglevel: constants.LogLevel = typer.Option(
  196. console.LOG_LEVEL, help="The log level to use."
  197. ),
  198. ):
  199. """Export the app to a zip file."""
  200. # Set the log level.
  201. console.set_log_level(loglevel)
  202. # Show system info
  203. exec.output_system_info()
  204. # Check that the app is initialized.
  205. prerequisites.check_initialized(frontend=frontend)
  206. # Compile the app in production mode and export it.
  207. console.rule("[bold]Compiling production app and preparing for export.")
  208. if frontend:
  209. # Ensure module can be imported and app.compile() is called.
  210. prerequisites.get_app()
  211. # Set up .web directory and install frontend dependencies.
  212. build.setup_frontend(Path.cwd())
  213. # Export the app.
  214. build.export(
  215. backend=backend,
  216. frontend=frontend,
  217. zip=zipping,
  218. deploy_url=config.deploy_url,
  219. )
  220. # Post a telemetry event.
  221. telemetry.send("export", config.telemetry_enabled)
  222. db_cli = typer.Typer()
  223. def _skip_compile():
  224. """Skip the compile step."""
  225. os.environ[constants.SKIP_COMPILE_ENV_VAR] = "yes"
  226. @db_cli.command(name="init")
  227. def db_init():
  228. """Create database schema and migration configuration."""
  229. # Check the database url.
  230. if config.db_url is None:
  231. console.error("db_url is not configured, cannot initialize.")
  232. return
  233. # Check the alembic config.
  234. if Path(constants.ALEMBIC_CONFIG).exists():
  235. console.error(
  236. "Database is already initialized. Use "
  237. "[bold]reflex db makemigrations[/bold] to create schema change "
  238. "scripts and [bold]reflex db migrate[/bold] to apply migrations "
  239. "to a new or existing database.",
  240. )
  241. return
  242. # Initialize the database.
  243. _skip_compile()
  244. prerequisites.get_app()
  245. model.Model.alembic_init()
  246. model.Model.migrate(autogenerate=True)
  247. @db_cli.command()
  248. def migrate():
  249. """Create or update database schema from migration scripts."""
  250. _skip_compile()
  251. prerequisites.get_app()
  252. if not prerequisites.check_db_initialized():
  253. return
  254. model.Model.migrate()
  255. prerequisites.check_schema_up_to_date()
  256. @db_cli.command()
  257. def makemigrations(
  258. message: str = typer.Option(
  259. None, help="Human readable identifier for the generated revision."
  260. ),
  261. ):
  262. """Create autogenerated alembic migration scripts."""
  263. _skip_compile()
  264. prerequisites.get_app()
  265. if not prerequisites.check_db_initialized():
  266. return
  267. with model.Model.get_db_engine().connect() as connection:
  268. try:
  269. model.Model.alembic_autogenerate(connection=connection, message=message)
  270. except CommandError as command_error:
  271. if "Target database is not up to date." not in str(command_error):
  272. raise
  273. console.error(
  274. f"{command_error} Run [bold]reflex db migrate[/bold] to update database."
  275. )
  276. cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
  277. if __name__ == "__main__":
  278. cli()