Repository: beeware/briefcase

Issue:
Improve error message when a wheel for one platform is missing
### What is the problem or limitation you are having?

Briefcase builds for iOS and macOS are multi-architecture - they involve compiling for multiple CPU architectures, which potentially involves downloading multiple binary wheels. For example building for iOS means downloading an `iphoneos` and `iphonesimulator` wheel.

However, if *one* of these wheels is missing, unless you're paying close attention, the error message that is returned suggests that *neither* wheel is available.

### Describe the solution you'd like

When performing a build on iOS and macOS, if a wheel isn't available on *one* platform, the error message should make it clear (1) that this issue is a *single* wheel, and (2) which platform is the problem.

I hit this recently testing a new build of numpy wheels in the Python-support-tested project. I copied the iphonesimulator wheel into the `wheels` folder, and ran a build; it reported:
```
Collecting pyspamsum
  Using cached pyspamsum-1.0.7-cp313-cp313-ios_13_0_arm64_iphoneos.whl.metadata (3.9 kB)
WARNING: Cache entry deserialization failed, entry ignored
ERROR: Could not find a version that satisfies the requirement numpy (from versions: none)
ERROR: No matching distribution found for numpy
Installing app requirements for iPhone device... errored

Unable to install requirements. This may be because one of your
requirements is invalid, or because pip was unable to connect
to the PyPI server.
```
At the time, cause I was doing some testing with IPHONEOS_DEPLOYMENT_TARGET, so I initially thought the issue was that the min-version tag wasn't matching. I\it took me a couple of rounds of debugging before I noticed that the issue was the missing *iPhone device* wheel was completely missing.

It would be preferable if, in the case of iOS builds, the error was more explicit - for example:
```
ERROR: No matching distribution found for numpy
Installing app requirements for iPhone device... errored

Unable to install requirements. This may be because one of your
requirements is invalid, or because pip was unable to connect
to the PyPI server.

It may also indicate that an `iphonesimulator` wheel could be found, 
but an `iphoneos` wheel could not be found. 
```

This message will need to be modified depending on which platform install failed; or, in the case of macOS, which architecture was missing.

### Describe alternatives you've considered

Do nothing. The error is *there*, you just have to read it. However, as my recent experience shows, it's easy to miss this detail.

### Additional context

The same problem also exists for Android; however, the installation process there is handled by Chaquopy, so it will be more difficult for Briefcase to enhance the error message.

Discussion:
@pjfan: Taking a look at this one @ PyCon 2025

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 13b3ffb00..7da6dfe56 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -15,7 +15,7 @@ repos:
   #     - id: docformatter
   #       args: [--in-place, --black]
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.11.10
+    rev: v0.11.11
     hooks:
       - id: ruff-format
       - id: ruff-check
diff --git a/automation/pyproject.toml b/automation/pyproject.toml
index e6c013682..e19d7ba0b 100644
--- a/automation/pyproject.toml
+++ b/automation/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
     # keep versions in sync with ../pyproject.toml
-    "setuptools==80.7.1",
+    "setuptools==80.8.0",
     "setuptools_scm==8.3.1",
     "setuptools_dynamic_dependencies @ git+https://github.com/beeware/setuptools_dynamic_dependencies",
 ]
diff --git a/changes/2230.feature.rst b/changes/2230.feature.rst
new file mode 100644
index 000000000..72cd612fe
--- /dev/null
+++ b/changes/2230.feature.rst
@@ -0,0 +1 @@
+When an iOS or macOS binary wheel cannot be found, the error message now provides more useful hints regarding the possible cause.
diff --git a/changes/2296.bugfix.rst b/changes/2296.bugfix.1.rst
similarity index 100%
rename from changes/2296.bugfix.rst
rename to changes/2296.bugfix.1.rst
diff --git a/changes/2296.bugfix.2.rst b/changes/2296.bugfix.2.rst
new file mode 100644
index 000000000..1b9b50de1
--- /dev/null
+++ b/changes/2296.bugfix.2.rst
@@ -0,0 +1 @@
+Briefcase now provides richer error information when it is unable to complete a download. In particular, issues associated with SSL verification are now reported as such, rather than as a generic "are you offline?" message.
diff --git a/changes/2310.misc.rst b/changes/2310.misc.rst
new file mode 100644
index 000000000..bd9b56a05
--- /dev/null
+++ b/changes/2310.misc.rst
@@ -0,0 +1 @@
+Updated setuptools from 80.7.1 to 80.8.0.
diff --git a/changes/2311.misc.rst b/changes/2311.misc.rst
new file mode 100644
index 000000000..f8236c94c
--- /dev/null
+++ b/changes/2311.misc.rst
@@ -0,0 +1 @@
+Updated coverage[toml] from 7.8.0 to 7.8.2.
diff --git a/changes/2312.misc.rst b/changes/2312.misc.rst
new file mode 100644
index 000000000..dcf9d4f95
--- /dev/null
+++ b/changes/2312.misc.rst
@@ -0,0 +1 @@
+Updated setuptools from 80.7.1 to 80.8.0 in /automation.
diff --git a/changes/2313.misc.rst b/changes/2313.misc.rst
new file mode 100644
index 000000000..0ac70ca93
--- /dev/null
+++ b/changes/2313.misc.rst
@@ -0,0 +1 @@
+The ``pre-commit`` hook for ``ruff-pre-commit`` was updated to its latest version.
diff --git a/pyproject.toml b/pyproject.toml
index 7ec0db34a..0415b11f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
     # keep versions in sync with automation/pyproject.toml
-    "setuptools==80.7.1",
+    "setuptools==80.8.0",
     "setuptools_scm==8.3.1",
 ]
 build-backend = "setuptools.build_meta"
@@ -99,7 +99,7 @@ dependencies = [
 # Extras used by developers *of* briefcase are pinned to specific versions to
 # ensure environment consistency.
 dev = [
-    "coverage[toml] == 7.8.0",
+    "coverage[toml] == 7.8.2",
     "coverage-conditional-plugin == 0.9.0",
     "pre-commit == 4.2.0",
     "pytest == 8.3.5",
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 28a12d0d9..e69aca396 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -634,6 +634,7 @@ def _install_app_requirements(
         progress_message: str = "Installing app requirements...",
         pip_args: list[str] | None = None,
         pip_kwargs: dict[str, str] | None = None,
+        install_hint: str = "",
     ):
         """Install requirements for the app with pip.
 
@@ -645,6 +646,8 @@ def _install_app_requirements(
         :param pip_args: Any additional command line arguments to use when invoking pip.
         :param pip_kwargs: Any additional keyword arguments to pass to the subprocess
             when invoking pip.
+        :param install_hint: Additional hint information to provide in the exception
+            message if the pip install call fails.
         """
         # Clear existing dependency directory
         if app_packages_path.is_dir():
@@ -661,6 +664,7 @@ def _install_app_requirements(
                         ([] if pip_args is None else pip_args)
                         + self._pip_requires(app, requires)
                     ),
+                    install_hint=install_hint,
                     **(pip_kwargs if pip_kwargs else {}),
                 )
         else:
diff --git a/src/briefcase/integrations/file.py b/src/briefcase/integrations/file.py
index f46b73d87..810c15af7 100644
--- a/src/briefcase/integrations/file.py
+++ b/src/briefcase/integrations/file.py
@@ -251,10 +251,35 @@ def download(self, url: str, download_path: Path, role: str | None = None) -> Pa
                 hint = "exceeded redirects when downloading the file.\n\nPlease report this as a bug to Briefcase."
             elif isinstance(e, httpx.DecodingError):
                 hint = "the server sent a malformed response."
+            elif isinstance(e, httpx.ConnectError):
+                try:
+                    # It's a little difficult to verify exactly what might cause httpx
+                    # to raise a ConnectError, but `__context__.__context__` should be
+                    # an SSLCertVerificationError if there's a certificate problem.
+                    # Catch that case, and print the raw exception in other cases.
+                    context = e.__context__.__context__
+                except AttributeError:
+                    context = None
+
+                if isinstance(context, ssl.SSLCertVerificationError):
+                    hint = (
+                        "a connection to the server could not be established due to "
+                        "a certificate verification problem.\n\n"
+                        "This probably indicates an issue with a proxy configuration "
+                        "on your computer."
+                    )
+                else:
+                    hint = (
+                        "a connection to the server could not be established.\n\n"
+                        f"The reported cause of the problem was {e}"
+                    )
             else:
                 # httpx.TransportError
                 # Use the default hint for generic network communication errors
-                hint = None
+                hint = (
+                    "is your computer offline?\n\n"
+                    f"The reported cause of the problem was {e}"
+                )
 
             raise NetworkFailure(
                 f"download {description}",
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index b44516ca5..bdea0cf7a 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -373,8 +373,13 @@ def _install_app_requirements(
             pip_kwargs={
                 "env": {
                     "PYTHONPATH": str(device_platform_site),
-                },
+                }
             },
+            install_hint=f"""
+
+This may be because the `iphoneos` wheels that are available are not compatible
+with a minimum iOS version of {ios_min_version}.
+""",
         )
 
         # Perform a second install pass targeting the "iphonesimulator" platform for the
@@ -392,6 +397,13 @@ def _install_app_requirements(
                     "PYTHONPATH": str(simulator_platform_site),
                 },
             },
+            install_hint=f"""
+
+This may indicate that an `iphoneos` wheel could be found, but an
+`iphonesimulator` wheel could not be found; or that the `iphonesimulator`
+binary wheels that are available are not compatible with a minimum iOS
+version of {ios_min_version}.
+""",
         )
 
 
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 586685dc9..e0e0b9e52 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -149,6 +149,11 @@ def _install_app_requirements(
                     "--platform",
                     f"macosx_{macOS_min_tag}_{self.tools.host_arch}",
                 ],
+                install_hint=f"""
+
+This may be because an {self.tools.host_arch} wheel that is compatible with a minimum
+macOS version of {macOS_min_version} is not available.
+""",
             )
 
             # Find all the packages with binary components.
@@ -192,8 +197,10 @@ def _install_app_requirements(
                         ],
                         install_hint=f"""
 
-If an {other_arch} wheel has not been published for one or more of your requirements,
-you must compile those wheels yourself, or build a non-universal app by setting:
+This may be because an {other_arch} wheel that is compatible with a minimum
+macOS version of {macOS_min_version} is not available.
+
+You may need to build a non-universal app by setting:
 
     universal_build = False
 


Test patch:
diff --git a/tests/integrations/file/test_File__download.py b/tests/integrations/file/test_File__download.py
index 046825ca0..9d15f31e0 100644
--- a/tests/integrations/file/test_File__download.py
+++ b/tests/integrations/file/test_File__download.py
@@ -1,12 +1,14 @@
 import os
 import platform
 import shutil
+import ssl
 import stat
 from collections.abc import Iterable, Iterator
 from pathlib import Path
 from typing import Optional
 from unittest import mock
 
+import httpcore
 import httpx
 import pytest
 
@@ -483,3 +485,114 @@ def test_redirect_connection_error(mock_tools):
     mock_tools.shutil.move.assert_not_called()
     mock_tools.os.chmod.assert_not_called()
     mock_tools.os.remove.assert_not_called()
+
+
+def test_ssl_verification_error(mock_tools):
+    """NetworkFailure is raised if the request fails due to SSL."""
+    # Mock an SSL verification error
+    error = httpx.ConnectError("connection error")
+    error.__context__ = httpcore.ConnectError()
+    error.__context__.__context__ = ssl.SSLCertVerificationError()
+    mock_tools.httpx.stream.side_effect = [error]
+
+    # Download the file
+    with pytest.raises(
+        NetworkFailure,
+        match=(
+            r"a connection to the server could not be established "
+            r"due to a certificate verification problem"
+        ),
+    ):
+        mock_tools.file.download(
+            url="https://example.com/something.zip",
+            download_path=mock_tools.base_path,
+        )
+
+    # httpx.stream has been invoked, but nothing else.
+    mock_tools.httpx.stream.assert_called_with(
+        "GET",
+        "https://example.com/something.zip",
+        follow_redirects=True,
+        verify=mock_tools.file.ssl_context,
+    )
+
+    # The file doesn't exist as a result of the download failure
+    assert not (mock_tools.base_path / "something.zip").exists()
+
+    # Temporary file was not created, moved, or deleted
+    mock_tools.shutil.move.assert_not_called()
+    mock_tools.os.chmod.assert_not_called()
+    mock_tools.os.remove.assert_not_called()
+
+
+def test_unknown_httpcore_connectionerror(mock_tools):
+    """NetworkFailure is raised if an unknown core connection error occurs."""
+    # Mock a connection error at the level of httpcore
+    error = httpx.ConnectError("connection error")
+    error.__context__ = httpcore.ConnectError()
+    mock_tools.httpx.stream.side_effect = [error]
+
+    # Download the file
+    with pytest.raises(
+        NetworkFailure,
+        match=(
+            r"a connection to the server could not be established.\n\n"
+            r"The reported cause of the problem "
+        ),
+    ):
+        mock_tools.file.download(
+            url="https://example.com/something.zip",
+            download_path=mock_tools.base_path,
+        )
+
+    # httpx.stream has been invoked, but nothing else.
+    mock_tools.httpx.stream.assert_called_with(
+        "GET",
+        "https://example.com/something.zip",
+        follow_redirects=True,
+        verify=mock_tools.file.ssl_context,
+    )
+
+    # The file doesn't exist as a result of the download failure
+    assert not (mock_tools.base_path / "something.zip").exists()
+
+    # Temporary file was not created, moved, or deleted
+    mock_tools.shutil.move.assert_not_called()
+    mock_tools.os.chmod.assert_not_called()
+    mock_tools.os.remove.assert_not_called()
+
+
+def test_unknown_httpx_connectionerror(mock_tools):
+    """NetworkFailure is raised if an unknown httpx connection error occurs."""
+    # Mock a connection error at the level of httpx
+    error = httpx.ConnectError("connection error")
+    mock_tools.httpx.stream.side_effect = [error]
+
+    # Download the file
+    with pytest.raises(
+        NetworkFailure,
+        match=(
+            r"a connection to the server could not be established.\n\n"
+            r"The reported cause of the problem "
+        ),
+    ):
+        mock_tools.file.download(
+            url="https://example.com/something.zip",
+            download_path=mock_tools.base_path,
+        )
+
+    # httpx.stream has been invoked, but nothing else.
+    mock_tools.httpx.stream.assert_called_with(
+        "GET",
+        "https://example.com/something.zip",
+        follow_redirects=True,
+        verify=mock_tools.file.ssl_context,
+    )
+
+    # The file doesn't exist as a result of the download failure
+    assert not (mock_tools.base_path / "something.zip").exists()
+
+    # Temporary file was not created, moved, or deleted
+    mock_tools.shutil.move.assert_not_called()
+    mock_tools.os.chmod.assert_not_called()
+    mock_tools.os.remove.assert_not_called()
diff --git a/tests/platforms/iOS/xcode/test_create.py b/tests/platforms/iOS/xcode/test_create.py
index 0989a4785..ce6caa859 100644
--- a/tests/platforms/iOS/xcode/test_create.py
+++ b/tests/platforms/iOS/xcode/test_create.py
@@ -1,11 +1,17 @@
 import shutil
 import sys
+from pathlib import Path
+from subprocess import CalledProcessError
 from unittest.mock import MagicMock, call
 
 import pytest
 
 from briefcase.console import Console
-from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+    RequirementsInstallError,
+    UnsupportedHostError,
+)
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.iOS.xcode import iOSXcodeCreateCommand
 
@@ -438,3 +444,70 @@ def test_permissions_context(create_command, first_app, permissions, info, conte
     x_permissions = create_command._x_permissions(first_app)
     # Check that the final platform permissions are rendered as expected.
     assert context == create_command.permissions_context(first_app, x_permissions)
+
+
+def test_install_app_requirements_error_adds_install_hint_missing_iphoneos_wheel(
+    create_command, first_app_generated
+):
+    """Install_hint (mentioning a missing iphoneos wheel) is added when RequirementsInstallError is raised
+    by _install_app_requirements in the iOS create command."""
+    first_app_generated.min_os_version = "15.4"
+    first_app_generated.requires = ["package-one", "package_two", "package_three"]
+
+    # Mock app_context for the generated app to simulate pip failure
+    mock_app_context = MagicMock(spec=Subprocess)
+    mock_app_context.run.side_effect = CalledProcessError(returncode=1, cmd="pip")
+    create_command.tools[first_app_generated].app_context = mock_app_context
+
+    # Check that _install_app_requirements raises a RequirementsInstallError with an install hint
+    with pytest.raises(
+        RequirementsInstallError,
+        match=(
+            r"This may be because the `iphoneos` wheels that are available are not compatible\n"
+            r"with a minimum iOS version of 15.4."
+        ),
+    ):
+        create_command._install_app_requirements(
+            app=first_app_generated,
+            requires=first_app_generated.requires,
+            app_packages_path=Path("/test/path"),
+        )
+
+    # Ensure the mocked subprocess was called as expected
+    mock_app_context.run.assert_called_once()
+
+
+def test_install_app_requirements_error_adds_install_hint_missing_iphonesimulator_wheel(
+    create_command, first_app_generated
+):
+    """Install_hint (mentioning a missing iphonesimulator wheel) is added when RequirementsInstallError is raised
+    by _install_app_requirements in the iOS create command."""
+    first_app_generated.min_os_version = "15.4"
+    first_app_generated.requires = ["package-one", "package_two", "package_three"]
+
+    # Mock app_context for the generated app to simulate pip failure
+    mock_app_context = MagicMock(spec=Subprocess)
+    mock_app_context.run.side_effect = [
+        None,
+        CalledProcessError(returncode=1, cmd="pip"),
+    ]
+    create_command.tools[first_app_generated].app_context = mock_app_context
+
+    # Check that _install_app_requirements raises a RequirementsInstallError with an install hint
+    with pytest.raises(
+        RequirementsInstallError,
+        match=(
+            r"This may indicate that an `iphoneos` wheel could be found, but an\n"
+            r"`iphonesimulator` wheel could not be found; or that the `iphonesimulator`\n"
+            r"binary wheels that are available are not compatible with a minimum iOS\n"
+            r"version of 15.4.\n"
+        ),
+    ):
+        create_command._install_app_requirements(
+            app=first_app_generated,
+            requires=first_app_generated.requires,
+            app_packages_path=Path("/test/path"),
+        )
+
+    # Ensure the mocked subprocess was called as expected
+    assert mock_app_context.run.call_count == 2
diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py
index de41ab30f..2d602d50a 100644
--- a/tests/platforms/macOS/app/test_create.py
+++ b/tests/platforms/macOS/app/test_create.py
@@ -2,12 +2,14 @@
 import subprocess
 import sys
 import time
+from pathlib import Path
+from subprocess import CalledProcessError
 from unittest import mock
 
 import pytest
 
 from briefcase.console import Console
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, RequirementsInstallError
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.macOS.app import macOSAppCreateCommand
 
@@ -1012,3 +1014,69 @@ def test_install_support_package(
 
     # The legacy content has been purged
     assert not (runtime_support_path / "python-stdlib/old-Python").exists()
+
+
+def test_install_app_requirements_error_adds_install_hint_missing_x86_64_wheel(
+    create_command, first_app_templated
+):
+    """Install_hint (mentioning a missing x86_64 wheel) is added when RequirementsInstallError is raised
+    by _install_app_requirements in the macOS create command."""
+
+    create_command.tools.host_arch = "x86_64"
+    first_app_templated.min_os_version = "12.0"
+    first_app_templated.requires = ["package-one", "package_two", "packagethree"]
+
+    # Mock app_context for the generated app to simulate pip failure
+    mock_app_context = mock.MagicMock(spec=Subprocess)
+    mock_app_context.run.side_effect = CalledProcessError(returncode=1, cmd="pip")
+    create_command.tools[first_app_templated].app_context = mock_app_context
+
+    # Check that _install_app_requirements raises a RequirementsInstallError with an install hint
+    with pytest.raises(
+        RequirementsInstallError,
+        match=r"x86_64 wheel that is compatible with a minimum\nmacOS version of 12.0",
+    ):
+        create_command._install_app_requirements(
+            app=first_app_templated,
+            requires=first_app_templated.requires,
+            app_packages_path=Path("/test/path"),
+        )
+
+    # Ensure the mocked subprocess was called as expected
+    mock_app_context.run.assert_called_once()
+
+
+def test_install_app_requirements_error_adds_install_hint_missing_arm64_wheel(
+    create_command, first_app_templated
+):
+    """Install_hint (mentioning a missing arm64 wheel) is added when RequirementsInstallError is raised
+    by _install_app_requirements in the macOS create command."""
+
+    create_command.tools.host_arch = "x86_64"
+    first_app_templated.min_os_version = "12.0"
+    first_app_templated.requires = ["package-one", "package_two", "packagethree"]
+
+    # Fake a found binary package (so second install is triggered)
+    create_command.find_binary_packages = mock.Mock(
+        return_value=[("package-one", "1.0")]
+    )
+
+    # First call (host arch x86_64) succeeds, second (other arch arm64) fails
+    create_command.tools[first_app_templated].app_context.run.side_effect = [
+        None,
+        CalledProcessError(returncode=1, cmd="pip"),
+    ]
+
+    # Check that _install_app_requirements raises a RequirementsInstallError with an install hint
+    with pytest.raises(
+        RequirementsInstallError,
+        match=r"arm64 wheel that is compatible with a minimum\nmacOS version of 12.0",
+    ):
+        create_command._install_app_requirements(
+            app=first_app_templated,
+            requires=first_app_templated.requires,
+            app_packages_path=Path("/test/path"),
+        )
+
+    # Ensure the mocked subprocess was called as expected
+    assert create_command.tools[first_app_templated].app_context.run.call_count == 2