Repository: beeware/briefcase
Pull request #2300 (2025-05-20 12:37:18)

Feature/fix 2151
<!--- Describe your changes in detail -->
Updated the unbuilt_executable_path method in base.py to seperate the windows builds from others.
updated tests that where affected by this issue  and added a test to specificaly verify that the issue is resolved
<!--- What problem does this change solve? -->
fix issue #2151 -  macos and linux apps can't contail '.' in the name
<!--- If this PR relates to an issue, include Refs #XXX or Fixes #XXX -->
#2151 

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [ X] All new features have been tested
- [ X] All new features have been documented
- [ X] I have read the **CONTRIBUTING.md** file
- [ X] I will abide by the code of conduct



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5ab56f02..13b3ffb0 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 00000000..4886c19d
--- /dev/null
+++ b/changes/1099.bugfix.rst
@@ -0,0 +1,1 @@
+A redundant signing phase has been removed when packaging an apps for macOS.
diff --git a/changes/1669.misc.rst b/changes/1669.misc.rst
new file mode 100644
index 00000000..e482b7f2
--- /dev/null
+++ b/changes/1669.misc.rst
@@ -0,0 +1,1 @@
+Warnings about outdated Python versions will now be shown.
diff --git a/changes/2151.bugfix.rst b/changes/2151.bugfix.rst
new file mode 100644
index 00000000..d2b57280
--- /dev/null
+++ b/changes/2151.bugfix.rst
@@ -0,0 +1,1 @@
+App 'formal_name' can now except names with '.' characters
diff --git a/changes/2292.misc.rst b/changes/2292.misc.rst
new file mode 100644
index 00000000..96563ae4
--- /dev/null
+++ b/changes/2292.misc.rst
@@ -0,0 +1,1 @@
+Updated from black and flake8 to ruff
diff --git a/docs/conf.py b/docs/conf.py
index dfdc5126..3c95ebc3 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 7643a717..fb193a40 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/pyproject.toml b/pyproject.toml
index b13e81e9..9323fdd3 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/base.py b/src/briefcase/commands/base.py
index 4a19b58c..33682e9e 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -11,6 +11,7 @@ import subprocess
 import sys
 from abc import ABC, abstractmethod
 from argparse import RawDescriptionHelpFormatter
+from datetime import datetime
 from pathlib import Path
 from typing import Any
 
@@ -170,6 +171,7 @@ class BaseCommand(ABC):
             console=console,
             base_path=self.data_path / "tools",
         )
+        self.validate_python_version()
 
         # Immediately add tools that must be always available
         Subprocess.verify(tools=self.tools)
@@ -287,6 +289,36 @@ a custom location for Briefcase's tools.
 """
             )
 
+    def validate_python_version(self) -> bool:
+        """Validate the system's Python version is within its end-of-life date.
+
+        :return: True if the Python version is within its end-of-life date.
+        """
+        # Python EOL dates are provided on: https://devguide.python.org/versions/
+        # EOL for Python 3.8 was October 2024. Assuming Python releases occur on the
+        # same yearly cadence, the EOL for Python 3.x is October of 2024 + (x - 8)
+        EOL_year = 2024 + (sys.version_info.minor - 8)
+        if datetime.today() > datetime(EOL_year, 10, 1):
+            self.console.warning(
+                f"""
+*************************************************************************
+** WARNING: Your Python version is unsupported!                        **
+*************************************************************************
+
+    The version of Python you are using ({platform.python_version()}) is past its
+    end of life. As a result, it is highly likely your Briefcase
+    version is also out of date.
+
+    See https://devguide.python.org/versions/ for details on currently
+    supported Python versions.
+
+*************************************************************************
+"""
+            )
+            return False
+
+        return True
+
     def _command_factory(self, command_name: str):
         """Command factory for the current platform and format.
 
@@ -401,9 +433,13 @@ a custom location for Briefcase's tools.
 
         :param app: The app config
         """
-        return self.binary_executable_path(app).parent / (
-            "Stub" + self.binary_executable_path(app).suffix
-        )
+
+        if sys.platform == "win32":  # pragma: no-cover-if-not-windows
+            suffix = self.binary_executable_path(app).suffix
+        else:  # pragma: no-cover-if-is-windows
+            suffix = ""
+
+        return self.binary_executable_path(app).parent / f"Stub{suffix}"
 
     def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
         """Load the ``briefcase.toml`` file provided by the app template.
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 550f7af4..23d58f32 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -587,7 +587,7 @@ class NewCommand(BaseCommand):
             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 483608af..5cebcfd1 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -165,7 +165,6 @@ class PackageCommand(BaseCommand):
         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 7befb8d7..06c10f36 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -620,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 e49d44f7..65391ac6 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 import defaultdict
 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 @@ class ToolCache(Mapping):
         # 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 98bd1462..032eb31e 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -6,7 +6,7 @@ import socket
 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 @@ Delete this file and run Briefcase again.
 
         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/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index fc37e426..586685dc 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -912,8 +912,7 @@ class macOSPackageMixin(macOSSigningMixin):
         # 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 @@ password:
                                 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 d4da6651..bf08e912 100644
--- a/src/briefcase/platforms/macOS/app.py
+++ b/src/briefcase/platforms/macOS/app.py
@@ -127,9 +127,14 @@ class macOSAppBuildCommand(
         # 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 5cecc44d..15909ba6 100644
--- a/src/briefcase/platforms/web/static.py
+++ b/src/briefcase/platforms/web/static.py
@@ -197,7 +197,7 @@ class StaticWebBuildCommand(StaticWebMixin, BuildCommand):
             # 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/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py
index b1c5f717..83772a84 100644
--- a/src/briefcase/platforms/windows/app.py
+++ b/src/briefcase/platforms/windows/app.py
@@ -56,7 +56,12 @@ class WindowsAppBuildCommand(WindowsAppMixin, BuildCommand):
 
         # Move the stub binary in to the final executable location
         unbuilt_binary_path = self.unbuilt_executable_path(app)
-        if unbuilt_binary_path.exists():
+
+        # We can run this test on non-Windows platforms, but when we run on
+        # non-windows platforms, the ".exe" suffix doesn't exist, so it doesn't
+        # hit this branch. That's not actually a problem, as long as we *are*
+        # hitting the branch under Windows.
+        if unbuilt_binary_path.exists():  # pragma: no-cover-if-not-windows
             with self.console.wait_bar("Renaming stub binary..."):
                 unbuilt_binary_path.rename(self.binary_executable_path(app))
 
diff --git a/tox.ini b/tox.ini
index a620fc3c..cc16ba20 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/base/test_end_of_life.py b/tests/commands/base/test_end_of_life.py
new file mode 100644
index 00000000..04637c75
--- /dev/null
+++ b/tests/commands/base/test_end_of_life.py
@@ -0,0 +1,42 @@
+import sys
+from collections import namedtuple
+from datetime import datetime
+from unittest import mock
+
+import pytest
+
+import briefcase
+
+
+def _create_version_info(major, minor, patch=0):
+    Version = namedtuple("Version", "major minor patch")
+    return Version(major, minor, patch)
+
+
+@pytest.mark.parametrize(
+    "minor_version, today, is_valid",
+    [
+        (8, datetime(2025, 10, 1), False),  # after EOL
+        (9, datetime(2025, 10, 1), True),  # on EOL
+        (9, datetime(2025, 10, 2), False),  # after EOL
+        (10, datetime(2026, 9, 30), True),  # before EOL
+        (10, datetime(2026, 10, 2), False),  # after EOL
+        (14, datetime(2030, 9, 30), True),  # before EOL
+        (14, datetime(2030, 10, 2), False),  # after EOL
+    ],
+)
+def test_valid_python_version(
+    minor_version, today, is_valid, base_command, monkeypatch
+):
+    """A warning is produced if the Python version is past its EOL"""
+
+    version_info = _create_version_info(3, minor_version)
+    sys_mock = mock.MagicMock(wraps=sys)
+    sys_mock.version_info = version_info
+    monkeypatch.setattr(briefcase.commands.base, "sys", sys_mock)
+
+    datetime_mock = mock.MagicMock(wraps=datetime)
+    datetime_mock.today.return_value = today
+    monkeypatch.setattr(briefcase.commands.base, "datetime", datetime_mock)
+
+    assert base_command.validate_python_version() is is_valid
diff --git a/tests/commands/build/conftest.py b/tests/commands/build/conftest.py
index 2caeae40..32390ecb 100644
--- a/tests/commands/build/conftest.py
+++ b/tests/commands/build/conftest.py
@@ -3,7 +3,8 @@ import pytest
 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 39b40e25..fda127ef 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 61dbae90..0e586642 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/conftest.py b/tests/commands/create/conftest.py
index 4c723920..2e08b3d1 100644
--- a/tests/commands/create/conftest.py
+++ b/tests/commands/create/conftest.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import sys
 from unittest import mock
 
 import pytest
@@ -77,8 +78,14 @@ class DummyCreateCommand(CreateCommand):
             ("arch", self.tools.host_arch),
         ]
 
+    def exe_name(self, app_name=None):
+        if sys.platform == "win32":
+            return f"{'Stub' if app_name is None else app_name}.exe"
+        else:
+            return "Stub" if app_name is None else app_name
+
     def binary_path(self, app):
-        return self.bundle_path(app) / f"{app.app_name}.bin"
+        return self.bundle_path(app) / self.exe_name(app.formal_name)
 
     # Hard code the python version to make testing easier.
     @property
@@ -173,7 +180,7 @@ class TrackingCreateCommand(DummyCreateCommand):
     def install_stub_binary(self, app):
         self.actions.append(("stub", app.app_name))
         # A mock version of a stub binary
-        create_file(self.bundle_path(app) / "Stub.bin", "stub binary")
+        create_file(self.binary_path(app), "stub binary")
 
     def cleanup_app_content(self, app):
         self.actions.append(("cleanup", app.app_name))
diff --git a/tests/commands/create/test_cleanup_stub_binary.py b/tests/commands/create/test_cleanup_stub_binary.py
index fac85cfe..3f959fef 100644
--- a/tests/commands/create/test_cleanup_stub_binary.py
+++ b/tests/commands/create/test_cleanup_stub_binary.py
@@ -14,16 +14,19 @@ from ...utils import create_file
 )
 def test_cleanup_stubs(create_command, unbuilt, built, myapp, tmp_path):
     """If an unbuilt stub already exists, it is cleaned up."""
-
     # Mock a existing stubs
     if unbuilt:
         create_file(
-            tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin",
+            tmp_path
+            / "base_path/build/my-app/tester/dummy"
+            / create_command.exe_name("Stub"),
             "Unbuilt stub",
         )
     if built:
         create_file(
-            tmp_path / "base_path/build/my-app/tester/dummy/my-app.bin",
+            tmp_path
+            / "base_path/build/my-app/tester/dummy"
+            / create_command.exe_name(myapp.formal_name),
             "Built stub",
         )
 
@@ -31,5 +34,13 @@ def test_cleanup_stubs(create_command, unbuilt, built, myapp, tmp_path):
     create_command.cleanup_stub_binary(myapp)
 
     # No matter the starting state, the stubs don't exist after cleanup.
-    assert not (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
-    assert not (tmp_path / "base_path/build/my-app/tester/dummy/my-app.bin").exists()
+    assert not (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
+    assert not (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name(myapp.formal_name)
+    ).exists()
diff --git a/tests/commands/create/test_create_app.py b/tests/commands/create/test_create_app.py
index a5aab5e3..a91f63d4 100644
--- a/tests/commands/create/test_create_app.py
+++ b/tests/commands/create/test_create_app.py
@@ -26,7 +26,11 @@ def test_create_app(tracking_create_command, tmp_path):
     # New app content has been created
     assert (tmp_path / "base_path/build/first/tester/dummy/new").exists()
     # A stub binary has *not* been created
-    assert not (tmp_path / "base_path/build/first/tester/dummy/Stub.bin").exists()
+    assert not (
+        tmp_path
+        / "base_path/build/first/tester/dummy"
+        / tracking_create_command.exe_name("first")
+    ).exists()
 
 
 def test_create_existing_app_overwrite(tracking_create_command, tmp_path):
@@ -168,11 +172,13 @@ def test_create_app_not_supported(tracking_create_command, tmp_path):
 def test_create_app_with_stub(tracking_create_command, tmp_path):
     """If an app template defines a stub revision, the stub will be created."""
     # Add an entry to the path index indicating a stub is required
-    tracking_create_command._briefcase_toml[tracking_create_command.apps["first"]] = {
+    first_app = tracking_create_command.apps["first"]
+
+    tracking_create_command._briefcase_toml[first_app] = {
         "paths": {"stub_binary_revision": "b1"}
     }
 
-    tracking_create_command.create_app(tracking_create_command.apps["first"])
+    tracking_create_command.create_app(first_app)
 
     # Input wasn't required by the user
     assert tracking_create_command.console.prompts == []
@@ -192,4 +198,8 @@ def test_create_app_with_stub(tracking_create_command, tmp_path):
 
     # New app content and stub binary has been created
     assert (tmp_path / "base_path/build/first/tester/dummy/new").exists()
-    assert (tmp_path / "base_path/build/first/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/first/tester/dummy"
+        / tracking_create_command.exe_name("first")
+    ).exists()
diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py
index 8acf13fd..aa439b1c 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/commands/create/test_install_stub_binary.py b/tests/commands/create/test_install_stub_binary.py
index 78251fcf..4cca1f94 100644
--- a/tests/commands/create/test_install_stub_binary.py
+++ b/tests/commands/create/test_install_stub_binary.py
@@ -16,6 +16,57 @@ from briefcase.exceptions import (
 from ...utils import create_file, create_tgz_file, create_zip_file, mock_zip_download
 
 
+@pytest.mark.parametrize(
+    "formal_name",
+    ["App 4.2", "App 4.2.4", "App .", "App A.B", "App 42."],
+)
+def test_install_stub_binary_formal_name(
+    create_command,
+    myapp,
+    formal_name,
+    stub_binary_revision_path_index,
+    tmp_path,
+):
+    # Mock the app type
+    myapp.formal_name = formal_name
+    console_app = True
+    myapp.console_app = console_app
+    stub_name = "Console-Stub" if console_app else "GUI-Stub"
+
+    # Mock download.file to return a stub binary
+    create_command.tools.file.download = mock.MagicMock(
+        side_effect=mock_zip_download(
+            f"{stub_name}-3.X-b37.zip",
+            [(create_command.exe_name("Stub"), "stub binary")],
+        )
+    )
+    # Wrap shutil so we can confirm that unpack is called
+    create_command.tools.shutil = mock.MagicMock(wraps=shutil)
+
+    # Install the stub binary
+    create_command.install_stub_binary(myapp)
+
+    # Confirm the right URL was used
+    create_command.tools.file.download.assert_called_with(
+        download_path=create_command.data_path / "stub",
+        url=f"https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/{stub_name}-3.X-b37.zip",
+        role="stub binary",
+    )
+
+    # Confirm the right file was unpacked
+    create_command.tools.shutil.unpack_archive.assert_called_with(
+        filename=tmp_path / f"data/stub/{stub_name}-3.X-b37.zip",
+        extract_dir=tmp_path / "base_path/build/my-app/tester/dummy",
+    )
+
+    # Confirm that the full path to the stub file has been unpacked.
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
+
+
 @pytest.mark.parametrize("console_app", [True, False])
 def test_install_stub_binary(
     create_command,
@@ -33,10 +84,9 @@ def test_install_stub_binary(
     create_command.tools.file.download = mock.MagicMock(
         side_effect=mock_zip_download(
             f"{stub_name}-3.X-b37.zip",
-            [("Stub.bin", "stub binary")],
+            [(create_command.exe_name("Stub"), "stub binary")],
         )
     )
-
     # Wrap shutil so we can confirm that unpack is called
     create_command.tools.shutil = mock.MagicMock(wraps=shutil)
 
@@ -57,7 +107,11 @@ def test_install_stub_binary(
     )
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
 
 @pytest.mark.parametrize("console_app", [True, False])
@@ -77,7 +131,7 @@ def test_install_stub_binary_unpack_failure(
     create_command.tools.file.download = mock.MagicMock(
         side_effect=mock_zip_download(
             f"{stub_name}-3.X-b37.zip",
-            [("Stub.bin", "stub binary")],
+            [(create_command.exe_name(myapp.formal_name), "stub binary")],
         )
     )
 
@@ -123,7 +177,7 @@ def test_install_pinned_stub_binary(
     create_command.tools.file.download = mock.MagicMock(
         side_effect=mock_zip_download(
             f"{stub_name}-3.X-b42.zip",
-            [("Stub.bin", "stub binary")],
+            [(create_command.exe_name("Stub"), "stub binary")],
         )
     )
 
@@ -147,7 +201,11 @@ def test_install_pinned_stub_binary(
     )
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
 
 def test_install_stub_binary_missing(
@@ -186,7 +244,7 @@ def test_install_custom_stub_binary_url(
     create_command.tools.file.download = mock.MagicMock(
         side_effect=mock_zip_download(
             "My-Stub.zip",
-            [("Stub.bin", "stub binary")],
+            [(create_command.exe_name("Stub"), "stub binary")],
         )
     )
 
@@ -212,7 +270,11 @@ def test_install_custom_stub_binary_url(
     )
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
 
 def test_install_custom_stub_binary_file(
@@ -245,7 +307,11 @@ def test_install_custom_stub_binary_file(
     create_command.tools.shutil.unpack_archive.assert_not_called()
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
 
 def test_install_custom_stub_binary_zip(
@@ -261,7 +327,7 @@ def test_install_custom_stub_binary_zip(
     # Write a temporary stub zip file
     stub_file = create_zip_file(
         tmp_path / "custom/stub.zip",
-        [("Stub.bin", "Custom stub")],
+        [(create_command.exe_name("Stub"), "Custom stub")],
     )
 
     # Modify download.file to return the temp zipfile
@@ -284,7 +350,11 @@ def test_install_custom_stub_binary_zip(
     )
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
 
 @pytest.mark.parametrize("stub_filename", ("stub.tar", "stub.tar.gz"))
@@ -302,7 +372,7 @@ def test_install_custom_stub_binary_tar(
     # Write a temporary stub zip file
     stub_file = create_tgz_file(
         tmp_path / f"custom/{stub_filename}",
-        [("Stub.bin", "Custom stub")],
+        [(create_command.exe_name("Stub"), "Custom stub")],
     )
 
     # Modify download.file to return the temp zipfile
@@ -326,7 +396,11 @@ def test_install_custom_stub_binary_tar(
     )
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
 
 def test_install_custom_stub_binary_with_revision(
@@ -345,7 +419,7 @@ def test_install_custom_stub_binary_with_revision(
     # Write a temporary stub zip file
     stub_file = create_zip_file(
         tmp_path / "custom/stub.zip",
-        [("Stub.bin", "Custom stub")],
+        [(create_command.exe_name("Stub"), "Custom stub")],
     )
 
     # Modify download.file to return the temp zipfile
@@ -368,7 +442,11 @@ def test_install_custom_stub_binary_with_revision(
     )
 
     # Confirm that the full path to the stub file has been unpacked.
-    assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists()
+    assert (
+        tmp_path
+        / "base_path/build/my-app/tester/dummy"
+        / create_command.exe_name("Stub")
+    ).exists()
 
     # A warning about the stub revision was generated.
     assert "stub binary revision will be ignored." in capsys.readouterr().out
diff --git a/tests/console/Console/test_wait_bar.py b/tests/console/Console/test_wait_bar.py
index 69c9cb97..1072516b 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 3d5f7fdc..fd96b4c0 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 93fcc1b3..fd1b40a3 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 @@ from briefcase.integrations.base import ToolCache
 
 @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 a7cdd2c0..f131b8bc 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 405496c7..fffd57ec 100644
--- a/tests/integrations/file/test_File__download.py
+++ b/tests/integrations/file/test_File__download.py
@@ -4,6 +4,7 @@ import shutil
 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 24bb543f..9ac9974b 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 b4c94172..8e62ded1 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 d225a2d1..b16bc8dd 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 cf8dbdaf..3ca20143 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__verify_python.py b/tests/platforms/linux/system/test_mixin__verify_python.py
index d38a4e96..dd0607f2 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 6c912bf0..78ed7fb8 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 75c4a9ed..cb37a4e5 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 c1e3ccf2..2ce67775 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 82995470..289d384a 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_package_pkg.py b/tests/platforms/macOS/app/package/test_package_pkg.py
index caebb3ea..32f4da50 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 825f3f51..6e6baea1 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 4d101577..ab255900 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -731,7 +731,7 @@ def test_sign_app_with_failure(
 
     # 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 9b4cbd27..471298f2 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 73109ac0..cb927e9c 100644
--- a/tests/platforms/web/static/test_build.py
+++ b/tests/platforms/web/static/test_build.py
@@ -48,7 +48,7 @@ def test_build_app(build_command, first_app_generated, logging_level, tmp_path):
                 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 test_build_app(build_command, first_app_generated, logging_level, tmp_path):
                 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 test_build_app_no_requirements(build_command, first_app_generated, tmp_path)
                 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 adcc2043..4cbfff99 100644
--- a/tests/platforms/windows/app/test_build.py
+++ b/tests/platforms/windows/app/test_build.py
@@ -1,6 +1,7 @@
 import re
 import shutil
 import subprocess
+import sys
 from pathlib import Path
 from unittest import mock
 
@@ -94,6 +95,7 @@ def test_verify_with_windows_sdk(build_command, windows_sdk, monkeypatch):
     assert isinstance(build_command.tools.windows_sdk, WindowsSDK)
 
 
+@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows")
 @pytest.mark.parametrize("pre_existing", [True, False])
 @pytest.mark.parametrize("console_app", [True, False])
 def test_build_app_without_windows_sdk(
@@ -226,14 +228,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 +297,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 d7863184..8f3f161a 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",
                 ],