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

Print message when Wait Bar is started non-interactively #1649

Merged
merged 4 commits into from Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/1649.feature.rst
@@ -0,0 +1 @@
In non-interactive environments, such as CI, a message is now printed to signify a task has begun where an animated bar would be displayed in interactive console sessions.
133 changes: 89 additions & 44 deletions src/briefcase/console.py
Expand Up @@ -6,11 +6,13 @@
import platform
import re
import sys
import time
import traceback
from contextlib import contextmanager
from datetime import datetime
from enum import IntEnum
from pathlib import Path
from typing import Callable, Iterable

from rich.console import Console as RichConsole
from rich.control import strip_control_codes
Expand All @@ -23,7 +25,7 @@
TextColumn,
TimeRemainingColumn,
)
from rich.traceback import Traceback
from rich.traceback import Trace, Traceback

from briefcase import __version__

Expand Down Expand Up @@ -65,20 +67,19 @@ class RichConsoleHighlighter(RegexHighlighter):
"""

base_style = "repr."
highlights = [r"(?P<url>(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#]*)"]
highlights = [
r"(?P<url>(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~]*)"
]
freakboy3742 marked this conversation as resolved.
Show resolved Hide resolved


class Printer:
"""Interface for printing and managing output to the console and/or log."""

def __init__(self, log_width=180):
"""Create an interface for printing and managing output to the console and/or
log.
"""Interface for printing and managing output to the console and/or log.

The default width is wide enough to render the output of ``sdkmanager
--list_installed`` without line wrapping.
--list_installed`` in the log file without line wrapping.

:param width: The width at which content should be wrapped.
:param log_width: The width at which content should be wrapped in the log file
"""
self.log_width = log_width

Expand All @@ -91,8 +92,7 @@ def __init__(self, log_width=180):

# Rich only records what's being logged if it is actually written somewhere;
# writing to /dev/null allows Rich to do so without needing to print the
# logs in the console or save them to file before it is known a file is
# wanted.
# logs in the console or save them to file before it is known a file is wanted.
self.dev_null = open(os.devnull, "w", encoding="utf-8", errors="ignore")
self.log = RichConsole(
file=self.dev_null,
Expand Down Expand Up @@ -176,24 +176,28 @@ class Log:
# subdirectory of command.base_path to store log files
LOG_DIR = "logs"

def __init__(self, printer=None, verbosity: LogLevel = LogLevel.INFO):
def __init__(
self,
printer: Printer | None = None,
verbosity: LogLevel = LogLevel.INFO,
):
self.print = Printer() if printer is None else printer
# --verbosity flag: 0 for info, 1 for debug, 2 for deep debug
# --verbosity flag: see LogLevel for valid values
self.verbosity = verbosity
# --log flag to force logfile creation
self.save_log = False
# flag set by exceptions to skip writing the log; save_log takes precedence.
self.skip_log = False
# Rich stacktraces of exceptions for logging to file.
# A list of tuples containing a label for the thread context, and the Trace object
self.stacktraces = []
self.stacktraces: list[tuple[str, Trace]] = []
# functions to run for additional logging if creating a logfile
self.log_file_extras = []
self.log_file_extras: list[Callable[[], object]] = []
# The current context for the log
self._context = ""

@contextmanager
def context(self, context):
def context(self, context: str):
"""Wrap a collection of output in a logging context.

A logging context is a prefix on every logging line. It is used when a set of
Expand Down Expand Up @@ -272,7 +276,7 @@ def debug(self, message="", *, preface="", prefix="", markup=False):
)

def verbose(self, message="", *, prefix="", markup=False):
"""Log message at verbose level if debug enabled; included if verbosity >= 1."""
"""Log message at verbose level; included if verbosity >= 1."""
self._log(prefix=prefix, message=message, show=self.is_verbose, markup=markup)

def info(self, message="", *, prefix="", markup=False):
Expand Down Expand Up @@ -359,7 +363,7 @@ def capture_stacktrace(self, label="Main thread"):

self.stacktraces.append((label, Traceback.extract(*exc_info, show_locals=True)))

def add_log_file_extra(self, func):
def add_log_file_extra(self, func: Callable[[], object]):
"""Register a function to be called in the event that a log file is written.

This can be used to provide additional debugging information which is too
Expand Down Expand Up @@ -460,13 +464,41 @@ def _build_log(self, command):
)


class NotDeadYet:
# I’m getting better! No you’re not, you’ll be stone dead in a minute.

def __init__(self, printer: Printer):
"""A keep-alive spinner for long-running processes without console output.

Returned by the Wait Bar's context manager but can be used independently. Use in
a loop that calls update() each iteration. A keep-alive message will be printed
every 10 seconds.
"""
self.printer: Printer = printer
self.interval_sec: int = 10
self.message: str = "... still waiting"
self.ready_time: float = 0.0

self.reset() # initialize

def update(self):
"""Write keep-alive message if the periodic interval has elapsed."""
if self.ready_time < time.time():
self.printer(self.message)
self.reset()

def reset(self):
"""Initialize periodic interval to now; next message prints after interval."""
self.ready_time = time.time() + self.interval_sec


class Console:
def __init__(self, printer=None, enabled=True):
def __init__(self, printer: Printer | None = None, enabled: bool = True):
self.enabled = enabled
self.print = Printer() if printer is None else printer
# Use Rich's input() to read from user
self.input = self.print.console.input
self._wait_bar: Progress = None
self._wait_bar: Progress | None = None
# Signal that Rich is dynamically controlling the console output. Therefore,
# all output must be printed to the screen by Rich to prevent corruption of
# dynamic elements like the Wait Bar.
Expand All @@ -484,7 +516,8 @@ def is_interactive(self):
can dramatically compromise the quality of logged output. So, dynamic elements
should be specifically disabled in non-interactive sessions.
"""
return sys.stdout.isatty()
# `sys.__stdout__` is used because Rich captures and redirects `sys.stdout`
return sys.__stdout__.isatty()

def progress_bar(self):
"""Returns a progress bar as a context manager."""
Expand All @@ -502,12 +535,12 @@ def progress_bar(self):
@contextmanager
def wait_bar(
self,
message="",
done_message="done",
message: str = "",
done_message: str = "done",
*,
transient=False,
markup=False,
):
transient: bool = False,
markup: bool = False,
) -> NotDeadYet:
"""Activates the Wait Bar as a context manager.

If the Wait Bar is already active, then its message is updated for the new
Expand All @@ -520,35 +553,47 @@ def wait_bar(
if False (default), the message will remain on the screen without pulsing bar.
:param markup: whether to interpret Rich styling markup in the message; if True,
the message must already be escaped; defaults False.
:returns: Keep-alive spinner to notify user Briefcase is still waiting
"""
is_wait_bar_disabled = not self.is_interactive
show_outcome_message = message and (is_wait_bar_disabled or not transient)

if self._wait_bar is None:
self._wait_bar = Progress(
TextColumn(" "),
BarColumn(bar_width=20, style="black", pulse_style="white"),
TextColumn("{task.fields[message]}"),
transient=True,
disable=not self.is_interactive,
disable=is_wait_bar_disabled,
console=self.print.console,
)
# start=False causes the progress bar to "pulse"
# message=None is a sentinel the Wait Bar should be inactive
self._wait_bar.add_task("", start=False, message=None)

self.print(
f"{message} started", markup=markup, show=message and is_wait_bar_disabled
)

self.is_console_controlled = True
wait_bar_task = self._wait_bar.tasks[0]
previous_message = wait_bar_task.fields["message"]
self._wait_bar.update(wait_bar_task.id, message=message)

try:
self._wait_bar.start()
yield
except BaseException:
# ensure the message is left on the screen even if user sends CTRL+C
if message and not transient:
self.print(message, markup=markup)
yield NotDeadYet(printer=self.print)
except BaseException as e:
# capture BaseException so message is left on the screen even if user sends CTRL+C
error_message = "aborted" if isinstance(e, KeyboardInterrupt) else "errored"
self.print(
f"{message} {error_message}", markup=markup, show=show_outcome_message
)
raise
else:
if message and not transient:
self.print(f"{message} {done_message}", markup=markup)
self.print(
f"{message} {done_message}", markup=markup, show=show_outcome_message
)
finally:
self._wait_bar.update(wait_bar_task.id, message=previous_message)
# Deactivate the Wait Bar if returning to its initial state
Expand Down Expand Up @@ -584,15 +629,15 @@ def release_console_control(self):
self._wait_bar.start()

def prompt(self, *values, markup=False, **kwargs):
"""Print to the screen for soliciting user interaction.
"""Print to the screen for soliciting user interaction if input enabled.

:param values: strings to print as the user prompt
:param markup: True if prompt contains Rich markup
"""
if self.enabled:
self.print(*values, markup=markup, stack_offset=4, **kwargs)

def boolean_input(self, question, default=False):
def boolean_input(self, question: str, default: bool = False) -> bool:
"""Get a boolean input from user, in the form of y/n.

The user might press "y" for true or "n" for false. If input is disabled,
Expand Down Expand Up @@ -630,12 +675,12 @@ def boolean_input(self, question, default=False):

def selection_input(
self,
prompt,
choices,
default=None,
error_message="Invalid Selection",
transform=None,
):
prompt: str,
choices: Iterable[str],
default: str | None = None,
error_message: str = "Invalid Selection",
transform: Callable[[str], str] | None = None,
) -> str:
"""Prompt the user to select an option from a list of choices.

:param prompt: The text prompt to display
Expand All @@ -657,7 +702,7 @@ def selection_input(
self.prompt()
self.prompt(error_message)

def text_input(self, prompt, default=None):
def text_input(self, prompt: str, default: str | None = None) -> str:
"""Prompt the user for text input.

If no default is specified, the input will be returned as entered.
Expand All @@ -680,8 +725,8 @@ def text_input(self, prompt, default=None):

return user_input

def __call__(self, prompt, *, markup=False):
"""Present input() interface."""
def __call__(self, prompt: str, *, markup: bool = False):
"""Present input() interface; prompt should be bold if markup is included."""
if not self.enabled:
raise InputDisabled()

Expand Down
6 changes: 4 additions & 2 deletions src/briefcase/integrations/android_sdk.py
Expand Up @@ -1337,7 +1337,7 @@ def start_emulator(
# Phase 1: Wait for the device to appear so we can get an
# ADB instance for the new device.
try:
with self.tools.input.wait_bar("Starting emulator..."):
with self.tools.input.wait_bar("Starting emulator...") as keep_alive:
adb = None
known_devices = set()
while adb is None:
Expand Down Expand Up @@ -1366,11 +1366,12 @@ def start_emulator(

# If we haven't found a device, try again in 2 seconds...
if adb is None:
keep_alive.update()
self.sleep(2)

# Phase 2: Wait for the boot process to complete
if not adb.has_booted():
with self.tools.input.wait_bar("Booting emulator..."):
with self.tools.input.wait_bar("Booting emulator...") as keep_alive:
while not adb.has_booted():
if emulator_popen.poll() is not None:
raise BriefcaseCommandError(
Expand All @@ -1380,6 +1381,7 @@ def start_emulator(
)

# Try again in 2 seconds...
keep_alive.update()
self.sleep(2)
except BaseException as e:
self.tools.logger.warning(
Expand Down