Repository: beeware/briefcase
Pull request #1722 (2024-04-07 18:34:49)

Consolidate text wrapping support for console printing
## Changes
- This consolidates the text wrapping support in `Console` from its original implementation in `NewCommand`.

## Notes
- If there isn't objection, the next step is to apply this everywhere the formatting is implemented directly in the code.
- That's a lot of places, though....so, I'm not sure yet if I'll try to do that in this PR or not.
- Notably, this will likely require converting the liberal use of  multil-ine `f"""..."""`  string definitions in to strings without newlines so `Console.textwrap()` can format them.
- Additionally, I think it would be ideal to textwrap the boxed `**** WARNING ****` messages...but that'll also take some thought for implementation.

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [X] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct


Patch:
diff --git a/changes/1722.misc.rst b/changes/1722.misc.rst
new file mode 100644
index 00000000..af601071
--- /dev/null
+++ b/changes/1722.misc.rst
@@ -0,0 +1,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 31033945..94544c27 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 e284e3e9..6404a2a8 100644
--- a/src/briefcase/cmdline.py
+++ b/src/briefcase/cmdline.py
@@ -1,7 +1,7 @@
+from __future__ import annotations
+
 import argparse
-import shutil
 import sys
-import textwrap
 from argparse import RawDescriptionHelpFormatter
 
 from briefcase import __version__
@@ -18,6 +18,7 @@ from briefcase.commands import (
     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 +37,21 @@ COMMANDS = [
 ]
 
 
-def parse_cmdline(args):
+def parse_cmdline(args, console: Console | None = None):
     """Parses the command line to determine the Command and its arguments.
 
     :param args: the arguments provided at the command line
+    :param console: interface for interacting with the console
     :return: Command and command-specific arguments
     """
+    if console is None:
+        console = Console()
+
     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 +62,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 +82,9 @@ def parse_cmdline(args):
         ),
         usage="briefcase [-h] <command> [<platform>] [<format>] ...",
         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 aa088ec6..6e9772e5 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -6,10 +6,8 @@ import importlib.metadata
 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 @@ else:  # pragma: no-cover-if-gte-py311
 
 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 @@ any compatibility problems, and then add the compatibility declaration.
             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 8424549d..ca851fa9 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 @@ from briefcase.config import (
     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 @@ class NewCommand(BaseCommand):
         """
         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 @@ class NewCommand(BaseCommand):
         """
         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 @@ class NewCommand(BaseCommand):
     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 @@ class NewCommand(BaseCommand):
                 "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 b2c48905..4a2081f1 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -5,7 +5,9 @@ import operator
 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 rich.traceback import Trace, Traceback
 
 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 @@ class Console:
             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.
 


Test patch:
diff --git a/tests/console/Console/test_textwrap.py b/tests/console/Console/test_textwrap.py
new file mode 100644
index 00000000..1c135e8d
--- /dev/null
+++ b/tests/console/Console/test_textwrap.py
@@ -0,0 +1,83 @@
+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
+
+
+@pytest.mark.parametrize(
+    "width, in_text, out_text",
+    [
+        (20, "This is 27 characters long.", "This is 27\ncharacters long."),
+        (
+            50,
+            "This is 57 characters long. This is 57 characters long.",
+            "This is 57 characters long. This is 57 characters\nlong.",
+        ),
+        (
+            80,
+            "This is 83 characters long. This is 83 characters long. This is 83 characters long.",
+            "This is 83 characters long. This is 83 characters long. This is 83 characters\nlong.",
+        ),
+        (
+            120,
+            "This is 144 characters long. This is 144 characters long. This is 144 characters long. "
+            "This is 144 characters long. This is 144 characters long.",
+            "This is 144 characters long. This is 144 characters long. "
+            "This is 144 characters long. This is 144 characters long. This\nis 144 characters long.",
+        ),
+    ],
+)
+def test_textwrap_width_override(width, in_text, out_text):
+    """Width override is respected."""
+    assert Console().textwrap(in_text, width=width) == out_text