Repository: beeware/briefcase
Pull request #964 (2022-11-08 23:34:39)

Add `ToolCache` typing
## Changes 
Added typing support for `ToolCache` for QoL.
Specifically:
- Enumerate each Tool and its type in `ToolCache`
- Type `tools` as `ToolCache` each place it is introduced in a Tool

## Rationale
Personally, I really like my IDE's hints and auto-completion. The current implementation of `ToolCache` prevents any of these luxuries because its attributes cannot be inferred.
- This is most noticeable for tools in `briefcase.integrations` because `self.tools` cannot be inferred as a `ToolCache`.
- Within commands, `self.tools` is at least inferred to be a `ToolCache` (at least for the subclasses that aren't mix-ins) but since none of the Tools are actually defined within `ToolCache`, none of them can be inferred.

## Tradeoff
Unfortunately, this typing requires jumping through a few hoops and a bit more maintenance due to new dependencies within `ToolCache` itself....but I think its ultimately a win.

Also interested to know if this is just a PyCharm problem or if VS is in the same boat.

## 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


rmartin16: ## Explanation of the Hoop Jumping
The primary problem with typing for `ToolCache` is circular imports. To avoid this, we have to make sure none of the typing is evaluated at runtime.

The `from __future__ import annotations` import makes all typing annotations be interpreted as strings. So `android_sdk: AndroidSDK` is interpreted as `android_sdk: "AndroidSDK"`, for example. This avoids the requirement to actually import these definitions during runtime just for typing.

The `if TYPE_CHECKING: <do imports>` prevents actually doing the imports when Briefcase runs but allows type checkers and IDE's know what the types are. 

Feedback is welcome.
rmartin16: @freakboy3742,
My primary impetus for this change is to avoid the common workflow I have now. For instance, I want to use a method from `os` but I can't remember its name. So I literally have to write `import os; os.<blah>` to find it, and then I can use it with `self.tools.os`. This extrapolates to everything in `ToolCache` though because nothing can have it types inferred.

This is purely efficiency for me.

As for actually enforcing typing.....I'm less interested in that goal....or in achieving some sort of typing coverage. To that end, I have been experimenting with typing for `Command` and `AppConfig`. I haven't landed on a good strategy yet though that isn't over-onerous to maintain.

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4fa8ae5a..ea9eb55e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,15 +67,9 @@ jobs:
     needs: [pre-commit, towncrier, package]
     strategy:
       matrix:
-        platform: ["macos", "ubuntu", "windows"]
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev"]
-        include:
-          - experimental: false
-
-          - python-version: "3.12-dev"
-            experimental: true
+        platform: ['macos', 'ubuntu', 'windows']
+        python-version: ['3.8', '3.9', '3.10', '3.11']
     runs-on: ${{ matrix.platform }}-latest
-    continue-on-error: ${{ matrix.experimental }}
     steps:
     - uses: actions/checkout@v3.1.0
       with:
diff --git a/changes/886.feature.rst b/changes/886.feature.rst
index 7660dda3..2cabe918 100644
--- a/changes/886.feature.rst
+++ b/changes/886.feature.rst
@@ -1,1 +1,1 @@
-The Dockerfile used to build AppImages can now include user-provided container setup instructions.
+The Dockerfile used to build AppImages can now include arbitrary additional instructions.
diff --git a/changes/964.misc.rst b/changes/964.misc.rst
new file mode 100644
index 00000000..dca54df5
--- /dev/null
+++ b/changes/964.misc.rst
@@ -0,0 +1,1 @@
+The ``ToolCache`` and its tools are now typed to better support IDE hints and auto-completion.
diff --git a/changes/965.feature.rst b/changes/965.feature.rst
deleted file mode 100644
index 89008bc3..00000000
--- a/changes/965.feature.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Support for Python 3.12 was added.
diff --git a/docs/reference/platforms/linux/appimage.rst b/docs/reference/platforms/linux/appimage.rst
index b08f8234..70909dad 100644
--- a/docs/reference/platforms/linux/appimage.rst
+++ b/docs/reference/platforms/linux/appimage.rst
@@ -142,21 +142,6 @@ used to build your Python app. For example, any dependencies that cannot be
 configured with ``apt-get`` could be installed. ``dockerfile_extra_content`` is
 string literal that will be added verbatim to the end of the project Dockerfile.
 
-Any Dockerfile instructions added by ``dockerfile_extra_content`` will be
-executed as the ``brutus`` user, rather than the ``root`` user. If you need to
-perform container setup operations as ``root``, switch the container's user to
-``root``, perform whatever operations are required, then switch back to the
-``brutus`` user - e.g.::
-
-    dockerfile_extra_content = """
-    RUN <first command run as brutus>
-
-    USER root
-    RUN <second command run as root>
-
-    USER brutus
-    """
-
 Runtime issues with AppImages
 =============================
 
diff --git a/pyproject.toml b/pyproject.toml
index 59ab2181..7dbbf1b1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,8 @@ precision = 1
 exclude_lines = [
     "pragma: no cover",
     "@(abc\\.)?abstractmethod",
-    "NotImplementedError\\(\\)"
+    "NotImplementedError\\(\\)",
+    "if TYPE_CHECKING:",
 ]
 
 [tool.isort]
diff --git a/setup.cfg b/setup.cfg
index a2285c6f..bf0f039f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,6 @@ classifiers =
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
     Programming Language :: Python :: 3.11
-    Programming Language :: Python :: 3.12
     Programming Language :: Python :: 3 :: Only
     Topic :: Software Development
     Topic :: Utilities
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index e0cbbffa..8550e8cd 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -26,6 +26,8 @@ from briefcase.exceptions import (
     NetworkFailure,
 )
 from briefcase.integrations.base import ToolCache
+from briefcase.integrations.download import Download
+from briefcase.integrations.subprocess import Subprocess
 
 
 class InvalidTemplateRepository(BriefcaseCommandError):
@@ -146,6 +148,10 @@ class BaseCommand(ABC):
             base_path=self.data_path / "tools",
         )
 
+        # Immediately add tools that must be always available
+        Subprocess.verify(tools=self.tools)
+        Download.verify(tools=self.tools)
+
         self.global_config = None
         self._path_index = {}
 
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index faa4f6e8..b517c75a 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -13,6 +13,7 @@ from briefcase.exceptions import (
     InvalidDeviceError,
     MissingToolError,
 )
+from briefcase.integrations.base import Tool, ToolCache
 from briefcase.integrations.java import JDK
 
 DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found")
@@ -36,11 +37,11 @@ this device as a deployment target.
         )
 
 
-class AndroidSDK:
+class AndroidSDK(Tool):
     name = "android_sdk"
     full_name = "Android SDK"
 
-    def __init__(self, tools, root_path):
+    def __init__(self, tools: ToolCache, root_path: Path):
         self.tools = tools
         self.dot_android_path = self.tools.home_path / ".android"
         self.root_path = root_path
@@ -129,7 +130,7 @@ class AndroidSDK:
         )
 
     @classmethod
-    def verify(cls, tools, install=True):
+    def verify(cls, tools: ToolCache, install=True):
         """Verify an Android SDK is available.
 
         If the ANDROID_SDK_ROOT environment variable is set, that location will
@@ -1100,7 +1101,7 @@ find this page helpful in diagnosing emulator problems.
 
 
 class ADB:
-    def __init__(self, tools, device):
+    def __init__(self, tools: ToolCache, device: str):
         """An API integration for the Android Debug Bridge (ADB).
 
         :param tools: ToolCache of available tools
diff --git a/src/briefcase/integrations/base.py b/src/briefcase/integrations/base.py
index 839cb3e3..02f9afca 100644
--- a/src/briefcase/integrations/base.py
+++ b/src/briefcase/integrations/base.py
@@ -8,15 +8,29 @@ from abc import ABC
 from collections import defaultdict
 from collections.abc import Mapping
 from pathlib import Path
-from typing import DefaultDict
+from typing import TYPE_CHECKING, DefaultDict
 
 import requests
 from cookiecutter.main import cookiecutter
 
 from briefcase.config import AppConfig
 from briefcase.console import Console, Log
-from briefcase.integrations.download import Download
-from briefcase.integrations.subprocess import Subprocess
+
+if TYPE_CHECKING:
+    # Tools are imported only for type checking
+    # to avoid circular import errors.
+    import git as git_
+
+    from briefcase.integrations.android_sdk import AndroidSDK
+    from briefcase.integrations.docker import Docker, DockerAppContext
+    from briefcase.integrations.download import Download
+    from briefcase.integrations.flatpak import Flatpak
+    from briefcase.integrations.java import JDK
+    from briefcase.integrations.linuxdeploy import LinuxDeploy
+    from briefcase.integrations.rcedit import RCEdit
+    from briefcase.integrations.subprocess import Subprocess
+    from briefcase.integrations.visualstudio import VisualStudio
+    from briefcase.integrations.wix import WiX
 
 
 # TODO: Implement Tool base class
@@ -25,11 +39,29 @@ class Tool(ABC):
 
 
 class ToolCache(Mapping):
+    # Briefcase tools
+    android_sdk: AndroidSDK
+    app_context: Subprocess | DockerAppContext
+    docker: Docker
+    download: Download
+    flatpak: Flatpak
+    git: git_
+    java: JDK
+    linuxdeploy: LinuxDeploy
+    rcedit: RCEdit
+    subprocess: Subprocess
+    visualstudio: VisualStudio
+    wix: WiX
+    xcode: bool
+    xcode_cli: bool
+
+    # Python stdlib tools
     platform = platform
     os = os
     shutil = shutil
     sys = sys
 
+    # Third party tools
     cookiecutter = staticmethod(cookiecutter)
     requests = requests
 
@@ -71,10 +103,6 @@ class ToolCache(Mapping):
             )
         )
 
-        # Built-in tools without any external dependencies
-        Subprocess.verify(tools=self)
-        Download.verify(tools=self)
-
     def __getitem__(self, app: AppConfig) -> ToolCache:
         return self.app_tools[app]
 
diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py
index 0c4dbc50..e28ac2be 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -3,10 +3,15 @@ import subprocess
 import sys
 from pathlib import Path
 
+from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.base import Tool, ToolCache
 
 
-class Docker:
+class Docker(Tool):
+    name = "docker"
+    full_name = "Docker"
+
     WRONG_DOCKER_VERSION_ERROR = """\
 Briefcase requires Docker 19 or higher, but you are currently running
 version {docker_version}. Visit:
@@ -107,11 +112,11 @@ installation, and try again.
         },
     }
 
-    def __init__(self, tools):
+    def __init__(self, tools: ToolCache):
         self.tools = tools
 
     @classmethod
-    def verify(cls, tools):
+    def verify(cls, tools: ToolCache):
         """Verify Docker is installed and operational."""
         # short circuit since already verified and available
         if hasattr(tools, "docker"):
@@ -179,8 +184,8 @@ installation, and try again.
         return tools.docker
 
 
-class DockerAppContext:
-    def __init__(self, tools, app):
+class DockerAppContext(Tool):
+    def __init__(self, tools: ToolCache, app: AppConfig):
         self.tools = tools
         self.app = app
 
@@ -198,8 +203,8 @@ class DockerAppContext:
     @classmethod
     def verify(
         cls,
-        tools,
-        app,
+        tools: ToolCache,
+        app: AppConfig,
         image_tag: str,
         dockerfile_path: Path,
         app_base_path: Path,
diff --git a/src/briefcase/integrations/download.py b/src/briefcase/integrations/download.py
index 83ab2055..ecc82a78 100644
--- a/src/briefcase/integrations/download.py
+++ b/src/briefcase/integrations/download.py
@@ -11,14 +11,18 @@ from briefcase.exceptions import (
     MissingNetworkResourceError,
     NetworkFailure,
 )
+from briefcase.integrations.base import Tool, ToolCache
 
 
-class Download:
-    def __init__(self, tools):
+class Download(Tool):
+    name = "download"
+    full_name = "Download"
+
+    def __init__(self, tools: ToolCache):
         self.tools = tools
 
     @classmethod
-    def verify(cls, tools):
+    def verify(cls, tools: ToolCache):
         """Make downloader available in tool cache."""
         # short circuit since already verified and available
         if hasattr(tools, "download"):
@@ -31,9 +35,9 @@ class Download:
         """Download a given URL, caching it. If it has already been downloaded,
         return the value that has been cached.
 
-        This is a utility method used to obtain assets used by the
-        install process. The cached filename will be the filename portion of
-        the URL, appended to the download path.
+        This is a utility method used to obtain assets used by the installation
+        process. The cached filename will be the filename portion of the URL,
+        appended to the download path.
 
         :param url: The URL to download
         :param download_path: The path to the download cache folder. This path
diff --git a/src/briefcase/integrations/flatpak.py b/src/briefcase/integrations/flatpak.py
index 491dbffd..72224088 100644
--- a/src/briefcase/integrations/flatpak.py
+++ b/src/briefcase/integrations/flatpak.py
@@ -1,9 +1,13 @@
 import subprocess
 
 from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.base import Tool, ToolCache
 
 
-class Flatpak:
+class Flatpak(Tool):
+    name = "flatpak"
+    full_name = "Flatpak"
+
     DEFAULT_REPO_ALIAS = "flathub"
     DEFAULT_REPO_URL = "https://flathub.org/repo/flathub.flatpakrepo"
 
@@ -11,11 +15,11 @@ class Flatpak:
     DEFAULT_RUNTIME_VERSION = "21.08"
     DEFAULT_SDK = "org.freedesktop.Sdk"
 
-    def __init__(self, tools):
+    def __init__(self, tools: ToolCache):
         self.tools = tools
 
     @classmethod
-    def verify(cls, tools):
+    def verify(cls, tools: ToolCache):
         """Verify that the Flatpak toolchain is available.
 
         :param tools: ToolCache of available tools
diff --git a/src/briefcase/integrations/git.py b/src/briefcase/integrations/git.py
index 3403422e..7f2e4b43 100644
--- a/src/briefcase/integrations/git.py
+++ b/src/briefcase/integrations/git.py
@@ -1,7 +1,8 @@
 from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.base import ToolCache
 
 
-def verify_git_is_installed(tools):
+def verify_git_is_installed(tools: ToolCache):
     """Verify if git is installed.
 
     Unfortunately, `import git` triggers a call on the operating system
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 3d3fe5ec..f0d90c7d 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -8,13 +8,14 @@ from briefcase.exceptions import (
     MissingToolError,
     NonManagedToolError,
 )
+from briefcase.integrations.base import Tool, ToolCache
 
 
-class JDK:
+class JDK(Tool):
     name = "java"
     full_name = "Java JDK"
 
-    def __init__(self, tools, java_home):
+    def __init__(self, tools: ToolCache, java_home: Path):
         self.tools = tools
 
         # As of April 10 2020, 8u242-b08 is the current AdoptOpenJDK
@@ -43,7 +44,7 @@ class JDK:
         )
 
     @classmethod
-    def verify(cls, tools, install=True):
+    def verify(cls, tools: ToolCache, install=True):
         """Verify that a Java 8 JDK exists.
 
         If ``JAVA_HOME`` is set, try that version. If it is a JRE, or its *not*
diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py
index 90873965..0a9bf45c 100644
--- a/src/briefcase/integrations/linuxdeploy.py
+++ b/src/briefcase/integrations/linuxdeploy.py
@@ -9,6 +9,7 @@ from briefcase.exceptions import (
     CorruptToolError,
     MissingToolError,
 )
+from briefcase.integrations.base import Tool, ToolCache
 
 ELF_HEADER_IDENT = bytes.fromhex("7F454C46")
 ELF_PATCH_OFFSET = 0x08
@@ -17,7 +18,11 @@ ELF_PATCH_PATCHED_BYTES = bytes.fromhex("000000")
 
 
 class LinuxDeployBase:
-    def __init__(self, tools, **kwargs):
+    name: str
+    full_name: str
+    install_msg: str
+
+    def __init__(self, tools: ToolCache, **kwargs):
         self.tools = tools
 
     @property
@@ -62,7 +67,7 @@ class LinuxDeployBase:
                 self.patch_elf_header()
 
     @classmethod
-    def verify(cls, tools, install=True, **kwargs):
+    def verify(cls, tools: ToolCache, install=True, **kwargs):
         """Verify that linuxdeploy tool or plugin is available.
 
         :param tools: ToolCache of available tools
@@ -290,7 +295,7 @@ class LinuxDeployURLPlugin(LinuxDeployPluginBase):
         return self._download_url
 
 
-class LinuxDeploy(LinuxDeployBase):
+class LinuxDeploy(LinuxDeployBase, Tool):
     name = "linuxdeploy"
     full_name = "linuxdeploy"
     install_msg = "linuxdeploy was not found; downloading and installing..."
diff --git a/src/briefcase/integrations/rcedit.py b/src/briefcase/integrations/rcedit.py
index 4dbe34a6..60b59b6c 100644
--- a/src/briefcase/integrations/rcedit.py
+++ b/src/briefcase/integrations/rcedit.py
@@ -1,11 +1,12 @@
 from briefcase.exceptions import MissingToolError
+from briefcase.integrations.base import Tool, ToolCache
 
 
-class RCEdit:
+class RCEdit(Tool):
     name = "rcedit"
     full_name = "RCEdit"
 
-    def __init__(self, tools):
+    def __init__(self, tools: ToolCache):
         self.tools = tools
 
     @property
@@ -19,7 +20,7 @@ class RCEdit:
         return self.tools.base_path / "rcedit-x64.exe"
 
     @classmethod
-    def verify(cls, tools, install=True):
+    def verify(cls, tools: ToolCache, install=True):
         """Verify that rcedit is available.
 
         :param tools: ToolCache of available tools
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 7e470ded..37fc3baa 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -12,8 +12,10 @@ from pathlib import Path
 
 import psutil
 
+from briefcase.config import AppConfig
 from briefcase.console import Log
 from briefcase.exceptions import CommandOutputParseError
+from briefcase.integrations.base import Tool, ToolCache
 
 
 class ParseError(Exception):
@@ -51,7 +53,9 @@ def is_process_dead(pid: int):
 
 
 def get_process_id_by_command(
-    command_list: list = None, command: str = "", logger: Log = None
+    command_list: list = None,
+    command: str = "",
+    logger: Log = None,
 ):
     """Find a Process ID (PID) a by its command. If multiple processes are
     found, then the most recently created process ID is returned.
@@ -129,11 +133,11 @@ def ensure_console_is_safe(sub_method):
     return inner
 
 
-class NativeAppContext:
+class NativeAppContext(Tool):
     """A wrapper around subprocess for use as an app-bound tool."""
 
     @classmethod
-    def verify(cls, tools, app):
+    def verify(cls, tools: ToolCache, app: AppConfig):
         """Make subprocess available as app-bound tool."""
         # short circuit since already verified and available
         if hasattr(tools[app], "app_context"):
@@ -143,11 +147,14 @@ class NativeAppContext:
         return tools[app].app_context
 
 
-class Subprocess:
+class Subprocess(Tool):
     """A wrapper around subprocess that can be used as a logging point for
     commands that are executed."""
 
-    def __init__(self, tools):
+    name = "subprocess"
+    full_name = "Subprocess"
+
+    def __init__(self, tools: ToolCache):
         self.tools = tools
         self._subprocess = subprocess
 
@@ -246,7 +253,7 @@ class Subprocess:
         return kwargs
 
     @classmethod
-    def verify(cls, tools):
+    def verify(cls, tools: ToolCache):
         """Make subprocess available in tool cache."""
         # short circuit since already verified and available
         if hasattr(tools, "subprocess"):
diff --git a/src/briefcase/integrations/visualstudio.py b/src/briefcase/integrations/visualstudio.py
index 4ed27dfd..94a25a37 100644
--- a/src/briefcase/integrations/visualstudio.py
+++ b/src/briefcase/integrations/visualstudio.py
@@ -2,11 +2,13 @@ import subprocess
 from pathlib import Path
 
 from briefcase.exceptions import BriefcaseCommandError, CommandOutputParseError
+from briefcase.integrations.base import Tool, ToolCache
 from briefcase.integrations.subprocess import json_parser
 
 
-class VisualStudio:
+class VisualStudio(Tool):
     name = "visualstudio"
+    full_name = "Visual Studio"
     VSCODE_REQUIRED_COMPONENTS = """
     * .NET Desktop Development
       - Default packages
@@ -15,7 +17,12 @@ class VisualStudio:
       - C++/CLI support for v143 build tools
 """
 
-    def __init__(self, tools, msbuild_path, install_metadata=None):
+    def __init__(
+        self,
+        tools: ToolCache,
+        msbuild_path: Path,
+        install_metadata: dict = None,
+    ):
         self.tools = tools
         self._msbuild_path = msbuild_path
         self._install_metadata = install_metadata
@@ -37,7 +44,7 @@ class VisualStudio:
         return self._install_metadata
 
     @classmethod
-    def verify(cls, tools):
+    def verify(cls, tools: ToolCache):
         """Verify that Visual Studio is available.
 
         :param tools: ToolCache of available tools
diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py
index a14b3952..1c6af072 100644
--- a/src/briefcase/integrations/wix.py
+++ b/src/briefcase/integrations/wix.py
@@ -7,15 +7,16 @@ from briefcase.exceptions import (
     MissingToolError,
     NonManagedToolError,
 )
+from briefcase.integrations.base import Tool, ToolCache
 
 WIX_DOWNLOAD_URL = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"
 
 
-class WiX:
+class WiX(Tool):
     name = "wix"
     full_name = "WiX"
 
-    def __init__(self, tools, wix_home=None, bin_install=False):
+    def __init__(self, tools: ToolCache, wix_home: Path = None, bin_install=False):
         """Create a wrapper around a WiX install.
 
         :param tools: ToolCache of available tools.
@@ -55,7 +56,7 @@ class WiX:
             return self.wix_home / "bin" / "candle.exe"
 
     @classmethod
-    def verify(cls, tools, install=True):
+    def verify(cls, tools: ToolCache, install=True):
         """Verify that there is a WiX install available.
 
         If the WIX environment variable is set, that location will be checked
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 0b0adc61..b3902fc0 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -4,6 +4,7 @@ import subprocess
 from pathlib import Path
 
 from briefcase.exceptions import BriefcaseCommandError, CommandOutputParseError
+from briefcase.integrations.base import ToolCache
 from briefcase.integrations.subprocess import json_parser
 
 
@@ -14,7 +15,7 @@ class DeviceState(enum.Enum):
     UNKNOWN = 99
 
 
-def verify_xcode_install(tools, min_version=None):
+def verify_xcode_install(tools: ToolCache, min_version: tuple = None):
     """Verify that Xcode and the command line developer tools are installed and
     ready for use.
 
@@ -43,7 +44,7 @@ def verify_xcode_install(tools, min_version=None):
     tools.xcode = True
 
 
-def verify_command_line_tools_install(tools):
+def verify_command_line_tools_install(tools: ToolCache):
     """Verify that command line developer tools are installed and ready for
     use.
 
@@ -64,7 +65,7 @@ def verify_command_line_tools_install(tools):
     tools.xcode_cli = True
 
 
-def ensure_command_line_tools_are_installed(tools):
+def ensure_command_line_tools_are_installed(tools: ToolCache):
     """Determine if the Xcode command line tools are installed.
 
     If they are not installed, an exception is raised; in addition, an OS dialog
@@ -126,8 +127,8 @@ Re-run Briefcase once that installation is complete.
 
 
 def ensure_xcode_is_installed(
-    tools,
-    min_version=None,
+    tools: ToolCache,
+    min_version: tuple = None,
     xcode_location="/Applications/Xcode.app",
 ):
     """Determine if Xcode is installed; and if so, that it meets minimum
@@ -275,7 +276,7 @@ installation is complete.
             ) from e
 
 
-def confirm_xcode_license_accepted(tools):
+def confirm_xcode_license_accepted(tools: ToolCache):
     """Confirm if the Xcode license has been accepted.
 
     :param tools: ToolCache of available tools
@@ -382,8 +383,8 @@ You need to accept the Xcode license before Briefcase can package your app.
 
 
 def get_simulators(
-    tools,
-    os_name,
+    tools: ToolCache,
+    os_name: str,
     simulator_location="/Library/Developer/PrivateFrameworks/CoreSimulator.framework/",
 ):
     """Obtain the simulators available on this machine.
@@ -458,7 +459,7 @@ Press Return to continue: """
         raise BriefcaseCommandError("Unable to run xcrun simctl.") from e
 
 
-def get_device_state(tools, udid):
+def get_device_state(tools: ToolCache, udid: str):
     """Determine the state of an iOS simulator device.
 
     :param tools: ToolCache of available tools
@@ -493,7 +494,7 @@ def get_device_state(tools, udid):
 IDENTITY_RE = re.compile(r"\s*\d+\) ([0-9A-F]{40}) \"(.*)\"")
 
 
-def get_identities(tools, policy):
+def get_identities(tools: ToolCache, policy: str):
     """Obtain a set of valid identities for the given policy.
 
     :param tools: ToolCache of available tools


Test patch:
diff --git a/tests/commands/base/test_verify_tools.py b/tests/commands/base/test_verify_tools.py
index 6bc687ac..e78a6621 100644
--- a/tests/commands/base/test_verify_tools.py
+++ b/tests/commands/base/test_verify_tools.py
@@ -1,3 +1,10 @@
+from briefcase.integrations.download import Download
+from briefcase.integrations.subprocess import Subprocess
+
+
 def test_base_tools_exist(base_command):
-    """No verification required for base command."""
+    """Ensure default tools are always available."""
+    assert isinstance(base_command.tools.subprocess, Subprocess)
+    assert isinstance(base_command.tools.download, Download)
+
     base_command.verify_tools()
diff --git a/tests/integrations/base/test_ToolCache.py b/tests/integrations/base/test_ToolCache.py
index 35df89a6..2a5a3d15 100644
--- a/tests/integrations/base/test_ToolCache.py
+++ b/tests/integrations/base/test_ToolCache.py
@@ -1,3 +1,5 @@
+import importlib
+import inspect
 import os
 import platform
 import shutil
@@ -8,10 +10,9 @@ import pytest
 import requests
 from cookiecutter.main import cookiecutter
 
+import briefcase.integrations
 from briefcase.console import Console, Log
-from briefcase.integrations.base import ToolCache
-from briefcase.integrations.download import Download
-from briefcase.integrations.subprocess import Subprocess
+from briefcase.integrations.base import Tool, ToolCache
 
 
 @pytest.fixture
@@ -23,6 +24,62 @@ def simple_tools(tmp_path):
     )
 
 
+def tools_for_module(tool_module_name: str) -> set:
+    """Return names of classes that subclass Tool in a module in
+    ``briefcase.integrations``, e.g. "android_sdk"."""
+    return {
+        klass_name
+        for klass_name, _ in inspect.getmembers(
+            sys.modules[f"briefcase.integrations.{tool_module_name}"],
+            lambda klass: (
+                inspect.isclass(klass) and issubclass(klass, Tool) and klass is not Tool
+            ),
+        )
+    }
+
+
+def test_toolcache_typing():
+    """Tool typing for ToolCache is correct."""
+    # Tools that are intentionally not annotated in ToolCache.
+    tools_unannotated = {"cookiecutter"}
+    # Tool names to exclude from the dynamic annotation checks; they are manually checked.
+    tool_names_skip_dynamic_check = {"app_context", "git", "xcode", "xcode_cli"}
+    # Tool classes to exclude from dynamic annotation checks.
+    tool_klasses_skip_dynamic_checks = {"DockerAppContext", "NativeAppContext"}
+
+    # Ensure defined Tool modules/classes are annotated in ToolCache.
+    for tool_module_name in briefcase.integrations.__all__:
+        if tool_module_name not in tools_unannotated:
+            assert tool_module_name in ToolCache.__annotations__.keys()
+        for tool_name in tools_for_module(tool_module_name):
+            if tool_name not in tool_klasses_skip_dynamic_checks:
+                assert tool_name in ToolCache.__annotations__.values()
+
+    # Ensure annotated tools use valid Tool names.
+    for tool_name, tool_klass_name in ToolCache.__annotations__.items():
+        if tool_name not in tool_names_skip_dynamic_check:
+            assert tool_name in briefcase.integrations.__all__
+            assert tool_klass_name in tools_for_module(tool_name)
+            tool_klass = getattr(
+                importlib.import_module(f"briefcase.integrations.{tool_name}"),
+                tool_klass_name,
+            )
+            assert tool_name == tool_klass.name
+
+    # Manually check tools that aren't Tool classes or use special annotations.
+    app_context_klasses = [
+        briefcase.integrations.docker.DockerAppContext.__name__,
+        briefcase.integrations.subprocess.Subprocess.__name__,
+    ]
+    app_context_annotated = ToolCache.__annotations__["app_context"].split(" | ")
+    assert sorted(app_context_klasses) == sorted(app_context_annotated)
+
+    assert ToolCache.__annotations__["xcode"] == "bool"
+    assert ToolCache.__annotations__["xcode_cli"] == "bool"
+
+    assert ToolCache.__annotations__["git"] == "git_"
+
+
 def test_third_party_tools_available():
     """Third party tools are available."""
     assert ToolCache.os is os
@@ -34,14 +91,6 @@ def test_third_party_tools_available():
     assert ToolCache.requests is requests
 
 
-def test_default_tools_available(simple_tools):
-    """Default tools are always available."""
-    assert isinstance(simple_tools.subprocess, Subprocess)
-    assert isinstance(simple_tools["app"].subprocess, Subprocess)
-    assert isinstance(simple_tools.download, Download)
-    assert isinstance(simple_tools["app"].download, Download)
-
-
 def test_always_true(simple_tools, tmp_path):
     """Implicit boolean casts are always True."""
     assert simple_tools or False
diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py
index e711220c..15c0fcc3 100644
--- a/tests/integrations/conftest.py
+++ b/tests/integrations/conftest.py
@@ -9,6 +9,8 @@ import pytest
 from briefcase.config import AppConfig
 from briefcase.console import Log
 from briefcase.integrations.base import ToolCache
+from briefcase.integrations.download import Download
+from briefcase.integrations.subprocess import Subprocess
 from tests.utils import DummyConsole
 
 
@@ -34,6 +36,10 @@ def mock_tools(tmp_path) -> ToolCache:
     mock_tools.base_path.mkdir(parents=True)
     mock_tools.home_path.mkdir(parents=True)
 
+    # Make Download and Subprocess always available
+    Download.verify(tools=mock_tools)
+    Subprocess.verify(tools=mock_tools)
+
     return mock_tools
 
 
diff --git a/tests/integrations/rcedit/conftest.py b/tests/integrations/rcedit/conftest.py
index 0f12c7b0..15fbb26a 100644
--- a/tests/integrations/rcedit/conftest.py
+++ b/tests/integrations/rcedit/conftest.py
@@ -2,13 +2,14 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.integrations.base import ToolCache
 from briefcase.integrations.download import Download
 from briefcase.integrations.rcedit import RCEdit
 from briefcase.integrations.subprocess import Subprocess
 
 
 @pytest.fixture
-def mock_tools(tmp_path, mock_tools):
+def mock_tools(tmp_path, mock_tools) -> ToolCache:
     mock_tools.host_arch = "wonky"
 
     # Mock default tools