HEX
Server: LiteSpeed
System: Linux s3512.bom1.stableserver.net 4.18.0-513.11.1.lve.el8.x86_64 #1 SMP Thu Jan 18 16:21:02 UTC 2024 x86_64
User: surajaut (1797)
PHP: 8.0.30
Disabled: NONE
Upload Files
File: //proc/thread-self/root/opt/saltstack/salt/lib/python3.10/site-packages/relenv/build/common/ui.py
# Copyright 2022-2026 Broadcom.
# SPDX-License-Identifier: Apache-2.0
"""
UI and build statistics utilities.
"""

from __future__ import annotations

import logging
import os
import sys
import threading
from typing import TYPE_CHECKING, TypedDict, cast

if TYPE_CHECKING:
    import multiprocessing
    import pathlib
    from collections.abc import MutableMapping, Sequence
    from multiprocessing.synchronize import Event as SyncEvent
else:
    SyncEvent = None

from relenv.common import DATA_DIR

from .download import CICD

log = logging.getLogger(__name__)


# ANSI color codes for terminal output
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
RED = "\033[0;31m"
END = "\033[0m"
MOVEUP = "\033[F"


# Detect terminal capabilities for Unicode vs ASCII output
USE_UNICODE = True

# Allow forcing ASCII mode via environment variable (useful for testing/debugging)
if os.environ.get("RELENV_ASCII"):
    USE_UNICODE = False
elif sys.platform == "win32":
    # Check if we're in a modern terminal that supports Unicode
    # Windows Terminal and modern PowerShell support Unicode
    wt_session = os.environ.get("WT_SESSION")
    term_program = os.environ.get("TERM_PROGRAM")
    if not wt_session and not term_program:
        # Likely cmd.exe or old PowerShell, use ASCII
        USE_UNICODE = False


# Spinner frames for in-progress builds
if USE_UNICODE:
    # Modern Unicode spinner (looks great in most terminals)
    SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
    SYMBOL_PENDING = "◯"
    SYMBOL_RUNNING = None  # Will use spinner
    SYMBOL_SUCCESS = "✓"
    SYMBOL_FAILED = "✗"
else:
    # ASCII fallback for Windows cmd.exe
    SPINNER_FRAMES = ["|", "/", "-", "\\"]
    SYMBOL_PENDING = "o"
    SYMBOL_RUNNING = None  # Will use spinner
    SYMBOL_SUCCESS = "+"
    SYMBOL_FAILED = "X"


# Build statistics filename (path computed at runtime)
BUILD_STATS_FILENAME = "build_stats.json"


def _get_build_stats_file() -> pathlib.Path:
    """Get the path to the build statistics file.

    Returns:
        Path to build_stats.json in the relenv data directory.

    Note:
        This is a function rather than a module-level constant to avoid
        import-time dependencies on DATA_DIR, following CPython conventions.
    """
    return DATA_DIR / BUILD_STATS_FILENAME


class SpinnerState:
    """Thread-safe spinner state management.

    Tracks the animation frame index for each named spinner to ensure
    smooth, consistent animation across multiple UI updates.
    """

    def __init__(self) -> None:
        """Initialize empty spinner state with thread safety."""
        self._state: dict[str, int] = {}
        self._lock = threading.Lock()

    def get(self, name: str) -> int:
        """Get the current frame index for a named spinner.

        Args:
            name: The spinner identifier

        Returns:
            The current frame index (0 if spinner hasn't been used yet)
        """
        with self._lock:
            return self._state.get(name, 0)

    def increment(self, name: str) -> None:
        """Increment the frame index for a named spinner.

        Args:
            name: The spinner identifier
        """
        with self._lock:
            self._state[name] = self._state.get(name, 0) + 1

    def reset(self, name: str | None = None) -> None:
        """Reset spinner state.

        Args:
            name: The spinner to reset, or None to reset all spinners
        """
        with self._lock:
            if name is None:
                self._state.clear()
            elif name in self._state:
                del self._state[name]


# Module-level spinner state instance
_spinner_state = SpinnerState()


class BuildStats(TypedDict):
    """Structure for tracking build step statistics."""

    avg_lines: int
    samples: int
    last_lines: int


def print_ui(
    events: MutableMapping[str, multiprocessing.synchronize.Event],
    processes: MutableMapping[str, multiprocessing.Process],
    fails: Sequence[str],
    flipstat: dict[str, tuple[int, float]] | None = None,
) -> None:
    """
    Prints the UI during the relenv building process.

    :param events: A dictionary of events that are updated during the build process
    :type events: dict
    :param processes: A dictionary of build processes
    :type processes: dict
    :param fails: A list of processes that have failed
    :type fails: list
    :param flipstat: Deprecated parameter, no longer used
    :type flipstat: dict, optional
    """
    if CICD:
        sys.stdout.flush()
        return
    uiline = []
    for name in events:
        if not events[name].is_set():
            # Pending: event not yet started
            status = f" {YELLOW}{SYMBOL_PENDING}"
        elif name in processes:
            # Running: show animated spinner
            frame_idx = _spinner_state.get(name) % len(SPINNER_FRAMES)
            spinner = SPINNER_FRAMES[frame_idx]
            _spinner_state.increment(name)
            status = f" {GREEN}{spinner}"
        elif name in fails:
            # Failed: show error symbol
            status = f" {RED}{SYMBOL_FAILED}"
        else:
            # Success: show success symbol
            status = f" {GREEN}{SYMBOL_SUCCESS}"
        uiline.append(status)
    uiline.append("  " + END)
    sys.stdout.write("\r")
    sys.stdout.write("".join(uiline))
    sys.stdout.flush()


def print_ui_expanded(
    events: MutableMapping[str, multiprocessing.synchronize.Event],
    processes: MutableMapping[str, multiprocessing.Process],
    fails: Sequence[str],
    line_counts: MutableMapping[str, int],
    build_stats: dict[str, BuildStats],
    phase: str = "build",
) -> None:
    """
    Prints an expanded UI with progress bars during the relenv building process.

    :param events: A dictionary of events that are updated during the build process
    :type events: dict
    :param processes: A dictionary of build processes
    :type processes: dict
    :param fails: A list of processes that have failed
    :type fails: list
    :param line_counts: Current line counts for each step
    :type line_counts: MutableMapping[str, int]
    :param build_stats: Historical build statistics
    :type build_stats: dict
    :param phase: The current phase ("download" or "build")
    :type phase: str
    """
    if CICD:
        sys.stdout.flush()
        return

    # Track state per phase to handle download->build transitions
    if not hasattr(print_ui_expanded, "_phase_state"):
        print_ui_expanded._phase_state = {}  # type: ignore

    phase_state = print_ui_expanded._phase_state  # type: ignore

    # Number of lines = number of steps + 2 (header + separator)
    num_lines = len(events) + 2

    # If this phase has been called before, move up to overwrite previous output
    if phase in phase_state:
        prev_lines = phase_state[phase]
        # Move up by previous line count to overwrite
        sys.stdout.write(MOVEUP * prev_lines)
    else:
        # First call for this phase - if we're starting builds after downloads,
        # add a newline to separate them
        if phase == "build" and "download" in phase_state:
            sys.stdout.write("\n")

    # Store line count for this phase
    phase_state[phase] = num_lines

    # Clear line and print header
    phase_name = "Downloads" if phase == "download" else "Builds"
    sys.stdout.write("\r\033[K")  # Clear line
    sys.stdout.write(f"{phase_name}\n")
    sys.stdout.write("─" * 70 + "\n")

    # Print each step
    for name in events:
        # Determine status
        if not events[name].is_set():
            # Pending
            status_symbol = f"{YELLOW}{SYMBOL_PENDING}{END}"
            status_text = "Pending"
            progress_bar = ""
        elif name in processes:
            # Running - show spinner and progress
            frame_idx = _spinner_state.get(name) % len(SPINNER_FRAMES)
            spinner = SPINNER_FRAMES[frame_idx]
            _spinner_state.increment(name)
            status_symbol = f"{GREEN}{spinner}{END}"

            # Determine if this is download or build phase
            phase_action = "Downloading" if phase == "download" else "Building"

            # Calculate progress if we have historical data
            current_lines = line_counts.get(name, 0)
            if phase == "download":
                # For downloads, line_counts stores bytes downloaded and total bytes
                # Format: line_counts[name] = downloaded, line_counts[f"{name}_total"] = total
                downloaded = current_lines
                total = line_counts.get(f"{name}_total", 0)
                if total > 0:
                    progress = min(100, int((downloaded / total) * 100))
                    status_text = f"{phase_action} {progress:3d}%"
                    # Progress bar (20 chars wide)
                    filled = int(progress / 5)  # 20 segments = 100% / 5
                    bar = "█" * filled + "░" * (20 - filled) if USE_UNICODE else ("#" * filled + "-" * (20 - filled))
                    progress_bar = f" [{bar}]"
                else:
                    status_text = phase_action
                    progress_bar = ""
            else:
                # For builds, use historical line count data
                if name in build_stats and build_stats[name]["avg_lines"] > 0:
                    avg_lines = build_stats[name]["avg_lines"]
                    progress = min(100, int((current_lines / avg_lines) * 100))
                    status_text = f"{phase_action} {progress:3d}%"

                    # Progress bar (20 chars wide)
                    filled = int(progress / 5)  # 20 segments = 100% / 5
                    bar = "█" * filled + "░" * (20 - filled) if USE_UNICODE else ("#" * filled + "-" * (20 - filled))
                    progress_bar = f" [{bar}]"
                else:
                    status_text = phase_action
                    progress_bar = ""
        elif name in fails:
            # Failed
            status_symbol = f"{RED}{SYMBOL_FAILED}{END}"
            status_text = "Failed"
            progress_bar = ""
        else:
            # Success
            status_symbol = f"{GREEN}{SYMBOL_SUCCESS}{END}"
            status_text = "Done"
            progress_bar = ""

        # Format step name (truncate/pad to 20 chars)
        name_display = f"{name:<20}"[:20]
        status_display = f"{status_text:<12}"

        # Clear line before writing to prevent leftover text
        sys.stdout.write("\r\033[K")
        sys.stdout.write(f"{status_symbol} {name_display} {status_display}{progress_bar}\n")

    sys.stdout.flush()


def load_build_stats() -> dict[str, BuildStats]:
    """
    Load historical build statistics from disk.

    :return: Dictionary mapping step names to their statistics
    :rtype: dict
    """
    stats_file = _get_build_stats_file()
    if not stats_file.exists():
        return {}
    try:
        import json

        with open(stats_file) as f:
            data = json.load(f)
            return cast("dict[str, BuildStats]", data)
    except (OSError, json.JSONDecodeError):
        log.warning("Failed to load build stats, starting fresh")
        return {}


def save_build_stats(stats: dict[str, BuildStats]) -> None:
    """
    Save build statistics to disk.

    :param stats: Dictionary mapping step names to their statistics
    :type stats: dict
    """
    try:
        import json

        stats_file = _get_build_stats_file()
        stats_file.parent.mkdir(parents=True, exist_ok=True)
        with open(stats_file, "w") as f:
            json.dump(stats, f, indent=2)
    except OSError:
        log.warning("Failed to save build stats")


def update_build_stats(step_name: str, line_count: int) -> None:
    """
    Update statistics for a build step with a new sample.

    Uses exponential moving average with weight 0.7 for new samples.

    :param step_name: Name of the build step
    :type step_name: str
    :param line_count: Number of log lines for this build
    :type line_count: int
    """
    stats = load_build_stats()
    if step_name not in stats:
        stats[step_name] = BuildStats(avg_lines=line_count, samples=1, last_lines=line_count)
    else:
        old_avg = stats[step_name]["avg_lines"]
        # Exponential moving average: 70% new value, 30% old average
        new_avg = int(0.7 * line_count + 0.3 * old_avg)
        stats[step_name] = BuildStats(
            avg_lines=new_avg,
            samples=stats[step_name]["samples"] + 1,
            last_lines=line_count,
        )
    save_build_stats(stats)


class LineCountHandler(logging.Handler):
    """
    Custom logging handler that counts log lines for progress tracking.

    This handler increments a counter in a shared multiprocessing dict
    for each log message emitted, enabling real-time progress estimation.
    """

    def __init__(self, step_name: str, shared_dict: MutableMapping[str, int]) -> None:
        """
        Initialize the line count handler.

        :param step_name: Name of the build step being tracked
        :type step_name: str
        :param shared_dict: Multiprocessing-safe dict for sharing counts
        :type shared_dict: MutableMapping[str, int]
        """
        super().__init__()
        self.step_name = step_name
        self.shared_dict = shared_dict

    def emit(self, record: logging.LogRecord) -> None:
        """
        Count each log record as a line.

        :param record: The log record to process
        :type record: logging.LogRecord
        """
        try:
            # Increment line count in shared memory
            current = self.shared_dict.get(self.step_name, 0)
            self.shared_dict[self.step_name] = current + 1
        except Exception:
            # Silently ignore errors in the handler to avoid breaking builds
            pass