diff --git a/pyproject.toml b/pyproject.toml index 4fa28a20eed..a69dcc8c7ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = [ "python-socketio >=5.7.0,<6.0", "redis >=4.3.5,<6.0", "reflex-hosting-cli >=0.1.29", - "rich >=13.0.0,<14.0", "setuptools >=75.0", "starlette-admin >=0.11.0,<1.0", "sqlmodel >=0.0.14,<0.1", diff --git a/reflex/app.py b/reflex/app.py index 2c823ec1e4b..c3f5dd2e9a0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -36,7 +36,6 @@ from fastapi.middleware import cors from fastapi.responses import JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from socketio import ASGIApp, AsyncNamespace, AsyncServer from starlette.datastructures import Headers from starlette.datastructures import UploadFile as StarletteUploadFile @@ -110,6 +109,14 @@ ) from reflex.utils.exec import get_compile_context, is_prod_mode, is_testing_env from reflex.utils.imports import ImportVar +from reflex.utils.progress import ( + CounterComponent, + MessageComponent, + ProgressBar, + SimpleProgressComponent, + TimeComponent, +) +from reflex.utils.terminal import colored if TYPE_CHECKING: from reflex.vars import Var @@ -1122,23 +1129,38 @@ def get_compilation_time() -> str: return - # Create a progress bar. - progress = Progress( - *Progress.get_default_columns()[:-1], - MofNCompleteColumn(), - TimeElapsedColumn(), - ) - # try to be somewhat accurate - but still not 100% adhoc_steps_without_executor = 7 fixed_pages_within_executor = 5 - progress.start() - task = progress.add_task( - f"[{get_compilation_time()}] Compiling:", - total=len(self._pages) - + (len(self._unevaluated_pages) * 2) - + fixed_pages_within_executor - + adhoc_steps_without_executor, + + # Create a progress bar. + progress = ProgressBar( + steps=( + len(self._pages) + + (len(self._unevaluated_pages) * 2) + + fixed_pages_within_executor + + adhoc_steps_without_executor + ), + components=( + ( + MessageComponent( + message=colored(f"[{get_compilation_time()}]", "cyan") + + " Compiling:", + ), + 0, + ), + ( + SimpleProgressComponent( + colorer=lambda x: colored(x, "light_green") + ), + 2, + ), + ( + CounterComponent(colorer=lambda x: colored(x, "green")), + 1, + ), + (TimeComponent(colorer=lambda x: colored(x, "yellow")), 3), + ), ) with console.timing("Evaluate Pages (Frontend)"): @@ -1149,7 +1171,7 @@ def get_compilation_time() -> str: self._compile_page(route, save_page=should_compile) end = timer() performance_metrics.append((route, end - start)) - progress.advance(task) + progress.update(1) console.debug( "Slowest pages:\n" + "\n".join( @@ -1180,12 +1202,12 @@ def get_compilation_time() -> str: if is_prod_mode() and config.show_built_with_reflex: self._setup_sticky_badge() - progress.advance(task) + progress.update(1) # Store the compile results. compile_results: list[tuple[str, str]] = [] - progress.advance(task) + progress.update(1) # Track imports and custom components found. all_imports = {} @@ -1239,7 +1261,7 @@ def memoized_toast_provider(): stateful_components_code, page_components, ) = compiler.compile_stateful_components(self._pages.values()) - progress.advance(task) + progress.update(1) # Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State. if code_uses_state_contexts(stateful_components_code) and self._state is None: @@ -1249,7 +1271,7 @@ def memoized_toast_provider(): ) compile_results.append((stateful_components_path, stateful_components_code)) - progress.advance(task) + progress.update(1) # Compile the root document before fork. compile_results.append( @@ -1262,7 +1284,7 @@ def memoized_toast_provider(): ) ) - progress.advance(task) + progress.update(1) # Copy the assets. assets_src = Path.cwd() / constants.Dirs.APP_ASSETS @@ -1287,7 +1309,7 @@ def memoized_toast_provider(): def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): f = executor.submit(fn, *args, **kwargs) - f.add_done_callback(lambda _: progress.advance(task)) + f.add_done_callback(lambda _: progress.update(1)) result_futures.append(f) # Compile the pre-compiled pages. @@ -1323,7 +1345,7 @@ def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): # Get imports from AppWrap components. all_imports.update(app_root._get_all_imports()) - progress.advance(task) + progress.update(1) # Compile the contexts. compile_results.append( @@ -1332,13 +1354,13 @@ def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): if self.theme is not None: # Fix #2992 by removing the top-level appearance prop self.theme.appearance = None - progress.advance(task) + progress.update(1) # Compile the app root. compile_results.append( compiler.compile_app(app_root), ) - progress.advance(task) + progress.update(1) # Compile custom components. ( @@ -1349,8 +1371,8 @@ def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs): compile_results.append((custom_components_output, custom_components_result)) all_imports.update(custom_components_imports) - progress.advance(task) - progress.stop() + progress.update(1) + progress.finish() # Install frontend packages. with console.timing("Install Frontend Packages"): diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py index 638a2cd5144..0205b0c1b75 100644 --- a/reflex/custom_components/custom_components.py +++ b/reflex/custom_components/custom_components.py @@ -338,7 +338,7 @@ def init( # Check the name follows the convention if picked. name_variants = _validate_library_name(library_name) - console.rule(f"[bold]Initializing {name_variants.package_name} project") + console.rule(f"Initializing {name_variants.package_name} project") _populate_custom_component_project(name_variants) @@ -351,25 +351,25 @@ def init( if install: package_name = name_variants.package_name - console.rule(f"[bold]Installing {package_name} in editable mode.") + console.rule(f"Installing {package_name} in editable mode.") if _pip_install_on_demand(package_name=".", install_args=["-e"]): console.info(f"Package {package_name} installed!") else: raise typer.Exit(code=1) - console.print("[bold]Custom component initialized successfully!") - console.rule("[bold]Project Summary") + console.success("Custom component initialized successfully!", bold=True) + console.rule("Project Summary") console.print( - f"[ {CustomComponents.PACKAGE_README} ]: Package description. Please add usage examples." + f"[{CustomComponents.PACKAGE_README}]: Package description. Please add usage examples." ) console.print( - f"[ {CustomComponents.PYPROJECT_TOML} ]: Project configuration file. Please fill in details such as your name, email, homepage URL." + f"[{CustomComponents.PYPROJECT_TOML}]: Project configuration file. Please fill in details such as your name, email, homepage URL." ) console.print( - f"[ {CustomComponents.SRC_DIR}/ ]: Custom component code template. Start by editing it with your component implementation." + f"[{CustomComponents.SRC_DIR}/]: Custom component code template. Start by editing it with your component implementation." ) console.print( - f"[ {name_variants.demo_app_dir}/ ]: Demo App. Add more code to this app and test." + f"[{name_variants.demo_app_dir}/]: Demo App. Add more code to this app and test." ) @@ -829,7 +829,7 @@ def _collect_details_for_gallery(): import reflex_cli.constants from reflex_cli.utils import hosting - console.rule("[bold]Authentication with Reflex Services") + console.rule("Authentication with Reflex Services") console.print("First let's log in to Reflex backend services.") access_token, _ = hosting.authenticated_token() @@ -839,7 +839,7 @@ def _collect_details_for_gallery(): ) raise typer.Exit(code=1) - console.rule("[bold]Custom Component Information") + console.rule("Custom Component Information") params = {} package_name = None try: diff --git a/reflex/model.py b/reflex/model.py index e499f9de3e5..0ce929ca06c 100644 --- a/reflex/model.py +++ b/reflex/model.py @@ -24,6 +24,7 @@ from reflex.config import environment, get_config from reflex.utils import console from reflex.utils.compat import sqlmodel, sqlmodel_field_has_primary_key +from reflex.utils.terminal import colored _ENGINE: dict[str, sqlalchemy.engine.Engine] = {} _ASYNC_ENGINE: dict[str, sqlalchemy.ext.asyncio.AsyncEngine] = {} @@ -91,7 +92,10 @@ def get_engine(url: str | None = None) -> sqlalchemy.engine.Engine: if not environment.ALEMBIC_CONFIG.get().exists(): console.warn( - "Database is not initialized, run [bold]reflex db init[/bold] first." + colored("Database is not initialized, run ", "warning") + + colored("reflex db init", "warning", attrs=("bold",)) + + colored(" first.", "warning"), + color=None, ) _ENGINE[url] = sqlmodel.create_engine( url, @@ -133,7 +137,10 @@ def get_async_engine(url: str | None) -> sqlalchemy.ext.asyncio.AsyncEngine: if not environment.ALEMBIC_CONFIG.get().exists(): console.warn( - "Database is not initialized, run [bold]reflex db init[/bold] first." + colored("Database is not initialized, run ", "warning") + + colored("reflex db init", "warning", attrs=("bold",)) + + colored(" first.", "warning"), + color=None, ) _ASYNC_ENGINE[url] = sqlalchemy.ext.asyncio.create_async_engine( url, diff --git a/reflex/reflex.py b/reflex/reflex.py index ebeb051d04a..11d13a88952 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -15,6 +15,7 @@ from reflex.state import reset_disk_state_manager from reflex.utils import console, redir, telemetry from reflex.utils.exec import should_use_granian +from reflex.utils.terminal import colored # Disable typer+rich integration for help panels typer.core.rich = None # pyright: ignore [reportPrivateImportUsage] @@ -79,7 +80,7 @@ def _init( # Validate the app name. app_name = prerequisites.validate_app_name(name) - console.rule(f"[bold]Initializing {app_name}") + console.rule(f"Initializing {app_name}") # Check prerequisites. prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) @@ -200,7 +201,7 @@ def _run( # Reload the config to make sure the env vars are persistent. get_config(reload=True) - console.rule("[bold]Starting Reflex App") + console.rule("Starting Reflex App") prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) @@ -457,10 +458,12 @@ def db_init(): # Check the alembic config. if environment.ALEMBIC_CONFIG.get().exists(): 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.", + colored("Database is already initialized. Use ", "error") + + colored("reflex db makemigrations", "error", attrs=("bold",)) + + colored(" to create schema change scripts and ", "error") + + colored("reflex db migrate", "error", attrs=("bold",)) + + colored(" to apply migrations to a new or existing database.", "error"), + color=None, ) return @@ -510,7 +513,10 @@ def makemigrations( if "Target database is not up to date." not in str(command_error): raise console.error( - f"{command_error} Run [bold]reflex db migrate[/bold] to update database." + colored(f"{command_error} Run ", "error") + + colored("reflex db migrate", "error", attrs=("bold",)) + + colored(" to update database.", "error"), + color=None, ) diff --git a/reflex/state.py b/reflex/state.py index 04860a22c6e..f8f8471d23b 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -3564,7 +3564,7 @@ async def _get_pubsub_message( if timeout is None: timeout = self.lock_expiration / 1000.0 - started = time.time() + started = time.monotonic() message = await pubsub.get_message( ignore_subscribe_messages=True, timeout=timeout, @@ -3573,7 +3573,7 @@ async def _get_pubsub_message( message is None or message["data"] not in self._redis_keyspace_lock_release_events ): - remaining = timeout - (time.time() - started) + remaining = timeout - (time.monotonic() - started) if remaining <= 0: return await self._get_pubsub_message(pubsub, timeout=remaining) diff --git a/reflex/testing.py b/reflex/testing.py index 0269cef352e..6e6b7015f98 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -525,8 +525,8 @@ def _poll_for( timeout = DEFAULT_TIMEOUT if step is None: step = POLL_INTERVAL - deadline = time.time() + timeout - while time.time() < deadline: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: success = target() if success: return success @@ -554,8 +554,8 @@ async def _poll_for_async( timeout = DEFAULT_TIMEOUT if step is None: step = POLL_INTERVAL - deadline = time.time() + timeout - while time.time() < deadline: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: success = await target() if success: return success diff --git a/reflex/utils/build.py b/reflex/utils/build.py index fdac26500d6..addd999c501 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -8,8 +8,6 @@ import zipfile from pathlib import Path -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, processes @@ -114,19 +112,9 @@ def _zip( ] # Create a progress bar for zipping the component. - progress = Progress( - *Progress.get_default_columns()[:-1], - MofNCompleteColumn(), - TimeElapsedColumn(), - ) - task = progress.add_task( - f"Zipping {component_name.value}:", total=len(files_to_zip) - ) - - with progress, zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zipf: + console.info(f"Zipping {component_name.value} to {target}") + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zipf: for file in files_to_zip: - console.debug(f"{target}: {file}", progress=progress) - progress.advance(task) zipf.write(file, Path(file).relative_to(root_dir)) diff --git a/reflex/utils/console.py b/reflex/utils/console.py index 82a4186057e..bb4d329382a 100644 --- a/reflex/utils/console.py +++ b/reflex/utils/console.py @@ -3,18 +3,246 @@ from __future__ import annotations import contextlib +import dataclasses import inspect import os +import re import shutil +import sys import time +import types +from dataclasses import dataclass from pathlib import Path from types import FrameType -from rich.console import Console -from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn -from rich.prompt import Prompt - from reflex.constants import LogLevel +from reflex.utils.terminal import colored + + +def _get_terminal_width() -> int: + try: + # First try using shutil, which is more reliable across platforms + return shutil.get_terminal_size().columns + except (AttributeError, ValueError, OSError): + try: + # Fallback to environment variables + return int(os.environ.get("COLUMNS", os.environ.get("TERM_WIDTH", 80))) + except (TypeError, ValueError): + # Default fallback + return 80 + + +IS_REPRENTER_ACTIVE = False + + +@dataclasses.dataclass +class Reprinter: + """A class that reprints text on the terminal.""" + + _text: str = dataclasses.field(default="", init=False) + + @staticmethod + def _moveup(lines: int): + for _ in range(lines): + sys.stdout.write("\x1b[A") + + @staticmethod + def _movestart(): + sys.stdout.write("\r") + + def reprint(self, text: str): + """Reprint the text. + + Args: + text: The text to print + """ + global IS_REPRENTER_ACTIVE + IS_REPRENTER_ACTIVE = True + text = text.rstrip("\n") + number_of_lines = self._text.count("\n") + 1 + number_of_lines_new = text.count("\n") + 1 + + # Clear previous text by overwritig non-spaces with spaces + self._moveup(number_of_lines - 1) + self._movestart() + sys.stdout.write(re.sub(r"[^\s]", " ", self._text)) + + # Print new text + lines = min(number_of_lines, number_of_lines_new) + self._moveup(lines - 1) + self._movestart() + sys.stdout.write(text) + sys.stdout.flush() + self._text = text + + def finish(self): + """Finish printing the text.""" + sys.stdout.write("\n") + sys.stdout.flush() + global IS_REPRENTER_ACTIVE + IS_REPRENTER_ACTIVE = False + + +STATUS_CHARS = ["◐", "◓", "◑", "◒"] + + +@dataclass +class Status: + """A status class for displaying a spinner.""" + + message: str = "Loading" + _reprinter: Reprinter | None = dataclasses.field(default=None, init=False) + _parity: int = dataclasses.field(default=0, init=False) + + def __enter__(self): + """Enter the context manager. + + Returns: + The status object. + """ + self._reprinter = Reprinter() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ): + """Exit the context manager. + + Args: + exc_type: The exception type. + exc_value: The exception value. + traceback: The traceback. + """ + if self._reprinter: + self._reprinter.reprint("") + self._reprinter.finish() + self._reprinter._moveup(1) + sys.stdout.flush() + self._reprinter = None + + def update(self, msg: str, **kwargs): + """Update the status spinner. + + Args: + msg: The message to display. + kwargs: Keyword arguments to pass to the print function. + """ + if self._reprinter: + char = STATUS_CHARS[self._parity % 4] + self._parity += 1 + self._reprinter.reprint(f"{char} {msg}") + + +@dataclass +class Console: + """A console class for pretty printing.""" + + def print(self, msg: str, **kwargs): + """Print a message. + + Args: + msg: The message to print. + kwargs: Keyword arguments to pass to the print function. + """ + from builtins import print + + color = kwargs.pop("color", None) + bold = kwargs.pop("bold", False) + if color or bold: + msg = colored(msg, color, attrs=["bold"] if bold else []) + + if IS_REPRENTER_ACTIVE: + print("\n" + msg, flush=True, **kwargs) # noqa: T201 + else: + print(msg, **kwargs) # noqa: T201 + + def rule(self, 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. + """ + terminal_width = _get_terminal_width() + remaining_width = ( + terminal_width - len(title) - 2 + ) # 2 for the spaces around the title + left_padding = remaining_width // 2 + right_padding = remaining_width - left_padding + + color = kwargs.pop("color", None) + bold = kwargs.pop("bold", True) + rule_color = "green" if color is None else color + title = colored(title, color, attrs=("bold",) if bold else ()) + + rule_line = ( + colored("─" * left_padding, rule_color) + + " " + + title + + " " + + colored("─" * right_padding, rule_color) + ) + self.print(rule_line, **kwargs) + + def status(self, *args, **kwargs): + """Create a status. + + Args: + *args: Args to pass to the status. + **kwargs: Kwargs to pass to the status. + + Returns: + A new status. + """ + return Status(*args, **kwargs) + + +class Prompt: + """A class for prompting the user for input.""" + + @staticmethod + def ask( + question: str, + choices: list[str] | None = None, + default: str | None = None, + show_choices: bool = True, + ) -> str | None: + """Ask the user a question. + + Args: + question: The question to ask the user. + choices: A list of choices to select from. + default: The default option selected. + show_choices: Whether to show the choices. + + Returns: + The user's response or the default value. + """ + prompt = question + + if choices and show_choices: + choice_str = "/".join(choices) + prompt = f"{question} [{choice_str}]" + + if default is not None: + prompt = f"{prompt} ({default})" + + prompt = f"{prompt}: " + + response = input(prompt) + + if not response and default is not None: + return default + + if choices and response not in choices: + print(f"Please choose from: {', '.join(choices)}") + return Prompt.ask(question, choices, default, show_choices) + + return response + # Console for pretty printing. _console = Console() @@ -101,16 +329,13 @@ def debug(msg: str, dedupe: bool = False, **kwargs): kwargs: Keyword arguments to pass to the print function. """ if is_debug(): - msg_ = f"[purple]Debug: {msg}[/purple]" if dedupe: - if msg_ in _EMITTED_DEBUG: + if msg in _EMITTED_DEBUG: return else: - _EMITTED_DEBUG.add(msg_) - if progress := kwargs.pop("progress", None): - progress.console.print(msg_, **kwargs) - else: - print(msg_, **kwargs) + _EMITTED_DEBUG.add(msg) + kwargs.setdefault("color", "debug") + print(msg, **kwargs) def info(msg: str, dedupe: bool = False, **kwargs): @@ -127,7 +352,8 @@ def info(msg: str, dedupe: bool = False, **kwargs): return else: _EMITTED_INFO.add(msg) - print(f"[cyan]Info: {msg}[/cyan]", **kwargs) + kwargs.setdefault("color", "info") + print(f"Info: {msg}", **kwargs) def success(msg: str, dedupe: bool = False, **kwargs): @@ -144,7 +370,8 @@ def success(msg: str, dedupe: bool = False, **kwargs): return else: _EMITTED_SUCCESS.add(msg) - print(f"[green]Success: {msg}[/green]", **kwargs) + kwargs.setdefault("color", "success") + print(f"Success: {msg}", **kwargs) def log(msg: str, dedupe: bool = False, **kwargs): @@ -161,7 +388,7 @@ def log(msg: str, dedupe: bool = False, **kwargs): return else: _EMITTED_LOGS.add(msg) - _console.log(msg, **kwargs) + _console.print(msg, **kwargs) def rule(title: str, **kwargs): @@ -188,7 +415,8 @@ def warn(msg: str, dedupe: bool = False, **kwargs): return else: _EMIITED_WARNINGS.add(msg) - print(f"[orange1]Warning: {msg}[/orange1]", **kwargs) + kwargs.setdefault("color", "warning") + print(f"Warning: {msg}", **kwargs) def _get_first_non_framework_frame() -> FrameType | None: @@ -254,7 +482,8 @@ def deprecate( f"removed in {removal_version}. ({loc})" ) if _LOG_LEVEL <= LogLevel.WARNING: - print(f"[yellow]DeprecationWarning: {msg}[/yellow]", **kwargs) + kwargs.setdefault("color", "warning") + print(f"DeprecationWarning: {msg}", **kwargs) if dedupe: _EMITTED_DEPRECATION_WARNINGS.add(dedupe_key) @@ -273,7 +502,8 @@ def error(msg: str, dedupe: bool = False, **kwargs): return else: _EMITTED_ERRORS.add(msg) - print(f"[red]{msg}[/red]", **kwargs) + kwargs.setdefault("color", "error") + print(f"{msg}", **kwargs) def ask( @@ -299,19 +529,6 @@ def ask( ) -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. @@ -335,8 +552,8 @@ def timing(msg: str): Yields: None. """ - start = time.time() + start = time.monotonic() try: yield finally: - debug(f"[white]\\[timing] {msg}: {time.time() - start:.2f}s[/white]") + debug(f"[timing] {msg}: {time.monotonic() - start:.2f}s", color="white") diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 83115f0f460..a9ed2df1ef5 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -22,6 +22,7 @@ from reflex.utils import console, path_ops from reflex.utils.decorator import once from reflex.utils.prerequisites import get_web_dir +from reflex.utils.terminal import colored # For uvicorn windows bug fix (#2335) frontend_process = None @@ -67,7 +68,10 @@ def kill(proc_pid: int): def notify_backend(): """Output a string notifying where the backend is running.""" console.print( - f"Backend running at: [bold green]http://0.0.0.0:{get_config().backend_port}[/bold green]" + "Backend running at: " + + colored( + f"http://0.0.0.0:{get_config().backend_port}", "green", attrs=("bold",) + ) ) @@ -113,7 +117,9 @@ def run_process_and_launch_url( url = urljoin(url, get_config().frontend_path) console.print( - f"App running at: [bold green]{url}[/bold green]{' (Frontend-only mode)' if not backend_present else ''}" + "App running at: " + + colored(url, "green", attrs=("bold",)) + + (" (Frontend-only mode)" if not backend_present else "") ) if backend_present: notify_backend() @@ -153,7 +159,7 @@ def run_frontend(root: Path, port: str, backend_present: bool = True): prerequisites.validate_frontend_dependencies(init=False) # Run the frontend in development mode. - console.rule("[bold green]App Running") + console.rule("App Running", color="green") os.environ["PORT"] = str(get_config().frontend_port if port is None else port) run_process_and_launch_url( [ @@ -180,7 +186,7 @@ def run_frontend_prod(root: Path, port: str, backend_present: bool = True): # validate dependencies before run prerequisites.validate_frontend_dependencies(init=False) # Run the frontend in production mode. - console.rule("[bold green]App Running") + console.rule("App Running", color="green") run_process_and_launch_url( [*prerequisites.get_js_package_executor(raise_on_none=True)[0], "run", "prod"], backend_present, diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 71b56b8f827..132ac63c578 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -51,7 +51,7 @@ def export( exec.output_system_info() # Compile the app in production mode and export it. - console.rule("[bold]Compiling production app and preparing for export.") + console.rule("Compiling production app and preparing for export.") if frontend: # Ensure module can be imported and app.compile() is called. diff --git a/reflex/utils/net.py b/reflex/utils/net.py index ddc68a54284..113c0f57f84 100644 --- a/reflex/utils/net.py +++ b/reflex/utils/net.py @@ -42,7 +42,7 @@ def _wrap_https_func( def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: url = args[0] console.debug(f"Sending HTTPS request to {args[0]}") - initial_time = time.time() + initial_time = time.monotonic() try: response = func(*args, **kwargs) except httpx.ConnectError as err: @@ -55,7 +55,7 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: raise else: console.debug( - f"Received response from {url} in {time.time() - initial_time:.3f} seconds" + f"Received response from {url} in {time.monotonic() - initial_time:.3f} seconds" ) return response diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 81aa97f05fd..d14f1cc6f64 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -8,7 +8,6 @@ import importlib import importlib.metadata import importlib.util -import io import json import os import platform @@ -44,6 +43,7 @@ ) from reflex.utils.format import format_library_name from reflex.utils.registry import get_npm_registry +from reflex.utils.terminal import _can_colorize, colored if typing.TYPE_CHECKING: from reflex.app import App @@ -453,48 +453,6 @@ def compile_app(reload: bool = False, export: bool = False) -> None: get_compiled_app(reload=reload, export=export) -def _can_colorize() -> bool: - """Check if the output can be colorized. - - Copied from _colorize.can_colorize. - - https://raw.githubusercontent.com/python/cpython/refs/heads/main/Lib/_colorize.py - - Returns: - If the output can be colorized - """ - file = sys.stdout - - if not sys.flags.ignore_environment: - if os.environ.get("PYTHON_COLORS") == "0": - return False - if os.environ.get("PYTHON_COLORS") == "1": - return True - if os.environ.get("NO_COLOR"): - return False - if os.environ.get("FORCE_COLOR"): - return True - if os.environ.get("TERM") == "dumb": - return False - - if not hasattr(file, "fileno"): - return False - - if sys.platform == "win32": - try: - import nt - - if not nt._supports_virtual_terminal(): - return False - except (ImportError, AttributeError): - return False - - try: - return os.isatty(file.fileno()) - except io.UnsupportedOperation: - return file.isatty() - - def compile_or_validate_app(compile: bool = False) -> bool: """Compile or validate the app module based on the default config. @@ -609,7 +567,10 @@ def validate_app_name(app_name: str | None = None) -> str: # Make sure the app is not named "reflex". if app_name.lower() == constants.Reflex.MODULE_NAME: console.error( - f"The app directory cannot be named [bold]{constants.Reflex.MODULE_NAME}[/bold]." + colored("The app directory cannot be named ", "error") + + colored(constants.Reflex.MODULE_NAME, "error", attrs=("bold",)) + + colored(".", "error"), + color=None, ) raise typer.Exit(1) @@ -700,7 +661,12 @@ def rename_app(new_app_name: str, loglevel: constants.LogLevel): rename_path_up_tree(Path(module_path.origin), config.app_name, new_app_name) - console.success(f"App directory renamed to [bold]{new_app_name}[/bold].") + console.success( + colored("App directory renamed to ", "success") + + colored(new_app_name, "success", attrs=("bold",)) + + colored(".", "success"), + color=None, + ) def rename_imports_and_app_name(file_path: str | Path, old_name: str, new_name: str): @@ -1310,7 +1276,13 @@ def needs_reinit(frontend: bool = True) -> bool: """ if not constants.Config.FILE.exists(): console.error( - f"[cyan]{constants.Config.FILE}[/cyan] not found. Move to the root folder of your project, or run [bold]{constants.Reflex.MODULE_NAME} init[/bold] to start a new project." + colored(constants.Config.FILE, "cyan") + + colored( + " not found. Move to the root folder of your project, or run ", "error" + ) + + colored(constants.Reflex.MODULE_NAME + " init", "error", attrs=("bold",)) + + colored(" to start a new project.", "error"), + color=None, ) raise typer.Exit(1) @@ -1491,7 +1463,10 @@ def check_db_initialized() -> bool: and not environment.ALEMBIC_CONFIG.get().exists() ): console.error( - "Database is not initialized. Run [bold]reflex db init[/bold] first." + colored("Database is not initialized. Run ", "error") + + colored("reflex db init", "error", attrs=("bold",)) + + colored(" first.", "error"), + color=None, ) return False return True @@ -1508,13 +1483,18 @@ def check_schema_up_to_date(): write_migration_scripts=False, ): console.error( - "Detected database schema changes. Run [bold]reflex db makemigrations[/bold] " - "to generate migration scripts.", + colored("Detected database schema changes. Run ", "error") + + colored("reflex db makemigrations", "error", attrs=("bold",)) + + colored(" to generate migration scripts.", "error"), + color=None, ) except CommandError as command_error: if "Target database is not up to date." in str(command_error): console.error( - f"{command_error} Run [bold]reflex db migrate[/bold] to update database." + colored(f"{command_error} Run ", "error") + + colored("reflex db migrate", "error", attrs=("bold",)) + + colored(" to update database.", "error"), + color=None, ) diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index 87d97307308..49b89c7e179 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -15,11 +15,17 @@ import psutil import typer from redis.exceptions import RedisError -from rich.progress import Progress from reflex import constants from reflex.config import environment from reflex.utils import console, path_ops, prerequisites +from reflex.utils.progress import ( + CounterComponent, + MessageComponent, + ProgressBar, + SimpleProgressComponent, +) +from reflex.utils.terminal import colored def kill(pid: int): @@ -111,7 +117,10 @@ def change_port(port: int, _type: str) -> int: if is_process_on_port(new_port): return change_port(new_port, _type) console.info( - f"The {_type} will run on port [bold underline]{new_port}[/bold underline]." + colored(f"The {_type} will run on port ", "info") + + colored(port, "info", attrs=("bold", "underline")) + + colored(".", "info"), + color=None, ) return new_port @@ -273,7 +282,6 @@ def run_concurrently(*fns: Callable | Tuple) -> None: def stream_logs( message: str, process: subprocess.Popen, - progress: Progress | None = None, suppress_errors: bool = False, analytics_enabled: bool = False, ): @@ -282,7 +290,6 @@ def stream_logs( Args: message: The message to display. process: The process. - progress: The ongoing progress bar if one is being used. suppress_errors: If True, do not exit if errors are encountered (for fallback). analytics_enabled: Whether analytics are enabled for this command. @@ -297,13 +304,16 @@ def stream_logs( # Store the tail of the logs. logs = collections.deque(maxlen=512) with process: - console.debug(message, progress=progress) + console.debug(message) if process.stdout is None: return - for line in process.stdout: - console.debug(line, end="", progress=progress) - logs.append(line) - yield line + try: + for line in process.stdout: + console.debug(line, end="") + logs.append(line) + yield line + except ValueError as e: + console.error(f"Error streaming logs: {e}") # Check if the process failed (not printing the logs for SIGINT). @@ -316,7 +326,12 @@ def stream_logs( console.error(line, end="") if analytics_enabled: telemetry.send("error", context=message) - console.error("Run with [bold]--loglevel debug [/bold] for the full log.") + console.error( + colored("Run with ", "error") + + colored("--loglevel debug", "error", attrs=("bold",)) + + colored(" for the full log.", "error"), + color=None, + ) raise typer.Exit(1) @@ -367,16 +382,22 @@ def show_progress(message: str, process: subprocess.Popen, checkpoints: list[str 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, progress=progress): - # 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 + progress = ProgressBar( + steps=len(checkpoints), + components=( + (MessageComponent(message), 0), + (SimpleProgressComponent(), 2), + (CounterComponent(), 1), + ), + ) + 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(1) + if special_string == checkpoints[-1]: + progress.finish() + break def atexit_handler(): diff --git a/reflex/utils/progress.py b/reflex/utils/progress.py new file mode 100644 index 00000000000..dfe1c5b5013 --- /dev/null +++ b/reflex/utils/progress.py @@ -0,0 +1,550 @@ +"""A module that provides a progress bar for the terminal.""" + +import dataclasses +import time +from typing import Callable, Sequence + +from reflex.utils.console import Reprinter, _get_terminal_width + +reprinter = Reprinter() + + +@dataclasses.dataclass(kw_only=True) +class ProgressBarComponent: + """A protocol for progress bar components.""" + + colorer: Callable[[str], str] = lambda x: x + + def minimum_width(self, current: int, steps: int) -> int: + """Return the minimum width of the component. + + Args: + current: The current step. + steps: The total number of steps. + """ + ... + + def requested_width(self, current: int, steps: int) -> int: + """Return the requested width of the component. + + Args: + current: The current step. + steps: The total number of steps. + """ + ... + + def initialize(self, steps: int) -> None: + """Initialize the component. + + Args: + steps: The total number of steps. + """ + ... + + def get_message(self, current: int, steps: int, max_width: int) -> str: + """Return the message to display. + + Args: + current: The current step. + steps: The total number of steps. + max_width: The maximum width of the component. + """ + ... + + +@dataclasses.dataclass +class MessageComponent(ProgressBarComponent): + """A simple component that displays a message.""" + + message: str = "" + + def minimum_width(self, current: int, steps: int) -> int: + """Return the minimum width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The minimum width of the component. + """ + return len(self.message) + + def requested_width(self, current: int, steps: int) -> int: + """Return the requested width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The requested width of the component. + """ + return len(self.message) + + def initialize(self, steps: int) -> None: + """Initialize the component. + + Args: + steps: The total number of steps. + """ + + def get_message(self, current: int, steps: int, max_width: int) -> str: + """Return the message to display. + + Args: + current: The current step. + steps: The total number of steps. + max_width: The maximum width of the component. + + Returns: + The message to display. + """ + return self.message + + +@dataclasses.dataclass +class PercentageComponent(ProgressBarComponent): + """A component that displays the percentage of completion.""" + + def minimum_width(self, current: int, steps: int) -> int: + """Return the minimum width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The minimum width of the component. + """ + return 4 + + def requested_width(self, current: int, steps: int) -> int: + """Return the requested width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The requested width of the component. + """ + return 4 + + def initialize(self, steps: int) -> None: + """Initialize the component. + + Args: + steps: The total number of steps. + """ + + def get_message(self, current: int, steps: int, max_width: int) -> str: + """Return the message to display. + + Args: + current: The current step. + steps: The total number of steps. + max_width: The maximum width of the component. + + Returns: + The message to display. + """ + return f"{int(current / steps * 100):3}%" + + +@dataclasses.dataclass +class TimeComponent(ProgressBarComponent): + """A component that displays the time elapsed.""" + + initial_time: float | None = None + + _cached_time: float | None = dataclasses.field(default=None, init=False) + + def _minimum_and_requested_string( + self, current: int, steps: int + ) -> tuple[str, str]: + """Return the minimum and requested string length of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The minimum and requested string length of the component. + + Raises: + ValueError: If the component is not initialized. + """ + if self.initial_time is None or self._cached_time is None: + raise ValueError("TimeComponent not initialized") + return ( + f"{int(self._cached_time - self.initial_time)!s}s", + f"{int((self._cached_time - self.initial_time) * 1000)!s}ms", + ) + + def minimum_width(self, current: int, steps: int) -> int: + """Return the minimum width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The minimum width of the component. + + Raises: + ValueError: If the component is not initialized. + """ + if self.initial_time is None: + raise ValueError("TimeComponent not initialized") + self._cached_time = time.monotonic() + _min, _ = self._minimum_and_requested_string(current, steps) + return len(_min) + + def requested_width(self, current: int, steps: int) -> int: + """Return the requested width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The requested width of the component. + + Raises: + ValueError: If the component is not initialized. + """ + if self.initial_time is None: + raise ValueError("TimeComponent not initialized") + _, _req = self._minimum_and_requested_string(current, steps) + return len(_req) + + def initialize(self, steps: int) -> None: + """Initialize the component. + + Args: + steps: The total number of steps. + """ + self.initial_time = time.monotonic() + + def get_message(self, current: int, steps: int, max_width: int) -> str: + """Return the message to display. + + Args: + current: The current step. + steps: The total number of steps. + max_width: The maximum width of the component. + + Returns: + The message to display. + + Raises: + ValueError: If the component is not initialized. + """ + if self.initial_time is None: + raise ValueError("TimeComponent not initialized") + _min, _req = self._minimum_and_requested_string(current, steps) + if len(_req) <= max_width: + return _req + return _min + + +@dataclasses.dataclass +class CounterComponent(ProgressBarComponent): + """A component that displays the current step and total steps.""" + + def minimum_width(self, current: int, steps: int) -> int: + """Return the minimum width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The minimum width of the component. + """ + return 1 + 2 * len(str(steps)) + + def requested_width(self, current: int, steps: int) -> int: + """Return the requested width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The requested width of the component. + """ + return 1 + 2 * len(str(steps)) + + def initialize(self, steps: int) -> None: + """Initialize the component. + + Args: + steps: The total number of steps. + """ + + def get_message(self, current: int, steps: int, max_width: int) -> str: + """Return the message to display. + + Args: + current: The current step. + steps: The total number of steps. + max_width: The maximum width of the component. + + Returns: + The message to display. + """ + return current.__format__(f"{len(str(steps))}") + "/" + str(steps) + + +@dataclasses.dataclass +class SimpleProgressComponent(ProgressBarComponent): + """A component that displays a not so fun guy.""" + + starting_str: str = "" + ending_str: str = "" + complete_str: str = "█" + incomplete_str: str = "░" + + def minimum_width(self, current: int, steps: int) -> int: + """Return the minimum width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The minimum width of the component. + """ + return ( + len(self.starting_str) + + 2 * len(self.incomplete_str) + + 2 * len(self.complete_str) + + len(self.ending_str) + ) + + def requested_width(self, current: int, steps: int) -> int: + """Return the requested width of the component. + + Args: + current: The current step. + steps: The total number of steps. + + Returns: + The requested width of the component. + """ + return ( + len(self.starting_str) + + steps * max(len(self.incomplete_str), len(self.complete_str)) + + len(self.ending_str) + ) + + def initialize(self, steps: int) -> None: + """Initialize the component. + + Args: + steps: The total number of steps. + """ + + def get_message(self, current: int, steps: int, max_width: int) -> str: + """Return the message to display. + + Args: + current: The current step. + steps: The total number of steps. + max_width: The maximum width of the component. + + Returns: + The message to display. + """ + progress = int( + current + / steps + * (max_width - len(self.starting_str) - len(self.ending_str)) + ) + + complete_part = self.complete_str * (progress // len(self.complete_str)) + + incomplete_part = self.incomplete_str * ( + ( + max_width + - len(self.starting_str) + - len(self.ending_str) + - len(complete_part) + ) + // len(self.incomplete_str) + ) + + return self.starting_str + complete_part + incomplete_part + self.ending_str + + +@dataclasses.dataclass +class ProgressBar: + """A progress bar that displays the progress of a task.""" + + steps: int + max_width: int = 80 + separator: str = " " + components: Sequence[tuple[ProgressBarComponent, int]] = dataclasses.field( + default_factory=lambda: [ + (SimpleProgressComponent(), 2), + (CounterComponent(), 3), + (PercentageComponent(), 0), + (TimeComponent(), 1), + ] + ) + + _printer: Reprinter = dataclasses.field(default_factory=Reprinter, init=False) + _current: int = dataclasses.field(default=0, init=False) + + def __post_init__(self): + """Initialize the progress bar.""" + for component, _ in self.components: + component.initialize(self.steps) + + def print(self): + """Print the current progress bar state.""" + current_terminal_width = _get_terminal_width() + + components_by_priority = [ + (index, component) + for index, (component, _) in sorted( + enumerate(self.components), key=lambda x: x[1][1], reverse=True + ) + ] + + possible_width = min(current_terminal_width, self.max_width) + sum_of_minimum_widths = sum( + component.minimum_width(self._current, self.steps) + for _, component in components_by_priority + ) + + if sum_of_minimum_widths > possible_width: + used_width = 0 + + visible_components: list[tuple[int, ProgressBarComponent, int]] = [] + + for index, component in components_by_priority: + if ( + used_width + + component.minimum_width(self._current, self.steps) + + len(self.separator) + > possible_width + ): + continue + + used_width += component.minimum_width(self._current, self.steps) + visible_components.append( + ( + index, + component, + component.requested_width(self._current, self.steps), + ) + ) + else: + components = [ + ( + priority, + component, + component.minimum_width(self._current, self.steps), + ) + for (component, priority) in self.components + ] + + while True: + sum_of_assigned_width = sum(width for _, _, width in components) + + extra_width = ( + possible_width + - sum_of_assigned_width + - (len(self.separator) * (len(components) - 1)) + ) + + possible_extra_width_to_take = [ + ( + max( + 0, + component.requested_width(self._current, self.steps) + - width, + ), + priority, + ) + for priority, component, width in components + ] + + sum_of_possible_extra_width = sum( + width for width, _ in possible_extra_width_to_take + ) + + if sum_of_possible_extra_width <= 0 or extra_width <= 0: + break + + min_width, max_prioririty = min( + filter(lambda x: x[0] > 0, possible_extra_width_to_take), + key=lambda x: x[0] / x[1], + ) + + maximum_prioririty_repeats = min_width / max_prioririty + + give_width = [ + min(width, maximum_prioririty_repeats * priority) + for width, priority in possible_extra_width_to_take + ] + sum_of_give_width = sum(give_width) + + normalized_give_width = [ + width / sum_of_give_width * min(extra_width, sum_of_give_width) + for width in give_width + ] + + components = [ + (index, component, int(width + give)) + for (index, component, width), give in zip( + components, normalized_give_width, strict=True + ) + ] + + if sum(width for _, _, width in components) == sum_of_minimum_widths: + break + + visible_components = [ + (index, component, width) + for index, (_, component, width) in enumerate(components) + if width > 0 + ] + + messages = [ + self.get_message(component, width) + for _, component, width in sorted(visible_components, key=lambda x: x[0]) + ] + + self._printer.reprint(self.separator.join(messages)) + + def get_message(self, component: ProgressBarComponent, width: int): + """Get the message for a given component. + + Args: + component: The component to get the message for. + width: The width of the component. + + Returns: + The message for the component + """ + message = component.get_message(self._current, self.steps, width) + return component.colorer(message[:width]) + + def update(self, step: int): + """Update the progress bar by a given step. + + Args: + step: The step to update the progress bar by. + """ + self._current += step + self.print() + + def finish(self): + """Finish the progress bar.""" + self._current = self.steps + self.print() + self._printer.finish() diff --git a/reflex/utils/registry.py b/reflex/utils/registry.py index fc959c104ae..1f49d00d39c 100644 --- a/reflex/utils/registry.py +++ b/reflex/utils/registry.py @@ -48,11 +48,11 @@ def _get_best_registry() -> str: """ console.debug("Getting best registry...") registries = [ - "https://registry.npmjs.org", - "https://r.cnpmjs.org", + ("https://registry.npmjs.org", 1), + ("https://registry.npmmirror.com", 2), ] - best_registry = min(registries, key=average_latency) + best_registry = min(registries, key=lambda x: average_latency(x[0]) * x[1])[0] console.debug(f"Best registry: {best_registry}") return best_registry diff --git a/reflex/utils/terminal.py b/reflex/utils/terminal.py new file mode 100644 index 00000000000..c9d8bfb053e --- /dev/null +++ b/reflex/utils/terminal.py @@ -0,0 +1,225 @@ +"""ANSI color formatting for output in terminal.""" + +from __future__ import annotations + +import io +import os +import sys +from functools import reduce +from typing import Iterable, Literal + +from reflex.utils.decorator import once + +_Attribute = Literal[ + "bold", + "dark", + "italic", + "underline", + "slow_blink", + "rapid_blink", + "reverse", + "concealed", + "strike", +] + +_ATTRIBUTES: dict[_Attribute, int] = { + "bold": 1, + "dark": 2, + "italic": 3, + "underline": 4, + "slow_blink": 5, + "rapid_blink": 6, + "reverse": 7, + "concealed": 8, + "strike": 9, +} + +_Color = Literal[ + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "light_grey", + "dark_grey", + "light_red", + "light_green", + "light_yellow", + "light_blue", + "light_magenta", + "light_cyan", + "white", + "error", + "warning", + "info", + "success", + "debug", +] + + +_COLORS: dict[_Color, int] = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "light_grey": 37, + "dark_grey": 90, + "light_red": 91, + "light_green": 92, + "light_yellow": 93, + "light_blue": 94, + "light_magenta": 95, + "light_cyan": 96, + "white": 97, + "error": 31, + "warning": 33, + "info": 36, + "success": 32, + "debug": 90, +} + +_BackgroundColor = Literal[ + "on_black", + "on_red", + "on_green", + "on_yellow", + "on_blue", + "on_magenta", + "on_cyan", + "on_light_grey", + "on_dark_grey", + "on_light_red", + "on_light_green", + "on_light_yellow", + "on_light_blue", + "on_light_magenta", + "on_light_cyan", + "on_white", +] + +BACKGROUND_COLORS: dict[_BackgroundColor, int] = { + "on_black": 40, + "on_red": 41, + "on_green": 42, + "on_yellow": 43, + "on_blue": 44, + "on_magenta": 45, + "on_cyan": 46, + "on_light_grey": 47, + "on_dark_grey": 100, + "on_light_red": 101, + "on_light_green": 102, + "on_light_yellow": 103, + "on_light_blue": 104, + "on_light_magenta": 105, + "on_light_cyan": 106, + "on_white": 107, +} + + +_ANSI_CODES = _ATTRIBUTES | BACKGROUND_COLORS | _COLORS + + +_RESET_MARKER = "\033[0m" + + +@once +def _can_colorize() -> bool: + """Check if the output can be colorized. + + Copied from _colorize.can_colorize. + + https://raw.githubusercontent.com/python/cpython/refs/heads/main/Lib/_colorize.py + + Returns: + If the output can be colorized + """ + file = sys.stdout + + if not sys.flags.ignore_environment: + if os.environ.get("PYTHON_COLORS") == "0": + return False + if os.environ.get("PYTHON_COLORS") == "1": + return True + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + if os.environ.get("TERM") == "dumb": + return False + + if not hasattr(file, "fileno"): + return False + + if sys.platform == "win32": + try: + import nt + + if not nt._supports_virtual_terminal(): + return False + except (ImportError, AttributeError): + return False + + try: + return os.isatty(file.fileno()) + except io.UnsupportedOperation: + return file.isatty() + + +def _format_str(text: str, ansi_escape_code: int | None) -> str: + """Format text with ANSI escape code. + + Args: + text: Text to format + ansi_escape_code: ANSI escape code + + Returns: + Formatted text + """ + if ansi_escape_code is None: + return text + return f"\033[{ansi_escape_code}m{text}" + + +def colored( + text: object, + color: _Color | None = None, + background_color: _BackgroundColor | None = None, + attrs: Iterable[_Attribute] = (), +) -> str: + """Colorize text for terminal output. + + Args: + text: Text to colorize + color: Text color + background_color: Background color + attrs: Text attributes + + Returns: + Colorized text + """ + result = str(text) + + if not _can_colorize(): + return result + + ansi_codes_to_apply = [ + _ANSI_CODES.get(x) + for x in [ + color, + background_color, + *attrs, + ] + if x + ] + + return ( + reduce(_format_str, ansi_codes_to_apply, result) + _RESET_MARKER + if ansi_codes_to_apply + else result + ) diff --git a/scripts/wait_for_listening_port.py b/scripts/wait_for_listening_port.py index a65293f735b..b06f553fabb 100644 --- a/scripts/wait_for_listening_port.py +++ b/scripts/wait_for_listening_port.py @@ -23,16 +23,19 @@ def _pid_exists(pid: int): def _wait_for_port(port: int, server_pid: int, timeout: float) -> tuple[bool, str]: - start = time.time() + start = time.monotonic() print(f"Waiting for up to {timeout} seconds for port {port} to start listening.") # noqa: T201 while True: if not _pid_exists(server_pid): return False, f"Server PID {server_pid} is not running." try: socket.create_connection(("localhost", port), timeout=0.5) - return True, f"Port {port} is listening after {time.time() - start} seconds" + return ( + True, + f"Port {port} is listening after {time.monotonic() - start} seconds", + ) except Exception: - if time.time() - start > timeout: + if time.monotonic() - start > timeout: return ( False, f"Port {port} still not listening after {timeout} seconds.", diff --git a/tests/integration/test_event_actions.py b/tests/integration/test_event_actions.py index aff3785ad7b..653ed14e0f9 100644 --- a/tests/integration/test_event_actions.py +++ b/tests/integration/test_event_actions.py @@ -345,8 +345,8 @@ async def test_event_actions_throttle_debounce( exp_events = 10 throttle_duration = exp_events * 0.2 # 200ms throttle - throttle_start = time.time() - while time.time() - throttle_start < throttle_duration: + throttle_start = time.monotonic() + while time.monotonic() - throttle_start < throttle_duration: btn_throttle.click() btn_debounce.click() diff --git a/tests/integration/test_exception_handlers.py b/tests/integration/test_exception_handlers.py index 71858b8995b..c57e1a47d5a 100644 --- a/tests/integration/test_exception_handlers.py +++ b/tests/integration/test_exception_handlers.py @@ -199,6 +199,6 @@ def test_frontend_exception_handler_with_react( assert "Error: Minified React error #31" in captured_default_handler_output.out else: assert ( - "Error: Objects are not valid as a React child (found: object with keys \n{invalid})" + "Error: Objects are not valid as a React child (found: object with keys {invalid})" in captured_default_handler_output.out ) diff --git a/tests/integration/test_large_state.py b/tests/integration/test_large_state.py index a9a8ff2ec6f..d34d243df83 100644 --- a/tests/integration/test_large_state.py +++ b/tests/integration/test_large_state.py @@ -67,23 +67,23 @@ def test_large_state(var_count: int, tmp_path_factory, benchmark): assert large_state.app_instance is not None button = driver.find_element(By.ID, "button") - t = time.time() + t = time.monotonic() while button.text != "0": time.sleep(0.1) - if time.time() - t > 30.0: + if time.monotonic() - t > 30.0: raise TimeoutError("Timeout waiting for initial state") times_clicked = 0 def round_trip(clicks: int, timeout: float): - t = time.time() + t = time.monotonic() for _ in range(clicks): button.click() nonlocal times_clicked times_clicked += clicks while button.text != str(times_clicked): time.sleep(0.005) - if time.time() - t > timeout: + if time.monotonic() - t > timeout: raise TimeoutError("Timeout waiting for state update") benchmark(round_trip, clicks=10, timeout=30.0) diff --git a/uv.lock b/uv.lock index 6b2fa438a50..7e28094b2bb 100644 --- a/uv.lock +++ b/uv.lock @@ -640,7 +640,7 @@ name = "importlib-metadata" version = "8.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } wheels = [ @@ -1731,7 +1731,6 @@ dependencies = [ { name = "python-socketio" }, { name = "redis" }, { name = "reflex-hosting-cli" }, - { name = "rich" }, { name = "setuptools" }, { name = "sqlmodel" }, { name = "starlette-admin" }, @@ -1794,7 +1793,6 @@ requires-dist = [ { name = "python-socketio", specifier = ">=5.7.0,<6.0" }, { name = "redis", specifier = ">=4.3.5,<6.0" }, { name = "reflex-hosting-cli", specifier = ">=0.1.29" }, - { name = "rich", specifier = ">=13.0.0,<14.0" }, { name = "setuptools", specifier = ">=75.0" }, { name = "sqlmodel", specifier = ">=0.0.14,<0.1" }, { name = "starlette-admin", specifier = ">=0.11.0,<1.0" },