printer.py 16 KB

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