Repository: beeware/briefcase
Pull request #704 (2022-04-10 20:11:53)

Enhance debug logging for commands executed in the shell
## Changes
 - Fixes beeware/briefcase#640.
 - Implement logger to encapsulate all application logging including warnings and errors.
 - Update debug mode (i.e -v) to print the full command and any updated environment variables.
 - Introduce deep debug mode (i.e. -vv) to print full command, all environment variables, and command return code.
 - In the event a shell command returns a non-zero return code, the command output is now printed.

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [ ] All new features have been tested
- [ ] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct


-----------Original PR Content Below----------------------------------------------------------------------------------------------
Outstanding Questions
1) Is there any interest or questions in using `logging` like this? If not, no problem; it was mostly find/replace.
2) Ok with just using `-vv` for this debug mode?
3) Outputting _all_ environment variables is especially noisy....or did you intend just the injected env vars to be printed?


(Probably overly verbose thoughts/details below :)

__Logging__
These changes go beyond what #640 is requesting by implementing the `logging` package. By using `logging`, all output is funneled through the same code. This allows for much easier control of the output in the future; for example, `logging` allows us to easily add ">>>" to the front of all debug output by controlling the format of "debug" output in `logging` setup. Or wholesale changes to how warnings and error are displayed. This approach also (theoretically) allows for simpler output redirection than using `print`.

__Activating debug mode__
As opposed to creating a separate flag for debug mode, this simply adds to the output from the `-vv` switch. It seemed like it might be confusing to create another switch if multiple `v` flags are already supported.

__Managing command output__
As for command output, a lot of it is already sent to `stdout/stderr` since `subprocess.run` is being used mostly. I considered that `Popen` could be used instead with `stdout` and `stderr` piped back out and their outputs logged manually from `briefcase`....however, some of this output is pretty robust. For instance, building an APK has some fancy screen writing that's clearly using curses or similar. That would likely be lost or corrupted if all that is piped out of `subprocess`.
That said, `check_output` returns `stdout`....so that can be included pretty easily in debug mode. Although, I found at least one case (i.e trying to run android without vmx enabled) where getting `stderr` would probably be useful but is unavailable because `check_output` is used....any changes there seems pretty out of scope here.

__Return code__
Finally, the return code is nearly always zero as best I can tell.....since `check=True` and `check_output` causes `subprocess` to raise an exception for non-zero return codes. Nonetheless, these changes will include it after calls to `run`.

rmartin16: @freakboy3742, take two on logging infrastructure. I created a small `Log` class that gets attached to the `command` along side `verbosity` (although, `verbosity` should probably be removed if this is acceptable). It simply encapsulates all the debug logging semantics and doesn't require any more exposure elsewhere to use it than the `command.visibility` property requires.

Additionally, since we're trying to use the `-v` flags for this, I thought it'd be reasonable to actually use them....as such:
 - One v flag:
   - executed command and its arguments
 - Two v flags:
   - executed command's output (unless already included without any flags)
   - executed command's return code
 - Three v flags:
   - environment variables for command

Also, inspired from `ssh`, changed up the debug log preface to `debug#> ` where `#` is the debug level.

Thoughts? If you feel this is still too over-engineered, I can go back to `print` statements :)
rmartin16: Irrespective of the feedback related to the `Log` class mentioned above, I also made changes to ensure the command output and return code can always logged.
This is accomplished by:
 - intercepting any `CalledProcessError` exceptions from `run` and `check_output`
 - Debug-printing the output & return code stored in the exception
 - Re-raising `CalledProcessError` for the calling code to continue

As it relates to "capture all output, in the form that it is displayed to the user", I experimented a little bit with piping `stdout` and `stderr` back out for commands sent to `run` (by mimicking `run` with a call to `Popen`). This would allow us to capture and preserve the command output while simultaneously printing it to the screen. Although, like I mentioned before, we lose all the pretty-printing and what's printed to the screen looks the same as if the user was piping the command output to a file. I'll do a bit more research but I definitely didn't see a straightforward way to preserve the experience users have today while also capturing stdout/stderr for our own purposes.
rmartin16: @freakboy3742,
Good catch with the `subprocess` `kwargs`...I was apparently just overlooking that.

Two things before more code changes, though.

__1) One more pitch regarding env vars :)__
What if we just print the full list of environment variables _once_ at the beginning of the beeware command execution and then only print the env vars to be injected for each `subprocess` command? This would avoid printing all the env vars each time a `subprocess` command is run. Based on the my understanding, the env vars should not change once the beeware command execution starts.....although, if that doesn't hold true (or even just may not be true), then all env vars should be printed in entirety each time.

__2) Ephemeral/One-time Error Output__
I am not sure if I am missing something here regarding this statement:

> [W]e need to be able to ensure that in the event of a command failure, we always capture the output, and put it somewhere it can be accessed.

For commands running though `check_output()`, capturing the output is relatively straightforward since the return value is the command's output or the exception has it attached (although, `stderr` handling can complicate things here). However, for commands running through `run()`, `stdout` and `stderr` are being written to the screen in real time. As far as I know, `subprocess` does not support sending the command's `stdout` and `stderr` to the screen while also making that same output available later to the calling python code. That's why I was talking about piping all output for `run` commands back out in to `subprocess.py` so that we could manually write it to the screen and preserve that output for other purposes. (This was also why I started bringing up the compromise about degrading user experience given the fancy screen printing why Apple's/Google's/etc tools would be lost.)

Can you help me reconcile your thoughts with my understanding?


<BR><BR>
P.S. - I definitely support converting all `print()` calls to something like `Log.info()`....furthermore, I can create `Log.warning()` and `Log.error()` as well. This also brings up the `print()` calls that are part of requesting real-time user interaction....I think there is opportunity there as well to better consolidate and normalize putting that information on the screen (or not requesting user interaction as may be necessary). I'll at least include a commit to replace the print calls for current info, warning, and error logging.
freakboy3742: > **1) One more pitch regarding env vars :)** What if we just print the full list of environment variables _once_ at the beginning of the beeware command execution and then only print the env vars to be injected for each `subprocess` command? This would avoid printing all the env vars each time a `subprocess` command is run. Based on the my understanding, the env vars should not change once the beeware command execution starts.....although, if that doesn't hold true (or even just may not be true), then all env vars should be printed in entirety each time.

Your instincts here are definitely coming from a good place, but they collide up against the harsh reality of how end-users actually behave :-) 

However, if you hunt through bug reports made against Briefcase, a disturbing number of them fall into the "I got an error what happened" bucket - that is, people who report that they experienced a problem, but don't tell you *what the problem actually was*. Or, if they do, they give you the barest minimum of the stack trace - select readings from the last line of console output.

It's also not uncommon to go through a long debugging process trying to work out why some obscure error has occurred, and after multiple back-and-forth interactions, they offer "oh, we also have a firewall that blocks all network access to even numbered IP addresses... could that be causing a problem?".

To that end, we need to make sure that when we provide diagnostic information, we make it as simple and foolproof as possible for them to give us *all* the diagnostic information. Printing the background environment at the start of execution (under a 'max logging' option) is definitely a good idea though - having an idea of the "clean baseline" environment is one of those things that could be helpful as an initial diagnosis. But splitting the diagnostic information into "background environment" and "explicit environment for this command" is asking for trouble - because we'll be in a perpetual cycle of saying "ok, so now can you give us the *rest* of the environment debugging information?". 

It occurs to me that one way to get the "best of both worlds" would be to differentiate between what is printed, and what is output to a log file. Since we now have a single entry point for logging, we can potentially output to multiple locations - console *and* a file. I know this is something that can be captured at the shell with pipes and tee, but you'd be surprised how many users *don't* know how to drive pipes - and in the case of Windows, the option for tee doesn't exist at all AFAIK. This is evidenced by the fact that most console logs from Windows users come in the form of *screenshots* of a console, not text.

Using this approach `briefcase create -vv` will output useful logging detail, but will also create a `briefcase.20220418T0958.create.log` file that contains *all* output from the run. The console log would just show the "environment additions"; but the log *file* would contain the *full* environment. Again - anything we can do to make this foolproof is desirable, and saying "run command with -vv and send us the log file" is on that list. 

> **2) Ephemeral/One-time Error Output** I am not sure if I am missing something here regarding this statement:
> 
> > [W]e need to be able to ensure that in the event of a command failure, we always capture the output, and put it somewhere it can be accessed.
> 
> For commands running though `check_output()`, capturing the output is relatively straightforward since the return value is the command's output or the exception has it attached (although, `stderr` handling can complicate things here). However, for commands running through `run()`, `stdout` and `stderr` are being written to the screen in real time. As far as I know, `subprocess` does not support sending the command's `stdout` and `stderr` to the screen while also making that same output available later to the calling python code. That's why I was talking about piping all output for `run` commands back out in to `subprocess.py` so that we could manually write it to the screen and preserve that output for other purposes. (This was also why I started bringing up the compromise about degrading user experience given the fancy screen printing from Apple's/Google's/etc tools would be lost.)
> 
> Can you help me reconcile your thoughts with my understanding?

It's almost certainly *possible* to capture stdout and stderr, because you can replace stdout and stderr with any file-like object (we do this on iOS and Android to redirect stdout/err to system logs). However, I don't think this is needed here; we usually only care about the literal output of the command when we're using `check_output()`. 

Thinking about the problem some more, it's not even than `check_output()` is returning errors - it's that the post-processing (e.g., parsing the output as JSON) that is the problem. So, there may not actually be any special handling required here - it's more a case of ensuring that everywhere that we're handing "successful" `check_output()` output, the parsing done on that output includes a "dump to log in case of error" handling path.
 
> P.S. - I definitely support converting all `print()` calls to something like `Log.info()`....furthermore, I can create `Log.warning()` and `Log.error()` as well. This also brings up the `print()` calls that are part of requesting real-time user interaction....I think there is opportunity there as well to better consolidate and normalize putting that information on the screen (or not requesting user interaction as may be necessary). I'll at least include a commit to replace the print calls for current info, warning, and error logging.

+1 to this - we have a `--no-input` option for CI operation; anything we can do to formalize/centralize this would be a good addition IMHO.


Patch:
diff --git a/changes/704.feature.rst b/changes/704.feature.rst
new file mode 100644
index 00000000..ea72c0e0
--- /dev/null
+++ b/changes/704.feature.rst
@@ -0,0 +1,1 @@
+Verbose logging via -v and -vv now includes the return code, output, and environment variables for shell commands
diff --git a/src/briefcase/__main__.py b/src/briefcase/__main__.py
index 1df57746..aa6e5d70 100644
--- a/src/briefcase/__main__.py
+++ b/src/briefcase/__main__.py
@@ -11,6 +11,7 @@ def main():
         command(**options)
         result = 0
     except BriefcaseError as e:
+        print()
         print(e, file=sys.stdout if e.error_code == 0 else sys.stderr)
         result = e.error_code
     except KeyboardInterrupt:
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index ee6df6d7..ead94e46 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -1,4 +1,3 @@
-
 import argparse
 import importlib
 import inspect
@@ -22,7 +21,7 @@ except ModuleNotFoundError:
 
 from briefcase import __version__, integrations
 from briefcase.config import AppConfig, BaseConfig, GlobalConfig, parse_config
-from briefcase.console import Console
+from briefcase.console import Console, Log
 from briefcase.exceptions import (
     BadNetworkResourceError,
     BriefcaseCommandError,
@@ -149,6 +148,9 @@ class BaseCommand(ABC):
         # The internal Briefcase integrations API.
         self.integrations = integrations
 
+        # Initialize default logger (replaced when options are parsed).
+        self.logger = Log()
+
     @property
     def create_command(self):
         "Factory property; return an instance of a create command for the same format"
@@ -391,7 +393,7 @@ class BaseCommand(ABC):
 
         # Extract the base default options onto the command
         self.input.enabled = options.pop('input_enabled')
-        self.verbosity = options.pop('verbosity')
+        self.logger = Log(verbosity=options.pop('verbosity'))
 
         return options
 
@@ -402,7 +404,7 @@ class BaseCommand(ABC):
         :param command: The command whose options are to be cloned
         """
         self.input.enabled = command.input.enabled
-        self.verbosity = command.verbosity
+        self.logger = command.logger
 
     def add_default_options(self, parser):
         """
@@ -414,7 +416,7 @@ class BaseCommand(ABC):
             '-v', '--verbosity',
             action='count',
             default=1,
-            help="set the verbosity of output"
+            help="set the verbosity of output (use -vv for additional debug output)"
         )
         parser.add_argument(
             '-V', '--version',
@@ -513,22 +515,20 @@ class BaseCommand(ABC):
         if not filename.exists():
             # We have meaningful content, and it hasn't been cached previously,
             # so save it in the requested location
-            print('Downloading {cache_name}...'.format(cache_name=cache_name))
+            self.logger.info('Downloading {cache_name}...'.format(cache_name=cache_name))
             with filename.open('wb') as f:
                 total = response.headers.get('content-length')
                 if total is None:
                     f.write(response.content)
                 else:
                     downloaded = 0
-                    total = int(total)
-                    for data in response.iter_content(chunk_size=1024 * 1024):
-                        downloaded += len(data)
-                        f.write(data)
-                        done = int(50 * downloaded / total)
-                        print('\r{}{} {}%'.format('#' * done, '.' * (50-done), 2*done), end='', flush=True)
-            print()
+                    with self.input.progress_bar(total=int(total)) as progress_bar:
+                        for data in response.iter_content(chunk_size=1024 * 1024):
+                            f.write(data)
+                            downloaded += len(data)
+                            progress_bar.update(completed=downloaded)
         else:
-            print('{cache_name} already downloaded'.format(cache_name=cache_name))
+            self.logger.info('{cache_name} already downloaded'.format(cache_name=cache_name))
         return filename
 
     def update_cookiecutter_cache(self, template: str, branch='master'):
@@ -566,15 +566,15 @@ class BaseCommand(ABC):
                     # We are offline, or otherwise unable to contact
                     # the origin git repo. It's OK to continue; but warn
                     # the user that the template may be stale.
-                    print("***************************************************************************")
-                    print("WARNING: Unable to update template (is your computer offline?)")
-                    print("WARNING: Briefcase will use existing template without updating.")
-                    print("***************************************************************************")
+                    self.logger.warning("***************************************************************************")
+                    self.logger.warning("WARNING: Unable to update template (is your computer offline?)")
+                    self.logger.warning("WARNING: Briefcase will use existing template without updating.")
+                    self.logger.warning("***************************************************************************")
                 try:
                     # Check out the branch for the required version tag.
                     head = remote.refs[branch]
 
-                    print("Using existing template (sha {hexsha}, updated {datestamp})".format(
+                    self.logger.info("Using existing template (sha {hexsha}, updated {datestamp})".format(
                         hexsha=head.commit.hexsha,
                         datestamp=head.commit.committed_datetime.strftime("%c")
                     ))
diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py
index dc4ff848..5cffb11f 100644
--- a/src/briefcase/commands/build.py
+++ b/src/briefcase/commands/build.py
@@ -43,8 +43,8 @@ class BuildCommand(BaseCommand):
 
         state = self.build_app(app, **full_options(state, options))
 
-        print()
-        print("[{app.app_name}] Built {filename}".format(
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Built {filename}".format(
             app=app,
             filename=self.binary_path(app).relative_to(self.base_path),
         ))
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index c4f9b94b..d603ca0f 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -263,7 +263,7 @@ class CreateCommand(BaseCommand):
         if app.template_branch is None:
             app.template_branch = self.python_version_tag
 
-        print("Using app template: {app_template}, branch {template_branch}".format(
+        self.logger.info("Using app template: {app_template}, branch {template_branch}".format(
             app_template=app.template,
             template_branch=app.template_branch,
         ))
@@ -327,19 +327,19 @@ class CreateCommand(BaseCommand):
             try:
                 support_package_url = app.support_package
                 custom_support_package = True
-                print("Using custom support package {support_package_url}".format(
+                self.logger.info("Using custom support package {support_package_url}".format(
                     support_package_url=support_package_url
                 ))
             except AttributeError:
                 support_package_url = self.support_package_url
                 custom_support_package = False
-                print("Using support package {support_package_url}".format(
+                self.logger.info("Using support package {support_package_url}".format(
                     support_package_url=support_package_url
                 ))
 
             if support_package_url.startswith('https://') or support_package_url.startswith('http://'):
                 try:
-                    print("... pinned to revision {app.support_revision}".format(
+                    self.logger.info("... pinned to revision {app.support_revision}".format(
                         app=app
                     ))
                     # If a revision has been specified, add the revision
@@ -357,7 +357,7 @@ class CreateCommand(BaseCommand):
 
                 except AttributeError:
                     # No support revision specified.
-                    print("... using most recent revision")
+                    self.logger.info("... using most recent revision")
 
                 # Download the support file, caching the result
                 # in the user's briefcase support cache directory.
@@ -380,7 +380,7 @@ class CreateCommand(BaseCommand):
             raise NetworkFailure('downloading support package')
 
         try:
-            print("Unpacking support package...")
+            self.logger.info("Unpacking support package...")
             support_path = self.support_path(app)
             support_path.mkdir(parents=True, exist_ok=True)
             # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
@@ -389,7 +389,6 @@ class CreateCommand(BaseCommand):
                 extract_dir=os.fsdecode(support_path)
             )
         except (shutil.ReadError, EOFError):
-            print()
             raise InvalidSupportPackage(support_package_url)
 
     def install_app_dependencies(self, app: BaseConfig):
@@ -422,7 +421,7 @@ class CreateCommand(BaseCommand):
             except subprocess.CalledProcessError:
                 raise DependencyInstallError()
         else:
-            print("No application dependencies.")
+            self.logger.info("No application dependencies.")
 
     def install_app_code(self, app: BaseConfig):
         """
@@ -440,7 +439,7 @@ class CreateCommand(BaseCommand):
         # Install app code.
         if app.sources:
             for src in app.sources:
-                print(f"Installing {src}...")
+                self.logger.info("Installing {src}...".format(src=src))
                 original = self.base_path / src
                 target = app_path / original.name
 
@@ -452,7 +451,7 @@ class CreateCommand(BaseCommand):
                 else:
                     self.shutil.copy(original, target)
         else:
-            print(f"No sources defined for {app.app_name}.")
+            self.logger.info("No sources defined for {app.app_name}.".format(app=app))
 
         # Write the dist-info folder for the application.
         write_dist_info(
@@ -495,7 +494,7 @@ class CreateCommand(BaseCommand):
                             role=role,
                         )
                     except (TypeError, KeyError):
-                        print(
+                        self.logger.info(
                             "Unable to find {variant} variant for {role}; "
                             "using default".format(
                                 variant=variant,
@@ -521,7 +520,7 @@ class CreateCommand(BaseCommand):
                         )
                     except TypeError:
                         # The lookup on the source failed; that means we
-                        # have an sized image without variants.
+                        # have a sized image without variants.
                         source_filename = '{source}-{size}{target.suffix}'.format(
                             source=source,
                             size=size,
@@ -545,7 +544,7 @@ class CreateCommand(BaseCommand):
                             role=role,
                         )
                     except (TypeError, KeyError):
-                        print(
+                        self.logger.info(
                             "Unable to find {size}px {variant} variant for {role}; "
                             "using default".format(
                                 size=size,
@@ -557,7 +556,7 @@ class CreateCommand(BaseCommand):
 
             full_source = self.base_path / source_filename
             if full_source.exists():
-                print("Installing {source_filename} as {full_role}...".format(
+                self.logger.info("Installing {source_filename} as {full_role}...".format(
                     source_filename=source_filename,
                     full_role=full_role,
                 ))
@@ -567,7 +566,7 @@ class CreateCommand(BaseCommand):
                 # Copy the source image to the target location
                 self.shutil.copy(full_source, target)
             else:
-                print(
+                self.logger.info(
                     "Unable to find {source_filename} for {full_role}; using default".format(
                         full_role=full_role,
                         source_filename=source_filename,
@@ -646,54 +645,56 @@ class CreateCommand(BaseCommand):
 
         bundle_path = self.bundle_path(app)
         if bundle_path.exists():
-            print()
+            self.logger.info()
             confirm = self.input.boolean_input(
                 'Application {app.app_name} already exists; overwrite'.format(app=app),
                 default=False
             )
             if not confirm:
-                print("Aborting creation of app {app.app_name}".format(
-                    app=app
-                ))
+                self.logger.error(
+                    "Aborting creation of app {app.app_name}; existing application will not be overwritten.".format(
+                        app=app
+                    )
+                )
                 return
-            print()
-            print("[{app.app_name}] Removing old application bundle...".format(
+            self.logger.info()
+            self.logger.info("[{app.app_name}] Removing old application bundle...".format(
                 app=app
             ))
             self.shutil.rmtree(bundle_path)
 
-        print()
-        print('[{app.app_name}] Generating application template...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Generating application template...'.format(
             app=app
         ))
         self.generate_app_template(app=app)
 
-        print()
-        print('[{app.app_name}] Installing support package...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Installing support package...'.format(
             app=app
         ))
         self.install_app_support_package(app=app)
 
-        print()
-        print('[{app.app_name}] Installing dependencies...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Installing dependencies...'.format(
             app=app
         ))
         self.install_app_dependencies(app=app)
 
-        print()
-        print('[{app.app_name}] Installing application code...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Installing application code...'.format(
             app=app
         ))
         self.install_app_code(app=app)
 
-        print()
-        print('[{app.app_name}] Installing application resources...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Installing application resources...'.format(
             app=app
         ))
         self.install_app_resources(app=app)
-        print()
 
-        print("[{app.app_name}] Created {filename}".format(
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Created {filename}".format(
             app=app,
             filename=self.bundle_path(app).relative_to(self.base_path),
         ))
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index a253dc41..0b59927f 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -77,7 +77,7 @@ class DevCommand(BaseCommand):
             except subprocess.CalledProcessError:
                 raise DependencyInstallError()
         else:
-            print("No application dependencies.")
+            self.logger.info("No application dependencies.")
 
     def run_dev_app(self, app: BaseConfig, env: dict, **options):
         """
@@ -94,7 +94,6 @@ class DevCommand(BaseCommand):
                 check=True,
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to start application '{app.app_name}'".format(
                     app=app
@@ -145,16 +144,16 @@ class DevCommand(BaseCommand):
             # If we are not running the app, it means we should update dependencies.
             update_dependencies = True
         if update_dependencies or not dist_info_path.exists():
-            print()
-            print('[{app.app_name}] Installing dependencies...'.format(
+            self.logger.info()
+            self.logger.info('[{app.app_name}] Installing dependencies...'.format(
                 app=app
             ))
             self.install_dev_dependencies(app, **options)
             write_dist_info(app, dist_info_path)
 
         if run_app:
-            print()
-            print('[{app.app_name}] Starting in dev mode...'.format(
+            self.logger.info()
+            self.logger.info('[{app.app_name}] Starting in dev mode...'.format(
                 app=app
             ))
             env = self.get_environment(app)
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index bed4f8dd..06ea0b3e 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -269,11 +269,10 @@ class NewCommand(BaseCommand):
         :returns: a string, guaranteed to meet the validation criteria of
             ``validator``.
         """
-        if self.input.enabled:
-            print(intro)
+        self.input.prompt(intro)
+
         while True:
-            if self.input.enabled:
-                print()
+            self.input.prompt()
 
             answer = self.input.text_input(
                 "{variable} [{default}]: ".format(
@@ -293,8 +292,8 @@ class NewCommand(BaseCommand):
                 if not self.input.enabled:
                     raise BriefcaseCommandError(str(e))
 
-                print()
-                print("Invalid value; {e}".format(e=e))
+                self.input.prompt()
+                self.input.prompt("Invalid value; {e}".format(e=e))
 
     def input_select(self, intro, variable, options):
         """
@@ -309,8 +308,7 @@ class NewCommand(BaseCommand):
             options.
         :returns: The string content of the selected option.
         """
-        if self.input.enabled:
-            print(intro)
+        self.input.prompt(intro)
 
         index_choices = [str(key) for key in range(1, len(options) + 1)]
         display_options = '\n'.join(
@@ -444,7 +442,7 @@ up yet, you can put in a dummy URL.""",
         project_license = self.input_select(
             intro="""
 What license do you want to use for this project's code?""",
-            variable="project license""",
+            variable="project license",
             options=[
                 "BSD license",
                 "MIT license",
@@ -454,7 +452,7 @@ What license do you want to use for this project's code?""",
                 "GNU General Public License v3 (GPLv3)",
                 "GNU General Public License v3 or later (GPLv3+)",
                 "Proprietary",
-                "Other"
+                "Other",
             ],
         )
 
@@ -463,11 +461,11 @@ What license do you want to use for this project's code?""",
 What GUI toolkit do you want to use for this project?""",
             variable="GUI framework",
             options=[
-                'Toga',
-                'PySide2 (does not support iOS/Android deployment)',
-                'PySide6 (does not support iOS/Android deployment)',
-                'PursuedPyBear (does not support iOS/Android deployment)',
-                'None',
+                "Toga",
+                "PySide2 (does not support iOS/Android deployment)",
+                "PySide6 (does not support iOS/Android deployment)",
+                "PursuedPyBear (does not support iOS/Android deployment)",
+                "None",
             ],
         )
 
@@ -494,15 +492,14 @@ What GUI toolkit do you want to use for this project?""",
         if template is None:
             template = 'https://github.com/beeware/briefcase-template'
 
-        if self.input.enabled:
-            print()
-            print("Let's build a new Briefcase app!")
-            print()
+        self.input.prompt()
+        self.input.prompt("Let's build a new Briefcase app!")
+        self.input.prompt()
 
         context = self.build_app_context()
 
-        print()
-        print("Generating a new application '{formal_name}'".format(
+        self.logger.info()
+        self.logger.info("Generating a new application '{formal_name}'".format(
             **context
         ))
 
@@ -513,7 +510,6 @@ What GUI toolkit do you want to use for this project?""",
 
         # Make extra sure we won't clobber an existing application.
         if (self.base_path / context['app_name']).exists():
-            print()
             raise BriefcaseCommandError(
                 "A directory named '{app_name}' already exists.".format(
                     **context
@@ -538,7 +534,7 @@ What GUI toolkit do you want to use for this project?""",
             # or it isn't a cookiecutter template (i.e., no cookiecutter.json)
             raise InvalidTemplateRepository(template)
 
-        print("""
+        self.logger.info("""
 Application '{formal_name}' has been generated. To run your application, type:
 
     cd {app_name}
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 77784d6b..f9309165 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -60,8 +60,8 @@ class PackageCommand(BaseCommand):
             **full_options(state, options)
         )
 
-        print()
-        print("[{app.app_name}] Packaged {filename}".format(
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Packaged {filename}".format(
             app=app,
             filename=self.distribution_path(
                 app,
diff --git a/src/briefcase/commands/publish.py b/src/briefcase/commands/publish.py
index f42763a6..a755aba6 100644
--- a/src/briefcase/commands/publish.py
+++ b/src/briefcase/commands/publish.py
@@ -33,7 +33,7 @@ class PublishCommand(BaseCommand):
         :param app: The application to publish
         :param str: The publication channel to use
         """
-        print("TODO: Publish {app.app_name} to {channel}".format(
+        self.logger.info("TODO: Publish {app.app_name} to {channel}".format(
             app=app,
             channel=channel,
         ))
diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py
index 188090b6..e89da366 100644
--- a/src/briefcase/commands/update.py
+++ b/src/briefcase/commands/update.py
@@ -34,34 +34,34 @@ class UpdateCommand(CreateCommand):
 
         bundle_path = self.bundle_path(app)
         if not bundle_path.exists():
-            print()
-            print("[{app.app_name}] Application does not exist; call create first!".format(
+            self.logger.error()
+            self.logger.error("[{app.app_name}] Application does not exist; call create first!".format(
                 app=app
             ))
             return
 
         if update_dependencies:
-            print()
-            print('[{app.app_name}] Updating dependencies...'.format(
+            self.logger.info()
+            self.logger.info('[{app.app_name}] Updating dependencies...'.format(
                 app=app
             ))
             self.install_app_dependencies(app=app)
 
-        print()
-        print('[{app.app_name}] Updating application code...'.format(
+        self.logger.info()
+        self.logger.info('{app.app_name}] Updating application code...'.format(
             app=app
         ))
         self.install_app_code(app=app)
 
         if update_resources:
-            print()
-            print('[{app.app_name}] Updating extra application resources...'.format(
+            self.logger.info()
+            self.logger.info('[{app.app_name}] Updating extra application resources...'.format(
                 app=app
             ))
             self.install_app_resources(app=app)
 
-        print()
-        print('[{app.app_name}] Application updated.'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Application updated.'.format(
             app=app
         ))
 
diff --git a/src/briefcase/commands/upgrade.py b/src/briefcase/commands/upgrade.py
index 6165133e..b3bccbee 100644
--- a/src/briefcase/commands/upgrade.py
+++ b/src/briefcase/commands/upgrade.py
@@ -94,22 +94,22 @@ class UpgradeCommand(BaseCommand):
 
         if found_tools:
             if list_tools:
-                print("Briefcase is managing the following tools:")
+                self.logger.info("Briefcase is managing the following tools:")
                 for name in found_tools:
-                    print(" - {name}".format(name=name))
+                    self.logger.info(" - {name}".format(name=name))
             else:
-                print('Briefcase will upgrade the following tools:')
+                self.logger.info('Briefcase will upgrade the following tools:')
                 for name in found_tools:
-                    print(" - {name}".format(name=name))
-                print()
+                    self.logger.info(" - {name}".format(name=name))
+                self.logger.info()
 
                 for name in found_tools:
                     tool = managed_tools[name]
-                    print("[{tool.name}] Upgrading {tool.full_name}...".format(tool=tool))
-                    print()
+                    self.logger.info("[{tool.name}] Upgrading {tool.full_name}...".format(tool=tool))
+                    self.logger.info()
                     tool.upgrade()
 
-                print()
+                self.logger.info()
 
         else:
-            print("Briefcase is not managing any tools.")
+            self.logger.info("Briefcase is not managing any tools.")
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index 8ff45b0d..a0c8d94c 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -8,6 +8,58 @@ class InputDisabled(Exception):
         )
 
 
+class Log:
+    """
+    Manage logging output driven by verbosity flags.
+    """
+    DEBUG = 2
+    DEEP_DEBUG = 3
+
+    def __init__(self, verbosity=1):
+        # verbosity will be 1 more than the number of v flags from invocation
+        self.verbosity = verbosity
+        # value to be printed at the beginning of all debug output
+        self.debug_preface = ">>> "
+
+    def _log(self, preface="", msg=""):
+        """Funnel to log all messages."""
+        # print each line of message; ensure a line is printed when msg is empty
+        for line in msg.splitlines() or ("",):
+            print(f"{preface}{line}")
+
+    def deep_debug(self, msg=None):
+        """Log messages at deep debug level. Included in output if verbosity>=3."""
+        if self.verbosity >= self.DEEP_DEBUG:
+            if msg is None:
+                # On a completely no-args debug() call, don't output the preface;
+                # This type of call is just clearing some vertical space.
+                self._log()
+            else:
+                self._log(preface=self.debug_preface, msg=msg)
+
+    def debug(self, msg=None):
+        """Log messages at debug level. Included in output if verbosity>=2."""
+        if self.verbosity >= self.DEBUG:
+            if msg is None:
+                # On a completely no-args debug() call, don't output the preface;
+                # This type of call is just clearing some vertical space.
+                self._log()
+            else:
+                self._log(preface=self.debug_preface, msg=msg)
+
+    def info(self, msg=""):
+        """Log message at info level. Always included in output."""
+        self._log(msg=msg)
+
+    def warning(self, msg=""):
+        """Log message at warning level. Always included in output."""
+        self._log(msg=msg)
+
+    def error(self, msg=""):
+        """Log message at error level. Always included in output."""
+        self._log(msg=msg)
+
+
 class Console:
     def __init__(self, enabled=True):
         self._enabled = enabled
@@ -21,6 +73,19 @@ class Console:
     def enabled(self, enabled):
         self._enabled = enabled
 
+    def prompt(self, *values, **kwargs):
+        """Print to the screen for soliciting user interaction."""
+        if self.enabled:
+            print(*values, **kwargs)
+
+    def progress_bar(self, total: int):
+        """Returns a progress bar as a context manager."""
+        return ProgressBar(total=total)
+
+    def wait_bar(self, message: str = ""):
+        """Returns a wait bar as a context manager."""
+        return WaitBar(message=message)
+
     def boolean_input(self, question, default=False):
         """
         Get a boolean input from user, in the form of y/n.
@@ -85,8 +150,8 @@ class Console:
             if result in choices:
                 return result
 
-            print()
-            print(error_message)
+            self.prompt()
+            self.prompt(error_message)
 
     def text_input(self, prompt, default=None):
         """
@@ -117,8 +182,77 @@ class Console:
         "Make Console present the same interface as input()"
         if not self.enabled:
             raise InputDisabled()
+        try:
+            return self._input(prompt)
+        except EOFError:
+            raise KeyboardInterrupt
+
+
+class ProgressBar:
+    def __init__(self, total: int):
+        """
+        Context manager to display a progress bar in the console.
+
+        Continuously call update() on the yielded object to redraw the progress bar.
+        The progress bar will reach 100% when completed == total.
+
+        :param total: integer representing 100% of progress
+        """
+        self.bar_width = 50
+        self.completed_char = "#"
+        self.remaining_char = "."
+
+        self.total = total
+
+    def __enter__(self):
+        """Initialize the progress bar at 0 and return it to the caller."""
+        self.update(completed=0)
+        return self
+
+    def __exit__(self, *args):
+        """On exit, flush the output and add a clearing line."""
+        print()
+        print()
 
-        return self._input(prompt)
+    def update(self, completed: int):
+        """
+        Build the progress bar and (re)draw it on the console.
+
+        :param completed: amount of the total to show as completed.
+        """
+        completed_count = int(self.bar_width * completed / self.total)
+        bar_completed = self.completed_char * completed_count
+        bar_remaining = self.remaining_char * (self.bar_width - completed_count)
+        percent_done = int(completed_count * (100 / self.bar_width))
+        print(f"\r{bar_completed}{bar_remaining} {percent_done}%", end="", flush=True)
+
+
+class WaitBar:
+    def __init__(self, message: str = ""):
+        """
+        Context manager to inform a user a process is being awaited.
+        Call update() on the yielded object to print a new period character after the message.
+
+        :param message: message to inform the user what's being awaited
+        """
+        self.alive_char = "."
+
+        self.input = input
+        self.message = message
+
+    def __enter__(self):
+        """Show message to user and return bar to the caller."""
+        print(self.message, end="", flush=True)
+        return self
+
+    def __exit__(self, *args):
+        """On exit, flush the output and add a clearing line."""
+        print()
+        print()
+
+    def update(self):
+        """Add another period at the end of the bar."""
+        print(self.alive_char, end="", flush=True)
 
 
 def select_option(options, input, prompt='> ', error="Invalid selection"):
@@ -157,9 +291,9 @@ def select_option(options, input, prompt='> ', error="Invalid selection"):
 
     if input.enabled:
         for i, (key, value) in enumerate(ordered, start=1):
-            print('  {i}) {label}'.format(i=i, label=value))
+            input.prompt('  {i}) {label}'.format(i=i, label=value))
 
-        print()
+        input.prompt()
 
     choices = [str(index) for index in range(1, len(ordered) + 1)]
     index = input.selection_input(prompt=prompt, choices=choices, error_message=error)
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 82e9e31d..757960a8 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -128,7 +128,7 @@ class AndroidSDK:
                 sdk.verify_license()
                 return sdk
             else:
-                print(
+                command.logger.warning(
                     """
 *************************************************************************
 ** WARNING: ANDROID_SDK_ROOT does not point to an Android SDK          **
@@ -199,7 +199,7 @@ class AndroidSDK:
             raise NetworkFailure("download Android SDK")
 
         try:
-            print("Install Android SDK...")
+            self.command.logger.info("Install Android SDK...")
             # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
             self.command.shutil.unpack_archive(os.fsdecode(sdk_zip_path), extract_dir=os.fsdecode(self.root_path))
         except (shutil.ReadError, EOFError):
@@ -236,7 +236,7 @@ Delete {sdk_zip_path} and run briefcase again.""".format(
             )
         except subprocess.CalledProcessError:
             raise BriefcaseCommandError(
-                """\
+                """
     Error while reviewing Android SDK licenses. Please run this command and examine
     its output for errors.
 
@@ -263,7 +263,7 @@ Delete {sdk_zip_path} and run briefcase again.""".format(
         if license_path.exists():
             return
 
-        print(
+        self.command.logger.info(
             "\n"
             + """\
     The Android tools provided by Google have license terms that you must accept
@@ -303,7 +303,7 @@ Delete {sdk_zip_path} and run briefcase again.""".format(
         if (self.root_path / "emulator").exists():
             return
 
-        print("Downloading the Android emulator and system image...")
+        self.command.logger.info("Downloading the Android emulator and system image...")
         try:
             # Using `check_output` and `stderr=STDOUT` so we buffer output,
             # displaying it only if an exception occurs.
@@ -496,9 +496,9 @@ Delete {sdk_zip_path} and run briefcase again.""".format(
         choices.append((None, "Create a new Android emulator"))
 
         # Show the choices to the user.
-        print()
-        print("Select device:")
-        print()
+        self.command.input.prompt()
+        self.command.input.prompt("Select device:")
+        self.command.input.prompt()
         try:
             choice = select_option(choices, input=self.command.input)
         except InputDisabled:
@@ -507,9 +507,11 @@ Delete {sdk_zip_path} and run briefcase again.""".format(
             if len(choices) <= 2:
                 choice = choices[0][0]
             else:
-                raise BriefcaseCommandError(
-                    "Input has been disabled; can't select a device to target."
-                )
+                raise BriefcaseCommandError("""
+Input has been disabled; can't select a device to target.
+Use the -d/--device option to explicitly specify the device to use.
+"""
+                                            )
 
         # Proces the user's choice
         if choice is None:
@@ -539,14 +541,14 @@ Delete {sdk_zip_path} and run briefcase again.""".format(
                 raise InvalidDeviceError("device ID", choice)
 
         if avd:
-            print("""
+            self.command.logger.info("""
 In future, you can specify this device by running:
 
     briefcase run android -d @{avd}
 
 """.format(avd=avd))
         elif device:
-            print("""
+            self.command.logger.info("""
 In future, you can specify this device by running:
 
     briefcase run android -d {device}
@@ -571,7 +573,7 @@ In future, you can specify this device by running:
             default_avd = 'beePhone{i}'.format(i=i)
 
         # Prompt for a device avd until a valid one is provided.
-        print("""
+        self.command.logger.info("""
 You need to select a name for your new emulator. This is an identifier that
 can be used to start the emulator in future. It should follow the same naming
 conventions as a Python package (i.e., it may only contain letters, numbers,
@@ -589,17 +591,17 @@ a default name '{default_avd}'.
                 avd = default_avd
 
             if not PEP508_NAME_RE.match(avd):
-                print("""
+                self.command.logger.info("""
 '{avd}' is not a valid emulator name. An emulator name may only contain
 letters, numbers, hyphens and underscores
 
 """.format(avd=avd))
             elif avd in emulators:
-                print("""
+                self.command.logger.info("""
 An emulator named '{avd}' already exists.
 
 """.format(avd=avd))
-                print()
+                self.command.logger.info()
             else:
                 avd_is_invalid = False
 
@@ -608,9 +610,9 @@ An emulator named '{avd}' already exists.
         skin = 'pixel_3a'
 
         try:
-            print()
-            print("Creating Android emulator {avd}...".format(avd=avd))
-            print()
+            self.command.logger.info()
+            self.command.logger.info("Creating Android emulator {avd}...".format(avd=avd))
+            self.command.logger.info()
             self.command.subprocess.check_output(
                 [
                     os.fsdecode(self.avdmanager_path),
@@ -631,9 +633,9 @@ An emulator named '{avd}' already exists.
         # Check for a device skin. If it doesn't exist, download it.
         skin_path = self.root_path / "skins" / skin
         if skin_path.exists():
-            print("Device skin '{skin}' already exists".format(skin=skin))
+            self.command.logger.info("Device skin '{skin}' already exists".format(skin=skin))
         else:
-            print("Obtaining device skin...")
+            self.command.logger.info("Obtaining device skin...")
             skin_url = (
                 "https://android.googlesource.com/platform/tools/adt/idea/"
                 "+archive/refs/heads/mirror-goog-studio-master-dev/"
@@ -663,7 +665,7 @@ An emulator named '{avd}' already exists.
             # Delete the downloaded file.
             skin_tgz_path.unlink()
 
-        print("Adding extra device configuration...")
+        self.command.logger.info("Adding extra device configuration...")
         with (
             self.avd_path / '{avd}.avd'.format(avd=avd) / 'config.ini'
         ).open('a') as f:
@@ -676,7 +678,7 @@ skin.path=skins/{skin}
 showDeviceFrame=yes
 """.format(skin=skin))
 
-            print("""
+            self.command.logger.info("""
 Android emulator '{avd}' created.
 
 In future, you can specify this device by running:
@@ -694,7 +696,7 @@ In future, you can specify this device by running:
         :param avd: The AVD of the device.
         """
         if avd in set(self.emulators()):
-            print("Starting emulator {avd}...".format(avd=avd))
+            self.command.logger.info("Starting emulator {avd}...".format(avd=avd))
             emulator_popen = self.command.subprocess.Popen(
                 [
                     os.fsdecode(self.emulator_path),
@@ -713,14 +715,14 @@ In future, you can specify this device by running:
 
             # Step 1: Wait for the device to appear so we can get an
             # ADB instance for the new device.
-            print()
-            print('Waiting for emulator to start...', flush=True, end='')
-            adb = None
-            known_devices = set()
-            while adb is None:
-                print('.', flush=True, end='')
-                if emulator_popen.poll() is not None:
-                    raise BriefcaseCommandError("""
+            self.command.logger.info()
+            with self.command.input.wait_bar("Waiting for emulator to start...") as startup_wait_bar:
+                adb = None
+                known_devices = set()
+                while adb is None:
+                    startup_wait_bar.update()
+                    if emulator_popen.poll() is not None:
+                        raise BriefcaseCommandError("""
 Android emulator was unable to start!
 
 Try starting the emulator manually by running:
@@ -733,34 +735,32 @@ find this page helpful in diagnosing emulator problems.
     https://developer.android.com/studio/run/emulator-acceleration#accel-vm
 """.format(cmdline=' '.join(str(arg) for arg in emulator_popen.args)))
 
-                for device, details in sorted(self.devices().items()):
-                    # Only process authorized devices that we haven't seen.
-                    if details['authorized'] and device not in known_devices:
-                        adb = self.adb(device)
-                        device_avd = adb.avd_name()
-
-                        if device_avd == avd:
-                            # Found an active device that matches
-                            # the AVD we are starting.
-                            full_name = "@{avd} (running emulator)".format(
-                                avd=avd,
-                            )
-                            break
-                        else:
-                            # Not the one. Zathras knows.
-                            adb = None
-                            known_devices.add(device)
-
-                # Try again in 2 seconds...
-                self.sleep(2)
-
-            # Print a marker so we can see the phase change
-            print(' booting...', flush=True, end='')
+                    for device, details in sorted(self.devices().items()):
+                        # Only process authorized devices that we haven't seen.
+                        if details['authorized'] and device not in known_devices:
+                            adb = self.adb(device)
+                            device_avd = adb.avd_name()
+
+                            if device_avd == avd:
+                                # Found an active device that matches
+                                # the AVD we are starting.
+                                full_name = "@{avd} (running emulator)".format(
+                                    avd=avd,
+                                )
+                                break
+                            else:
+                                # Not the one. Zathras knows.
+                                adb = None
+                                known_devices.add(device)
+
+                    # Try again in 2 seconds...
+                    self.sleep(2)
 
             # Phase 2: Wait for the boot process to complete
-            while not adb.has_booted():
-                if emulator_popen.poll() is not None:
-                    raise BriefcaseCommandError("""
+            with self.command.input.wait_bar("Booting...") as boot_wait_bar:
+                while not adb.has_booted():
+                    if emulator_popen.poll() is not None:
+                        raise BriefcaseCommandError("""
 Android emulator was unable to boot!
 
 Try starting the emulator manually by running:
@@ -773,11 +773,10 @@ find this page helpful in diagnosing emulator problems.
     https://developer.android.com/studio/run/emulator-acceleration#accel-vm
 """.format(cmdline=' '.join(str(arg) for arg in emulator_popen.args)))
 
-                # Try again in 2 seconds...
-                self.sleep(2)
-                print('.', flush=True, end='')
+                    # Try again in 2 seconds...
+                    self.sleep(2)
+                    boot_wait_bar.update()
 
-            print()
             # Return the device ID and full name.
             return device, full_name
         else:
diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py
index a8edc99c..70d287e4 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -125,9 +125,9 @@ need to restart your terminal session.
                 )
 
         else:
-            print(UNKNOWN_DOCKER_VERSION_WARNING)
+            command.logger.warning(UNKNOWN_DOCKER_VERSION_WARNING)
     except subprocess.CalledProcessError:
-        print(DOCKER_INSTALLATION_STATUS_UNKNOWN_WARNING)
+        command.logger.warning(DOCKER_INSTALLATION_STATUS_UNKNOWN_WARNING)
     except FileNotFoundError:
         # Docker executable doesn't exist.
         raise BriefcaseCommandError(
@@ -208,9 +208,9 @@ class Docker:
 
     def prepare(self):
         try:
-            print()
-            print("[{app.app_name}] Building Docker container image...".format(app=self.app))
-            print()
+            self.command.logger.info()
+            self.command.logger.info("[{app.app_name}] Building Docker container image...".format(app=self.app))
+            self.command.logger.info()
             try:
                 system_requires = ' '.join(self.app.system_requires)
             except AttributeError:
@@ -235,7 +235,6 @@ class Docker:
             )
 
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Error building Docker container for {app.app_name}.".format(app=self.app)
             )
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 288b18d7..729d3075 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -219,7 +219,7 @@ class JDK:
                 # We only display the warning messages on the pass where we actually
                 # install the JDK.
                 if install_message:
-                    print(install_message)
+                    command.logger.warning(install_message)
 
                 jdk.install()
 
@@ -254,7 +254,7 @@ class JDK:
             raise NetworkFailure("download Java 8 JDK")
 
         try:
-            print("Installing AdoptOpenJDK...")
+            self.command.logger.info("Installing AdoptOpenJDK...")
             # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
             self.command.shutil.unpack_archive(
                 os.fsdecode(jdk_zip_path),
@@ -262,7 +262,7 @@ class JDK:
             )
         except (shutil.ReadError, EOFError):
             raise BriefcaseCommandError(
-                """\
+                """
 Unable to unpack AdoptOpenJDK ZIP file. The download may have been interrupted
 or corrupted.
 
@@ -286,14 +286,14 @@ Delete {jdk_zip_path} and run briefcase again.""".format(
         """
         if self.managed_install:
             if self.exists():
-                print("Removing old JDK install...")
+                self.command.logger.info("Removing old JDK install...")
                 if self.command.host_os == 'Darwin':
                     self.command.shutil.rmtree(self.java_home.parent.parent)
                 else:
                     self.command.shutil.rmtree(self.java_home)
 
                 self.install()
-                print("...done.")
+                self.command.logger.info("...done.")
             else:
                 raise MissingToolError('Java')
         else:
diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py
index 876dab50..b3346bbe 100644
--- a/src/briefcase/integrations/linuxdeploy.py
+++ b/src/briefcase/integrations/linuxdeploy.py
@@ -79,11 +79,11 @@ class LinuxDeploy:
         Upgrade an existing linuxdeploy install.
         """
         if self.exists():
-            print("Removing old LinuxDeploy install...")
+            self.command.logger.info("Removing old LinuxDeploy install...")
             self.appimage_path.unlink()
 
             self.install()
-            print("...done.")
+            self.command.logger.info("...done.")
         else:
             raise MissingToolError('linuxdeploy')
 
@@ -115,10 +115,10 @@ class LinuxDeploy:
                     appimage.write(ELF_PATCH_PATCHED_BYTES)
                     appimage.flush()
                     appimage.seek(0)
-                    print("Patched ELF header of linuxdeploy AppImage.")
+                    self.command.logger.info("Patched ELF header of linuxdeploy AppImage.")
                 # Else if the header is the patched value, do nothing.
                 elif appimage.read(len(ELF_PATCH_ORIGINAL_BYTES)) == ELF_PATCH_PATCHED_BYTES:
-                    print("ELF header of linuxdeploy AppImage is already patched.")
+                    self.command.logger.info("ELF header of linuxdeploy AppImage is already patched.")
                 else:
                     # We should only get here if the file at the AppImage patch doesn't have
                     # The original or patched value. If this is the case, the file is likely
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 7a314a64..bbf8109d 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -18,21 +18,37 @@ class Subprocess:
         # This is a no-op; the native subprocess environment is ready-to-use.
         pass
 
+    def full_env(self, overrides):
+        """
+        Generate the full environment in which the command will run.
+
+        :param overrides: The environment passed to the subprocess call;
+            can be `None` if there are no explicit environment changes.
+        """
+        env = self.command.os.environ.copy()
+        if overrides:
+            env.update(**overrides)
+        return env
+
     def final_kwargs(self, **kwargs):
-        """Convert subprocess keyword arguments into their final form.
+        """
+        Convert subprocess keyword arguments into their final form.
+
+        This involves:
+         * Converting any environment overrides into a full environment
+         * Converting the `cwd` into a string
         """
         # If `env` has been provided, inject a full copy of the local
         # environment, with the values in `env` overriding the local
         # environment.
         try:
-            extra_env = kwargs.pop('env')
-            kwargs['env'] = self.command.os.environ.copy()
-            kwargs['env'].update(extra_env)
+            overrides = kwargs.pop('env')
+            kwargs['env'] = self.full_env(overrides)
         except KeyError:
             # No explicit environment provided.
             pass
 
-        # If `cwd` has been provded, ensure it is in string form.
+        # If `cwd` has been provided, ensure it is in string form.
         try:
             cwd = kwargs.pop('cwd')
             kwargs['cwd'] = str(cwd)
@@ -42,9 +58,8 @@ class Subprocess:
         return kwargs
 
     def run(self, args, **kwargs):
-        """A wrapper for subprocess.run()
-
-        If verbosity >= 2, the executed command will be printed to the console.
+        """
+        A wrapper for subprocess.run()
 
         The behavior of this method is identical to subprocess.run(),
         except for the `env` argument. If provided, the current system
@@ -53,57 +68,63 @@ class Subprocess:
         """
         # Invoke subprocess.run().
         # Pass through all arguments as-is.
-        # All exceptions are propegated back to the caller.
-        if self.command.verbosity >= 2:
-            print(">>> {cmdline}".format(
-                cmdline=' '.join(shlex.quote(str(arg)) for arg in args)
-            ))
+        # All exceptions are propagated back to the caller.
+        self._log_command(args)
+        self._log_environment(kwargs.get("env"))
 
-        return self._subprocess.run(
-            [
-                str(arg) for arg in args
-            ],
-            **self.final_kwargs(**kwargs)
-        )
+        try:
+            command_result = self._subprocess.run(
+                [
+                    str(arg) for arg in args
+                ],
+                **self.final_kwargs(**kwargs)
+            )
+        except subprocess.CalledProcessError as exc:
+            self._log_return_code(exc.returncode)
+            raise
+
+        self._log_return_code(command_result.returncode)
+        return command_result
 
     def check_output(self, args, **kwargs):
-        """A wrapper for subprocess.check_output()
-
-        If verbosity >= 2, the executed command will be printed to the console.
+        """
+        A wrapper for subprocess.check_output()
 
         The behavior of this method is identical to subprocess.check_output(),
         except for the `env` argument. If provided, the current system
         environment will be copied, and the contents of env overwritten
         into that environment.
         """
-        # Invoke subprocess.check_output
-        if self.command.verbosity >= 2:
-            print(">>> {cmdline}".format(
-                cmdline=' '.join(shlex.quote(arg) for arg in args)
-            ))
+        self._log_command(args)
+        self._log_environment(kwargs.get("env"))
 
-        return self._subprocess.check_output(
-            [
-                str(arg) for arg in args
-            ],
-            **self.final_kwargs(**kwargs)
-        )
+        try:
+            cmd_output = self._subprocess.check_output(
+                [
+                    str(arg) for arg in args
+                ],
+                **self.final_kwargs(**kwargs)
+            )
+        except subprocess.CalledProcessError as exc:
+            self._log_output(exc.output, exc.stderr)
+            self._log_return_code(exc.returncode)
+            raise
+
+        self._log_output(cmd_output)
+        self._log_return_code(0)
+        return cmd_output
 
     def Popen(self, args, **kwargs):
-        """A wrapper for subprocess.Popen()
-
-        If verbosity >= 2, the executed command will be printed to the console.
+        """
+        A wrapper for subprocess.Popen()
 
         The behavior of this method is identical to subprocess.Popen(),
         except for the `env` argument. If provided, the current system
         environment will be copied, and the contents of env overwritten
         into that environment.
         """
-        # Invoke subprocess.check_output
-        if self.command.verbosity >= 2:
-            print(">>> {cmdline}".format(
-                cmdline=' '.join(shlex.quote(arg) for arg in args)
-            ))
+        self._log_command(args)
+        self._log_environment(kwargs.get("env"))
 
         return self._subprocess.Popen(
             [
@@ -111,3 +132,53 @@ class Subprocess:
             ],
             **self.final_kwargs(**kwargs)
         )
+
+    def _log_command(self, args):
+        """
+        Log the entire console command being executed.
+        """
+        self.command.logger.debug()
+        self.command.logger.debug("Running Command:")
+        self.command.logger.debug(f"    {' '.join(shlex.quote(str(arg)) for arg in args)}")
+
+    def _log_environment(self, overrides):
+        """
+        Log the state of environment variables prior to command execution.
+
+        In debug mode, only the updates to the current environment are logged.
+        In deep debug, the entire environment for the command is logged.
+
+        :param overrides: The explicit environment passed to the subprocess call;
+            can be `None` if there are no explicit environment changes.
+        """
+        if self.command.logger.verbosity >= self.command.logger.DEEP_DEBUG:
+            full_env = self.full_env(overrides)
+            self.command.logger.deep_debug("Full Environment:")
+            for env_var, value in full_env.items():
+                self.command.logger.deep_debug(f"    {env_var}={value}")
+
+        elif self.command.logger.verbosity >= self.command.logger.DEBUG:
+            if overrides:
+                self.command.logger.debug("Environment:")
+                for env_var, value in overrides.items():
+                    self.command.logger.debug(f"    {env_var}={value}")
+
+    def _log_output(self, output, stderr=None):
+        """
+        Log the output of the executed command.
+        """
+        if output:
+            self.command.logger.deep_debug("Command Output:")
+            for line in str(output).splitlines():
+                self.command.logger.deep_debug(f"    {line}")
+
+        if stderr:
+            self.command.logger.deep_debug("Command Error Output (stderr):")
+            for line in str(stderr).splitlines():
+                self.command.logger.deep_debug(f"    {line}")
+
+    def _log_return_code(self, return_code):
+        """
+        Log the output value of the executed command.
+        """
+        self.command.logger.deep_debug(f"Return code: {return_code}")
diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py
index 9dde1375..a1473c8a 100644
--- a/src/briefcase/integrations/wix.py
+++ b/src/briefcase/integrations/wix.py
@@ -133,7 +133,7 @@ WiX Toolset. Current value: {wix_home!r}
             raise NetworkFailure("download WiX")
 
         try:
-            print("Installing WiX...")
+            self.command.logger.info("Installing WiX...")
             # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
             self.command.shutil.unpack_archive(
                 os.fsdecode(wix_zip_path),
@@ -158,11 +158,11 @@ Delete {wix_zip_path} and run briefcase again.""".format(
         """
         if self.managed_install:
             if self.exists():
-                print("Removing old WiX install...")
+                self.command.logger.info("Removing old WiX install...")
                 self.command.shutil.rmtree(self.wix_home)
 
                 self.install()
-                print("...done.")
+                self.command.logger.info("...done.")
             else:
                 raise MissingToolError('WiX')
         else:
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 6c50422d..037d06fa 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -93,7 +93,7 @@ Re-run Briefcase once that installation is complete.
 """)
     except subprocess.CalledProcessError as e:
         if e.returncode != 1:
-            print("""
+            command.logger.warning("""
 *************************************************************************
 ** WARNING: Unable to determine if Xcode is installed                  **
 *************************************************************************
@@ -200,7 +200,7 @@ Re-run Briefcase once that installation is complete.
                 except IndexError:
                     pass
 
-            print("""
+            command.logger.warning("""
 *************************************************************************
 ** WARNING: Unable to determine the version of Xcode that is installed **
 *************************************************************************
@@ -266,7 +266,7 @@ def confirm_xcode_license_accepted(command):
         )
     except subprocess.CalledProcessError as e:
         if e.returncode == 69:
-            print("""
+            command.logger.info("""
 Use of Xcode and the iOS developer tools are covered by a license that must be
 accepted before you can use those tools.
 
@@ -292,7 +292,7 @@ to enter your password (Briefcase will not store this password anywhere).
                     raise BriefcaseCommandError("""
 Briefcase was unable to run the Xcode licensing tool. This may be because you
 did not enter your password correctly, or because your account does not have
-administrator priviliges on this computer.
+administrator privileges on this computer.
 
 You need to accept the Xcode license before Briefcase can package your app.
 """)
@@ -303,7 +303,7 @@ Xcode license has not been accepted. Briefcase cannot continue.
 You need to accept the Xcode license before Briefcase can package your app.
 """)
                 else:
-                    print("""
+                    command.logger.warning("""
 *************************************************************************
 ** WARNING: Unable to determine if the Xcode license has been accepted **
 *************************************************************************
@@ -324,7 +324,7 @@ You need to accept the Xcode license before Briefcase can package your app.
 *************************************************************************
 """)
         else:
-            print("""
+            command.logger.warning("""
 *************************************************************************
 ** WARNING: Unable to determine if the Xcode license has been accepted **
 *************************************************************************
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 632294d9..10990988 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -134,7 +134,7 @@ class GradleBuildCommand(GradleMixin, BuildCommand):
 
         :param app: The application to build
         """
-        print("[{app.app_name}] Building Android APK...".format(app=app))
+        self.logger.info("[{app.app_name}] Building Android APK...".format(app=app))
         try:
             self.subprocess.run(
                 # Windows needs the full path to `gradlew`; macOS & Linux can find it
@@ -148,7 +148,6 @@ class GradleBuildCommand(GradleMixin, BuildCommand):
                 check=True
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError("Error while building project.")
 
 
@@ -192,8 +191,8 @@ class GradleRunCommand(GradleMixin, RunCommand):
 
             device, name = self.android_sdk.start_emulator(avd)
 
-        print()
-        print(
+        self.logger.info()
+        self.logger.info(
             "[{app.app_name}] Starting app on {name} (device ID {device})".format(
                 app=app,
                 name=name,
@@ -209,31 +208,27 @@ class GradleRunCommand(GradleMixin, RunCommand):
         package = "{app.package_name}.{app.module_name}".format(app=app)
 
         # We force-stop the app to ensure the activity launches freshly.
-        print()
-        print("[{app.app_name}] Stopping old versions of the app...".format(app=app))
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Stopping old versions of the app...".format(app=app))
         adb.force_stop_app(package)
 
         # Install the latest APK file onto the device.
-        print()
-        print("[{app.app_name}] Installing app...".format(
-            app=app,
-        ))
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Installing app...".format(app=app))
         adb.install_apk(self.binary_path(app))
 
-        print()
-        print("[{app.app_name}] Clearing device log...".format(
-            app=app,
-        ))
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Clearing device log...".format(app=app))
         adb.clear_log()
 
         # To start the app, we launch `org.beeware.android.MainActivity`.
-        print()
-        print("[{app.app_name}] Launching app...".format(app=app))
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Launching app...".format(app=app))
         adb.start_app(package, "org.beeware.android.MainActivity")
 
-        print()
-        print("[{app.app_name}] Following device log output (type CTRL-C to stop log)...".format(app=app))
-        print("=" * 75)
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Following device log output (type CTRL-C to stop log)...".format(app=app))
+        self.logger.info("=" * 75)
         adb.logcat()
 
 
@@ -248,7 +243,7 @@ class GradlePackageCommand(GradleMixin, PackageCommand):
 
         :param app: The application to build
         """
-        print("[{app.app_name}] Building Android App Bundle and APK in release mode...".format(app=app))
+        self.logger.info("[{app.app_name}] Building Android App Bundle and APK in release mode...".format(app=app))
         try:
             self.subprocess.run(
                 # Windows needs the full path to `gradlew`; macOS & Linux can find it
@@ -262,7 +257,6 @@ class GradlePackageCommand(GradleMixin, PackageCommand):
                 check=True
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError("Error while building project.")
 
 
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index be7744a3..48df62e8 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -159,10 +159,9 @@ class iOSXcodeMixin(iOSXcodePassiveMixin):
         elif len(simulators) == 1:
             iOS_version = list(simulators.keys())[0]
         else:
-            if self.input.enabled:
-                print()
-                print("Select iOS version:")
-                print()
+            self.input.prompt()
+            self.input.prompt("Select iOS version:")
+            self.input.prompt()
             iOS_version = select_option({
                 version: version
                 for version in simulators.keys()
@@ -179,25 +178,27 @@ class iOSXcodeMixin(iOSXcodePassiveMixin):
         elif len(devices) == 1:
             udid = list(devices.keys())[0]
         else:
-            if self.input.enabled:
-                print()
-                print("Select simulator device:")
-                print()
+            self.input.prompt()
+            self.input.prompt("Select simulator device:")
+            self.input.prompt()
             udid = select_option(devices, input=self.input)
 
         device = devices[udid]
 
-        print("In future, you could specify this device by running:")
-        print()
-        print('    briefcase {self.command} iOS -d "{device}::{iOS_version}"'.format(
+        self.logger.info("""
+In the future, you could specify this device by running:
+
+    briefcase {self.command} iOS -d "{device}::{iOS_version}"
+
+or:
+
+    briefcase {self.command} iOS -d {udid}
+""".format(
             self=self,
             device=device,
-            iOS_version=iOS_version
+            iOS_version=iOS_version,
+            udid=udid
         ))
-        print()
-        print('or:')
-        print()
-        print("    briefcase {self.command} iOS -d {udid}".format(self=self, udid=udid))
         return udid, iOS_version, device
 
 
@@ -227,17 +228,15 @@ class iOSXcodeBuildCommand(iOSXcodeMixin, BuildCommand):
                 "Input has been disabled; can't select a device to target."
             )
 
-        print()
-        print("Targeting an {device} running {iOS_version} (device UDID {udid})".format(
+        self.logger.info()
+        self.logger.info("Targeting an {device} running {iOS_version} (device UDID {udid})".format(
             device=device,
             iOS_version=iOS_version,
             udid=udid,
         ))
 
-        print()
-        print('[{app.app_name}] Building XCode project...'.format(
-            app=app
-        ))
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Building XCode project...'.format(app=app))
 
         # build_settings = [
         #     ('AD_HOC_CODE_SIGNING_ALLOWED', 'YES'),
@@ -249,7 +248,6 @@ class iOSXcodeBuildCommand(iOSXcodeMixin, BuildCommand):
         # build_settings_str = ['{}={}'.format(*x) for x in build_settings]
 
         try:
-            print()
             self.subprocess.run(
                 [
                     'xcodebuild',  # ' '.join(build_settings_str),
@@ -267,9 +265,8 @@ class iOSXcodeBuildCommand(iOSXcodeMixin, BuildCommand):
                 ],
                 check=True,
             )
-            print('Build succeeded.')
+            self.logger.info('Build succeeded.')
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to build app {app.app_name}.".format(app=app)
             )
@@ -298,7 +295,6 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         :param app: The config object for the app
         :param udid: The device UDID to target. If ``None``, the user will
             be asked to select a device at runtime.
-        :param base_path: The path to the project directory.
         """
         try:
             udid, iOS_version, device = self.select_target_device(udid)
@@ -307,8 +303,8 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
                 "Input has been disabled; can't select a device to target."
             )
 
-        print()
-        print(
+        self.logger.info()
+        self.logger.info(
             "[{app.app_name}] Starting app on an {device} running "
             "iOS {iOS_version} (device UDID {udid})".format(
                 app=app,
@@ -323,17 +319,17 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         # shutting down, we need to wait for it to shut down before restarting.
         device_state = self.get_device_state(self, udid)
         if device_state not in {DeviceState.SHUTDOWN, DeviceState.BOOTED}:
-            print('Waiting for simulator...', flush=True, end='')
-            while device_state not in {DeviceState.SHUTDOWN, DeviceState.BOOTED}:
-                self.sleep(2)
-                print('.', flush=True, end='')
-                device_state = self.get_device_state(self, udid)
+            with self.input.wait_bar("Waiting for simulator...") as wait_bar:
+                while device_state not in {DeviceState.SHUTDOWN, DeviceState.BOOTED}:
+                    self.sleep(2)
+                    wait_bar.update()
+                    device_state = self.get_device_state(self, udid)
 
         # We now know the simulator is either shut down or booted;
         # if it's shut down, start it again.
         if device_state == DeviceState.SHUTDOWN:
             try:
-                print("Booting {device} simulator running iOS {iOS_version}...".format(
+                self.logger.info("Booting {device} simulator running iOS {iOS_version}...".format(
                         device=device,
                         iOS_version=iOS_version,
                     )
@@ -352,7 +348,7 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
 
         # We now know the simulator is *running*, so we can open it.
         try:
-            print("Opening {device} simulator running iOS {iOS_version}...".format(
+            self.logger.info("Opening {device} simulator running iOS {iOS_version}...".format(
                     device=device,
                     iOS_version=iOS_version,
                 )
@@ -372,7 +368,7 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         # Try to uninstall the app first. If the app hasn't been installed
         # before, this will still succeed.
         app_identifier = '.'.join([app.bundle, app.app_name])
-        print('[{app.app_name}] Uninstalling old app version...'.format(
+        self.logger.info('[{app.app_name}] Uninstalling old app version...'.format(
             app=app
         ))
         try:
@@ -388,7 +384,7 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
             )
 
         # Install the app.
-        print('[{app.app_name}] Installing new app version...'.format(
+        self.logger.info('[{app.app_name}] Installing new app version...'.format(
             app=app
         ))
         try:
@@ -403,7 +399,7 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
                 )
             )
 
-        print('[{app.app_name}] Starting app...'.format(
+        self.logger.info('[{app.app_name}] Starting app...'.format(
             app=app
         ))
         try:
@@ -420,9 +416,11 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
 
         # Start streaming logs for the app.
         try:
-            print()
-            print("[{app.app_name}] Following simulator log output (type CTRL-C to stop log)...".format(app=app))
-            print("=" * 75)
+            self.logger.info()
+            self.logger.info(
+                "[{app.app_name}] Following simulator log output (type CTRL-C to stop log)...".format(app=app)
+            )
+            self.logger.info("=" * 75)
             self.subprocess.run(
                 [
                     "xcrun", "simctl", "spawn", udid,
@@ -433,7 +431,6 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
                 check=True,
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to start log stream for app {app.app_name}.".format(app=app)
             )
diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py
index 6aa3efc2..64d2a1dd 100644
--- a/src/briefcase/platforms/linux/appimage.py
+++ b/src/briefcase/platforms/linux/appimage.py
@@ -105,13 +105,15 @@ Linux AppImages can only be generated on Linux.
             """
             Enter the Docker context.
             """
-            print("[{app.app_name}] Entering Docker context...".format(app=app))
+            self.logger.info()
+            self.logger.info("[{app.app_name}] Entering Docker context...".format(app=app))
             orig_subprocess = self.subprocess
             self.subprocess = self.Docker(self, app)
 
             yield self.subprocess
 
-            print("[{app.app_name}] Leaving Docker context.".format(app=app))
+            self.logger.info()
+            self.logger.info("[{app.app_name}] Leaving Docker context.".format(app=app))
             self.subprocess = orig_subprocess
         else:
             yield self.subprocess
@@ -162,11 +164,10 @@ class LinuxAppImageBuildCommand(LinuxAppImageMixin, BuildCommand):
 
         :param app: The application to build
         """
-        print()
-        print("[{app.app_name}] Building AppImage...".format(app=app))
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Building AppImage...".format(app=app))
 
         try:
-            print()
             # Build the AppImage.
             # For some reason, the version has to be passed in as an
             # environment variable, *not* in the configuration...
@@ -209,7 +210,6 @@ class LinuxAppImageBuildCommand(LinuxAppImageMixin, BuildCommand):
             # Make the binary executable.
             self.os.chmod(self.binary_path(app), 0o755)
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Error while building app {app.app_name}.".format(app=app)
             )
@@ -233,14 +233,12 @@ class LinuxAppImageRunCommand(LinuxAppImageMixin, RunCommand):
         Start the application.
 
         :param app: The config object for the app
-        :param base_path: The path to the project directory.
         """
-        print()
-        print('[{app.app_name}] Starting app...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Starting app...'.format(
             app=app
         ))
         try:
-            print()
             self.subprocess.run(
                 [
                     os.fsdecode(self.binary_path(app)),
@@ -248,7 +246,6 @@ class LinuxAppImageRunCommand(LinuxAppImageMixin, RunCommand):
                 check=True,
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to start app {app.app_name}.".format(app=app)
             )
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 62d05720..459e883e 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -31,10 +31,9 @@ class macOSRunMixin:
         Start the application.
 
         :param app: The config object for the app
-        :param base_path: The path to the project directory.
         """
-        print()
-        print('[{app.app_name}] Starting app...'.format(
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Starting app...'.format(
             app=app
         ))
         try:
@@ -47,16 +46,17 @@ class macOSRunMixin:
                 check=True,
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to start app {app.app_name}.".format(app=app)
             )
 
         # Start streaming logs for the app.
         try:
-            print()
-            print("[{app.app_name}] Following system log output (type CTRL-C to stop log)...".format(app=app))
-            print("=" * 75)
+            self.logger.info()
+            self.logger.info("[{app.app_name}] Following system log output (type CTRL-C to stop log)...".format(
+                app=app
+            ))
+            self.logger.info("=" * 75)
             # Streaming the system log is... a mess. The system log contains a
             # *lot* of noise from other processes; even if you filter by
             # process, there's a lot of macOS-generated noise. It's very
@@ -83,7 +83,6 @@ class macOSRunMixin:
                 check=True,
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to start log stream for app {app.app_name}.".format(app=app)
             )
@@ -153,11 +152,12 @@ class macOSSigningMixin:
         elif len(identities) == 1:
             identity = list(identities.items())[0][1]
         else:
-            print()
-            print("Select code signing identity to use:")
-            print()
+            self.input.prompt()
+            self.input.prompt("Select code signing identity to use:")
+            self.input.prompt()
             selection = select_option(identities, input=self.input)
             identity = identities[selection]
+            self.logger.info(f"selected {identity}")
 
         return identity
 
@@ -185,7 +185,7 @@ class macOSSigningMixin:
             process_command.append(options)
 
         try:
-            print(f"Signing {Path(path).relative_to(self.base_path)}")
+            self.logger.info(f"Signing {Path(path).relative_to(self.base_path)}")
             self.subprocess.run(
                 process_command,
                 stderr=subprocess.PIPE,
@@ -194,7 +194,7 @@ class macOSSigningMixin:
         except subprocess.CalledProcessError as e:
             errors = e.stderr.decode('utf-8', errors='replace')
             if 'code object is not signed at all' in errors:
-                print("... file requires a deep sign; retrying")
+                self.logger.info("... file requires a deep sign; retrying")
                 try:
                     self.subprocess.run(
                         process_command + ['--deep'],
@@ -216,7 +216,7 @@ class macOSSigningMixin:
                 ]
             ):
                 # We should not be signing this in the first place
-                print("... no signature required")
+                self.logger.info("... no signature required")
                 return
             else:
                 raise BriefcaseCommandError(f"Unable to code sign {path}.")
@@ -310,13 +310,13 @@ class macOSPackageMixin(macOSSigningMixin):
             if adhoc_sign:
                 identity = "-"
 
-                print()
-                print("[{app.app_name}] Signing app with adhoc identity...".format(app=app))
+                self.logger.info()
+                self.logger.info("[{app.app_name}] Signing app with adhoc identity...".format(app=app))
             else:
                 identity = self.select_identity(identity=identity)
 
-                print()
-                print("[{app.app_name}] Signing app with identity {identity}...".format(
+                self.logger.info()
+                self.logger.info("[{app.app_name}] Signing app with identity {identity}...".format(
                     app=app,
                     identity=identity
                 ))
@@ -324,8 +324,8 @@ class macOSPackageMixin(macOSSigningMixin):
             self.sign_app(app=app, identity=identity)
 
         if packaging_format == 'dmg':
-            print()
-            print('[{app.app_name}] Building DMG...'.format(app=app))
+            self.logger.info()
+            self.logger.info('[{app.app_name}] Building DMG...'.format(app=app))
 
             dmg_settings = {
                 'files': [os.fsdecode(self.binary_path(app))],
@@ -342,7 +342,7 @@ class macOSPackageMixin(macOSSigningMixin):
             try:
                 icon_filename = self.base_path / f'{app.installer_icon}.icns'
                 if not icon_filename.exists():
-                    print(f"Can't find {app.installer_icon}.icns to use as DMG installer icon")
+                    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
@@ -351,7 +351,7 @@ class macOSPackageMixin(macOSSigningMixin):
                         icon=app.icon
                     )
                     if not icon_filename.exists():
-                        print(f"Can't find {app.icon}.icns to use as fallback DMG installer icon")
+                        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
@@ -367,7 +367,7 @@ class macOSPackageMixin(macOSSigningMixin):
                 if image_filename.exists():
                     dmg_settings['background'] = os.fsdecode(image_filename)
                 else:
-                    print("Can't find {filename}.png to use as DMG background".format(
+                    self.logger.warning("Can't find {filename}.png to use as DMG background".format(
                         filename=app.installer_background
                     ))
             except AttributeError:
diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py
index 67dd26d7..5b15ad43 100644
--- a/src/briefcase/platforms/macOS/app.py
+++ b/src/briefcase/platforms/macOS/app.py
@@ -80,8 +80,8 @@ class macOSAppBuildCommand(macOSAppMixin, macOSSigningMixin, BuildCommand):
         # signed to be able to execute on M1 hardware - even if it's only an
         # adhoc signing identity. Apply an adhoc signing identity to the
         # app bundle.
-        print()
-        print(f'[{app.app_name}] Adhoc signing app...')
+        self.logger.info()
+        self.logger.info(f'[{app.app_name}] Adhoc signing app...')
         self.sign_app(app=app, identity="-")
 
 
diff --git a/src/briefcase/platforms/macOS/xcode.py b/src/briefcase/platforms/macOS/xcode.py
index b63497ef..8f16ce9e 100644
--- a/src/briefcase/platforms/macOS/xcode.py
+++ b/src/briefcase/platforms/macOS/xcode.py
@@ -78,10 +78,8 @@ class macOSXcodeBuildCommand(macOSXcodeMixin, BuildCommand):
         :param app: The application to build
         """
 
-        print()
-        print('[{app.app_name}] Building XCode project...'.format(
-            app=app
-        ))
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Building XCode project...'.format(app=app))
 
         # build_settings = [
         #     ('AD_HOC_CODE_SIGNING_ALLOWED', 'YES'),
@@ -93,7 +91,6 @@ class macOSXcodeBuildCommand(macOSXcodeMixin, BuildCommand):
         # build_settings_str = ['{}={}'.format(*x) for x in build_settings]
 
         try:
-            print()
             self.subprocess.run(
                 [
                     'xcodebuild',  # ' '.join(build_settings_str),
@@ -104,9 +101,8 @@ class macOSXcodeBuildCommand(macOSXcodeMixin, BuildCommand):
                 ],
                 check=True,
             )
-            print('Build succeeded.')
+            self.logger.info('Build succeeded.')
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to build app {app.app_name}.".format(app=app)
             )
diff --git a/src/briefcase/platforms/windows/msi.py b/src/briefcase/platforms/windows/msi.py
index 06a76c78..223f0001 100644
--- a/src/briefcase/platforms/windows/msi.py
+++ b/src/briefcase/platforms/windows/msi.py
@@ -75,7 +75,7 @@ class WindowsMSICreateCommand(WindowsMSIMixin, CreateCommand):
             # Create a DNS domain by reversing the bundle identifier
             domain = '.'.join([app.app_name] + app.bundle.split('.')[::-1])
             guid = uuid.uuid5(uuid.NAMESPACE_DNS, domain)
-            print("Assigning {app.app_name} an application GUID of {guid}".format(
+            self.logger.info("Assigning {app.app_name} an application GUID of {guid}".format(
                 app=app,
                 guid=guid,
             ))
@@ -134,14 +134,10 @@ class WindowsMSIRunCommand(WindowsMSIMixin, RunCommand):
         Start the application.
 
         :param app: The config object for the app
-        :param base_path: The path to the project directory.
         """
-        print()
-        print('[{app.app_name}] Starting app...'.format(
-            app=app
-        ))
+        self.logger.info()
+        self.logger.info('[{app.app_name}] Starting app...'.format(app=app))
         try:
-            print()
             self.subprocess.run(
                 [
                     os.fsdecode(self.binary_path(app) / 'src' / 'python' / 'pythonw.exe'),
@@ -150,7 +146,6 @@ class WindowsMSIRunCommand(WindowsMSIMixin, RunCommand):
                 check=True,
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to start app {app.app_name}.".format(app=app)
             )
@@ -165,12 +160,12 @@ class WindowsMSIPackageCommand(WindowsMSIMixin, PackageCommand):
 
         :param app: The application to build
         """
-        print()
-        print("[{app.app_name}] Building MSI...".format(app=app))
+        self.logger.info()
+        self.logger.info("[{app.app_name}] Building MSI...".format(app=app))
 
         try:
-            print()
-            print("Compiling application manifest...")
+            self.logger.info()
+            self.logger.info("Compiling application manifest...")
             self.subprocess.run(
                 [
                     self.wix.heat_exe,
@@ -196,8 +191,8 @@ class WindowsMSIPackageCommand(WindowsMSIMixin, PackageCommand):
             )
 
         try:
-            print()
-            print("Compiling application installer...")
+            self.logger.info()
+            self.logger.info("Compiling application installer...")
             self.subprocess.run(
                 [
                     self.wix.candle_exe,
@@ -217,8 +212,8 @@ class WindowsMSIPackageCommand(WindowsMSIMixin, PackageCommand):
             )
 
         try:
-            print()
-            print("Linking application installer...")
+            self.logger.info()
+            self.logger.info("Linking application installer...")
             self.subprocess.run(
                 [
                     self.wix.light_exe,
@@ -233,7 +228,6 @@ class WindowsMSIPackageCommand(WindowsMSIMixin, PackageCommand):
                 cwd=self.bundle_path(app)
             )
         except subprocess.CalledProcessError:
-            print()
             raise BriefcaseCommandError(
                 "Unable to link app {app.app_name}.".format(app=app)
             )


Test patch:
diff --git a/tests/commands/base/test_parse_options.py b/tests/commands/base/test_parse_options.py
index b52aeaa4..55505bea 100644
--- a/tests/commands/base/test_parse_options.py
+++ b/tests/commands/base/test_parse_options.py
@@ -16,7 +16,7 @@ def test_parse_options(base_command):
         'required': "important",
     }
     assert base_command.input.enabled
-    assert base_command.verbosity
+    assert base_command.logger.verbosity == 1
 
 
 def test_missing_option(base_command, capsys):
diff --git a/tests/commands/base/test_properties.py b/tests/commands/base/test_properties.py
index 77c1e785..1e9fd6fb 100644
--- a/tests/commands/base/test_properties.py
+++ b/tests/commands/base/test_properties.py
@@ -40,7 +40,6 @@ def test_publish_command(base_command):
 def test_input_state_transferred(tmp_path):
     "If input is disabled, that status is transferred to created subcommands"
     command = DummyCommand(base_path=tmp_path, input_enabled=False)
-    command.verbosity = 0
 
     # Check the enabled state of subcommands
     assert not command.create_command.input.enabled
diff --git a/tests/commands/dev/test_install_dev_dependencies.py b/tests/commands/dev/test_install_dev_dependencies.py
new file mode 100644
index 00000000..f31f8589
--- /dev/null
+++ b/tests/commands/dev/test_install_dev_dependencies.py
@@ -0,0 +1,46 @@
+import sys
+from subprocess import CalledProcessError
+
+import pytest
+
+from briefcase.commands.create import DependencyInstallError
+
+
+def test_install_dependencies_no_error(dev_command, first_app):
+    "Ensure run is executed properly to install dependencies"
+    first_app.requires = ["package-one", "package_two", "packagethree"]
+
+    dev_command.install_dev_dependencies(app=first_app)
+
+    dev_command.subprocess.run.assert_called_once_with(
+        [
+            sys.executable, "-m", "pip", "install", "--upgrade",
+            "package-one", "package_two", "packagethree",
+        ], check=True
+    )
+
+
+def test_install_dependencies_error(dev_command, first_app):
+    "Ensure DependencyInstallError exception is raised for install errors"
+    first_app.requires = ["package-one", "package_two", "packagethree"]
+
+    dev_command.subprocess.run.side_effect = CalledProcessError(returncode=-1, cmd='pip')
+
+    with pytest.raises(DependencyInstallError, match='Unable to install dependencies.'):
+        dev_command.install_dev_dependencies(app=first_app)
+
+    dev_command.subprocess.run.assert_called_once_with(
+        [
+            sys.executable, "-m", "pip", "install", "--upgrade",
+            "package-one", "package_two", "packagethree",
+        ], check=True
+    )
+
+
+def test_no_dependencies(dev_command, first_app):
+    "Ensure dependency installation is not attempted when nothing to install"
+    first_app.requires = []
+
+    dev_command.install_dev_dependencies(app=first_app)
+
+    dev_command.subprocess.run.assert_not_called()
diff --git a/tests/conftest.py b/tests/conftest.py
index 7d554d91..ea8d337b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,4 @@
+import inspect
 from unittest import mock
 
 import pytest
@@ -10,3 +11,25 @@ def mock_git():
     git.exc = git_exceptions
 
     return git
+
+
+# preserve print so sanctioned callers can still use it
+_print = print
+
+
+def monkeypatched_print(*arg, **kwargs):
+    "Allow print calls from console.py...raise an error for all other callers"
+    frame = inspect.currentframe().f_back
+    module = inspect.getmodule(frame.f_code)
+    if module.__name__ == "briefcase.console":
+        _print(*arg, **kwargs)
+    else:
+        pytest.fail(
+            "print() should not be invoked directly. Use Log or Console for printing."
+        )
+
+
+@pytest.fixture(autouse=True)
+def no_print(monkeypatch):
+    "Replace builtin print function for ALL tests"
+    monkeypatch.setattr("builtins.print", monkeypatched_print)
diff --git a/tests/console/Console/test_call.py b/tests/console/Console/test_call.py
index 8dbfa589..78ea96f6 100644
--- a/tests/console/Console/test_call.py
+++ b/tests/console/Console/test_call.py
@@ -22,3 +22,11 @@ def test_call_raise_exception_when_disabled(disabled_console):
     with pytest.raises(InputDisabled):
         disabled_console(prompt=prompt)
     disabled_console._input.assert_not_called()
+
+
+def test_call_raise_keyboardinterrupt_for_eoferror(console):
+    "Ensure KeyboardInterrupt is raised when users send EOF to an input prompt."
+    console._input.side_effect = EOFError()
+
+    with pytest.raises(KeyboardInterrupt):
+        console(prompt="")
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py b/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py
index 6a094a83..8ca2c82d 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py
@@ -2,6 +2,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.exceptions import BriefcaseCommandError, InvalidDeviceError
 from briefcase.integrations.android_sdk import (
     AndroidDeviceNotAuthorized,
@@ -13,6 +14,7 @@ from tests.utils import DummyConsole
 @pytest.fixture
 def mock_sdk(tmp_path):
     command = MagicMock()
+    command.logger = Log()
     command.input = DummyConsole()
 
     sdk = AndroidSDK(command, jdk=MagicMock(), root_path=tmp_path)
diff --git a/tests/integrations/docker/conftest.py b/tests/integrations/docker/conftest.py
index 93d6b0e6..6cc6f748 100644
--- a/tests/integrations/docker/conftest.py
+++ b/tests/integrations/docker/conftest.py
@@ -2,6 +2,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.integrations.docker import Docker
 from briefcase.integrations.subprocess import Subprocess
 
@@ -9,7 +10,7 @@ from briefcase.integrations.subprocess import Subprocess
 @pytest.fixture
 def mock_docker(tmp_path):
     command = MagicMock()
-    command.verbosity = 0
+    command.logger = Log()
     command.base_path = tmp_path / 'base'
     command.platform_path = tmp_path / 'platform'
     command.bundle_path.return_value = tmp_path / 'bundle'
diff --git a/tests/integrations/docker/test_Docker__run.py b/tests/integrations/docker/test_Docker__run.py
index aedbcb2d..8482ce18 100644
--- a/tests/integrations/docker/test_Docker__run.py
+++ b/tests/integrations/docker/test_Docker__run.py
@@ -3,25 +3,23 @@ import sys
 
 import pytest
 
+from briefcase.console import Log
+
 
 def test_simple_call(mock_docker, tmp_path, capsys):
     "A simple call will be invoked"
 
-    mock_docker.run(['hello', 'world'])
+    mock_docker.run(["hello", "world"])
 
     mock_docker._subprocess._subprocess.run.assert_called_with(
         [
-            'docker',
-            'run', '--tty',
-            '--volume', '{platform_path}:/app:z'.format(
-                platform_path=tmp_path / 'platform'
-            ),
-            '--volume', '{dot_briefcase_path}:/home/brutus/.briefcase:z'.format(
-                dot_briefcase_path=tmp_path / '.briefcase'
-            ),
-            'briefcase/com.example.myapp:py3.X',
-            'hello',
-            'world'
+            "docker",
+            "run", "--tty",
+            "--volume", f"{tmp_path / 'platform'}:/app:z",
+            "--volume", f"{tmp_path / '.briefcase'}:/home/brutus/.briefcase:z",
+            "briefcase/com.example.myapp:py3.X",
+            "hello",
+            "world"
         ]
     )
     assert capsys.readouterr().out == ""
@@ -30,21 +28,17 @@ def test_simple_call(mock_docker, tmp_path, capsys):
 def test_simple_call_with_arg(mock_docker, tmp_path, capsys):
     "Any extra keyword arguments are passed through as-is"
 
-    mock_docker.run(['hello', 'world'], universal_newlines=True)
+    mock_docker.run(["hello", "world"], universal_newlines=True)
 
     mock_docker._subprocess._subprocess.run.assert_called_with(
         [
-            'docker',
-            'run', '--tty',
-            '--volume', '{platform_path}:/app:z'.format(
-                platform_path=tmp_path / 'platform'
-            ),
-            '--volume', '{dot_briefcase_path}:/home/brutus/.briefcase:z'.format(
-                dot_briefcase_path=tmp_path / '.briefcase'
-            ),
-            'briefcase/com.example.myapp:py3.X',
-            'hello',
-            'world'
+            "docker",
+            "run", "--tty",
+            "--volume", f"{tmp_path / 'platform'}:/app:z",
+            "--volume", f"{tmp_path / '.briefcase'}:/home/brutus/.briefcase:z",
+            "briefcase/com.example.myapp:py3.X",
+            "hello",
+            "world"
         ],
         universal_newlines=True
     )
@@ -54,23 +48,19 @@ def test_simple_call_with_arg(mock_docker, tmp_path, capsys):
 def test_simple_call_with_path_arg(mock_docker, tmp_path, capsys):
     "Path-based arguments are converted to strings andpassed in as-is"
 
-    mock_docker.run(['hello', tmp_path / 'location'], cwd=tmp_path / 'cwd')
+    mock_docker.run(["hello", tmp_path / "location"], cwd=tmp_path / "cwd")
 
     mock_docker._subprocess._subprocess.run.assert_called_with(
         [
-            'docker',
-            'run',  '--tty',
-            '--volume', '{platform_path}:/app:z'.format(
-                platform_path=tmp_path / 'platform'
-            ),
-            '--volume', '{dot_briefcase_path}:/home/brutus/.briefcase:z'.format(
-                dot_briefcase_path=tmp_path / '.briefcase'
-            ),
-            'briefcase/com.example.myapp:py3.X',
-            'hello',
-            os.fsdecode(tmp_path / 'location')
+            "docker",
+            "run",  "--tty",
+            "--volume", f"{tmp_path / 'platform'}:/app:z",
+            "--volume", f"{tmp_path / '.briefcase'}:/home/brutus/.briefcase:z",
+            "briefcase/com.example.myapp:py3.X",
+            "hello",
+            os.fsdecode(tmp_path / "location")
         ],
-        cwd=os.fsdecode(tmp_path / 'cwd')
+        cwd=os.fsdecode(tmp_path / "cwd")
     )
     assert capsys.readouterr().out == ""
 
@@ -81,32 +71,27 @@ def test_simple_call_with_path_arg(mock_docker, tmp_path, capsys):
 )
 def test_simple_verbose_call(mock_docker, tmp_path, capsys):
     "If verbosity is turned out, there is output"
-    mock_docker.command.verbosity = 2
+    mock_docker.command.logger = Log(verbosity=2)
 
-    mock_docker.run(['hello', 'world'])
+    mock_docker.run(["hello", "world"])
 
     mock_docker._subprocess._subprocess.run.assert_called_with(
         [
-            'docker',
-            'run', '--tty',
-            '--volume', '{platform_path}:/app:z'.format(
-                platform_path=tmp_path / 'platform'
-            ),
-            '--volume', '{dot_briefcase_path}:/home/brutus/.briefcase:z'.format(
-                dot_briefcase_path=tmp_path / '.briefcase'
-            ),
-            'briefcase/com.example.myapp:py3.X',
-            'hello',
-            'world',
+            "docker",
+            "run", "--tty",
+            "--volume", f"{tmp_path / 'platform'}:/app:z",
+            "--volume", f"{tmp_path / '.briefcase'}:/home/brutus/.briefcase:z",
+            "briefcase/com.example.myapp:py3.X",
+            "hello",
+            "world",
         ]
     )
     assert capsys.readouterr().out == (
-        ">>> docker run --tty "
-        "--volume {platform_path}:/app:z "
-        "--volume {dot_briefcase_path}:/home/brutus/.briefcase:z "
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     docker run --tty "
+        f"--volume {tmp_path / 'platform'}:/app:z "
+        f"--volume {tmp_path / '.briefcase'}:/home/brutus/.briefcase:z "
         "briefcase/com.example.myapp:py3.X "
         "hello world\n"
-    ).format(
-        platform_path=tmp_path / 'platform',
-        dot_briefcase_path=tmp_path / '.briefcase',
     )
diff --git a/tests/integrations/docker/test_verify_docker.py b/tests/integrations/docker/test_verify_docker.py
index 49e74a71..b2ba78cd 100644
--- a/tests/integrations/docker/test_verify_docker.py
+++ b/tests/integrations/docker/test_verify_docker.py
@@ -3,6 +3,7 @@ from unittest import mock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.docker import (
     Docker,
@@ -14,6 +15,7 @@ from briefcase.integrations.docker import (
 @pytest.fixture
 def test_command(tmp_path):
     command = mock.MagicMock()
+    command.logger = Log()
 
     return command
 
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index ffe28551..abcf16af 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -8,6 +8,7 @@ from unittest import mock
 import pytest
 from requests import exceptions as requests_exceptions
 
+from briefcase.console import Log
 from briefcase.exceptions import (
     BriefcaseCommandError,
     MissingToolError,
@@ -20,6 +21,7 @@ from tests.utils import FsPathMock
 @pytest.fixture
 def test_command(tmp_path):
     command = mock.MagicMock()
+    command.logger = Log()
     command.tools_path = tmp_path / 'tools'
 
     # Mock environ.get returning no explicit JAVA_HOME
diff --git a/tests/integrations/subprocess/conftest.py b/tests/integrations/subprocess/conftest.py
index 844335d6..f951f873 100644
--- a/tests/integrations/subprocess/conftest.py
+++ b/tests/integrations/subprocess/conftest.py
@@ -3,14 +3,28 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.integrations.subprocess import Subprocess
+from briefcase.console import Log
 
 
 @pytest.fixture
 def mock_sub():
     command = MagicMock()
-    command.verbosity = 0
+    command.logger = Log(verbosity=1)
+
+    command.os = MagicMock()
+    command.os.environ = {
+        "VAR1": "Value 1",
+        "PS1": "\nLine 2\n\nLine 4",
+        "PWD": "/home/user/",
+    }
 
     sub = Subprocess(command)
     sub._subprocess = MagicMock()
 
+    run_result = MagicMock()
+    run_result.returncode = 0
+    sub._subprocess.run.return_value = run_result
+
+    sub._subprocess.check_output.return_value = "some output line 1\nmore output line 2"
+
     return sub
diff --git a/tests/integrations/subprocess/test_Subprocess__Popen.py b/tests/integrations/subprocess/test_Subprocess__Popen.py
index 7f5d13a7..fd75a308 100644
--- a/tests/integrations/subprocess/test_Subprocess__Popen.py
+++ b/tests/integrations/subprocess/test_Subprocess__Popen.py
@@ -1,7 +1,9 @@
 import os
 
+from briefcase.console import Log
 
-def test_simple_call(mock_sub, capsys):
+
+def test_call(mock_sub, capsys):
     "A simple call will be invoked"
 
     mock_sub.Popen(['hello', 'world'])
@@ -10,7 +12,7 @@ def test_simple_call(mock_sub, capsys):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_call_with_arg(mock_sub, capsys):
+def test_call_with_arg(mock_sub, capsys):
     "Any extra keyword arguments are passed through as-is"
 
     mock_sub.Popen(['hello', 'world'], universal_newlines=True)
@@ -22,7 +24,7 @@ def test_simple_call_with_arg(mock_sub, capsys):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_call_with_path_arg(mock_sub, capsys, tmp_path):
+def test_call_with_path_arg(mock_sub, capsys, tmp_path):
     "Path-based arguments are converted to strings andpassed in as-is"
 
     mock_sub.Popen(['hello', tmp_path / 'location'], cwd=tmp_path / 'cwd')
@@ -34,11 +36,91 @@ def test_simple_call_with_path_arg(mock_sub, capsys, tmp_path):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_verbose_call(mock_sub, capsys):
-    "If verbosity is turned out, there is output"
-    mock_sub.command.verbosity = 2
+def test_debug_call(mock_sub, capsys):
+    "If verbosity is turned up, there is output"
+    mock_sub.command.logger = Log(verbosity=2)
 
     mock_sub.Popen(['hello', 'world'])
 
     mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'])
-    assert capsys.readouterr().out == ">>> hello world\n"
+    assert capsys.readouterr().out == (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+    )
+
+
+def test_debug_call_with_env(mock_sub, capsys):
+    "If verbosity is turned up, and injected env vars are included output"
+    mock_sub.command.logger = Log(verbosity=2)
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.Popen(['hello', 'world'], env=env)
+
+    merged_env = mock_sub.command.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], env=merged_env)
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Environment:\n"
+        ">>>     NewVar=NewVarValue\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_deep_debug_call(mock_sub, capsys):
+    "If verbosity is at the max, the full environment and return is output"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    mock_sub.Popen(["hello", "world"])
+
+    mock_sub._subprocess.Popen.assert_called_with(["hello", "world"])
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_deep_debug_call_with_env(mock_sub, capsys):
+    "If verbosity is at the max, the full environment and return is output, and the environment is merged"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.Popen(['hello', 'world'], env=env)
+
+    merged_env = mock_sub.command.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], env=merged_env)
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>>     NewVar=NewVarValue\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
diff --git a/tests/integrations/subprocess/test_Subprocess__check_output.py b/tests/integrations/subprocess/test_Subprocess__check_output.py
index 992e99bf..fc1c84ea 100644
--- a/tests/integrations/subprocess/test_Subprocess__check_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__check_output.py
@@ -1,7 +1,12 @@
 import os
+from subprocess import CalledProcessError
 
+import pytest
 
-def test_simple_call(mock_sub, capsys):
+from briefcase.console import Log
+
+
+def test_call(mock_sub, capsys):
     "A simple call will be invoked"
 
     mock_sub.check_output(['hello', 'world'])
@@ -10,7 +15,7 @@ def test_simple_call(mock_sub, capsys):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_call_with_arg(mock_sub, capsys):
+def test_call_with_arg(mock_sub, capsys):
     "Any extra keyword arguments are passed through as-is"
 
     mock_sub.check_output(['hello', 'world'], universal_newlines=True)
@@ -22,7 +27,7 @@ def test_simple_call_with_arg(mock_sub, capsys):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_call_with_path_arg(mock_sub, capsys, tmp_path):
+def test_call_with_path_arg(mock_sub, capsys, tmp_path):
     "Path-based arguments are converted to strings andpassed in as-is"
 
     mock_sub.check_output(['hello', tmp_path / 'location'], cwd=tmp_path / 'cwd')
@@ -34,11 +39,134 @@ def test_simple_call_with_path_arg(mock_sub, capsys, tmp_path):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_verbose_call(mock_sub, capsys):
-    "If verbosity is turned out, there is output"
-    mock_sub.command.verbosity = 2
+def test_debug_call(mock_sub, capsys):
+    "If verbosity is turned up, there is output"
+    mock_sub.command.logger = Log(verbosity=2)
 
     mock_sub.check_output(['hello', 'world'])
 
     mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'])
-    assert capsys.readouterr().out == ">>> hello world\n"
+
+    assert capsys.readouterr().out == "\n>>> Running Command:\n>>>     hello world\n"
+
+
+def test_debug_call_with_env(mock_sub, capsys):
+    "If verbosity is turned up, injected env vars are included in output"
+    mock_sub.command.logger = Log(verbosity=2)
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.check_output(['hello', 'world'], env=env)
+
+    merged_env = mock_sub.command.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'], env=merged_env)
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Environment:\n"
+        ">>>     NewVar=NewVarValue\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_deep_debug_call(mock_sub, capsys):
+    "If verbosity is at the max, the full environment and return is output"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    mock_sub.check_output(["hello", "world"])
+
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"])
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>> Command Output:\n"
+        ">>>     some output line 1\n"
+        ">>>     more output line 2\n"
+        ">>> Return code: 0\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_deep_debug_call_with_env(mock_sub, capsys):
+    "If verbosity is at the max, the full environment and return is output, and the environment is merged"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.check_output(['hello', 'world'], env=env)
+
+    merged_env = mock_sub.command.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], env=merged_env)
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>>     NewVar=NewVarValue\n"
+        ">>> Command Output:\n"
+        ">>>     some output line 1\n"
+        ">>>     more output line 2\n"
+        ">>> Return code: 0\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_calledprocesserror_exception_logging(mock_sub, capsys):
+    "If command errors, ensure command output is printed"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    called_process_error = CalledProcessError(
+        returncode=-1,
+        cmd="hello world",
+        output="output line 1\noutput line 2",
+        stderr="error line 1\nerror line 2",
+    )
+    mock_sub._subprocess.check_output.side_effect = called_process_error
+
+    with pytest.raises(CalledProcessError):
+        mock_sub.check_output(["hello", "world"])
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>> Command Output:\n"
+        ">>>     output line 1\n"
+        ">>>     output line 2\n"
+        ">>> Command Error Output (stderr):\n"
+        ">>>     error line 1\n"
+        ">>>     error line 2\n"
+        ">>> Return code: -1\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
diff --git a/tests/integrations/subprocess/test_Subprocess__run.py b/tests/integrations/subprocess/test_Subprocess__run.py
index e551b64d..673eff4e 100644
--- a/tests/integrations/subprocess/test_Subprocess__run.py
+++ b/tests/integrations/subprocess/test_Subprocess__run.py
@@ -1,7 +1,12 @@
 import os
+from subprocess import CalledProcessError
 
+import pytest
 
-def test_simple_call(mock_sub, capsys):
+from briefcase.console import Log
+
+
+def test_call(mock_sub, capsys):
     "A simple call will be invoked"
 
     mock_sub.run(['hello', 'world'])
@@ -10,7 +15,7 @@ def test_simple_call(mock_sub, capsys):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_call_with_arg(mock_sub, capsys):
+def test_call_with_arg(mock_sub, capsys):
     "Any extra keyword arguments are passed through as-is"
 
     mock_sub.run(['hello', 'world'], universal_newlines=True)
@@ -22,7 +27,7 @@ def test_simple_call_with_arg(mock_sub, capsys):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_call_with_path_arg(mock_sub, capsys, tmp_path):
+def test_call_with_path_arg(mock_sub, capsys, tmp_path):
     "Path-based arguments are converted to strings andpassed in as-is"
 
     mock_sub.run(['hello', tmp_path / 'location'], cwd=tmp_path / 'cwd')
@@ -34,11 +39,124 @@ def test_simple_call_with_path_arg(mock_sub, capsys, tmp_path):
     assert capsys.readouterr().out == ""
 
 
-def test_simple_verbose_call(mock_sub, capsys):
-    "If verbosity is turned out, there is output"
-    mock_sub.command.verbosity = 2
+def test_debug_call(mock_sub, capsys):
+    "If verbosity is turned up, there is output"
+    mock_sub.command.logger = Log(verbosity=2)
 
     mock_sub.run(['hello', 'world'])
 
     mock_sub._subprocess.run.assert_called_with(['hello', 'world'])
-    assert capsys.readouterr().out == ">>> hello world\n"
+    assert capsys.readouterr().out == (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+    )
+
+
+def test_debug_call_with_env(mock_sub, capsys):
+    "If verbosity is turned up, injected env vars are included output"
+    mock_sub.command.logger = Log(verbosity=2)
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.run(['hello', 'world'], env=env)
+
+    merged_env = mock_sub.command.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.run.assert_called_with(['hello', 'world'], env=merged_env)
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Environment:\n"
+        ">>>     NewVar=NewVarValue\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_deep_debug_call(mock_sub, capsys):
+    "If verbosity is at the max, the full environment and return is output"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    mock_sub.run(["hello", "world"])
+
+    mock_sub._subprocess.run.assert_called_with(["hello", "world"])
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>> Return code: 0\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_deep_debug_call_with_env(mock_sub, capsys):
+    "If verbosity is at the max, the full environment and return is output, and the environment is merged"
+    mock_sub.command.logger = Log(verbosity=3)
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.run(["hello", "world"], env=env)
+
+    merged_env = mock_sub.command.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.run.assert_called_with(["hello", "world"], env=merged_env)
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>>     NewVar=NewVarValue\n"
+        ">>> Return code: 0\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_calledprocesserror_exception_logging(mock_sub, capsys):
+    mock_sub.command.logger = Log(verbosity=3)
+
+    called_process_error = CalledProcessError(
+        returncode=-1,
+        cmd="hello world",
+        output="output line 1\noutput line 2",
+        stderr="error line 1\nerror line 2",
+    )
+    mock_sub._subprocess.run.side_effect = called_process_error
+
+    with pytest.raises(CalledProcessError):
+        mock_sub.run(["hello", "world"])
+
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Full Environment:\n"
+        ">>>     VAR1=Value 1\n"
+        ">>>     PS1=\n"
+        ">>> Line 2\n"
+        ">>> \n"
+        ">>> Line 4\n"
+        ">>>     PWD=/home/user/\n"
+        ">>> Return code: -1\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
diff --git a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
index 54819a40..3f6b6c35 100644
--- a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
+++ b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
@@ -3,6 +3,7 @@ from unittest import mock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.xcode import confirm_xcode_license_accepted
 
@@ -10,6 +11,7 @@ from briefcase.integrations.xcode import confirm_xcode_license_accepted
 def test_license_accepted(capsys):
     "If the Xcode license has been accepted, pass without comment."
     command = mock.MagicMock()
+    command.logger = Log()
 
     # Check passes without an error...
     confirm_xcode_license_accepted(command)
@@ -28,6 +30,7 @@ def test_license_accepted(capsys):
 def test_unknown_error(capsys):
     "If an unexpected problem occurred accepting the license, warn the user."
     command = mock.MagicMock()
+    command.logger = Log()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['/usr/bin/clang', '--version'],
         returncode=1
@@ -132,6 +135,7 @@ def test_license_not_accepted():
 def test_license_status_unknown(capsys):
     "If we get an unusual response from the license, warn but continue."
     command = mock.MagicMock()
+    command.logger = Log()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['/usr/bin/clang', '--version'],
         returncode=69
diff --git a/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py b/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
index e6f32e3e..f9d462bb 100644
--- a/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
+++ b/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
@@ -3,6 +3,7 @@ from unittest import mock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.xcode import (
     ensure_command_line_tools_are_installed
@@ -12,7 +13,7 @@ from briefcase.integrations.xcode import (
 def test_not_installed():
     "If cmdline dev tools are not installed, raise an error."
     command = mock.MagicMock()
-
+    command.logger = Log()
     with pytest.raises(BriefcaseCommandError):
         ensure_command_line_tools_are_installed(command)
 
@@ -26,6 +27,7 @@ def test_not_installed():
 def test_installed(capsys):
     "If cmdline dev tools *are* installed, check passes without comment."
     command = mock.MagicMock()
+    command.logger = Log()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['xcode-select', '--install'],
         returncode=1
@@ -48,6 +50,7 @@ def test_installed(capsys):
 def test_unsure_if_installed(capsys):
     "If xcode-select returns something odd, mention it but don't break."
     command = mock.MagicMock()
+    command.logger = Log()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['xcode-select', '--install'],
         returncode=69
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index f82de173..27662fa5 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -4,6 +4,7 @@ from unittest import mock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.xcode import ensure_xcode_is_installed
 
@@ -115,6 +116,7 @@ def test_installed_extra_output(capsys, xcode):
     "If Xcode but outputs extra content, the check is still satisfied."
     # This specific output was seen in the wild with Xcode 13.2.1; see #668
     command = mock.MagicMock()
+    command.logger = Log()
     command.subprocess.check_output.return_value = '\n'.join([
         "objc[86306]: Class AMSupportURLConnectionDelegate is implemented in both /usr/lib/libauthinstall.dylib (0x20d17ab90) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b82c8). One of the two will be used. Which one is undefined."  # noqa: E501
         "objc[86306]: Class AMSupportURLSession is implemented in both /usr/lib/libauthinstall.dylib (0x20d17abe0) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b8318). One of the two will be used. Which one is undefined.",  # noqa: E501
@@ -257,6 +259,7 @@ def test_installed_with_minimum_version_failure(min_version, version, xcode):
 def test_unexpected_version_output(capsys, xcode):
     "If xcodebuild returns unexpected output, assume it's ok..."
     command = mock.MagicMock()
+    command.logger = Log()
     command.subprocess.check_output.return_value = "Wibble Wibble Wibble\n"
 
     # Check passes without an error...
diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py
index bb527cf8..eb979bd3 100644
--- a/tests/platforms/linux/appimage/test_build.py
+++ b/tests/platforms/linux/appimage/test_build.py
@@ -39,7 +39,6 @@ def build_command(tmp_path, first_app_config):
     )
     command.host_os = 'Linux'
     command.host_arch = 'wonky'
-    command.verbosity = 0
     command.use_docker = False
     command._path_index = {
         first_app_config: {
diff --git a/tests/platforms/linux/appimage/test_mixin.py b/tests/platforms/linux/appimage/test_mixin.py
index bab75863..544347f8 100644
--- a/tests/platforms/linux/appimage/test_mixin.py
+++ b/tests/platforms/linux/appimage/test_mixin.py
@@ -98,7 +98,6 @@ def test_verify_non_linux_no_docker(tmp_path):
     command = LinuxAppImageCreateCommand(base_path=tmp_path)
     command.host_os = "WeirdOS"
     command.use_docker = False
-    command.verbosity = 0
 
     # Verify the tools
     with pytest.raises(BriefcaseCommandError):
@@ -110,7 +109,6 @@ def test_verify_linux_docker(tmp_path):
     command = LinuxAppImageCreateCommand(base_path=tmp_path)
     command.host_os = "Linux"
     command.use_docker = True
-    command.verbosity = 0
     # Mock the existence of Docker.
     command.subprocess = MagicMock()
     command.subprocess.check_output.return_value = "Docker version 19.03.8, build afacb8b\n"
@@ -127,7 +125,6 @@ def test_verify_non_linux_docker(tmp_path):
     command = LinuxAppImageCreateCommand(base_path=tmp_path)
     command.host_os = "WierdOS"
     command.use_docker = True
-    command.verbosity = 0
     # Mock the existence of Docker.
     command.subprocess = MagicMock()
     command.subprocess.check_output.return_value = "Docker version 19.03.8, build afacb8b\n"
@@ -144,7 +141,6 @@ def test_verify_windows_docker(tmp_path):
     command = LinuxAppImageCreateCommand(base_path=tmp_path)
     command.host_os = "Windows"
     command.use_docker = True
-    command.verbosity = 0
 
     # Verify the tools
     with pytest.raises(BriefcaseCommandError):
diff --git a/tests/platforms/linux/appimage/test_run.py b/tests/platforms/linux/appimage/test_run.py
index f2456620..80972531 100644
--- a/tests/platforms/linux/appimage/test_run.py
+++ b/tests/platforms/linux/appimage/test_run.py
@@ -10,7 +10,6 @@ from briefcase.platforms.linux.appimage import LinuxAppImageRunCommand
 def test_verify_linux(tmp_path):
     "A linux App can be started on linux"
     command = LinuxAppImageRunCommand(base_path=tmp_path)
-    command.verbosity = 0
     command.use_docker = True
     command.host_os = "Linux"
 
@@ -24,7 +23,6 @@ def test_verify_linux(tmp_path):
 def test_verify_non_linux(tmp_path):
     "A linux App can be started on linux, even if Docker is enabled"
     command = LinuxAppImageRunCommand(base_path=tmp_path)
-    command.verbosity = 0
     command.use_docker = True
     command.host_os = "WierdOS"
 
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index 1615d751..f86427f7 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -91,7 +91,7 @@ def test_new_command():
     assert cmd.platform == 'all'
     assert cmd.output_format is None
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {'template': None}
 
 
@@ -106,7 +106,7 @@ def test_dev_command(monkeypatch):
     assert cmd.platform == 'macOS'
     assert cmd.output_format is None
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {
         'appname': None,
         'update_dependencies': False,
@@ -125,7 +125,7 @@ def test_upgrade_command(monkeypatch):
     assert cmd.platform == 'macOS'
     assert cmd.output_format is None
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {
         'list_tools': False,
         'tool_list': [],
@@ -143,7 +143,7 @@ def test_bare_command(monkeypatch):
     assert cmd.platform == 'macOS'
     assert cmd.output_format == 'app'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -157,7 +157,7 @@ def test_linux_default():
     assert cmd.platform == 'linux'
     assert cmd.output_format == 'appimage'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -171,7 +171,7 @@ def test_macOS_default():
     assert cmd.platform == 'macOS'
     assert cmd.output_format == 'app'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -185,7 +185,7 @@ def test_windows_default():
     assert cmd.platform == 'windows'
     assert cmd.output_format == 'msi'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -257,7 +257,7 @@ def test_command_explicit_platform(monkeypatch):
     assert cmd.platform == 'linux'
     assert cmd.output_format == 'appimage'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -273,7 +273,7 @@ def test_command_explicit_platform_case_handling(monkeypatch):
     assert cmd.platform == 'macOS'
     assert cmd.output_format == 'app'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -320,7 +320,7 @@ def test_command_explicit_format(monkeypatch):
     assert cmd.platform == 'macOS'
     assert cmd.output_format == 'app'
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -385,7 +385,7 @@ def test_command_disable_input(monkeypatch):
     assert cmd.platform == 'macOS'
     assert cmd.output_format == 'app'
     assert not cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {}
 
 
@@ -400,7 +400,7 @@ def test_command_options(monkeypatch, capsys):
 
     assert isinstance(cmd, macOSAppPublishCommand)
     assert cmd.input.enabled
-    assert cmd.verbosity == 1
+    assert cmd.logger.verbosity == 1
     assert options == {
         'channel': 's3'
     }