diff --git a/changes/1722.misc.rst b/changes/1722.misc.rst new file mode 100644 index 000000000..af601071e --- /dev/null +++ b/changes/1722.misc.rst @@ -0,0 +1 @@ +The support for textwrapping messages printed to the console was consolidated to allow for broader usage. diff --git a/src/briefcase/__main__.py b/src/briefcase/__main__.py index 31033945c..94544c278 100644 --- a/src/briefcase/__main__.py +++ b/src/briefcase/__main__.py @@ -19,7 +19,7 @@ def main(): console = Console(printer=printer) logger = Log(printer=printer) try: - Command, extra_cmdline = parse_cmdline(sys.argv[1:]) + Command, extra_cmdline = parse_cmdline(sys.argv[1:], console=console) command = Command(logger=logger, console=console) options, overrides = command.parse_options(extra=extra_cmdline) command.parse_config( diff --git a/src/briefcase/cmdline.py b/src/briefcase/cmdline.py index e284e3e94..deaa695da 100644 --- a/src/briefcase/cmdline.py +++ b/src/briefcase/cmdline.py @@ -1,7 +1,5 @@ import argparse -import shutil import sys -import textwrap from argparse import RawDescriptionHelpFormatter from briefcase import __version__ @@ -18,6 +16,7 @@ UpgradeCommand, ) from briefcase.commands.base import split_passthrough +from briefcase.console import MAX_TEXT_WIDTH, Console from briefcase.platforms import get_output_formats, get_platforms from .exceptions import InvalidFormatError, NoCommandError, UnsupportedCommandError @@ -36,19 +35,18 @@ ] -def parse_cmdline(args): +def parse_cmdline(args, console: Console = Console()): """Parses the command line to determine the Command and its arguments. :param args: the arguments provided at the command line + :param console: :return: Command and command-specific arguments """ platforms = get_platforms() - width = max(min(shutil.get_terminal_size().columns, 80) - 2, 20) - briefcase_description = textwrap.fill( + briefcase_description = ( "Briefcase is a tool for converting a Python project " - "into a standalone native application for distribution.", - width=width, + "into a standalone native application for distribution." ) description_max_pad_len = max(len(cmd.command) for cmd in COMMANDS) + 2 @@ -59,15 +57,14 @@ def parse_cmdline(args): platform_list = ", ".join(sorted(platforms, key=str.lower)) - additional_instruction = textwrap.fill( + additional_instruction = ( "Each command, platform, and format has additional options. " - "Use the -h option on a specific command for more details.", - width=width, + "Use the -h option on a specific command for more details." ) parser = argparse.ArgumentParser( prog="briefcase", - description=( + description=console.textwrap( f"{briefcase_description}\n" "\n" "Commands:\n" @@ -80,7 +77,9 @@ def parse_cmdline(args): ), usage="briefcase [-h] [] [] ...", add_help=False, - formatter_class=lambda prog: RawDescriptionHelpFormatter(prog, width=width), + formatter_class=( + lambda prog: RawDescriptionHelpFormatter(prog, width=MAX_TEXT_WIDTH) + ), ) parser.add_argument("-V", "--version", action="version", version=__version__) diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index aa088ec67..6e9772e54 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -6,10 +6,8 @@ import inspect import os import platform -import shutil import subprocess import sys -import textwrap from abc import ABC, abstractmethod from argparse import RawDescriptionHelpFormatter from pathlib import Path @@ -26,7 +24,7 @@ from briefcase import __version__ from briefcase.config import AppConfig, GlobalConfig, parse_config -from briefcase.console import Console, Log +from briefcase.console import MAX_TEXT_WIDTH, Console, Log from briefcase.exceptions import ( BriefcaseCommandError, BriefcaseConfigError, @@ -655,24 +653,23 @@ def parse_options(self, extra): formats = list(get_output_formats(self.platform).keys()) formats[formats.index(default_format)] = f"{default_format} (default)" supported_formats_helptext = ( - "\nSupported formats:\n" - f" {', '.join(sorted(formats, key=str.lower))}" + f"Supported formats:\n {', '.join(sorted(formats, key=str.lower))}" ) else: supported_formats_helptext = "" - width = max(min(shutil.get_terminal_size().columns, 80) - 2, 20) parser = argparse.ArgumentParser( prog=self.cmd_line.format( command=self.command, platform=self.platform, output_format=self.output_format, ), - description=( - f"{textwrap.fill(self.description, width=width)}\n" - f"{supported_formats_helptext}" + description=self.input.textwrap( + f"{self.description}\n\n{supported_formats_helptext}" + ), + formatter_class=( + lambda prog: RawDescriptionHelpFormatter(prog, width=MAX_TEXT_WIDTH) ), - formatter_class=lambda prog: RawDescriptionHelpFormatter(prog, width=width), ) self.add_default_options(parser) diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py index 8424549dc..ca851fa9c 100644 --- a/src/briefcase/commands/new.py +++ b/src/briefcase/commands/new.py @@ -1,9 +1,7 @@ from __future__ import annotations import re -import shutil import sys -import textwrap import unicodedata from collections import OrderedDict from email.utils import parseaddr @@ -26,14 +24,12 @@ is_valid_bundle_identifier, make_class_name, ) -from briefcase.console import select_option +from briefcase.console import MAX_TEXT_WIDTH, select_option from briefcase.exceptions import BriefcaseCommandError, TemplateUnsupportedVersion from briefcase.integrations.git import Git from .base import BaseCommand -MAX_TEXT_WIDTH = max(min(shutil.get_terminal_size().columns, 80) - 2, 20) - def titlecase(s): """Convert a string to titlecase. @@ -178,17 +174,24 @@ def validate_app_name(self, candidate): """ if not is_valid_app_name(candidate): raise ValueError( - f"{candidate!r} is not a valid app name.\n\n" - "App names must not be reserved keywords such as 'and', 'for' and 'while'.\n" - "They must also be PEP508 compliant (i.e., they can only include letters,\n" - "numbers, '-' and '_'; must start with a letter; and cannot end with '-' or '_')." + self.input.textwrap( + f"{candidate!r} is not a valid app name.\n" + "\n" + "App names must not be reserved keywords such as 'and', 'for' and " + "'while'. They must also be PEP508 compliant (i.e., they can only " + "include letters, numbers, '-' and '_'; must start with a letter; " + "and cannot end with '-' or '_')." + ) ) if (self.base_path / candidate).exists(): raise ValueError( - f"A {candidate!r} directory already exists. Select a different " - "name, move to a different parent directory, or delete the " - "existing folder." + self.input.textwrap( + f"A {candidate!r} directory already exists.\n" + f"\n" + f"Select a different name, move to a different parent directory, or " + f"delete the existing folder." + ) ) return True @@ -210,11 +213,14 @@ def validate_bundle(self, candidate): """ if not is_valid_bundle_identifier(candidate): raise ValueError( - f"{candidate!r} is not a valid bundle identifier.\n\n" - "The bundle should be a reversed domain name. It must contain at least 2\n" - "dot-separated sections; each section may only include letters, numbers,\n" - "and hyphens; and each section may not contain any reserved words (like\n" - "'switch', or 'while')." + self.input.textwrap( + f"{candidate!r} is not a valid bundle identifier.\n" + "\n" + "The bundle should be a reversed domain name. It must contain at " + "least 2 dot-separated sections; each section may only include " + "letters, numbers, and hyphens; and each section may not contain any " + "reserved words (like 'switch', or 'while')." + ) ) return True @@ -289,14 +295,7 @@ def prompt_divider(self, title: str = ""): def prompt_intro(self, intro: str): """Write the introduction for a prompt.""" self.input.prompt() - # textwrap isn't really designed to format text that already contains newlines. - # So, instead, break the intro by newlines and format each line individually. - self.input.prompt( - "\n".join( - "\n".join(textwrap.wrap(line, MAX_TEXT_WIDTH)) - for line in intro.splitlines() - ) - ) + self.input.prompt(self.input.textwrap(intro)) self.input.prompt() def validate_user_input(self, validator, answer) -> bool: @@ -438,7 +437,7 @@ def build_app_context(self, project_overrides: dict[str, str]) -> dict[str, str] "usually the domain name of your company or project, in reverse order.\n" "\n" "For example, if you are writing an application for Example Corp, " - "whose website is example.com, your bundle would be ``com.example``. " + "whose website is example.com, your bundle would be 'com.example'. " "The bundle will be combined with your application's machine readable " "name to form a complete application identifier (e.g., " f"com.example.{app_name})." diff --git a/src/briefcase/console.py b/src/briefcase/console.py index b2c489059..4a2081f1f 100644 --- a/src/briefcase/console.py +++ b/src/briefcase/console.py @@ -5,7 +5,9 @@ import os import platform import re +import shutil import sys +import textwrap import time import traceback from contextlib import contextmanager @@ -29,6 +31,9 @@ from briefcase import __version__ +# Max width for printing to console; matches argparse's default width +MAX_TEXT_WIDTH = max(min(shutil.get_terminal_size().columns, 80) - 2, 20) + # Regex to identify settings likely to contain sensitive information SENSITIVE_SETTING_RE = re.compile(r"API|TOKEN|KEY|SECRET|PASS|SIGNATURE", flags=re.I) @@ -656,6 +661,14 @@ def release_console_control(self): if is_wait_bar_running: self._wait_bar.start() + def textwrap(self, text: str, width: int = MAX_TEXT_WIDTH) -> str: + """Wrap text to the console width, a default max width, or a specified width.""" + # textwrap isn't really designed to format text that already contains newlines. + # So, instead, break the text by newlines and format each line individually. + return "\n".join( + "\n".join(textwrap.wrap(line, width)) for line in text.splitlines() + ) + def prompt(self, *values, markup=False, **kwargs): """Print to the screen for soliciting user interaction if input enabled. diff --git a/tests/console/Console/test_textwrap.py b/tests/console/Console/test_textwrap.py new file mode 100644 index 000000000..6958bbc82 --- /dev/null +++ b/tests/console/Console/test_textwrap.py @@ -0,0 +1,63 @@ +import pytest + +from briefcase.console import Console + + +@pytest.mark.parametrize( + "in_text, out_text", + [ + ( + "There is nothing wrong with your television set.", + "There is nothing wrong with your television set.", + ), + ( + "There is nothing wrong with your television set.\n" + "Do not attempt to adjust the picture.", + "There is nothing wrong with your television set.\n" + "Do not attempt to adjust the picture.", + ), + ( + "There is nothing\n\n\nwrong with your television set.\n\n" + "Do not attempt to adjust the picture. We are controlling transmission. If we wish to make it louder, " + "we will bring\nup the volume.\n", + "There is nothing\n" + "\n" + "\n" + "wrong with your television set.\n" + "\n" + "Do not attempt to adjust the picture. We are controlling transmission. If we\n" + "wish to make it louder, we will bring\n" + "up the volume.", + ), + ( + "There is nothing wrong with your television set. Do not " + "attempt to adjust the picture. We are controlling transmission. " + "If we wish to make it louder, we will bring up the volume. If " + "we wish to make it softer, we will tune it to " + "a whisper. We will control the horizontal. We will control the vertical. " + "We can roll the image, make it flutter. We can change the " + "focus to a soft blur or sharpen it to crystal clarity. For the next " + "hour, sit quietly, and we will control all that you see and hear. We repeat: There is nothing " + "wrong with your television set.", + "There is nothing wrong with your television set. Do not attempt to adjust the\n" + "picture. We are controlling transmission. If we wish to make it louder, we\n" + "will bring up the volume. If we wish to make it softer, we will tune it to a\n" + "whisper. We will control the horizontal. We will control the vertical. We can\n" + "roll the image, make it flutter. We can change the focus to a soft blur or\n" + "sharpen it to crystal clarity. For the next hour, sit quietly, and we will\n" + "control all that you see and hear. We repeat: There is nothing wrong with your\n" + "television set.", + ), + ], +) +def test_textwrap(in_text, out_text): + """Text is formatted as expected.""" + assert Console().textwrap(in_text) == out_text + + +def test_textwrap_width_override(): + """Width override is respected.""" + in_text = "This is 27 characters long." + out_text = "This is 27\ncharacters long." + + assert Console().textwrap(in_text, width=20) == out_text