Repository: beeware/briefcase
Pull request #2203 (2025-03-19 06:31:13)

Add XML content escaping filter to cookiecutter,py
Added an XML content escaping filter to cookiecutter.py.
By using this filter in the windows app and visual studio templates, special characters  & < and > will be escaped. refs #2103 

## 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



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/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/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/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):


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