From 068bcd906ecd2b69482d6524c82fa1c801f98be1 Mon Sep 17 00:00:00 2001 From: Nikhil Rao Date: Sun, 30 Jul 2023 19:58:48 -0700 Subject: [PATCH] Add unified logging (#1462) --- reflex/constants.py | 13 ++++ reflex/model.py | 4 +- reflex/reflex.py | 69 +++++++++++------ reflex/testing.py | 1 + reflex/utils/build.py | 63 ++++----------- reflex/utils/console.py | 141 ++++++++++++++++++++++++++------- reflex/utils/exec.py | 57 ++++++-------- reflex/utils/prerequisites.py | 142 ++++++++++++++++------------------ reflex/utils/processes.py | 104 +++++++++++++++++++++---- tests/test_utils.py | 13 ++-- 10 files changed, 370 insertions(+), 237 deletions(-) diff --git a/reflex/constants.py b/reflex/constants.py index a7686ca66..cab2de821 100644 --- a/reflex/constants.py +++ b/reflex/constants.py @@ -1,4 +1,5 @@ """Constants used throughout the package.""" +from __future__ import annotations import os import platform @@ -250,6 +251,18 @@ class LogLevel(str, Enum): ERROR = "error" CRITICAL = "critical" + def __le__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is less than or equal to the other log level. + """ + levels = list(LogLevel) + return levels.index(self) <= levels.index(other) + # Templates class Template(str, Enum): diff --git a/reflex/model.py b/reflex/model.py index 121ebac4c..3db908534 100644 --- a/reflex/model.py +++ b/reflex/model.py @@ -37,8 +37,8 @@ def get_engine(url: Optional[str] = None): if url is None: raise ValueError("No database url configured") if not Path(constants.ALEMBIC_CONFIG).exists(): - console.print( - "[red]Database is not initialized, run [bold]reflex db init[/bold] first." + console.warn( + "Database is not initialized, run [bold]reflex db init[/bold] first." ) echo_db_query = False if conf.env == constants.Env.DEV and constants.SQLALCHEMY_ECHO: diff --git a/reflex/reflex.py b/reflex/reflex.py index 6cbbfb270..5b5f753f5 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -14,7 +14,7 @@ from reflex.config import get_config from reflex.utils import build, console, exec, prerequisites, processes, telemetry # Create the app. -cli = typer.Typer() +cli = typer.Typer(add_completion=False) def version(value: bool): @@ -35,25 +35,33 @@ def version(value: bool): def main( version: bool = typer.Option( None, - "--version", "-v", + "--version", callback=version, help="Get the Reflex version.", is_eager=True, ), ): - """Reflex CLI global configuration.""" + """Reflex CLI to create, run, and deploy apps.""" pass @cli.command() def init( - name: str = typer.Option(None, help="Name of the app to be initialized."), + name: str = typer.Option( + None, metavar="APP_NAME", help="The name of the app to be initialized." + ), template: constants.Template = typer.Option( - constants.Template.DEFAULT, help="Template to use for the app." + constants.Template.DEFAULT, help="The template to initialize the app with." + ), + loglevel: constants.LogLevel = typer.Option( + constants.LogLevel.INFO, help="The log level to use." ), ): """Initialize a new Reflex app in the current directory.""" + # Set the log level. + console.set_log_level(loglevel) + # Get the app name. app_name = prerequisites.get_default_app_name() if name is None else name console.rule(f"[bold]Initializing {app_name}") @@ -78,7 +86,7 @@ def init( prerequisites.initialize_gitignore() # Finish initializing the app. - console.log(f"[bold green]Finished Initializing: {app_name}") + console.success(f"Finished Initializing: {app_name}") @cli.command() @@ -90,16 +98,16 @@ def run( False, "--frontend-only", help="Execute only frontend." ), backend: bool = typer.Option(False, "--backend-only", help="Execute only backend."), - loglevel: constants.LogLevel = typer.Option( - constants.LogLevel.ERROR, help="The log level to use." - ), frontend_port: str = typer.Option(None, help="Specify a different frontend port."), backend_port: str = typer.Option(None, help="Specify a different backend port."), backend_host: str = typer.Option(None, help="Specify the backend host."), + loglevel: constants.LogLevel = typer.Option( + constants.LogLevel.INFO, help="The log level to use." + ), ): """Run the app in the current directory.""" - # Check that the app is initialized. - prerequisites.check_initialized(frontend=frontend) + # Set the log level. + console.set_log_level(loglevel) # Set ports as os env variables to take precedence over config and # .env variables(if override_os_envs flag in config is set to False). @@ -120,6 +128,9 @@ def run( frontend = True backend = True + # Check that the app is initialized. + prerequisites.check_initialized(frontend=frontend) + # If something is running on the ports, ask the user if they want to kill or change it. if frontend and processes.is_process_on_port(frontend_port): frontend_port = processes.change_or_terminate_port(frontend_port, "frontend") @@ -158,14 +169,12 @@ def run( # Run the frontend and backend. if frontend: - setup_frontend(Path.cwd(), loglevel) - threading.Thread( - target=frontend_cmd, args=(Path.cwd(), frontend_port, loglevel) - ).start() + setup_frontend(Path.cwd()) + threading.Thread(target=frontend_cmd, args=(Path.cwd(), frontend_port)).start() if backend: threading.Thread( target=backend_cmd, - args=(app.__name__, backend_host, backend_port, loglevel), + args=(app.__name__, backend_host, backend_port), ).start() # Display custom message when there is a keyboard interrupt. @@ -217,8 +226,14 @@ def export( backend: bool = typer.Option( True, "--frontend-only", help="Export only frontend.", show_default=False ), + loglevel: constants.LogLevel = typer.Option( + constants.LogLevel.INFO, help="The log level to use." + ), ): """Export the app to a zip file.""" + # Set the log level. + console.set_log_level(loglevel) + # Check that the app is initialized. prerequisites.check_initialized(frontend=frontend) @@ -233,7 +248,7 @@ def export( # Export the app. config = get_config() - build.export_app( + build.export( backend=backend, frontend=frontend, zip=zipping, @@ -244,12 +259,12 @@ def export( telemetry.send("export", config.telemetry_enabled) if zipping: - console.rule( + console.log( """Backend & Frontend compiled. See [green bold]backend.zip[/green bold] and [green bold]frontend.zip[/green bold].""" ) else: - console.rule( + console.log( """Backend & Frontend compiled. See [green bold]app[/green bold] and [green bold].web/_static[/green bold] directories.""" ) @@ -261,16 +276,22 @@ db_cli = typer.Typer() @db_cli.command(name="init") def db_init(): """Create database schema and migration configuration.""" + # Check the database url. if get_config().db_url is None: - console.print("[red]db_url is not configured, cannot initialize.") + console.error("db_url is not configured, cannot initialize.") + return + + # Check the alembic config. if Path(constants.ALEMBIC_CONFIG).exists(): - console.print( - "[red]Database is already initialized. Use " + console.error( + "Database is already initialized. Use " "[bold]reflex db makemigrations[/bold] to create schema change " "scripts and [bold]reflex db migrate[/bold] to apply migrations " "to a new or existing database.", ) return + + # Initialize the database. prerequisites.get_app() model.Model.alembic_init() model.Model.migrate(autogenerate=True) @@ -302,8 +323,8 @@ def makemigrations( except CommandError as command_error: if "Target database is not up to date." not in str(command_error): raise - console.print( - f"[red]{command_error} Run [bold]reflex db migrate[/bold] to update database." + console.error( + f"{command_error} Run [bold]reflex db migrate[/bold] to update database." ) diff --git a/reflex/testing.py b/reflex/testing.py index fb832674e..4d51d6dc2 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -151,6 +151,7 @@ class AppHarness: reflex.reflex.init( name=self.app_name, template=reflex.constants.Template.DEFAULT, + loglevel=reflex.constants.LogLevel.INFO, ) self.app_module_path.write_text(source_code) with chdir(self.app_path): diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 8a167c246..ba9be5e59 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -9,12 +9,10 @@ import subprocess from pathlib import Path from typing import Optional, Union -from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn - from reflex import constants from reflex.config import get_config from reflex.utils import console, path_ops, prerequisites -from reflex.utils.processes import new_process +from reflex.utils.processes import new_process, show_progress def update_json_file(file_path: str, update_dict: dict[str, Union[int, str]]): @@ -39,7 +37,9 @@ def update_json_file(file_path: str, update_dict: dict[str, Union[int, str]]): def set_reflex_project_hash(): """Write the hash of the Reflex project to a REFLEX_JSON.""" - update_json_file(constants.REFLEX_JSON, {"project_hash": random.getrandbits(128)}) + project_hash = random.getrandbits(128) + console.debug(f"Setting project hash to {project_hash}.") + update_json_file(constants.REFLEX_JSON, {"project_hash": project_hash}) def set_environment_variables(): @@ -86,21 +86,19 @@ def generate_sitemap_config(deploy_url: str): f.write(templates.SITEMAP_CONFIG(config=config)) -def export_app( +def export( backend: bool = True, frontend: bool = True, zip: bool = False, deploy_url: Optional[str] = None, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, ): - """Zip up the app for deployment. + """Export the app for deployment. Args: backend: Whether to zip up the backend app. frontend: Whether to zip up the frontend app. zip: Whether to zip the app. deploy_url: The URL of the deployed app. - loglevel: The log level to use. """ # Remove the static folder. path_ops.rm(constants.WEB_STATIC_DIR) @@ -111,13 +109,6 @@ def export_app( generate_sitemap_config(deploy_url) command = "export-sitemap" - # Create a progress object - progress = Progress( - *Progress.get_default_columns()[:-1], - MofNCompleteColumn(), - TimeElapsedColumn(), - ) - checkpoints = [ "Linting and checking ", "Compiled successfully", @@ -130,36 +121,12 @@ def export_app( "Export successful", ] - # Add a single task to the progress object - task = progress.add_task("Creating Production Build: ", total=len(checkpoints)) - # Start the subprocess with the progress bar. - try: - with progress, new_process( - [prerequisites.get_package_manager(), "run", command], - cwd=constants.WEB_DIR, - ) as export_process: - assert export_process.stdout is not None, "No stdout for export process." - for line in export_process.stdout: - if loglevel == constants.LogLevel.DEBUG: - print(line, end="") - - # Check for special strings and update the progress bar. - for special_string in checkpoints: - if special_string in line: - if special_string == checkpoints[-1]: - progress.update(task, completed=len(checkpoints)) - break # Exit the loop if the completion message is found - else: - progress.update(task, advance=1) - break - - except Exception as e: - console.print(f"[red]Export process errored: {e}") - console.print( - "[red]Run in with [bold]--loglevel debug[/bold] to see the full error." - ) - os._exit(1) + process = new_process( + [prerequisites.get_package_manager(), "run", command], + cwd=constants.WEB_DIR, + ) + show_progress("Creating Production Build", process, checkpoints) # Zip up the app. if zip: @@ -203,14 +170,12 @@ def posix_export(backend: bool = True, frontend: bool = True): def setup_frontend( root: Path, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, disable_telemetry: bool = True, ): """Set up the frontend to run the app. Args: root: The root path of the project. - loglevel: The log level to use. disable_telemetry: Whether to disable the Next telemetry. """ # Install frontend packages. @@ -242,15 +207,13 @@ def setup_frontend( def setup_frontend_prod( root: Path, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, disable_telemetry: bool = True, ): """Set up the frontend for prod mode. Args: root: The root path of the project. - loglevel: The log level to use. disable_telemetry: Whether to disable the Next telemetry. """ - setup_frontend(root, loglevel, disable_telemetry) - export_app(loglevel=loglevel, deploy_url=get_config().deploy_url) + setup_frontend(root, disable_telemetry) + export(deploy_url=get_config().deploy_url) diff --git a/reflex/utils/console.py b/reflex/utils/console.py index b90d322bc..91de5d750 100644 --- a/reflex/utils/console.py +++ b/reflex/utils/console.py @@ -5,56 +5,123 @@ from __future__ import annotations from typing import List, Optional from rich.console import Console +from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from rich.prompt import Prompt -from rich.status import Status + +from reflex.constants import LogLevel # Console for pretty printing. _console = Console() +# The current log level. +LOG_LEVEL = LogLevel.INFO -def deprecate(msg: str) -> None: - """Print a deprecation warning. + +def set_log_level(log_level: LogLevel): + """Set the log level. Args: - msg: The deprecation message. + log_level: The log level to set. """ - _console.print(f"[yellow]DeprecationWarning: {msg}[/yellow]") + global LOG_LEVEL + LOG_LEVEL = log_level -def warn(msg: str) -> None: - """Print a warning about bad usage in Reflex. +def print(msg: str, **kwargs): + """Print a message. Args: - msg: The warning message. + msg: The message to print. + kwargs: Keyword arguments to pass to the print function. """ - _console.print(f"[orange1]UsageWarning: {msg}[/orange1]") + _console.print(msg, **kwargs) -def log(msg: str) -> None: +def debug(msg: str, **kwargs): + """Print a debug message. + + Args: + msg: The debug message. + kwargs: Keyword arguments to pass to the print function. + """ + if LOG_LEVEL <= LogLevel.DEBUG: + print(f"[blue]Debug: {msg}[/blue]", **kwargs) + + +def info(msg: str, **kwargs): + """Print an info message. + + Args: + msg: The info message. + kwargs: Keyword arguments to pass to the print function. + """ + if LOG_LEVEL <= LogLevel.INFO: + print(f"[cyan]Info: {msg}[/cyan]", **kwargs) + + +def success(msg: str, **kwargs): + """Print a success message. + + Args: + msg: The success message. + kwargs: Keyword arguments to pass to the print function. + """ + if LOG_LEVEL <= LogLevel.INFO: + print(f"[green]Success: {msg}[/green]", **kwargs) + + +def log(msg: str, **kwargs): """Takes a string and logs it to the console. Args: msg: The message to log. + kwargs: Keyword arguments to pass to the print function. """ - _console.log(msg) + if LOG_LEVEL <= LogLevel.INFO: + _console.log(msg, **kwargs) -def print(msg: str) -> None: - """Prints the given message to the console. - - Args: - msg: The message to print to the console. - """ - _console.print(msg) - - -def rule(title: str) -> None: +def rule(title: str, **kwargs): """Prints a horizontal rule with a title. Args: title: The title of the rule. + kwargs: Keyword arguments to pass to the print function. """ - _console.rule(title) + _console.rule(title, **kwargs) + + +def warn(msg: str, **kwargs): + """Print a warning message. + + Args: + msg: The warning message. + kwargs: Keyword arguments to pass to the print function. + """ + if LOG_LEVEL <= LogLevel.WARNING: + print(f"[orange1]Warning: {msg}[/orange1]", **kwargs) + + +def deprecate(msg: str, **kwargs): + """Print a deprecation warning. + + Args: + msg: The deprecation message. + kwargs: Keyword arguments to pass to the print function. + """ + if LOG_LEVEL <= LogLevel.WARNING: + print(f"[yellow]DeprecationWarning: {msg}[/yellow]", **kwargs) + + +def error(msg: str, **kwargs): + """Print an error message. + + Args: + msg: The error message. + kwargs: Keyword arguments to pass to the print function. + """ + if LOG_LEVEL <= LogLevel.ERROR: + print(f"[red]Error: {msg}[/red]", **kwargs) def ask( @@ -69,19 +136,33 @@ def ask( default: The default option selected. Returns: - A string + A string with the user input. """ return Prompt.ask(question, choices=choices, default=default) # type: ignore -def status(msg: str) -> Status: - """Returns a status, - which can be used as a context manager. +def progress(): + """Create a new progress bar. - Args: - msg: The message to be used as status title. Returns: - The status of the console. + A new progress bar. """ - return _console.status(msg) + return Progress( + *Progress.get_default_columns()[:-1], + MofNCompleteColumn(), + TimeElapsedColumn(), + ) + + +def status(*args, **kwargs): + """Create a status with a spinner. + + Args: + *args: Args to pass to the status. + **kwargs: Kwargs to pass to the status. + + Returns: + A new status. + """ + return _console.status(*args, **kwargs) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index cab15e0c2..a1ebdfc38 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -3,12 +3,8 @@ from __future__ import annotations import os -import platform -import subprocess from pathlib import Path -from rich import print - from reflex import constants from reflex.config import get_config from reflex.utils import console, prerequisites, processes @@ -28,13 +24,11 @@ def start_watching_assets_folder(root): def run_process_and_launch_url( run_command: list[str], - loglevel: constants.LogLevel = constants.LogLevel.ERROR, ): """Run the process and launch the URL. Args: run_command: The command to run. - loglevel: The log level to use. """ process = new_process( run_command, @@ -45,22 +39,20 @@ def run_process_and_launch_url( for line in process.stdout: if "ready started server on" in line: url = line.split("url: ")[-1].strip() - print(f"App running at: [bold green]{url}") - if loglevel == constants.LogLevel.DEBUG: - print(line, end="") + console.print(f"App running at: [bold green]{url}") + else: + console.debug(line) def run_frontend( root: Path, port: str, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, ): """Run the frontend. Args: root: The root path of the project. port: The port to run the frontend on. - loglevel: The log level to use. """ # Start watching asset folder. start_watching_assets_folder(root) @@ -68,29 +60,25 @@ def run_frontend( # Run the frontend in development mode. console.rule("[bold green]App Running") os.environ["PORT"] = get_config().frontend_port if port is None else port - run_process_and_launch_url( - [prerequisites.get_package_manager(), "run", "dev"], loglevel - ) + run_process_and_launch_url([prerequisites.get_package_manager(), "run", "dev"]) def run_frontend_prod( root: Path, port: str, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, ): """Run the frontend. Args: root: The root path of the project (to keep same API as run_frontend). port: The port to run the frontend on. - loglevel: The log level to use. """ # Set the port. os.environ["PORT"] = get_config().frontend_port if port is None else port # Run the frontend in production mode. console.rule("[bold green]App Running") - run_process_and_launch_url([constants.NPM_PATH, "run", "prod"], loglevel) + run_process_and_launch_url([constants.NPM_PATH, "run", "prod"]) def run_backend( @@ -107,20 +95,23 @@ def run_backend( port: The app port loglevel: The log level. """ - cmd = [ - "uvicorn", - f"{app_name}:{constants.APP_VAR}.{constants.API_VAR}", - "--host", - host, - "--port", - str(port), - "--log-level", - loglevel, - "--reload", - "--reload-dir", - app_name.split(".")[0], - ] - subprocess.run(cmd) + new_process( + [ + "uvicorn", + f"{app_name}:{constants.APP_VAR}.{constants.API_VAR}", + "--host", + host, + "--port", + str(port), + "--log-level", + loglevel, + "--reload", + "--reload-dir", + app_name.split(".")[0], + ], + run=True, + show_logs=True, + ) def run_backend_prod( @@ -147,7 +138,7 @@ def run_backend_prod( str(port), f"{app_name}:{constants.APP_VAR}", ] - if platform.system() == "Windows" + if prerequisites.IS_WINDOWS else [ *constants.RUN_BACKEND_PROD, "--bind", @@ -164,4 +155,4 @@ def run_backend_prod( "--workers", str(num_workers), ] - subprocess.run(command) + new_process(command, run=True, show_logs=True) diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 1a436148d..8d493c3ac 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -7,11 +7,9 @@ import json import os import platform import re -import subprocess import sys import tempfile import threading -from datetime import datetime from fileinput import FileInput from pathlib import Path from types import ModuleType @@ -26,34 +24,32 @@ from redis import Redis from reflex import constants, model from reflex.config import get_config from reflex.utils import console, path_ops +from reflex.utils.processes import new_process, show_logs, show_status IS_WINDOWS = platform.system() == "Windows" -def check_node_version(): +def check_node_version() -> bool: """Check the version of Node.js. Returns: Whether the version of Node.js is valid. """ try: - # Run the node -v command and capture the output - result = subprocess.run( - [constants.NODE_PATH, "-v"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - # The output will be in the form "vX.Y.Z", but version.parse() can handle it - current_version = version.parse(result.stdout.decode()) - # Compare the version numbers - return ( - current_version >= version.parse(constants.NODE_VERSION_MIN) - if IS_WINDOWS - else current_version == version.parse(constants.NODE_VERSION) - ) - except Exception: + # Run the node -v command and capture the output. + result = new_process([constants.NODE_PATH, "-v"], run=True) + except FileNotFoundError: return False + # The output will be in the form "vX.Y.Z", but version.parse() can handle it + current_version = version.parse(result.stdout) # type: ignore + # Compare the version numbers + return ( + current_version >= version.parse(constants.NODE_VERSION_MIN) + if IS_WINDOWS + else current_version == version.parse(constants.NODE_VERSION) + ) + def get_bun_version() -> Optional[version.Version]: """Get the version of bun. @@ -63,13 +59,9 @@ def get_bun_version() -> Optional[version.Version]: """ try: # Run the bun -v command and capture the output - result = subprocess.run( - [constants.BUN_PATH, "-v"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return version.parse(result.stdout.decode().strip()) - except Exception: + result = new_process([constants.BUN_PATH, "-v"], run=True) + return version.parse(result.stdout) # type: ignore + except FileNotFoundError: return None @@ -98,7 +90,7 @@ def get_install_package_manager() -> str: get_config() # On Windows, we use npm instead of bun. - if platform.system() == "Windows": + if IS_WINDOWS: return get_windows_package_manager() # On other platforms, we use bun. @@ -114,7 +106,7 @@ def get_package_manager() -> str: """ get_config() - if platform.system() == "Windows": + if IS_WINDOWS: return get_windows_package_manager() return constants.NPM_PATH @@ -141,7 +133,7 @@ def get_redis() -> Optional[Redis]: if config.redis_url is None: return None redis_url, redis_port = config.redis_url.split(":") - print("Using redis at", config.redis_url) + console.info(f"Using redis at {config.redis_url}") return Redis(host=redis_url, port=int(redis_port), db=0) @@ -173,8 +165,8 @@ def get_default_app_name() -> str: # Make sure the app is not named "reflex". if app_name == constants.MODULE_NAME: - console.print( - f"[red]The app directory cannot be named [bold]{constants.MODULE_NAME}." + console.error( + f"The app directory cannot be named [bold]{constants.MODULE_NAME}[/bold]." ) raise typer.Exit() @@ -192,6 +184,7 @@ def create_config(app_name: str): config_name = f"{re.sub(r'[^a-zA-Z]', '', app_name).capitalize()}Config" with open(constants.CONFIG_FILE, "w") as f: + console.debug(f"Creating {constants.CONFIG_FILE}") f.write(templates.RXCONFIG.render(app_name=app_name, config_name=config_name)) @@ -204,8 +197,10 @@ def initialize_gitignore(): if os.path.exists(constants.GITIGNORE_FILE): with open(constants.GITIGNORE_FILE, "r") as f: files |= set([line.strip() for line in f.readlines()]) + # Write files to the .gitignore file. with open(constants.GITIGNORE_FILE, "w") as f: + console.debug(f"Creating {constants.GITIGNORE_FILE}") f.write(f"{(path_ops.join(sorted(files))).lstrip()}") @@ -256,16 +251,22 @@ def initialize_bun(): """Check that bun requirements are met, and install if not.""" if IS_WINDOWS: # Bun is not supported on Windows. + console.debug("Skipping bun installation on Windows.") return # Check the bun version. - if get_bun_version() != version.parse(constants.BUN_VERSION): + bun_version = get_bun_version() + if bun_version != version.parse(constants.BUN_VERSION): + console.debug( + f"Current bun version ({bun_version}) does not match ({constants.BUN_VERSION})." + ) remove_existing_bun_installation() install_bun() def remove_existing_bun_installation(): """Remove existing bun installation.""" + console.debug("Removing existing bun installation.") if os.path.exists(constants.BUN_PATH): path_ops.rm(constants.BUN_ROOT_PATH) @@ -279,17 +280,13 @@ def initialize_node(): def download_and_run(url: str, *args, **env): """Download and run a script. - Args: url: The url of the script. args: The arguments to pass to the script. env: The environment variables to use. - - - Raises: - Exit: if installation failed """ # Download the script + console.debug(f"Downloading {url}") response = httpx.get(url) if response.status_code != httpx.codes.OK: response.raise_for_status() @@ -300,13 +297,9 @@ def download_and_run(url: str, *args, **env): f.write(response.text) # Run the script. - env = { - **os.environ, - **env, - } - result = subprocess.run(["bash", f.name, *args], env=env) - if result.returncode != 0: - raise typer.Exit(code=result.returncode) + env = {**os.environ, **env} + process = new_process(["bash", f.name, *args], env=env) + show_logs(f"Installing {url}", process) def install_node(): @@ -318,8 +311,8 @@ def install_node(): """ # NVM is not supported on Windows. if IS_WINDOWS: - console.print( - f"[red]Node.js version {constants.NODE_VERSION} or higher is required to run Reflex." + console.error( + f"Node.js version {constants.NODE_VERSION} or higher is required to run Reflex." ) raise typer.Exit() @@ -330,7 +323,7 @@ def install_node(): # Install node. # We use bash -c as we need to source nvm.sh to use nvm. - result = subprocess.run( + process = new_process( [ "bash", "-c", @@ -338,8 +331,7 @@ def install_node(): ], env=env, ) - if result.returncode != 0: - raise typer.Exit(code=result.returncode) + show_logs("Installing node", process) def install_bun(): @@ -350,13 +342,15 @@ def install_bun(): """ # Bun is not supported on Windows. if IS_WINDOWS: + console.debug("Skipping bun installation on Windows.") return # Skip if bun is already installed. if os.path.exists(constants.BUN_PATH): + console.debug("Skipping bun installation as it is already installed.") return - # Check if unzip is installed + # if unzip is installed unzip_path = path_ops.which("unzip") if unzip_path is None: raise FileNotFoundError("Reflex requires unzip to be installed.") @@ -371,24 +365,21 @@ def install_bun(): def install_frontend_packages(): """Installs the base and custom frontend packages.""" - # Install the frontend packages. - console.rule("[bold]Installing frontend packages") - # Install the base packages. - subprocess.run( - [get_install_package_manager(), "install"], + process = new_process( + [get_install_package_manager(), "install", "--loglevel", "silly"], cwd=constants.WEB_DIR, - stdout=subprocess.PIPE, ) + show_status("Installing base frontend packages", process) # Install the app packages. packages = get_config().frontend_packages if len(packages) > 0: - subprocess.run( + process = new_process( [get_install_package_manager(), "add", *packages], cwd=constants.WEB_DIR, - stdout=subprocess.PIPE, ) + show_status("Installing custom frontend packages", process) def check_initialized(frontend: bool = True): @@ -406,22 +397,22 @@ def check_initialized(frontend: bool = True): # Check if the app is initialized. if not (has_config and has_reflex_dir and has_web_dir): - console.print( - f"[red]The app is not initialized. Run [bold]{constants.MODULE_NAME} init[/bold] first." + console.error( + f"The app is not initialized. Run [bold]{constants.MODULE_NAME} init[/bold] first." ) raise typer.Exit() # Check that the template is up to date. if frontend and not is_latest_template(): - console.print( - "[red]The base app template has updated. Run [bold]reflex init[/bold] again." + console.error( + "The base app template has updated. Run [bold]reflex init[/bold] again." ) raise typer.Exit() # Print a warning for Windows users. if IS_WINDOWS: - console.print( - "[yellow][WARNING] We strongly advise using Windows Subsystem for Linux (WSL) for optimal performance with reflex." + console.warn( + "We strongly advise using Windows Subsystem for Linux (WSL) for optimal performance with reflex." ) @@ -462,17 +453,16 @@ def initialize_frontend_dependencies(): def check_admin_settings(): """Check if admin settings are set and valid for logging in cli app.""" admin_dash = get_config().admin_dash - current_time = datetime.now() if admin_dash: if not admin_dash.models: - console.print( - f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin dashboard enabled, but no models defined in [bold magenta]rxconfig.py[/bold magenta]. Time: {current_time}" + console.log( + f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin dashboard enabled, but no models defined in [bold magenta]rxconfig.py[/bold magenta]." ) else: - console.print( - f"[yellow][Admin Dashboard][/yellow] Admin enabled, building admin dashboard. Time: {current_time}" + console.log( + f"[yellow][Admin Dashboard][/yellow] Admin enabled, building admin dashboard." ) - console.print( + console.log( "Admin dashboard running at: [bold green]http://localhost:8000/admin[/bold green]" ) @@ -484,8 +474,8 @@ def check_db_initialized() -> bool: True if alembic is initialized (or if database is not used). """ if get_config().db_url is not None and not Path(constants.ALEMBIC_CONFIG).exists(): - console.print( - "[red]Database is not initialized. Run [bold]reflex db init[/bold] first." + console.error( + "Database is not initialized. Run [bold]reflex db init[/bold] first." ) return False return True @@ -501,14 +491,14 @@ def check_schema_up_to_date(): connection=connection, write_migration_scripts=False, ): - console.print( - "[red]Detected database schema changes. Run [bold]reflex db makemigrations[/bold] " + console.error( + "Detected database schema changes. Run [bold]reflex db makemigrations[/bold] " "to generate migration scripts.", ) except CommandError as command_error: if "Target database is not up to date." in str(command_error): - console.print( - f"[red]{command_error} Run [bold]reflex db migrate[/bold] to update database." + console.error( + f"{command_error} Run [bold]reflex db migrate[/bold] to update database." ) @@ -527,7 +517,7 @@ def migrate_to_reflex(): return # Rename pcconfig to rxconfig. - console.print( + console.log( f"[bold]Renaming {constants.OLD_CONFIG_FILE} to {constants.CONFIG_FILE}" ) os.rename(constants.OLD_CONFIG_FILE, constants.CONFIG_FILE) diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index 75fe5afa5..305f7458d 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -7,8 +7,7 @@ import os import signal import subprocess import sys -from datetime import datetime -from typing import Optional +from typing import List, Optional from urllib.parse import urlparse import psutil @@ -101,7 +100,7 @@ def change_or_terminate_port(port, _type) -> str: Returns: The new port or the current one. """ - console.print( + console.info( f"Something is already running on port [bold underline]{port}[/bold underline]. This is the port the {_type} runs on." ) frontend_action = console.ask("Kill or change it?", choices=["k", "c", "n"]) @@ -115,41 +114,119 @@ def change_or_terminate_port(port, _type) -> str: if is_process_on_port(new_port): return change_or_terminate_port(new_port, _type) else: - console.print( + console.info( f"The {_type} will run on port [bold underline]{new_port}[/bold underline]." ) return new_port else: - console.print("Exiting...") + console.log("Exiting...") sys.exit() -def new_process(args, **kwargs): +def new_process(args, run: bool = False, show_logs: bool = False, **kwargs): """Wrapper over subprocess.Popen to unify the launch of child processes. Args: args: A string, or a sequence of program arguments. + run: Whether to run the process to completion. + show_logs: Whether to show the logs of the process. **kwargs: Kwargs to override default wrap values to pass to subprocess.Popen as arguments. Returns: Execute a child program in a new process. """ + # Add the node bin path to the PATH environment variable. env = { **os.environ, "PATH": os.pathsep.join([constants.NODE_BIN_PATH, os.environ["PATH"]]), } kwargs = { "env": env, - "stderr": subprocess.STDOUT, - "stdout": subprocess.PIPE, + "stderr": None if show_logs else subprocess.STDOUT, + "stdout": None if show_logs else subprocess.PIPE, "universal_newlines": True, "encoding": "UTF-8", **kwargs, } - return subprocess.Popen( - args, - **kwargs, - ) + console.debug(f"Running command: {args}") + fn = subprocess.run if run else subprocess.Popen + return fn(args, **kwargs) + + +def stream_logs( + message: str, + process: subprocess.Popen, +): + """Stream the logs for a process. + + Args: + message: The message to display. + process: The process. + + Yields: + The lines of the process output. + """ + with process: + console.debug(message) + if process.stdout is None: + return + for line in process.stdout: + console.debug(line, end="") + yield line + + if process.returncode != 0: + console.error(f"Error during {message}") + console.error( + "Run in with [bold]--loglevel debug[/bold] to see the full error." + ) + os._exit(1) + + +def show_logs( + message: str, + process: subprocess.Popen, +): + """Show the logs for a process. + + Args: + message: The message to display. + process: The process. + """ + for _ in stream_logs(message, process): + pass + + +def show_status(message: str, process: subprocess.Popen): + """Show the status of a process. + + Args: + message: The initial message to display. + process: The process. + """ + with console.status(message) as status: + for line in stream_logs(message, process): + status.update(f"{message}: {line}") + + +def show_progress(message: str, process: subprocess.Popen, checkpoints: List[str]): + """Show a progress bar for a process. + + Args: + message: The message to display. + process: The process. + checkpoints: The checkpoints to advance the progress bar. + """ + # Iterate over the process output. + with console.progress() as progress: + task = progress.add_task(f"{message}: ", total=len(checkpoints)) + for line in stream_logs(message, process): + # Check for special strings and update the progress bar. + for special_string in checkpoints: + if special_string in line: + progress.update(task, advance=1) + if special_string == checkpoints[-1]: + progress.update(task, completed=len(checkpoints)) + break def catch_keyboard_interrupt(signal, frame): @@ -159,5 +236,4 @@ def catch_keyboard_interrupt(signal, frame): signal: The keyboard interrupt signal. frame: The current stack frame. """ - current_time = datetime.now().isoformat() - console.print(f"\nReflex app stopped at time: {current_time}") + console.log("Reflex app stopped.") diff --git a/tests/test_utils.py b/tests/test_utils.py index aa54eaa35..87b49c9cc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,4 @@ import os -import subprocess import typing from pathlib import Path from typing import Any, List, Union @@ -529,10 +528,6 @@ def test_node_install_unix(tmp_path, mocker): nvm_root_path = tmp_path / ".reflex" / ".nvm" mocker.patch("reflex.utils.prerequisites.constants.NVM_DIR", nvm_root_path) - subprocess_run = mocker.patch( - "reflex.utils.prerequisites.subprocess.run", - return_value=subprocess.CompletedProcess(args="", returncode=0), - ) mocker.patch("reflex.utils.prerequisites.IS_WINDOWS", False) class Resp(Base): @@ -540,13 +535,15 @@ def test_node_install_unix(tmp_path, mocker): text = "test" mocker.patch("httpx.get", return_value=Resp()) - mocker.patch("reflex.utils.prerequisites.download_and_run") + download = mocker.patch("reflex.utils.prerequisites.download_and_run") + mocker.patch("reflex.utils.prerequisites.new_process") + mocker.patch("reflex.utils.prerequisites.show_logs") prerequisites.install_node() assert nvm_root_path.exists() - subprocess_run.assert_called() - subprocess_run.call_count = 2 + download.assert_called() + download.call_count = 2 def test_bun_install_without_unzip(mocker):