"""Terminal, Jupyter and file output for W&B.""" from __future__ import annotations import abc import contextlib import itertools import platform import sys from collections.abc import Iterator from typing import Callable import click from typing_extensions import override import wandb import wandb.util from wandb.errors import term from wandb.sdk import wandb_setup from . import ipython, sparkline # Follow the same logic as the python logging module CRITICAL = 50 FATAL = CRITICAL ERROR = 40 WARNING = 30 WARN = WARNING INFO = 20 DEBUG = 10 NOTSET = 0 _level_to_name = { CRITICAL: "CRITICAL", ERROR: "ERROR", WARNING: "WARNING", INFO: "INFO", DEBUG: "DEBUG", NOTSET: "NOTSET", } _name_to_level = { "CRITICAL": CRITICAL, "FATAL": FATAL, "ERROR": ERROR, "WARN": WARNING, "WARNING": WARNING, "INFO": INFO, "DEBUG": DEBUG, "NOTSET": NOTSET, } _PROGRESS_SYMBOL_ANIMATION = "⢿⣻⣽⣾⣷⣯⣟⡿" """Sequence of characters for a progress spinner. Unicode characters from the Braille Patterns block arranged to form a subtle clockwise spinning animation. """ _PROGRESS_SYMBOL_COLOR = 0xB2 """Color from the 256-color palette for the progress symbol.""" _JUPYTER_TABLE_STYLES = """ """ _JUPYTER_PANEL_STYLES = """ """ def new_printer(settings: wandb.Settings | None = None) -> Printer: """Returns a printer appropriate for the environment we're in. Args: settings: The settings of a run. If not provided and `wandb.setup()` has been called, then global settings are used. Otherwise, settings (such as silent mode) are ignored. """ if not settings and (s := wandb_setup.singleton().settings_if_loaded): settings = s if ipython.in_jupyter(): return _PrinterJupyter(settings=settings) else: return _PrinterTerm(settings=settings) class Printer(abc.ABC): """An object that shows styled text to the user.""" @contextlib.contextmanager @abc.abstractmethod def dynamic_text(self) -> Iterator[DynamicText | None]: """A context manager providing a handle to a block of changeable text. Since `wandb` may be outputting to a terminal, it's important to only use this when `wandb` is performing blocking calls, or else text output by non-`wandb` code may get overwritten. Returns None if dynamic text is not supported, such as if stderr is not a TTY and we're not in a Jupyter notebook. """ @abc.abstractmethod def display( self, text: str | list[str] | tuple[str], *, level: str | int | None = None, ) -> None: """Display text to the user. Args: text: The text to display. If given an iterable of strings, they're joined with newlines. level: The logging level, for controlling verbosity. """ @abc.abstractmethod def progress_update( self, text: str, percent_done: float | None = None, ) -> None: r"""Set the text on the progress indicator. Args: text: The text to set, which must end with \r. percent_done: The current progress, between 0 and 1. """ @abc.abstractmethod def progress_close(self) -> None: """Close the progress indicator. After this, `progress_update` should not be used. """ @staticmethod def _sanitize_level(name_or_level: str | int | None) -> int: """Returns the number corresponding to the logging level. Args: name_or_level: The logging level passed to `display`. Raises: ValueError: if the input is not a valid logging level. """ if isinstance(name_or_level, str): try: return _name_to_level[name_or_level.upper()] except KeyError: raise ValueError( f"Unknown level name: {name_or_level}, supported levels: {_name_to_level.keys()}" ) if isinstance(name_or_level, int): return name_or_level if name_or_level is None: return INFO raise ValueError(f"Unknown status level {name_or_level}") @property @abc.abstractmethod def supports_html(self) -> bool: """Whether text passed to display may contain HTML styling.""" @property @abc.abstractmethod def supports_unicode(self) -> bool: """Whether text passed to display may contain arbitrary Unicode.""" def sparklines(self, series: list[int | float]) -> str | None: """Returns a Unicode art representation of the series of numbers. Also known as "ASCII art", except this uses non-ASCII Unicode characters. Returns None if the output doesn't support Unicode. """ if self.supports_unicode: return sparkline.sparkify(series) else: return None @abc.abstractmethod def code(self, text: str) -> str: """Returns the text styled like code.""" @abc.abstractmethod def name(self, text: str) -> str: """Returns the text styled like a run name.""" @abc.abstractmethod def link(self, link: str, text: str | None = None) -> str: """Returns the text styled like a link. Args: link: The target link. text: The text to show for the link. If not set, or if we're not in an environment that supports clickable links, this is ignored. """ @abc.abstractmethod def secondary_text(self, text: str) -> str: """Returns the text styled to draw less attention.""" @abc.abstractmethod def loading_symbol(self, tick: int) -> str: """Returns a frame of an animated loading symbol. May return an empty string. Args: tick: An index into the animation. """ @abc.abstractmethod def error(self, text: str) -> str: """Returns the text colored like an error.""" @abc.abstractmethod def emoji(self, name: str) -> str: """Returns the string for a named emoji, or an empty string.""" @abc.abstractmethod def files(self, text: str) -> str: """Returns the text styled like a file path.""" @abc.abstractmethod def grid(self, rows: list[list[str]], title: str | None = None) -> str: """Returns a grid of strings with an optional title.""" @abc.abstractmethod def panel(self, columns: list[str]) -> str: """Returns the column text combined in a compact way.""" class DynamicText(abc.ABC): """A handle to a block of text that's allowed to change.""" @abc.abstractmethod def set_text(self, text: str) -> None: r"""Change the text. Args: text: The text to put in the block, with lines separated by \n characters. The text should not end in \n unless a blank line at the end of the block is desired. May include styled output from methods on the Printer that created this. """ class _PrinterTerm(Printer): def __init__(self, *, settings: wandb.Settings | None) -> None: super().__init__() self._settings = settings self._progress = itertools.cycle(["-", "\\", "|", "/"]) @override @contextlib.contextmanager def dynamic_text(self) -> Iterator[DynamicText | None]: if self._settings and self._settings.silent: yield None return with term.dynamic_text() as handle: if not handle: yield None else: yield _DynamicTermText(handle) @override def display( self, text: str | list[str] | tuple[str], *, level: str | int | None = None, ) -> None: if self._settings and self._settings.silent: return text = "\n".join(text) if isinstance(text, (list, tuple)) else text self._display_fn_mapping(level)(text) @staticmethod def _display_fn_mapping(level: str | int | None = None) -> Callable[[str], None]: level = Printer._sanitize_level(level) if level >= CRITICAL: return wandb.termerror elif ERROR <= level < CRITICAL: return wandb.termerror elif WARNING <= level < ERROR: return wandb.termwarn elif INFO <= level < WARNING: return wandb.termlog elif DEBUG <= level < INFO: return wandb.termlog else: return wandb.termlog @override def progress_update(self, text: str, percent_done: float | None = None) -> None: if self._settings and self._settings.silent: return wandb.termlog(f"{next(self._progress)} {text}", newline=False) @override def progress_close(self) -> None: if self._settings and self._settings.silent: return @property @override def supports_html(self) -> bool: return False @property @override def supports_unicode(self) -> bool: return wandb.util.is_unicode_safe(sys.stderr) @override def code(self, text: str) -> str: ret: str = click.style(text, bold=True) return ret @override def name(self, text: str) -> str: ret: str = click.style(text, fg="yellow") return ret @override def link(self, link: str, text: str | None = None) -> str: ret: str = click.style(link, fg="blue", underline=True) # ret = f"\x1b[m{text or link}\x1b[0m" # ret = f"\x1b]8;;{link}\x1b\\{ret}\x1b]8;;\x1b\\" return ret @override def emoji(self, name: str) -> str: emojis = dict() if platform.system() != "Windows" and wandb.util.is_unicode_safe(sys.stdout): emojis = dict( star="⭐️", broom="🧹", rocket="🚀", gorilla="🦍", turtle="🐢", lightning="️⚡", ) return emojis.get(name, "") @override def secondary_text(self, text: str) -> str: # NOTE: "white" is really a light gray, and is usually distinct # from the terminal's foreground color (i.e. default text color) return click.style(text, fg="white") @override def loading_symbol(self, tick: int) -> str: if not self.supports_unicode: return "" idx = tick % len(_PROGRESS_SYMBOL_ANIMATION) return click.style( _PROGRESS_SYMBOL_ANIMATION[idx], fg=_PROGRESS_SYMBOL_COLOR, ) @override def error(self, text: str) -> str: return click.style(text, fg="red") @override def files(self, text: str) -> str: ret: str = click.style(text, fg="magenta", bold=True) return ret @override def grid(self, rows: list[list[str]], title: str | None = None) -> str: max_len = max(len(row[0]) for row in rows) format_row = " ".join(["{:>{max_len}}", "{}" * (len(rows[0]) - 1)]) grid = "\n".join([format_row.format(*row, max_len=max_len) for row in rows]) if title: return f"{title}\n{grid}\n" return f"{grid}\n" @override def panel(self, columns: list[str]) -> str: return "\n" + "\n".join(columns) class _DynamicTermText(DynamicText): def __init__(self, handle: term.DynamicBlock) -> None: self._handle = handle @override def set_text(self, text: str) -> None: self._handle.set_text(text) class _PrinterJupyter(Printer): def __init__(self, *, settings: wandb.Settings | None) -> None: super().__init__() self._settings = settings self._progress = ipython.jupyter_progress_bar() from IPython import display self._ipython_display = display @override @contextlib.contextmanager def dynamic_text(self) -> Iterator[DynamicText | None]: if self._settings and self._settings.silent: yield None return handle = self._ipython_display.display( self._ipython_display.HTML(""), display_id=True, ) if not handle: yield None return try: yield _DynamicJupyterText(handle) finally: handle.update(self._ipython_display.HTML("")) @override def display( self, text: str | list[str] | tuple[str], *, level: str | int | None = None, ) -> None: if self._settings and self._settings.silent: return text = "
".join(text) if isinstance(text, (list, tuple)) else text text = "
".join(text.splitlines()) self._ipython_display.display(self._ipython_display.HTML(text)) @property @override def supports_html(self) -> bool: return True @property @override def supports_unicode(self) -> bool: return True @override def code(self, text: str) -> str: return f"{text}" @override def name(self, text: str) -> str: return f'{text}' @override def link(self, link: str, text: str | None = None) -> str: return f'{text or link}' @override def emoji(self, name: str) -> str: return "" @override def secondary_text(self, text: str) -> str: return text @override def loading_symbol(self, tick: int) -> str: return "" @override def error(self, text: str) -> str: return f'{text}' @override def files(self, text: str) -> str: return f"{text}" @override def progress_update( self, text: str, percent_done: float | None = None, ) -> None: if (self._settings and self._settings.silent) or not self._progress: return if percent_done is None: percent_done = 1.0 self._progress.update(percent_done, text) @override def progress_close(self) -> None: if self._progress: self._progress.close() @override def grid(self, rows: list[list[str]], title: str | None = None) -> str: format_row = "".join(["", "{}" * len(rows[0]), ""]) grid = "".join([format_row.format(*row) for row in rows]) grid = f'{grid}
' if title: return f"

{title}


{grid}
" return f"{_JUPYTER_TABLE_STYLES}{grid}
" @override def panel(self, columns: list[str]) -> str: row = "".join([f'
{col}
' for col in columns]) return f'{_JUPYTER_PANEL_STYLES}
{row}
' class _DynamicJupyterText(DynamicText): def __init__(self, handle) -> None: from IPython import display self._ipython_to_html = display.HTML self._handle: display.DisplayHandle = handle @override def set_text(self, text: str) -> None: text = "
".join(text.splitlines()) self._handle.update(self._ipython_to_html(text))