import logging
import sys
import traceback
from typing import Any
from rich.console import Console
from onyo.lib.exceptions import UIInputError
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 to handle user interaction.
Includes printing, errors, requests, etc.
"""
[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
Suppress all output. Requires ``yes=True``.
yes
Answer "yes" to all user-interactive prompts.
"""
# set the attributes of the UI object
self.logger: logging.Logger = logging.getLogger('onyo')
r"""The logger to display information with."""
self.quiet: bool = quiet
r"""Suppress all output. Requires ``yes=True``.
"""
self.yes: bool = yes
r"""Answer "yes" to all user-interactive prompts."""
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
Activate debug mode, and configure 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
Suppress all output. Requires ``yes=True``.
Raises
------
ValueError
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 user-interactive prompts.
Parameters
----------
yes
Answer "yes" to all user-interactive prompts.
"""
self.yes = yes
[docs]
def error(self,
error: str | Exception,
end: str = '\n') -> None:
r"""Print an error message.
When provided, Exceptions will print tracebacks in debug mode.
Nothing is printed when :py:data:`quiet` is ``True``.
Parameters
----------
error
Error message to print. Exceptions will have their ``message``
printed and traceback added to the debug log.
end
String to end the message with. Default is ``"\n"``.
"""
self.error_count += 1
if not self.quiet:
print(f"ERROR: {error}", file=sys.stderr, end=end)
if isinstance(error, Exception):
self.log_debug(self.format_traceback(error))
[docs]
def log(self,
message: str,
level: int = logging.INFO) -> None:
r"""Log a message.
Parameters
----------
message
Message to log.
level
Level to log at.
"""
self.logger.log(level=level, msg=message)
[docs]
def log_debug(self,
*args,
**kwargs) -> None:
r"""Log at ``DEBUG`` level.
Parameters
----------
args
Passed to :py:meth:`logging.Logger.debug`
kwargs
Passed to :py:meth:`logging.Logger.debug`
"""
self.logger.debug(*args, **kwargs)
[docs]
def print(self,
*args,
**kwargs) -> None:
r"""Print a message.
Nothing is printed when :py:data:`quiet` is ``True``.
Parameters
----------
args
Passed to builtin :py:func:`print`
kwargs
Passed to builtin :py:func:`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 a question and read a response from ``stdin``.
When :py:data:`yes` is ``True``, the ``default`` answer is used without
prompting the user.
When a user's response matches any of the ``answers``, the corresponding
return value is returned. If the user's response doesn't match any
answers, the question is repeated.
Parameters
----------
question
Question that the user should respond to.
default
Default answer used when the answer is empty (user hit only enter)
or :py:data:`yes` is ``True``.
answers
List of answers and corresponding value to return for those answers.
The first element is the return value, and the second is a list of
strings.
Default is 'y', 'Y', or 'yes' return ``True`` and 'n', 'N', or 'no'
return ``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:
try:
answer = input(question) or default # empty answer (hit return) gives the default answer
except EOFError as e:
raise UIInputError("Failed to read user input.") from e
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"""Print via the ``rich`` module.
A proxy for ``rich.Console.print``.
Parameters
----------
stderr
Bool to use a ``stderr`` Rich Console instead of a ``stdout`` Rich
Console.
args
Passed to :py:func:`rich.Console.print`
kwargs
Passed to :py:func:`rich.Console.print`
"""
# TODO: This should be fused with the regular `UI.print` and `UI.error`,
# 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()