Exemplo n.º 1
0
    def __init__(self,
                 base_path,
                 home_path=Path.home(),
                 apps=None,
                 input_enabled=True):
        self.base_path = base_path
        self.home_path = home_path
        self.dot_briefcase_path = home_path / ".briefcase"
        self.tools_path = self.dot_briefcase_path / "tools"

        self.global_config = None
        self.apps = {} if apps is None else apps
        self._path_index = {}

        # Some details about the host machine
        self.host_arch = platform.machine()
        self.host_os = platform.system()

        # External service APIs.
        # These are abstracted to enable testing without patching.
        self.cookiecutter = cookiecutter
        self.requests = requests
        self.input = Console(enabled=input_enabled)
        self.os = os
        self.sys = sys
        self.shutil = shutil
        self.subprocess = Subprocess(self)

        # The internal Briefcase integrations API.
        self.integrations = integrations

        # Initialize default logger (replaced when options are parsed).
        self.logger = Log()
def test_license_status_unknown(capsys):
    """If we get an unusual response from the license, warn but continue."""
    command = mock.MagicMock()
    command.logger = Log()
    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
        cmd=["/usr/bin/clang", "--version"], returncode=69)
    command.subprocess.run.side_effect = subprocess.CalledProcessError(
        cmd=["sudo", "xcodebuild", "-license"], returncode=42)

    # Check passes without error...
    confirm_xcode_license_accepted(command)

    # ... clang *and* xcodebuild were invoked ...
    command.subprocess.check_output.assert_called_once_with(
        ["/usr/bin/clang", "--version"],
        stderr=subprocess.STDOUT,
    )
    command.subprocess.run.assert_called_once_with(
        ["sudo", "xcodebuild", "-license"],
        check=True,
    )

    # ...but stdout contains a warning
    out = capsys.readouterr().out
    assert "************" in out
Exemplo n.º 3
0
def test_deep_debug_call_with_env(mock_sub, capsys):
    """If verbosity is at the max, the full environment and return is output,
    and the environment is merged."""
    mock_sub.command.logger = Log(verbosity=3)

    env = {"NewVar": "NewVarValue"}
    mock_sub.run(["hello", "world"], env=env)

    merged_env = mock_sub.command.os.environ.copy()
    merged_env.update(env)

    mock_sub._subprocess.run.assert_called_with(["hello", "world"],
                                                env=merged_env,
                                                text=True)

    expected_output = ("\n"
                       ">>> Running Command:\n"
                       ">>>     hello world\n"
                       ">>> Full Environment:\n"
                       ">>>     VAR1=Value 1\n"
                       ">>>     PS1=\n"
                       ">>> Line 2\n"
                       ">>> \n"
                       ">>> Line 4\n"
                       ">>>     PWD=/home/user/\n"
                       ">>>     NewVar=NewVarValue\n"
                       ">>> Return code: 0\n")

    assert capsys.readouterr().out == expected_output
Exemplo n.º 4
0
def test_calledprocesserror_exception_logging(mock_sub, capsys):
    mock_sub.command.logger = Log(verbosity=3)

    called_process_error = CalledProcessError(
        returncode=-1,
        cmd="hello world",
        output="output line 1\noutput line 2",
        stderr="error line 1\nerror line 2",
    )
    mock_sub._subprocess.run.side_effect = called_process_error

    with pytest.raises(CalledProcessError):
        mock_sub.run(["hello", "world"])

    expected_output = ("\n"
                       ">>> Running Command:\n"
                       ">>>     hello world\n"
                       ">>> Full Environment:\n"
                       ">>>     VAR1=Value 1\n"
                       ">>>     PS1=\n"
                       ">>> Line 2\n"
                       ">>> \n"
                       ">>> Line 4\n"
                       ">>>     PWD=/home/user/\n"
                       ">>> Return code: -1\n")

    assert capsys.readouterr().out == expected_output
Exemplo n.º 5
0
def test_deep_debug_call(mock_sub, capsys):
    """If verbosity is at the max, the full environment and return is
    output."""
    mock_sub.command.logger = Log(verbosity=3)

    mock_sub.check_output(["hello", "world"])

    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], text=True)

    expected_output = (
        "\n"
        ">>> Running Command:\n"
        ">>>     hello world\n"
        ">>> Full Environment:\n"
        ">>>     VAR1=Value 1\n"
        ">>>     PS1=\n"
        ">>> Line 2\n"
        ">>> \n"
        ">>> Line 4\n"
        ">>>     PWD=/home/user/\n"
        ">>> Command Output:\n"
        ">>>     some output line 1\n"
        ">>>     more output line 2\n"
        ">>> Return code: 0\n"
    )

    assert capsys.readouterr().out == expected_output
def test_installed_extra_output(capsys, xcode):
    """If Xcode but outputs extra content, the check is still satisfied."""
    # This specific output was seen in the wild with Xcode 13.2.1; see #668
    command = mock.MagicMock()
    command.logger = Log()
    command.subprocess.check_output.return_value = "\n".join([
        "objc[86306]: Class AMSupportURLConnectionDelegate is implemented in both /usr/lib/libauthinstall.dylib (0x20d17ab90) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b82c8). One of the two will be used. Which one is undefined."  # noqa: E501
        "objc[86306]: Class AMSupportURLSession is implemented in both /usr/lib/libauthinstall.dylib (0x20d17abe0) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b8318). One of the two will be used. Which one is undefined.",  # noqa: E501
        "Xcode 13.2.1",
        "Build version 13C100",
    ])

    # Check passes without an error.
    ensure_xcode_is_installed(command,
                              xcode_location=xcode,
                              min_version=(11, 1))

    # xcode-select was invoked
    command.subprocess.check_output.assert_called_once_with(
        ["xcodebuild", "-version"],
        stderr=subprocess.STDOUT,
    )

    # No warning generated.
    out = capsys.readouterr().out
    assert "WARNING" not in out
Exemplo n.º 7
0
def test_simple_verbose_call(mock_docker, tmp_path, capsys):
    """If verbosity is turned out, there is output."""
    mock_docker.command.logger = Log(verbosity=2)

    mock_docker.run(["hello", "world"])

    mock_docker._subprocess._subprocess.run.assert_called_with(
        [
            "docker",
            "run",
            "--tty",
            "--volume",
            f"{tmp_path / 'platform'}:/app:z",
            "--volume",
            f"{tmp_path / '.briefcase'}:/home/brutus/.briefcase:z",
            "briefcase/com.example.myapp:py3.X",
            "hello",
            "world",
        ],
        text=True,
    )
    assert capsys.readouterr().out == (
        "\n"
        ">>> Running Command:\n"
        ">>>     docker run --tty "
        f"--volume {tmp_path / 'platform'}:/app:z "
        f"--volume {tmp_path / '.briefcase'}:/home/brutus/.briefcase:z "
        "briefcase/com.example.myapp:py3.X "
        "hello world\n"
    )
Exemplo n.º 8
0
def test_output_debug(mock_sub, popen_process, capsys):
    """Readline output is printed; debug mode should not add extra output."""
    mock_sub.command.logger = Log(verbosity=2)

    mock_sub.stream_output("testing", popen_process)

    assert capsys.readouterr().out == ("output line 1\n" "\n" "output line 3\n")
    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
Exemplo n.º 9
0
def test_get_process_id_by_command_w_command(process_list, command,
                                             expected_pid, expected_stdout,
                                             monkeypatch, capsys):
    """Finds correct process for command or returns None."""
    monkeypatch.setattr("psutil.process_iter", lambda attrs: process_list)
    found_pid = get_process_id_by_command(command=command, logger=Log())
    assert found_pid == expected_pid
    assert capsys.readouterr().out == expected_stdout
Exemplo n.º 10
0
def test_debug_call(mock_sub, capsys):
    """If verbosity is turned up, there is output."""
    mock_sub.command.logger = Log(verbosity=2)

    mock_sub.check_output(["hello", "world"])

    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], text=True)

    assert capsys.readouterr().out == "\n>>> Running Command:\n>>>     hello world\n"
Exemplo n.º 11
0
def test_command(tmp_path):
    command = mock.MagicMock()
    command.logger = Log()
    command.tools_path = tmp_path / "tools"

    # Mock environ.get returning no explicit JAVA_HOME
    command.os.environ.get = mock.MagicMock(return_value="")

    return command
Exemplo n.º 12
0
def test_output_deep_debug(mock_sub, popen_process, capsys):
    """Readline output is printed with debug return code in deep debug mode."""
    mock_sub.command.logger = Log(verbosity=3)

    mock_sub.stream_output("testing", popen_process)

    assert capsys.readouterr().out == (
        "output line 1\n" "\n" "output line 3\n" ">>> Return code: -3\n"
    )
    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
Exemplo n.º 13
0
def test_not_installed():
    """If cmdline dev tools are not installed, raise an error."""
    command = mock.MagicMock()
    command.logger = Log()
    with pytest.raises(BriefcaseCommandError):
        ensure_command_line_tools_are_installed(command)

    # xcode-select was invoked
    command.subprocess.check_output.assert_called_once_with(
        ["xcode-select", "--install"],
        stderr=subprocess.STDOUT,
    )
Exemplo n.º 14
0
def get_process_id_by_command(command_list: list = None,
                              command: str = "",
                              logger: Log = None):
    """Find a Process ID (PID) a by its command. If multiple processes are
    found, then the most recently created process ID is returned.

    :param command_list: list of a command's fully qualified path and its arguments.
    :param command: a partial or complete fully-qualified filepath to a command.
        This is primarily intended for use on macOS where the `open` command
        takes a filepath to a directory for an application; therefore, the actual
        running process will be running a command within that directory.
    :param logger: optional Log to show messages about process matching to users
    :return: PID if found else None
    """
    matching_procs = []
    # retrieve command line, creation time, and ID for all running processes.
    # note: psutil returns None for a process attribute if it is unavailable;
    #   this is most likely to happen for restricted or zombie processes.
    for proc in psutil.process_iter(["cmdline", "create_time", "pid"]):
        proc_cmdline = proc.info["cmdline"]
        if command_list and proc_cmdline == command_list:
            matching_procs.append(proc.info)
        if command and proc_cmdline and proc_cmdline[0].startswith(command):
            matching_procs.append(proc.info)

    if len(matching_procs) == 1:
        return matching_procs[0]["pid"]
    elif len(matching_procs) > 1:
        # return the ID of the most recently created matching process
        pid = sorted(matching_procs,
                     key=operator.itemgetter("create_time"))[-1]["pid"]
        if logger:
            logger.info(
                f"Multiple running instances of app found. Using most recently created app process {pid}."
            )
        return pid

    return None
Exemplo n.º 15
0
    def parse_options(self, extra):
        parser = argparse.ArgumentParser(
            prog=self.cmd_line.format(
                command=self.command,
                platform=self.platform,
                output_format=self.output_format,
            ),
            description=self.description,
        )

        self.add_default_options(parser)
        self.add_options(parser)

        # Parse the full set of command line options from the content
        # remaining after the basic command/platform/output format
        # has been extracted.
        options = vars(parser.parse_args(extra))

        # Extract the base default options onto the command
        self.input.enabled = options.pop("input_enabled")
        self.logger = Log(verbosity=options.pop("verbosity"))

        return options
def test_license_accepted(capsys):
    """If the Xcode license has been accepted, pass without comment."""
    command = mock.MagicMock()
    command.logger = Log()

    # Check passes without an error...
    confirm_xcode_license_accepted(command)

    # ... clang was invoked ...
    command.subprocess.check_output.assert_called_once_with(
        ["/usr/bin/clang", "--version"],
        stderr=subprocess.STDOUT,
    )

    # ... and the user is none the wiser
    out = capsys.readouterr().out
    assert len(out) == 0
Exemplo n.º 17
0
def test_installed(capsys):
    """If cmdline dev tools *are* installed, check passes without comment."""
    command = mock.MagicMock()
    command.logger = Log()
    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
        cmd=["xcode-select", "--install"], returncode=1)

    # Check passes without an error...
    ensure_command_line_tools_are_installed(command)

    # ... xcode-select was invoked
    command.subprocess.check_output.assert_called_once_with(
        ["xcode-select", "--install"],
        stderr=subprocess.STDOUT,
    )

    # ...and the user is none the wiser
    out = capsys.readouterr().out
    assert len(out) == 0
Exemplo n.º 18
0
def test_unsure_if_installed(capsys):
    """If xcode-select returns something odd, mention it but don't break."""
    command = mock.MagicMock()
    command.logger = Log()
    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
        cmd=["xcode-select", "--install"], returncode=69)

    # Check passes without an error...
    ensure_command_line_tools_are_installed(command)

    # ... xcode-select was invoked
    command.subprocess.check_output.assert_called_once_with(
        ["xcode-select", "--install"],
        stderr=subprocess.STDOUT,
    )

    # ...but stdout contains a warning
    out = capsys.readouterr().out
    assert "************" in out
def test_unknown_error(capsys):
    """If an unexpected problem occurred accepting the license, warn the
    user."""
    command = mock.MagicMock()
    command.logger = Log()
    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
        cmd=["/usr/bin/clang", "--version"], returncode=1)

    # Check passes without an error...
    confirm_xcode_license_accepted(command)

    # ... clang was invoked ...
    command.subprocess.check_output.assert_called_once_with(
        ["/usr/bin/clang", "--version"],
        stderr=subprocess.STDOUT,
    )

    # ...but stdout contains a warning
    out = capsys.readouterr().out
    assert "************" in out
Exemplo n.º 20
0
def mock_sdk(tmp_path):
    command = MagicMock()
    command.home_path = tmp_path
    command.subprocess = MagicMock()
    command.input = DummyConsole()
    command.logger = Log(verbosity=1)

    # For default test purposes, assume we're on macOS x86_64
    command.host_os = "Darwin"
    command.host_arch = "x86_64"

    # Mock an empty environment
    command.os.environ = {}

    # Set up a JDK
    jdk = MagicMock()
    jdk.java_home = Path("/path/to/jdk")

    sdk = AndroidSDK(command, jdk=jdk, root_path=tmp_path / "sdk")

    return sdk
Exemplo n.º 21
0
def test_debug_call_with_env(mock_sub, capsys):
    """If verbosity is turned up, injected env vars are included output."""
    mock_sub.command.logger = Log(verbosity=2)

    env = {"NewVar": "NewVarValue"}
    mock_sub.run(["hello", "world"], env=env)

    merged_env = mock_sub.command.os.environ.copy()
    merged_env.update(env)

    mock_sub._subprocess.run.assert_called_with(["hello", "world"],
                                                env=merged_env,
                                                text=True)

    expected_output = ("\n"
                       ">>> Running Command:\n"
                       ">>>     hello world\n"
                       ">>> Environment:\n"
                       ">>>     NewVar=NewVarValue\n")

    assert capsys.readouterr().out == expected_output
Exemplo n.º 22
0
def mock_sdk(tmp_path):
    command = MagicMock()
    command.logger = Log()
    command.input = DummyConsole()

    sdk = AndroidSDK(command, jdk=MagicMock(), root_path=tmp_path)

    sdk.devices = MagicMock(
        return_value={
            "041234567892009a": {
                "name": "Unknown device (not authorized for development)",
                "authorized": False,
            },
            "KABCDABCDA1513": {
                "name": "Kogan Agora 9",
                "authorized": True,
            },
            "emulator-5554": {
                "name": "Android SDK built for x86",
                "authorized": True,
            },
        })

    sdk.emulators = MagicMock(return_value=[
        "runningEmulator",
        "idleEmulator",
    ])

    # Set up an ADB for each device.
    def mock_adb(device_id):
        adb = MagicMock()
        if device_id == "emulator-5554":
            adb.avd_name.return_value = "runningEmulator"
        else:
            adb.avd_name.return_value = None
        return adb

    sdk.adb = mock_adb

    return sdk
def test_unexpected_version_output(capsys, xcode):
    """If xcodebuild returns unexpected output, assume it's ok..."""
    command = mock.MagicMock()
    command.logger = Log()
    command.subprocess.check_output.return_value = "Wibble Wibble Wibble\n"

    # Check passes without an error...
    ensure_xcode_is_installed(
        command,
        min_version=(11, 2, 1),
        xcode_location=xcode,
    )

    # xcode-select was invoked
    command.subprocess.check_output.assert_called_once_with(
        ["xcodebuild", "-version"],
        stderr=subprocess.STDOUT,
    )

    # ...but stdout contains a warning
    out = capsys.readouterr().out
    assert "************" in out
Exemplo n.º 24
0
def mock_docker(tmp_path):
    command = MagicMock()
    command.logger = Log()
    command.base_path = tmp_path / "base"
    command.platform_path = tmp_path / "platform"
    command.bundle_path.return_value = tmp_path / "bundle"
    command.dot_briefcase_path = tmp_path / ".briefcase"
    command.docker_image_tag.return_value = "briefcase/com.example.myapp:py3.X"
    command.python_version_tag = "3.X"
    command.os.getuid.return_value = "37"
    command.os.getgid.return_value = "42"

    command.subprocess = Subprocess(command)
    command.subprocess._subprocess = MagicMock()

    app = MagicMock()
    app.app_name = "myapp"
    app.sources = ["path/to/src/myapp", "other/stuff"]
    app.system_requires = ["things==1.2", "stuff>=3.4"]

    docker = Docker(command, app)

    return docker
Exemplo n.º 25
0
def mock_sub():
    command = MagicMock()
    command.logger = Log(verbosity=1)

    command.os = MagicMock()
    command.os.environ = {
        "VAR1": "Value 1",
        "PS1": "\nLine 2\n\nLine 4",
        "PWD": "/home/user/",
    }

    sub = Subprocess(command)
    sub._subprocess = MagicMock()

    run_result = MagicMock()
    run_result.returncode = 0
    sub._subprocess.run.return_value = run_result

    sub._subprocess.check_output.return_value = "some output line 1\nmore output line 2"

    sub._subprocess.CREATE_NO_WINDOW = CREATE_NO_WINDOW
    sub._subprocess.CREATE_NEW_PROCESS_GROUP = CREATE_NEW_PROCESS_GROUP

    return sub
Exemplo n.º 26
0
def test_command(tmp_path):
    command = mock.MagicMock()
    command.logger = Log()

    return command
Exemplo n.º 27
0
class BaseCommand(ABC):
    cmd_line = "briefcase {command} {platform} {output_format}"
    GLOBAL_CONFIG_CLASS = GlobalConfig
    APP_CONFIG_CLASS = AppConfig

    def __init__(self,
                 base_path,
                 home_path=Path.home(),
                 apps=None,
                 input_enabled=True):
        self.base_path = base_path
        self.home_path = home_path
        self.dot_briefcase_path = home_path / ".briefcase"
        self.tools_path = self.dot_briefcase_path / "tools"

        self.global_config = None
        self.apps = {} if apps is None else apps
        self._path_index = {}

        # Some details about the host machine
        self.host_arch = platform.machine()
        self.host_os = platform.system()

        # External service APIs.
        # These are abstracted to enable testing without patching.
        self.cookiecutter = cookiecutter
        self.requests = requests
        self.input = Console(enabled=input_enabled)
        self.os = os
        self.sys = sys
        self.shutil = shutil
        self.subprocess = Subprocess(self)

        # The internal Briefcase integrations API.
        self.integrations = integrations

        # Initialize default logger (replaced when options are parsed).
        self.logger = Log()

    @property
    def create_command(self):
        """Factory property; return an instance of a create command for the
        same format."""
        format_module = importlib.import_module(self.__module__)
        command = format_module.create(
            base_path=self.base_path,
            apps=self.apps,
            input_enabled=self.input.enabled,
        )
        command.clone_options(self)
        return command

    @property
    def update_command(self):
        """Factory property; return an instance of an update command for the
        same format."""
        format_module = importlib.import_module(self.__module__)
        command = format_module.update(
            base_path=self.base_path,
            apps=self.apps,
            input_enabled=self.input.enabled,
        )
        command.clone_options(self)
        return command

    @property
    def build_command(self):
        """Factory property; return an instance of a build command for the same
        format."""
        format_module = importlib.import_module(self.__module__)
        command = format_module.build(
            base_path=self.base_path,
            apps=self.apps,
            input_enabled=self.input.enabled,
        )
        command.clone_options(self)
        return command

    @property
    def run_command(self):
        """Factory property; return an instance of a run command for the same
        format."""
        format_module = importlib.import_module(self.__module__)
        command = format_module.run(
            base_path=self.base_path,
            apps=self.apps,
            input_enabled=self.input.enabled,
        )
        command.clone_options(self)
        return command

    @property
    def package_command(self):
        """Factory property; return an instance of a package command for the
        same format."""
        format_module = importlib.import_module(self.__module__)
        command = format_module.package(
            base_path=self.base_path,
            apps=self.apps,
            input_enabled=self.input.enabled,
        )
        command.clone_options(self)
        return command

    @property
    def publish_command(self):
        """Factory property; return an instance of a publish command for the
        same format."""
        format_module = importlib.import_module(self.__module__)
        command = format_module.publish(
            base_path=self.base_path,
            apps=self.apps,
            input_enabled=self.input.enabled,
        )
        command.clone_options(self)
        return command

    @property
    def platform_path(self):
        """The path for all applications for this command's platform."""
        return self.base_path / self.platform

    def bundle_path(self, app):
        """The path to the bundle for the app in the output format.

        The bundle is the template-generated source form of the app.
        The path will usually be a directory, the existence of which is
        indicative that the template has been rolled out for an app.

        :param app: The app config
        """
        return self.platform_path / self.output_format / app.formal_name

    @abstractmethod
    def binary_path(self, app):
        """The path to the executable artefact for the app in the output
        format.

        This may be a binary file produced by compilation; however, if
        the output format doesn't require compilation, it may be the same
        as the bundle path (assuming the bundle path is inherently
        "executable"), or a path that reasonably represents the thing that can
        be executed.

        :param app: The app config
        """
        ...

    @abstractmethod
    def distribution_path(self, app, packaging_format):
        """The path to the distributable artefact for the app in the given
        packaging format.

        This is the single file that should be uploaded for distribution.
        This may be the binary (if the binary is a self contained executable);
        however, if the output format produces an installer, it will be the
        path to the installer.

        :param app: The app config
        :param packaging_format: The format of the redistributable artefact.
        """
        ...

    def _load_path_index(self, app: BaseConfig):
        """Load the path index from the index file provided by the app
        template.

        :param app: The config object for the app
        :return: The contents of the application path index.
        """
        with (self.bundle_path(app) / "briefcase.toml").open("rb") as f:
            self._path_index[app] = tomllib.load(f)["paths"]
        return self._path_index[app]

    def support_path(self, app: BaseConfig):
        """Obtain the path into which the support package should be unpacked.

        :param app: The config object for the app
        :return: The full path where the support package should be unpacked.
        """
        # If the index file hasn't been loaded for this app, load it.
        try:
            path_index = self._path_index[app]
        except KeyError:
            path_index = self._load_path_index(app)
        return self.bundle_path(app) / path_index["support_path"]

    def app_packages_path(self, app: BaseConfig):
        """Obtain the path into which dependencies should be installed.

        :param app: The config object for the app
        :return: The full path where application dependencies should be installed.
        """
        # If the index file hasn't been loaded for this app, load it.
        try:
            path_index = self._path_index[app]
        except KeyError:
            path_index = self._load_path_index(app)
        return self.bundle_path(app) / path_index["app_packages_path"]

    def app_path(self, app: BaseConfig):
        """Obtain the path into which the application should be installed.

        :param app: The config object for the app
        :return: The full path where application code should be installed.
        """
        # If the index file hasn't been loaded for this app, load it.
        try:
            path_index = self._path_index[app]
        except KeyError:
            path_index = self._load_path_index(app)
        return self.bundle_path(app) / path_index["app_path"]

    def app_module_path(self, app):
        """Find the path for the application module for an app.

        :param app: The config object for the app
        :returns: The Path to the dist-info folder.
        """
        app_home = [
            path.split("/") for path in app.sources
            if path.rsplit("/", 1)[-1] == app.module_name
        ]
        try:
            if len(app_home) == 1:
                path = Path(str(self.base_path), *app_home[0])
            else:
                raise BriefcaseCommandError(
                    f"Multiple paths in sources found for application '{app.app_name}'"
                )
        except IndexError as e:
            raise BriefcaseCommandError(
                f"Unable to find code for application '{app.app_name}'") from e

        return path

    @property
    def python_version_tag(self):
        """The major.minor of the Python version in use, as a string.

        This is used as a repository label/tag to identify the
        appropriate templates, etc to use.
        """
        return f"{self.sys.version_info.major}.{self.sys.version_info.minor}"

    def verify_tools(self):
        """Verify that the tools needed to run this command exist.

        Raises MissingToolException if a required system tool is
        missing.
        """
        pass

    def parse_options(self, extra):
        parser = argparse.ArgumentParser(
            prog=self.cmd_line.format(
                command=self.command,
                platform=self.platform,
                output_format=self.output_format,
            ),
            description=self.description,
        )

        self.add_default_options(parser)
        self.add_options(parser)

        # Parse the full set of command line options from the content
        # remaining after the basic command/platform/output format
        # has been extracted.
        options = vars(parser.parse_args(extra))

        # Extract the base default options onto the command
        self.input.enabled = options.pop("input_enabled")
        self.logger = Log(verbosity=options.pop("verbosity"))

        return options

    def clone_options(self, command):
        """Clone options from one command to this one.

        :param command: The command whose options are to be cloned
        """
        self.input.enabled = command.input.enabled
        self.logger = command.logger

    def add_default_options(self, parser):
        """Add the default options that exist on *all* commands.

        :param parser: a stub argparse parser for the command.
        """
        parser.add_argument(
            "-v",
            "--verbosity",
            action="count",
            default=1,
            help=
            "set the verbosity of output (use -vv for additional debug output)",
        )
        parser.add_argument("-V",
                            "--version",
                            action="version",
                            version=__version__)
        parser.add_argument(
            "--no-input",
            action="store_false",
            default=True,
            dest="input_enabled",
            help=(
                "Don't ask for user input. If any action would be destructive, "
                "an error will be raised; otherwise, default answers will be "
                "assumed."),
        )

    def add_options(self, parser):
        """Add any options that this command needs to parse from the command
        line.

        :param parser: a stub argparse parser for the command.
        """
        pass

    def parse_config(self, filename):
        try:
            with open(filename, "rb") as config_file:
                # Parse the content of the pyproject.toml file, extracting
                # any platform and output format configuration for each app,
                # creating a single set of configuration options.
                global_config, app_configs = parse_config(
                    config_file,
                    platform=self.platform,
                    output_format=self.output_format,
                )

                self.global_config = create_config(
                    klass=self.GLOBAL_CONFIG_CLASS,
                    config=global_config,
                    msg="Global configuration",
                )

                for app_name, app_config in app_configs.items():
                    # Construct an AppConfig object with the final set of
                    # configuration options for the app.
                    self.apps[app_name] = create_config(
                        klass=self.APP_CONFIG_CLASS,
                        config=app_config,
                        msg=f"Configuration for '{app_name}'",
                    )

        except FileNotFoundError as e:
            raise BriefcaseConfigError("configuration file not found") from e

    def download_url(self, url, download_path):
        """Download a given URL, caching it. If it has already been downloaded,
        return the value that has been cached.

        This is a utility method used to obtain assets used by the
        install process. The cached filename will be the filename portion of
        the URL, appended to the download path.

        :param url: The URL to download
        :param download_path: The path to the download cache folder. This path
            will be created if it doesn't exist.
        :returns: The filename of the downloaded (or cached) file.
        """
        download_path.mkdir(parents=True, exist_ok=True)

        response = self.requests.get(url, stream=True)
        if response.status_code == 404:
            raise MissingNetworkResourceError(url=url, )
        elif response.status_code != 200:
            raise BadNetworkResourceError(url=url,
                                          status_code=response.status_code)

        # The initial URL might (read: will) go through URL redirects, so
        # we need the *final* response. We look at either the `Content-Disposition`
        # header, or the final URL, to extract the cache filename.
        cache_full_name = urlparse(response.url).path
        header_value = response.headers.get("Content-Disposition")
        if header_value:
            # See also https://tools.ietf.org/html/rfc6266
            value, parameters = parse_header(header_value)
            if value.split(":", 1)[-1].strip().lower(
            ) == "attachment" and parameters.get("filename"):
                cache_full_name = parameters["filename"]
        cache_name = cache_full_name.split("/")[-1]
        filename = download_path / cache_name
        if not filename.exists():
            # We have meaningful content, and it hasn't been cached previously,
            # so save it in the requested location
            self.logger.info(f"Downloading {cache_name}...")
            with filename.open("wb") as f:
                total = response.headers.get("content-length")
                if total is None:
                    f.write(response.content)
                else:
                    downloaded = 0
                    with self.input.progress_bar(
                            total=int(total)) as progress_bar:
                        for data in response.iter_content(chunk_size=1024 *
                                                          1024):
                            f.write(data)
                            downloaded += len(data)
                            progress_bar.update(completed=downloaded)
        else:
            self.logger.info(f"{cache_name} already downloaded")
        return filename

    def update_cookiecutter_cache(self, template: str, branch="master"):
        """Ensure that we have a current checkout of a template path.

        If the path is a local path, use the path as is.

        If the path is a URL, look for a local cache; if one exists, update it,
        including checking out the required branch.

        :param template: The template URL or path.
        :param branch: The template branch to use. Default: ``master``
        :return: The path to the cached template. This may be the originally
            provided path if the template was a file path.
        """
        if is_repo_url(template):
            # The app template is a repository URL.
            #
            # When in `no_input=True` mode, cookiecutter deletes and reclones
            # a template directory, rather than updating the existing repo.
            #
            # Look for a cookiecutter cache of the template; if one exists,
            # try to update it using git. If no cache exists, or if the cache
            # directory isn't a git directory, or git fails for some reason,
            # fall back to using the specified template directly.
            try:
                cached_template = cookiecutter_cache_path(template)
                repo = self.git.Repo(cached_template)
                try:
                    # Attempt to update the repository
                    remote = repo.remote(name="origin")
                    remote.fetch()
                except self.git.exc.GitCommandError:
                    # We are offline, or otherwise unable to contact
                    # the origin git repo. It's OK to continue; but warn
                    # the user that the template may be stale.
                    self.logger.warning("""
*************************************************************************
** WARNING: Unable to update template                                  **
*************************************************************************

   Briefcase is unable the update the application template. This
   may be because your computer is currently offline. Briefcase will
   use existing template without updating.

*************************************************************************
""")
                try:
                    # Check out the branch for the required version tag.
                    head = remote.refs[branch]

                    self.logger.info(
                        f"Using existing template (sha {head.commit.hexsha}, "
                        f"updated {head.commit.committed_datetime.strftime('%c')})"
                    )
                    head.checkout()
                except IndexError as e:
                    # No branch exists for the requested version.
                    raise TemplateUnsupportedVersion(branch) from e
            except self.git.exc.NoSuchPathError:
                # Template cache path doesn't exist.
                # Just use the template directly, rather than attempting an update.
                cached_template = template
            except self.git.exc.InvalidGitRepositoryError:
                # Template cache path exists, but isn't a git repository
                # Just use the template directly, rather than attempting an update.
                cached_template = template
        else:
            # If this isn't a repository URL, treat it as a local directory
            cached_template = template

        return cached_template