Repository: beeware/briefcase

Issue:
The `-v` flag no longer prints debug output to the console
### Describe the bug

When Briefcase is started with the `-v` flag, debug output is no longer printed to the console. Furthermore, when `-vv` is used, both debug and deep debug output is printed.

The log file, though, doesn't appear to be affected and continues to always include debug logging and the `-vv` flag still includes deep debug.

### Steps to reproduce

Run `briefcase build -v`.

### Expected behavior

When the `-v` is used, debug output should be printed to the console.

### Screenshots

_No response_

### Environment

- Operating System: pop os 22.04
- Python version: 3.10.13
- Software versions:
  - Briefcase: v0.3.16

### Logs

_No response_

### Additional context

_No response_

Discussion:
rmartin16: Looking at #1475 a bit more, it seems as though the intention was to effectively introduce a new level of logging between `info` and `debug`; although, in doing so, it redefined _all_ `debug` logging as `deep debug`. Therefore, any logging previously considered `debug` is only enabled when `deep debug` logging is also enabled.

Given that was ostensible objective, this PR (as well as #1501) restores the ability to include `debug` logging without needing to include `deep debug` logging....but it doesn't currently allow macOS packaging to use this new level of logging....
rmartin16: I think the logging desires of #1475 might be best achieved by adding `quiet` to `subprocess.run()` the same way it was added to `subprocess.check_output()`. In that way, macOS packaging could drive the `quiet` argument based on the current level of verbosity.

Or perhaps, even, just let `Subprocess` drive the `quiet` argument using the logging verbosity...
freakboy3742: > Looking at #1475 a bit more, it seems as though the intention was to effectively introduce a new level of logging between `info` and `debug`; although, in doing so, it redefined _all_ `debug` logging as `deep debug`. Therefore, any logging previously considered `debug` is only enabled when `deep debug` logging is also enabled.

I think that's a correct interpretation of the intent. 

The underlying problem was that some of the macOS commands generate important warning-level signals that the user really needs to see (as they indicate problems that don't *stop* progress, but *might* need to be addressed), but the macOS logs also contained extremely verbose tracking of progress. 

As a practical example: if you build an app using numpy or pillow, the merge of the x86 and arm wheels reveals a couple of files that are different between the two platforms. As it happens, this isn't *actually* a problem - but it's a potential indicator of a problem, and you should be confirming that the differences aren't actually a problem. However, the merge process then output dozens of lines talking about the individual files being merged, and then listed every file being signed, and the warning got completely obscured from the user's screen unless you did a deep dive on the logs... and ain't nobody got time to do that...:-)

Given that the per-file output isn't *really* needed since we have a really good progress bar, the intention was to downgrade the per-file output to "verbose mode"; that way, the default output will clearly show the warning, but only show a progress bar for merging and signing, so you're more likely to see the warning. 

However, we then needed a way to differentiate -v output from the output level that dumps the underlying subprocess detail. That's a deeper level of detail still that isn't needed if you just want to confirm that the right list of files has been merged/signed.

freakboy3742: > I think the logging desires of #1475 might be best achieved by adding `quiet` to `subprocess.run()` the same way it was added to `subprocess.check_output()`. In that way, macOS packaging could drive the `quiet` argument based on the current level of verbosity.

I'm not sure I agree. `quiet` exists specifically to work around the verbosity level, not an implementation of verbosity. It's only used in 1 situation IIRC - when you're rapidly polling ADB for process status. In that instance, having dozens of calls logging the same command line, env etc is just noise, even if you *have* asked for "deep debug" that displays the subprocess detail. In an ideal world, we'd have the first invocation outputting the command line in the logs, followed by something like "[42 identical calls]" that let you know you were hammering the same thing over and over - but I'm not sure that's worth the effort to set up.

> [edit] additionally....given that signing is multi-threaded....the debug output is indecipherable for any practical purposes...

In what way? I agree that the output *previously* didn't work well with subprocessing because each line of output didn't include enough context to say what which file it related to; but I though I had fixed that in #1475 by including the filename every time a line is output. Unless you're seeing some sort of output where lines from different threads are being interwoven... but I haven't seen that myself.

I agree that the output is indecipherable when you turn on *deep* debug - but I'm not sure how we'd address that short of some sort of locking around the output of the subprocess preamble...
rmartin16: I'll be able to respond more fully tomorrow...but I'm mostly interested in what you'd like to see at this point.

There seems to be a need to introduce a logging level between info and debug....so, that being the case, do we want to re-define what `-v` and `-vv` mean? while also introducing `-vvv` I think?

In this way, we might have:
- `-v`: verbose
- `-vv`: debug
- `-vvv`: deep debug
rmartin16: For the time being, we can ignore the current implementation in this PR; I think I implemented it too hastily before fully understanding #1475.

I'd like your thoughts on my considerations and especially ones I've failed to consider at all.

Here's my thoughts to introduce a new logging level between `info` and `debug` called `verbose`:
- Zero `-v` flags remains info mode
  - Only calls to `Log.info()` are shown in the console
- Redefine `-v` as verbose mode
  - This would introduce `Log.verbose()` and would replace the logging in #[1475] that is gated by a `verbosity >= 1`
- Redefine `-vv` as debug mode
  - This would include all calls to `Log.debug()` including subprocess details
- Introduce `-vvv` as deep debug mode
  - This increases the verbosity for third party tools own logging
  - This would not inherently necessitate creating a `Log.deep_debug()` because we don't have any actual Briefcase logging at this level
  - Since this level of verbosity is really quite disconnected from Briefcase logging altogether....creating a dedicated flag for tool logging verbosity may make sense

To reiterate, I really don't want to merge debug in to deep debug because the third party tool logging is very noisy and I'm usually most interested in the subprocess details.

I suppose another alternative is to leave logging as is and instead summarize the warnings and errors for a user once the process completes. I was going to implement something like this for linuxdeploy logging....but it doesn't really seem as worthwhile now with deprecating AppImage. I'm also not sure if that's reasonably an option for the use-case here.

> > [edit] additionally....given that signing is multi-threaded....the debug output is indecipherable for any practical purposes...
> 
> In what way? I agree that the output _previously_ didn't work well with subprocessing because each line of output didn't include enough context to say what which file it related to; but I though I had fixed that in #1475 by including the filename every time a line is output. Unless you're seeing some sort of output where lines from different threads are being interwoven... but I haven't seen that myself.
> 
> I agree that the output is indecipherable when you turn on _deep_ debug - but I'm not sure how we'd address that short of some sort of locking around the output of the subprocess preamble...

Our terminology is becoming skewed; I was referring to the original debug mode that includes printing subprocess detail. Nonetheless, when subprocess details are included, all of the threads are printing over each other and you can't make heads or tails of it....this is also probably where I started thinking that `quiet` might be useful because printing the subprocess detail in this case isn't useful.

It's also probably pertinent to keep in mind that using `quiet` or gating logging behind a `if verbosity >=` check in the codebase means the logging doesn't appear anywhere including in the created logfile. So, we should definitely limit this to truly useless logging (like the rapid `adb.pid_exists()` calls).

Bringing in comments from #1501:
> I don't think it's entirely surprising that a change to DEBUG didn't trigger anything in the tests; log output isn't generally asserted in tests unless either the output is the point, or output is the only signal that something has been done.

Yes...but only be accident. Had the subprocess tests been properly setting `logger.verbosity` to `1` instead of `2`, then changing `Log.DEBUG` in #1475 would have caused all those tests to start failing.

> However, there's definitely some unintended damage from the DEBUG=1->2 change.
> 
> For me, I think the underlying issue is that we've got multiple competing terminology and descriptions of debug level - there's the call we use to do the debug (logger.info, logger.debug etc), and the constant that describes log level. We've also got DEBUG_PREFIX, which is really a "subprocess prefix" rather than anything related to debug.

While it's true that `DEBUG_PREFIX` is largely used to print subprocess detail, it is not regulated to that. Debug logging also includes dropping bread crumbs during Tool verification and android skin installation.

> My inclination is that rather than having `if verbosity >=` calls in the general codebase, verbosity should be checked exactly once

Completely agree; they are removed in this implementation and will be in any final implementation.

> and we should be using the appropriate `logger` call. That likely means adding a `logger.deep_debug` that is only used by subprocess and selected "way-too-much-detail" logging options (I can't think of any in particular candidates, but at least conceptually it's not *explicitly* a subprocess feature).

Included in my design above with the extra details for third party tool logging; FWIW, I think it might also be reasonable to create a new flag altogether to enable verbose third tool logging so it isn't wrapped up in the verbosity flag definition.
freakboy3742: > Here's my thoughts to introduce a new logging level between `info` and `debug` called `verbose`:

This all makes sense to me. My only hesitation is that the naming doesn't map to traditional info/warning/debug logging labels - but those labels also don't have enough fidelity for what we're describing here, and I think the names you've proposed make sense in the context.

> To reiterate, I really don't want to merge debug in to deep debug because the third party tool logging is very noisy and I'm usually most interested in the subprocess details.

That definitely makes sense - I'd forgotten that there is a debug level "deeper" than showing the subprocess details.
 
> I suppose another alternative is to leave logging as is and instead summarize the warnings and errors for a user once the process completes. I was going to implement something like this for linuxdeploy logging....but it doesn't really seem as worthwhile now with deprecating AppImage. I'm also not sure if that's reasonably an option for the use-case here.

That's an interesting idea - although it would need a lot more housekeeping to make sure those details aren't lost. It would definitely work for the macOS case; but once we've reduced the default verbosity, the need to use an approach like this is also massively reduced. At least for now, I don't think it's worth the effort.

> > However, there's definitely some unintended damage from the DEBUG=1->2 change.
> > For me, I think the underlying issue is that we've got multiple competing terminology and descriptions of debug level - there's the call we use to do the debug (logger.info, logger.debug etc), and the constant that describes log level. We've also got DEBUG_PREFIX, which is really a "subprocess prefix" rather than anything related to debug.
> 
> While it's true that `DEBUG_PREFIX` is largely used to print subprocess detail, it is not regulated to that. Debug logging also includes dropping bread crumbs during Tool verification and android skin installation.

I think the conflict here is that for me, the `>>>` prefix is mentally linked to subprocesses - it's a visual marker that this content comes from calling an external tool. Using that prefix for tool verification isn't adding any *additional* signal to the log that can't already be derived from the `dim` setting.

Perhaps the fix here is to use `dim` for debug-level output, but only include the `>>>` prefix when a subprocess is involved?
 
> > and we should be using the appropriate `logger` call. That likely means adding a `logger.deep_debug` that is only used by subprocess and selected "way-too-much-detail" logging options (I can't think of any in particular candidates, but at least conceptually it's not _explicitly_ a subprocess feature).
> 
> Included in my design above with the extra details for third party tool logging; FWIW, I think it might also be reasonable to create a new flag altogether to enable verbose third tool logging so it isn't wrapped up in the verbosity flag definition.

An interesting idea; however, I'm having difficulty of thinking of a situation where we'd need to differentiate -vvv from "only show the debug level output from subprocesses". By the time you're running -vvv, you're already saying "something has gone badly wrong, give me everything so I can work out what broke". I don't think this is an area where having complicated interacting flags will be beneficial :-)


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d54e80e4..f8baf6bc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,7 +56,7 @@ jobs:
           experimental: false
     steps:
     - name: Checkout
-      uses: actions/checkout@v4.1.1
+      uses: actions/checkout@v4.1.0
       with:
         fetch-depth: 0
 
@@ -112,7 +112,7 @@ jobs:
     if: always() && contains('success,failure', needs.unit-tests.result)
     steps:
     - name: Checkout
-      uses: actions/checkout@v4.1.1
+      uses: actions/checkout@v4.1.0
       with:
         fetch-depth: 0
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 859109eb..60777d75 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
       - id: docformatter
         args: [--in-place, --black]
   - repo: https://github.com/psf/black
-    rev: 23.10.0
+    rev: 23.9.1
     hooks:
       - id: black
         language_version: python3
diff --git a/changes/1500.removal.rst b/changes/1500.removal.rst
deleted file mode 100644
index e6def385..00000000
--- a/changes/1500.removal.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The use of AppImage as an output format now generates a warning.
diff --git a/changes/1501.feature.rst b/changes/1501.feature.rst
new file mode 100644
index 00000000..aaf860ee
--- /dev/null
+++ b/changes/1501.feature.rst
@@ -0,0 +1,1 @@
+The verbosity flag, ``-v``, was expanded to support three levels of logging verbosity.
diff --git a/changes/1504.misc.rst b/changes/1504.misc.rst
deleted file mode 100644
index 0be93000..00000000
--- a/changes/1504.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``black`` was updated to its latest version.
diff --git a/changes/1505.misc.rst b/changes/1505.misc.rst
deleted file mode 100644
index 160e818f..00000000
--- a/changes/1505.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated actions/checkout from 4.1.0 to 4.1.1.
diff --git a/changes/1506.misc.rst b/changes/1506.misc.rst
deleted file mode 100644
index f8053b18..00000000
--- a/changes/1506.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated sphinx-tabs from 3.4.1 to 3.4.4.
diff --git a/changes/1507.bugfix.rst b/changes/1507.bugfix.rst
deleted file mode 100644
index 765db808..00000000
--- a/changes/1507.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The filter for iOS build warnings was improved to catch messages from Xcode 15.0.1.
diff --git a/setup.cfg b/setup.cfg
index ef926e61..da50c049 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -101,7 +101,7 @@ docs =
     # Sphinx 7.2 deprecated support for Python 3.8
     sphinx == 7.1.2 ; python_version < '3.9'
     sphinx == 7.2.6 ; python_version >= '3.9'
-    sphinx_tabs == 3.4.4
+    sphinx_tabs == 3.4.1
     sphinx-autobuild == 2021.3.14
     sphinx-copybutton == 0.5.2
     sphinxcontrib-spelling == 8.0.0
diff --git a/src/briefcase/cmdline.py b/src/briefcase/cmdline.py
index 9ad0a4e7..e284e3e9 100644
--- a/src/briefcase/cmdline.py
+++ b/src/briefcase/cmdline.py
@@ -105,10 +105,10 @@ def parse_cmdline(args):
 
     # argparse handles `--` specially, so make the passthrough args bypass the parser.
     def parse_known_args(args):
-        args, passthough = split_passthrough(args)
+        args, passthrough = split_passthrough(args)
         options, extra = parser.parse_known_args(args)
-        if passthough:
-            extra += ["--"] + passthough
+        if passthrough:
+            extra += ["--"] + passthrough
         return options, extra
 
     # Use parse_known_args to ensure any extra arguments can be ignored,
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index 86a180f8..06fe4131 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -693,7 +693,7 @@ any compatibility problems, and then add the compatibility declaration.
             "--verbosity",
             action="count",
             default=0,
-            help="Enable verbose logging. -vv enables super verbose mode.",
+            help="Enable verbose logging. Use -vv and -vvv to increase logging verbosity.",
         )
         parser.add_argument("-V", "--version", action="version", version=__version__)
         parser.add_argument(
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 2b45d030..44b938af 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -762,12 +762,10 @@ class CreateCommand(BaseCommand):
                 for path in self.bundle_path(app).glob(glob):
                     relative_path = path.relative_to(self.bundle_path(app))
                     if path.is_dir():
-                        if self.logger.verbosity >= 1:
-                            self.logger.info(f"Removing directory {relative_path}")
+                        self.logger.verbose(f"Removing directory {relative_path}")
                         self.tools.shutil.rmtree(path)
                     else:
-                        if self.logger.verbosity >= 1:
-                            self.logger.info(f"Removing {relative_path}")
+                        self.logger.verbose(f"Removing {relative_path}")
                         path.unlink()
 
     def create_app(self, app: AppConfig, test_mode: bool = False, **options):
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index da9cf099..3b6c6a13 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import operator
 import os
 import platform
@@ -6,6 +8,7 @@ import sys
 import traceback
 from contextlib import contextmanager
 from datetime import datetime
+from enum import IntEnum
 from pathlib import Path
 
 from rich.console import Console as RichConsole
@@ -113,19 +116,22 @@ class Printer:
         return cls.log.export_text()
 
 
+class LogLevel(IntEnum):
+    INFO = 0
+    VERBOSE = 1
+    DEBUG = 2
+    DEEP_DEBUG = 3
+
+
 class Log:
     """Manage logging output driven by verbosity flags."""
 
-    # Level of verbosity when debug output is shown in the console
-    DEBUG = 2
-    # Printed at the beginning of all debug output
-    DEBUG_PREFACE = ">>> "
     # subdirectory of command.base_path to store log files
     LOG_DIR = "logs"
 
-    def __init__(self, printer=Printer(), verbosity=0):
+    def __init__(self, printer=Printer(), verbosity: LogLevel = LogLevel.INFO):
         self.print = printer
-        # --verbosity flag: 0 for info, 1 for debug, 2 for super-verbose
+        # --verbosity flag: 0 for info, 1 for debug, 2 for deep debug
         self.verbosity = verbosity
         # --log flag to force logfile creation
         self.save_log = False
@@ -150,12 +156,12 @@ class Log:
         :param context: The name of the context to enter. This *must* be simple text,
             with no markup or other special characters.
         """
+        self.info()
+        self.info(f"Entering {context} context...")
+        old_context = self._context
+        self._context = f"{context}| "
+        self.info("-" * (72 - len(context)))
         try:
-            self.info()
-            self.info(f"Entering {context} context...")
-            old_context = self._context
-            self._context = f"{context}| "
-            self.info("-" * (72 - len(context)))
             yield
         finally:
             self.info("-" * (72 - len(context)))
@@ -207,17 +213,21 @@ class Log:
                     style=style,
                 )
 
-    def debug(self, message="", *, prefix="", markup=False):
-        """Log messages at debug level; included if verbosity>=2."""
+    def debug(self, message="", *, preface="", prefix="", markup=False):
+        """Log messages at debug level; included if verbosity >= 2."""
         self._log(
-            preface=self.DEBUG_PREFACE,
+            preface=preface,
             prefix=prefix,
             message=message,
-            show=self.verbosity >= self.DEBUG,
+            show=self.is_debug,
             markup=markup,
             style="dim",
         )
 
+    def verbose(self, message="", *, prefix="", markup=False):
+        """Log message at verbose level if debug enabled; included if verbosity >= 1."""
+        self._log(prefix=prefix, message=message, show=self.is_verbose, markup=markup)
+
     def info(self, message="", *, prefix="", markup=False):
         """Log message at info level; always included in output."""
         self._log(prefix=prefix, message=message, markup=markup)
@@ -230,13 +240,38 @@ class Log:
         """Log message at error level; always included in output."""
         self._log(prefix=prefix, message=message, markup=markup, style="bold red")
 
+    @property
+    def verbosity(self) -> LogLevel:
+        return self._verbosity
+
+    @verbosity.setter
+    def verbosity(self, value: int | LogLevel):
+        """Clamp verbosity between valid log levels."""
+        self._verbosity = LogLevel(max(min(LogLevel), min(value, max(LogLevel))))
+
+    @property
+    def is_verbose(self):
+        """Is verbose logging enabled via the -v flag?
+
+        This includes Briefcase logging that isn't relevant during normal operation.
+        """
+        return self.verbosity >= LogLevel.VERBOSE
+
+    @property
+    def is_debug(self):
+        """Is debug logging enabled via the -vv flag?
+
+        This includes debug logging from third party tools.
+        """
+        return self.verbosity >= LogLevel.DEBUG
+
     @property
     def is_deep_debug(self):
-        """Is extra verbosity enabled via -vv?
+        """Is deep debug logging enabled via the -vvv flag?
 
-        Enables verbose output from third-party tools.
+        This includes debug logging from third party tools.
         """
-        return self.verbosity >= 2
+        return self.verbosity >= LogLevel.DEEP_DEBUG
 
     def capture_stacktrace(self, label="Main thread"):
         """Preserve Rich stacktrace from exception while in except block.
@@ -580,7 +615,7 @@ class Console:
         try:
             input_value = self.input(prompt, markup=markup)
             self.print.to_log(prompt)
-            self.print.to_log(f"{Log.DEBUG_PREFACE}User input: {input_value}")
+            self.print.to_log(f"User input: {input_value}")
             return input_value
         except EOFError:
             raise KeyboardInterrupt
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 3434eef6..a26339df 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -706,19 +706,21 @@ class Subprocess(Tool):
             self.tools.logger.warning(f"Forcibly killing {label}...")
             popen_process.kill()
 
+    def _log(self, msg: str = ""):
+        """Funnel for all subprocess details logging."""
+        self.tools.logger.debug(msg, preface=">>> " if msg else "")
+
     def _log_command(self, args: SubprocessArgsT):
         """Log the entire console command being executed."""
-        self.tools.logger.debug()
-        self.tools.logger.debug("Running Command:")
-        self.tools.logger.debug(
-            f"    {' '.join(shlex.quote(str(arg)) for arg in args)}"
-        )
+        self._log()
+        self._log("Running Command:")
+        self._log(f"    {' '.join(shlex.quote(str(arg)) for arg in args)}")
 
     def _log_cwd(self, cwd: str | Path | None):
-        """Log the working directory for the  command being executed."""
+        """Log the working directory for the command being executed."""
         effective_cwd = Path.cwd() if cwd is None else cwd
-        self.tools.logger.debug("Working Directory:")
-        self.tools.logger.debug(f"    {effective_cwd}")
+        self._log("Working Directory:")
+        self._log(f"    {effective_cwd}")
 
     def _log_environment(self, overrides: dict[str, str] | None):
         """Log the environment variables overrides prior to command execution.
@@ -727,22 +729,22 @@ class Subprocess(Tool):
             can be `None` if there are no explicit environment changes.
         """
         if overrides:
-            self.tools.logger.debug("Environment Overrides:")
+            self._log("Environment Overrides:")
             for env_var, value in overrides.items():
-                self.tools.logger.debug(f"    {env_var}={value}")
+                self._log(f"    {env_var}={value}")
 
     def _log_output(self, output: str, stderr: str | None = None):
         """Log the output of the executed command."""
         if output:
-            self.tools.logger.debug("Command Output:")
+            self._log("Command Output:")
             for line in ensure_str(output).splitlines():
-                self.tools.logger.debug(f"    {line}")
+                self._log(f"    {line}")
 
         if stderr:
-            self.tools.logger.debug("Command Error Output (stderr):")
+            self._log("Command Error Output (stderr):")
             for line in ensure_str(stderr).splitlines():
-                self.tools.logger.debug(f"    {line}")
+                self._log(f"    {line}")
 
     def _log_return_code(self, return_code: int | str):
         """Log the output value of the executed command."""
-        self.tools.logger.debug(f"Return code: {return_code}")
+        self._log(f"Return code: {return_code}")
diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py
index ceaa4c85..55f47acb 100644
--- a/src/briefcase/platforms/linux/appimage.py
+++ b/src/briefcase/platforms/linux/appimage.py
@@ -96,24 +96,6 @@ class LinuxAppImagePassiveMixin(LinuxMixin):
 """
             )
 
-        self.logger.warning(
-            """\
-*************************************************************************
-** WARNING: Use of AppImage is not recommended!                        **
-*************************************************************************
-
-    Briefcase supports AppImage in a best-effort capacity. It has proven
-    to be highly unreliable as a distribution platform. AppImages cannot
-    use pre-compiled binary wheels, and has significant problems with
-    most commonly used GUI toolkits (including GTK and PySide).
-
-    Consider using system packages or Flatpak for Linux app
-    distribution.
-
-*************************************************************************
-"""
-        )
-
 
 class LinuxAppImageMostlyPassiveMixin(LinuxAppImagePassiveMixin):
     # The Mostly Passive mixin verifies that Docker exists and can be run, but
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 8d7f2aa8..9fe422c4 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -364,8 +364,7 @@ or
             process_command.append("--options")
             process_command.append(options)
 
-        if self.logger.verbosity >= 1:
-            self.logger.info(f"Signing {Path(path).relative_to(self.base_path)}")
+        self.logger.verbose(f"Signing {Path(path).relative_to(self.base_path)}")
 
         try:
             self.tools.subprocess.run(
@@ -376,10 +375,9 @@ or
         except subprocess.CalledProcessError as e:
             errors = e.stderr
             if "code object is not signed at all" in errors:
-                if self.logger.verbosity >= 1:
-                    self.logger.info(
-                        f"... {Path(path).relative_to(self.base_path)} requires a deep sign; retrying"
-                    )
+                self.logger.verbose(
+                    f"... {Path(path).relative_to(self.base_path)} requires a deep sign; retrying"
+                )
                 try:
                     self.tools.subprocess.run(
                         process_command + ["--deep"],
@@ -402,10 +400,9 @@ or
                 ]
             ):
                 # We should not be signing this in the first place
-                if self.logger.verbosity >= 1:
-                    self.logger.info(
-                        f"... {Path(path).relative_to(self.base_path)} does not require a signature"
-                    )
+                self.logger.verbose(
+                    f"... {Path(path).relative_to(self.base_path)} does not require a signature"
+                )
                 return
             else:
                 raise BriefcaseCommandError(f"Unable to code sign {path}.")
@@ -711,16 +708,10 @@ password:
                 raise BriefcaseCommandError(
                     "Can't notarize an app with an ad-hoc signing identity"
                 )
-            self.logger.info(
-                "Signing app with ad-hoc identity...",
-                prefix=app.app_name,
-            )
+            self.logger.info("Signing app with ad-hoc identity...", prefix=app.app_name)
             self.logger.warning(
-                (
-                    "Because you are signing with the ad-hoc identity, this "
-                    "app will run, but cannot be re-distributed."
-                ),
-                prefix=app.app_name,
+                "Because you are signing with the ad-hoc identity, this "
+                "app will run, but cannot be re-distributed."
             )
         else:
             # If we're signing, and notarization isn't explicitly disabled,
@@ -729,7 +720,8 @@ password:
                 notarize_app = True
 
             self.logger.info(
-                f"Signing app with identity {identity_name}...", prefix=app.app_name
+                f"Signing app with identity {identity_name}...",
+                prefix=app.app_name,
             )
 
             if notarize_app:
@@ -737,6 +729,8 @@ password:
 
         self.sign_app(app=app, identity=identity)
 
+        dist_path: Path = self.distribution_path(app)
+
         if app.packaging_format == "app":
             if notarize_app:
                 self.logger.info(
@@ -745,11 +739,9 @@ password:
                 )
                 self.notarize(self.binary_path(app), team_id=team_id)
 
-            with self.input.wait_bar(
-                f"Archiving {self.distribution_path(app).name}..."
-            ):
+            with self.input.wait_bar(f"Archiving {dist_path.name}..."):
                 self.tools.shutil.make_archive(
-                    self.distribution_path(app).with_suffix(""),
+                    dist_path.with_suffix(""),
                     format="zip",
                     root_dir=self.binary_path(app).parent,
                     base_dir=self.binary_path(app).name,
@@ -758,68 +750,65 @@ password:
         else:  # Default packaging format is DMG
             self.logger.info("Building DMG...", prefix=app.app_name)
 
-            dmg_settings = {
-                "files": [os.fsdecode(self.binary_path(app))],
-                "symlinks": {"Applications": "/Applications"},
-                "icon_locations": {
-                    f"{app.formal_name}.app": (75, 75),
-                    "Applications": (225, 75),
-                },
-                "window_rect": ((600, 600), (350, 150)),
-                "icon_size": 64,
-                "text_size": 12,
-            }
+            with self.input.wait_bar(f"Building {dist_path.name}..."):
+                dmg_settings = {
+                    "files": [os.fsdecode(self.binary_path(app))],
+                    "symlinks": {"Applications": "/Applications"},
+                    "icon_locations": {
+                        f"{app.formal_name}.app": (75, 75),
+                        "Applications": (225, 75),
+                    },
+                    "window_rect": ((600, 600), (350, 150)),
+                    "icon_size": 64,
+                    "text_size": 12,
+                }
 
-            try:
-                icon_filename = self.base_path / f"{app.installer_icon}.icns"
-                if not icon_filename.exists():
-                    self.logger.warning(
-                        f"Can't find {app.installer_icon}.icns to use as DMG installer icon"
-                    )
-                    raise AttributeError()
-            except AttributeError:
-                # No installer icon specified. Fall back to the app icon
-                if app.icon:
-                    icon_filename = self.base_path / f"{app.icon}.icns"
+                try:
+                    icon_filename = self.base_path / f"{app.installer_icon}.icns"
                     if not icon_filename.exists():
                         self.logger.warning(
-                            f"Can't find {app.icon}.icns to use as fallback DMG installer icon"
+                            f"Can't find {app.installer_icon}.icns to use as DMG installer icon"
                         )
+                        raise AttributeError()
+                except AttributeError:
+                    # No installer icon specified. Fall back to the app icon
+                    if app.icon:
+                        icon_filename = self.base_path / f"{app.icon}.icns"
+                        if not icon_filename.exists():
+                            self.logger.warning(
+                                f"Can't find {app.icon}.icns to use as fallback DMG installer icon"
+                            )
+                            icon_filename = None
+                    else:
+                        # No app icon specified either
                         icon_filename = None
-                else:
-                    # No app icon specified either
-                    icon_filename = None
 
-            if icon_filename:
-                dmg_settings["icon"] = os.fsdecode(icon_filename)
+                if icon_filename:
+                    dmg_settings["icon"] = os.fsdecode(icon_filename)
 
-            try:
-                image_filename = self.base_path / f"{app.installer_background}.png"
-                if image_filename.exists():
-                    dmg_settings["background"] = os.fsdecode(image_filename)
-                else:
-                    self.logger.warning(
-                        f"Can't find {app.installer_background}.png to use as DMG background"
-                    )
-            except AttributeError:
-                # No installer background image provided
-                pass
-
-            dmg_path = self.distribution_path(app)
-            self.dmgbuild.build_dmg(
-                filename=os.fsdecode(dmg_path),
-                volume_name=f"{app.formal_name} {app.version}",
-                settings=dmg_settings,
-            )
+                try:
+                    image_filename = self.base_path / f"{app.installer_background}.png"
+                    if image_filename.exists():
+                        dmg_settings["background"] = os.fsdecode(image_filename)
+                    else:
+                        self.logger.warning(
+                            f"Can't find {app.installer_background}.png to use as DMG background"
+                        )
+                except AttributeError:
+                    # No installer background image provided
+                    pass
+
+                self.dmgbuild.build_dmg(
+                    filename=os.fsdecode(dist_path),
+                    volume_name=f"{app.formal_name} {app.version}",
+                    settings=dmg_settings,
+                )
 
-            self.sign_file(
-                dmg_path,
-                identity=identity,
-            )
+            self.sign_file(dist_path, identity=identity)
 
             if notarize_app:
                 self.logger.info(
                     f"Notarizing DMG with team ID {team_id}...",
                     prefix=app.app_name,
                 )
-                self.notarize(dmg_path, team_id=team_id)
+                self.notarize(dist_path, team_id=team_id)
diff --git a/src/briefcase/platforms/macOS/filters.py b/src/briefcase/platforms/macOS/filters.py
index eefd1dc6..14f307eb 100644
--- a/src/briefcase/platforms/macOS/filters.py
+++ b/src/briefcase/platforms/macOS/filters.py
@@ -112,7 +112,7 @@ class XcodeBuildFilter:
         XCODEBUILD_PREFIX + r"\[MT\] DVTAssertions: "
         r"Warning in /System/Volumes/Data/SWE/Apps/DT/BuildRoots/BuildRoot11/"
         r"ActiveBuildRoot/Library/Caches/com.apple.xbs/Sources/IDEFrameworks/"
-        r"IDEFrameworks-\d+/IDEFoundation/Provisioning"
+        r"IDEFrameworks-22267/IDEFoundation/Provisioning"
         r"/Capabilities Infrastructure/IDECapabilityQuerySelection.swift:\d+"
     )
 
diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py
index 840de9fe..eac85353 100644
--- a/src/briefcase/platforms/macOS/utils.py
+++ b/src/briefcase/platforms/macOS/utils.py
@@ -85,13 +85,11 @@ class AppPackagesMergeMixin:
             ) from e
         else:
             if output.startswith("Non-fat file: "):
-                if self.logger.verbosity >= 1:
-                    self.logger.info(f"{path} is already thin.")
+                self.logger.verbose(f"{path} is already thin.")
             elif output.startswith("Architectures in the fat file: "):
                 architectures = set(output.strip().split(":")[-1].strip().split(" "))
                 if arch in architectures:
-                    if self.logger.verbosity >= 1:
-                        self.logger.info(f"Thinning {path}")
+                    self.logger.verbose(f"Thinning {path}")
                     try:
                         thin_lib_path = path.parent / f"{path.name}.{arch}"
                         self.tools.subprocess.run(
@@ -129,8 +127,7 @@ class AppPackagesMergeMixin:
         :param target_path: The root location where the fat library will be written
         :param sources: A list of root locations providing single platform libraries.
         """
-        if self.logger.verbosity >= 1:
-            self.logger.info(f"Creating fat library {relative_path}")
+        self.logger.verbose(f"Creating fat library {relative_path}")
 
         try:
             # Ensure the directory where the library will be written exists.


Test patch:
diff --git a/tests/commands/base/test_cookiecutter_logging.py b/tests/commands/base/test_cookiecutter_logging.py
index 34be8b83..45f58cb2 100644
--- a/tests/commands/base/test_cookiecutter_logging.py
+++ b/tests/commands/base/test_cookiecutter_logging.py
@@ -2,6 +2,8 @@ import logging
 
 import pytest
 
+from briefcase.console import LogLevel
+
 cookiecutter_logger = logging.getLogger("cookiecutter")
 
 
@@ -16,9 +18,9 @@ def base_command(base_command):
 @pytest.mark.parametrize(
     "verbosity, log_level",
     [
-        (0, logging.INFO),
-        (1, logging.INFO),
-        (2, logging.DEBUG),
+        (LogLevel.INFO, logging.INFO),
+        (LogLevel.DEBUG, logging.INFO),
+        (LogLevel.DEEP_DEBUG, logging.DEBUG),
     ],
 )
 def test_cookiecutter_logging_config(base_command, verbosity, log_level):
diff --git a/tests/commands/base/test_parse_options.py b/tests/commands/base/test_parse_options.py
index a6620027..684e59dd 100644
--- a/tests/commands/base/test_parse_options.py
+++ b/tests/commands/base/test_parse_options.py
@@ -1,5 +1,7 @@
 import pytest
 
+from briefcase.console import LogLevel
+
 
 def test_parse_options(base_command):
     """Command line options are parsed if provided."""
@@ -11,7 +13,25 @@ def test_parse_options(base_command):
         "required": "important",
     }
     assert base_command.input.enabled
-    assert base_command.logger.verbosity == 0
+    assert base_command.logger.verbosity == LogLevel.INFO
+
+
+@pytest.mark.parametrize(
+    "verbosity, log_level",
+    [
+        ("", LogLevel.INFO),
+        ("-v", LogLevel.VERBOSE),
+        ("-vv", LogLevel.DEBUG),
+        ("-vvv", LogLevel.DEEP_DEBUG),
+        ("-vvvv", LogLevel.DEEP_DEBUG),
+        ("-vvvvv", LogLevel.DEEP_DEBUG),
+    ],
+)
+def test_verbosity(base_command, verbosity, log_level):
+    """The logging level is set correctly for the verbosity."""
+    base_command.parse_options(extra=filter(None, ("-r", "default", verbosity)))
+
+    assert base_command.logger.verbosity == log_level
 
 
 def test_missing_option(base_command, capsys):
@@ -27,7 +47,7 @@ def test_missing_option(base_command, capsys):
 
 
 def test_unknown_option(other_command, capsys):
-    """If an unknown command is provided, it rasises an error."""
+    """If an unknown command is provided, it raises an error."""
     with pytest.raises(SystemExit) as excinfo:
         other_command.parse_options(extra=("-y", "because"))
 
diff --git a/tests/commands/create/test_cleanup_app_content.py b/tests/commands/create/test_cleanup_app_content.py
index ba1eb353..a1638b00 100644
--- a/tests/commands/create/test_cleanup_app_content.py
+++ b/tests/commands/create/test_cleanup_app_content.py
@@ -1,5 +1,7 @@
 import pytest
 
+from briefcase.console import LogLevel
+
 from ...utils import create_file
 
 
@@ -23,7 +25,7 @@ def test_no_cleanup(create_command, myapp_unrolled, support_path, debug, capsys)
     """If there are no cleanup directives, bundle content isn't touched; but __pycache__
     is cleaned."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     # Cleanup app content
     create_command.cleanup_app_content(myapp_unrolled)
@@ -47,7 +49,7 @@ def test_no_cleanup(create_command, myapp_unrolled, support_path, debug, capsys)
 def test_dir_cleanup(create_command, myapp_unrolled, support_path, debug, capsys):
     """A directory can be cleaned up."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = ["path/to/support/dir1"]
 
@@ -70,7 +72,7 @@ def test_dir_cleanup(create_command, myapp_unrolled, support_path, debug, capsys
 def test_file_cleanup(create_command, myapp_unrolled, support_path, debug, capsys):
     """A single file can be cleaned up."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = ["path/to/support/dir1/a_file1.txt"]
 
@@ -98,7 +100,7 @@ def test_all_files_in_dir_cleanup(
 ):
     """All files in a directory can be cleaned up."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = ["path/to/support/dir1/*"]
 
@@ -126,7 +128,7 @@ def test_all_files_in_dir_cleanup(
 def test_dir_glob_cleanup(create_command, myapp_unrolled, support_path, debug, capsys):
     """A glob of directories can be cleaned up."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = ["path/to/support/dir*"]
 
@@ -149,7 +151,7 @@ def test_dir_glob_cleanup(create_command, myapp_unrolled, support_path, debug, c
 def test_file_glob_cleanup(create_command, myapp_unrolled, support_path, debug, capsys):
     """A glob of files can be cleaned up."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = ["path/to/support/dir1/*.txt"]
 
@@ -175,7 +177,7 @@ def test_file_glob_cleanup(create_command, myapp_unrolled, support_path, debug,
 def test_deep_glob_cleanup(create_command, myapp_unrolled, support_path, debug, capsys):
     """A glob that matches all directories will be added to the cleanup list."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = ["path/to/support/**/b_file.*"]
 
@@ -204,7 +206,7 @@ def test_template_glob_cleanup(
 ):
     """A glob of files specified in the template will be added to the cleanup list."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     # Define a cleanup_paths in the template *and* on the app
     create_command._briefcase_toml[myapp_unrolled] = {
@@ -243,7 +245,7 @@ def test_non_existent_cleanup(
 ):
     """Referencing a specific file that doesn't exist doesn't cause a problem."""
     if debug:
-        create_command.logger.verbosity = 1
+        create_command.logger.verbosity = LogLevel.DEBUG
 
     myapp_unrolled.cleanup_paths = [
         # This file exists
diff --git a/tests/console/test_Log.py b/tests/console/test_Log.py
index 444e1599..af0cf858 100644
--- a/tests/console/test_Log.py
+++ b/tests/console/test_Log.py
@@ -7,7 +7,7 @@ from rich.traceback import Trace
 
 import briefcase
 from briefcase.commands.dev import DevCommand
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import BriefcaseError
 
 TRACEBACK_HEADER = "Traceback (most recent call last)"
@@ -39,18 +39,71 @@ def command(mock_now, tmp_path) -> DevCommand:
 
 
 @pytest.mark.parametrize(
-    "verbosity, enabled",
+    "verbosity, verbose_enabled, debug_enabled, deep_debug_enabled",
     [
-        (0, False),
-        (1, False),
-        (2, True),
-        (3, True),
-        (4, True),
+        (-1, False, False, False),
+        (0, False, False, False),
+        (LogLevel.INFO, False, False, False),
+        (1, True, False, False),
+        (LogLevel.VERBOSE, True, False, False),
+        (2, True, True, False),
+        (LogLevel.DEBUG, True, True, False),
+        (3, True, True, True),
+        (LogLevel.DEEP_DEBUG, True, True, True),
+        (4, True, True, True),
+        (5, True, True, True),
     ],
 )
-def test_is_deep_debug(verbosity, enabled):
-    """Deep debug is enabled at =>2 verbosity."""
-    assert Log(verbosity=verbosity).is_deep_debug is enabled
+def test_log_level(verbosity, verbose_enabled, debug_enabled, deep_debug_enabled):
+    """Logging level is correct."""
+    assert Log(verbosity=verbosity).is_verbose is verbose_enabled
+    assert Log(verbosity=verbosity).is_debug is debug_enabled
+    assert Log(verbosity=verbosity).is_deep_debug is deep_debug_enabled
+
+
+def test_info_logging(capsys):
+    """The info level logging only includes info logs."""
+    logger = Log()
+
+    logger.info("info")
+    logger.verbose("verbose")
+    logger.debug("debug")
+
+    output = capsys.readouterr().out.splitlines()
+
+    assert "info" in output
+    assert "verbose" not in output
+    assert "debug" not in output
+
+
+def test_verbose_logging(capsys):
+    """The verbose level logging includes info and verbose logs."""
+    logger = Log(verbosity=LogLevel.VERBOSE)
+
+    logger.info("info")
+    logger.verbose("verbose")
+    logger.debug("debug")
+
+    output = capsys.readouterr().out.splitlines()
+
+    assert "info" in output
+    assert "verbose" in output
+    assert "debug" not in output
+
+
+def test_debug_logging(capsys):
+    """The debug level logging includes info, verbose and debug logs."""
+    logger = Log(verbosity=LogLevel.DEBUG)
+
+    logger.info("info")
+    logger.verbose("verbose")
+    logger.debug("debug")
+
+    output = capsys.readouterr().out.splitlines()
+
+    assert "info" in output
+    assert "verbose" in output
+    assert "debug" in output
 
 
 def test_capture_stacktrace():
@@ -107,7 +160,7 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
         "ANDROID_HOME": "/androidsdk",
     }
 
-    logger = Log(verbosity=2)
+    logger = Log(verbosity=LogLevel.DEBUG)
     logger.save_log = True
     logger.debug("this is debug output")
     logger.info("this is info output")
@@ -136,7 +189,7 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
         log_contents = log.read()
 
     assert log_contents.startswith("Date/Time:       2022-06-25 16:12:29")
-    assert ">>> this is debug output" in log_contents
+    assert "this is debug output" in log_contents
     assert "this is info output" in log_contents
     assert "this is [bold]info output with markup[/bold]" in log_contents
     assert "this is info output with escaped markup" in log_contents
@@ -323,7 +376,7 @@ def test_save_log_to_file_fail_to_write_file(
 
 def test_log_with_context(capsys):
     """Log file can be given a persistent context."""
-    logger = Log(verbosity=2)
+    logger = Log(verbosity=LogLevel.DEBUG)
     logger.save_log = False
 
     logger.info("this is info output")
@@ -350,7 +403,7 @@ def test_log_with_context(capsys):
             "Deep| ",
             "Deep| [prefix] prefixed deep context",
             "Deep| ",
-            "Deep| >>> this is deep debug",
+            "Deep| this is deep debug",
             "Deep| ",
             "Deep| Entering Really Deep context...",
             "Really Deep| -------------------------------------------------------------",
@@ -358,7 +411,7 @@ def test_log_with_context(capsys):
             "Really Deep| ",
             "Really Deep| [prefix2] prefixed really deep context",
             "Really Deep| ",
-            "Really Deep| >>> this is really deep debug",
+            "Really Deep| this is really deep debug",
             "Really Deep| -------------------------------------------------------------",
             "Deep| Leaving Really Deep context.",
             "Deep| ",
@@ -374,7 +427,7 @@ def test_log_with_context(capsys):
 
 def test_log_error_with_context(capsys):
     """If an exception is raised in a logging context, the context is cleared."""
-    logger = Log(verbosity=2)
+    logger = Log(verbosity=LogLevel.DEBUG)
     logger.save_log = False
 
     logger.info("this is info output")
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
index 84c1ccb4..012f36c3 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import (
     BriefcaseCommandError,
     IncompatibleToolError,
@@ -159,7 +160,7 @@ def test_succeeds_immediately_in_happy_path(mock_tools, tmp_path):
 def test_user_provided_sdk(mock_tools, env_var, tmp_path, capsys):
     """If the user environment specifies a valid Android SDK, it is used."""
     # Increase the log level.
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Create `sdkmanager` and the license file.
     existing_android_sdk_root_path = tmp_path / "other_sdk"
@@ -188,9 +189,9 @@ def test_user_provided_sdk(mock_tools, env_var, tmp_path, capsys):
         assert "Using Android SDK from ANDROID_SDK_ROOT" in capsys.readouterr().out
     else:
         assert capsys.readouterr().out == (
-            "\n>>> [Android SDK] Evaluating ANDROID_HOME...\n"
-            f">>> ANDROID_HOME={tmp_path / 'other_sdk'}\n"
-            f">>> Using Android SDK at {tmp_path / 'other_sdk'}\n"
+            "\n[Android SDK] Evaluating ANDROID_HOME...\n"
+            f"ANDROID_HOME={tmp_path / 'other_sdk'}\n"
+            f"Using Android SDK at {tmp_path / 'other_sdk'}\n"
         )
 
 
@@ -198,7 +199,7 @@ def test_consistent_user_provided_sdk(mock_tools, tmp_path, capsys):
     """If the user environment specifies a valid Android SDK in both ANDROID_HOME and
     ANDROID_SDK_ROOT, it is used."""
     # Increase the log level.
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Create `sdkmanager` and the license file.
     existing_android_sdk_root_path = tmp_path / "other_sdk"
@@ -227,16 +228,16 @@ def test_consistent_user_provided_sdk(mock_tools, tmp_path, capsys):
 
     # User is not warned about configuration
     assert capsys.readouterr().out == (
-        "\n>>> [Android SDK] Evaluating ANDROID_HOME...\n"
-        f">>> ANDROID_HOME={tmp_path / 'other_sdk'}\n"
-        f">>> Using Android SDK at {tmp_path / 'other_sdk'}\n"
+        "\n[Android SDK] Evaluating ANDROID_HOME...\n"
+        f"ANDROID_HOME={tmp_path / 'other_sdk'}\n"
+        f"Using Android SDK at {tmp_path / 'other_sdk'}\n"
     )
 
 
 def test_inconsistent_user_provided_sdk(mock_tools, tmp_path, capsys):
     """A warning is logged if ANDROID_HOME and ANDROID_SDK_ROOT are not the same."""
     # Increase the log level.
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Create `sdkmanager` and the license file.
     existing_android_sdk_root_path = tmp_path / "other_sdk"
diff --git a/tests/integrations/docker/test_DockerAppContext__check_output.py b/tests/integrations/docker/test_DockerAppContext__check_output.py
index c2f641be..c6a08c8d 100644
--- a/tests/integrations/docker/test_DockerAppContext__check_output.py
+++ b/tests/integrations/docker/test_DockerAppContext__check_output.py
@@ -4,6 +4,8 @@ from pathlib import Path
 
 import pytest
 
+from briefcase.console import LogLevel
+
 
 def test_simple_call(mock_docker_app_context, tmp_path, sub_check_output_kw, capsys):
     """A simple call will be invoked."""
@@ -192,7 +194,7 @@ def test_simple_verbose_call(
     capsys,
 ):
     """If verbosity is turned out, there is output."""
-    mock_docker_app_context.tools.logger.verbosity = 2
+    mock_docker_app_context.tools.logger.verbosity = LogLevel.DEBUG
 
     assert mock_docker_app_context.check_output(["hello", "world"]) == "goodbye\n"
 
diff --git a/tests/integrations/docker/test_DockerAppContext__run.py b/tests/integrations/docker/test_DockerAppContext__run.py
index 80d4b7f2..8619bfb0 100644
--- a/tests/integrations/docker/test_DockerAppContext__run.py
+++ b/tests/integrations/docker/test_DockerAppContext__run.py
@@ -4,6 +4,8 @@ from pathlib import Path
 
 import pytest
 
+from briefcase.console import LogLevel
+
 
 def test_simple_call(mock_docker_app_context, tmp_path, sub_stream_kw, capsys):
     """A simple call will be invoked."""
@@ -310,7 +312,7 @@ def test_interactive_with_path_arg_and_env_and_mounts(
 )
 def test_simple_verbose_call(mock_docker_app_context, tmp_path, sub_stream_kw, capsys):
     """If verbosity is turned out, there is output."""
-    mock_docker_app_context.tools.logger.verbosity = 2
+    mock_docker_app_context.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_docker_app_context.run(["hello", "world"])
 
diff --git a/tests/integrations/flatpak/test_Flatpak__build.py b/tests/integrations/flatpak/test_Flatpak__build.py
index 17a75664..cd262923 100644
--- a/tests/integrations/flatpak/test_Flatpak__build.py
+++ b/tests/integrations/flatpak/test_Flatpak__build.py
@@ -2,6 +2,7 @@ import subprocess
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +11,7 @@ def test_build(flatpak, tool_debug_mode, tmp_path):
     """A Flatpak project can be built."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        flatpak.tools.logger.verbosity = 2
+        flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     flatpak.build(
         bundle_identifier="com.example.my-app",
diff --git a/tests/integrations/flatpak/test_Flatpak__bundle.py b/tests/integrations/flatpak/test_Flatpak__bundle.py
index a88a5773..1c02b852 100644
--- a/tests/integrations/flatpak/test_Flatpak__bundle.py
+++ b/tests/integrations/flatpak/test_Flatpak__bundle.py
@@ -2,6 +2,7 @@ import subprocess
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +11,7 @@ def test_bundle(flatpak, tool_debug_mode, tmp_path):
     """A Flatpak project can be bundled."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        flatpak.tools.logger.verbosity = 2
+        flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     flatpak.bundle(
         repo_url="https://example.com/flatpak",
diff --git a/tests/integrations/flatpak/test_Flatpak__run.py b/tests/integrations/flatpak/test_Flatpak__run.py
index 8c322ecb..57e031c9 100644
--- a/tests/integrations/flatpak/test_Flatpak__run.py
+++ b/tests/integrations/flatpak/test_Flatpak__run.py
@@ -3,13 +3,15 @@ from unittest import mock
 
 import pytest
 
+from briefcase.console import LogLevel
+
 
 @pytest.mark.parametrize("tool_debug_mode", (True, False))
 def test_run(flatpak, tool_debug_mode):
     """A Flatpak project can be executed."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        flatpak.tools.logger.verbosity = 2
+        flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     # Set up the log streamer to return a known stream
     log_popen = mock.MagicMock()
@@ -40,7 +42,7 @@ def test_run_with_args(flatpak, tool_debug_mode):
     """A Flatpak project can be executed with additional arguments."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        flatpak.tools.logger.verbosity = 2
+        flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     # Set up the log streamer to return a known stream
     log_popen = mock.MagicMock()
diff --git a/tests/integrations/flatpak/test_Flatpak__verify_repo.py b/tests/integrations/flatpak/test_Flatpak__verify_repo.py
index e3ac136c..5c710396 100644
--- a/tests/integrations/flatpak/test_Flatpak__verify_repo.py
+++ b/tests/integrations/flatpak/test_Flatpak__verify_repo.py
@@ -2,6 +2,7 @@ import subprocess
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +11,7 @@ def test_verify_repo(flatpak, tool_debug_mode):
     """A Flatpak repo can be verified."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        flatpak.tools.logger.verbosity = 2
+        flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     flatpak.verify_repo(
         repo_alias="test-alias",
diff --git a/tests/integrations/flatpak/test_Flatpak__verify_runtime.py b/tests/integrations/flatpak/test_Flatpak__verify_runtime.py
index 591adf29..fc1a4db2 100644
--- a/tests/integrations/flatpak/test_Flatpak__verify_runtime.py
+++ b/tests/integrations/flatpak/test_Flatpak__verify_runtime.py
@@ -2,6 +2,7 @@ import subprocess
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +11,7 @@ def test_verify_runtime(flatpak, tool_debug_mode):
     """A Flatpak runtime and SDK can be verified."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        flatpak.tools.logger.verbosity = 2
+        flatpak.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     flatpak.verify_runtime(
         repo_alias="test-alias",
diff --git a/tests/integrations/subprocess/test_Subprocess__Popen.py b/tests/integrations/subprocess/test_Subprocess__Popen.py
index bdc9e57c..024648e8 100644
--- a/tests/integrations/subprocess/test_Subprocess__Popen.py
+++ b/tests/integrations/subprocess/test_Subprocess__Popen.py
@@ -4,6 +4,8 @@ from unittest.mock import ANY
 
 import pytest
 
+from briefcase.console import LogLevel
+
 from .conftest import CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW
 
 
@@ -122,7 +124,7 @@ def test_call_windows_with_start_new_session_and_creationflags(
 
 def test_debug_call(mock_sub, capsys, sub_kw):
     """If verbosity is turned up, there is output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
     mock_sub.Popen(["hello", "world"])
 
     mock_sub._subprocess.Popen.assert_called_with(["hello", "world"], **sub_kw)
@@ -138,7 +140,7 @@ def test_debug_call(mock_sub, capsys, sub_kw):
 
 def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_kw):
     """If verbosity is turned up, and injected env vars are included output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     env = {"NewVar": "NewVarValue"}
     mock_sub.Popen(["hello", "world"], env=env, cwd=tmp_path / "cwd")
diff --git a/tests/integrations/subprocess/test_Subprocess__check_output.py b/tests/integrations/subprocess/test_Subprocess__check_output.py
index 512f37f8..1394f622 100644
--- a/tests/integrations/subprocess/test_Subprocess__check_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__check_output.py
@@ -6,6 +6,8 @@ from unittest.mock import ANY
 
 import pytest
 
+from briefcase.console import LogLevel
+
 from .conftest import CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW
 
 
@@ -129,7 +131,7 @@ def test_call_windows_with_start_new_session_and_creationflags(
 
 def test_debug_call(mock_sub, capsys, sub_check_output_kw):
     """If verbosity is turned up, there is output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_sub.check_output(["hello", "world"])
 
@@ -155,7 +157,7 @@ def test_debug_call(mock_sub, capsys, sub_check_output_kw):
 
 def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_check_output_kw):
     """If verbosity is turned up, injected env vars are included in output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     env = {"NewVar": "NewVarValue"}
     mock_sub.check_output(["hello", "world"], env=env, cwd=tmp_path / "cwd")
@@ -189,7 +191,7 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_check_output_kw):
 
 def test_debug_call_with_quiet(mock_sub, capsys, tmp_path, sub_check_output_kw):
     """If quiet mode is on, calls aren't logged, even if verbosity is turned up."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     env = {"NewVar": "NewVarValue"}
     mock_sub.check_output(
@@ -215,7 +217,7 @@ def test_debug_call_with_quiet(mock_sub, capsys, tmp_path, sub_check_output_kw):
 
 def test_debug_call_with_stderr(mock_sub, capsys, tmp_path, sub_check_output_kw):
     """If stderr is specified, it is not defaulted to stdout."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_sub.check_output(
         ["hello", "world"],
@@ -248,7 +250,7 @@ def test_debug_call_with_stderr(mock_sub, capsys, tmp_path, sub_check_output_kw)
 
 def test_calledprocesserror_exception_logging(mock_sub, capsys):
     """If command errors, ensure command output is printed."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     called_process_error = CalledProcessError(
         returncode=-1,
@@ -281,7 +283,7 @@ def test_calledprocesserror_exception_logging(mock_sub, capsys):
 
 def test_calledprocesserror_exception_quiet(mock_sub, capsys):
     """If command errors in quiet mode, no command output is printed."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     called_process_error = CalledProcessError(
         returncode=-1,
@@ -300,7 +302,7 @@ def test_calledprocesserror_exception_quiet(mock_sub, capsys):
 
 def test_calledprocesserror_exception_logging_no_cmd_output(mock_sub, capsys):
     """If command errors, and there is no command output, errors are still printed."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     called_process_error = CalledProcessError(
         returncode=-1,
diff --git a/tests/integrations/subprocess/test_Subprocess__run__controlled_console.py b/tests/integrations/subprocess/test_Subprocess__run__controlled_console.py
index bf409dc6..43287111 100644
--- a/tests/integrations/subprocess/test_Subprocess__run__controlled_console.py
+++ b/tests/integrations/subprocess/test_Subprocess__run__controlled_console.py
@@ -5,6 +5,8 @@ from unittest.mock import ANY
 
 import pytest
 
+from briefcase.console import LogLevel
+
 
 def test_call(mock_sub, sub_stream_kw, sleep_zero, capsys):
     """A simple call will be invoked."""
@@ -48,7 +50,7 @@ def test_call_with_arg(mock_sub, sub_stream_kw, sleep_zero, capsys):
 
 def test_debug_call(mock_sub, sub_stream_kw, sleep_zero, capsys):
     """If verbosity is turned up, there is debug output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     with mock_sub.tools.input.wait_bar():
         mock_sub.run(["hello", "world"])
@@ -71,7 +73,7 @@ def test_debug_call(mock_sub, sub_stream_kw, sleep_zero, capsys):
 
 def test_debug_call_with_env(mock_sub, sub_stream_kw, sleep_zero, capsys, tmp_path):
     """If verbosity is turned up, injected env vars are included in debug output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     env = {"NewVar": "NewVarValue"}
     with mock_sub.tools.input.wait_bar():
diff --git a/tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py b/tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py
index baf44985..71ed6e95 100644
--- a/tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py
+++ b/tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py
@@ -5,6 +5,8 @@ from unittest.mock import ANY
 
 import pytest
 
+from briefcase.console import LogLevel
+
 from .conftest import CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW
 
 
@@ -131,7 +133,7 @@ def test_call_windows_with_start_new_session_and_creationflags(
 
 def test_debug_call(mock_sub, capsys, sub_kw):
     """If verbosity is turned up, there is output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_sub.run(["hello", "world"], stream_output=False)
 
@@ -152,7 +154,7 @@ def test_debug_call(mock_sub, capsys, sub_kw):
 
 def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_kw):
     """If verbosity is turned up, injected env vars are included output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     env = {"NewVar": "NewVarValue"}
     mock_sub.run(["hello", "world"], env=env, cwd=tmp_path / "cwd", stream_output=False)
@@ -180,7 +182,7 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_kw):
 
 
 def test_calledprocesserror_exception_logging(mock_sub, capsys):
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_sub._subprocess.run.side_effect = CalledProcessError(
         returncode=-1,
diff --git a/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py b/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py
index 5f04da55..aa221072 100644
--- a/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py
+++ b/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py
@@ -6,6 +6,8 @@ from unittest.mock import ANY
 
 import pytest
 
+from briefcase.console import LogLevel
+
 from .conftest import CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW
 
 
@@ -156,7 +158,7 @@ def test_call_windows_with_start_new_session_and_creationflags(
 
 def test_debug_call(mock_sub, capsys, sub_stream_kw, sleep_zero):
     """If verbosity is turned up, there is output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_sub.run(["hello", "world"])
 
@@ -180,7 +182,7 @@ def test_debug_call(mock_sub, capsys, sub_stream_kw, sleep_zero):
 
 def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_stream_kw, sleep_zero):
     """If verbosity is turned up, injected env vars are included output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     env = {"NewVar": "NewVarValue"}
     mock_sub.run(["hello", "world"], env=env, cwd=tmp_path / "cwd")
@@ -211,7 +213,7 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path, sub_stream_kw, sleep_ze
 
 
 def test_calledprocesserror_exception_logging(mock_sub, sleep_zero, capsys):
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     with pytest.raises(CalledProcessError):
         mock_sub.run(["hello", "world"], check=True)
diff --git a/tests/integrations/subprocess/test_Subprocess__stream_output.py b/tests/integrations/subprocess/test_Subprocess__stream_output.py
index ef788f79..feb7c951 100644
--- a/tests/integrations/subprocess/test_Subprocess__stream_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__stream_output.py
@@ -5,6 +5,7 @@ from unittest import mock
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.integrations import subprocess
 
 
@@ -31,7 +32,7 @@ def test_output(mock_sub, streaming_process, sleep_zero, capsys):
 
 def test_output_debug(mock_sub, streaming_process, sleep_zero, capsys):
     """Process output is printed; no debug output for only stream_output."""
-    mock_sub.tools.logger.verbosity = 2
+    mock_sub.tools.logger.verbosity = LogLevel.DEBUG
 
     mock_sub.stream_output("testing", streaming_process)
 
diff --git a/tests/integrations/windows_sdk/test_WindowsSDK__verify.py b/tests/integrations/windows_sdk/test_WindowsSDK__verify.py
index c1c20309..227df88d 100644
--- a/tests/integrations/windows_sdk/test_WindowsSDK__verify.py
+++ b/tests/integrations/windows_sdk/test_WindowsSDK__verify.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations import windows_sdk
 from briefcase.integrations.windows_sdk import WindowsSDK
@@ -168,7 +169,7 @@ def test_winsdk_latest_install_from_reg(mock_tools, mock_winreg, tmp_path, monke
     mock_tools.os.environ.get.return_value = None
 
     # Turn on verbose logging
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Patch target SDK version
     monkeypatch.setattr(WindowsSDK, "SDK_VERSION", "83.0")
@@ -207,7 +208,7 @@ def test_winsdk_nonlatest_install_from_reg(
     mock_tools.os.environ.get.return_value = None
 
     # Turn on verbose logging
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Patch target SDK version
     monkeypatch.setattr(WindowsSDK, "SDK_VERSION", "85.0")
@@ -236,10 +237,10 @@ def test_winsdk_nonlatest_install_from_reg(
     # Confirm invalid SDK was evaluated
     expected_output = (
         "\n"
-        ">>> [Windows SDK] Finding Suitable Installation...\n"
-        f">>> Evaluating Registry SDK version '85.0.9.0' at {tmp_path / 'invalid' / 'win_sdk'}\n"
-        f">>> Evaluating Registry SDK version '85.0.8.0' at {tmp_path / 'win_sdk'}\n"
-        f">>> Using Windows SDK v85.0.8.0 at {tmp_path / 'win_sdk'}\n"
+        "[Windows SDK] Finding Suitable Installation...\n"
+        f"Evaluating Registry SDK version '85.0.9.0' at {tmp_path / 'invalid' / 'win_sdk'}\n"
+        f"Evaluating Registry SDK version '85.0.8.0' at {tmp_path / 'win_sdk'}\n"
+        f"Using Windows SDK v85.0.8.0 at {tmp_path / 'win_sdk'}\n"
     )
     assert capsys.readouterr().out == expected_output
 
@@ -293,7 +294,7 @@ def test_winsdk_invalid_install_from_reg(
     WindowsSDK.DEFAULT_SDK_DIRS = []
 
     # Turn on verbose logging
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Patch target SDK version
     monkeypatch.setattr(WindowsSDK, "SDK_VERSION", "85.0")
@@ -333,17 +334,17 @@ def test_winsdk_invalid_install_from_reg(
         WindowsSDK.verify(mock_tools)
 
     # Confirm invalid SDK was evaluated
-    expected_output = "\n>>> [Windows SDK] Finding Suitable Installation...\n"
+    expected_output = "\n[Windows SDK] Finding Suitable Installation...\n"
     for subdir, version in [t for t in reg_installs if FileNotFoundError not in t]:
         if subdir and version:
             sdk_path = tmp_path / subdir / "win_sdk"
             expected_output += (
-                f">>> Evaluating Registry SDK version '{version}.0' at {sdk_path}\n"
+                f"Evaluating Registry SDK version '{version}.0' at {sdk_path}\n"
             )
     for subdir, version in additional_installs:
         sdk_path = tmp_path / subdir / "win_sdk"
         expected_output += (
-            f">>> Evaluating Registry SDK Bin version '{version}.0' at {sdk_path}\n"
+            f"Evaluating Registry SDK Bin version '{version}.0' at {sdk_path}\n"
         )
     assert capsys.readouterr().out == expected_output
 
@@ -360,7 +361,7 @@ def test_winsdk_valid_install_from_default_dir(
     mock_tools.os.environ.get.return_value = None
 
     # Turn on verbose logging
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Patch target SDK version
     monkeypatch.setattr(WindowsSDK, "SDK_VERSION", "86.0")
@@ -379,10 +380,10 @@ def test_winsdk_valid_install_from_default_dir(
     # Confirm invalid SDK was evaluated
     expected_output = (
         "\n"
-        ">>> [Windows SDK] Finding Suitable Installation...\n"
-        f">>> Evaluating Default Bin SDK version '86.0.7.0' at {tmp_path / 'invalid' / 'win_sdk'}\n"
-        f">>> Evaluating Default Bin SDK version '86.0.8.0' at {tmp_path / 'win_sdk'}\n"
-        f">>> Using Windows SDK v86.0.8.0 at {tmp_path / 'win_sdk'}\n"
+        "[Windows SDK] Finding Suitable Installation...\n"
+        f"Evaluating Default Bin SDK version '86.0.7.0' at {tmp_path / 'invalid' / 'win_sdk'}\n"
+        f"Evaluating Default Bin SDK version '86.0.8.0' at {tmp_path / 'win_sdk'}\n"
+        f"Using Windows SDK v86.0.8.0 at {tmp_path / 'win_sdk'}\n"
     )
     assert capsys.readouterr().out == expected_output
 
@@ -403,7 +404,7 @@ def test_winsdk_invalid_install_from_default_dir(
     mock_tools.os.environ.get.return_value = None
 
     # Turn on verbose logging
-    mock_tools.logger.verbosity = 2
+    mock_tools.logger.verbosity = LogLevel.DEBUG
 
     # Patch target SDK version
     monkeypatch.setattr(WindowsSDK, "SDK_VERSION", "87.0")
@@ -432,8 +433,8 @@ def test_winsdk_invalid_install_from_default_dir(
     # Confirm invalid SDK was evaluated
     expected_output = (
         "\n"
-        ">>> [Windows SDK] Finding Suitable Installation...\n"
-        f">>> Evaluating Default Bin SDK version '87.0.7.0' at {tmp_path / 'invalid_1' / 'win_sdk'}\n"
-        f">>> Evaluating Default Bin SDK version '87.0.8.0' at {tmp_path / 'invalid_2' / 'win_sdk'}\n"
+        "[Windows SDK] Finding Suitable Installation...\n"
+        f"Evaluating Default Bin SDK version '87.0.7.0' at {tmp_path / 'invalid_1' / 'win_sdk'}\n"
+        f"Evaluating Default Bin SDK version '87.0.8.0' at {tmp_path / 'invalid_2' / 'win_sdk'}\n"
     )
     assert capsys.readouterr().out == expected_output
diff --git a/tests/platforms/android/gradle/test_build.py b/tests/platforms/android/gradle/test_build.py
index e9f6bf22..03882183 100644
--- a/tests/platforms/android/gradle/test_build.py
+++ b/tests/platforms/android/gradle/test_build.py
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, PropertyMock
 import pytest
 import requests
 
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.android_sdk import AndroidSDK
 from briefcase.integrations.subprocess import Subprocess
@@ -72,7 +72,7 @@ def test_build_app(
     build_command.tools.host_os = host_os
     # Enable verbose tool logging
     if tool_debug_mode:
-        build_command.tools.logger.verbosity = 2
+        build_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
     # Create mock environment with `key`, which we expect to be preserved, and
     # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
     build_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
@@ -139,7 +139,7 @@ def test_build_app_test_mode(
     build_command.tools.host_os = host_os
     # Enable verbose tool logging
     if debug_mode:
-        build_command.tools.logger.verbosity = 2
+        build_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
     # Create mock environment with `key`, which we expect to be preserved, and
     # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
     build_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
diff --git a/tests/platforms/android/gradle/test_package__aab.py b/tests/platforms/android/gradle/test_package__aab.py
index 82a7ab07..f6d3afbf 100644
--- a/tests/platforms/android/gradle/test_package__aab.py
+++ b/tests/platforms/android/gradle/test_package__aab.py
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 from ....utils import create_file
@@ -66,7 +67,7 @@ def test_execute_gradle(
 
     # Enable verbose tool logging
     if tool_debug_mode:
-        package_command.tools.logger.verbosity = 2
+        package_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     # Set up a side effect of invoking gradle to create a bundle
     def create_bundle(*args, **kwargs):
diff --git a/tests/platforms/android/gradle/test_package__apk.py b/tests/platforms/android/gradle/test_package__apk.py
index 0eacf780..535c9d6b 100644
--- a/tests/platforms/android/gradle/test_package__apk.py
+++ b/tests/platforms/android/gradle/test_package__apk.py
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 from ....utils import create_file
@@ -67,7 +68,7 @@ def test_execute_gradle(
 
     # Enable verbose tool logging
     if tool_debug_mode:
-        package_command.tools.logger.verbosity = 3
+        package_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     # Set up a side effect of invoking gradle to create a bundle
     def create_bundle(*args, **kwargs):
diff --git a/tests/platforms/android/gradle/test_package__debug_apk.py b/tests/platforms/android/gradle/test_package__debug_apk.py
index b75b595f..162ae475 100644
--- a/tests/platforms/android/gradle/test_package__debug_apk.py
+++ b/tests/platforms/android/gradle/test_package__debug_apk.py
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 from ....utils import create_file
@@ -67,7 +68,7 @@ def test_execute_gradle(
 
     # Enable verbose tool logging
     if tool_debug_mode:
-        package_command.tools.logger.verbosity = 2
+        package_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     # Set up a side effect of invoking gradle to create a bundle
     def create_bundle(*args, **kwargs):
diff --git a/tests/platforms/iOS/xcode/test_build.py b/tests/platforms/iOS/xcode/test_build.py
index 3b9c53b0..b51acc0b 100644
--- a/tests/platforms/iOS/xcode/test_build.py
+++ b/tests/platforms/iOS/xcode/test_build.py
@@ -4,7 +4,7 @@ from unittest import mock
 
 import pytest
 
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.iOS.xcode import iOSXcodeBuildCommand
@@ -30,7 +30,7 @@ def test_build_app(build_command, first_app_generated, tool_debug_mode, tmp_path
 
     # Enable verbose tool logging
     if tool_debug_mode:
-        build_command.tools.logger.verbosity = 2
+        build_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     build_command.build_app(first_app_generated)
 
diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py
index 88096f16..44d96a65 100644
--- a/tests/platforms/linux/appimage/test_build.py
+++ b/tests/platforms/linux/appimage/test_build.py
@@ -7,7 +7,7 @@ from unittest import mock
 import pytest
 import tomli_w
 
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import (
     BriefcaseCommandError,
     NetworkFailure,
@@ -155,7 +155,7 @@ def test_build_appimage(build_command, first_app, debug_mode, tmp_path, sub_stre
     """A Linux app can be packaged as an AppImage."""
     # Enable verbose tool logging
     if debug_mode:
-        build_command.tools.logger.verbosity = 2
+        build_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     build_command.verify_app_tools(first_app)
     build_command.build_app(first_app)
diff --git a/tests/platforms/linux/appimage/test_create.py b/tests/platforms/linux/appimage/test_create.py
index 62674398..d008b9e6 100644
--- a/tests/platforms/linux/appimage/test_create.py
+++ b/tests/platforms/linux/appimage/test_create.py
@@ -69,12 +69,8 @@ def test_finalize_docker(create_command, first_app_config, capsys):
 
     create_command.finalize_app_config(first_app_config)
 
-    stdout = capsys.readouterr().out
     # Warning message was not recorded
-    assert "WARNING: Building a Local AppImage!" not in stdout
-
-    # Generic appimage warning *was* recorded
-    assert "WARNING: Use of AppImage is not recommended!" in stdout
+    assert "WARNING: Building a Local AppImage!" not in capsys.readouterr().out
 
 
 def test_finalize_nodocker(create_command, first_app_config, capsys):
@@ -83,12 +79,8 @@ def test_finalize_nodocker(create_command, first_app_config, capsys):
 
     create_command.finalize_app_config(first_app_config)
 
-    stdout = capsys.readouterr().out
-    # Warning message was recorded
-    assert "WARNING: Building a Local AppImage!" in stdout
-
-    # Generic appimage warning *was* recorded
-    assert "WARNING: Use of AppImage is not recommended!" in stdout
+    # Warning message was not recorded
+    assert "WARNING: Building a Local AppImage!" in capsys.readouterr().out
 
 
 @pytest.mark.parametrize(
diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py
index 28d1849b..93886686 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -7,7 +7,7 @@ from unittest import mock
 import pytest
 
 from briefcase.commands.base import BaseCommand
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.macOS import macOSSigningMixin
@@ -235,11 +235,11 @@ def test_selected_identity(dummy_command):
     assert dummy_command.input.prompts == ["> "]
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_adhoc_identity(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_adhoc_identity(dummy_command, verbose, tmp_path, capsys):
     """If an ad-hoc identity is used, the runtime option isn't used."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Sign the file with an ad-hoc identity
     dummy_command.sign_file(tmp_path / "base_path" / "random.file", identity="-")
@@ -263,11 +263,11 @@ def test_sign_file_adhoc_identity(dummy_command, tmp_path, debug, capsys):
     assert len(output.strip("\n").split("\n")) == 1
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_entitlements(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_entitlements(dummy_command, verbose, tmp_path, capsys):
     """Entitlements can be included in a signing call."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Sign the file with an ad-hoc identity
     dummy_command.sign_file(
@@ -295,11 +295,11 @@ def test_sign_file_entitlements(dummy_command, tmp_path, debug, capsys):
     assert len(output.strip("\n").split("\n")) == 1
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_deep_sign(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_deep_sign(dummy_command, verbose, tmp_path, capsys):
     """A file can be identified as needing a deep sign."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # First call raises the deep sign warning; second call succeeds
     dummy_command.tools.subprocess.run.side_effect = mock_codesign(
@@ -330,19 +330,19 @@ def test_sign_file_deep_sign(dummy_command, tmp_path, debug, capsys):
     )
 
     output = capsys.readouterr().out
-    if debug:
+    if verbose:
         # The console includes a warning about the attempt to deep sign
         assert "... random.file requires a deep sign; retrying\n" in output
 
     # Output only happens if in debug mode
-    assert len(output.strip("\n").split("\n")) == (2 if debug else 1)
+    assert len(output.strip("\n").split("\n")) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_deep_sign_failure(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_deep_sign_failure(dummy_command, verbose, tmp_path, capsys):
     """If deep signing fails, an error is raised."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # First invocation raises the deep sign error; second invocation raises some other error
     dummy_command.tools.subprocess.run.side_effect = mock_codesign(
@@ -372,20 +372,20 @@ def test_sign_file_deep_sign_failure(dummy_command, tmp_path, debug, capsys):
     )
 
     output = capsys.readouterr().out
-    if debug:
+    if verbose:
         # The console includes a warning about the attempt to deep sign
         assert "... random.file requires a deep sign; retrying\n" in output
 
     # Output only happens if in debug mode
-    assert len(output.strip("\n").split("\n")) == (2 if debug else 1)
+    assert len(output.strip("\n").split("\n")) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_unsupported_format(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_unsupported_format(dummy_command, verbose, tmp_path, capsys):
     """If codesign reports an unsupported format, the signing attempt is ignored with a
     warning."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # FIXME: I'm not sure how to manufacture this in practice.
     dummy_command.tools.subprocess.run.side_effect = mock_codesign(
@@ -411,20 +411,20 @@ def test_sign_file_unsupported_format(dummy_command, tmp_path, debug, capsys):
     )
 
     output = capsys.readouterr().out
-    if debug:
+    if verbose:
         # The console includes a warning about not needing a signature.
         assert "... random.file does not require a signature\n" in output
 
     # Output only happens if in debug mode
-    assert len(output.strip("\n").split("\n")) == (2 if debug else 1)
+    assert len(output.strip("\n").split("\n")) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_unknown_bundle_format(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_unknown_bundle_format(dummy_command, verbose, tmp_path, capsys):
     """If a folder happens to have a .framework extension, the signing attempt is
     ignored with a warning."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Raise an error caused by an unknown bundle format during codesign
     dummy_command.tools.subprocess.run.side_effect = mock_codesign(
@@ -450,19 +450,19 @@ def test_sign_file_unknown_bundle_format(dummy_command, tmp_path, debug, capsys)
     )
 
     output = capsys.readouterr().out
-    if debug:
+    if verbose:
         # The console includes a warning about not needing a signature.
         assert "... random.file does not require a signature\n" in output
 
     # Output only happens if in debug mode
-    assert len(output.strip("\n").split("\n")) == (2 if debug else 1)
+    assert len(output.strip("\n").split("\n")) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_file_unknown_error(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_file_unknown_error(dummy_command, verbose, tmp_path, capsys):
     """Any other codesigning error raises an error."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Raise an unknown error during codesign
     dummy_command.tools.subprocess.run.side_effect = mock_codesign("Unknown error")
@@ -490,11 +490,11 @@ def test_sign_file_unknown_error(dummy_command, tmp_path, debug, capsys):
     assert len(output.strip("\n").split("\n")) == 1
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_app(dummy_command, first_app_with_binaries, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_app(dummy_command, first_app_with_binaries, verbose, tmp_path, capsys):
     """An app bundle can be signed."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Sign the app
     dummy_command.sign_app(
@@ -563,16 +563,16 @@ def test_sign_app(dummy_command, first_app_with_binaries, tmp_path, debug, capsy
         # coverage we need to. However, win32 doesn't handle executable permissions
         # the same as linux/unix, `unknown.binary` is identified as a signing target.
         # We ignore this discrepancy for testing purposes.
-        assert len(output.strip("\n").split("\n")) == (12 if debug else 1)
+        assert len(output.strip("\n").split("\n")) == (12 if verbose else 1)
     else:
-        assert len(output.strip("\n").split("\n")) == (11 if debug else 1)
+        assert len(output.strip("\n").split("\n")) == (11 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_sign_app_with_failure(dummy_command, first_app_with_binaries, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_sign_app_with_failure(dummy_command, first_app_with_binaries, verbose, capsys):
     """If signing a single file in the app fails, the error is surfaced."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Sign the app. Signing first_dylib.dylib will fail.
     def _codesign(args, **kwargs):
@@ -603,6 +603,6 @@ def test_sign_app_with_failure(dummy_command, first_app_with_binaries, debug, ca
         # coverage we need to. However, win32 doesn't handle executable permissions
         # the same as linux/unix, `unknown.binary` is identified as a signing target.
         # We ignore this discrepancy for testing purposes.
-        assert len(output.strip("\n").split("\n")) == (7 if debug else 1)
+        assert len(output.strip("\n").split("\n")) == (7 if verbose else 1)
     else:
-        assert len(output.strip("\n").split("\n")) == (6 if debug else 1)
+        assert len(output.strip("\n").split("\n")) == (6 if verbose else 1)
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py
index dd2742ed..82e8eef6 100644
--- a/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_binary.py
@@ -2,16 +2,17 @@ import subprocess
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 from ...utils import create_file, file_content
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_thin_binary(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_thin_binary(dummy_command, verbose, tmp_path, capsys):
     """A thin binary is left as-is."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create a source binary.
     create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-original")
@@ -44,14 +45,14 @@ def test_thin_binary(dummy_command, tmp_path, debug, capsys):
 
     # Output only happens if in debug mode
     output = capsys.readouterr().out.split("\n")
-    assert len(output) == (2 if debug else 1)
+    assert len(output) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_fat_dylib(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_fat_dylib(dummy_command, verbose, tmp_path, capsys):
     """A fat binary library can be thinned."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create a source binary.
     create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
@@ -100,14 +101,14 @@ def test_fat_dylib(dummy_command, tmp_path, debug, capsys):
 
     # Output only happens if in debug mode
     output = capsys.readouterr().out.split("\n")
-    assert len(output) == (2 if debug else 1)
+    assert len(output) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_fat_dylib_arch_mismatch(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_fat_dylib_arch_mismatch(dummy_command, verbose, tmp_path, capsys):
     """If a fat binary doesn't contain the target architecture, an error is raised."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create a source binary.
     create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
@@ -140,12 +141,12 @@ def test_fat_dylib_arch_mismatch(dummy_command, tmp_path, debug, capsys):
     dummy_command.tools.subprocess.run.assert_not_called()
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_fat_dylib_unknown_info(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_fat_dylib_unknown_info(dummy_command, verbose, tmp_path, capsys):
     """If the lipo info call succeeds, but generates unknown output, an error is
     raised."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create a source binary.
     create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
@@ -210,11 +211,11 @@ def test_lipo_info_fail(dummy_command, tmp_path):
     dummy_command.tools.subprocess.run.assert_not_called()
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_lipo_thin_fail(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_lipo_thin_fail(dummy_command, verbose, tmp_path, capsys):
     """If lipo fails thinning the binary, an error is raised."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create a source binary.
     create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
@@ -266,4 +267,4 @@ def test_lipo_thin_fail(dummy_command, tmp_path, debug, capsys):
 
     # Output only happens if in debug mode
     output = capsys.readouterr().out.split("\n")
-    assert len(output) == (2 if debug else 1)
+    assert len(output) == (2 if verbose else 1)
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__lipo_dylib.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__lipo_dylib.py
index 3090b922..9fdf6425 100644
--- a/tests/platforms/macOS/test_AppPackagesMergeMixin__lipo_dylib.py
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__lipo_dylib.py
@@ -3,16 +3,17 @@ from pathlib import Path
 
 import pytest
 
+from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 
 from ...utils import create_file
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_lipo_dylib(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_lipo_dylib(dummy_command, verbose, tmp_path, capsys):
     """A binary library can be merged with lipo."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create 3 source binaries.
     create_file(tmp_path / "source-1" / "path" / "to" / "file.dylib", "dylib-1")
@@ -49,14 +50,14 @@ def test_lipo_dylib(dummy_command, tmp_path, debug, capsys):
 
     # Output only happens if in debug mode
     output = capsys.readouterr().out.split("\n")
-    assert len(output) == (2 if debug else 1)
+    assert len(output) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_lipo_dylib_partial(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_lipo_dylib_partial(dummy_command, verbose, tmp_path, capsys):
     """If a source doesn't have the library, it isn't included in the merge."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create 2 source binaries. Source-2 doesn't have the binary.
     create_file(tmp_path / "source-1" / "path" / "to" / "file.dylib", "dylib-1")
@@ -90,14 +91,14 @@ def test_lipo_dylib_partial(dummy_command, tmp_path, debug, capsys):
 
     # Output only happens if in debug mode
     output = capsys.readouterr().out.split("\n")
-    assert len(output) == (2 if debug else 1)
+    assert len(output) == (2 if verbose else 1)
 
 
-@pytest.mark.parametrize("debug", [True, False])
-def test_lipo_dylib_merge_error(dummy_command, tmp_path, debug, capsys):
+@pytest.mark.parametrize("verbose", [True, False])
+def test_lipo_dylib_merge_error(dummy_command, verbose, tmp_path, capsys):
     """If the merge process fails, an exception is raised."""
-    if debug:
-        dummy_command.logger.verbosity = 1
+    if verbose:
+        dummy_command.logger.verbosity = LogLevel.VERBOSE
 
     # Create 3 source binaries.
     create_file(tmp_path / "source-1" / "path" / "to" / "file.dylib", "dylib-1")
@@ -143,4 +144,4 @@ def test_lipo_dylib_merge_error(dummy_command, tmp_path, debug, capsys):
 
     # Output only happens if in debug mode
     output = capsys.readouterr().out.split("\n")
-    assert len(output) == (2 if debug else 1)
+    assert len(output) == (2 if verbose else 1)
diff --git a/tests/platforms/macOS/xcode/test_build.py b/tests/platforms/macOS/xcode/test_build.py
index 57744e60..bde56b91 100644
--- a/tests/platforms/macOS/xcode/test_build.py
+++ b/tests/platforms/macOS/xcode/test_build.py
@@ -3,7 +3,7 @@ from unittest.mock import ANY, MagicMock
 
 import pytest
 
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.macOS.xcode import macOSXcodeBuildCommand
@@ -24,7 +24,7 @@ def test_build_app(build_command, first_app_generated, tool_debug_mode, tmp_path
     """An macOS App can be built."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        build_command.tools.logger.verbosity = 2
+        build_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     build_command.tools.subprocess = MagicMock(spec_set=Subprocess)
     build_command.build_app(first_app_generated, test_mode=False)
diff --git a/tests/platforms/windows/visualstudio/test_build.py b/tests/platforms/windows/visualstudio/test_build.py
index 634417ec..58b19721 100644
--- a/tests/platforms/windows/visualstudio/test_build.py
+++ b/tests/platforms/windows/visualstudio/test_build.py
@@ -5,7 +5,7 @@ from unittest import mock
 import pytest
 
 import briefcase.platforms.windows.visualstudio
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.integrations.visualstudio import VisualStudio
@@ -51,7 +51,7 @@ def test_build_app(build_command, first_app_config, tool_debug_mode, tmp_path):
     """The solution will be compiled when the project is built."""
     # Enable verbose tool logging
     if tool_debug_mode:
-        build_command.tools.logger.verbosity = 2
+        build_command.tools.logger.verbosity = LogLevel.DEEP_DEBUG
 
     build_command.build_app(first_app_config)
 
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index a557a621..1ed6df5e 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -6,7 +6,7 @@ import pytest
 
 from briefcase import __version__, cmdline
 from briefcase.commands import DevCommand, NewCommand, UpgradeCommand
-from briefcase.console import Console, Log
+from briefcase.console import Console, Log, LogLevel
 from briefcase.exceptions import (
     InvalidFormatError,
     NoCommandError,
@@ -101,7 +101,7 @@ def test_new_command(logger, console):
     assert cmd.platform == "all"
     assert cmd.output_format is None
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {"template": None, "template_branch": None}
@@ -150,7 +150,7 @@ def test_dev_command(monkeypatch, logger, console, cmdline, expected_options):
     assert cmd.platform == "macOS"
     assert cmd.output_format is None
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {
@@ -185,7 +185,7 @@ def test_run_command(monkeypatch, logger, console, cmdline, expected_options):
     assert cmd.platform == "macOS"
     assert cmd.output_format == "app"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {
@@ -212,7 +212,7 @@ def test_upgrade_command(monkeypatch, logger, console):
     assert cmd.platform == "macOS"
     assert cmd.output_format is None
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {
@@ -232,7 +232,7 @@ def test_bare_command(monkeypatch, logger, console):
     assert cmd.platform == "macOS"
     assert cmd.output_format == "app"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -248,7 +248,7 @@ def test_linux_default(logger, console):
     assert cmd.platform == "linux"
     assert cmd.output_format == "system"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -264,7 +264,7 @@ def test_macOS_default(logger, console):
     assert cmd.platform == "macOS"
     assert cmd.output_format == "app"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -280,7 +280,7 @@ def test_windows_default(logger, console):
     assert cmd.platform == "windows"
     assert cmd.output_format == "app"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -343,7 +343,7 @@ def test_command_explicit_platform(monkeypatch, logger, console):
     assert cmd.platform == "linux"
     assert cmd.output_format == "system"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -361,7 +361,7 @@ def test_command_explicit_platform_case_handling(monkeypatch, logger, console):
     assert cmd.platform == "macOS"
     assert cmd.output_format == "app"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -397,7 +397,7 @@ def test_command_explicit_format(monkeypatch, logger, console):
     assert cmd.platform == "macOS"
     assert cmd.output_format == "app"
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -463,7 +463,7 @@ def test_command_disable_input(monkeypatch, logger, console):
     assert cmd.platform == "macOS"
     assert cmd.output_format == "app"
     assert not cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {}
@@ -480,7 +480,7 @@ def test_command_options(monkeypatch, capsys, logger, console):
 
     assert isinstance(cmd, macOSAppPublishCommand)
     assert cmd.input.enabled
-    assert cmd.logger.verbosity == 0
+    assert cmd.logger.verbosity == LogLevel.INFO
     assert cmd.logger is logger
     assert cmd.input is console
     assert options == {"channel": "s3"}