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.
16 changes: 16 additions & 0 deletions docs/reference/commands/index.rst
Original file line number Diff line number Diff line change
@@ -20,6 +20,22 @@ 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 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\"

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

43 changes: 42 additions & 1 deletion src/briefcase/commands/base.py
Original file line number Diff line number Diff line change
@@ -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
@@ -103,6 +104,26 @@ 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 value in config_overrides:
try:
overrides.update(tomllib.loads(value))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't see any mention of multi-level config values, like --config 'linux.requires=...', which is valid TOML.

I think this code might actually work with that as long as you only had one of them. But then if you added --config linux.something_else=..., the second linux would overwrite the first instead of merging with it.

We don't necessarily need to support that syntax in this PR, but if we don't support it, we should at least display a useful error when the user tries to use it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Right now, it's probably easier to reject multi-level keys completely. We can always add them later if there's a reasonable use case.

Copy link
Member Author

@freakboy3742 freakboy3742 Nov 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually - in the process of looking at this, it occurred to me that this approach avoids validation of any provided value (e.g., you could specify an invalid version number)... and when I went looking to see how I could do validation, I found a much cleaner way to handle the overrides, in a way that both validates and avoids the issue @rmartin16 found with not passing the overrides to some commands.

except ValueError:
raise BriefcaseCommandError(
f"Unable to parse configuration override {value}"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Rich's stacktrace handles this regardless but can you raise from the ValueError?

Suggested change
except ValueError:
raise BriefcaseCommandError(
f"Unable to parse configuration override {value}"
)
except ValueError as e:
raise BriefcaseCommandError(
f"Unable to parse configuration override {value}"
) from e

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should be unnecessary: exceptions raised within an except block are automatically chained unless you say from None.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well be explicit.

return overrides


class BaseCommand(ABC):
cmd_line = "briefcase {command} {platform} {output_format}"
supported_host_os = {"Darwin", "Linux", "Windows"}
@@ -530,7 +551,11 @@ def finalize_app_config(self, app: AppConfig):
:param app: The app configuration to finalize.
"""

def finalize(self, app: AppConfig | None = None):
def finalize(
self,
app: AppConfig | None = None,
config_overrides: list[str] | None = None,
):
"""Finalize Briefcase configuration.

This will:
@@ -544,17 +569,25 @@ def finalize(self, app: AppConfig | None = None):
:param app: If provided, the specific app configuration
to finalize. By default, all apps will be finalized.
"""
# Convert any configuration overrides provided at the command line
# into values that can be applied to the app's config
overrides = parse_config_overrides(config_overrides)

self.verify_host()
self.verify_tools()

if app is None:
for app in self.apps.values():
if hasattr(app, "__draft__"):
self.finalize_app_config(app)
for key, value in overrides.items():
setattr(app, key, value)
delattr(app, "__draft__")
else:
if hasattr(app, "__draft__"):
self.finalize_app_config(app)
for key, value in overrides.items():
setattr(app, key, value)
delattr(app, "__draft__")

def verify_app(self, app: AppConfig):
@@ -687,6 +720,14 @@ 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.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: no period at the end to match the other options (although, I also shouldn't have added one to -v...)

Suggested change
help="Override the value of the app configuration item KEY with VALUE.",
help="Override the value of the app configuration item KEY with VALUE",

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting - the very next option defined (--verbosity) did have a period. I've corrected both.

)
parser.add_argument(
"-v",
"--verbosity",
3 changes: 2 additions & 1 deletion src/briefcase/commands/build.py
Original file line number Diff line number Diff line change
@@ -88,6 +88,7 @@ def __call__(
update_support: bool = False,
no_update: bool = False,
test_mode: bool = False,
config_overrides: list[str] | None = None,
**options,
) -> dict | None:
# Has the user requested an invalid set of options?
@@ -112,7 +113,7 @@ def __call__(

# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
self.finalize(app)
self.finalize(app, config_overrides)

if app:
state = self._build_app(
9 changes: 7 additions & 2 deletions src/briefcase/commands/create.py
Original file line number Diff line number Diff line change
@@ -831,10 +831,15 @@ 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,
config_overrides: list[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)
self.finalize(app, config_overrides)

if app:
state = self.create_app(app, **options)
18 changes: 10 additions & 8 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
@@ -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.
@@ -174,11 +175,12 @@ 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,
config_overrides: list[str] | None = None,
**options,
):
# Which app should we run? If there's only one defined
@@ -200,7 +202,7 @@ def __call__(
)
# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
self.finalize(app)
self.finalize(app, config_overrides)

self.verify_app(app)

11 changes: 6 additions & 5 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
@@ -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
@@ -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.
9 changes: 7 additions & 2 deletions src/briefcase/commands/open.py
Original file line number Diff line number Diff line change
@@ -44,10 +44,15 @@ def open_app(self, app: AppConfig, **options):

return state

def __call__(self, app: AppConfig | None = None, **options):
def __call__(
self,
app: AppConfig | None = None,
config_overrides: list[str] | None = None,
**options,
):
# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
self.finalize(app)
self.finalize(app, config_overrides)

if app:
state = self.open_app(app, **options)
3 changes: 2 additions & 1 deletion src/briefcase/commands/package.py
Original file line number Diff line number Diff line change
@@ -137,11 +137,12 @@ def __call__(
self,
app: AppConfig | None = None,
update: bool = False,
config_overrides: list[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)
self.finalize(app, config_overrides)

if app:
state = self._package_app(app, update=update, **options)
1 change: 1 addition & 0 deletions src/briefcase/commands/run.py
Original file line number Diff line number Diff line change
@@ -248,6 +248,7 @@ def __call__(
no_update: bool = False,
test_mode: bool = False,
passthrough: list[str] | None = None,
config_overrides: list[str] | None = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to be passed to finalize()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but not any more with the revised approach that I've implemented.

**options,
) -> dict | None:
# Which app should we run? If there's only one defined
1 change: 1 addition & 0 deletions src/briefcase/commands/update.py
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@ def __call__(
update_resources: bool = False,
update_support: bool = False,
test_mode: bool = False,
config_overrides: list[str] | None = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to be passed to finalize()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, Yes, but not any more with the revised approach that I've implemented.

**options,
) -> dict | None:
# Confirm host compatibility, that all required tools are available,
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,
@@ -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
@@ -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.
Loading