Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(emitter): decouple message emitter and printer #300

Draft
wants to merge 4 commits into
base: poc/cli-to-cli-protocol
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions craft_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
from typing import Any, Optional, Union, cast


class CraftError(Exception):
"""Signal a program error with a lot of information to report."""
class BaseErrorData:
"""Common error fields for CraftError and CLI protocol."""

message: str
"""The main message to the user, to be shown as first line (and probably only that,
Expand Down Expand Up @@ -56,6 +56,10 @@ class CraftError(Exception):
retcode: int
"""The code to return when the application finishes."""


class CraftError(BaseErrorData, Exception):
"""Signal a program error with a lot of information to report."""

def __init__( # noqa: PLR0913 (too many arguments)
self,
message: str,
Expand Down
262 changes: 214 additions & 48 deletions craft_cli/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import enum
import functools
import json
import logging
import os
import pathlib
Expand All @@ -38,6 +39,8 @@

import platformdirs

from .errors import BaseErrorData, CraftError

try:
import win32pipe # type: ignore[import]

Expand Down Expand Up @@ -67,6 +70,96 @@
# craft_cli/pytest_plugin.py )
TESTMODE = False

# Constants for emitter-to-emitter line protocol
_MSG_MESSAGE = "<M>"
_MSG_VERBOSE = "<V>"
_MSG_DEBUG = "<D>"
_MSG_TRACE = "<T>"
_MSG_PROGRESS_PERMANENT = "<P>"
_MSG_PROGRESS = "<p>"
_MSG_ERROR = "<E>"
_MSG_STREAM = "<S>"


class _ErrorMessage(BaseErrorData):
message: str
is_command_error: bool
traceback_lines: list[str]

def __init__( # noqa: PLR0913 (too many arguments)
self,
message: str,
*,
details: str | None,
resolution: str | None,
docs_url: str | None,
logpath_report: bool,
reportable: bool,
retcode: int,
doc_slug: str | None,
stderr: str | None,
is_command_error: bool,
traceback_lines: list[str] | None,
) -> None:
self.message = message
self.details = details
self.resolution = resolution
self.docs_url = docs_url
self.logpath_report = logpath_report
self.reportable = reportable
self.retcode = retcode
self.doc_slug = doc_slug
self.stderr = stderr
self.is_command_error = is_command_error
self.traceback_lines = traceback_lines or []

@classmethod
def from_error(cls, error: CraftError) -> _ErrorMessage:
"""Create an error message from an error exception.

:param error: The CraftError exception.
:return: The error message for the CLI protocol.
"""
msg = cls(
message=str(error),
details=error.details,
resolution=error.resolution,
docs_url=error.docs_url,
doc_slug=error.doc_slug,
stderr="",
logpath_report=error.logpath_report,
reportable=error.reportable,
retcode=error.retcode,
is_command_error=False,
traceback_lines=[],
)

if error.__cause__:
msg.traceback_lines = list(_get_traceback_lines(error.__cause__))

if isinstance(error, errors.CraftCommandError):
msg.is_command_error = True
msg.stderr = error.stderr

return msg

@classmethod
def loads(cls, data: str) -> _ErrorMessage:
"""Unmarshal error data from a string.

:param data: Serialized error attributes.
:return: CraftError object.
"""
j = json.loads(data)
return cls(**j)

def dumps(self) -> str:
"""Marshal error data to a string.

:return: Serialized error attributes.
"""
return json.dumps(self.__dict__)


def _get_log_filepath(appname: str) -> pathlib.Path:
"""Provide a unique filepath for logging.
Expand Down Expand Up @@ -245,7 +338,12 @@ def _write(self, data: bytes) -> None:
# can correctly count the characters.
unicode_line = unicode_line.replace("\t", " ")
text = f":: {unicode_line}"
self.printer.show(self.stream, text, **self.printer_flags)

self.handle_message(_MSG_STREAM, text)

def handle_message(self, _msg_type: str, text: str) -> None:
"""Process emitted message according to the local system configuration."""
self.printer.show(self.stream, text, **self.printer_flags)

def _run_posix(self) -> None:
"""Run the thread, handling pipes in the POSIX way."""
Expand Down Expand Up @@ -526,17 +624,18 @@ def set_mode(self, mode: EmitterMode) -> None:
sys.stderr, msg, use_timestamp=use_timestamp, avoid_logging=True, end_line=True
)

@_active_guard()
def stream(self, text: str) -> None:
"""Show strings streamed from a stream context."""
self.handle_message(_MSG_STREAM, text)

@_active_guard()
def message(self, text: str) -> None:
"""Show an important message to the user.

Normally used as the final message, to show the result of a command.
"""
stream = None if self._mode == EmitterMode.QUIET else sys.stdout
if self._streaming_brief:
# Clear the message prefix, as this message stands alone
self._printer.set_terminal_prefix("")
self._printer.show(stream, text)
self.handle_message(_MSG_MESSAGE, text)

@_active_guard()
def verbose(self, text: str) -> None:
Expand All @@ -545,16 +644,7 @@ def verbose(self, text: str) -> None:
Useful to provide more information to the user that shouldn't be exposed
when in brief mode for clarity and simplicity.
"""
if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF):
stream = None
use_timestamp = False
elif self._mode == EmitterMode.VERBOSE:
stream = sys.stderr
use_timestamp = False
else:
stream = sys.stderr
use_timestamp = True
self._printer.show(stream, text, use_timestamp=use_timestamp)
self.handle_message(_MSG_VERBOSE, text)

@_active_guard()
def debug(self, text: str) -> None:
Expand All @@ -564,11 +654,7 @@ def debug(self, text: str) -> None:
for the app developers to understand why things are failing or performing
forensics on the produced logs.
"""
if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF, EmitterMode.VERBOSE):
stream = None
else:
stream = sys.stderr
self._printer.show(stream, text, use_timestamp=True)
self.handle_message(_MSG_DEBUG, text)

@_active_guard()
def trace(self, text: str) -> None:
Expand All @@ -580,13 +666,102 @@ def trace(self, text: str) -> None:

It only produces information to the screen and into the logs if in TRACE mode.
"""
# as we're not even logging anything if not in TRACE mode, instead of calling the
# Printer with no stream and the 'avoid_logging' flag (which would be more consistent
# with the rest of the Emitter methods, in this case we just avoid moving any
# machinery as much as possible, because potentially there will be huge number
# of trace calls.
if self._mode == EmitterMode.TRACE:
self._printer.show(sys.stderr, text, use_timestamp=True)
self.handle_message(_MSG_TRACE, text)

def handle_message( # noqa: PLR0912 (too many branches)
self, msg_type: str, text: str
) -> None:
"""Process emitted message according to the local system configuration.

Emitted messages can be handled differently according to the type of the
message and configuration of the message handler. By decoupling the emitter
and message printer, the (potentially remote) emitter sets the type of the
emitted message, which is printed according to the local handler's verbosity
rules.

Note that text lines are passed directly, whereas structured error data is
serialized. This shouldn't impose significant performance overhead as error
messages are only emitted at the end of a failed session.

:param msg: The emitted message, in Craft CLI message protocol format.
"""
if msg_type == _MSG_STREAM:
if self._mode == EmitterMode.QUIET:
# no third party stream
self._printer.show(None, text, use_timestamp=False, ephemeral=True, end_line=False)
elif self._mode == EmitterMode.BRIEF:
# third party stream to stderr
self._printer.show(
sys.stderr, text, use_timestamp=False, ephemeral=True, end_line=False
)
elif self._mode == EmitterMode.VERBOSE:
# third party stream to stderr
self._printer.show(
sys.stderr, text, use_timestamp=False, ephemeral=False, end_line=True
)
else:
# third party stream to stderr with timestamp
self._printer.show(
sys.stderr, text, use_timestamp=True, ephemeral=False, end_line=True
)

elif msg_type == _MSG_MESSAGE:
stream = None if self._mode == EmitterMode.QUIET else sys.stdout
if self._streaming_brief:
# Clear the message prefix, as this message stands alone
self._printer.set_terminal_prefix("")
self._printer.show(stream, text)

elif msg_type == _MSG_VERBOSE:
if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF):
stream = None
use_timestamp = False
elif self._mode == EmitterMode.VERBOSE:
stream = sys.stderr
use_timestamp = False
else:
stream = sys.stderr
use_timestamp = True
self._printer.show(stream, text, use_timestamp=use_timestamp)

elif msg_type == _MSG_DEBUG:
if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF, EmitterMode.VERBOSE):
stream = None
else:
stream = sys.stderr
self._printer.show(stream, text, use_timestamp=True)

elif msg_type == _MSG_TRACE:
# as we're not even logging anything if not in TRACE mode, instead of calling the
# Printer with no stream and the 'avoid_logging' flag (which would be more consistent
# with the rest of the Emitter methods, in this case we just avoid moving any
# machinery as much as possible, because potentially there will be huge number
# of trace calls.
if self._mode == EmitterMode.TRACE:
self._printer.show(sys.stderr, text, use_timestamp=True)

elif msg_type in (_MSG_PROGRESS_PERMANENT, _MSG_PROGRESS):
is_permament = msg_type == _MSG_PROGRESS_PERMANENT
stream, use_timestamp, ephemeral = self._get_progress_params(is_permament)

if self._streaming_brief:
# Clear the "new thing" prefix, as this is a new progress message.
self._printer.set_terminal_prefix("")

self._printer.show(stream, text, ephemeral=ephemeral, use_timestamp=use_timestamp)

if self._mode == EmitterMode.BRIEF and ephemeral and self._streaming_brief:
# Set the "progress prefix" for upcoming non-permanent messages.
self._printer.set_terminal_prefix(text)

elif msg_type == _MSG_ERROR:
if self._streaming_brief:
# Clear the message prefix, as this error stands alone
self._printer.set_terminal_prefix("")
self._report_error(_ErrorMessage.loads(text))

else:
raise RuntimeError("unknown message type '{msg_type}'")

def _get_progress_params(
self, permanent: bool # noqa: FBT001 (boolean positional arg)
Expand Down Expand Up @@ -626,17 +801,10 @@ def progress(self, text: str, permanent: bool = False) -> None: # noqa: FBT001,
These messages will be truncated to the terminal's width, and overwritten by the next
line (unless verbose/trace mode).
"""
stream, use_timestamp, ephemeral = self._get_progress_params(permanent)

if self._streaming_brief:
# Clear the "new thing" prefix, as this is a new progress message.
self._printer.set_terminal_prefix("")

self._printer.show(stream, text, ephemeral=ephemeral, use_timestamp=use_timestamp)

if self._mode == EmitterMode.BRIEF and ephemeral and self._streaming_brief:
# Set the "progress prefix" for upcoming non-permanent messages.
self._printer.set_terminal_prefix(text)
if permanent:
self.handle_message(_MSG_PROGRESS_PERMANENT, text)
else:
self.handle_message(_MSG_PROGRESS, text)

@_active_guard()
def progress_bar(
Expand Down Expand Up @@ -710,7 +878,7 @@ def ended_ok(self) -> None:
"""Finish the messaging system gracefully."""
self._stop()

def _report_error(self, error: errors.CraftError) -> None: # noqa: PLR0912 (too many branches)
def _report_error(self, error: _ErrorMessage) -> None: # noqa: PLR0912 (too many branches)
"""Report the different message lines from a CraftError."""
if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF, EmitterMode.VERBOSE):
use_timestamp = False
Expand All @@ -721,10 +889,10 @@ def _report_error(self, error: errors.CraftError) -> None: # noqa: PLR0912 (too

# The initial message. Print every line individually to correctly clear
# previous lines, if necessary.
for line in str(error).splitlines():
for line in error.message.splitlines():
self._printer.show(sys.stderr, line, use_timestamp=use_timestamp, end_line=True)

if isinstance(error, errors.CraftCommandError):
if error.is_command_error:
stderr = error.stderr
if stderr:
text = f"Captured error:\n{stderr}"
Expand All @@ -734,8 +902,8 @@ def _report_error(self, error: errors.CraftError) -> None: # noqa: PLR0912 (too
if error.details:
text = f"Detailed information: {error.details}"
self._printer.show(full_stream, text, use_timestamp=use_timestamp, end_line=True)
if error.__cause__:
for line in _get_traceback_lines(error.__cause__):
if error.traceback_lines:
for line in error.traceback_lines:
self._printer.show(full_stream, line, use_timestamp=use_timestamp, end_line=True)

# hints for the user to know more
Expand All @@ -761,10 +929,8 @@ def _report_error(self, error: errors.CraftError) -> None: # noqa: PLR0912 (too
@_active_guard(ignore_when_stopped=True)
def error(self, error: errors.CraftError) -> None:
"""Handle the system's indicated error and stop machinery."""
if self._streaming_brief:
# Clear the message prefix, as this error stands alone
self._printer.set_terminal_prefix("")
self._report_error(error)
msg = _ErrorMessage.from_error(error)
self.handle_message(_MSG_ERROR, msg.dumps())
self._stop()

@_active_guard()
Expand Down
Loading