Repository: beeware/briefcase
Pull request #1552 (2023-11-22 22:00:14)

Add support for specifying overrides in the command line for new projects
## Changes
- Configuration for new projects can be specified at the command line via `-Q`
  - For instance, to specify the license, `-Q "license=MIT license"`
- Question prompts are now re-flowed based on terminal size
- Questions are more clearly formatted and separated
- Issues with user inputs are written as logging warnings
- All user prompts are now written in bold by default

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [X] All new features have been tested
- [X] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct


rmartin16: > One documentation related issue - the names of the configuration keys will be mostly undiscoverable by end users. Unless you actually open the template, you won't know that you can use `app_name` to set the app name. However, I wonder if we should make this _deliberate_ - i.e., provide the functionality, but _not_ document the specific keys that can be used (and document them as being not documented), so that we don't have a backwards incompatibility problem if we need to change the template. The docs for the -Q option can then tell the user that the keys are template dependent, and we make specific guarantees of compatibility of keys between Briefcase versions.

I agree with that....although, it also kinda makes me wonder if we shouldn't let this be a backdoor to putting extra key/value pairs in to the cookiecutter context without requiring a custom bootstrap...
rmartin16: I suppose this also begs the question if these overrides should be exposed to third-party bootstraps....probably either via the bootstrap's constructor or passing it to `bootstrap.extra_content()`.

Although, this is also really just letting scope creep take the reins.....and YAGNI, anyway.

That said, I do think we should add `**kwargs` to all the methods in the built-in bootstraps; this should encourage anyone who makes a bootstrap to do the same and IDEs should help with detecting incongruent method overriding. This would just help provide cover for breaks in compatibility when new arguments are passed.

Patch:
diff --git a/changes/1552.feature.rst b/changes/1552.feature.rst
new file mode 100644
index 00000000..b873c2e3
--- /dev/null
+++ b/changes/1552.feature.rst
@@ -0,0 +1,1 @@
+When creating new projects with the ``briefcase new`` command, project configuration overrides can be specified via the ``-Q`` command line argument. For instance, a specific license can be specified with ``-Q "license=MIT license"``.
diff --git a/docs/reference/commands/new.rst b/docs/reference/commands/new.rst
index b376cbf0..d4fbeefe 100644
--- a/docs/reference/commands/new.rst
+++ b/docs/reference/commands/new.rst
@@ -34,3 +34,18 @@ version of Briefcase that is being used (i.e., if you're using Briefcase 0.3.14,
 Briefcase will use the ``v0.3.14`` template branch when generating the app). If
 you're using a development version of Briefcase, Briefcase will use the ``main``
 branch of the template.
+
+``-Q <KEY=VALUE>``
+------------------
+
+Override the answer to a new project prompt with the provided value.
+
+For instance, if ``-Q "license=MIT license`` is specified, then the question to
+choose a license will not be presented to the user and the MIT license will be
+automatically used for the project. When used in conjunction with ``--no-input``,
+the provided value overrides the default answer.
+
+The expected keys are specified by the cookiecutter template being used to
+create the new project. Therefore, the set of possible keys is not listed here
+but should be expected to remain consistent for any specific version of
+Briefcase; with version changes, though, the keys may change.
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index ee1a30ff..f4d78b13 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -65,7 +65,7 @@ class DevCommand(RunAppMixin, BaseCommand):
             dest="run_app",
             action="store_false",
             default=True,
-            help="Do not run the app, just install requirements.",
+            help="Do not run the app, just install requirements",
         )
         parser.add_argument(
             "--test",
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 59d0cb50..8a230663 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -1,10 +1,13 @@
 from __future__ import annotations
 
 import re
+import shutil
 import sys
+import textwrap
 import unicodedata
 from collections import OrderedDict
 from email.utils import parseaddr
+from typing import Iterable
 from urllib.parse import urlparse
 
 from packaging.version import Version
@@ -29,6 +32,8 @@ from briefcase.integrations.git import Git
 
 from .base import BaseCommand
 
+MAX_TEXT_WIDTH = max(min(shutil.get_terminal_size().columns, 80) - 2, 20)
+
 
 def titlecase(s):
     """Convert a string to titlecase.
@@ -80,6 +85,31 @@ def get_gui_bootstraps() -> dict[str, type[BaseGuiBootstrap]]:
     }
 
 
+def parse_project_overrides(project_overrides: list[str]) -> dict[str, str]:
+    """Parse the command-line arguments to override new project defaults."""
+    overrides = {}
+
+    if project_overrides:
+        for override in project_overrides:
+            try:
+                key, value = override.split("=", 1)
+            except ValueError as e:
+                raise BriefcaseCommandError(
+                    f"Unable to parse project configuration override {override!r}"
+                ) from e
+            else:
+                if not ((key := key.strip()) and (value := value.strip())):
+                    raise BriefcaseCommandError(
+                        f"Invalid Project configuration override {override!r}"
+                    )
+                if key in overrides:
+                    raise BriefcaseCommandError(
+                        f"Project configuration override {key!r} specified multiple times"
+                    )
+                overrides[key] = value
+    return overrides
+
+
 class NewCommand(BaseCommand):
     cmd_line = "briefcase new"
     command = "new"
@@ -114,6 +144,14 @@ class NewCommand(BaseCommand):
             help="The branch of the cookiecutter template to use for the new project",
         )
 
+        parser.add_argument(
+            "-Q",
+            dest="project_overrides",
+            action="append",
+            metavar="KEY=VALUE",
+            help="Override the value of the project configuration item KEY with VALUE",
+        )
+
     def make_app_name(self, formal_name):
         """Construct a candidate app name from a formal name.
 
@@ -239,49 +277,99 @@ class NewCommand(BaseCommand):
             raise ValueError("Not a valid URL!")
         return True
 
-    def input_text(self, intro, variable, default, validator=None):
+    def prompt_divider(self, title: str = ""):
+        """Writes a divider with an optional title."""
+        title = f"-- {title} " if title else ""
+        self.input.prompt()
+        self.input.prompt()
+        self.input.prompt(f"{title}{'-' * (MAX_TEXT_WIDTH - len(title))}", style="bold")
+
+    def prompt_intro(self, intro: str):
+        """Write the introduction for a prompt."""
+        self.input.prompt()
+        # textwrap isn't really designed to format text that already contains newlines.
+        # So, instead, break the intro by newlines and format each line individually.
+        self.input.prompt(
+            "\n".join(
+                "\n".join(textwrap.wrap(line, MAX_TEXT_WIDTH))
+                for line in intro.splitlines()
+            )
+        )
+        self.input.prompt()
+
+    def validate_user_input(self, validator, answer) -> bool:
+        """Validate a user's input is acceptable.
+
+        A warning message is issued if input is enabled. Otherwise, an exception is
+        raised and project creation is aborted.
+        """
+        error_msg = f"Invalid value; {answer}"
+        try:
+            if validator is None or validator(answer):
+                return True
+        except ValueError as e:
+            error_msg = str(e)
+
+        if not self.input.enabled:
+            raise BriefcaseCommandError(error_msg)
+        self.logger.warning()
+        self.logger.warning(error_msg)
+
+        return False
+
+    def validate_override(self, override_value: str | None, validator=None) -> bool:
+        """Validates and returns override value for a project configuration."""
+        if override_value is not None:
+            self.input.prompt()
+            self.input.prompt(f"Using override value {override_value!r}")
+            if not self.validate_user_input(validator, override_value):
+                return False
+        return True
+
+    def validate_selection_override(
+        self,
+        choices: Iterable[str],
+        override_value: str | None,
+    ) -> bool:
+        """Validate a project override value against a list of options."""
+        return self.validate_override(override_value, validator=lambda c: c in choices)
+
+    def input_text(self, intro, variable, default, validator=None, override_value=None):
         """Read a text answer from the user.
 
-        :param intro: An introductory paragraph explaining the question being
-            asked.
+        :param intro: An introductory paragraph explaining the question being asked.
         :param variable: The name of the variable being entered.
-        :param default: The default value if the user hits enter without typing
-            anything.
-        :param validator: (optional) A validator function; accepts a single
-            input (the candidate response), returns True if the answer is
-            valid, or raises ValueError() with a debugging message if the
-            candidate value isn't valid.
-        :returns: a string, guaranteed to meet the validation criteria of
-            ``validator``.
+        :param default: The default value if the user hits enter without typing anything.
+        :param validator: (optional) A validator function; accepts a single input (the
+            candidate response), returns True if the answer is valid, or raises
+            ValueError() with a debugging message if the candidate value isn't valid.
+        :param override_value: value to return instead of soliciting input
+        :returns: a string, guaranteed to meet the validation criteria of ``validator``
         """
-        self.input.prompt(intro)
+        variable = titlecase(variable)
+        self.prompt_divider(title=variable)
 
-        while True:
-            self.input.prompt()
+        if override_value and self.validate_override(override_value, validator):
+            return override_value
 
-            answer = self.input.text_input(
-                f"{titlecase(variable)} [{default}]: ", default=default
-            )
+        self.prompt_intro(intro=intro)
+        while True:
+            answer = self.input.text_input(f"{variable} [{default}]: ", default=default)
 
-            if validator is None:
+            if self.validate_user_input(validator, answer):
                 return answer
 
-            try:
-                validator(answer)
-                return answer
-            except ValueError as e:
-                if not self.input.enabled:
-                    raise BriefcaseCommandError(str(e)) from e
-                self.input.prompt()
-                self.input.prompt(f"Invalid value; {e}")
+            self.input.prompt()
 
     def build_context(
         self,
         template_source: str,
         template_branch: str,
         briefcase_version: Version,
+        project_overrides: dict[str, str],
     ) -> dict[str, str]:
-        context = self.build_app_context()
+        """Builds the cookiecutter context dict for the new project."""
+        context = self.build_app_context(project_overrides)
         # Additional context for the Briefcase template pyproject.toml header to
         # include the version of Briefcase as well as the source of the template.
         context.update(
@@ -291,22 +379,25 @@ class NewCommand(BaseCommand):
                 "briefcase_version": str(briefcase_version),
             }
         )
-        context.update(self.build_gui_context(context=context))
+        context.update(self.build_gui_context(context, project_overrides))
         return context
 
-    def build_app_context(self) -> dict[str, str]:
+    def build_app_context(self, project_overrides: dict[str, str]) -> dict[str, str]:
         """Ask the user for details about the app to be created.
 
         :returns: A context dictionary to be used in the cookiecutter project template.
         """
         formal_name = self.input_text(
-            intro="""
-First, we need a formal name for your application. This is the name that will
-be displayed to humans whenever the name of the application is displayed. It
-can have spaces and punctuation if you like, and any capitalization will be
-used as you type it.""",
+            intro=(
+                "First, we need a formal name for your application.\n"
+                "\n"
+                "This is the name that will be displayed to humans whenever the name "
+                "of the application is displayed. It can have spaces and punctuation "
+                "if you like, and any capitalization will be used as you type it."
+            ),
             variable="formal name",
             default="Hello World",
+            override_value=project_overrides.pop("formal_name", None),
         )
 
         # The class name can be completely derived from the formal name.
@@ -314,85 +405,105 @@ used as you type it.""",
 
         default_app_name = self.make_app_name(formal_name)
         app_name = self.input_text(
-            intro=f"""
-Next, we need a name that can serve as a machine-readable Python package name
-for your application. This name must be PEP508-compliant - that means the name
-may only contain letters, numbers, hyphens and underscores; it can't contain
-spaces or punctuation, and it can't start with a hyphen or underscore.
-
-Based on your formal name, we suggest an app name of '{default_app_name}',
-but you can use another name if you want.""",
+            intro=(
+                "Next, we need a name that can serve as a machine-readable Python "
+                "package name for your application.\n"
+                "\n"
+                "This name must be PEP508-compliant - that means the name may only "
+                "contain letters, numbers, hyphens and underscores; it can't contain "
+                "spaces or punctuation, and it can't start with a hyphen or "
+                "underscore.\n"
+                "\n"
+                "Based on your formal name, we suggest an app name of "
+                f"{default_app_name!r}, but you can use another name if you want."
+            ),
             variable="app name",
             default=default_app_name,
             validator=self.validate_app_name,
+            override_value=project_overrides.pop("app_name", None),
         )
 
         # The module name can be completely derived from the app name.
         module_name = self.make_module_name(app_name)
 
         bundle = self.input_text(
-            intro=f"""
-Now we need a bundle identifier for your application. App stores need to
-protect against having multiple applications with the same name; the bundle
-identifier is the namespace they use to identify applications that come from
-you. The bundle identifier is usually the domain name of your company or
-project, in reverse order.
-
-For example, if you are writing an application for Example Corp, whose website
-is example.com, your bundle would be ``com.example``. The bundle will be
-combined with your application's machine readable name to form a complete
-application identifier (e.g., com.example.{app_name}).""",
+            intro=(
+                "Now we need a bundle identifier for your application.\n"
+                "\n"
+                "App stores need to protect against having multiple applications with "
+                "the same name; the bundle identifier is the namespace they use to "
+                "identify applications that come from you. The bundle identifier is "
+                "usually the domain name of your company or project, in reverse order.\n"
+                "\n"
+                "For example, if you are writing an application for Example Corp, "
+                "whose website is example.com, your bundle would be ``com.example``. "
+                "The bundle will be combined with your application's machine readable "
+                "name to form a complete application identifier (e.g., "
+                f"com.example.{app_name})."
+            ),
             variable="bundle identifier",
             default="com.example",
             validator=self.validate_bundle,
+            override_value=project_overrides.pop("bundle", None),
         )
 
         project_name = self.input_text(
-            intro="""
-Briefcase can manage projects that contain multiple applications, so we need a
-Project name. If you're only planning to have one application in this
-project, you can use the formal name as the project name.""",
+            intro=(
+                "Briefcase can manage projects that contain multiple applications, so "
+                "we need a Project name.\n"
+                "\n"
+                "If you're only planning to have one application in this project, you "
+                "can use the formal name as the project name."
+            ),
             variable="project name",
             default=formal_name,
+            override_value=project_overrides.pop("project_name", None),
         )
 
         description = self.input_text(
-            intro="""
-Now, we need a one line description for your application.""",
+            intro="Now, we need a one line description for your application.",
             variable="description",
             default="My first application",
+            override_value=project_overrides.pop("description", None),
         )
 
         author = self.input_text(
-            intro="""
-Who do you want to be credited as the author of this application? This could be
-your own name, or the name of your company you work for.""",
+            intro=(
+                "Who do you want to be credited as the author of this application?\n"
+                "\n"
+                "This could be your own name, or the name of your company you work for."
+            ),
             variable="author",
             default="Jane Developer",
+            override_value=project_overrides.pop("author", None),
         )
 
         author_email = self.input_text(
-            intro="""
-What email address should people use to contact the developers of this
-application? This might be your own email address, or a generic contact address
-you set up specifically for this application.""",
+            intro=(
+                "What email address should people use to contact the developers of "
+                "this application?\n"
+                "\n"
+                "This might be your own email address, or a generic contact address "
+                "you set up specifically for this application."
+            ),
             variable="author's email",
             default=self.make_author_email(author, bundle),
             validator=self.validate_email,
+            override_value=project_overrides.pop("author_email", None),
         )
 
         url = self.input_text(
-            intro="""
-What is the website URL for this application? If you don't have a website set
-up yet, you can put in a dummy URL.""",
+            intro=(
+                "What is the website URL for this application?\n"
+                "\n"
+                "If you don't have a website set up yet, you can put in a dummy URL."
+            ),
             variable="application URL",
             default=self.make_project_url(bundle, app_name),
             validator=self.validate_url,
+            override_value=project_overrides.pop("url", None),
         )
 
-        self.input.prompt()
-        self.input.prompt("What license do you want to use for this project's code?")
-        self.input.prompt()
         licenses = [
             "BSD license",
             "MIT license",
@@ -404,12 +515,24 @@ up yet, you can put in a dummy URL.""",
             "Proprietary",
             "Other",
         ]
-        project_license = select_option(
-            prompt="Project License [1]: ",
-            input=self.input,
-            default="1",
-            options=list(zip(licenses, licenses)),
-        )
+
+        self.prompt_divider(title="Project License")
+
+        project_license = None
+        if license_override := project_overrides.pop("license", None):
+            if self.validate_selection_override(licenses, license_override):
+                project_license = license_override
+
+        if not project_license:
+            self.prompt_intro(
+                "What license do you want to use for this project's code?"
+            )
+            project_license = select_option(
+                prompt="Project License [1]: ",
+                input=self.input,
+                default="1",
+                options=list(zip(licenses, licenses)),
+            )
 
         return {
             "formal_name": formal_name,
@@ -425,24 +548,34 @@ up yet, you can put in a dummy URL.""",
             "license": project_license,
         }
 
-    def build_gui_context(self, context: dict[str, str]) -> dict[str, str]:
+    def build_gui_context(
+        self,
+        context: dict[str, str],
+        project_overrides: dict[str, str],
+    ) -> dict[str, str]:
         """Build context specific to the GUI toolkit."""
         bootstraps = get_gui_bootstraps()
 
-        self.input.prompt()
-        self.input.prompt("What GUI toolkit do you want to use for this project?")
-        self.input.prompt()
-        bootstrap_class: type[BaseGuiBootstrap] = select_option(
-            prompt="GUI Framework [1]: ",
-            input=self.input,
-            default="1",
-            options=self._gui_bootstrap_choices(bootstraps),
-        )
+        self.prompt_divider(title="GUI Framework")
+
+        bootstrap_class = None
+        if bootstrap_override := project_overrides.pop("bootstrap", None):
+            if self.validate_selection_override(bootstraps.keys(), bootstrap_override):
+                bootstrap_class = bootstraps[bootstrap_override]
+
+        if not bootstrap_class:
+            self.prompt_intro("What GUI toolkit do you want to use for this project?")
+            bootstrap_class = select_option(
+                prompt="GUI Framework [1]: ",
+                input=self.input,
+                default="1",
+                options=self._gui_bootstrap_choices(bootstraps),
+            )
 
         gui_context = {}
 
         if bootstrap_class is not None:
-            bootstrap = bootstrap_class(context=context)
+            bootstrap: BaseGuiBootstrap = bootstrap_class(context=context)
 
             # Iterate over the Bootstrap interface to build the context.
             # Returning ``None`` is a special case that means the field should not be
@@ -490,13 +623,13 @@ up yet, you can put in a dummy URL.""",
         self,
         template: str | None = None,
         template_branch: str | None = None,
+        project_overrides: dict[str, str] | None = None,
         **options,
     ):
         """Ask questions to generate a new application, and generate a stub project from
         the briefcase-template."""
         self.input.prompt()
         self.input.prompt("Let's build a new Briefcase app!")
-        self.input.prompt()
 
         if template is None:
             template = "https://github.com/beeware/briefcase-template"
@@ -509,15 +642,29 @@ up yet, you can put in a dummy URL.""",
         else:
             branch = template_branch
 
-        context = self.build_context(template, branch, version)
+        context = self.build_context(template, branch, version, project_overrides)
+        self.prompt_divider()  # close the prompting section of output
 
-        self.logger.info()
-        self.logger.info(f"Generating a new application '{context['formal_name']}'")
+        # Inform user of project configuration overrides that were not used
+        if project_overrides:
+            unused_overrides = "\n    ".join(
+                f"{key} = {value}" for key, value in project_overrides.items()
+            )
+            self.logger.warning()
+            self.logger.warning(
+                "WARNING: These project configuration overrides were not used:\n\n"
+                f"    {unused_overrides}"
+            )
+
+        self.logger.info(
+            f"Generating a new application {context['formal_name']!r}",
+            prefix=context["app_name"],
+        )
 
         # Make extra sure we won't clobber an existing application.
         if (self.base_path / context["app_name"]).exists():
             raise BriefcaseCommandError(
-                f"A directory named '{context['app_name']}' already exists."
+                f"A directory named {context['app_name']!r} already exists."
             )
 
         try:
@@ -547,12 +694,16 @@ up yet, you can put in a dummy URL.""",
                 extra_context=context,
             )
 
+        self.logger.info(
+            f"Generated new application {context['formal_name']!r}",
+            prefix=context["app_name"],
+        )
         self.logger.info(
             f"""
-Application '{context['formal_name']}' has been generated. To run your application, type:
+To run your application, type:
 
-    cd {context['app_name']}
-    briefcase dev
+    $ cd {context['app_name']}
+    $ briefcase dev
 """
         )
 
@@ -568,6 +719,7 @@ Application '{context['formal_name']}' has been generated. To run your applicati
         self,
         template: str | None = None,
         template_branch: str | None = None,
+        project_overrides: list[str] = None,
         **options,
     ):
         # Confirm host compatibility, and that all required tools are available.
@@ -577,5 +729,6 @@ Application '{context['formal_name']}' has been generated. To run your applicati
         return self.new_app(
             template=template,
             template_branch=template_branch,
+            project_overrides=parse_project_overrides(project_overrides),
             **options,
         )
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 465fc477..40348190 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -11,8 +11,8 @@ class PackageCommand(BaseCommand):
     command = "package"
     description = "Package an app for distribution."
 
-    ADHOC_SIGN_HELP = "Ignored; signing is not supported."
-    IDENTITY_HELP = "Ignored; signing is not supported."
+    ADHOC_SIGN_HELP = "Ignored; signing is not supported"
+    IDENTITY_HELP = "Ignored; signing is not supported"
 
     @property
     def packaging_formats(self):
@@ -107,13 +107,13 @@ class PackageCommand(BaseCommand):
             "-u",
             "--update",
             action="store_true",
-            help="Update the app before building.",
+            help="Update the app before building",
         )
         parser.add_argument(
             "-p",
             "--packaging-format",
             dest="packaging_format",
-            help="Packaging format to use.",
+            help="Packaging format to use",
             default=self.default_packaging_format,
             choices=self.packaging_formats,
         )
diff --git a/src/briefcase/commands/upgrade.py b/src/briefcase/commands/upgrade.py
index c432a0b2..932980f9 100644
--- a/src/briefcase/commands/upgrade.py
+++ b/src/briefcase/commands/upgrade.py
@@ -42,13 +42,13 @@ class UpgradeCommand(BaseCommand):
             "--list",
             dest="list_tools",
             action="store_true",
-            help="List the Briefcase-managed tools that are currently installed.",
+            help="List the Briefcase-managed tools that are currently installed",
         )
         parser.add_argument(
             "tool_list",
             metavar="tool",
             nargs="*",
-            help="The Briefcase-managed tool to upgrade. If no tool is named, all tools will be upgraded.",
+            help="The Briefcase-managed tool to upgrade. If no tool is named, all tools will be upgraded",
         )
 
     def get_tools_to_upgrade(self, tool_list: set[str]) -> list[ManagedTool]:
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index d8ae1da9..18532c98 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -657,14 +657,20 @@ class Console:
         """Present input() interface."""
         if not self.enabled:
             raise InputDisabled()
+
+        # make the prompt *bold* if it doesn't already contain markup
+        escaped_prompt = f"[bold]{escape(prompt)}[/bold]" if not markup else prompt
+
         try:
-            input_value = self.input(prompt, markup=markup)
-            self.print.to_log(prompt)
-            self.print.to_log(f"User input: {input_value}")
-            return input_value
+            input_value = self.input(escaped_prompt, markup=True)
         except EOFError:
             raise KeyboardInterrupt
 
+        self.print.to_log(prompt)
+        self.print.to_log(f"User input: {input_value}")
+
+        return input_value
+
 
 def select_option(options, input, prompt="> ", error="Invalid selection", default=None):
     """Prompt the user for a choice from a list of options.
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 19167e9a..5e1b7596 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -407,16 +407,16 @@ def get_simulators(
     # If the simulator frameworks don't exist, they will be downloaded
     # and installed. This should only occur on first execution.
     if not Path(simulator_location).exists():
-        tools.input(
+        tools.input.prompt(
             f"""
 It looks like the {os_name} Simulator is not installed. The {os_name} Simulator
 must be installed with administrator privileges.
 
 xcodebuild will prompt you for your admin password so that it can download
 and install the simulator.
-
-Press Return to continue: """
+"""
         )
+        tools.input("Press Return to continue: ")
 
     try:
         simctl_data = tools.subprocess.parse_output(
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 89f73026..1a23c87a 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -239,8 +239,10 @@ class GradleRunCommand(GradleMixin, RunCommand):
             "-d",
             "--device",
             dest="device_or_avd",
-            help="The device to target; either a device ID for a physical device, "
-            " or an AVD name ('@emulatorName') ",
+            help=(
+                "The device to target; either a device ID for a physical device, "
+                " or an AVD name ('@emulatorName') "
+            ),
             required=False,
         )
         parser.add_argument(
@@ -253,7 +255,7 @@ class GradleRunCommand(GradleMixin, RunCommand):
         parser.add_argument(
             "--shutdown-on-exit",
             action="store_true",
-            help="Shutdown the emulator on exit.",
+            help="Shutdown the emulator on exit",
             required=False,
         )
 
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 9fe422c4..6436119b 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -478,11 +478,11 @@ or
 class macOSPackageMixin(macOSSigningMixin):
     ADHOC_SIGN_HELP = (
         "Perform ad-hoc signing on the app. "
-        "The app will only run on this machine; it cannot be redistributed to others."
+        "The app will only run on this machine; it cannot be redistributed to others"
     )
     IDENTITY_HELP = (
         "The code signing identity to use; either the 40-digit hex "
-        "checksum, or the full name of the identity."
+        "checksum, or the full name of the identity"
     )
 
     @property
diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py
index b4e18b92..f8b7d412 100644
--- a/src/briefcase/platforms/web/static.py
+++ b/src/briefcase/platforms/web/static.py
@@ -290,7 +290,7 @@ class StaticWebRunCommand(StaticWebMixin, RunCommand):
             "--no-browser",
             action="store_false",
             dest="open_browser",
-            help="Don't open a web browser on the newly opened server.",
+            help="Don't open a web browser on the newly opened server",
             required=False,
         )
 
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index deed7bb9..173b5306 100644
--- a/src/briefcase/platforms/windows/__init__.py
+++ b/src/briefcase/platforms/windows/__init__.py
@@ -165,7 +165,7 @@ class WindowsPackageCommand(PackageCommand):
         "Your app will be reported as coming from an unverified publisher."
     )
 
-    IDENTITY_HELP = "The 40-digit hex checksum of the code signing identity to use."
+    IDENTITY_HELP = "The 40-digit hex checksum of the code signing identity to use"
 
     @property
     def packaging_formats(self):
@@ -185,7 +185,7 @@ class WindowsPackageCommand(PackageCommand):
         super().add_options(parser)
         parser.add_argument(
             "--file-digest",
-            help="File digest algorithm to use for code signing; defaults to sha256.",
+            help="File digest algorithm to use for code signing; defaults to sha256",
             default="sha256",
             required=False,
         )
@@ -193,7 +193,7 @@ class WindowsPackageCommand(PackageCommand):
             "--use-local-machine-stores",
             help=(
                 "Specifies the code signing certificate is stored in the Local Machine's "
-                "stores instead of the Current User's."
+                "stores instead of the Current User's"
             ),
             action="store_true",
             dest="use_local_machine",
@@ -203,7 +203,7 @@ class WindowsPackageCommand(PackageCommand):
             "--cert-store",
             help=(
                 "The internal Windows name for the certificate store containing the certificate "
-                "for code signing; defaults to 'My' for the Personal store."
+                "for code signing; defaults to 'My' for the Personal store"
             ),
             default="My",
             required=False,
@@ -212,7 +212,7 @@ class WindowsPackageCommand(PackageCommand):
             "--timestamp-url",
             help=(
                 "URL for the Timestamp Authority server to timestamp the code signing; "
-                "defaults to timestamp.digicert.com."
+                "defaults to timestamp.digicert.com"
             ),
             default="http://timestamp.digicert.com",
             required=False,
@@ -221,7 +221,7 @@ class WindowsPackageCommand(PackageCommand):
             "--timestamp-digest",
             help=(
                 "Digest algorithm to request the Timestamp Authority server uses "
-                "for the timestamp for code signing; defaults to sha256."
+                "for the timestamp for code signing; defaults to sha256"
             ),
             default="sha256",
             required=False,
diff --git a/tox.ini b/tox.ini
index 860a7e9b..6e9a284d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,4 +1,4 @@
-# Flake8 doesn'g believe in pyproject.toml, so we put the configuration here.
+# Flake8 doesn't believe in pyproject.toml, so we put the configuration here.
 [flake8]
 # https://flake8.readthedocs.org/en/latest/
 exclude=\


Test patch:
diff --git a/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py
index 2bd4e604..6fd1442d 100644
--- a/tests/commands/new/test_build_context.py
+++ b/tests/commands/new/test_build_context.py
@@ -43,6 +43,7 @@ def test_question_sequence_toga(new_command):
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -268,6 +269,7 @@ def test_question_sequence_pyside6(new_command):
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -458,6 +460,7 @@ def test_question_sequence_pursuedpybear(new_command):
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -625,6 +628,7 @@ def test_question_sequence_pygame(new_command):
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -793,6 +797,7 @@ def test_question_sequence_none(new_command):
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -813,6 +818,201 @@ def test_question_sequence_none(new_command):
     )
 
 
+def test_question_sequence_with_overrides(
+    new_command,
+    mock_builtin_bootstraps,
+    monkeypatch,
+):
+    """Questions are asked, a context is constructed."""
+
+    # Prime answers for none of the questions.
+    new_command.input.values = []
+
+    class GuiBootstrap:
+        fields = []
+
+        def __init__(self, context):
+            pass
+
+    monkeypatch.setattr(
+        briefcase.commands.new,
+        "get_gui_bootstraps",
+        MagicMock(
+            return_value=dict(
+                **mock_builtin_bootstraps,
+                **{"Custom GUI": GuiBootstrap},
+            ),
+        ),
+    )
+
+    context = new_command.build_context(
+        template_source="https://example.com/beeware/briefcase-template",
+        template_branch="my-branch",
+        briefcase_version=Version("1.1.1.post1"),
+        project_overrides=dict(
+            formal_name="My Override App",
+            app_name="myoverrideapp",
+            bundle="net.example",
+            project_name="My Override Project",
+            description="My override description",
+            author="override, author",
+            author_email="author@override.tld",
+            url="https://override.example.com",
+            license="MIT license",
+            bootstrap="Custom GUI",
+        ),
+    )
+
+    assert context == dict(
+        app_name="myoverrideapp",
+        author="override, author",
+        author_email="author@override.tld",
+        bundle="net.example",
+        class_name="MyOverrideApp",
+        description="My override description",
+        formal_name="My Override App",
+        license="MIT license",
+        module_name="myoverrideapp",
+        project_name="My Override Project",
+        url="https://override.example.com",
+        template_source="https://example.com/beeware/briefcase-template",
+        template_branch="my-branch",
+        briefcase_version="1.1.1.post1",
+    )
+
+
+def test_question_sequence_with_bad_license_override(
+    new_command,
+    mock_builtin_bootstraps,
+    monkeypatch,
+):
+    """A bad override for license uses user input instead."""
+
+    # Prime answers for all the questions.
+    new_command.input.values = [
+        "4",  # license
+    ]
+
+    class GuiBootstrap:
+        fields = []
+
+        def __init__(self, context):
+            pass
+
+    monkeypatch.setattr(
+        briefcase.commands.new,
+        "get_gui_bootstraps",
+        MagicMock(
+            return_value=dict(
+                **mock_builtin_bootstraps,
+                **{"Custom GUI": GuiBootstrap},
+            ),
+        ),
+    )
+
+    context = new_command.build_context(
+        template_source="https://example.com/beeware/briefcase-template",
+        template_branch="my-branch",
+        briefcase_version=Version("1.1.1.post1"),
+        project_overrides=dict(
+            formal_name="My Override App",
+            app_name="myoverrideapp",
+            bundle="net.example",
+            project_name="My Override Project",
+            description="My override description",
+            author="override, author",
+            author_email="author@override.tld",
+            url="https://override.example.com",
+            license="BAD i don't exist license",
+            bootstrap="Custom GUI",
+        ),
+    )
+
+    assert context == dict(
+        app_name="myoverrideapp",
+        author="override, author",
+        author_email="author@override.tld",
+        bundle="net.example",
+        class_name="MyOverrideApp",
+        description="My override description",
+        formal_name="My Override App",
+        license="GNU General Public License v2 (GPLv2)",
+        module_name="myoverrideapp",
+        project_name="My Override Project",
+        url="https://override.example.com",
+        template_source="https://example.com/beeware/briefcase-template",
+        template_branch="my-branch",
+        briefcase_version="1.1.1.post1",
+    )
+
+
+def test_question_sequence_with_bad_bootstrap_override(
+    new_command,
+    mock_builtin_bootstraps,
+    monkeypatch,
+):
+    """A bad override for the bootstrap uses user input instead."""
+
+    # Prime answers for none of the questions.
+    new_command.input.values = [
+        "6",  # None
+    ]
+
+    class GuiBootstrap:
+        # if this custom bootstrap is chosen, the lack of
+        # requires() would cause an error
+        fields = ["requires"]
+
+        def __init__(self, context):
+            pass
+
+    monkeypatch.setattr(
+        briefcase.commands.new,
+        "get_gui_bootstraps",
+        MagicMock(
+            return_value=dict(
+                **mock_builtin_bootstraps,
+                **{"Custom GUI": GuiBootstrap},
+            ),
+        ),
+    )
+
+    context = new_command.build_context(
+        template_source="https://example.com/beeware/briefcase-template",
+        template_branch="my-branch",
+        briefcase_version=Version("1.1.1.post1"),
+        project_overrides=dict(
+            formal_name="My Override App",
+            app_name="myoverrideapp",
+            bundle="net.example",
+            project_name="My Override Project",
+            description="My override description",
+            author="override, author",
+            author_email="author@override.tld",
+            url="https://override.example.com",
+            license="MIT license",
+            bootstrap="BAD i don't exist GUI",
+        ),
+    )
+
+    assert context == dict(
+        app_name="myoverrideapp",
+        author="override, author",
+        author_email="author@override.tld",
+        bundle="net.example",
+        class_name="MyOverrideApp",
+        description="My override description",
+        formal_name="My Override App",
+        license="MIT license",
+        module_name="myoverrideapp",
+        project_name="My Override Project",
+        url="https://override.example.com",
+        template_source="https://example.com/beeware/briefcase-template",
+        template_branch="my-branch",
+        briefcase_version="1.1.1.post1",
+    )
+
+
 def test_question_sequence_with_no_user_input(new_command):
     """If no user input is provided, all user inputs are taken as default."""
 
@@ -822,6 +1022,7 @@ def test_question_sequence_with_no_user_input(new_command):
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -1077,6 +1278,7 @@ def test_question_sequence_custom_bootstrap(
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
@@ -1148,6 +1350,7 @@ def test_question_sequence_custom_bootstrap_without_additional_context(
         template_source="https://example.com/beeware/briefcase-template",
         template_branch="my-branch",
         briefcase_version=Version("1.1.1.post1"),
+        project_overrides={},
     )
 
     assert context == dict(
diff --git a/tests/commands/new/test_call.py b/tests/commands/new/test_call.py
index 04648626..c890619e 100644
--- a/tests/commands/new/test_call.py
+++ b/tests/commands/new/test_call.py
@@ -24,11 +24,22 @@ def test_parse_config(new_command):
     assert new_command.parse_config("some_file.toml", {}) is None
 
 
-def test_new_app(new_command):
+@pytest.mark.parametrize(
+    "cmdline, overrides",
+    [
+        ([], {}),
+        (["-Q", "license=MIT"], {"license": "MIT"}),
+        (
+            ["-Q", "license=MIT", "-Q", "bootstrap=Toga"],
+            {"license": "MIT", "bootstrap": "Toga"},
+        ),
+    ],
+)
+def test_new_app(new_command, cmdline, overrides):
     """A new application can be created."""
 
     # Configure no command line options
-    options, _ = new_command.parse_options([])
+    options, _ = new_command.parse_options(cmdline)
 
     # Run the run command
     new_command(**options)
@@ -40,5 +51,8 @@ def test_new_app(new_command):
         # Tools are verified
         ("verify-tools",),
         # Run the first app
-        ("new", {"template": None, "template_branch": None}),
+        (
+            "new",
+            {"template": None, "template_branch": None, "project_overrides": overrides},
+        ),
     ]
diff --git a/tests/commands/new/test_input_text.py b/tests/commands/new/test_input_text.py
index 3823be4a..b9f498d9 100644
--- a/tests/commands/new/test_input_text.py
+++ b/tests/commands/new/test_input_text.py
@@ -15,6 +15,22 @@ def test_unvalidated_input(new_command):
     assert value == "hello"
 
 
+def test_unvalidated_input_with_override(new_command):
+    """If an override is provided and there's no validation, the override is
+    returned."""
+    new_command.input.values = ["hello"]
+
+    value = new_command.input_text(
+        intro="Some introduction",
+        variable="my variable",
+        default="goodbye",
+        override_value="override",
+    )
+
+    assert new_command.input.prompts == []
+    assert value == "override"
+
+
 def test_validated_input(new_command):
     """If the user enters text and there's validation, the user is prompted until valid
     text is entered."""
@@ -23,6 +39,7 @@ def test_validated_input(new_command):
     def validator(text):
         if text == "bad":
             raise ValueError("That's bad...")
+        return True
 
     value = new_command.input_text(
         intro="Some introduction",
@@ -38,6 +55,52 @@ def test_validated_input(new_command):
     assert value == "hello"
 
 
+def test_validated_input_with_override(new_command):
+    """If an override is provided and there's validation, the override is validated and
+    returned."""
+
+    def validator(text):
+        if text == "bad":
+            raise ValueError("That's bad...")
+        return True
+
+    value = new_command.input_text(
+        intro="Some introduction",
+        variable="my variable",
+        default="goodbye",
+        validator=validator,
+        override_value="override",
+    )
+
+    assert new_command.input.prompts == []
+    assert value == "override"
+
+
+def test_validated_input_with_invalid_override(new_command):
+    """If an invalid override is provided and there's validation, the override is
+    rejected and the user is prompted until valid text is entered."""
+    new_command.input.values = ["bad", "hello"]
+
+    def validator(text):
+        if text in {"bad", "bad-override"}:
+            return False
+        return True
+
+    value = new_command.input_text(
+        intro="Some introduction",
+        variable="my variable",
+        default="goodbye",
+        validator=validator,
+        override_value="bad-override",
+    )
+
+    assert new_command.input.prompts == [
+        "My Variable [goodbye]: ",
+        "My Variable [goodbye]: ",
+    ]
+    assert value == "hello"
+
+
 def test_input_with_default(new_command):
     """If the user enters text and there's no validation, the text is returned."""
     new_command.input.values = [""]
@@ -64,11 +127,45 @@ def test_input_disabled(new_command):
     assert value == "goodbye"
 
 
+def test_input_disabled_with_override(new_command):
+    """If input is disabled, the override is returned."""
+    new_command.input.enabled = False
+
+    value = new_command.input_text(
+        intro="Some introduction",
+        variable="my variable",
+        default="goodbye",
+        override_value="override",
+    )
+
+    assert new_command.input.prompts == []
+    assert value == "override"
+
+
 def test_input_disabled_validation_failure(new_command):
     """If input is disabled, and validation fails, an error is raised."""
     new_command.input.enabled = False
 
-    with pytest.raises(BriefcaseCommandError):
+    with pytest.raises(BriefcaseCommandError, match="Well that won't work..."):
+
+        def not_valid(text):
+            raise ValueError("Well that won't work...")
+
+        new_command.input_text(
+            intro="Some introduction",
+            variable="my variable",
+            default="goodbye",
+            validator=not_valid,
+        )
+
+    assert new_command.input.prompts == []
+
+
+def test_input_disabled_validation_failure_with_override(new_command):
+    """If input is disabled, and validation fails for override, an error is raised."""
+    new_command.input.enabled = False
+
+    with pytest.raises(BriefcaseCommandError, match="Well that won't work..."):
 
         def not_valid(text):
             raise ValueError("Well that won't work...")
@@ -78,6 +175,7 @@ def test_input_disabled_validation_failure(new_command):
             variable="my variable",
             default="goodbye",
             validator=not_valid,
+            override_value="override",
         )
 
     assert new_command.input.prompts == []
diff --git a/tests/commands/new/test_new_app.py b/tests/commands/new/test_new_app.py
index 14217e6d..5e234245 100644
--- a/tests/commands/new/test_new_app.py
+++ b/tests/commands/new/test_new_app.py
@@ -62,8 +62,8 @@ def test_new_app(
     new_command.new_app()
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
     # Template is updated
     new_command.update_cookiecutter_cache.assert_called_once_with(
         template="https://github.com/beeware/briefcase-template",
@@ -121,8 +121,8 @@ def test_new_app_missing_template(monkeypatch, new_command, tmp_path):
         new_command.new_app()
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
 
     # The cookiecutter cache is updated once
     new_command.update_cookiecutter_cache.assert_called_once_with(
@@ -189,8 +189,8 @@ def test_new_app_dev(monkeypatch, new_command, tmp_path, briefcase_version):
     new_command.new_app()
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
     # Template is updated
     assert new_command.update_cookiecutter_cache.mock_calls == [
         mock.call(
@@ -273,8 +273,8 @@ def test_new_app_with_template(monkeypatch, new_command, tmp_path):
     new_command.new_app(template="https://example.com/other.git")
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
     # Template is updated
     new_command.update_cookiecutter_cache.assert_called_once_with(
         template="https://example.com/other.git",
@@ -332,8 +332,8 @@ def test_new_app_with_invalid_template(monkeypatch, new_command, tmp_path):
         new_command.new_app(template="https://example.com/other.git")
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
     # Template is updated
     new_command.update_cookiecutter_cache.assert_called_once_with(
         template="https://example.com/other.git",
@@ -392,8 +392,8 @@ def test_new_app_with_invalid_template_branch(monkeypatch, new_command, tmp_path
         new_command.new_app(template="https://example.com/other.git")
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
 
     # Template is updated
     new_command.update_cookiecutter_cache.assert_called_once_with(
@@ -447,8 +447,8 @@ def test_new_app_with_branch(monkeypatch, new_command, tmp_path):
     new_command.new_app(template_branch="experimental")
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
     # Template is updated
     new_command.update_cookiecutter_cache.assert_called_once_with(
         template="https://github.com/beeware/briefcase-template",
@@ -476,6 +476,72 @@ def test_new_app_with_branch(monkeypatch, new_command, tmp_path):
     )
 
 
+def test_new_app_unused_project_overrides(
+    monkeypatch,
+    new_command,
+    tmp_path,
+    capsys,
+):
+    """The user is informed of unused project configuration overrides."""
+    monkeypatch.setattr(briefcase, "__version__", "37.42.7")
+    app_context = {
+        "formal_name": "My Application",
+        "class_name": "MyApplication",
+        "app_name": "myapplication",
+    }
+    new_command.build_app_context = mock.MagicMock(return_value=app_context)
+    new_command.build_gui_context = mock.MagicMock(
+        return_value={
+            "app_source": "main()",
+            "pyproject_requires": "toga",
+        }
+    )
+    new_command.update_cookiecutter_cache = mock.MagicMock(
+        return_value="~/.cookiecutters/briefcase-template"
+    )
+    new_command.tools.cookiecutter = mock.MagicMock(spec_set=cookiecutter)
+
+    # Create the new app, using the default template.
+    new_command.new_app(project_overrides={"unused": "override"})
+
+    # App context is constructed
+    new_command.build_app_context.assert_called_once_with({"unused": "override"})
+    new_command.build_gui_context.assert_called_once_with(
+        app_context, {"unused": "override"}
+    )
+    # Template is updated
+    new_command.update_cookiecutter_cache.assert_called_once_with(
+        template="https://github.com/beeware/briefcase-template",
+        branch="v37.42.7",
+    )
+    # Cookiecutter is invoked
+    new_command.tools.cookiecutter.assert_called_once_with(
+        "~/.cookiecutters/briefcase-template",
+        no_input=True,
+        output_dir=os.fsdecode(tmp_path),
+        checkout="v37.42.7",
+        extra_context={
+            "formal_name": "My Application",
+            "class_name": "MyApplication",
+            "app_name": "myapplication",
+            # The expected app context
+            # should now also contain the
+            # default template and branch
+            "template_source": "https://github.com/beeware/briefcase-template",
+            "template_branch": "v37.42.7",
+            "briefcase_version": "37.42.7",
+            "app_source": "main()",
+            "pyproject_requires": "toga",
+        },
+    )
+
+    unused_project_override_warning = (
+        "WARNING: These project configuration overrides were not used:\n\n"
+        "    unused = override"
+    )
+    assert unused_project_override_warning in capsys.readouterr().out
+
+
 def test_abort_if_directory_exists(monkeypatch, new_command, tmp_path):
     """If the application name directory exists, the create aborts."""
     monkeypatch.setattr(briefcase, "__version__", "37.42.7")
@@ -506,8 +572,8 @@ def test_abort_if_directory_exists(monkeypatch, new_command, tmp_path):
         new_command.new_app()
 
     # App context is constructed
-    new_command.build_app_context.assert_called_once_with()
-    new_command.build_gui_context.assert_called_once_with(context=app_context)
+    new_command.build_app_context.assert_called_once_with(None)
+    new_command.build_gui_context.assert_called_once_with(app_context, None)
     # Template won't be updated or unrolled
     # Cookiecutter was *not* invoked
     assert new_command.update_cookiecutter_cache.call_count == 0
diff --git a/tests/commands/new/test_parse_project_overrides.py b/tests/commands/new/test_parse_project_overrides.py
new file mode 100644
index 00000000..41103ac2
--- /dev/null
+++ b/tests/commands/new/test_parse_project_overrides.py
@@ -0,0 +1,59 @@
+import pytest
+
+from briefcase.commands.new import parse_project_overrides
+from briefcase.exceptions import BriefcaseCommandError
+
+
+@pytest.mark.parametrize(
+    "cmdline, overrides",
+    [
+        ([], {}),
+        (["license=MIT"], {"license": "MIT"}),
+        (["bootstrap=Toga Automation"], {"bootstrap": "Toga Automation"}),
+        (["one=1", "two=2"], {"one": "1", "two": "2"}),
+        (["one\n=\n1", "two=\n\n2"], {"one": "1", "two": "2"}),
+        (["one==1", "two===2"], {"one": "=1", "two": "==2"}),
+        (["   one  = 1  ", " two  =  2 "], {"one": "1", "two": "2"}),
+    ],
+)
+def test_project_overrides(cmdline, overrides):
+    """Valid project configuration overrides are parsed correctly."""
+    assert parse_project_overrides(cmdline) == overrides
+
+
+def test_project_overrides_invalid():
+    """Invalid project configuration overrides are rejected."""
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="Unable to parse project configuration override ' license '",
+    ):
+        parse_project_overrides([" license "])
+
+
+@pytest.mark.parametrize(
+    "cmdline",
+    [
+        "=",
+        " = ",
+        "key_for_invalid_value=",
+        " key_for_invalid_value =   ",
+        "=value_for_invalid_key",
+        "  = value_for_invalid_key   ",
+    ],
+)
+def test_project_overrides_empty(cmdline):
+    """Invalid project configuration overrides are rejected."""
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=f"Invalid Project configuration override '{cmdline}'",
+    ):
+        parse_project_overrides([cmdline])
+
+
+def test_project_overrides_duplicate():
+    """Duplicate project configuration overrides are rejected."""
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="Project configuration override 'license' specified multiple times",
+    ):
+        parse_project_overrides(["license=MIT", "license=BSD"])
diff --git a/tests/commands/new/test_validate_override_for_selection.py b/tests/commands/new/test_validate_override_for_selection.py
new file mode 100644
index 00000000..4662ccf2
--- /dev/null
+++ b/tests/commands/new/test_validate_override_for_selection.py
@@ -0,0 +1,60 @@
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+
+def test_validate_selection_override_none(new_command):
+    """An override of ``None`` fails validation."""
+    outcome = new_command.validate_selection_override(
+        choices=["one", "two", "three"],
+        override_value=None,
+    )
+    assert outcome is True
+
+
+def test_validate_selection_override_valid(new_command, capsys):
+    """A valid override passes validation."""
+    outcome = new_command.validate_selection_override(
+        choices=["one", "two", "three"],
+        override_value="two",
+    )
+
+    assert capsys.readouterr().out == "\nUsing override value 'two'\n"
+    assert outcome is True
+
+
+def test_validate_selection_override_valid_no_input(new_command, capsys):
+    """A valid override passes validation and no notification with input disabled."""
+    new_command.input.enabled = False
+
+    outcome = new_command.validate_selection_override(
+        choices=["one", "two", "three"],
+        override_value="two",
+    )
+
+    assert capsys.readouterr().out == ""
+    assert outcome is True
+
+
+def test_validate_selection_override_invalid(new_command, capsys):
+    """An invalid override fails validation."""
+    outcome = new_command.validate_selection_override(
+        choices=["one", "two", "three"],
+        override_value="four",
+    )
+
+    assert capsys.readouterr().out == (
+        "\nUsing override value 'four'\n\n" "Invalid value; four\n"
+    )
+    assert outcome is False
+
+
+def test_validate_selection_override_invalid_no_input(new_command, capsys):
+    """An invalid override with input disabled raises."""
+    new_command.input.enabled = False
+
+    with pytest.raises(BriefcaseCommandError, match="Invalid value; four"):
+        _ = new_command.validate_selection_override(
+            choices=["one", "two", "three"],
+            override_value="four",
+        )
diff --git a/tests/console/Console/test_boolean_input.py b/tests/console/Console/test_boolean_input.py
index f8336df5..c4349330 100644
--- a/tests/console/Console/test_boolean_input.py
+++ b/tests/console/Console/test_boolean_input.py
@@ -1,6 +1,7 @@
 import pytest
 
 from briefcase.console import InputDisabled
+from tests.utils import default_rich_prompt
 
 
 @pytest.mark.parametrize(
@@ -30,7 +31,7 @@ def test_boolean_input(console, user_input, expected):
     result = console.boolean_input(question=question)
 
     assert result == expected
-    console.input.assert_called_once_with(prompt, markup=False)
+    console.input.assert_called_once_with(default_rich_prompt(prompt), markup=True)
 
 
 def test_boolean_default_true(console):
@@ -42,7 +43,7 @@ def test_boolean_default_true(console):
     result = console.boolean_input(question=question, default=True)
 
     assert result
-    console.input.assert_called_once_with(prompt, markup=False)
+    console.input.assert_called_once_with(default_rich_prompt(prompt), markup=True)
 
 
 def test_boolean_default_false(console):
@@ -54,7 +55,7 @@ def test_boolean_default_false(console):
     result = console.boolean_input(question=question, default=False)
 
     assert not result
-    console.input.assert_called_once_with(prompt, markup=False)
+    console.input.assert_called_once_with(default_rich_prompt(prompt), markup=True)
 
 
 def test_boolean_default_None(console):
diff --git a/tests/console/Console/test_call.py b/tests/console/Console/test_call.py
index e149f7e1..dac2f4b7 100644
--- a/tests/console/Console/test_call.py
+++ b/tests/console/Console/test_call.py
@@ -1,6 +1,8 @@
 import pytest
+from rich.markup import escape
 
 from briefcase.console import InputDisabled
+from tests.utils import default_rich_prompt
 
 
 def test_call_returns_user_input_when_enabled(console):
@@ -12,7 +14,20 @@ def test_call_returns_user_input_when_enabled(console):
     actual_value = console(prompt=prompt)
 
     assert actual_value == value
-    console.input.assert_called_once_with(prompt, markup=False)
+    console.input.assert_called_once_with(default_rich_prompt(prompt), markup=True)
+
+
+def test_call_returns_user_input_when_enabled_with_markup_prompt(console):
+    """If input wrapper is enabled, call returns user input with a prompt with existing
+    markup."""
+    value = "abs"
+    prompt = f"[red]{escape('this is prompt with escaped [markup] text')}[/red]"
+    console.input.return_value = value
+
+    actual_value = console(prompt=prompt, markup=True)
+
+    assert actual_value == value
+    console.input.assert_called_once_with(prompt, markup=True)
 
 
 def test_call_raise_exception_when_disabled(disabled_console):
diff --git a/tests/console/Console/test_selection_input.py b/tests/console/Console/test_selection_input.py
index 9c82bf6b..3d81232f 100644
--- a/tests/console/Console/test_selection_input.py
+++ b/tests/console/Console/test_selection_input.py
@@ -3,6 +3,7 @@ from unittest.mock import call
 import pytest
 
 from briefcase.console import InputDisabled
+from tests.utils import default_rich_prompt
 
 
 @pytest.mark.parametrize(
@@ -28,7 +29,7 @@ def test_selection_input(console, value, expected, default, transform):
     )
 
     assert actual == expected
-    console.input.assert_called_once_with(prompt, markup=False)
+    console.input.assert_called_once_with(default_rich_prompt(prompt), markup=True)
 
 
 def test_bad_input(console):
@@ -42,9 +43,15 @@ def test_bad_input(console):
 
     assert actual == "C"
     assert console.input.call_count == 3
-    assert console.input.call_args_list[0] == call(prompt, markup=False)
-    assert console.input.call_args_list[1] == call(prompt, markup=False)
-    assert console.input.call_args_list[2] == call(prompt, markup=False)
+    assert console.input.call_args_list[0] == call(
+        default_rich_prompt(prompt), markup=True
+    )
+    assert console.input.call_args_list[1] == call(
+        default_rich_prompt(prompt), markup=True
+    )
+    assert console.input.call_args_list[2] == call(
+        default_rich_prompt(prompt), markup=True
+    )
 
 
 def test_disabled(disabled_console):
diff --git a/tests/console/Console/test_text_input.py b/tests/console/Console/test_text_input.py
index d9a6339a..5b0764e3 100644
--- a/tests/console/Console/test_text_input.py
+++ b/tests/console/Console/test_text_input.py
@@ -1,6 +1,7 @@
 import pytest
 
 from briefcase.console import InputDisabled
+from tests.utils import default_rich_prompt
 
 
 @pytest.mark.parametrize(
@@ -19,7 +20,7 @@ def test_text_input(console, value, expected):
     actual = console.text_input(prompt=prompt, default=default)
 
     assert actual == expected
-    console.input.assert_called_once_with(prompt, markup=False)
+    console.input.assert_called_once_with(default_rich_prompt(prompt), markup=True)
 
 
 def test_disabled(disabled_console):
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index db61cbcb..a5a813c2 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -101,6 +101,7 @@ def test_unknown_command():
             {
                 "template": None,
                 "template_branch": None,
+                "project_overrides": None,
             },
             {},
         ),
@@ -109,6 +110,7 @@ def test_unknown_command():
             {
                 "template": "path/to/template",
                 "template_branch": "experiment",
+                "project_overrides": None,
             },
             {
                 "version": "1.2.3",
diff --git a/tests/test_mainline.py b/tests/test_mainline.py
index b633f0b4..86ee5ff4 100644
--- a/tests/test_mainline.py
+++ b/tests/test_mainline.py
@@ -75,7 +75,9 @@ def test_command(monkeypatch, tmp_path, capsys):
     assert main() == 0
 
     output = capsys.readouterr().out
-    assert output.startswith("\nGenerating a new application 'Hello World'\n")
+    assert output.startswith(
+        "\n[helloworld] Generating a new application 'Hello World'"
+    )
 
     # No log file was written
     assert len(list(tmp_path.glob(f"{Log.LOG_DIR}/briefcase.*.log"))) == 0
diff --git a/tests/utils.py b/tests/utils.py
index e39dde53..4999d9ab 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -8,6 +8,8 @@ import zipfile
 from email.message import EmailMessage
 from pathlib import Path
 
+from rich.markup import escape
+
 from briefcase.console import Console, InputDisabled
 
 
@@ -24,6 +26,11 @@ class DummyConsole(Console):
         return self.values.pop(0)
 
 
+def default_rich_prompt(prompt: str) -> str:
+    """Formats a prompt as what is actually passed to Rich."""
+    return f"[bold]{escape(prompt)}[/bold]"
+
+
 def create_file(filepath, content, mode="w", chmod=None):
     """A test utility to create a file with known content.