Repository: beeware/briefcase
Pull request #1413 (2023-08-21 05:22:37)

Move command-line args out of dev-mode Python invocation
For some reason NSApplication/CoreFoundation gets very confused if we pass certain command-line arguments to the outer Python interpreter when running in dev mode.

This patch removes all command-line arguments from the invocation, using environment variables to achieve the equivalent outcomes. The remaining argument, the actual code to start the application, appears to not cause problems.


Patch:
diff --git a/changes/1412.misc.rst b/changes/1412.misc.rst
deleted file mode 100644
index 9334d6af..00000000
--- a/changes/1412.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Changed build output for iOS to refer to 'Xcode' instead of 'XCode'.
diff --git a/changes/1413.bugfix.rst b/changes/1413.bugfix.rst
new file mode 100644
index 00000000..746aedab
--- /dev/null
+++ b/changes/1413.bugfix.rst
@@ -0,0 +1,1 @@
+The command line arguments used to configure the Python environment for ``briefcase dev`` no longer leak into the runtime environment on macOS.
diff --git a/docs/how-to/PyStackTraceOnXcode.png b/docs/how-to/PyStackTraceOnXCode.png
similarity index 100%
rename from docs/how-to/PyStackTraceOnXcode.png
rename to docs/how-to/PyStackTraceOnXCode.png
diff --git a/docs/how-to/see_errors_on_ios.rst b/docs/how-to/see_errors_on_ios.rst
index 78f0e6b1..71c5c7f0 100644
--- a/docs/how-to/see_errors_on_ios.rst
+++ b/docs/how-to/see_errors_on_ios.rst
@@ -13,4 +13,4 @@ the stack trace. Here's how to do it -
    screen. If you can't see that area, you may have to activate it with
    ``View`` > ``Debug Area`` > ``Show Debug Area``
 
- .. image:: PyStackTraceOnXcode.png
+ .. image:: PyStackTraceOnXCode.png
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index 1ad27576..268e0863 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -18,6 +18,25 @@ class DevCommand(RunAppMixin, BaseCommand):
     output_format = None
     description = "Run a Briefcase project in the dev environment."
 
+    # On macOS CoreFoundation/NSApplication will do its own independent parsing of argc/argv.
+    # This means that whatever we pass to the Python interpreter on start-up will also be
+    # visible to NSApplication which will interpret things like `-u` (used to make I/O
+    # unbuffered in CPython) as `-u [URL]` (a request to open a document by URL). This is,
+    # rather patently, Not Good.
+    # To avoid this causing unwanted hilarity, we use environment variables to configure the
+    # Python interpreter rather than command-line options.
+    DEV_ENVIRONMENT = {
+        # Equivalent of passing "-u"
+        "PYTHONUNBUFFERED": "1",
+        # Equivalent of passing "-X dev"
+        "PYTHONMALLOC": "debug",
+        "PYTHONASYNCIODEBUG": "1",
+        "PYTHONFAULTHANDLER": "1",
+        "PYTHONWARNINGS": "default",
+        # Equivalent of passing "-X utf8"
+        "PYTHONUTF8": "1",
+    }
+
     @property
     def platform(self):
         """The dev command always reports as the local platform."""
@@ -104,14 +123,13 @@ class DevCommand(RunAppMixin, BaseCommand):
         :param passthrough: A list of arguments to pass to the app
         """
         main_module = app.main_module(test_mode)
+
+        # Add in the environment settings to get Python in the state we want.
+        env.update(self.DEV_ENVIRONMENT)
+
         app_popen = self.tools.subprocess.Popen(
             [
                 sys.executable,
-                "-u",
-                "-X",
-                "dev",
-                "-X",
-                "utf8",
                 "-c",
                 (
                     "import runpy, sys;"
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 19167e9a..33886b1b 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -68,7 +68,7 @@ class Xcode(Tool):
         """Determine if Xcode is installed; and if so, that it meets minimum version
         requirements.
 
-        Raises an exception if Xcode isn't installed, or if the version of Xcode
+        Raises an exception if XCode isn't installed, or if the version of Xcode
         that is installed doesn't meet the minimum requirement.
 
         :param tools: ToolCache of available tools
@@ -290,7 +290,7 @@ to continue, and re-run Briefcase once that installation is complete.
 
         :param tools: ToolCache of available tools
         """
-        # Lastly, check if the Xcode license has been accepted. The command line
+        # Lastly, check if the XCode license has been accepted. The command line
         # tools return a status code of 69 (nice...) if the license has not been
         # accepted. In this case, we can prompt the user to accept the license.
         try:
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index 3011b492..b28afca2 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -307,7 +307,7 @@ class iOSXcodeBuildCommand(iOSXcodePassiveMixin, BuildCommand):
         self.logger.info("Updating app metadata...", prefix=app.app_name)
         self.update_app_metadata(app=app, test_mode=test_mode)
 
-        self.logger.info("Building Xcode project...", prefix=app.app_name)
+        self.logger.info("Building XCode project...", prefix=app.app_name)
         with self.input.wait_bar("Building..."):
             try:
                 self.tools.subprocess.run(
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index fd547ce9..9522069d 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -433,7 +433,7 @@ class macOSPackageMixin(macOSSigningMixin):
         )
 
     def verify_tools(self):
-        # Require the Xcode command line tools.
+        # Require the XCode command line tools.
         XcodeCliTools.verify(tools=self.tools)
 
         # Verify superclass tools *after* xcode. This ensures we get the
diff --git a/src/briefcase/platforms/macOS/xcode.py b/src/briefcase/platforms/macOS/xcode.py
index 9b416a19..820e85f2 100644
--- a/src/briefcase/platforms/macOS/xcode.py
+++ b/src/briefcase/platforms/macOS/xcode.py
@@ -56,7 +56,7 @@ class macOSXcodeBuildCommand(macOSXcodeMixin, BuildCommand):
 
         :param app: The application to build
         """
-        self.logger.info("Building Xcode project...", prefix=app.app_name)
+        self.logger.info("Building XCode project...", prefix=app.app_name)
         with self.input.wait_bar("Building..."):
             try:
                 self.tools.subprocess.run(


Test patch:
diff --git a/tests/commands/dev/conftest.py b/tests/commands/dev/conftest.py
index 9b754faa..4297eefa 100644
--- a/tests/commands/dev/conftest.py
+++ b/tests/commands/dev/conftest.py
@@ -11,6 +11,8 @@ from briefcase.integrations.subprocess import Subprocess
 @pytest.fixture
 def dev_command(tmp_path):
     command = DevCommand(logger=Log(), console=Console(), base_path=tmp_path)
+    command.DEV_ENVIRONMENT.clear()
+    command.DEV_ENVIRONMENT["PYTHONSTUBSETTING"] = "23"
     command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
     return command
 
diff --git a/tests/commands/dev/test_run_dev_app.py b/tests/commands/dev/test_run_dev_app.py
index c9d19641..265d8731 100644
--- a/tests/commands/dev/test_run_dev_app.py
+++ b/tests/commands/dev/test_run_dev_app.py
@@ -15,14 +15,13 @@ def test_dev_run(dev_command, first_app, tmp_path):
         test_mode=False,
         passthrough=[],
     )
+
+    expected_env = {"a": 1, "b": 2, "c": 3}
+    expected_env.update(dev_command.DEV_ENVIRONMENT)
+
     dev_command.tools.subprocess.Popen.assert_called_once_with(
         [
             sys.executable,
-            "-u",
-            "-X",
-            "dev",
-            "-X",
-            "utf8",
             "-c",
             (
                 "import runpy, sys;"
@@ -31,7 +30,7 @@ def test_dev_run(dev_command, first_app, tmp_path):
                 'runpy.run_module("first", run_name="__main__", alter_sys=True)'
             ),
         ],
-        env={"a": 1, "b": 2, "c": 3},
+        env=expected_env,
         cwd=dev_command.tools.home_path,
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
@@ -57,14 +56,13 @@ def test_dev_run_with_args(dev_command, first_app, tmp_path):
         test_mode=False,
         passthrough=["foo", "bar", "--whiz"],
     )
+
+    expected_env = {"a": 1, "b": 2, "c": 3}
+    expected_env.update(dev_command.DEV_ENVIRONMENT)
+
     dev_command.tools.subprocess.Popen.assert_called_once_with(
         [
             sys.executable,
-            "-u",
-            "-X",
-            "dev",
-            "-X",
-            "utf8",
             "-c",
             (
                 "import runpy, sys;"
@@ -73,7 +71,7 @@ def test_dev_run_with_args(dev_command, first_app, tmp_path):
                 'runpy.run_module("first", run_name="__main__", alter_sys=True)'
             ),
         ],
-        env={"a": 1, "b": 2, "c": 3},
+        env=expected_env,
         cwd=dev_command.tools.home_path,
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
@@ -99,14 +97,13 @@ def test_dev_test_mode(dev_command, first_app, tmp_path):
         test_mode=True,
         passthrough=[],
     )
+
+    expected_env = {"a": 1, "b": 2, "c": 3}
+    expected_env.update(dev_command.DEV_ENVIRONMENT)
+
     dev_command.tools.subprocess.Popen.assert_called_once_with(
         [
             sys.executable,
-            "-u",
-            "-X",
-            "dev",
-            "-X",
-            "utf8",
             "-c",
             (
                 "import runpy, sys;"
@@ -115,7 +112,7 @@ def test_dev_test_mode(dev_command, first_app, tmp_path):
                 'runpy.run_module("tests.first", run_name="__main__", alter_sys=True)'
             ),
         ],
-        env={"a": 1, "b": 2, "c": 3},
+        env=expected_env,
         cwd=dev_command.tools.home_path,
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
@@ -141,14 +138,13 @@ def test_dev_test_mode_with_args(dev_command, first_app, tmp_path):
         test_mode=True,
         passthrough=["foo", "bar", "--whiz"],
     )
+
+    expected_env = {"a": 1, "b": 2, "c": 3}
+    expected_env.update(dev_command.DEV_ENVIRONMENT)
+
     dev_command.tools.subprocess.Popen.assert_called_once_with(
         [
             sys.executable,
-            "-u",
-            "-X",
-            "dev",
-            "-X",
-            "utf8",
             "-c",
             (
                 "import runpy, sys;"
@@ -157,7 +153,7 @@ def test_dev_test_mode_with_args(dev_command, first_app, tmp_path):
                 'runpy.run_module("tests.first", run_name="__main__", alter_sys=True)'
             ),
         ],
-        env={"a": 1, "b": 2, "c": 3},
+        env=expected_env,
         cwd=dev_command.tools.home_path,
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index a83b5223..e1e093d7 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -293,7 +293,7 @@ def test_installed_with_minimum_version_success(
     xcode,
     mock_tools,
 ):
-    """Check Xcode can meet a minimum version requirement."""
+    """Check XCode can meet a minimum version requirement."""
 
     def check_output_mock(cmd_list, *args, **kwargs):
         if cmd_list == ["xcode-select", "-p"]:
@@ -343,7 +343,7 @@ def test_installed_with_minimum_version_failure(
     xcode,
     mock_tools,
 ):
-    """Check Xcode fail to meet a minimum version requirement."""
+    """Check XCode fail to meet a minimum version requirement."""
     mock_tools.subprocess.check_output.side_effect = [
         xcode + "\n",  # xcode-select -p
         f"Xcode {version}\nBuild version 11B500\n",  # xcodebuild -version
diff --git a/tests/platforms/iOS/xcode/mixin/test_select_target_device.py b/tests/platforms/iOS/xcode/mixin/test_select_target_device.py
index a7108195..a959960d 100644
--- a/tests/platforms/iOS/xcode/mixin/test_select_target_device.py
+++ b/tests/platforms/iOS/xcode/mixin/test_select_target_device.py
@@ -10,7 +10,7 @@ from tests.utils import DummyConsole
 
 
 class DummyCommand(iOSXcodeMixin, BaseCommand):
-    """A dummy command that includes the iOS Xcode mixin."""
+    """A dummy command that includes the iOS XCode mixin."""
 
     command = "dummy"