Repository: beeware/briefcase

Issue:
Allow specifying app name(s) to briefcase build and briefcase package
### What is the problem or limitation you are having?

In projects with multiple applications, it would save time to permit only rebuilding/repackaging specific apps. In particular, apps with large dependencies like Qt take a significant amount of time to link (several minutes) so this wouldn't be a marginal improvement either.

### Describe the solution you'd like

Allow specifying one or more app name when running `briefcase build` and/or `briefcase package`, possibly using `-a` or `--app` as with the `dev` and `run` commands.

### Describe alternatives you've considered

Make coffee while waiting several minutes for briefcase to remake an identical .msi file to the one already in the dist folder.

### Additional context

_No response_


Discussion:
I can see how this could be useful. 

Honestly, I'm a little surprised the option wasn't there - as you note, run/dev both support it (out of necessity), so almost all the *mechanics* to support single app create/build/package are there. All we're missing is a couple of lines to differentiate "all apps" from "one app" invocation cases. 

Looking at the proposed fix for this issue #2148, it occurs to me that the `--app` option should probably also exist for the `create` and `update` commands.
I’ll work on adding the --app option to the create and update commands as well.



Patch:
diff --git a/changes/2148.feature.rst b/changes/2148.feature.rst
index de19ab50f..abce4fa43 100644
--- a/changes/2148.feature.rst
+++ b/changes/2148.feature.rst
@@ -1,1 +1,1 @@
-The `build` and `package` commands can now be run on a single app within a multi-app project by using the `-a / --app` option.
+The `create`, `build`, `package` and `update` commands can now be run on a single app within a multi-app project by using the `-a / --app` option.
diff --git a/docs/reference/commands/create.rst b/docs/reference/commands/create.rst
index bd61d73d6..e2cde4e67 100644
--- a/docs/reference/commands/create.rst
+++ b/docs/reference/commands/create.rst
@@ -32,4 +32,11 @@ to delete and regenerate the app.
 Options
 =======
 
-There are no additional command line options.
+The following options can be provided at the command line.
+
+``-a <app name>`` / ``--app <app name>``
+----------------------------------------
+
+Run a specific application target in your project. This argument is only
+required if your project contains more than one application target. The app
+name specified should be the machine-readable package name for the app.
diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py
index 59b92b999..a649910c0 100644
--- a/src/briefcase/commands/build.py
+++ b/src/briefcase/commands/build.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import argparse
+
 from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError
 
@@ -19,6 +21,7 @@ def add_options(self, parser):
             "--app",
             dest="app_name",
             help="Name of the app to build (if multiple apps exist in the project)",
+            default=argparse.SUPPRESS,
         )
 
     def build_app(self, app: AppConfig, **options):
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 8e4f177ca..28a12d0d9 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import argparse
 import hashlib
 import os
 import platform
@@ -84,6 +85,16 @@ class CreateCommand(BaseCommand):
     command = "create"
     description = "Create a new app for a target platform."
 
+    def add_options(self, parser):
+        super().add_options(parser)
+        parser.add_argument(
+            "-a",
+            "--app",
+            dest="app_name",
+            help="Name of the app to create (if multiple apps exist in the project)",
+            default=argparse.SUPPRESS,
+        )
+
     # app properties that won't be exposed to the context
     hidden_app_properties = {"permission"}
 
@@ -974,18 +985,31 @@ def verify_app_tools(self, app: AppConfig):
     def __call__(
         self,
         app: AppConfig | None = None,
+        app_name: str | None = None,
         **options,
     ) -> dict | None:
         # Confirm host compatibility, that all required tools are available,
         # and that the app configuration is finalized.
         self.finalize(app)
 
-        if app:
-            state = self.create_app(app, **options)
+        if app_name:
+            try:
+                apps_to_create = {app_name: self.apps[app_name]}
+            except KeyError:
+                raise BriefcaseCommandError(
+                    f"App '{app_name}' does not exist in this project."
+                )
+        elif app:
+            apps_to_create = {app.app_name: app}
         else:
-            state = None
-            for app_name, app in sorted(self.apps.items()):
-                state = self.create_app(app, **full_options(state, options))
+            apps_to_create = self.apps
+
+        state = None
+        for app_name, app_obj in sorted(apps_to_create.items()):
+            state = self.create_app(
+                app_obj,
+                **full_options(state, options),
+            )
 
         return state
 
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 5abaa333f..483608af3 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import argparse
 from abc import abstractmethod
 
 from briefcase.config import AppConfig
@@ -124,6 +125,7 @@ def add_options(self, parser):
             "--app",
             dest="app_name",
             help="Name of the app to package (if multiple apps exist in the project)",
+            default=argparse.SUPPRESS,
         )
 
         parser.add_argument(
@@ -189,11 +191,3 @@ def __call__(
             )
 
         return state
-
-    def parse_options(self, extra=None):
-        options, overrides = super().parse_options(extra=extra)
-
-        if options.get("app_name") is None:
-            options.pop("app_name", None)
-
-        return options, overrides
diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py
index 66349278f..27673b326 100644
--- a/src/briefcase/commands/update.py
+++ b/src/briefcase/commands/update.py
@@ -1,6 +1,9 @@
 from __future__ import annotations
 
+import argparse
+
 from briefcase.config import AppConfig
+from briefcase.exceptions import BriefcaseCommandError
 
 from .base import full_options
 from .create import CreateCommand
@@ -14,6 +17,14 @@ def add_options(self, parser):
         self._add_update_options(parser, update=False)
         self._add_test_options(parser, context_label="Update")
 
+        parser.add_argument(
+            "-a",
+            "--app",
+            dest="app_name",
+            help="Name of the app to update (if multiple apps exist in the project)",
+            default=argparse.SUPPRESS,
+        )
+
     def update_app(
         self,
         app: AppConfig,
@@ -79,6 +90,7 @@ def update_app(
     def __call__(
         self,
         app: AppConfig | None = None,
+        app_name: str | None = None,
         update_requirements: bool = False,
         update_resources: bool = False,
         update_support: bool = False,
@@ -90,27 +102,28 @@ def __call__(
         # and that the app configuration is finalized.
         self.finalize(app)
 
-        if app:
+        if app_name:
+            try:
+                apps_to_update = {app_name: self.apps[app_name]}
+            except KeyError:
+                raise BriefcaseCommandError(
+                    f"App '{app_name}' does not exist in this project."
+                )
+        elif app:
+            apps_to_update = {app.app_name: app}
+        else:
+            apps_to_update = self.apps
+
+        state = None
+        for app_name, app_obj in sorted(apps_to_update.items()):
             state = self.update_app(
-                app,
+                app_obj,
                 update_requirements=update_requirements,
                 update_resources=update_resources,
                 update_support=update_support,
                 update_stub=update_stub,
                 test_mode=test_mode,
-                **options,
+                **full_options(state, options),
             )
-        else:
-            state = None
-            for app_name, app in sorted(self.apps.items()):
-                state = self.update_app(
-                    app,
-                    update_requirements=update_requirements,
-                    update_resources=update_resources,
-                    update_support=update_support,
-                    update_stub=update_stub,
-                    test_mode=test_mode,
-                    **full_options(state, options),
-                )
 
         return state


Test patch:
diff --git a/tests/commands/create/test_call.py b/tests/commands/create/test_call.py
index 3e6ea7369..ff53d25f4 100644
--- a/tests/commands/create/test_call.py
+++ b/tests/commands/create/test_call.py
@@ -84,3 +84,99 @@ def test_create_single(tracking_create_command, tmp_path):
     # New app content has been created
     assert (tmp_path / "base_path/build/first/tester/dummy/new").exists()
     assert not (tmp_path / "base_path/build/second/tester/dummy/new").exists()
+
+
+# Parametrize both --apps/-a flags
+@pytest.mark.parametrize("app_flags", ["--app", "-a"])
+def test_create_app_single(tracking_create_command, app_flags):
+    """If the --app or -a flag is used, only the selected app is created."""
+    # Configure command line options with the parameterized flag
+    options, _ = tracking_create_command.parse_options([app_flags, "first"])
+
+    # Run the create command
+    tracking_create_command(**options)
+
+    # Only the selected app is created
+    assert tracking_create_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # Create the selected app
+        ("generate", "first"),
+        ("support", "first"),
+        ("verify-app-template", "first"),
+        ("verify-app-tools", "first"),
+        ("code", "first", False),
+        ("requirements", "first", False),
+        ("resources", "first"),
+        ("cleanup", "first"),
+    ]
+
+
+def test_create_app_invalid(tracking_create_command):
+    """If an invalid app name is passed to --app, raise an error."""
+    # Configure the --app option with an invalid app
+    options, _ = tracking_create_command.parse_options(["--app", "invalid"])
+
+    # Running the command should raise an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"App 'invalid' does not exist in this project.",
+    ):
+        tracking_create_command(**options)
+
+
+def test_create_app_none_defined(tracking_create_command):
+    """If no apps are defined, do nothing."""
+    # No apps defined
+    tracking_create_command.apps = {}
+
+    # Configure no command line options
+    options, _ = tracking_create_command.parse_options([])
+
+    # Run the create command
+    result = tracking_create_command(**options)
+
+    # Nothing happens beyond basic setup
+    assert result is None
+    assert tracking_create_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+    ]
+
+
+def test_create_app_all_flags(tracking_create_command):
+    """Verify that all create-related flags work correctly with -a."""
+    # Configure command line options with all available flags
+    options, _ = tracking_create_command.parse_options(
+        ["-a", "first", "--no-input", "--log", "-v"]
+    )
+
+    # Run the create command
+    tracking_create_command(**options)
+
+    # The right sequence of things will be done
+    assert tracking_create_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # Create the selected app
+        ("generate", "first"),
+        ("support", "first"),
+        ("verify-app-template", "first"),
+        ("verify-app-tools", "first"),
+        ("code", "first", False),
+        ("requirements", "first", False),
+        ("resources", "first"),
+        ("cleanup", "first"),
+    ]
diff --git a/tests/commands/update/test_call.py b/tests/commands/update/test_call.py
index 2ca085fc5..0c44a0328 100644
--- a/tests/commands/update/test_call.py
+++ b/tests/commands/update/test_call.py
@@ -63,7 +63,7 @@ def test_update_single(update_command, first_app, second_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
-        # update the first app
+        # Update the first app
         ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
@@ -164,3 +164,110 @@ def test_update_with_support(update_command, first_app, second_app):
         ("support", "second"),
         ("cleanup", "second"),
     ]
+
+
+# Parametrize both --apps/-a flags
+@pytest.mark.parametrize("app_flags", ["--app", "-a"])
+def test_update_app_single(update_command, first_app, second_app, app_flags):
+    """If the --app or -a flag is used, only the selected app is updated."""
+    # Configure command line options with the parameterized flag
+    options, _ = update_command.parse_options([app_flags, "first"])
+
+    # Run the update command
+    update_command(**options)
+
+    # Only the selected app is updated
+    assert update_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # Update the app
+        ("verify-app-template", "first"),
+        ("verify-app-tools", "first"),
+        ("code", "first", False),
+        ("cleanup", "first"),
+    ]
+
+
+def test_update_app_invalid(update_command, first_app, second_app):
+    """If an invalid app name is passed to --app, raise an error."""
+    # Add two apps
+    update_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure the --app option with an invalid app
+    options, _ = update_command.parse_options(["--app", "invalid"])
+
+    # Running the command should raise an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"App 'invalid' does not exist in this project.",
+    ):
+        update_command(**options)
+
+
+def test_update_app_none_defined(update_command):
+    """If no apps are defined, do nothing."""
+    # No apps defined
+    update_command.apps = {}
+
+    # Configure no command line options
+    options, _ = update_command.parse_options([])
+
+    # Run the update command
+    result = update_command(**options)
+
+    # The right sequence of things will be done
+    assert result is None
+    assert update_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+    ]
+
+
+def test_update_app_all_flags(update_command, first_app, second_app):
+    """Verify that all update-related flags work correctly with -a."""
+    options, _ = update_command.parse_options(
+        [
+            "-a",
+            "first",
+            "--update-requirements",
+            "--update-resources",
+            "--update-support",
+            "--update-stub",
+            "--no-input",
+            "--log",
+            "-vv",
+        ]
+    )
+
+    # Run the update command
+    update_command(**options)
+
+    # The right sequence of things will be done
+    assert update_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # Update the app
+        ("verify-app-template", "first"),
+        ("verify-app-tools", "first"),
+        ("code", "first", False),
+        ("requirements", "first", False),
+        ("resources", "first"),
+        ("cleanup-support", "first"),
+        ("support", "first"),
+        ("cleanup", "first"),
+    ]
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index 51ecaf3c0..71e95f4a6 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -406,7 +406,7 @@ def test_bare_command_help(monkeypatch, capsys, console):
     output = capsys.readouterr().out
     assert output.startswith(
         "usage: briefcase create macOS app [-h] [-C KEY=VALUE] [-v] [-V] [--no-input]\n"
-        "                                  [--log]\n"
+        "                                  [--log] [-a APP_NAME]\n"
         "\n"
         "Create and populate a macOS app.\n"
     )
@@ -481,7 +481,7 @@ def test_command_explicit_platform_help(monkeypatch, capsys, console):
     output = capsys.readouterr().out
     assert output.startswith(
         "usage: briefcase create macOS app [-h] [-C KEY=VALUE] [-v] [-V] [--no-input]\n"
-        "                                  [--log]\n"
+        "                                  [--log] [-a APP_NAME]\n"
         "\n"
         "Create and populate a macOS app.\n"
     )
@@ -547,7 +547,7 @@ def test_command_explicit_format_help(monkeypatch, capsys, console):
     output = capsys.readouterr().out
     assert output.startswith(
         "usage: briefcase create macOS app [-h] [-C KEY=VALUE] [-v] [-V] [--no-input]\n"
-        "                                  [--log]\n"
+        "                                  [--log] [-a APP_NAME]\n"
         "\n"
         "Create and populate a macOS app.\n"
     )