Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to override app configurations at the command line #1542

Merged
merged 9 commits into from
Nov 17, 2023
1 change: 1 addition & 0 deletions changes/1115.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``-C``/``--config`` option can now be used to override app settings from the command line.
25 changes: 25 additions & 0 deletions docs/reference/commands/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ Common options

The following options are available to all commands:

``-C <KEY=VALUE>`` / ``--config <KEY=VALUE>``
---------------------------------------------

Override the value of an app's configuration in ``pyproject.toml`` with the provided
value.

The key will be applied *after* (and will therefore take precedence over) any platform
or backend-specific configuration has been merged into the app's configuration. The key
must be a "top level" TOML key. The use of `dotted keys
<https://toml.io/en/v1.0.0#keys>`__ to define nested configuration structures is not
permitted.

The value passed to the setting should be valid TOML. If the value being overridden is a
string, this means you must quote the value. This may require the use of escape
sequences at the command line to ensure the value provided to Briefcase by the shell
includes the quotes.

For example, to override the template used by the create command, you can use ``-C
template=...``, but the value must be quoted::

briefcase create -C template=\"https://example.com/template\"

The only app key that *cannot* be overridden with ``-C`` is ``app_name``, as it is used
to identify apps.

``-h`` / ``--help``
-------------------

Expand Down
7 changes: 5 additions & 2 deletions src/briefcase/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ def main():
try:
Command, extra_cmdline = parse_cmdline(sys.argv[1:])
command = Command(logger=logger, console=console)
options = command.parse_options(extra=extra_cmdline)
command.parse_config(Path.cwd() / "pyproject.toml")
options, overrides = command.parse_options(extra=extra_cmdline)
command.parse_config(
Path.cwd() / "pyproject.toml",
overrides=overrides,
)
command(**options)
except HelpText as e:
logger.info()
Expand Down
56 changes: 52 additions & 4 deletions src/briefcase/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from abc import ABC, abstractmethod
from argparse import RawDescriptionHelpFormatter
from pathlib import Path
from typing import Any

from cookiecutter import exceptions as cookiecutter_exceptions
from cookiecutter.repository import is_repo_url
Expand Down Expand Up @@ -103,6 +104,38 @@ def split_passthrough(args):
return args[:pos], args[pos + 1 :]


def parse_config_overrides(config_overrides: list[str] | None) -> dict[str, Any]:
"""Parse command line -C/--config option overrides.

:param config_overrides: The values passed in as configuration overrides. Each value
*should* be a "key=<valid TOML>" string.
:returns: A dictionary of app configuration keys to override and their new values.
:raises BriefcaseCommandError: if any of the values can't be parsed as valid TOML.
"""
overrides = {}
if config_overrides:
for override in config_overrides:
try:
# Do initial checks of the key being overridden.
# These catch cases that would be valid TOML, but would result
# in invalid app configurations.
key, _ = override.split("=", 1)
if "." in key:
raise BriefcaseConfigError(
"Can't override multi-level configuration keys."
)
elif key == "app_name":
raise BriefcaseConfigError("The app name cannot be overridden.")

# Now actually parse the value
overrides.update(tomllib.loads(override))
except ValueError as e:
raise BriefcaseConfigError(
f"Unable to parse configuration override {override}"
) from e
return overrides


class BaseCommand(ABC):
cmd_line = "briefcase {command} {platform} {output_format}"
supported_host_os = {"Darwin", "Linux", "Windows"}
Expand Down Expand Up @@ -614,7 +647,8 @@ def parse_options(self, extra):

:param extra: the remaining command line arguments after the initial
ArgumentParser runs over the command line.
:return: dictionary of parsed arguments for Command
:return: dictionary of parsed arguments for Command, and a dictionary of parsed
configuration overrides.
"""
default_format = getattr(
get_platforms().get(self.platform), "DEFAULT_OUTPUT_FORMAT", None
Expand Down Expand Up @@ -674,7 +708,10 @@ def parse_options(self, extra):
self.logger.verbosity = options.pop("verbosity")
self.logger.save_log = options.pop("save_log")

return options
# Parse the configuration overrides
overrides = parse_config_overrides(options.pop("config_overrides"))

return options, overrides

def clone_options(self, command):
"""Clone options from one command to this one.
Expand All @@ -687,12 +724,20 @@ def add_default_options(self, parser):

:param parser: a stub argparse parser for the command.
"""
parser.add_argument(
"-C",
"--config",
dest="config_overrides",
action="append",
metavar="KEY=VALUE",
help="Override the value of the app configuration item KEY with VALUE",
)
parser.add_argument(
"-v",
"--verbosity",
action="count",
default=0,
help="Enable verbose logging. Use -vv and -vvv to increase logging verbosity.",
help="Enable verbose logging. Use -vv and -vvv to increase logging verbosity",
)
parser.add_argument("-V", "--version", action="version", version=__version__)
parser.add_argument(
Expand Down Expand Up @@ -780,7 +825,7 @@ def add_options(self, parser):
:param parser: a stub argparse parser for the command.
"""

def parse_config(self, filename):
def parse_config(self, filename, overrides):
try:
with open(filename, "rb") as config_file:
# Parse the content of the pyproject.toml file, extracting
Expand All @@ -792,6 +837,8 @@ def parse_config(self, filename):
output_format=self.output_format,
)

# Create the global config
global_config.update(overrides)
self.global_config = create_config(
klass=GlobalConfig,
config=global_config,
Expand All @@ -801,6 +848,7 @@ def parse_config(self, filename):
for app_name, app_config in app_configs.items():
# Construct an AppConfig object with the final set of
# configuration options for the app.
app_config.update(overrides)
self.apps[app_name] = create_config(
klass=AppConfig,
config=app_config,
Expand Down
6 changes: 5 additions & 1 deletion src/briefcase/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,11 @@ def verify_app_tools(self, app: AppConfig):
super().verify_app_tools(app)
NativeAppContext.verify(tools=self.tools, app=app)

def __call__(self, app: AppConfig | None = None, **options) -> dict | None:
def __call__(
self,
app: AppConfig | None = None,
**options,
) -> dict | None:
# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
self.finalize(app)
Expand Down
15 changes: 8 additions & 7 deletions src/briefcase/commands/dev.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path
from typing import List, Optional

from briefcase.commands.run import RunAppMixin
from briefcase.config import AppConfig
Expand Down Expand Up @@ -112,7 +113,7 @@ def run_dev_app(
app: AppConfig,
env: dict,
test_mode: bool,
passthrough: List[str],
passthrough: list[str],
**options,
):
"""Run the app in the dev environment.
Expand Down Expand Up @@ -174,11 +175,11 @@ def get_environment(self, app, test_mode: bool):

def __call__(
self,
appname: Optional[str] = None,
update_requirements: Optional[bool] = False,
run_app: Optional[bool] = True,
test_mode: Optional[bool] = False,
passthrough: Optional[List[str]] = None,
appname: str | None = None,
update_requirements: bool | None = False,
run_app: bool | None = True,
test_mode: bool | None = False,
passthrough: list[str] | None = None,
**options,
):
# Which app should we run? If there's only one defined
Expand Down
13 changes: 7 additions & 6 deletions src/briefcase/commands/new.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import re
import unicodedata
from email.utils import parseaddr
from typing import Optional
from urllib.parse import urlparse

from packaging.version import Version
Expand Down Expand Up @@ -75,7 +76,7 @@ def binary_path(self, app):
"""A placeholder; New command doesn't have a binary path."""
raise NotImplementedError()

def parse_config(self, filename):
def parse_config(self, filename, overrides):
"""There is no configuration when starting a new project; this implementation
overrides the base so that no config is parsed."""
pass
Expand Down Expand Up @@ -432,8 +433,8 @@ def build_app_context(self):

def new_app(
self,
template: Optional[str] = None,
template_branch: Optional[str] = None,
template: str | None = None,
template_branch: str | None = None,
**options,
):
"""Ask questions to generate a new application, and generate a stub project from
Expand Down Expand Up @@ -520,8 +521,8 @@ def verify_tools(self):

def __call__(
self,
template: Optional[str] = None,
template_branch: Optional[str] = None,
template: str | None = None,
template_branch: str | None = None,
**options,
):
# Confirm host compatibility, and that all required tools are available.
Expand Down
6 changes: 5 additions & 1 deletion src/briefcase/commands/open.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ def open_app(self, app: AppConfig, **options):

return state

def __call__(self, app: AppConfig | None = None, **options):
def __call__(
self,
app: AppConfig | None = None,
**options,
):
# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
self.finalize(app)
Expand Down
6 changes: 5 additions & 1 deletion src/briefcase/commands/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ def _publish_app(self, app: AppConfig, channel: str, **options) -> dict | None:

return state

def __call__(self, channel=None, **options):
def __call__(
self,
channel: str | None = None,
**options,
):
# Confirm host compatibility, that all required tools are available,
# and that all app configurations are finalized.
self.finalize()
Expand Down
9 changes: 5 additions & 4 deletions src/briefcase/commands/upgrade.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import sys
from operator import attrgetter
from typing import List, Set, Type

from briefcase.exceptions import (
BriefcaseCommandError,
Expand Down Expand Up @@ -50,12 +51,12 @@ def add_options(self, parser):
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]:
def get_tools_to_upgrade(self, tool_list: set[str]) -> list[ManagedTool]:
"""Returns set of managed Tools that can be upgraded.

Raises ``BriefcaseCommandError`` if user list contains any invalid tool names.
"""
upgrade_list: set[Type[Tool]]
upgrade_list: set[type[Tool]]
tools_to_upgrade: set[ManagedTool] = set()

# Validate user tool list against tool registry
Expand Down Expand Up @@ -94,7 +95,7 @@ def get_tools_to_upgrade(self, tool_list: Set[str]) -> List[ManagedTool]:

return sorted(list(tools_to_upgrade), key=attrgetter("name"))

def __call__(self, tool_list: List[str], list_tools: bool = False, **options):
def __call__(self, tool_list: list[str], list_tools: bool = False, **options):
"""Perform tool upgrades or list tools qualifying for upgrade.

:param tool_list: List of tool names from user to upgrade.
Expand Down
4 changes: 2 additions & 2 deletions src/briefcase/platforms/linux/appimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ def add_options(self, parser):

def parse_options(self, extra):
"""Extract the use_docker option."""
options = super().parse_options(extra)
options, overrides = super().parse_options(extra)
self.use_docker = options.pop("use_docker")
return options
return options, overrides

def clone_options(self, command):
"""Clone the use_docker option."""
Expand Down
4 changes: 2 additions & 2 deletions src/briefcase/platforms/linux/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,10 @@ def add_options(self, parser):

def parse_options(self, extra):
"""Extract the target_image option."""
options = super().parse_options(extra)
options, overrides = super().parse_options(extra)
self.target_image = options.pop("target")

return options
return options, overrides

def clone_options(self, command):
"""Clone the target_image option."""
Expand Down
4 changes: 2 additions & 2 deletions src/briefcase/platforms/windows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ def add_options(self, parser):

def parse_options(self, extra):
"""Require the Windows SDK tool if an `identity` is specified for signing."""
options = super().parse_options(extra=extra)
options, overrides = super().parse_options(extra=extra)
self._windows_sdk_needed = options["identity"] is not None
return options
return options, overrides

def sign_file(
self,
Expand Down
Loading