Repository: beeware/briefcase

Issue:
Flake8 line limit is inconsistent with Black
### What is the problem or limitation you are having?

Briefcase's flake8 configuration (set in tox.ini because of... *reasons*) enforces a line length of 119. However, Black format enforces an 88 character limit. So - our code is being formatted to 88 character lines, but that won't format some comments and strings, which can lead to PRs that don't auto-flag a formatting issue.

### Describe the solution you'd like

Briefcase's flake8 configuration should set an 88 characters limit (to match Black). 

Existing code that exceeds the 88 character limit should be reformatted.

### Describe alternatives you've considered

None

### Additional context

After changing the tox.ini configuration, `pre-commit run --all` will highlight all the problem lines.

Hints from issue comments:
@rmartin16: Of potential note, Black has exceptions to its line length limits; e.g. long literal strings. I'm not sure if flake8 has the same exceptions. Furthermore, I think that docformatter was helping to enforce these rules....but it's been disabled too. (And while moot, consolidating on Ruff would almost certainly remove these sorts of conflicts...)@freakboy3742: Yeah - most of the exceptions are things like long method names that black *can't* break, but Flake8 will report as an issue. Most of those are actually in Toga, in "duplicate argument name" ObjC methods. 

As for Ruff: I'm becoming a lot more amenable to the idea. CPython is now using Ruff; and the appeal of having a single, consistent formatting tool is appealing - and the community around that tool seems more healthy than the one around flake8, in particular.

Regardless - any adoption of Ruff would be predicated by having consistent line length, so fixing this issue would be at least a co-requisite.@tekktrik: I'll take this one at the PyCon sprints today!

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5ab56f021..13b3ffb00 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,30 +8,17 @@ repos:
       - id: check-docstring-first
       - id: end-of-file-fixer
       - id: trailing-whitespace
-  - repo: https://github.com/asottile/pyupgrade
-    rev: v3.19.1
-    hooks:
-      - id: pyupgrade
-        args: [--py39-plus]
-  - repo: https://github.com/PyCQA/isort
-    rev: 6.0.1
-    hooks:
-      - id: isort
   # Docformatter 1.7.5 isn't compatible with Pre-commit 4.0
   # - repo: https://github.com/PyCQA/docformatter
   #   rev: v1.7.5
   #   hooks:
   #     - id: docformatter
   #       args: [--in-place, --black]
-  - repo: https://github.com/psf/black-pre-commit-mirror
-    rev: 25.1.0
-    hooks:
-      - id: black
-        language_version: python3
-  - repo: https://github.com/PyCQA/flake8
-    rev: 7.2.0
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.11.10
     hooks:
-      - id: flake8
+      - id: ruff-format
+      - id: ruff-check
   - repo: https://github.com/codespell-project/codespell
     rev: v2.4.1
     hooks:
diff --git a/changes/1099.bugfix.rst b/changes/1099.bugfix.rst
new file mode 100644
index 000000000..4886c19d9
--- /dev/null
+++ b/changes/1099.bugfix.rst
@@ -0,0 +1 @@
+A redundant signing phase has been removed when packaging an apps for macOS.
diff --git a/changes/1190.doc.rst b/changes/1190.doc.rst
new file mode 100644
index 000000000..0d3406a2e
--- /dev/null
+++ b/changes/1190.doc.rst
@@ -0,0 +1 @@
+Updated Documentation to Clarify iOS Physical vs. Simulated Devices Support
diff --git a/changes/2188.bugfix.rst b/changes/2188.bugfix.rst
new file mode 100644
index 000000000..7f7c635cd
--- /dev/null
+++ b/changes/2188.bugfix.rst
@@ -0,0 +1 @@
+Distribution-specific cumulative settings (such as `requires`) now correctly accumulate, rather than overwriting more general values.
diff --git a/changes/2292.misc.rst b/changes/2292.misc.rst
new file mode 100644
index 000000000..96563ae4f
--- /dev/null
+++ b/changes/2292.misc.rst
@@ -0,0 +1 @@
+Updated from black and flake8 to ruff
diff --git a/changes/2295.misc.rst b/changes/2295.misc.rst
new file mode 100644
index 000000000..7e1e000ff
--- /dev/null
+++ b/changes/2295.misc.rst
@@ -0,0 +1 @@
+Update ditto unit test to account for recent changes to MacOS zip files.
diff --git a/docs/conf.py b/docs/conf.py
index dfdc5126e..3c95ebc3a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,7 +11,6 @@
 
 import os
 import sys
-
 from importlib.metadata import version as metadata_version
 
 # If extensions (or modules to document with autodoc) are in another directory,
diff --git a/docs/how-to/contribute/code.rst b/docs/how-to/contribute/code.rst
index 7643a717b..fb193a404 100644
--- a/docs/how-to/contribute/code.rst
+++ b/docs/how-to/contribute/code.rst
@@ -160,25 +160,19 @@ pre-commit will make the changes needed to correct the problems it has found:
 
       (venv) $ git add some/interesting_file.py
       (venv) $ git commit -m "Minor change"
-      black....................................................................Failed
-      - hook id: black
-      - files were modified by this hook
-
-      reformatted some/interesting_file.py
-
-      All done! ✨ 🍰 ✨
-      1 file reformatted.
-
-      flake8...................................................................Passed
-      check toml...........................................(no files to check)Skipped
-      check yaml...........................................(no files to check)Skipped
+      check toml...............................................................Passed
+      check yaml...............................................................Passed
       check for case conflicts.................................................Passed
       check docstring is first.................................................Passed
       fix end of files.........................................................Passed
       trim trailing whitespace.................................................Passed
-      isort....................................................................Passed
-      pyupgrade................................................................Passed
-      docformatter.............................................................Passed
+      ruff format..............................................................Failed
+      - hook id: ruff-format
+      - files were modified by this hook
+
+      1 file reformatted, 488 files left unchanged
+
+      ruff check...............................................................Passed
 
   .. group-tab:: Linux
 
@@ -186,25 +180,19 @@ pre-commit will make the changes needed to correct the problems it has found:
 
       (venv) $ git add some/interesting_file.py
       (venv) $ git commit -m "Minor change"
-      black....................................................................Failed
-      - hook id: black
-      - files were modified by this hook
-
-      reformatted some/interesting_file.py
-
-      All done! ✨ 🍰 ✨
-      1 file reformatted.
-
-      flake8...................................................................Passed
-      check toml...........................................(no files to check)Skipped
-      check yaml...........................................(no files to check)Skipped
+      check toml...............................................................Passed
+      check yaml...............................................................Passed
       check for case conflicts.................................................Passed
       check docstring is first.................................................Passed
       fix end of files.........................................................Passed
       trim trailing whitespace.................................................Passed
-      isort....................................................................Passed
-      pyupgrade................................................................Passed
-      docformatter.............................................................Passed
+      ruff format..............................................................Failed
+      - hook id: ruff-format
+      - files were modified by this hook
+
+      1 file reformatted, 488 files left unchanged
+
+      ruff check...............................................................Passed
 
   .. group-tab:: Windows
 
@@ -212,25 +200,19 @@ pre-commit will make the changes needed to correct the problems it has found:
 
       (venv) C:\...>git add some/interesting_file.py
       (venv) C:\...>git commit -m "Minor change"
-      black....................................................................Failed
-      - hook id: black
-      - files were modified by this hook
-
-      reformatted some\interesting_file.py
-
-      All done! ✨ 🍰 ✨
-      1 file reformatted.
-
-      flake8...................................................................Passed
-      check toml...........................................(no files to check)Skipped
-      check yaml...........................................(no files to check)Skipped
+      check toml...............................................................Passed
+      check yaml...............................................................Passed
       check for case conflicts.................................................Passed
       check docstring is first.................................................Passed
       fix end of files.........................................................Passed
       trim trailing whitespace.................................................Passed
-      isort....................................................................Passed
-      pyupgrade................................................................Passed
-      docformatter.............................................................Passed
+      ruff format..............................................................Failed
+      - hook id: ruff-format
+      - files were modified by this hook
+
+      1 file reformatted, 488 files left unchanged
+
+      ruff check...............................................................Passed
 
 You can then re-add any files that were modified as a result of the pre-commit checks,
 and re-commit the change.
@@ -243,17 +225,14 @@ and re-commit the change.
 
       (venv) $ git add some/interesting_file.py
       (venv) $ git commit -m "Minor change"
-      black....................................................................Passed
-      flake8...................................................................Passed
-      check toml...........................................(no files to check)Skipped
-      check yaml...........................................(no files to check)Skipped
+      check toml...............................................................Passed
+      check yaml...............................................................Passed
       check for case conflicts.................................................Passed
       check docstring is first.................................................Passed
       fix end of files.........................................................Passed
       trim trailing whitespace.................................................Passed
-      isort....................................................................Passed
-      pyupgrade................................................................Passed
-      docformatter.............................................................Passed
+      ruff format..............................................................Passed
+      ruff check...............................................................Passed
       [bugfix e3e0f73] Minor change
       1 file changed, 4 insertions(+), 2 deletions(-)
 
@@ -263,17 +242,14 @@ and re-commit the change.
 
       (venv) $ git add some/interesting_file.py
       (venv) $ git commit -m "Minor change"
-      black....................................................................Passed
-      flake8...................................................................Passed
-      check toml...........................................(no files to check)Skipped
-      check yaml...........................................(no files to check)Skipped
+      check toml...............................................................Passed
+      check yaml...............................................................Passed
       check for case conflicts.................................................Passed
       check docstring is first.................................................Passed
       fix end of files.........................................................Passed
       trim trailing whitespace.................................................Passed
-      isort....................................................................Passed
-      pyupgrade................................................................Passed
-      docformatter.............................................................Passed
+      ruff format..............................................................Passed
+      ruff check...............................................................Passed
       [bugfix e3e0f73] Minor change
       1 file changed, 4 insertions(+), 2 deletions(-)
 
@@ -283,17 +259,14 @@ and re-commit the change.
 
       (venv) C:\...>git add some\interesting_file.py
       (venv) C:\...>git commit -m "Minor change"
-      black....................................................................Passed
-      flake8...................................................................Passed
-      check toml...........................................(no files to check)Skipped
-      check yaml...........................................(no files to check)Skipped
+      check toml...............................................................Passed
+      check yaml...............................................................Passed
       check for case conflicts.................................................Passed
       check docstring is first.................................................Passed
       fix end of files.........................................................Passed
       trim trailing whitespace.................................................Passed
-      isort....................................................................Passed
-      pyupgrade................................................................Passed
-      docformatter.............................................................Passed
+      ruff format..............................................................Passed
+      ruff check...............................................................Passed
 
 Now you are ready to start hacking on Briefcase!
 
diff --git a/docs/reference/platforms/iOS/xcode.rst b/docs/reference/platforms/iOS/xcode.rst
index 094a828c5..dd5df3f82 100644
--- a/docs/reference/platforms/iOS/xcode.rst
+++ b/docs/reference/platforms/iOS/xcode.rst
@@ -216,3 +216,21 @@ can do this using the ``cleanup_paths`` configuration option::
 
 This will find and purge all ``.a`` content in your app's dependencies. You can add
 additional patterns to remove other problematic content.
+
+
+Deployment to Simulated and Physical iOS Devices
+------------------------------------------------
+
+Briefcase provides support for deployment to simulated iOS devices through the
+command line.
+
+If you want to deploy to a physical iOS device, you will need need to use Xcode through
+the following steps:
+
+1. Run ``briefcase open ios`` in the command line. This will open your application in Xcode.
+2. Setup your Apple Developer account with your certificate in Xcode.
+3. In the project navigator, select your application at the top level (the root of the project).
+4. Select the `Signing and Capabilities` tab in the editor area.
+5. Select your Apple Developer team or individual account from the `Team` drop-down.
+6. Select your specific device.
+7. Press the run button.
diff --git a/pyproject.toml b/pyproject.toml
index b13e81e90..9323fdd33 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -218,15 +218,6 @@ no-cover-if-is-py310 = "python_version == '3.10' and os_environ.get('COVERAGE_EX
 no-cover-if-lt-py310 = "sys_version_info < (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'"
 no-cover-if-gte-py310 = "sys_version_info > (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'"
 
-[tool.isort]
-profile = "black"
-skip_glob = [
-    "docs/conf.py",
-    "venv*",
-    "local",
-]
-multi_line_output = 3
-
 [tool.pytest.ini_options]
 testpaths = ["tests"]
 filterwarnings = [
@@ -236,6 +227,50 @@ filterwarnings = [
 # need to ensure build directories aren't excluded from recursion
 norecursedirs = []
 
+[tool.ruff.lint]
+# In addition to the default rules, these additional rules will be used:
+extend-select = [
+    "E",      # pycodestyle
+    "W",      # pycodestyle
+    "F",      # pyflakes
+    "UP",     # pyupgrade
+    "B",      # flake8-bugbear
+    "SIM",    # flake8-simplify
+    "YTT",    # flake8-2020
+    "ASYNC",  # flake8-async
+    "C4",     # flake8-comprehensions
+    "I",      # isort
+]
+
+# Below is a list of ignored rules.  Many (if not all) of these have been disabled
+# to facilitate a smooth transition from tools like black and isort to ruff, where
+# instances of these errors were caught.  Many if not all of these are candidates
+# for future fixes throughout the project.
+ignore = [
+    "E501",    # line length
+    "B007",    # loop control variable not used
+    "B018",    # useless expression
+    "B019",    # memory leaks from functools.lru_cache / functools.cache
+    "B024",    # abstract base class without abstract methods / properties
+    "B027",    # empty method in abstract base class
+    "B904",    # use raise ... from ...
+    "SIM102",  # combine if statements
+    "SIM103",  # return condition directly
+    "SIM105",  # use contextlib.suppress() instead of try-except-pass
+    "SIM108",  # use ternary operators
+    "SIM115",  # use context manager for opening files
+    "SIM117",  # combine with statements
+    "SIM118",  # use "key in dict" instead of "key in dict.keys()"
+    "SIM201",  # use "x != y" instead of "not x == y"
+    "SIM300",  # Yoda condition (0 != x)
+    "SIM905",  # use list literal instead of str.split()
+    "C400",    # unnecessary generator
+    "C408",    # use dict literal instead of dict()
+    "C414",    # unnecessary list() use within sorted()
+    "C416",    # unnecessary list comprehension
+]
+
+
 [tool.setuptools_scm]
 # To enable SCM versioning, we need an empty tool configuration for setuptools_scm
 
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 550f7af41..23d58f324 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -587,7 +587,7 @@ def new_app(
             f"""
 To run your application, type:
 
-    $ cd {context['app_name']}
+    $ cd {context["app_name"]}
     $ briefcase dev
 
 """
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 483608af3..5cebcfd1f 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -165,7 +165,6 @@ def __call__(
         update: bool = False,
         **options,
     ) -> dict | None:
-
         # Confirm host compatibility, that all required tools are available,
         # and that the app configuration is finalized.
         self.finalize(app)
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 607f889de..06c10f369 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -224,6 +224,32 @@ def __init__(self, **kwargs):
         for attr, value in kwargs.items():
             setattr(self, attr, value)
 
+    def update(self, data):
+        """Add fields and values from data to BaseConfig instance.
+
+        Any existing fields named in data will be overwritten. To merge
+        data with existing configs, use the `merge_config` function.
+
+        :param data: The new configuration data dictionary.
+        """
+        for key, configs in data.items():
+            setattr(self, key, configs)
+
+    def setdefault(self, field_name, default_value):
+        """Return the field_name field or, if it does not exist, create it
+        to hold default_value.
+
+        Behaves similarly to dict.setdefault().
+
+        :param field_name: The name of the desired/new field.
+        :param default_value: The value to assign to self.field_name if it
+            does not already exist.
+        """
+        if not hasattr(self, field_name):
+            setattr(self, field_name, default_value)
+
+        return getattr(self, field_name)
+
 
 class GlobalConfig(BaseConfig):
     def __init__(
@@ -594,7 +620,7 @@ def parse_config(config_file, platform, output_format, console):
     The current configuration for {section_name} has a 'license' field
     that is specified as a string:
 
-        license = "{config['license']}"
+        license = "{config["license"]}"
 
     To use the PEP 621 format (and to remove this warning), specify that
     the LICENSE file contains the license for {section_name}:
diff --git a/src/briefcase/integrations/base.py b/src/briefcase/integrations/base.py
index e49d44f70..65391ac61 100644
--- a/src/briefcase/integrations/base.py
+++ b/src/briefcase/integrations/base.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import collections
 import locale
 import os
 import platform
@@ -10,7 +11,7 @@
 from collections.abc import Mapping
 from functools import cached_property
 from pathlib import Path
-from typing import TYPE_CHECKING, DefaultDict, TypeVar
+from typing import TYPE_CHECKING, TypeVar
 
 import httpx
 from cookiecutter.main import cookiecutter
@@ -199,7 +200,7 @@ def __init__(
         # Python is 32bit if its pointers can only address with 32 bits or fewer
         self.is_32bit_python = self.sys.maxsize <= 2**32
 
-        self.app_tools: DefaultDict[AppConfig, ToolCache] = defaultdict(
+        self.app_tools: collections.defaultdict[AppConfig, ToolCache] = defaultdict(
             lambda: ToolCache(
                 console=self.console,
                 base_path=self.base_path,
diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py
index 98bd14620..032eb31e6 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -6,7 +6,7 @@
 import subprocess
 import sys
 from collections.abc import Iterable, Mapping
-from functools import lru_cache
+from functools import cache
 from pathlib import Path, PosixPath, PurePosixPath
 
 from packaging.version import InvalidVersion, Version
@@ -344,7 +344,7 @@ def _is_user_mapping_enabled(self, image_tag: str | None = None) -> bool:
 
         return is_user_mapped
 
-    @lru_cache(maxsize=None)
+    @cache
     def cache_image(self, image_tag: str):
         """Ensures an image is available and cached locally.
 
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index a1fe5357c..882345729 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -15,7 +15,7 @@
     UpdateCommand,
 )
 from briefcase.commands.convert import find_changelog_filename
-from briefcase.config import AppConfig
+from briefcase.config import AppConfig, merge_config
 from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.docker import Docker, DockerAppContext
 from briefcase.integrations.subprocess import NativeAppContext
@@ -198,8 +198,7 @@ def finalize_app_config(self, app: AppConfig):
             vendor_config,
             codename_config,
         ]:
-            for key, value in config.items():
-                setattr(app, key, value)
+            merge_config(app, config)
 
         with self.console.wait_bar("Determining glibc version..."):
             app.glibc_version = self.target_glibc_version(app)
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index fc37e4260..586685dc9 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -912,8 +912,7 @@ def notarize(
         # Determine the arguments that would be needed to reproduce this notarization
         if installer_identity:
             identity_args = (
-                f"--identity {identity.id} "
-                f"--installer-identity {installer_identity.id}"
+                f"--identity {identity.id} --installer-identity {installer_identity.id}"
             )
             notarization_identity = installer_identity
         else:
@@ -1146,9 +1145,9 @@ def finalize_notarization(
                                 f"Notarization was rejected: {summary}\n"
                                 + "\n".join(
                                     f"""
-    * ({issue.get('severity', "?")}) {issue.get('path')} [{issue.get('architecture', "unknown architecture")}]
-      {issue.get('message')}
-      {issue.get('docUrl', '(No additional help available)')}"""
+    * ({issue.get("severity", "?")}) {issue.get("path")} [{issue.get("architecture", "unknown architecture")}]
+      {issue.get("message")}
+      {issue.get("docUrl", "(No additional help available)")}"""
                                     for issue in response.get("issues", [])
                                 )
                             )
diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py
index d4da66510..bf08e9124 100644
--- a/src/briefcase/platforms/macOS/app.py
+++ b/src/briefcase/platforms/macOS/app.py
@@ -127,9 +127,14 @@ def build_app(self, app: AppConfig, **kwargs):
         # macOS apps don't have anything to compile, but they do need to be
         # signed to be able to execute on Apple Silicon hardware - even if it's only an
         # ad-hoc signing identity. Apply an ad-hoc signing identity to the
-        # app bundle.
-        self.console.info("Ad-hoc signing app...", prefix=app.app_name)
-        self.sign_app(app=app, identity=SigningIdentity())
+        # app bundle. However, the app only needs to be signed once. If the build is
+        # being done as part of a package command, the signing will happen during
+        # packaging. The two case can be distinguished because "adhoc_sign" will
+        # be absent from kwargs if only doing a build command, but will be present if
+        # the build is being done as part of a package command.
+        if "adhoc_sign" not in kwargs:
+            self.console.info("Ad-hoc signing app...", prefix=app.app_name)
+            self.sign_app(app=app, identity=SigningIdentity())
 
 
 class macOSAppRunCommand(macOSRunMixin, macOSAppMixin, RunCommand):
diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py
index 5cecc44dc..15909ba68 100644
--- a/src/briefcase/platforms/web/static.py
+++ b/src/briefcase/platforms/web/static.py
@@ -197,7 +197,7 @@ def build_app(self, app: AppConfig, **kwargs):
             # Ensure that we're using Unix path separators, as the content
             # will be parsed by pyscript in the browser.
             config["packages"] = [
-                f'/{"/".join(wheel.relative_to(self.project_path(app)).parts)}'
+                f"/{'/'.join(wheel.relative_to(self.project_path(app)).parts)}"
                 for wheel in sorted(self.wheel_path(app).glob("*.whl"))
             ]
 
diff --git a/tox.ini b/tox.ini
index a620fc3cd..cc16ba208 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,20 +1,3 @@
-# Flake8 doesn't believe in pyproject.toml, so we put the configuration here.
-[flake8]
-# https://flake8.readthedocs.org/en/latest/
-exclude=\
-    venv*/*,\
-    local/*,\
-    docs/*,\
-    build/*,\
-    tests/apps/*,\
-    .eggs/*,\
-    .tox/*
-max-line-length = 119
-extend-ignore =
-    # whitespace before :
-    # See https://github.com/PyCQA/pycodestyle/issues/373
-    E203,
-
 [tox]
 envlist = towncrier-check,docs-lint,pre-commit,py{39,310,311,312,313}-cov,coverage
 labels =


Test patch:
diff --git a/tests/commands/build/conftest.py b/tests/commands/build/conftest.py
index 2caeae40e..32390ecb6 100644
--- a/tests/commands/build/conftest.py
+++ b/tests/commands/build/conftest.py
@@ -3,7 +3,8 @@
 from briefcase.commands import BuildCommand
 from briefcase.commands.base import full_options
 from briefcase.config import AppConfig
-from ...utils import create_file, DummyConsole
+
+from ...utils import DummyConsole, create_file
 
 
 class DummyBuildCommand(BuildCommand):
diff --git a/tests/commands/convert/test_input_author.py b/tests/commands/convert/test_input_author.py
index 39b40e259..fda127ef6 100644
--- a/tests/commands/convert/test_input_author.py
+++ b/tests/commands/convert/test_input_author.py
@@ -127,7 +127,7 @@ def test_no_pep621_author(convert_command, monkeypatch, write_empty_pyproject):
 
     if write_empty_pyproject:
         (convert_command.base_path / "pyproject.toml").write_text(
-            "[project]\n" "authors = [\n" '    {email="mail1@tld.com"},\n' "]",
+            '[project]\nauthors = [\n    {email="mail1@tld.com"},\n]',
             encoding="utf-8",
         )
     convert_command.input_author(None)
diff --git a/tests/commands/convert/test_input_license.py b/tests/commands/convert/test_input_license.py
index 61dbae901..0e5866425 100644
--- a/tests/commands/convert/test_input_license.py
+++ b/tests/commands/convert/test_input_license.py
@@ -108,7 +108,7 @@ def test_get_license_from_pep621_license_file(
         dummy_license_text, encoding="utf-8"
     )
     (convert_command.base_path / "pyproject.toml").write_text(
-        "[project]\n" 'license = {file = "LICENSE.txt"}', encoding="utf-8"
+        '[project]\nlicense = {file = "LICENSE.txt"}', encoding="utf-8"
     )
 
     convert_command.input_license(None)
@@ -147,7 +147,7 @@ def test_get_license_from_pyproject(
         convert_command.console, "selection_question", mock_selection_question
     )
     (convert_command.base_path / "pyproject.toml").write_text(
-        "[project]\n" f'license = {{text = "{license_text}"}}', encoding="utf-8"
+        f'[project]\nlicense = {{text = "{license_text}"}}', encoding="utf-8"
     )
 
     convert_command.input_license(None)
diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py
index 8acf13fd6..aa439b1c8 100644
--- a/tests/commands/create/test_install_app_requirements.py
+++ b/tests/commands/create/test_install_app_requirements.py
@@ -403,10 +403,10 @@ def test_app_packages_invalid_requires(
     # Unfortunately, no way to tell the difference between "offline" and
     # "your requirements are invalid"; pip returns status code 1 for all
     # failures.
-    create_command.tools[myapp].app_context.run.side_effect = (
-        subprocess.CalledProcessError(
-            cmd=["python", "-u", "-m", "pip", "..."], returncode=1
-        )
+    create_command.tools[
+        myapp
+    ].app_context.run.side_effect = subprocess.CalledProcessError(
+        cmd=["python", "-u", "-m", "pip", "..."], returncode=1
     )
 
     with pytest.raises(RequirementsInstallError):
@@ -449,10 +449,10 @@ def test_app_packages_offline(
     # Unfortunately, no way to tell the difference between "offline" and
     # "your requirements are invalid"; pip returns status code 1 for all
     # failures.
-    create_command.tools[myapp].app_context.run.side_effect = (
-        subprocess.CalledProcessError(
-            cmd=["python", "-u", "-m", "pip", "..."], returncode=1
-        )
+    create_command.tools[
+        myapp
+    ].app_context.run.side_effect = subprocess.CalledProcessError(
+        cmd=["python", "-u", "-m", "pip", "..."], returncode=1
     )
 
     with pytest.raises(RequirementsInstallError):
@@ -501,8 +501,10 @@ def test_app_packages_install_requirements(
     myapp.requires = ["first", "second", "third"]
 
     # The side effect of calling pip is creating installation artefacts
-    create_command.tools[myapp].app_context.run.side_effect = (
-        create_installation_artefacts(app_packages_path, myapp.requires)
+    create_command.tools[
+        myapp
+    ].app_context.run.side_effect = create_installation_artefacts(
+        app_packages_path, myapp.requires
     )
 
     # Install the requirements
@@ -556,8 +558,10 @@ def test_app_packages_replace_existing_requirements(
     myapp.requires = ["first", "second", "third"]
 
     # The side effect of calling pip is creating installation artefacts
-    create_command.tools[myapp].app_context.run.side_effect = (
-        create_installation_artefacts(app_packages_path, myapp.requires)
+    create_command.tools[
+        myapp
+    ].app_context.run.side_effect = create_installation_artefacts(
+        app_packages_path, myapp.requires
     )
 
     # Install the requirements
diff --git a/tests/config/test_BaseConfig.py b/tests/config/test_BaseConfig.py
new file mode 100644
index 000000000..b235ac682
--- /dev/null
+++ b/tests/config/test_BaseConfig.py
@@ -0,0 +1,24 @@
+from briefcase.config import BaseConfig
+
+
+def test_update():
+    # Expect update() to overwrite an existing field
+    config = BaseConfig()
+    config.requires = ["newpackage=1.1"]
+    new_requirements = ["newpackage=1.2"]
+    config.update({"requires": new_requirements})
+    assert config.requires == new_requirements
+
+
+def test_setdefault():
+    config = BaseConfig()
+    # Expect setdefault() to add new field since it does not exist
+    result = config.setdefault("requires", [])
+    assert result == config.requires
+    assert config.requires == []
+
+    # Expect setdefault() to not overwrite existing field
+    new_requirements = config.requires = ["newpackage=1.2"]
+    result = config.setdefault("requires", [])
+    assert result == config.requires
+    assert config.requires == new_requirements
diff --git a/tests/console/Console/test_wait_bar.py b/tests/console/Console/test_wait_bar.py
index 69c9cb978..1072516b8 100644
--- a/tests/console/Console/test_wait_bar.py
+++ b/tests/console/Console/test_wait_bar.py
@@ -19,7 +19,7 @@ def test_wait_bar_done_message_non_interactive(non_interactive_console, capsys):
         pass
 
     assert capsys.readouterr().out == (
-        "Wait message... started\n" "Wait message... finished\n\n"
+        "Wait message... started\nWait message... finished\n\n"
     )
 
 
@@ -29,7 +29,7 @@ def test_wait_bar_done_message_nested_interactive(console, capsys):
         with console.wait_bar("Wait message 2...", done_message="finished"):
             pass
 
-    expected = "Wait message 2... finished\n" "Wait message 1... finished\n\n"
+    expected = "Wait message 2... finished\nWait message 1... finished\n\n"
     assert capsys.readouterr().out == expected
 
 
@@ -326,7 +326,6 @@ def test_wait_bar_alive_messages_interactive(
 ):
     """Wait Bar keep_alive prints keep alive messages."""
     for test_console in [console, non_interactive_console]:
-
         # initialization will set interval to a small number
         # update() will see time at a large number and print the message
         # then interval is reset back to a small number
diff --git a/tests/integrations/android_sdk/ADB/test_pid_exists.py b/tests/integrations/android_sdk/ADB/test_pid_exists.py
index 3d5f7fdcf..fd96b4c0a 100644
--- a/tests/integrations/android_sdk/ADB/test_pid_exists.py
+++ b/tests/integrations/android_sdk/ADB/test_pid_exists.py
@@ -23,5 +23,5 @@ def test_pid_does_not_exist(adb):
     not existing."""
     adb.run = Mock(side_effect=subprocess.CalledProcessError(returncode=1, cmd="test"))
 
-    assert not adb.pid_exists("9999") is None
+    assert not adb.pid_exists("9999")
     adb.run.assert_called_once_with("shell", "test", "-e", "/proc/9999")
diff --git a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
index 93fcc1b3e..fd1b40a30 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
@@ -10,7 +10,6 @@
 
 @pytest.fixture
 def mock_tools(tmp_path, mock_tools) -> ToolCache:
-
     # For default test purposes, assume we're on macOS x86_64
     mock_tools.host_os = "Darwin"
     mock_tools.host_arch = "x86_64"
diff --git a/tests/integrations/docker/test_DockerAppContext__verify.py b/tests/integrations/docker/test_DockerAppContext__verify.py
index a7cdd2c06..f131b8bc1 100644
--- a/tests/integrations/docker/test_DockerAppContext__verify.py
+++ b/tests/integrations/docker/test_DockerAppContext__verify.py
@@ -100,7 +100,7 @@ def test_docker_verify_fail(mock_tools, first_app_config, verify_kwargs, monkeyp
 def test_docker_image_build_fail(mock_tools, first_app_config, verify_kwargs):
     """Failure if Docker image build fails."""
     mock_tools.subprocess._subprocess.Popen.side_effect = subprocess.CalledProcessError(
-        returncode=80, cmd=["docker" "build"]
+        returncode=80, cmd=["docker", "build"]
     )
 
     with pytest.raises(
diff --git a/tests/integrations/file/test_File__download.py b/tests/integrations/file/test_File__download.py
index 405496c76..fffd57ec9 100644
--- a/tests/integrations/file/test_File__download.py
+++ b/tests/integrations/file/test_File__download.py
@@ -4,6 +4,7 @@
 import stat
 from collections.abc import Iterable, Iterator
 from pathlib import Path
+from typing import Optional
 from unittest import mock
 
 import httpx
@@ -46,7 +47,7 @@ def _make_httpx_response(
     status_code: int,
     stream: list[bytes],
     method: str = "GET",
-    headers: dict = {},
+    headers: Optional[dict] = None,
 ) -> httpx.Response:
     """Create a real ``httpx.Response`` with key methods wrapped by ``mock.Mock`` for spying.
 
@@ -55,6 +56,9 @@ def _make_httpx_response(
         response.iter_bytes
         response.headers.get
     """
+    if headers is None:
+        headers = {}
+
     response = httpx.Response(
         request=httpx.Request(
             method=method,
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index 24bb543fa..9ac9974b8 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -193,7 +193,7 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys):
     # A single call to check output
     mock_tools.subprocess.check_output.assert_called_once_with(
         [Path("/path/to/java/bin/javac"), "-version"],
-    ),
+    )
 
     # No console output
     output = capsys.readouterr()
@@ -219,7 +219,7 @@ def test_valid_provided_java_home(mock_tools, capsys):
     # A single call to check output
     mock_tools.subprocess.check_output.assert_called_once_with(
         [Path("/path/to/java/bin/javac"), "-version"],
-    ),
+    )
 
     # No console output
     output = capsys.readouterr()
@@ -306,7 +306,7 @@ def test_no_javac(mock_tools, host_os, java_home, error_type, tmp_path, capsys):
     # A single call was made to check javac
     mock_tools.subprocess.check_output.assert_called_once_with(
         [Path("/path/to/nowhere/bin/javac"), "-version"],
-    ),
+    )
 
     # Warning is shown for invalid JAVA_HOME
     output = capsys.readouterr()
@@ -348,7 +348,7 @@ def test_javac_error(mock_tools, host_os, java_home, tmp_path, capsys):
     # A single call was made to check javac
     mock_tools.subprocess.check_output.assert_called_once_with(
         [Path("/path/to/nowhere/bin/javac"), "-version"],
-    ),
+    )
 
     # Warning is shown for invalid JAVA_HOME
     output = capsys.readouterr()
@@ -388,7 +388,7 @@ def test_unparseable_javac_version(mock_tools, host_os, java_home, tmp_path, cap
     # A single call was made to check javac
     mock_tools.subprocess.check_output.assert_called_once_with(
         [Path("/path/to/nowhere/bin/javac"), "-version"],
-    ),
+    )
 
     # No console output (because Briefcase JDK exists)
     output = capsys.readouterr()
diff --git a/tests/integrations/linuxdeploy/utils.py b/tests/integrations/linuxdeploy/utils.py
index b4c94172f..8e62ded17 100644
--- a/tests/integrations/linuxdeploy/utils.py
+++ b/tests/integrations/linuxdeploy/utils.py
@@ -22,7 +22,7 @@ def create_mock_appimage(appimage_path: Path, mock_appimage_kind: str = "origina
     appimage_headers = {
         "original": bytes.fromhex("7f454c46020101004149020000000000"),
         "patched": bytes.fromhex("7f454c46020101000000000000000000"),
-        "corrupt": bytes.fromhex("%030x" % randrange(16**30)),
+        "corrupt": bytes.fromhex(f"{randrange(16**30):030x}"),
         "empty": b"",
     }
 
diff --git a/tests/integrations/subprocess/test_Subprocess__check_output.py b/tests/integrations/subprocess/test_Subprocess__check_output.py
index d225a2d14..b16bc8ddd 100644
--- a/tests/integrations/subprocess/test_Subprocess__check_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__check_output.py
@@ -374,7 +374,7 @@ def test_calledprocesserror_exception_logging_no_output(mock_sub, capsys, caplog
         mock_sub.check_output(["hello", "world"])
 
     assert capsys.readouterr().out == (
-        "\n" "Running Command:\n" "    hello world\n" "Return code: -1\n" "\n"
+        "\nRunning Command:\n    hello world\nReturn code: -1\n\n"
     )
     assert "".join(caplog) == (
         "\n"
diff --git a/tests/platforms/linux/system/test_build.py b/tests/platforms/linux/system/test_build.py
index cf8dbdaf2..3ca201438 100644
--- a/tests/platforms/linux/system/test_build.py
+++ b/tests/platforms/linux/system/test_build.py
@@ -87,8 +87,10 @@ def test_build_app(build_command, first_app, tmp_path):
 def test_build_bootstrap_failed(build_command, first_app, tmp_path):
     """If the bootstrap binary can't be compiled, an error is raised."""
     # Mock a build failure
-    build_command.tools[first_app].app_context.run.side_effect = (
-        subprocess.CalledProcessError(cmd=["make ..."], returncode=-1)
+    build_command.tools[
+        first_app
+    ].app_context.run.side_effect = subprocess.CalledProcessError(
+        cmd=["make ..."], returncode=-1
     )
 
     # Build the app; it will fail
diff --git a/tests/platforms/linux/system/test_mixin__finalize_app_config.py b/tests/platforms/linux/system/test_mixin__finalize_app_config.py
index 7507cbfb1..c512ab601 100644
--- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py
+++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py
@@ -536,3 +536,46 @@ def test_passive_mixin(first_app_config, tmp_path):
 
     # For tests of other properties merged in finalization, see
     # test_properties
+
+
+def test_cascading_distribution_properties(create_command, first_app_config):
+    """Properties should be cascading/accumulating, and vendor-level
+    properties should overwrite os-level ones when in a dictionary.
+    """
+    # Run this test as "docker"; however, the things we're testing aren't docker specific.
+    create_command.target_image = "somevendor:surprising"
+    create_command.tools.docker = MagicMock()
+    create_command.target_glibc_version = MagicMock(return_value="2.42")
+
+    # Mock a minimal response from checking /etc/os-release
+    create_command.tools.docker.check_output.return_value = "\n".join(
+        [
+            "ID=somevendor",
+            "VERSION_CODENAME=surprising",
+            "ID_LIKE=debian",
+        ]
+    )
+
+    # Modify the app config with distribution-level properties
+    first_app_config.requires = []
+    first_app_config.permission = {}
+
+    first_app_config.debian = {
+        "requires": ["debian level prop"],
+        "permission": {"prop1": "debian level prop 1", "prop2": "debian level prop 2"},
+    }
+    first_app_config.somevendor = {
+        "requires": ["vendor level prop"],
+        "permission": {"prop1": "vendor level prop 1"},
+    }
+
+    create_command.finalize_app_config(first_app_config)
+
+    # The target's config attributes have been merged into the app
+    # List properties should be accumulated
+    assert first_app_config.requires == ["debian level prop", "vendor level prop"]
+    # Dictionary properties should overwrite higher-level ones and accumulate
+    assert first_app_config.permission == {
+        "prop1": "vendor level prop 1",
+        "prop2": "debian level prop 2",
+    }
diff --git a/tests/platforms/linux/system/test_mixin__verify_python.py b/tests/platforms/linux/system/test_mixin__verify_python.py
index d38a4e968..dd0607f25 100644
--- a/tests/platforms/linux/system/test_mixin__verify_python.py
+++ b/tests/platforms/linux/system/test_mixin__verify_python.py
@@ -32,10 +32,7 @@ def test_match(create_command, first_app_config, capsys):
         [
             "python3",
             "-c",
-            (
-                "import sys; "
-                "print(f'{sys.version_info.major}.{sys.version_info.minor}')"
-            ),
+            ("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"),
         ]
     )
 
@@ -71,10 +68,7 @@ def test_mismatch(create_command, first_app_config, capsys):
         [
             "python3",
             "-c",
-            (
-                "import sys; "
-                "print(f'{sys.version_info.major}.{sys.version_info.minor}')"
-            ),
+            ("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"),
         ]
     )
 
@@ -116,9 +110,6 @@ def test_target_too_old(create_command, first_app_config):
         [
             "python3",
             "-c",
-            (
-                "import sys; "
-                "print(f'{sys.version_info.major}.{sys.version_info.minor}')"
-            ),
+            ("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"),
         ]
     )
diff --git a/tests/platforms/linux/system/test_package__deb.py b/tests/platforms/linux/system/test_package__deb.py
index 6c912bf0d..78ed7fb8f 100644
--- a/tests/platforms/linux/system/test_package__deb.py
+++ b/tests/platforms/linux/system/test_package__deb.py
@@ -324,8 +324,10 @@ def test_deb_package_failure(package_command, first_app_deb, tmp_path):
     bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
 
     # Mock a packaging failure
-    package_command.tools.app_tools[first_app_deb].app_context.run.side_effect = (
-        subprocess.CalledProcessError(cmd="dpkg-deb ...", returncode=-1)
+    package_command.tools.app_tools[
+        first_app_deb
+    ].app_context.run.side_effect = subprocess.CalledProcessError(
+        cmd="dpkg-deb ...", returncode=-1
     )
 
     # Package the app
diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py
index 75c4a9ed0..cb37a4e52 100644
--- a/tests/platforms/linux/system/test_package__pkg.py
+++ b/tests/platforms/linux/system/test_package__pkg.py
@@ -429,8 +429,10 @@ def test_pkg_package_failure(package_command, first_app_pkg, tmp_path):
     bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
 
     # Mock a packaging failure
-    package_command.tools.app_tools[first_app_pkg].app_context.run.side_effect = (
-        subprocess.CalledProcessError(cmd="makepkg ...", returncode=-1)
+    package_command.tools.app_tools[
+        first_app_pkg
+    ].app_context.run.side_effect = subprocess.CalledProcessError(
+        cmd="makepkg ...", returncode=-1
     )
 
     # Package the app; this will fail
diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py
index c1e3ccf22..2ce677756 100644
--- a/tests/platforms/linux/system/test_package__rpm.py
+++ b/tests/platforms/linux/system/test_package__rpm.py
@@ -576,8 +576,10 @@ def test_rpm_package_failure(package_command, first_app_rpm, tmp_path):
     bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
 
     # Mock a packaging failure
-    package_command.tools.app_tools[first_app_rpm].app_context.run.side_effect = (
-        subprocess.CalledProcessError(cmd="rpmbuild ...", returncode=-1)
+    package_command.tools.app_tools[
+        first_app_rpm
+    ].app_context.run.side_effect = subprocess.CalledProcessError(
+        cmd="rpmbuild ...", returncode=-1
     )
 
     # Package the app; this will fail
diff --git a/tests/platforms/linux/system/test_run.py b/tests/platforms/linux/system/test_run.py
index 829954709..289d384af 100644
--- a/tests/platforms/linux/system/test_run.py
+++ b/tests/platforms/linux/system/test_run.py
@@ -359,7 +359,7 @@ def test_run_console_app(run_command, first_app, tmp_path):
     run_command.run_app(first_app, test_mode=False, passthrough=[])
 
     # The process was started
-    run_command.tools.subprocess.run.mock_calls == [
+    assert run_command.tools.subprocess.run.mock_calls == [
         mock.call(
             [
                 tmp_path
@@ -388,7 +388,7 @@ def test_run_console_app_passthrough(run_command, first_app, tmp_path):
     run_command.run_app(first_app, test_mode=False, passthrough=["foo", "--bar"])
 
     # The process was started
-    run_command.tools.subprocess.run.mock_calls == [
+    assert run_command.tools.subprocess.run.mock_calls == [
         mock.call(
             [
                 tmp_path
@@ -420,7 +420,7 @@ def test_run_console_app_failed(run_command, first_app, sub_kw, tmp_path):
         run_command.run_app(first_app, test_mode=False, passthrough=[])
 
     # The run command was still invoked
-    run_command.tools.subprocess.run.mock_calls == [
+    assert run_command.tools.subprocess.run.mock_calls == [
         mock.call(
             [
                 tmp_path
diff --git a/tests/platforms/macOS/app/package/test_ditto.py b/tests/platforms/macOS/app/package/test_ditto.py
index b615c59cf..c5f5d60cc 100644
--- a/tests/platforms/macOS/app/package/test_ditto.py
+++ b/tests/platforms/macOS/app/package/test_ditto.py
@@ -36,7 +36,13 @@ def test_ditto(
 
     # The archive contains the app as the only top level element.
     with ZipFile(archive_path) as archive:
-        assert sorted(archive.namelist()) == [
+        # zip file can include a “__MACOSX” folder for each document that contains information about
+        # the file useful for Finder and will not be in the unzipped set of files
+        archived_files = [
+            fn for fn in archive.namelist() if not fn.startswith("__MACOSX/")
+        ]
+
+        assert sorted(archived_files) == [
             "First App.app/",
             "First App.app/Contents/",
             "First App.app/Contents/Frameworks/",
diff --git a/tests/platforms/macOS/app/package/test_package_pkg.py b/tests/platforms/macOS/app/package/test_package_pkg.py
index caebb3ea3..32f4da502 100644
--- a/tests/platforms/macOS/app/package/test_package_pkg.py
+++ b/tests/platforms/macOS/app/package/test_package_pkg.py
@@ -59,7 +59,7 @@ def test_gui_app(
     package_command.package_app(first_app_with_binaries)
 
     # Two signing identities were selected; the second is an installer identity
-    package_command.select_identity.mock_calls == [
+    assert package_command.select_identity.mock_calls == [
         mock.call(identity=None),
         mock.call(identity=None, app_identity=sekrit_identity),
     ]
@@ -264,7 +264,7 @@ def test_console_app(
     package_command.package_app(first_app_with_binaries)
 
     # Two signing identities were selected; the second is an installer identity
-    package_command.select_identity.mock_calls == [
+    assert package_command.select_identity.mock_calls == [
         mock.call(identity=None),
         mock.call(identity=None, app_identity=sekrit_identity),
     ]
diff --git a/tests/platforms/macOS/app/test_build.py b/tests/platforms/macOS/app/test_build.py
index 825f3f51f..6e6baea1d 100644
--- a/tests/platforms/macOS/app/test_build.py
+++ b/tests/platforms/macOS/app/test_build.py
@@ -22,6 +22,9 @@ def build_command(tmp_path):
     return command
 
 
+@pytest.mark.parametrize(
+    "extra_args", [{}, {"adhoc_sign": True}, {"adhoc_sign": False}]
+)
 @pytest.mark.parametrize("universal_build", [True, False])
 @pytest.mark.parametrize("pre_existing", [True, False])
 @pytest.mark.parametrize("console_app", [True, False])
@@ -31,6 +34,7 @@ def test_build_app(
     console_app,
     universal_build,
     pre_existing,
+    extra_args,
     tmp_path,
 ):
     """A macOS App is ad-hoc signed as part of the build process."""
@@ -49,7 +53,9 @@ def test_build_app(
     build_command.ensure_thin_binary = mock.Mock()
 
     # Build the app
-    build_command.build_app(first_app_with_binaries, test_mode=False)
+    base_args = {"test_mode": False}
+    kwargs = base_args | extra_args
+    build_command.build_app(first_app_with_binaries, **kwargs)
 
     # The stub binary has been renamed
     assert not (exec_path / "Stub").is_file()
@@ -64,11 +70,17 @@ def test_build_app(
             arch="gothic",
         )
 
-    # A request has been made to sign the app
-    build_command.sign_app.assert_called_once_with(
-        app=first_app_with_binaries,
-        identity=SigningIdentity(),
-    )
+    # Verify that a request has been made to sign the app, but only when it is as part of a direct build command
+    if "adhoc_sign" in kwargs:
+        # build command was called as part of package command.
+        # expect no signing now since it will happen during packaging
+        build_command.sign_app.assert_not_called()
+    else:
+        # build command was called directly so expect signing
+        build_command.sign_app.assert_called_once_with(
+            app=first_app_with_binaries,
+            identity=SigningIdentity(),
+        )
 
     # No request to select a signing identity was made
     build_command.select_identity.assert_not_called()
diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py
index 4d1015775..ab2559005 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -731,7 +731,7 @@ def _codesign(args, **kwargs):
 
     # There has been at least 1 call to sign files. We can't know how many are
     # actually signed, as threads are involved.
-    dummy_command.tools.subprocess.run.call_count > 0
+    assert dummy_command.tools.subprocess.run.call_count > 0
 
     # Output only happens if in debug mode.
     output = capsys.readouterr().out
diff --git a/tests/platforms/macOS/test_XcodeBuildFilter.py b/tests/platforms/macOS/test_XcodeBuildFilter.py
index 9b4cbd27f..471298f27 100644
--- a/tests/platforms/macOS/test_XcodeBuildFilter.py
+++ b/tests/platforms/macOS/test_XcodeBuildFilter.py
@@ -1,6 +1,3 @@
-# This file contains raw log lines, which are extremely long. Turn of the flake8 rule
-# for line length.
-# flake8: noqa: E501
 import pytest
 
 from briefcase.platforms.macOS.filters import XcodeBuildFilter
diff --git a/tests/platforms/web/static/test_build.py b/tests/platforms/web/static/test_build.py
index 73109ac0b..cb927e9c4 100644
--- a/tests/platforms/web/static/test_build.py
+++ b/tests/platforms/web/static/test_build.py
@@ -48,7 +48,7 @@ def mock_run(*args, **kwargs):
                 extra_content=[
                     ("dependency/static/style.css", "span { margin: 10px; }\n"),
                 ],
-            ),
+            )
         elif args[0][5] == "pip":
             create_wheel(
                 bundle_path / "www/static/wheels",
@@ -56,14 +56,14 @@ def mock_run(*args, **kwargs):
                 extra_content=[
                     ("dependency/static/style.css", "div { margin: 10px; }\n"),
                 ],
-            ),
+            )
             create_wheel(
                 bundle_path / "www/static/wheels",
                 "other",
                 extra_content=[
                     ("other/static/style.css", "div { padding: 10px; }\n"),
                 ],
-            ),
+            )
         else:
             raise ValueError("Unknown command")
 
@@ -357,7 +357,7 @@ def mock_run(*args, **kwargs):
                 extra_content=[
                     ("dependency/static/style.css", "span { margin: 10px; }\n"),
                 ],
-            ),
+            )
         elif args[0][5] == "pip":
             pass
         else:
diff --git a/tests/platforms/windows/app/test_build.py b/tests/platforms/windows/app/test_build.py
index adcc2043e..94dc3611f 100644
--- a/tests/platforms/windows/app/test_build.py
+++ b/tests/platforms/windows/app/test_build.py
@@ -226,14 +226,16 @@ def test_build_app_without_any_digital_signatures(
     but app build succeeds."""
     build_command.tools.windows_sdk = windows_sdk
 
-    build_command.tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
-        returncode=1,
-        cmd="signtool.exe remove -s app.exe",
-        output="""
+    build_command.tools.subprocess.check_output.side_effect = (
+        subprocess.CalledProcessError(
+            returncode=1,
+            cmd="signtool.exe remove -s app.exe",
+            output="""
     Number of errors: 1
     SignTool Error: CryptSIPRemoveSignedDataMsg returned error: 0x00000057
             The parameter is incorrect.
 """,
+        )
     )
 
     build_command.build_app(first_app_templated)
@@ -293,13 +295,15 @@ def test_build_app_error_remove_signature(
     raises an unexpected error, then the build fails."""
     build_command.tools.windows_sdk = windows_sdk
 
-    build_command.tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
-        returncode=1,
-        cmd="signtool.exe remove /s filepath",
-        output="""
+    build_command.tools.subprocess.check_output.side_effect = (
+        subprocess.CalledProcessError(
+            returncode=1,
+            cmd="signtool.exe remove /s filepath",
+            output="""
     Number of errors: 1
     Unknown and unexpected error
 """,
+        )
     )
 
     error_message = (
diff --git a/tests/platforms/windows/visualstudio/test_package.py b/tests/platforms/windows/visualstudio/test_package.py
index d7863184c..8f3f161a0 100644
--- a/tests/platforms/windows/visualstudio/test_package.py
+++ b/tests/platforms/windows/visualstudio/test_package.py
@@ -72,7 +72,7 @@ def test_package_msi(package_command, first_app_config, tmp_path):
                     "WixUIExtension",
                     "-arch",
                     "x64",
-                    f'-dSourceDir={os.fsdecode(Path("x64/Release"))}',
+                    f"-dSourceDir={os.fsdecode(Path('x64/Release'))}",
                     "first-app.wxs",
                     "first-app-manifest.wxs",
                 ],