Repository: beeware/briefcase

Issue:
"briefcase build iOS" fails with "Xcode is not installed. You can install it......." on MacOS Big Sur with Xcode 12.5 installed and working.
Following the beeware "Hello World" online tutorial ends in failure at the final stage of trying to build for iOS, stating that Xcode is not installed, despite Xcode being installed and working ,as well as the Xcode command line tools. In my case I installed Xcode 12.5

It turn out the error is 100% due to the line where the Xcode location is hard coded :  **xcode_location='/Applications/Xcode.app'**. This is in _/beeware-tutorial/beeware-venv/lib/python3.8/site-packages/briefcase/integrations/xcode.py_.

I had downloaded Xcode using the Apple Installer and installed it on a different drive, i.e. not in /Applications, the default.

Simply changing the **xcode_location** to my real location fixed the problem, and it compiled 100% with 'briefcase build iOS' and ran with 'briefcase run iOS'.


Discussion:

Patch:
diff --git a/changes/622.feature.rst b/changes/622.feature.rst
new file mode 100644
index 00000000..c674060e
--- /dev/null
+++ b/changes/622.feature.rst
@@ -0,0 +1,1 @@
+Xcode detection code now allows for Xcode to be installed in locations other than ``/Applications/Xcode.app``.
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 7c123dc9..8ea1f1e8 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -117,7 +117,7 @@ Re-run Briefcase once that installation is complete.
 
 def ensure_xcode_is_installed(
     command,
-    xcode_location='/Applications/Xcode.app',
+    xcode_location=None,
     min_version=None,
 ):
     """
@@ -129,14 +129,34 @@ def ensure_xcode_is_installed(
 
     :param command: The command that needs to perform the verification check.
     :param xcode_location: The location where Xcode should be installed.
-        Default is `/Applications/Xcode.app`, which is where the App Store
-        installs Xcode.
+        If not given, the location returned by `xcode-select -p` will be used.
     :param min_version: The minimum allowed version of Xcode, specified as a
         tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
         is no minimum version.
     """
     # Try the direct approach. Look for the Xcode folder that is created
     # when you install from the App store.
+
+    if xcode_location is None:
+
+        try:
+            output = command.subprocess.check_output(
+                ['xcode-select', '-p'],
+                stderr=subprocess.STDOUT,
+                universal_newlines=True
+            )
+            xcode_location = output.strip()
+        except subprocess.CalledProcessError:
+            raise BriefcaseCommandError("""
+Could not find Xcode installation.
+
+To select an existing Xcode installation, run:
+
+    $ sudo xcode-select --switch path/to/Xcode.app
+
+or install Xcode from the macOS App Store. Re-run Briefcase afterwards.
+""")
+
     if not Path(xcode_location).exists():
         raise BriefcaseCommandError("""
 Xcode is not installed.
@@ -145,6 +165,7 @@ You can install Xcode from the macOS App Store.
 
 Re-run Briefcase once that installation is complete.
 """)
+
     try:
         output = command.subprocess.check_output(
             ['xcodebuild', '-version'],
@@ -202,11 +223,15 @@ Re-run Briefcase once that installation is complete.
     except subprocess.CalledProcessError as e:
         if " is a command line tools instance" in e.output:
             raise BriefcaseCommandError("""
-Xcode is installed, but the active developer directory is a
-command line tools instance. To make XCode the active developer
-directory, run:
+Xcode may be installed, but the active developer directory is a
+command line tools instance. To make the default Xcode install the
+active developer directory, run:
+
+    $ sudo xcode-select --switch /Applications/Xcode.app
+
+Or, to use a version of Xcode installed in a non-default location:
 
-    $ sudo xcode-select -switch /Applications/Xcode.app
+    $ sudo xcode-select --switch /path/to/Xcode.app
 
 and then re-run Briefcase.
 """)
@@ -247,7 +272,7 @@ accepted before you can use those tools.
 You can accept these licenses by starting Xcode and clicking "Accept"; or, you
 can run:
 
-    sudo xcodebuild -license
+    $ sudo xcodebuild -license
 
 at the command line and accepting the license there.
 


Test patch:
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index b1b2665c..181625fe 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -19,6 +19,21 @@ def xcode(tmp_path):
 def test_not_installed(tmp_path):
     "If Xcode is not installed, raise an error."
     command = mock.MagicMock()
+    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['xcode-select', '-p'],
+        returncode=2
+    )
+
+    # Test a location where Xcode *won't* be installed
+    with pytest.raises(BriefcaseCommandError):
+        ensure_xcode_is_installed(
+            command,
+        )
+
+
+def test_not_installed_hardcoded_path(tmp_path):
+    "If Xcode is not installed at the given location, raise an error."
+    command = mock.MagicMock()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['xcodebuild', '-version'],
         returncode=1
@@ -48,7 +63,7 @@ def test_exists_but_command_line_tools_selected(xcode):
         "is a command line tools instance\n"
     )
 
-    with pytest.raises(BriefcaseCommandError, match=r"xcode-select -switch"):
+    with pytest.raises(BriefcaseCommandError, match=r"xcode-select --switch"):
         ensure_xcode_is_installed(command, xcode_location=xcode)
 
     # xcode-select was invoked
@@ -138,23 +153,41 @@ def test_installed_no_minimum_version(xcode):
 )
 def test_installed_with_minimum_version_success(min_version, version, capsys, xcode):
     "Check XCode can meet a minimum version requirement."
+
+    def check_output_mock(cmd_list, *args, **kwargs):
+
+        if cmd_list == ['xcode-select', '-p']:
+            return xcode + "\n"
+
+        if cmd_list == ['xcodebuild', '-version']:
+            return "Xcode {version}\nBuild version 11B500\n".format(version=version)
+
+        return mock.DEFAULT
+
     command = mock.MagicMock()
-    command.subprocess.check_output.return_value = "Xcode {version}\nBuild version 11B500\n".format(
-        version=version
-    )
+    command.subprocess.check_output.side_effect = check_output_mock
 
     # Check passes without an error.
     ensure_xcode_is_installed(
         command,
         min_version=min_version,
-        xcode_location=xcode,
     )
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ['xcodebuild', '-version'],
-        stderr=subprocess.STDOUT,
-        universal_newlines=True,
+    # assert xcode-select and xcodebuild were invoked
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ['xcode-select', '-p'],
+                stderr=subprocess.STDOUT,
+                universal_newlines=True,
+            ),
+            mock.call(
+                ['xcodebuild', '-version'],
+                stderr=subprocess.STDOUT,
+                universal_newlines=True,
+            ),
+        ],
+        any_order=False,
     )
 
     # Make sure the warning wasn't displayed.