Add unified logging (#1462)
This commit is contained in:
parent
e1cb09e9d4
commit
068bcd906e
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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.")
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user