Browse Source

Add unified logging (#1462)

Nikhil Rao 1 year ago
parent
commit
068bcd906e

+ 13 - 0
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):

+ 2 - 2
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:

+ 45 - 24
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."
             )
 
 

+ 1 - 0
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):

+ 13 - 50
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)

+ 107 - 26
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:
-    """Takes a string and logs it to the console.
+def debug(msg: str, **kwargs):
+    """Print a debug message.
 
     Args:
-        msg: The message to log.
+        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.
     """
-    _console.log(msg)
+    if LOG_LEVEL <= LogLevel.INFO:
+        print(f"[cyan]Info: {msg}[/cyan]", **kwargs)
 
 
-def print(msg: str) -> None:
-    """Prints the given message to the console.
+def success(msg: str, **kwargs):
+    """Print a success message.
 
     Args:
-        msg: The message to print to the console.
+        msg: The success message.
+        kwargs: Keyword arguments to pass to the print function.
     """
-    _console.print(msg)
+    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.
 
-def rule(title: str) -> None:
+    Args:
+        msg: The message to log.
+        kwargs: Keyword arguments to pass to the print function.
+    """
+    if LOG_LEVEL <= LogLevel.INFO:
+        _console.log(msg, **kwargs)
+
+
+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.
+
+
+    Returns:
+        A new progress bar.
+    """
+    return Progress(
+        *Progress.get_default_columns()[:-1],
+        MofNCompleteColumn(),
+        TimeElapsedColumn(),
+    )
+
+
+def status(*args, **kwargs):
+    """Create a status with a spinner.
 
     Args:
-        msg: The message to be used as status title.
+        *args: Args to pass to the status.
+        **kwargs: Kwargs to pass to the status.
 
     Returns:
-        The status of the console.
+        A new status.
     """
-    return _console.status(msg)
+    return _console.status(*args, **kwargs)

+ 24 - 33
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)

+ 66 - 76
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)

+ 90 - 14
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.")

+ 5 - 8
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):