Add unified logging (#1462)

This commit is contained in:
Nikhil Rao 2023-07-30 19:58:48 -07:00 committed by GitHub
parent e1cb09e9d4
commit 068bcd906e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 370 additions and 237 deletions

View File

@ -1,4 +1,5 @@
"""Constants used throughout the package.""" """Constants used throughout the package."""
from __future__ import annotations
import os import os
import platform import platform
@ -250,6 +251,18 @@ class LogLevel(str, Enum):
ERROR = "error" ERROR = "error"
CRITICAL = "critical" 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 # Templates
class Template(str, Enum): class Template(str, Enum):

View File

@ -37,8 +37,8 @@ def get_engine(url: Optional[str] = None):
if url is None: if url is None:
raise ValueError("No database url configured") raise ValueError("No database url configured")
if not Path(constants.ALEMBIC_CONFIG).exists(): if not Path(constants.ALEMBIC_CONFIG).exists():
console.print( console.warn(
"[red]Database is not initialized, run [bold]reflex db init[/bold] first." "Database is not initialized, run [bold]reflex db init[/bold] first."
) )
echo_db_query = False echo_db_query = False
if conf.env == constants.Env.DEV and constants.SQLALCHEMY_ECHO: if conf.env == constants.Env.DEV and constants.SQLALCHEMY_ECHO:

View File

@ -14,7 +14,7 @@ from reflex.config import get_config
from reflex.utils import build, console, exec, prerequisites, processes, telemetry from reflex.utils import build, console, exec, prerequisites, processes, telemetry
# Create the app. # Create the app.
cli = typer.Typer() cli = typer.Typer(add_completion=False)
def version(value: bool): def version(value: bool):
@ -35,25 +35,33 @@ def version(value: bool):
def main( def main(
version: bool = typer.Option( version: bool = typer.Option(
None, None,
"--version",
"-v", "-v",
"--version",
callback=version, callback=version,
help="Get the Reflex version.", help="Get the Reflex version.",
is_eager=True, is_eager=True,
), ),
): ):
"""Reflex CLI global configuration.""" """Reflex CLI to create, run, and deploy apps."""
pass pass
@cli.command() @cli.command()
def init( 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( 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.""" """Initialize a new Reflex app in the current directory."""
# Set the log level.
console.set_log_level(loglevel)
# Get the app name. # Get the app name.
app_name = prerequisites.get_default_app_name() if name is None else name app_name = prerequisites.get_default_app_name() if name is None else name
console.rule(f"[bold]Initializing {app_name}") console.rule(f"[bold]Initializing {app_name}")
@ -78,7 +86,7 @@ def init(
prerequisites.initialize_gitignore() prerequisites.initialize_gitignore()
# Finish initializing the app. # Finish initializing the app.
console.log(f"[bold green]Finished Initializing: {app_name}") console.success(f"Finished Initializing: {app_name}")
@cli.command() @cli.command()
@ -90,16 +98,16 @@ def run(
False, "--frontend-only", help="Execute only frontend." False, "--frontend-only", help="Execute only frontend."
), ),
backend: bool = typer.Option(False, "--backend-only", help="Execute only backend."), 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."), 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_port: str = typer.Option(None, help="Specify a different backend port."),
backend_host: str = typer.Option(None, help="Specify the backend host."), 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.""" """Run the app in the current directory."""
# Check that the app is initialized. # Set the log level.
prerequisites.check_initialized(frontend=frontend) console.set_log_level(loglevel)
# Set ports as os env variables to take precedence over config and # 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). # .env variables(if override_os_envs flag in config is set to False).
@ -120,6 +128,9 @@ def run(
frontend = True frontend = True
backend = 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 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): if frontend and processes.is_process_on_port(frontend_port):
frontend_port = processes.change_or_terminate_port(frontend_port, "frontend") frontend_port = processes.change_or_terminate_port(frontend_port, "frontend")
@ -158,14 +169,12 @@ def run(
# Run the frontend and backend. # Run the frontend and backend.
if frontend: if frontend:
setup_frontend(Path.cwd(), loglevel) setup_frontend(Path.cwd())
threading.Thread( threading.Thread(target=frontend_cmd, args=(Path.cwd(), frontend_port)).start()
target=frontend_cmd, args=(Path.cwd(), frontend_port, loglevel)
).start()
if backend: if backend:
threading.Thread( threading.Thread(
target=backend_cmd, target=backend_cmd,
args=(app.__name__, backend_host, backend_port, loglevel), args=(app.__name__, backend_host, backend_port),
).start() ).start()
# Display custom message when there is a keyboard interrupt. # Display custom message when there is a keyboard interrupt.
@ -217,8 +226,14 @@ def export(
backend: bool = typer.Option( backend: bool = typer.Option(
True, "--frontend-only", help="Export only frontend.", show_default=False 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.""" """Export the app to a zip file."""
# Set the log level.
console.set_log_level(loglevel)
# Check that the app is initialized. # Check that the app is initialized.
prerequisites.check_initialized(frontend=frontend) prerequisites.check_initialized(frontend=frontend)
@ -233,7 +248,7 @@ def export(
# Export the app. # Export the app.
config = get_config() config = get_config()
build.export_app( build.export(
backend=backend, backend=backend,
frontend=frontend, frontend=frontend,
zip=zipping, zip=zipping,
@ -244,12 +259,12 @@ def export(
telemetry.send("export", config.telemetry_enabled) telemetry.send("export", config.telemetry_enabled)
if zipping: if zipping:
console.rule( console.log(
"""Backend & Frontend compiled. See [green bold]backend.zip[/green bold] """Backend & Frontend compiled. See [green bold]backend.zip[/green bold]
and [green bold]frontend.zip[/green bold].""" and [green bold]frontend.zip[/green bold]."""
) )
else: else:
console.rule( console.log(
"""Backend & Frontend compiled. See [green bold]app[/green bold] """Backend & Frontend compiled. See [green bold]app[/green bold]
and [green bold].web/_static[/green bold] directories.""" and [green bold].web/_static[/green bold] directories."""
) )
@ -261,16 +276,22 @@ db_cli = typer.Typer()
@db_cli.command(name="init") @db_cli.command(name="init")
def db_init(): def db_init():
"""Create database schema and migration configuration.""" """Create database schema and migration configuration."""
# Check the database url.
if get_config().db_url is None: 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(): if Path(constants.ALEMBIC_CONFIG).exists():
console.print( console.error(
"[red]Database is already initialized. Use " "Database is already initialized. Use "
"[bold]reflex db makemigrations[/bold] to create schema change " "[bold]reflex db makemigrations[/bold] to create schema change "
"scripts and [bold]reflex db migrate[/bold] to apply migrations " "scripts and [bold]reflex db migrate[/bold] to apply migrations "
"to a new or existing database.", "to a new or existing database.",
) )
return return
# Initialize the database.
prerequisites.get_app() prerequisites.get_app()
model.Model.alembic_init() model.Model.alembic_init()
model.Model.migrate(autogenerate=True) model.Model.migrate(autogenerate=True)
@ -302,8 +323,8 @@ def makemigrations(
except CommandError as command_error: except CommandError as command_error:
if "Target database is not up to date." not in str(command_error): if "Target database is not up to date." not in str(command_error):
raise raise
console.print( console.error(
f"[red]{command_error} Run [bold]reflex db migrate[/bold] to update database." f"{command_error} Run [bold]reflex db migrate[/bold] to update database."
) )

View File

@ -151,6 +151,7 @@ class AppHarness:
reflex.reflex.init( reflex.reflex.init(
name=self.app_name, name=self.app_name,
template=reflex.constants.Template.DEFAULT, template=reflex.constants.Template.DEFAULT,
loglevel=reflex.constants.LogLevel.INFO,
) )
self.app_module_path.write_text(source_code) self.app_module_path.write_text(source_code)
with chdir(self.app_path): with chdir(self.app_path):

View File

@ -9,12 +9,10 @@ import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn
from reflex import constants from reflex import constants
from reflex.config import get_config from reflex.config import get_config
from reflex.utils import console, path_ops, prerequisites 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]]): 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(): def set_reflex_project_hash():
"""Write the hash of the Reflex project to a REFLEX_JSON.""" """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(): def set_environment_variables():
@ -86,21 +86,19 @@ def generate_sitemap_config(deploy_url: str):
f.write(templates.SITEMAP_CONFIG(config=config)) f.write(templates.SITEMAP_CONFIG(config=config))
def export_app( def export(
backend: bool = True, backend: bool = True,
frontend: bool = True, frontend: bool = True,
zip: bool = False, zip: bool = False,
deploy_url: Optional[str] = None, deploy_url: Optional[str] = None,
loglevel: constants.LogLevel = constants.LogLevel.ERROR,
): ):
"""Zip up the app for deployment. """Export the app for deployment.
Args: Args:
backend: Whether to zip up the backend app. backend: Whether to zip up the backend app.
frontend: Whether to zip up the frontend app. frontend: Whether to zip up the frontend app.
zip: Whether to zip the app. zip: Whether to zip the app.
deploy_url: The URL of the deployed app. deploy_url: The URL of the deployed app.
loglevel: The log level to use.
""" """
# Remove the static folder. # Remove the static folder.
path_ops.rm(constants.WEB_STATIC_DIR) path_ops.rm(constants.WEB_STATIC_DIR)
@ -111,13 +109,6 @@ def export_app(
generate_sitemap_config(deploy_url) generate_sitemap_config(deploy_url)
command = "export-sitemap" command = "export-sitemap"
# Create a progress object
progress = Progress(
*Progress.get_default_columns()[:-1],
MofNCompleteColumn(),
TimeElapsedColumn(),
)
checkpoints = [ checkpoints = [
"Linting and checking ", "Linting and checking ",
"Compiled successfully", "Compiled successfully",
@ -130,36 +121,12 @@ def export_app(
"Export successful", "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. # Start the subprocess with the progress bar.
try: process = new_process(
with progress, new_process( [prerequisites.get_package_manager(), "run", command],
[prerequisites.get_package_manager(), "run", command], cwd=constants.WEB_DIR,
cwd=constants.WEB_DIR, )
) as export_process: show_progress("Creating Production Build", process, checkpoints)
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)
# Zip up the app. # Zip up the app.
if zip: if zip:
@ -203,14 +170,12 @@ def posix_export(backend: bool = True, frontend: bool = True):
def setup_frontend( def setup_frontend(
root: Path, root: Path,
loglevel: constants.LogLevel = constants.LogLevel.ERROR,
disable_telemetry: bool = True, disable_telemetry: bool = True,
): ):
"""Set up the frontend to run the app. """Set up the frontend to run the app.
Args: Args:
root: The root path of the project. root: The root path of the project.
loglevel: The log level to use.
disable_telemetry: Whether to disable the Next telemetry. disable_telemetry: Whether to disable the Next telemetry.
""" """
# Install frontend packages. # Install frontend packages.
@ -242,15 +207,13 @@ def setup_frontend(
def setup_frontend_prod( def setup_frontend_prod(
root: Path, root: Path,
loglevel: constants.LogLevel = constants.LogLevel.ERROR,
disable_telemetry: bool = True, disable_telemetry: bool = True,
): ):
"""Set up the frontend for prod mode. """Set up the frontend for prod mode.
Args: Args:
root: The root path of the project. root: The root path of the project.
loglevel: The log level to use.
disable_telemetry: Whether to disable the Next telemetry. disable_telemetry: Whether to disable the Next telemetry.
""" """
setup_frontend(root, loglevel, disable_telemetry) setup_frontend(root, disable_telemetry)
export_app(loglevel=loglevel, deploy_url=get_config().deploy_url) export(deploy_url=get_config().deploy_url)

View File

@ -5,56 +5,123 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from rich.console import Console from rich.console import Console
from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.status import Status
from reflex.constants import LogLevel
# Console for pretty printing. # Console for pretty printing.
_console = Console() _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: 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: def print(msg: str, **kwargs):
"""Print a warning about bad usage in Reflex. """Print a message.
Args: 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. """Takes a string and logs it to the console.
Args: Args:
msg: The message to log. 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: def rule(title: str, **kwargs):
"""Prints the given message to the console.
Args:
msg: The message to print to the console.
"""
_console.print(msg)
def rule(title: str) -> None:
"""Prints a horizontal rule with a title. """Prints a horizontal rule with a title.
Args: Args:
title: The title of the rule. 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( def ask(
@ -69,19 +136,33 @@ def ask(
default: The default option selected. default: The default option selected.
Returns: Returns:
A string A string with the user input.
""" """
return Prompt.ask(question, choices=choices, default=default) # type: ignore return Prompt.ask(question, choices=choices, default=default) # type: ignore
def status(msg: str) -> Status: def progress():
"""Returns a status, """Create a new progress bar.
which can be used as a context manager.
Args:
msg: The message to be used as status title.
Returns: 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)

View File

@ -3,12 +3,8 @@
from __future__ import annotations from __future__ import annotations
import os import os
import platform
import subprocess
from pathlib import Path from pathlib import Path
from rich import print
from reflex import constants from reflex import constants
from reflex.config import get_config from reflex.config import get_config
from reflex.utils import console, prerequisites, processes from reflex.utils import console, prerequisites, processes
@ -28,13 +24,11 @@ def start_watching_assets_folder(root):
def run_process_and_launch_url( def run_process_and_launch_url(
run_command: list[str], run_command: list[str],
loglevel: constants.LogLevel = constants.LogLevel.ERROR,
): ):
"""Run the process and launch the URL. """Run the process and launch the URL.
Args: Args:
run_command: The command to run. run_command: The command to run.
loglevel: The log level to use.
""" """
process = new_process( process = new_process(
run_command, run_command,
@ -45,22 +39,20 @@ def run_process_and_launch_url(
for line in process.stdout: for line in process.stdout:
if "ready started server on" in line: if "ready started server on" in line:
url = line.split("url: ")[-1].strip() url = line.split("url: ")[-1].strip()
print(f"App running at: [bold green]{url}") console.print(f"App running at: [bold green]{url}")
if loglevel == constants.LogLevel.DEBUG: else:
print(line, end="") console.debug(line)
def run_frontend( def run_frontend(
root: Path, root: Path,
port: str, port: str,
loglevel: constants.LogLevel = constants.LogLevel.ERROR,
): ):
"""Run the frontend. """Run the frontend.
Args: Args:
root: The root path of the project. root: The root path of the project.
port: The port to run the frontend on. port: The port to run the frontend on.
loglevel: The log level to use.
""" """
# Start watching asset folder. # Start watching asset folder.
start_watching_assets_folder(root) start_watching_assets_folder(root)
@ -68,29 +60,25 @@ def run_frontend(
# Run the frontend in development mode. # Run the frontend in development mode.
console.rule("[bold green]App Running") console.rule("[bold green]App Running")
os.environ["PORT"] = get_config().frontend_port if port is None else port os.environ["PORT"] = get_config().frontend_port if port is None else port
run_process_and_launch_url( run_process_and_launch_url([prerequisites.get_package_manager(), "run", "dev"])
[prerequisites.get_package_manager(), "run", "dev"], loglevel
)
def run_frontend_prod( def run_frontend_prod(
root: Path, root: Path,
port: str, port: str,
loglevel: constants.LogLevel = constants.LogLevel.ERROR,
): ):
"""Run the frontend. """Run the frontend.
Args: Args:
root: The root path of the project (to keep same API as run_frontend). root: The root path of the project (to keep same API as run_frontend).
port: The port to run the frontend on. port: The port to run the frontend on.
loglevel: The log level to use.
""" """
# Set the port. # Set the port.
os.environ["PORT"] = get_config().frontend_port if port is None else port os.environ["PORT"] = get_config().frontend_port if port is None else port
# Run the frontend in production mode. # Run the frontend in production mode.
console.rule("[bold green]App Running") 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( def run_backend(
@ -107,20 +95,23 @@ def run_backend(
port: The app port port: The app port
loglevel: The log level. loglevel: The log level.
""" """
cmd = [ new_process(
"uvicorn", [
f"{app_name}:{constants.APP_VAR}.{constants.API_VAR}", "uvicorn",
"--host", f"{app_name}:{constants.APP_VAR}.{constants.API_VAR}",
host, "--host",
"--port", host,
str(port), "--port",
"--log-level", str(port),
loglevel, "--log-level",
"--reload", loglevel,
"--reload-dir", "--reload",
app_name.split(".")[0], "--reload-dir",
] app_name.split(".")[0],
subprocess.run(cmd) ],
run=True,
show_logs=True,
)
def run_backend_prod( def run_backend_prod(
@ -147,7 +138,7 @@ def run_backend_prod(
str(port), str(port),
f"{app_name}:{constants.APP_VAR}", f"{app_name}:{constants.APP_VAR}",
] ]
if platform.system() == "Windows" if prerequisites.IS_WINDOWS
else [ else [
*constants.RUN_BACKEND_PROD, *constants.RUN_BACKEND_PROD,
"--bind", "--bind",
@ -164,4 +155,4 @@ def run_backend_prod(
"--workers", "--workers",
str(num_workers), str(num_workers),
] ]
subprocess.run(command) new_process(command, run=True, show_logs=True)

View File

@ -7,11 +7,9 @@ import json
import os import os
import platform import platform
import re import re
import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
from datetime import datetime
from fileinput import FileInput from fileinput import FileInput
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
@ -26,34 +24,32 @@ from redis import Redis
from reflex import constants, model from reflex import constants, model
from reflex.config import get_config from reflex.config import get_config
from reflex.utils import console, path_ops from reflex.utils import console, path_ops
from reflex.utils.processes import new_process, show_logs, show_status
IS_WINDOWS = platform.system() == "Windows" IS_WINDOWS = platform.system() == "Windows"
def check_node_version(): def check_node_version() -> bool:
"""Check the version of Node.js. """Check the version of Node.js.
Returns: Returns:
Whether the version of Node.js is valid. Whether the version of Node.js is valid.
""" """
try: try:
# Run the node -v command and capture the output # Run the node -v command and capture the output.
result = subprocess.run( result = new_process([constants.NODE_PATH, "-v"], run=True)
[constants.NODE_PATH, "-v"], except FileNotFoundError:
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:
return False 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]: def get_bun_version() -> Optional[version.Version]:
"""Get the version of bun. """Get the version of bun.
@ -63,13 +59,9 @@ def get_bun_version() -> Optional[version.Version]:
""" """
try: try:
# Run the bun -v command and capture the output # Run the bun -v command and capture the output
result = subprocess.run( result = new_process([constants.BUN_PATH, "-v"], run=True)
[constants.BUN_PATH, "-v"], return version.parse(result.stdout) # type: ignore
stdout=subprocess.PIPE, except FileNotFoundError:
stderr=subprocess.PIPE,
)
return version.parse(result.stdout.decode().strip())
except Exception:
return None return None
@ -98,7 +90,7 @@ def get_install_package_manager() -> str:
get_config() get_config()
# On Windows, we use npm instead of bun. # On Windows, we use npm instead of bun.
if platform.system() == "Windows": if IS_WINDOWS:
return get_windows_package_manager() return get_windows_package_manager()
# On other platforms, we use bun. # On other platforms, we use bun.
@ -114,7 +106,7 @@ def get_package_manager() -> str:
""" """
get_config() get_config()
if platform.system() == "Windows": if IS_WINDOWS:
return get_windows_package_manager() return get_windows_package_manager()
return constants.NPM_PATH return constants.NPM_PATH
@ -141,7 +133,7 @@ def get_redis() -> Optional[Redis]:
if config.redis_url is None: if config.redis_url is None:
return None return None
redis_url, redis_port = config.redis_url.split(":") 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) 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". # Make sure the app is not named "reflex".
if app_name == constants.MODULE_NAME: if app_name == constants.MODULE_NAME:
console.print( console.error(
f"[red]The app directory cannot be named [bold]{constants.MODULE_NAME}." f"The app directory cannot be named [bold]{constants.MODULE_NAME}[/bold]."
) )
raise typer.Exit() 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" config_name = f"{re.sub(r'[^a-zA-Z]', '', app_name).capitalize()}Config"
with open(constants.CONFIG_FILE, "w") as f: 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)) 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): if os.path.exists(constants.GITIGNORE_FILE):
with open(constants.GITIGNORE_FILE, "r") as f: with open(constants.GITIGNORE_FILE, "r") as f:
files |= set([line.strip() for line in f.readlines()]) files |= set([line.strip() for line in f.readlines()])
# Write files to the .gitignore file. # Write files to the .gitignore file.
with open(constants.GITIGNORE_FILE, "w") as f: with open(constants.GITIGNORE_FILE, "w") as f:
console.debug(f"Creating {constants.GITIGNORE_FILE}")
f.write(f"{(path_ops.join(sorted(files))).lstrip()}") 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.""" """Check that bun requirements are met, and install if not."""
if IS_WINDOWS: if IS_WINDOWS:
# Bun is not supported on Windows. # Bun is not supported on Windows.
console.debug("Skipping bun installation on Windows.")
return return
# Check the bun version. # 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() remove_existing_bun_installation()
install_bun() install_bun()
def remove_existing_bun_installation(): def remove_existing_bun_installation():
"""Remove existing bun installation.""" """Remove existing bun installation."""
console.debug("Removing existing bun installation.")
if os.path.exists(constants.BUN_PATH): if os.path.exists(constants.BUN_PATH):
path_ops.rm(constants.BUN_ROOT_PATH) path_ops.rm(constants.BUN_ROOT_PATH)
@ -279,17 +280,13 @@ def initialize_node():
def download_and_run(url: str, *args, **env): def download_and_run(url: str, *args, **env):
"""Download and run a script. """Download and run a script.
Args: Args:
url: The url of the script. url: The url of the script.
args: The arguments to pass to the script. args: The arguments to pass to the script.
env: The environment variables to use. env: The environment variables to use.
Raises:
Exit: if installation failed
""" """
# Download the script # Download the script
console.debug(f"Downloading {url}")
response = httpx.get(url) response = httpx.get(url)
if response.status_code != httpx.codes.OK: if response.status_code != httpx.codes.OK:
response.raise_for_status() response.raise_for_status()
@ -300,13 +297,9 @@ def download_and_run(url: str, *args, **env):
f.write(response.text) f.write(response.text)
# Run the script. # Run the script.
env = { env = {**os.environ, **env}
**os.environ, process = new_process(["bash", f.name, *args], env=env)
**env, show_logs(f"Installing {url}", process)
}
result = subprocess.run(["bash", f.name, *args], env=env)
if result.returncode != 0:
raise typer.Exit(code=result.returncode)
def install_node(): def install_node():
@ -318,8 +311,8 @@ def install_node():
""" """
# NVM is not supported on Windows. # NVM is not supported on Windows.
if IS_WINDOWS: if IS_WINDOWS:
console.print( console.error(
f"[red]Node.js version {constants.NODE_VERSION} or higher is required to run Reflex." f"Node.js version {constants.NODE_VERSION} or higher is required to run Reflex."
) )
raise typer.Exit() raise typer.Exit()
@ -330,7 +323,7 @@ def install_node():
# Install node. # Install node.
# We use bash -c as we need to source nvm.sh to use nvm. # We use bash -c as we need to source nvm.sh to use nvm.
result = subprocess.run( process = new_process(
[ [
"bash", "bash",
"-c", "-c",
@ -338,8 +331,7 @@ def install_node():
], ],
env=env, env=env,
) )
if result.returncode != 0: show_logs("Installing node", process)
raise typer.Exit(code=result.returncode)
def install_bun(): def install_bun():
@ -350,13 +342,15 @@ def install_bun():
""" """
# Bun is not supported on Windows. # Bun is not supported on Windows.
if IS_WINDOWS: if IS_WINDOWS:
console.debug("Skipping bun installation on Windows.")
return return
# Skip if bun is already installed. # Skip if bun is already installed.
if os.path.exists(constants.BUN_PATH): if os.path.exists(constants.BUN_PATH):
console.debug("Skipping bun installation as it is already installed.")
return return
# Check if unzip is installed # if unzip is installed
unzip_path = path_ops.which("unzip") unzip_path = path_ops.which("unzip")
if unzip_path is None: if unzip_path is None:
raise FileNotFoundError("Reflex requires unzip to be installed.") raise FileNotFoundError("Reflex requires unzip to be installed.")
@ -371,24 +365,21 @@ def install_bun():
def install_frontend_packages(): def install_frontend_packages():
"""Installs the base and custom frontend packages.""" """Installs the base and custom frontend packages."""
# Install the frontend packages.
console.rule("[bold]Installing frontend packages")
# Install the base packages. # Install the base packages.
subprocess.run( process = new_process(
[get_install_package_manager(), "install"], [get_install_package_manager(), "install", "--loglevel", "silly"],
cwd=constants.WEB_DIR, cwd=constants.WEB_DIR,
stdout=subprocess.PIPE,
) )
show_status("Installing base frontend packages", process)
# Install the app packages. # Install the app packages.
packages = get_config().frontend_packages packages = get_config().frontend_packages
if len(packages) > 0: if len(packages) > 0:
subprocess.run( process = new_process(
[get_install_package_manager(), "add", *packages], [get_install_package_manager(), "add", *packages],
cwd=constants.WEB_DIR, cwd=constants.WEB_DIR,
stdout=subprocess.PIPE,
) )
show_status("Installing custom frontend packages", process)
def check_initialized(frontend: bool = True): def check_initialized(frontend: bool = True):
@ -406,22 +397,22 @@ def check_initialized(frontend: bool = True):
# Check if the app is initialized. # Check if the app is initialized.
if not (has_config and has_reflex_dir and has_web_dir): if not (has_config and has_reflex_dir and has_web_dir):
console.print( console.error(
f"[red]The app is not initialized. Run [bold]{constants.MODULE_NAME} init[/bold] first." f"The app is not initialized. Run [bold]{constants.MODULE_NAME} init[/bold] first."
) )
raise typer.Exit() raise typer.Exit()
# Check that the template is up to date. # Check that the template is up to date.
if frontend and not is_latest_template(): if frontend and not is_latest_template():
console.print( console.error(
"[red]The base app template has updated. Run [bold]reflex init[/bold] again." "The base app template has updated. Run [bold]reflex init[/bold] again."
) )
raise typer.Exit() raise typer.Exit()
# Print a warning for Windows users. # Print a warning for Windows users.
if IS_WINDOWS: if IS_WINDOWS:
console.print( console.warn(
"[yellow][WARNING] We strongly advise using Windows Subsystem for Linux (WSL) for optimal performance with reflex." "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(): def check_admin_settings():
"""Check if admin settings are set and valid for logging in cli app.""" """Check if admin settings are set and valid for logging in cli app."""
admin_dash = get_config().admin_dash admin_dash = get_config().admin_dash
current_time = datetime.now()
if admin_dash: if admin_dash:
if not admin_dash.models: if not admin_dash.models:
console.print( console.log(
f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin dashboard enabled, but no models defined in [bold magenta]rxconfig.py[/bold magenta]. Time: {current_time}" f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin dashboard enabled, but no models defined in [bold magenta]rxconfig.py[/bold magenta]."
) )
else: else:
console.print( console.log(
f"[yellow][Admin Dashboard][/yellow] Admin enabled, building admin dashboard. Time: {current_time}" 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]" "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). 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(): if get_config().db_url is not None and not Path(constants.ALEMBIC_CONFIG).exists():
console.print( console.error(
"[red]Database is not initialized. Run [bold]reflex db init[/bold] first." "Database is not initialized. Run [bold]reflex db init[/bold] first."
) )
return False return False
return True return True
@ -501,14 +491,14 @@ def check_schema_up_to_date():
connection=connection, connection=connection,
write_migration_scripts=False, write_migration_scripts=False,
): ):
console.print( console.error(
"[red]Detected database schema changes. Run [bold]reflex db makemigrations[/bold] " "Detected database schema changes. Run [bold]reflex db makemigrations[/bold] "
"to generate migration scripts.", "to generate migration scripts.",
) )
except CommandError as command_error: except CommandError as command_error:
if "Target database is not up to date." in str(command_error): if "Target database is not up to date." in str(command_error):
console.print( console.error(
f"[red]{command_error} Run [bold]reflex db migrate[/bold] to update database." f"{command_error} Run [bold]reflex db migrate[/bold] to update database."
) )
@ -527,7 +517,7 @@ def migrate_to_reflex():
return return
# Rename pcconfig to rxconfig. # Rename pcconfig to rxconfig.
console.print( console.log(
f"[bold]Renaming {constants.OLD_CONFIG_FILE} to {constants.CONFIG_FILE}" f"[bold]Renaming {constants.OLD_CONFIG_FILE} to {constants.CONFIG_FILE}"
) )
os.rename(constants.OLD_CONFIG_FILE, constants.CONFIG_FILE) os.rename(constants.OLD_CONFIG_FILE, constants.CONFIG_FILE)

View File

@ -7,8 +7,7 @@ import os
import signal import signal
import subprocess import subprocess
import sys import sys
from datetime import datetime from typing import List, Optional
from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import psutil import psutil
@ -101,7 +100,7 @@ def change_or_terminate_port(port, _type) -> str:
Returns: Returns:
The new port or the current one. 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." 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"]) 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): if is_process_on_port(new_port):
return change_or_terminate_port(new_port, _type) return change_or_terminate_port(new_port, _type)
else: else:
console.print( console.info(
f"The {_type} will run on port [bold underline]{new_port}[/bold underline]." f"The {_type} will run on port [bold underline]{new_port}[/bold underline]."
) )
return new_port return new_port
else: else:
console.print("Exiting...") console.log("Exiting...")
sys.exit() 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. """Wrapper over subprocess.Popen to unify the launch of child processes.
Args: Args:
args: A string, or a sequence of program arguments. 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. **kwargs: Kwargs to override default wrap values to pass to subprocess.Popen as arguments.
Returns: Returns:
Execute a child program in a new process. Execute a child program in a new process.
""" """
# Add the node bin path to the PATH environment variable.
env = { env = {
**os.environ, **os.environ,
"PATH": os.pathsep.join([constants.NODE_BIN_PATH, os.environ["PATH"]]), "PATH": os.pathsep.join([constants.NODE_BIN_PATH, os.environ["PATH"]]),
} }
kwargs = { kwargs = {
"env": env, "env": env,
"stderr": subprocess.STDOUT, "stderr": None if show_logs else subprocess.STDOUT,
"stdout": subprocess.PIPE, "stdout": None if show_logs else subprocess.PIPE,
"universal_newlines": True, "universal_newlines": True,
"encoding": "UTF-8", "encoding": "UTF-8",
**kwargs, **kwargs,
} }
return subprocess.Popen( console.debug(f"Running command: {args}")
args, fn = subprocess.run if run else subprocess.Popen
**kwargs, 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): def catch_keyboard_interrupt(signal, frame):
@ -159,5 +236,4 @@ def catch_keyboard_interrupt(signal, frame):
signal: The keyboard interrupt signal. signal: The keyboard interrupt signal.
frame: The current stack frame. frame: The current stack frame.
""" """
current_time = datetime.now().isoformat() console.log("Reflex app stopped.")
console.print(f"\nReflex app stopped at time: {current_time}")

View File

@ -1,5 +1,4 @@
import os import os
import subprocess
import typing import typing
from pathlib import Path from pathlib import Path
from typing import Any, List, Union 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" nvm_root_path = tmp_path / ".reflex" / ".nvm"
mocker.patch("reflex.utils.prerequisites.constants.NVM_DIR", nvm_root_path) 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) mocker.patch("reflex.utils.prerequisites.IS_WINDOWS", False)
class Resp(Base): class Resp(Base):
@ -540,13 +535,15 @@ def test_node_install_unix(tmp_path, mocker):
text = "test" text = "test"
mocker.patch("httpx.get", return_value=Resp()) 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() prerequisites.install_node()
assert nvm_root_path.exists() assert nvm_root_path.exists()
subprocess_run.assert_called() download.assert_called()
subprocess_run.call_count = 2 download.call_count = 2
def test_bun_install_without_unzip(mocker): def test_bun_install_without_unzip(mocker):