printer.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. """Terminal, Jupyter and file output for W&B."""
  2. from __future__ import annotations
  3. import abc
  4. import contextlib
  5. import itertools
  6. import platform
  7. import sys
  8. from collections.abc import Iterator
  9. from typing import Callable
  10. import click
  11. from typing_extensions import override
  12. import wandb
  13. import wandb.util
  14. from wandb.errors import term
  15. from wandb.sdk import wandb_setup
  16. from . import ipython, sparkline
  17. # Follow the same logic as the python logging module
  18. CRITICAL = 50
  19. FATAL = CRITICAL
  20. ERROR = 40
  21. WARNING = 30
  22. WARN = WARNING
  23. INFO = 20
  24. DEBUG = 10
  25. NOTSET = 0
  26. _level_to_name = {
  27. CRITICAL: "CRITICAL",
  28. ERROR: "ERROR",
  29. WARNING: "WARNING",
  30. INFO: "INFO",
  31. DEBUG: "DEBUG",
  32. NOTSET: "NOTSET",
  33. }
  34. _name_to_level = {
  35. "CRITICAL": CRITICAL,
  36. "FATAL": FATAL,
  37. "ERROR": ERROR,
  38. "WARN": WARNING,
  39. "WARNING": WARNING,
  40. "INFO": INFO,
  41. "DEBUG": DEBUG,
  42. "NOTSET": NOTSET,
  43. }
  44. _PROGRESS_SYMBOL_ANIMATION = "⢿⣻⣽⣾⣷⣯⣟⡿"
  45. """Sequence of characters for a progress spinner.
  46. Unicode characters from the Braille Patterns block arranged
  47. to form a subtle clockwise spinning animation.
  48. """
  49. _PROGRESS_SYMBOL_COLOR = 0xB2
  50. """Color from the 256-color palette for the progress symbol."""
  51. _JUPYTER_TABLE_STYLES = """
  52. <style>
  53. table.wandb td:nth-child(1) {
  54. padding: 0 10px;
  55. text-align: left;
  56. width: auto;
  57. }
  58. table.wandb td:nth-child(2) {
  59. text-align: left;
  60. width: 100%;
  61. }
  62. </style>
  63. """
  64. _JUPYTER_PANEL_STYLES = """
  65. <style>
  66. .wandb-row {
  67. display: flex;
  68. flex-direction: row;
  69. flex-wrap: wrap;
  70. justify-content: flex-start;
  71. width: 100%;
  72. }
  73. .wandb-col {
  74. display: flex;
  75. flex-direction: column;
  76. flex-basis: 100%;
  77. flex: 1;
  78. padding: 10px;
  79. }
  80. </style>
  81. """
  82. def new_printer(settings: wandb.Settings | None = None) -> Printer:
  83. """Returns a printer appropriate for the environment we're in.
  84. Args:
  85. settings: The settings of a run. If not provided and `wandb.setup()`
  86. has been called, then global settings are used. Otherwise,
  87. settings (such as silent mode) are ignored.
  88. """
  89. if not settings and (s := wandb_setup.singleton().settings_if_loaded):
  90. settings = s
  91. if ipython.in_jupyter():
  92. return _PrinterJupyter(settings=settings)
  93. else:
  94. return _PrinterTerm(settings=settings)
  95. class Printer(abc.ABC):
  96. """An object that shows styled text to the user."""
  97. @contextlib.contextmanager
  98. @abc.abstractmethod
  99. def dynamic_text(self) -> Iterator[DynamicText | None]:
  100. """A context manager providing a handle to a block of changeable text.
  101. Since `wandb` may be outputting to a terminal, it's important to only
  102. use this when `wandb` is performing blocking calls, or else text output
  103. by non-`wandb` code may get overwritten.
  104. Returns None if dynamic text is not supported, such as if stderr is not
  105. a TTY and we're not in a Jupyter notebook.
  106. """
  107. @abc.abstractmethod
  108. def display(
  109. self,
  110. text: str | list[str] | tuple[str],
  111. *,
  112. level: str | int | None = None,
  113. ) -> None:
  114. """Display text to the user.
  115. Args:
  116. text: The text to display. If given an iterable of strings, they're
  117. joined with newlines.
  118. level: The logging level, for controlling verbosity.
  119. """
  120. @abc.abstractmethod
  121. def progress_update(
  122. self,
  123. text: str,
  124. percent_done: float | None = None,
  125. ) -> None:
  126. r"""Set the text on the progress indicator.
  127. Args:
  128. text: The text to set, which must end with \r.
  129. percent_done: The current progress, between 0 and 1.
  130. """
  131. @abc.abstractmethod
  132. def progress_close(self) -> None:
  133. """Close the progress indicator.
  134. After this, `progress_update` should not be used.
  135. """
  136. @staticmethod
  137. def _sanitize_level(name_or_level: str | int | None) -> int:
  138. """Returns the number corresponding to the logging level.
  139. Args:
  140. name_or_level: The logging level passed to `display`.
  141. Raises:
  142. ValueError: if the input is not a valid logging level.
  143. """
  144. if isinstance(name_or_level, str):
  145. try:
  146. return _name_to_level[name_or_level.upper()]
  147. except KeyError:
  148. raise ValueError(
  149. f"Unknown level name: {name_or_level}, supported levels: {_name_to_level.keys()}"
  150. )
  151. if isinstance(name_or_level, int):
  152. return name_or_level
  153. if name_or_level is None:
  154. return INFO
  155. raise ValueError(f"Unknown status level {name_or_level}")
  156. @property
  157. @abc.abstractmethod
  158. def supports_html(self) -> bool:
  159. """Whether text passed to display may contain HTML styling."""
  160. @property
  161. @abc.abstractmethod
  162. def supports_unicode(self) -> bool:
  163. """Whether text passed to display may contain arbitrary Unicode."""
  164. def sparklines(self, series: list[int | float]) -> str | None:
  165. """Returns a Unicode art representation of the series of numbers.
  166. Also known as "ASCII art", except this uses non-ASCII
  167. Unicode characters.
  168. Returns None if the output doesn't support Unicode.
  169. """
  170. if self.supports_unicode:
  171. return sparkline.sparkify(series)
  172. else:
  173. return None
  174. @abc.abstractmethod
  175. def code(self, text: str) -> str:
  176. """Returns the text styled like code."""
  177. @abc.abstractmethod
  178. def name(self, text: str) -> str:
  179. """Returns the text styled like a run name."""
  180. @abc.abstractmethod
  181. def link(self, link: str, text: str | None = None) -> str:
  182. """Returns the text styled like a link.
  183. Args:
  184. link: The target link.
  185. text: The text to show for the link. If not set, or if we're not
  186. in an environment that supports clickable links,
  187. this is ignored.
  188. """
  189. @abc.abstractmethod
  190. def secondary_text(self, text: str) -> str:
  191. """Returns the text styled to draw less attention."""
  192. @abc.abstractmethod
  193. def loading_symbol(self, tick: int) -> str:
  194. """Returns a frame of an animated loading symbol.
  195. May return an empty string.
  196. Args:
  197. tick: An index into the animation.
  198. """
  199. @abc.abstractmethod
  200. def error(self, text: str) -> str:
  201. """Returns the text colored like an error."""
  202. @abc.abstractmethod
  203. def emoji(self, name: str) -> str:
  204. """Returns the string for a named emoji, or an empty string."""
  205. @abc.abstractmethod
  206. def files(self, text: str) -> str:
  207. """Returns the text styled like a file path."""
  208. @abc.abstractmethod
  209. def grid(self, rows: list[list[str]], title: str | None = None) -> str:
  210. """Returns a grid of strings with an optional title."""
  211. @abc.abstractmethod
  212. def panel(self, columns: list[str]) -> str:
  213. """Returns the column text combined in a compact way."""
  214. class DynamicText(abc.ABC):
  215. """A handle to a block of text that's allowed to change."""
  216. @abc.abstractmethod
  217. def set_text(self, text: str) -> None:
  218. r"""Change the text.
  219. Args:
  220. text: The text to put in the block, with lines separated
  221. by \n characters. The text should not end in \n unless
  222. a blank line at the end of the block is desired.
  223. May include styled output from methods on the Printer
  224. that created this.
  225. """
  226. class _PrinterTerm(Printer):
  227. def __init__(self, *, settings: wandb.Settings | None) -> None:
  228. super().__init__()
  229. self._settings = settings
  230. self._progress = itertools.cycle(["-", "\\", "|", "/"])
  231. @override
  232. @contextlib.contextmanager
  233. def dynamic_text(self) -> Iterator[DynamicText | None]:
  234. if self._settings and self._settings.silent:
  235. yield None
  236. return
  237. with term.dynamic_text() as handle:
  238. if not handle:
  239. yield None
  240. else:
  241. yield _DynamicTermText(handle)
  242. @override
  243. def display(
  244. self,
  245. text: str | list[str] | tuple[str],
  246. *,
  247. level: str | int | None = None,
  248. ) -> None:
  249. if self._settings and self._settings.silent:
  250. return
  251. text = "\n".join(text) if isinstance(text, (list, tuple)) else text
  252. self._display_fn_mapping(level)(text)
  253. @staticmethod
  254. def _display_fn_mapping(level: str | int | None = None) -> Callable[[str], None]:
  255. level = Printer._sanitize_level(level)
  256. if level >= CRITICAL:
  257. return wandb.termerror
  258. elif ERROR <= level < CRITICAL:
  259. return wandb.termerror
  260. elif WARNING <= level < ERROR:
  261. return wandb.termwarn
  262. elif INFO <= level < WARNING:
  263. return wandb.termlog
  264. elif DEBUG <= level < INFO:
  265. return wandb.termlog
  266. else:
  267. return wandb.termlog
  268. @override
  269. def progress_update(self, text: str, percent_done: float | None = None) -> None:
  270. if self._settings and self._settings.silent:
  271. return
  272. wandb.termlog(f"{next(self._progress)} {text}", newline=False)
  273. @override
  274. def progress_close(self) -> None:
  275. if self._settings and self._settings.silent:
  276. return
  277. @property
  278. @override
  279. def supports_html(self) -> bool:
  280. return False
  281. @property
  282. @override
  283. def supports_unicode(self) -> bool:
  284. return wandb.util.is_unicode_safe(sys.stderr)
  285. @override
  286. def code(self, text: str) -> str:
  287. ret: str = click.style(text, bold=True)
  288. return ret
  289. @override
  290. def name(self, text: str) -> str:
  291. ret: str = click.style(text, fg="yellow")
  292. return ret
  293. @override
  294. def link(self, link: str, text: str | None = None) -> str:
  295. ret: str = click.style(link, fg="blue", underline=True)
  296. # ret = f"\x1b[m{text or link}\x1b[0m"
  297. # ret = f"\x1b]8;;{link}\x1b\\{ret}\x1b]8;;\x1b\\"
  298. return ret
  299. @override
  300. def emoji(self, name: str) -> str:
  301. emojis = dict()
  302. if platform.system() != "Windows" and wandb.util.is_unicode_safe(sys.stdout):
  303. emojis = dict(
  304. star="⭐️",
  305. broom="🧹",
  306. rocket="🚀",
  307. gorilla="🦍",
  308. turtle="🐢",
  309. lightning="️⚡",
  310. )
  311. return emojis.get(name, "")
  312. @override
  313. def secondary_text(self, text: str) -> str:
  314. # NOTE: "white" is really a light gray, and is usually distinct
  315. # from the terminal's foreground color (i.e. default text color)
  316. return click.style(text, fg="white")
  317. @override
  318. def loading_symbol(self, tick: int) -> str:
  319. if not self.supports_unicode:
  320. return ""
  321. idx = tick % len(_PROGRESS_SYMBOL_ANIMATION)
  322. return click.style(
  323. _PROGRESS_SYMBOL_ANIMATION[idx],
  324. fg=_PROGRESS_SYMBOL_COLOR,
  325. )
  326. @override
  327. def error(self, text: str) -> str:
  328. return click.style(text, fg="red")
  329. @override
  330. def files(self, text: str) -> str:
  331. ret: str = click.style(text, fg="magenta", bold=True)
  332. return ret
  333. @override
  334. def grid(self, rows: list[list[str]], title: str | None = None) -> str:
  335. max_len = max(len(row[0]) for row in rows)
  336. format_row = " ".join(["{:>{max_len}}", "{}" * (len(rows[0]) - 1)])
  337. grid = "\n".join([format_row.format(*row, max_len=max_len) for row in rows])
  338. if title:
  339. return f"{title}\n{grid}\n"
  340. return f"{grid}\n"
  341. @override
  342. def panel(self, columns: list[str]) -> str:
  343. return "\n" + "\n".join(columns)
  344. class _DynamicTermText(DynamicText):
  345. def __init__(self, handle: term.DynamicBlock) -> None:
  346. self._handle = handle
  347. @override
  348. def set_text(self, text: str) -> None:
  349. self._handle.set_text(text)
  350. class _PrinterJupyter(Printer):
  351. def __init__(self, *, settings: wandb.Settings | None) -> None:
  352. super().__init__()
  353. self._settings = settings
  354. self._progress = ipython.jupyter_progress_bar()
  355. from IPython import display
  356. self._ipython_display = display
  357. @override
  358. @contextlib.contextmanager
  359. def dynamic_text(self) -> Iterator[DynamicText | None]:
  360. if self._settings and self._settings.silent:
  361. yield None
  362. return
  363. handle = self._ipython_display.display(
  364. self._ipython_display.HTML(""),
  365. display_id=True,
  366. )
  367. if not handle:
  368. yield None
  369. return
  370. try:
  371. yield _DynamicJupyterText(handle)
  372. finally:
  373. handle.update(self._ipython_display.HTML(""))
  374. @override
  375. def display(
  376. self,
  377. text: str | list[str] | tuple[str],
  378. *,
  379. level: str | int | None = None,
  380. ) -> None:
  381. if self._settings and self._settings.silent:
  382. return
  383. text = "<br>".join(text) if isinstance(text, (list, tuple)) else text
  384. text = "<br>".join(text.splitlines())
  385. self._ipython_display.display(self._ipython_display.HTML(text))
  386. @property
  387. @override
  388. def supports_html(self) -> bool:
  389. return True
  390. @property
  391. @override
  392. def supports_unicode(self) -> bool:
  393. return True
  394. @override
  395. def code(self, text: str) -> str:
  396. return f"<code>{text}<code>"
  397. @override
  398. def name(self, text: str) -> str:
  399. return f'<strong style="color:#cdcd00">{text}</strong>'
  400. @override
  401. def link(self, link: str, text: str | None = None) -> str:
  402. return f'<a href={link!r} target="_blank">{text or link}</a>'
  403. @override
  404. def emoji(self, name: str) -> str:
  405. return ""
  406. @override
  407. def secondary_text(self, text: str) -> str:
  408. return text
  409. @override
  410. def loading_symbol(self, tick: int) -> str:
  411. return ""
  412. @override
  413. def error(self, text: str) -> str:
  414. return f'<strong style="color:red">{text}</strong>'
  415. @override
  416. def files(self, text: str) -> str:
  417. return f"<code>{text}</code>"
  418. @override
  419. def progress_update(
  420. self,
  421. text: str,
  422. percent_done: float | None = None,
  423. ) -> None:
  424. if (self._settings and self._settings.silent) or not self._progress:
  425. return
  426. if percent_done is None:
  427. percent_done = 1.0
  428. self._progress.update(percent_done, text)
  429. @override
  430. def progress_close(self) -> None:
  431. if self._progress:
  432. self._progress.close()
  433. @override
  434. def grid(self, rows: list[list[str]], title: str | None = None) -> str:
  435. format_row = "".join(["<tr>", "<td>{}</td>" * len(rows[0]), "</tr>"])
  436. grid = "".join([format_row.format(*row) for row in rows])
  437. grid = f'<table class="wandb">{grid}</table>'
  438. if title:
  439. return f"<h3>{title}</h3><br/>{grid}<br/>"
  440. return f"{_JUPYTER_TABLE_STYLES}{grid}<br/>"
  441. @override
  442. def panel(self, columns: list[str]) -> str:
  443. row = "".join([f'<div class="wandb-col">{col}</div>' for col in columns])
  444. return f'{_JUPYTER_PANEL_STYLES}<div class="wandb-row">{row}</div>'
  445. class _DynamicJupyterText(DynamicText):
  446. def __init__(self, handle) -> None:
  447. from IPython import display
  448. self._ipython_to_html = display.HTML
  449. self._handle: display.DisplayHandle = handle
  450. @override
  451. def set_text(self, text: str) -> None:
  452. text = "<br>".join(text.splitlines())
  453. self._handle.update(self._ipython_to_html(text))