Source code for onyo.lib.ui

import logging
import os
import sys
import traceback
from typing import Any

from rich.console import Console

logging.basicConfig()
log: logging.Logger = logging.getLogger('onyo')


# TODO:
# - Logging: Provide Formatter/Handler raise default level and maybe target file (~/.onyo/logs/  (config))?
#          logging errors -> print (rich) + actual log? Nope. Do both from within code - > different phrasing/details
#          special log_exception? Log when raised or when catched? (we have the traceback!)
# - How does the quiet flag behave (with and w/o to-be- introduced non-interactive)? What about "result" outputs that
# could be piped? Does it suppress everything but these? How to distinguish? Commands could return (or yield) a
# result object.
# - main.py could tell `UI` that we are in CLI (Paths -> render relative to CWD, otherwise absolute)


[docs] class UI(object): r""" An object handling user interaction, including printing, errors, requests, and others. Attributes ---------- logger: Logger The logger to display information with. quiet: bool Activate the quiet mode (requires that `yes=True`). This will suppresses all output generation. yes: bool Activate the yes mode, which suppresses all interactive requests to the user, and instead answers them with yes. """
[docs] def __init__(self, debug: bool = False, quiet: bool = False, yes: bool = False) -> None: # TODO: interactive mode with default values or autodetecting tty? And # should this be unified with the whole business of rich-coloring etc? r"""Initialize the User Interface object for user communication of Onyo. Parameters ---------- debug Activate the debug mode to display additional information via Onyo, and to print the full traceback stack if errors occur. quiet Activate the quiet mode (requires that `yes=True`) to suppress all output generation. yes Activate the yes mode to suppress all interactive requests to the user, and instead answers them with yes. """ # set the the attributes of the UI object self.quiet = quiet self.yes = yes self.logger = logging.getLogger('onyo') self.debug = debug # set the debug level if debug: self.logger.setLevel(logging.DEBUG) else: self.logger.setLevel(logging.INFO) self.stderr_console = Console(stderr=True, highlight=False, soft_wrap=True) self.stdout_console = Console(stderr=False, highlight=False, soft_wrap=True) # count reported errors; this allows to assess whether errors occurred # even when no exception bubbles up. self.error_count: int = 0
[docs] def set_debug(self, debug: bool = False) -> None: r"""Toggle debug mode. Parameters ---------- debug Activates debug mode, and configures the log level of the logger. """ if debug: self.logger.setLevel(logging.DEBUG) else: self.logger.setLevel(logging.INFO)
[docs] def set_quiet(self, quiet: bool = False) -> None: r"""Toggle quiet mode. Parameters ---------- quiet `True` suppresses of all user output. Requires `yes` mode to be active. Raises ------ ValueError If tried to activate quiet mode without `yes=True`. """ if quiet and not self.yes: # TODO: This condition would need to be triggered from __init__ as well. raise ValueError("The --quiet flag requires --yes.") self.quiet = quiet
[docs] def set_yes(self, yes: bool = False) -> None: r"""Toggle auto-response 'yes' to all questions. Parameters ---------- yes Activate yes mode, which suppresses all user requests and answers them positively. Allows the activation of the quiet mode. """ self.yes = yes
[docs] def error(self, error: str | Exception, end: str = os.linesep) -> None: r"""Print an error message, if the `UI` is not set to quiet mode. Parameters ---------- error Prints the string, or the message of an error. If debug mode is activated, displays the full traceback of an exception. end Specify the string at the end of prints. Per default, prints end with a line break. """ self.error_count += 1 if not self.quiet: print(f"ERROR: {error}", file=sys.stderr, end=end) if isinstance(error, Exception): tb = traceback.TracebackException.from_exception( error, lookup_lines=True, capture_locals=False ) if error.__traceback__: traceback.clear_frames(error.__traceback__) self.logger.debug(''.join(tb.format()))
[docs] def log(self, message: str) -> None: r"""Log a message at `logging.INFO` level. Parameters ---------- message The message to log. """ self.logger.info(message)
[docs] def log_debug(self, *args, **kwargs) -> None: r"""Log at `logging.DEBUG` level. Parameters ---------- args passed to Logger.debug kwargs passed to Logger.debug """ self.logger.debug(*args, **kwargs)
[docs] def print(self, *args, **kwargs) -> None: r"""Print a message, if the `UI` is not set to quiet mode. Parameters ---------- args passed on to builtin `print`. kwargs passed on to builtin `print`. """ if not self.quiet: print(*args, **kwargs)
[docs] def request_user_response(self, question: str, default: str = 'yes', answers: list[tuple] | None = None) -> Any: r"""Print `question` and read a response from `stdin`. Returns True when user answers yes, False when no, and asks again if the input is neither. If the UI is set to `yes=True` the `default` answer is assumed without asking the user. Parameters ---------- question: str The question to which the user should respond. This is appended by an indication of what is the default response (just hit enter). default: str Define a default answer. This is answer is assumed when `self.yes` is set (non-interactive mode) or when the response was empty, i.e. user just hit enter. answers: list of tuple Defined ways to answer the question and what to return accordingly. First element of a tuple is the return value, second element a list of strings. If the user's response matches any of these strings, the return value is returned. If the user's response doesn't match any, the question is repeated. By default, this function poses a yes-no question, where 'y,'Y','yes' are returned as `True`, and 'n', 'N', 'no' as `False`. """ # TODO: When use of rich is streamlined, we'd probably want to change how the default # and possible ways to respond are indicated. answers = answers or [(True, ['y', 'Y', 'yes']), (False, ['n', 'N', 'no'])] question += f"[Default: {default}] " while True: if self.yes: answer = default else: answer = input(question) or default # empty answer (hit return) gives the default answer for response, options in answers: if answer in options: return response self.log_debug(f"Invalid user response: {answer}. Retry.")
[docs] def rich_print(self, *args, **kwargs) -> None: r"""Refactoring helper to print via the `rich` package. Proxy for `rich.Console.print`. Takes `stderr: bool` option to use a stderr `Console` instead of a stdout Console. Notes ----- This is to be fused with the regular `UI.print`, `UI.error`, etc. so that `UI` decides whether and how to use `rich`. The stderr option should consequently be replaced by `print`'s standard `file` option. """ if not self.quiet: stderr = kwargs.pop('stderr') if 'stderr' in kwargs.keys() else False console = self.stderr_console if stderr else self.stdout_console console.print(*args, **kwargs)
# create a shared UI object to import by classes/commands ui = UI()