cli_logger.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825
  1. """Logger implementing the Command Line Interface.
  2. A replacement for the standard Python `logging` API
  3. designed for implementing a better CLI UX for the cluster launcher.
  4. Supports color, bold text, italics, underlines, etc.
  5. (depending on TTY features)
  6. as well as indentation and other structured output.
  7. """
  8. import inspect
  9. import logging
  10. import os
  11. import sys
  12. import time
  13. from contextlib import contextmanager
  14. from functools import wraps
  15. from typing import Any, Callable, Dict, List, Optional, Tuple
  16. import click
  17. import colorama
  18. # Import ray first to use the bundled colorama
  19. import ray # noqa: F401
  20. if sys.platform == "win32":
  21. import msvcrt
  22. else:
  23. import select
  24. class _ColorfulMock:
  25. def __init__(self):
  26. # do not do any color work
  27. self.identity = lambda x: x
  28. self.colorful = self
  29. self.colormode = None
  30. self.NO_COLORS = None
  31. self.ANSI_8_COLORS = None
  32. def disable(self):
  33. pass
  34. @contextmanager
  35. def with_style(self, x):
  36. class IdentityClass:
  37. def __getattr__(self, name):
  38. return lambda y: y
  39. yield IdentityClass()
  40. def __getattr__(self, name):
  41. if name == "with_style":
  42. return self.with_style
  43. return self.identity
  44. try:
  45. import colorful as _cf
  46. from colorful.core import ColorfulString
  47. _cf.use_8_ansi_colors()
  48. except ModuleNotFoundError:
  49. # We mock Colorful to restrict the colors used for consistency
  50. # anyway, so we also allow for not having colorful at all.
  51. # If the Ray Core dependency on colorful is ever removed,
  52. # the CliLogger code will still work.
  53. class ColorfulString:
  54. pass
  55. _cf = _ColorfulMock()
  56. # We want to only allow specific formatting
  57. # to prevent people from accidentally making bad looking color schemes.
  58. #
  59. # This is especially important since most will look bad on either light
  60. # or dark themes.
  61. class _ColorfulProxy:
  62. _proxy_allowlist = [
  63. "disable",
  64. "reset",
  65. "bold",
  66. "italic",
  67. "underlined",
  68. # used instead of `gray` as `dimmed` adapts to
  69. # both light and dark themes
  70. "dimmed",
  71. "dodgerBlue", # group
  72. "limeGreen", # success
  73. "red", # error
  74. "orange", # warning
  75. "skyBlue", # label
  76. "magenta", # syntax highlighting key words and symbols
  77. "yellow", # syntax highlighting strings
  78. ]
  79. def __getattr__(self, name):
  80. res = getattr(_cf, name)
  81. if callable(res) and name not in _ColorfulProxy._proxy_allowlist:
  82. raise ValueError(
  83. "Usage of the colorful method '" + name + "' is forbidden "
  84. "by the proxy to keep a consistent color scheme. "
  85. "Check `cli_logger.py` for allowed methods"
  86. )
  87. return res
  88. cf = _ColorfulProxy()
  89. colorama.init(strip=False)
  90. def _external_caller_info():
  91. """Get the info from the caller frame.
  92. Used to override the logging function and line number with the correct
  93. ones. See the comment on _patched_makeRecord for more info.
  94. """
  95. frame = inspect.currentframe()
  96. caller = frame
  97. levels = 0
  98. while caller.f_code.co_filename == __file__:
  99. caller = caller.f_back
  100. levels += 1
  101. return {
  102. "lineno": caller.f_lineno,
  103. "filename": os.path.basename(caller.f_code.co_filename),
  104. }
  105. def _format_msg(
  106. msg: str,
  107. *args: Any,
  108. no_format: bool = None,
  109. _tags: Dict[str, Any] = None,
  110. _numbered: Tuple[str, int, int] = None,
  111. **kwargs: Any,
  112. ):
  113. """Formats a message for printing.
  114. Renders `msg` using the built-in `str.format` and the passed-in
  115. `*args` and `**kwargs`.
  116. Args:
  117. *args (Any): `.format` arguments for `msg`.
  118. no_format (bool):
  119. If `no_format` is `True`,
  120. `.format` will not be called on the message.
  121. Useful if the output is user-provided or may otherwise
  122. contain an unexpected formatting string (e.g. "{}").
  123. _tags (Dict[str, Any]):
  124. key-value pairs to display at the end of
  125. the message in square brackets.
  126. If a tag is set to `True`, it is printed without the value,
  127. the presence of the tag treated as a "flag".
  128. E.g. `_format_msg("hello", _tags=dict(from=mom, signed=True))`
  129. `hello [from=Mom, signed]`
  130. _numbered (Tuple[str, int, int]):
  131. `(brackets, i, n)`
  132. The `brackets` string is composed of two "bracket" characters,
  133. `i` is the index, `n` is the total.
  134. The string `{i}/{n}` surrounded by the "brackets" is
  135. prepended to the message.
  136. This is used to number steps in a procedure, with different
  137. brackets specifying different major tasks.
  138. E.g. `_format_msg("hello", _numbered=("[]", 0, 5))`
  139. `[0/5] hello`
  140. Returns:
  141. The formatted message.
  142. """
  143. if isinstance(msg, str) or isinstance(msg, ColorfulString):
  144. tags_str = ""
  145. if _tags is not None:
  146. tags_list = []
  147. for k, v in _tags.items():
  148. if v is True:
  149. tags_list += [k]
  150. continue
  151. if v is False:
  152. continue
  153. tags_list += [k + "=" + v]
  154. if tags_list:
  155. tags_str = cf.reset(cf.dimmed(" [{}]".format(", ".join(tags_list))))
  156. numbering_str = ""
  157. if _numbered is not None:
  158. chars, i, n = _numbered
  159. numbering_str = cf.dimmed(chars[0] + str(i) + "/" + str(n) + chars[1]) + " "
  160. if no_format:
  161. # todo: throw if given args/kwargs?
  162. return numbering_str + msg + tags_str
  163. return numbering_str + msg.format(*args, **kwargs) + tags_str
  164. if kwargs:
  165. raise ValueError("We do not support printing kwargs yet.")
  166. res = [msg, *args]
  167. res = [str(x) for x in res]
  168. return ", ".join(res)
  169. # TODO: come up with a plan to unify logging.
  170. # formatter = logging.Formatter(
  171. # # TODO(maximsmol): figure out the required log level padding
  172. # # width automatically
  173. # fmt="[{asctime}] {levelname:6} {message}",
  174. # datefmt="%x %X",
  175. # # We want alignment on our level names
  176. # style="{")
  177. def _isatty():
  178. """More robust check for interactive terminal/tty."""
  179. try:
  180. # https://stackoverflow.com/questions/6108330/
  181. # checking-for-interactive-shell-in-a-python-script
  182. return sys.__stdin__.isatty()
  183. except Exception:
  184. # sometimes this can fail due to closed output
  185. # either way, no-tty is generally safe fallback.
  186. return False
  187. class _CliLogger:
  188. """Singleton class for CLI logging.
  189. Without calling 'cli_logger.configure', the CLILogger will default
  190. to 'record' style logging.
  191. Attributes:
  192. color_mode (str):
  193. Can be "true", "false", or "auto".
  194. Enables or disables `colorful`.
  195. If `color_mode` is "auto", is set to `not stdout.isatty()`
  196. indent_level (int):
  197. The current indentation level.
  198. All messages will be indented by prepending `" " * indent_level`
  199. vebosity (int):
  200. Output verbosity.
  201. Low verbosity will disable `verbose` and `very_verbose` messages.
  202. """
  203. color_mode: str
  204. # color_mode: Union[Literal["auto"], Literal["false"], Literal["true"]]
  205. indent_level: int
  206. interactive: bool
  207. VALID_LOG_STYLES = ("auto", "record", "pretty")
  208. _autodetected_cf_colormode: int
  209. def __init__(self):
  210. self.indent_level = 0
  211. self._verbosity = 0
  212. self._verbosity_overriden = False
  213. self._color_mode = "auto"
  214. self._log_style = "record"
  215. self.pretty = False
  216. self.interactive = False
  217. # store whatever colorful has detected for future use if
  218. # the color ouput is toggled (colorful detects # of supported colors,
  219. # so it has some non-trivial logic to determine this)
  220. self._autodetected_cf_colormode = cf.colorful.colormode
  221. self.set_format()
  222. def set_format(self, format_tmpl=None):
  223. if not format_tmpl:
  224. from ray.autoscaler._private.constants import LOGGER_FORMAT
  225. format_tmpl = LOGGER_FORMAT
  226. self._formatter = logging.Formatter(format_tmpl)
  227. def configure(self, log_style=None, color_mode=None, verbosity=None):
  228. """Configures the logger according to values."""
  229. if log_style is not None:
  230. self._set_log_style(log_style)
  231. if color_mode is not None:
  232. self._set_color_mode(color_mode)
  233. if verbosity is not None:
  234. self._set_verbosity(verbosity)
  235. self.detect_colors()
  236. @property
  237. def log_style(self):
  238. return self._log_style
  239. def _set_log_style(self, x):
  240. """Configures interactivity and formatting."""
  241. self._log_style = x.lower()
  242. self.interactive = _isatty()
  243. if self._log_style == "auto":
  244. self.pretty = _isatty()
  245. elif self._log_style == "record":
  246. self.pretty = False
  247. self._set_color_mode("false")
  248. elif self._log_style == "pretty":
  249. self.pretty = True
  250. @property
  251. def color_mode(self):
  252. return self._color_mode
  253. def _set_color_mode(self, x):
  254. self._color_mode = x.lower()
  255. self.detect_colors()
  256. @property
  257. def verbosity(self):
  258. if self._verbosity_overriden:
  259. return self._verbosity
  260. elif not self.pretty:
  261. return 999
  262. return self._verbosity
  263. def _set_verbosity(self, x):
  264. self._verbosity = x
  265. self._verbosity_overriden = True
  266. def detect_colors(self):
  267. """Update color output settings.
  268. Parse the `color_mode` string and optionally disable or force-enable
  269. color output
  270. (8-color ANSI if no terminal detected to be safe) in colorful.
  271. """
  272. if self.color_mode == "true":
  273. if self._autodetected_cf_colormode != cf.NO_COLORS:
  274. cf.colormode = self._autodetected_cf_colormode
  275. else:
  276. cf.colormode = cf.ANSI_8_COLORS
  277. return
  278. if self.color_mode == "false":
  279. cf.disable()
  280. return
  281. if self.color_mode == "auto":
  282. # colorful autodetects tty settings
  283. return
  284. raise ValueError("Invalid log color setting: " + self.color_mode)
  285. def newline(self):
  286. """Print a line feed."""
  287. self.print("")
  288. def _print(
  289. self,
  290. msg: str,
  291. _level_str: str = "INFO",
  292. _linefeed: bool = True,
  293. end: str = None,
  294. ):
  295. """Proxy for printing messages.
  296. Args:
  297. msg: Message to print.
  298. linefeed (bool):
  299. If `linefeed` is `False` no linefeed is printed at the
  300. end of the message.
  301. """
  302. if self.pretty:
  303. rendered_message = " " * self.indent_level + msg
  304. else:
  305. if msg.strip() == "":
  306. return
  307. caller_info = _external_caller_info()
  308. record = logging.LogRecord(
  309. name="cli",
  310. # We override the level name later
  311. # TODO(maximsmol): give approximate level #s to our log levels
  312. level=0,
  313. # The user-facing logs do not need this information anyway
  314. # and it would be very tedious to extract since _print
  315. # can be at varying depths in the call stack
  316. # TODO(maximsmol): do it anyway to be extra
  317. pathname=caller_info["filename"],
  318. lineno=caller_info["lineno"],
  319. msg=msg,
  320. args={},
  321. # No exception
  322. exc_info=None,
  323. )
  324. record.levelname = _level_str
  325. rendered_message = self._formatter.format(record)
  326. # We aren't using standard python logging convention, so we hardcode
  327. # the log levels for now.
  328. if _level_str in ["WARNING", "ERROR", "PANIC"]:
  329. stream = sys.stderr
  330. else:
  331. stream = sys.stdout
  332. if not _linefeed:
  333. stream.write(rendered_message)
  334. stream.flush()
  335. return
  336. kwargs = {"end": end}
  337. print(rendered_message, file=stream, **kwargs)
  338. def indented(self):
  339. """Context manager that starts an indented block of output."""
  340. cli_logger = self
  341. class IndentedContextManager:
  342. def __enter__(self):
  343. cli_logger.indent_level += 1
  344. def __exit__(self, type, value, tb):
  345. cli_logger.indent_level -= 1
  346. return IndentedContextManager()
  347. def group(self, msg: str, *args: Any, **kwargs: Any):
  348. """Print a group title in a special color and start an indented block.
  349. For arguments, see `_format_msg`.
  350. """
  351. self.print(cf.dodgerBlue(msg), *args, **kwargs)
  352. return self.indented()
  353. def verbatim_error_ctx(self, msg: str, *args: Any, **kwargs: Any):
  354. """Context manager for printing multi-line error messages.
  355. Displays a start sequence "!!! {optional message}"
  356. and a matching end sequence "!!!".
  357. The string "!!!" can be used as a "tombstone" for searching.
  358. For arguments, see `_format_msg`.
  359. """
  360. cli_logger = self
  361. class VerbatimErorContextManager:
  362. def __enter__(self):
  363. cli_logger.error(cf.bold("!!! ") + "{}", msg, *args, **kwargs)
  364. def __exit__(self, type, value, tb):
  365. cli_logger.error(cf.bold("!!!"))
  366. return VerbatimErorContextManager()
  367. def labeled_value(self, key: str, msg: str, *args: Any, **kwargs: Any):
  368. """Displays a key-value pair with special formatting.
  369. Args:
  370. key: Label that is prepended to the message.
  371. For other arguments, see `_format_msg`.
  372. """
  373. self._print(cf.skyBlue(key) + ": " + _format_msg(cf.bold(msg), *args, **kwargs))
  374. def verbose(self, msg: str, *args: Any, **kwargs: Any):
  375. """Prints a message if verbosity is not 0.
  376. For arguments, see `_format_msg`.
  377. """
  378. if self.verbosity > 0:
  379. self.print(msg, *args, _level_str="VINFO", **kwargs)
  380. def verbose_warning(self, msg, *args, **kwargs):
  381. """Prints a formatted warning if verbosity is not 0.
  382. For arguments, see `_format_msg`.
  383. """
  384. if self.verbosity > 0:
  385. self._warning(msg, *args, _level_str="VWARN", **kwargs)
  386. def verbose_error(self, msg: str, *args: Any, **kwargs: Any):
  387. """Logs an error if verbosity is not 0.
  388. For arguments, see `_format_msg`.
  389. """
  390. if self.verbosity > 0:
  391. self._error(msg, *args, _level_str="VERR", **kwargs)
  392. def very_verbose(self, msg: str, *args: Any, **kwargs: Any):
  393. """Prints if verbosity is > 1.
  394. For arguments, see `_format_msg`.
  395. """
  396. if self.verbosity > 1:
  397. self.print(msg, *args, _level_str="VVINFO", **kwargs)
  398. def success(self, msg: str, *args: Any, **kwargs: Any):
  399. """Prints a formatted success message.
  400. For arguments, see `_format_msg`.
  401. """
  402. self.print(cf.limeGreen(msg), *args, _level_str="SUCC", **kwargs)
  403. def _warning(self, msg: str, *args: Any, _level_str: str = None, **kwargs: Any):
  404. """Prints a formatted warning message.
  405. For arguments, see `_format_msg`.
  406. """
  407. if _level_str is None:
  408. raise ValueError("Log level not set.")
  409. self.print(cf.orange(msg), *args, _level_str=_level_str, **kwargs)
  410. def warning(self, *args, **kwargs):
  411. self._warning(*args, _level_str="WARN", **kwargs)
  412. def _error(self, msg: str, *args: Any, _level_str: str = None, **kwargs: Any):
  413. """Prints a formatted error message.
  414. For arguments, see `_format_msg`.
  415. """
  416. if _level_str is None:
  417. raise ValueError("Log level not set.")
  418. self.print(cf.red(msg), *args, _level_str=_level_str, **kwargs)
  419. def error(self, *args, **kwargs):
  420. self._error(*args, _level_str="ERR", **kwargs)
  421. def panic(self, *args, **kwargs):
  422. self._error(*args, _level_str="PANIC", **kwargs)
  423. # Fine to expose _level_str here, since this is a general log function.
  424. def print(
  425. self,
  426. msg: str,
  427. *args: Any,
  428. _level_str: str = "INFO",
  429. end: str = None,
  430. **kwargs: Any,
  431. ):
  432. """Prints a message.
  433. For arguments, see `_format_msg`.
  434. """
  435. self._print(_format_msg(msg, *args, **kwargs), _level_str=_level_str, end=end)
  436. def info(self, msg: str, no_format=True, *args, **kwargs):
  437. self.print(msg, no_format=no_format, *args, **kwargs)
  438. def abort(
  439. self, msg: Optional[str] = None, *args: Any, exc: Any = None, **kwargs: Any
  440. ):
  441. """Prints an error and aborts execution.
  442. Print an error and throw an exception to terminate the program
  443. (the exception will not print a message).
  444. """
  445. if msg is not None:
  446. self._error(msg, *args, _level_str="PANIC", **kwargs)
  447. if exc is not None:
  448. raise exc
  449. exc_cls = click.ClickException
  450. if self.pretty:
  451. exc_cls = SilentClickException
  452. if msg is None:
  453. msg = "Exiting due to cli_logger.abort()"
  454. raise exc_cls(msg)
  455. def doassert(self, val: bool, msg: str, *args: Any, **kwargs: Any):
  456. """Handle assertion without throwing a scary exception.
  457. Args:
  458. val: Value to check.
  459. For other arguments, see `_format_msg`.
  460. """
  461. if not val:
  462. exc = None
  463. if not self.pretty:
  464. exc = AssertionError()
  465. # TODO(maximsmol): rework asserts so that we get the expression
  466. # that triggered the assert
  467. # to do this, install a global try-catch
  468. # for AssertionError and raise them normally
  469. self.abort(msg, *args, exc=exc, **kwargs)
  470. def render_list(self, xs: List[str], separator: str = cf.reset(", ")):
  471. """Render a list of bolded values using a non-bolded separator."""
  472. return separator.join([str(cf.bold(x)) for x in xs])
  473. def confirm(
  474. self,
  475. yes: bool,
  476. msg: str,
  477. *args: Any,
  478. _abort: bool = False,
  479. _default: bool = False,
  480. _timeout_s: Optional[float] = None,
  481. **kwargs: Any,
  482. ):
  483. """Display a confirmation dialog.
  484. Valid answers are "y/yes/true/1" and "n/no/false/0".
  485. Args:
  486. yes: If `yes` is `True` the dialog will default to "yes"
  487. and continue without waiting for user input.
  488. _abort (bool):
  489. If `_abort` is `True`,
  490. "no" means aborting the program.
  491. _default (bool):
  492. The default action to take if the user just presses enter
  493. with no input.
  494. _timeout_s (float):
  495. If user has no input within _timeout_s seconds, the default
  496. action is taken. None means no timeout.
  497. """
  498. should_abort = _abort
  499. default = _default
  500. if not self.interactive and not yes:
  501. # no formatting around --yes here since this is non-interactive
  502. self.error(
  503. "This command requires user confirmation. "
  504. "When running non-interactively, supply --yes to skip."
  505. )
  506. raise ValueError("Non-interactive confirm without --yes.")
  507. if default:
  508. yn_str = "Y/n"
  509. else:
  510. yn_str = "y/N"
  511. confirm_str = cf.underlined("Confirm [" + yn_str + "]:") + " "
  512. rendered_message = _format_msg(msg, *args, **kwargs)
  513. # the rendered message ends with ascii coding
  514. if rendered_message and not msg.endswith("\n"):
  515. rendered_message += " "
  516. msg_len = len(rendered_message.split("\n")[-1])
  517. complete_str = rendered_message + confirm_str
  518. if yes:
  519. self._print(complete_str + "y " + cf.dimmed("[automatic, due to --yes]"))
  520. return True
  521. self._print(complete_str, _linefeed=False)
  522. res = None
  523. yes_answers = ["y", "yes", "true", "1"]
  524. no_answers = ["n", "no", "false", "0"]
  525. try:
  526. while True:
  527. if _timeout_s is None:
  528. ans = sys.stdin.readline()
  529. elif sys.platform == "win32":
  530. # Windows doesn't support select
  531. start_time = time.time()
  532. ans = ""
  533. while True:
  534. if (time.time() - start_time) >= _timeout_s:
  535. self.newline()
  536. ans = "\n"
  537. break
  538. elif msvcrt.kbhit():
  539. ch = msvcrt.getwch()
  540. if ch in ("\n", "\r"):
  541. self.newline()
  542. ans = ans + "\n"
  543. break
  544. elif ch == "\b":
  545. if ans:
  546. ans = ans[:-1]
  547. # Emulate backspace erasing
  548. print("\b \b", end="", flush=True)
  549. else:
  550. ans = ans + ch
  551. print(ch, end="", flush=True)
  552. else:
  553. time.sleep(0.1)
  554. else:
  555. ready, _, _ = select.select([sys.stdin], [], [], _timeout_s)
  556. if not ready:
  557. self.newline()
  558. ans = "\n"
  559. else:
  560. ans = sys.stdin.readline()
  561. ans = ans.lower()
  562. if ans == "\n":
  563. res = default
  564. break
  565. ans = ans.strip()
  566. if ans in yes_answers:
  567. res = True
  568. break
  569. if ans in no_answers:
  570. res = False
  571. break
  572. indent = " " * msg_len
  573. self.error(
  574. "{}Invalid answer: {}. Expected {} or {}",
  575. indent,
  576. cf.bold(ans.strip()),
  577. self.render_list(yes_answers, "/"),
  578. self.render_list(no_answers, "/"),
  579. )
  580. self._print(indent + confirm_str, _linefeed=False)
  581. except KeyboardInterrupt:
  582. self.newline()
  583. res = default
  584. if not res and should_abort:
  585. # todo: make sure we tell the user if they
  586. # need to do cleanup
  587. self._print("Exiting...")
  588. raise SilentClickException(
  589. "Exiting due to the response to confirm(should_abort=True)."
  590. )
  591. return res
  592. def prompt(self, msg: str, *args, **kwargs):
  593. """Prompt the user for some text input.
  594. Args:
  595. msg: The mesage to display to the user before the prompt.
  596. Returns:
  597. The string entered by the user.
  598. """
  599. complete_str = cf.underlined(msg)
  600. rendered_message = _format_msg(complete_str, *args, **kwargs)
  601. # the rendered message ends with ascii coding
  602. if rendered_message and not msg.endswith("\n"):
  603. rendered_message += " "
  604. self._print(rendered_message, linefeed=False)
  605. res = ""
  606. try:
  607. ans = sys.stdin.readline()
  608. ans = ans.lower()
  609. res = ans.strip()
  610. except KeyboardInterrupt:
  611. self.newline()
  612. return res
  613. def flush(self):
  614. sys.stdout.flush()
  615. sys.stderr.flush()
  616. class SilentClickException(click.ClickException):
  617. """`ClickException` that does not print a message.
  618. Some of our tooling relies on catching ClickException in particular.
  619. However the default prints a message, which is undesirable since we expect
  620. our code to log errors manually using `cli_logger.error()` to allow for
  621. colors and other formatting.
  622. """
  623. def __init__(self, message: str):
  624. super(SilentClickException, self).__init__(message)
  625. def show(self, file=None):
  626. pass
  627. cli_logger = _CliLogger()
  628. CLICK_LOGGING_OPTIONS = [
  629. click.option(
  630. "--log-style",
  631. required=False,
  632. type=click.Choice(cli_logger.VALID_LOG_STYLES, case_sensitive=False),
  633. default="auto",
  634. help=(
  635. "If 'pretty', outputs with formatting and color. If 'record', "
  636. "outputs record-style without formatting. "
  637. "'auto' defaults to 'pretty', and disables pretty logging "
  638. "if stdin is *not* a TTY."
  639. ),
  640. ),
  641. click.option(
  642. "--log-color",
  643. required=False,
  644. type=click.Choice(["auto", "false", "true"], case_sensitive=False),
  645. default="auto",
  646. help=("Use color logging. Auto enables color logging if stdout is a TTY."),
  647. ),
  648. click.option("-v", "--verbose", default=None, count=True),
  649. ]
  650. def add_click_logging_options(f: Callable) -> Callable:
  651. for option in reversed(CLICK_LOGGING_OPTIONS):
  652. f = option(f)
  653. @wraps(f)
  654. def wrapper(*args, log_style=None, log_color=None, verbose=None, **kwargs):
  655. cli_logger.configure(log_style, log_color, verbose)
  656. return f(*args, **kwargs)
  657. return wrapper