Skip to content

Commit

Permalink
printer: enhance ephemeral message handover after stop()
Browse files Browse the repository at this point in the history
In craft tools, emit.pause() is used to allow external processes to
take control the terminal. Specifically, in LXD and Multipass builds
the real build host is another instance of the same craft tool that
launched the build, running inside the sandbox.

In order to have a seamless handover between the local and sandbox
output of the craft tool instances, the concepts of permanent and
ephemeral messages must be honored when the terminal transitions to
or from the craft tool running inside the sandbox.

Example:

emit.progress("Starting LXD...")
:
with emit.pause():
    instance.execute_run(...)

In the example above, execute_run start another instance of the
craft tool, and continues emitting 'progress' (ephemeral) messages.

The expected behaviour is that the progress messages from the
sandbox environment will replace the last progress message from
the original craft tool instance.

This patch enhances the printer stop() code to detect if the last
print is ephemeral or permanent. The new behaviour only affect
the case where the last message before emit.pause() or
emit.ended_ok() is of ephemeral type, in which case no newline is
flushed on the terminal. This allows the new process to rewrite the
previous line.

It is of course the programmer's responsibility to use ephemeral
messages wisely before pausing or exiting the emitter engine.
  • Loading branch information
flotter committed Oct 7, 2022
1 parent 3b56aa4 commit 27cdff6
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 13 deletions.
85 changes: 72 additions & 13 deletions craft_cli/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,40 @@ def __init__(self, log_filepath: pathlib.Path) -> None:
if not TESTMODE:
self.spinner.start()

def _recover_line_terminal(self) -> None:
"""Recover the current terminal line.
This method only affect the terminal line where the cursor is
currently located. Please refer to the stop() function for an
usage example, and explanation.
The operations performed are:
- Reset the terminal cursor to the start of the line
- Write out spaces to clean the entire line
- Move the terminal cursor back to the start of the line
Note: Because 'spintext' printed with prv_msg is not recorded as
part of the message (it is lost after its printed), it is not
possible to determine the exact text length of the current line.
It is therefore only safe to clean the entire terminal line width.
"""
if self.unfinished_stream is None:
# The cursor is already at the start of a clean line, or this
# stream is not a terminal (and therefore does not reuse lines ever)
return

# How many spaces do we need to write from the start of the line to
# replace everything and leave the cursor at the end?
cleaner = " " * (_get_terminal_width() - 1)

# Reset the cursor, overwrite the entire line to replace any possible
# combination of time-stamp, text and spinner text. Finally reset cursor
# back to start of same line.
carriage_return = "\r"
line = carriage_return + cleaner + carriage_return
print(line, end="", flush=True, file=self.prv_msg.stream)

def _write_line_terminal(self, message: _MessageInfo, *, spintext: str = "") -> None:
"""Write a simple line message to the screen."""
# prepare the text with (maybe) the timestamp
Expand All @@ -251,15 +285,18 @@ def _write_line_terminal(self, message: _MessageInfo, *, spintext: str = "") ->
else:
text = message.text

if spintext:
# forced to overwrite the previous message to present the spinner
if self.prv_msg is None or self.prv_msg.ephemeral or spintext:
# There are three cases where we need to move the cursor hard left
# before we continue:
# - This is the first print ever, and we do not know that stage of the
# cursor. If we start on anything but a cursor at the left, the
# width calculation will overflow and create a new line.
# - We are replacing an ephemeral line with new text
# - The spinner is updating a line, and has to redraw it.
maybe_cr = "\r"
elif self.prv_msg is None or self.prv_msg.end_line:
# first message, or previous message completed the line: start clean
elif self.prv_msg.end_line:
# previous message completed the line: start clean
maybe_cr = ""
elif self.prv_msg.ephemeral:
# the last one was ephemeral, overwrite it
maybe_cr = "\r"
else:
# complete the previous line, leaving that message ok
maybe_cr = ""
Expand Down Expand Up @@ -310,12 +347,17 @@ def _write_bar_terminal(self, message: _MessageInfo) -> None:
else:
text = message.text

if self.prv_msg is None or self.prv_msg.end_line:
# first message, or previous message completed the line: start clean
maybe_cr = ""
elif self.prv_msg.ephemeral:
# the last one was ephemeral, overwrite it
if self.prv_msg is None or self.prv_msg.ephemeral:
# There are two cases where we need to move the cursor hard left
# before we continue:
# - This is the first print ever, and we do not know that stage of the
# cursor. If we start on anything but a cursor at the left, the
# width calculation will overflow and create a new line.
# - We are replacing an ephemeral line with new text
maybe_cr = "\r"
elif self.prv_msg.end_line:
# previous message completed the line: start clean
maybe_cr = ""
else:
# complete the previous line, leaving that message ok
maybe_cr = ""
Expand Down Expand Up @@ -438,8 +480,25 @@ def stop(self) -> None:
"""
if not TESTMODE:
self.spinner.stop()
# If the output stream is a terminal and the cursor is currently
# on a line with message text, this is considered an unfinished
# stream. This is true for any message printed without 'end_line'
# equal to True.
if self.unfinished_stream is not None:
print(flush=True, file=self.unfinished_stream)
if self.prv_msg.ephemeral:
# If the last printed message is of 'ephemeral' type, we will
# assume that the 'stop' request must honor the fact that
# this message is not intended to stay permanently. In this
# case, we will clear the line (ephemeral messages are always
# truncated to a single line) where the cursor currently is,
# and reset the cursor to the start of the line. This will
# allow the next write to the stream replace to rewrite the
# original ephemeral message.
self._recover_line_terminal()
else:
# The normal case of leaving the cursor on the next clean line
print(flush=True, file=self.unfinished_stream)

self.log.close()
self.stopped = True

Expand Down
44 changes: 44 additions & 0 deletions examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,50 @@ def example_25():
emit.message("The meaning of life is 42.")


def example_26():
"""Show emitter handover behaviour.
This example demonstrates emitter seamless handover between two
*craft tools, or between a *craft tool and some external process.
Handover uses emit.pause() on the local *craft tool before expecting
an external party to start using the terminal.
"""
with emit.pause():
print("1.")

# 1. Simple case, next line handover
emit.message("emit.message: From local build host no spinner ...")
with emit.pause():
print("External process print exactly on next line")
print("2.")

# 2. Simple case with spinner, next line handover
emit.message("emit.message: From local build host with spinner ...")
time.sleep(5)
with emit.pause():
print("External process print exactly on next line")
print("3.")

# 3. Ephemeral case, same line replace after handover
emit.progress("emit.progress: From local build host no spinner ...")
with emit.pause():
print("External process replaced last line")
print("4.")

# 4. Ephemeral case with spinner, same line replace after handover
emit.progress("emit.progress: From local build host with spinner ...")
time.sleep(5)
with emit.pause():
print("External process replaced last line")
print("5.")

# 5. Ephemeral case with spinner, same line replace after emit stop (app exit)
emit.progress("emit.progress: From local build host with spinner ...")
time.sleep(5)
emit.ended_ok()
print("External process replaced last line")


# -- end of test cases

if len(sys.argv) != 2:
Expand Down

0 comments on commit 27cdff6

Please sign in to comment.