Repository: beeware/briefcase
Pull request #2199 (2025-03-17 20:42:00)

Adding catch exception when deleting jdk
Adding error handling in the `uninstall()` method to catch `PermissionError` exceptions that happens if Java processes are still active or if file permissions prevent deletion. Provided a clear error message advising users to ensure no Java processes are running before retrying uninstallation.

Previously, when uninstalling the JDK while Java was running, the tool would crash with no meaningful error message.

Catch exceptions when deleting JDK while it's running on Windows #1520

## PR Checklist:
- [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/.github/dependabot.yml b/.github/dependabot.yml
index 3b302bb7..3f565057 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -16,3 +16,11 @@ updates:
       interval: "weekly"
       day: "sunday"
       time: "20:00"
+
+  - package-ecosystem: "pip"
+    directory: "automation"
+    schedule:
+      # Check for updates on Sunday, 8PM UTC
+      interval: "weekly"
+      day: "sunday"
+      time: "20:00"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f856f77b..b555a9e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,7 +90,7 @@ jobs:
         allow-prereleases: true
 
     - name: Get Packages
-      uses: actions/download-artifact@v4.1.9
+      uses: actions/download-artifact@v4.2.1
       with:
         name: ${{ needs.package.outputs.artifact-name }}
         path: dist
@@ -111,7 +111,7 @@ jobs:
 
     - name: Store Coverage Data
       if: always() && contains('success,failure', steps.test.outcome)
-      uses: actions/upload-artifact@v4.6.1
+      uses: actions/upload-artifact@v4.6.2
       with:
         name: coverage-data-${{ matrix.platform }}-${{ matrix.python-version }}
         path: ".coverage.*"
@@ -149,7 +149,7 @@ jobs:
         extra: dev
 
     - name: Retrieve Coverage Data
-      uses: actions/download-artifact@v4.1.9
+      uses: actions/download-artifact@v4.2.1
       with:
         pattern: coverage-data-*
         merge-multiple: true
@@ -167,7 +167,7 @@ jobs:
 
     - name: Upload Project Coverage HTML Report
       if: always() && steps.project-coverage.outcome == 'failure'
-      uses: actions/upload-artifact@v4.6.1
+      uses: actions/upload-artifact@v4.6.2
       with:
         name: html-coverage-report-project
         path: htmlcov
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bd40a773..26863268 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -39,7 +39,7 @@ jobs:
           python-version: "3.x"
 
       - name: Get packages
-        uses: actions/download-artifact@v4.1.9
+        uses: actions/download-artifact@v4.2.1
         with:
           name: ${{ needs.ci.outputs.artifact-name }}
           path: dist
diff --git a/automation/pyproject.toml b/automation/pyproject.toml
index 97d8c848..051d2e72 100644
--- a/automation/pyproject.toml
+++ b/automation/pyproject.toml
@@ -1,8 +1,8 @@
 [build-system]
 requires = [
     # keep versions in sync with ../pyproject.toml
-    "setuptools==75.5.0",
-    "setuptools_scm==8.1.0",
+    "setuptools==77.0.3",
+    "setuptools_scm==8.2.0",
     "setuptools_dynamic_dependencies @ git+https://github.com/beeware/setuptools_dynamic_dependencies",
 ]
 build-backend = "setuptools.build_meta"
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
 name = "x-briefcase-automation"
 description = "A Briefcase plugin for CI automation."
 readme = "README.md"
-license.text = "New BSD"
+license = "BSD-3-Clause"
 classifiers = ["Private :: Do Not Upload"]
 dynamic = ["version", "dependencies"]
 
diff --git a/changes/1520.bugfix.rst b/changes/1520.bugfix.rst
new file mode 100644
index 00000000..07815012
--- /dev/null
+++ b/changes/1520.bugfix.rst
@@ -0,0 +1,1 @@
+Error handling during JDK upgrades has been improved.
diff --git a/changes/2103.feature.rst b/changes/2103.feature.rst
new file mode 100644
index 00000000..561ea064
--- /dev/null
+++ b/changes/2103.feature.rst
@@ -0,0 +1,1 @@
+Cookiecutter filters for escaping XML content and attributes have been added.
diff --git a/changes/2128.feature.rst b/changes/2128.feature.rst
new file mode 100644
index 00000000..ad493044
--- /dev/null
+++ b/changes/2128.feature.rst
@@ -0,0 +1,1 @@
+It's now possible to ask boolean question using the Console Interface.
diff --git a/changes/2204.removal.rst b/changes/2204.removal.rst
new file mode 100644
index 00000000..d8f14517
--- /dev/null
+++ b/changes/2204.removal.rst
@@ -0,0 +1,1 @@
+The ``app_packages`` folder now occurs *after* the ``app`` folder in the package resolution path on Windows, Linux, macOS and iOS. This will result in subtle changes in behavior if you have packages defined in your ``sources``  that have the same name as packages installed from your ``requires``.
diff --git a/changes/2205.misc.rst b/changes/2205.misc.rst
new file mode 100644
index 00000000..8392c05f
--- /dev/null
+++ b/changes/2205.misc.rst
@@ -0,0 +1,1 @@
+Updated actions/download-artifact from 4.1.9 to 4.2.1.
diff --git a/changes/2206.misc.rst b/changes/2206.misc.rst
new file mode 100644
index 00000000..f67d204c
--- /dev/null
+++ b/changes/2206.misc.rst
@@ -0,0 +1,1 @@
+Updated actions/upload-artifact from 4.6.1 to 4.6.2.
diff --git a/changes/2207.misc.rst b/changes/2207.misc.rst
new file mode 100644
index 00000000..c678c56a
--- /dev/null
+++ b/changes/2207.misc.rst
@@ -0,0 +1,1 @@
+Updated setuptools from 76.0.0 to 77.0.3.
diff --git a/changes/2208.misc.rst b/changes/2208.misc.rst
new file mode 100644
index 00000000..b97ea5f1
--- /dev/null
+++ b/changes/2208.misc.rst
@@ -0,0 +1,1 @@
+Updated coverage[toml] from 7.7.0 to 7.7.1.
diff --git a/changes/2209.misc.rst b/changes/2209.misc.rst
new file mode 100644
index 00000000..1ae4f335
--- /dev/null
+++ b/changes/2209.misc.rst
@@ -0,0 +1,1 @@
+Updated pre-commit from 4.1.0 to 4.2.0.
diff --git a/changes/2210.misc.rst b/changes/2210.misc.rst
new file mode 100644
index 00000000..20e6f02b
--- /dev/null
+++ b/changes/2210.misc.rst
@@ -0,0 +1,1 @@
+Updated setuptools-scm from 8.1.0 to 8.2.0 in /automation.
diff --git a/changes/381.bugfix.rst b/changes/381.bugfix.rst
new file mode 100644
index 00000000..59042d48
--- /dev/null
+++ b/changes/381.bugfix.rst
@@ -0,0 +1,1 @@
+``.pth`` files created by packages installed as dependencies are now correctly processed during application startup on macOS, Windows, Linux and iOS.
diff --git a/pyproject.toml b/pyproject.toml
index 7d694e28..ebce9036 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
     # keep versions in sync with automation/pyproject.toml
-    "setuptools==76.0.0",
+    "setuptools==77.0.3",
     "setuptools_scm==8.2.0",
 ]
 build-backend = "setuptools.build_meta"
@@ -12,7 +12,10 @@ name = "briefcase"
 description = "Tools to support converting a Python project into a standalone native application."
 readme = "README.rst"
 requires-python = ">= 3.9"
-license.text = "New BSD"
+license = "BSD-3-Clause"
+license-files = [
+    "LICENSE",
+]
 authors = [
     {name="Russell Keith-Magee", email="russell@keith-magee.com"},
 ]
@@ -45,7 +48,6 @@ keywords = [
 classifiers = [
     "Development Status :: 4 - Beta",
     "Intended Audience :: Developers",
-    "License :: OSI Approved :: BSD License",
     "Operating System :: OS Independent",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3.9",
@@ -95,9 +97,9 @@ dependencies = [
 # Extras used by developers *of* briefcase are pinned to specific versions to
 # ensure environment consistency.
 dev = [
-    "coverage[toml] == 7.7.0",
+    "coverage[toml] == 7.7.1",
     "coverage-conditional-plugin == 0.9.0",
-    "pre-commit == 4.1.0",
+    "pre-commit == 4.2.0",
     "pytest == 8.3.5",
     "pytest-xdist == 3.6.1",
     "setuptools_scm == 8.2.0",
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 2c245870..607f889d 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -201,6 +201,24 @@ def parsed_version(version):
     return SimpleNamespace(**groupdict)
 
 
+def parse_boolean(value: str) -> bool:
+    """Takes a string value and attempts to convert to a boolean value"""
+
+    truth_vals = {"true", "t", "yes", "y", "1", "on"}
+    false_vals = {"false", "f", "no", "n", "0", "off"}
+
+    normalised_val = value.strip().lower()
+
+    if normalised_val in truth_vals:
+        return True
+    elif normalised_val in false_vals:
+        return False
+    else:
+        raise ValueError(
+            f"Invalid boolean value: {value!r}. Expected one of {truth_vals | false_vals}"
+        )
+
+
 class BaseConfig:
     def __init__(self, **kwargs):
         for attr, value in kwargs.items():
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index 9384daf9..69df38b3 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -30,6 +30,7 @@ from rich.progress import (
 from rich.traceback import Trace, Traceback
 
 from briefcase import __version__
+from briefcase.config import parse_boolean
 from briefcase.exceptions import InputDisabled
 
 # Max width for printing to console; matches argparse's default width
@@ -732,7 +733,7 @@ class Console:
         :returns: True if the user selected "y", or False if they selected "n".
         """
         if default is None:
-            yes_no = "y/n"
+            yes_no = "[y/n]"
             default_text = None
         elif default:
             yes_no = "[Y/n]"
@@ -960,3 +961,39 @@ class Console:
         )
 
         return ordered[int(index) - 1][0]
+
+    def boolean_question(
+        self,
+        description,
+        intro: str,
+        default: bool | None = None,
+        override_value: str | None = None,
+    ) -> bool:
+        """Ask the user a boolean question who's answer requires selecting yes/no
+
+        :param description: A short description of the question being asked. This text
+            is used in prompts and a header bar prefacing the question.
+        :param intro: An introductory paragraph explaining the question being asked.
+        :param default: The default option for empty user input.
+        :param override_value: A pre-selected answer for the question. This can be used
+            to shortcut asking the question, such as when a command line option provides
+            a value. If provided and valid, the header bar will be displayed, but the
+            intro paragraph and option list will not. Will take the provided string and attempt to parse into bool
+        :returns: The user's chosen answer or none if closed without input
+        """
+
+        self.divider(title=description)
+
+        if override_value is not None:
+            self.print()
+            self.print(f"Using override value {override_value!r}")
+            try:
+                return parse_boolean(override_value)
+            except ValueError as e:
+                raise ValueError(f"Invalid override value for {description}: {e}")
+
+        self.prompt()
+        self.prompt(self.textwrap(intro))
+        self.prompt()
+
+        return self.input_boolean(description, default=default)
diff --git a/src/briefcase/integrations/cookiecutter.py b/src/briefcase/integrations/cookiecutter.py
index d691b289..b9ac4540 100644
--- a/src/briefcase/integrations/cookiecutter.py
+++ b/src/briefcase/integrations/cookiecutter.py
@@ -1,6 +1,7 @@
 """Jinja2 extensions."""
 
 import uuid
+from xml.sax.saxutils import escape, quoteattr
 
 from jinja2.ext import Extension
 
@@ -139,7 +140,17 @@ class XMLExtension(Extension):
             """Render value in XML format appropriate for an attribute."""
             return "true" if obj else "false"
 
+        def xml_escape(obj):
+            """Filter to escape characters <, >, &, " and '"""
+            return escape(obj)
+
+        def xml_attr(obj):
+            """ "Filter to quote an XML value appropriately."""
+            return quoteattr(obj)
+
         environment.filters["bool_attr"] = bool_attr
+        environment.filters["xml_escape"] = xml_escape
+        environment.filters["xml_attr"] = xml_attr
 
 
 class UUIDExtension(Extension):
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 7e0684a7..e363ec7d 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -310,8 +310,19 @@ Delete {jdk_zip_path} and run briefcase again.
 
     def uninstall(self):
         """Uninstall a JDK."""
-        with self.tools.console.wait_bar("Removing old JDK install..."):
-            if self.tools.host_os == "Darwin":
-                self.tools.shutil.rmtree(self.java_home.parent.parent)
-            else:
-                self.tools.shutil.rmtree(self.java_home)
+        try:
+            with self.tools.console.wait_bar("Removing old JDK install..."):
+                if self.tools.host_os == "Darwin":
+                    jdk_location = self.java_home.parent.parent
+                else:
+                    jdk_location = self.java_home
+                self.tools.shutil.rmtree(jdk_location)
+
+        except PermissionError as e:
+            raise BriefcaseCommandError(
+                f"""\
+Permission denied when trying to remove {jdk_location!r}
+
+Ensure no Java processes are running and try again.
+"""
+            ) from e


Test patch:
diff --git a/tests/config/test_parse_boolean.py b/tests/config/test_parse_boolean.py
new file mode 100644
index 00000000..a022bd7d
--- /dev/null
+++ b/tests/config/test_parse_boolean.py
@@ -0,0 +1,33 @@
+import pytest
+
+from briefcase.config import parse_boolean
+
+
+@pytest.mark.parametrize(
+    "user_input_val",
+    ["true", "TRUE", "t", "T", "yes", "YES", "y", "Y", "1", "on", "ON"],
+)
+def test_true_values(user_input_val):
+    assert parse_boolean(user_input_val) is True
+
+
+@pytest.mark.parametrize(
+    "user_input_val",
+    ["false", "FALSE", "f", "F", "no", "NO", "n", "N", "0", "off", "OFF"],
+)
+def test_false_values(user_input_val):
+    assert parse_boolean(user_input_val) is False
+
+
+@pytest.mark.parametrize("user_input_val", ["maybe", "2", "", "help"])
+def test_invalid_values(user_input_val):
+    with pytest.raises(ValueError):
+        parse_boolean(user_input_val)
+
+
+@pytest.mark.parametrize(
+    "user_input_val, expected",
+    [("         yEs ", True), ("nO          ", False), ("    YEs", True)],
+)
+def test_whitespace(user_input_val, expected):
+    assert parse_boolean(user_input_val) is expected
diff --git a/tests/console/Console/test_boolean_question.py b/tests/console/Console/test_boolean_question.py
new file mode 100644
index 00000000..b55b889c
--- /dev/null
+++ b/tests/console/Console/test_boolean_question.py
@@ -0,0 +1,108 @@
+import re
+
+import pytest
+
+from tests.utils import DummyConsole
+
+
+def test_boolean_question_yes():
+    """Test that boolean_question returns True when user selects Yes."""
+    console = DummyConsole("y")
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+    )
+
+    assert console.prompts == ["Confirm? [y/n]? "]
+    assert result is True
+
+
+def test_boolean_question_no():
+    """Test that boolean_question returns False when user selects No."""
+    console = DummyConsole("n")
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+    )
+
+    assert console.prompts == ["Confirm? [y/n]? "]
+    assert result is False
+
+
+@pytest.mark.parametrize(
+    "default, expected, prompt",
+    [
+        (True, True, "Confirm? [Y/n]? "),
+        (False, False, "Confirm? [y/N]? "),
+    ],
+)
+def test_boolean_question_default_used(default, expected, prompt):
+    """If no input is provided, the default value should be used."""
+    console = DummyConsole("")
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=default,
+    )
+
+    assert console.prompts == [prompt]
+    assert result == expected
+
+
+@pytest.mark.parametrize(
+    "override_value, expected_result",
+    [
+        ("Yes", True),
+        ("no", False),
+    ],
+)
+def test_boolean_question_override_used(capsys, override_value, expected_result):
+    """The override is used if provided and valid (parametrized for True/False cases)."""
+    console = DummyConsole()
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+        override_value=override_value,
+    )
+
+    output = capsys.readouterr().out
+    assert f"Using override value {override_value!r}" in output
+    assert result == expected_result
+    assert console.prompts == []
+
+
+def test_boolean_question_override_invalid():
+    """If override_value is invalid, an error should be raised immediately."""
+    console = DummyConsole()
+
+    with pytest.raises(
+        ValueError,
+        match=re.escape("Invalid boolean value: 'Yeah Nah'. Expected one of "),
+    ):
+        console.boolean_question(
+            description="Confirm?",
+            intro="Are you sure?",
+            default=True,
+            override_value="Yeah Nah",
+        )
+
+
+def test_boolean_question_empty_input_no_default_returns_false():
+    """Reprompts the user when no override or default given"""
+    console = DummyConsole("", "", "n")  # Simulates pressing Enter
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+        override_value=None,
+    )
+
+    assert result is False
diff --git a/tests/integrations/cookiecutter/test_XMLExtension.py b/tests/integrations/cookiecutter/test_XMLExtension.py
index 54b256a4..7b3199b1 100644
--- a/tests/integrations/cookiecutter/test_XMLExtension.py
+++ b/tests/integrations/cookiecutter/test_XMLExtension.py
@@ -24,3 +24,41 @@ def test_bool_attr(value, expected):
     env.filters = {}
     XMLExtension(env)
     assert env.filters["bool_attr"](value) == expected
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        # No special characters
+        ("Hello World", "Hello World"),
+        # Ampersands escaped
+        ("Hello & World", "Hello &amp; World"),
+        # Less than
+        ("Hello < World", "Hello &lt; World"),
+        # Greater than
+        ("Hello > World", "Hello &gt; World"),
+    ],
+)
+def test_xml_escape(value, expected):
+    env = MagicMock()
+    env.filters = {}
+    XMLExtension(env)
+    assert env.filters["xml_escape"](value) == expected
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        # A single quote wrapped in double quotes
+        ("Hello ' World", '"Hello \' World"'),
+        # A double quote wrapped in single quotes
+        ('Hello " World', "'Hello \" World'"),
+        # A double quote and a single quote in the same string value
+        ("Hello \" And ' World", '"Hello &quot; And \' World"'),
+    ],
+)
+def test_xml_attr(value, expected):
+    env = MagicMock()
+    env.filters = {}
+    XMLExtension(env)
+    assert env.filters["xml_attr"](value) == expected
diff --git a/tests/integrations/java/test_JDK__uninstall.py b/tests/integrations/java/test_JDK__uninstall.py
new file mode 100644
index 00000000..efd94353
--- /dev/null
+++ b/tests/integrations/java/test_JDK__uninstall.py
@@ -0,0 +1,83 @@
+import re
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+)
+from briefcase.integrations.java import JDK
+
+
+@pytest.mark.parametrize(
+    "host_os, java_home",
+    [
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
+    ],
+)
+def test_java_running(mock_tools, host_os, java_home, tmp_path):
+    """If Java is running, uninstall will raise an error message."""
+    # Mock os
+    mock_tools.host_os = host_os
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / java_home / "bin").mkdir(parents=True)
+
+    # Create an JDK wrapper
+    jdk = JDK(mock_tools, java_home=tmp_path / java_home)
+
+    # Mock shutil.rmtree so that it gives a permission error
+    mock_tools.shutil.rmtree.side_effect = PermissionError(
+        "File is being used by other processes"
+    )
+
+    # Set correct path according to each OS
+    if host_os == "Darwin":
+        expected_loc = (tmp_path / java_home).parent.parent
+    else:
+        expected_loc = tmp_path / java_home
+
+    # Attempting to uninstall JDK which will fail due to Permission Error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=re.escape(f"Permission denied when trying to remove {expected_loc!r}"),
+    ):
+        jdk.uninstall()
+
+
+@pytest.mark.parametrize(
+    "host_os, java_home",
+    [
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
+    ],
+)
+def test_java_not_running(mock_tools, host_os, java_home, tmp_path):
+    """If Java is not running, uninstalling should proceed smoothly."""
+    # Mock os
+    mock_tools.host_os = host_os
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / java_home / "bin").mkdir(parents=True)
+
+    # Create an JDK wrapper
+    jdk = JDK(mock_tools, java_home=tmp_path / java_home)
+
+    # Set correct path according to each OS
+    if host_os == "Darwin":
+        expected_loc = (tmp_path / java_home).parent.parent
+    else:
+        expected_loc = tmp_path / java_home
+
+    # Mock so that shutil behave normally
+    mock_tools.shutil.rmtree = MagicMock()
+
+    # Uninstall JDK
+    jdk.uninstall()
+
+    # Verify that shutil works normally and that correct location is passed
+    mock_tools.shutil.rmtree.assert_called_once_with(expected_loc)