Repository: beeware/briefcase

Issue:
Distro-specific Linux settings cumulative settings don't accumulate
### Describe the bug

If you put a `requires` definition into a Linux distro-specific app configuration block, the requires definition *overrides* the project, app, and Linux definitions, rather than appending to them.

### Steps to reproduce

1. Generate a "hello world" Toga project
2. Modify the `[tool.briefcase.helloworld.linux.debian]` section to include `requires = []`
3. Build the project for a Debian-derived release: `briefcase build linux system --target debian:bookworm`
4. Try to run the app. It will fail saying that `toga` could not be found.

Alternatively, check the build log from step 3; observe that `toga-gtk` was not installed.


### Expected behavior

Adding `requires`, `sources`, `test_requires`, `test_sources` or `permission` definitions to the distro-specific section of an app definition should be cumulative, not overwrites.

### Screenshots

_No response_

### Environment

- Operating System: Linux (or building on macOS, targeting linux)
- Python version: any
- Software versions:
  - Briefcase: Observed in 0.3.21; exists in older releases.


### Logs

```

```


### Additional context

When [distro-specific app settings are processed (LinuxSystemPassiveMixin.finalize_app_config)](https://github.com/beeware/briefcase/blob/main/src/briefcase/platforms/linux/system.py#L200), the code is explicitly *copying* properties, not merging. It should be using the [merge_config](https://github.com/beeware/briefcase/blob/main/src/briefcase/config.py#L420) utility method, rather than iterating over properties and copying them. 

Using this method will also require adding an `update` method to BaseConfig.

Discussion:
@A1pha-Z3r0: Hey, I'm new to PR's and stuff, do you think this would be a good first contribution? If so could you help me out?@freakboy3742: @A1pha-Z3r0 Yes - this should be a good first contribution - that's why we flagged this issue with the `good-first-issue` tag!

We're happy to help where we can, but we need to know what help you need :-)

Briefcase has a [contribution guide](https://briefcase.readthedocs.io/en/stable/how-to/contribute-code.html) to help you get a development environment set up. 

I'd start by trying to reproduce the problem as described - the ticket description details how to do this. Once you've reproduced the problem, you can try to fix it; again, the ticket description contains a broad description of the change that is required. Implement that change, and confirm you can no longer reproduce the problem; then add a test for what you've changed, and you should be ready to submit a PR.

If you need more pointers than that, let us know what you need and we'll do our best to help!@ekhunter123: My name's Emily, I'm at the PyCon US sprints. I will be taking a look at this today! :)@freakboy3742: Fixed by #2297.

Solution patch:
diff --git a/changes/2188.bugfix.rst b/changes/2188.bugfix.rst
new file mode 100644
index 000000000..7f7c635cd
--- /dev/null
+++ b/changes/2188.bugfix.rst
@@ -0,0 +1 @@
+Distribution-specific cumulative settings (such as `requires`) now correctly accumulate, rather than overwriting more general values.
diff --git a/changes/2295.misc.rst b/changes/2295.misc.rst
new file mode 100644
index 000000000..7e1e000ff
--- /dev/null
+++ b/changes/2295.misc.rst
@@ -0,0 +1 @@
+Update ditto unit test to account for recent changes to MacOS zip files.
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 607f889de..7befb8d75 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -224,6 +224,32 @@ def __init__(self, **kwargs):
         for attr, value in kwargs.items():
             setattr(self, attr, value)
 
+    def update(self, data):
+        """Add fields and values from data to BaseConfig instance.
+
+        Any existing fields named in data will be overwritten. To merge
+        data with existing configs, use the `merge_config` function.
+
+        :param data: The new configuration data dictionary.
+        """
+        for key, configs in data.items():
+            setattr(self, key, configs)
+
+    def setdefault(self, field_name, default_value):
+        """Return the field_name field or, if it does not exist, create it
+        to hold default_value.
+
+        Behaves similarly to dict.setdefault().
+
+        :param field_name: The name of the desired/new field.
+        :param default_value: The value to assign to self.field_name if it
+            does not already exist.
+        """
+        if not hasattr(self, field_name):
+            setattr(self, field_name, default_value)
+
+        return getattr(self, field_name)
+
 
 class GlobalConfig(BaseConfig):
     def __init__(
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index a1fe5357c..882345729 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -15,7 +15,7 @@
     UpdateCommand,
 )
 from briefcase.commands.convert import find_changelog_filename
-from briefcase.config import AppConfig
+from briefcase.config import AppConfig, merge_config
 from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.docker import Docker, DockerAppContext
 from briefcase.integrations.subprocess import NativeAppContext
@@ -198,8 +198,7 @@ def finalize_app_config(self, app: AppConfig):
             vendor_config,
             codename_config,
         ]:
-            for key, value in config.items():
-                setattr(app, key, value)
+            merge_config(app, config)
 
         with self.console.wait_bar("Determining glibc version..."):
             app.glibc_version = self.target_glibc_version(app)


Test patch:
diff --git a/tests/config/test_BaseConfig.py b/tests/config/test_BaseConfig.py
new file mode 100644
index 000000000..b235ac682
--- /dev/null
+++ b/tests/config/test_BaseConfig.py
@@ -0,0 +1,24 @@
+from briefcase.config import BaseConfig
+
+
+def test_update():
+    # Expect update() to overwrite an existing field
+    config = BaseConfig()
+    config.requires = ["newpackage=1.1"]
+    new_requirements = ["newpackage=1.2"]
+    config.update({"requires": new_requirements})
+    assert config.requires == new_requirements
+
+
+def test_setdefault():
+    config = BaseConfig()
+    # Expect setdefault() to add new field since it does not exist
+    result = config.setdefault("requires", [])
+    assert result == config.requires
+    assert config.requires == []
+
+    # Expect setdefault() to not overwrite existing field
+    new_requirements = config.requires = ["newpackage=1.2"]
+    result = config.setdefault("requires", [])
+    assert result == config.requires
+    assert config.requires == new_requirements
diff --git a/tests/platforms/linux/system/test_mixin__finalize_app_config.py b/tests/platforms/linux/system/test_mixin__finalize_app_config.py
index 7507cbfb1..c512ab601 100644
--- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py
+++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py
@@ -536,3 +536,46 @@ def test_passive_mixin(first_app_config, tmp_path):
 
     # For tests of other properties merged in finalization, see
     # test_properties
+
+
+def test_cascading_distribution_properties(create_command, first_app_config):
+    """Properties should be cascading/accumulating, and vendor-level
+    properties should overwrite os-level ones when in a dictionary.
+    """
+    # Run this test as "docker"; however, the things we're testing aren't docker specific.
+    create_command.target_image = "somevendor:surprising"
+    create_command.tools.docker = MagicMock()
+    create_command.target_glibc_version = MagicMock(return_value="2.42")
+
+    # Mock a minimal response from checking /etc/os-release
+    create_command.tools.docker.check_output.return_value = "\n".join(
+        [
+            "ID=somevendor",
+            "VERSION_CODENAME=surprising",
+            "ID_LIKE=debian",
+        ]
+    )
+
+    # Modify the app config with distribution-level properties
+    first_app_config.requires = []
+    first_app_config.permission = {}
+
+    first_app_config.debian = {
+        "requires": ["debian level prop"],
+        "permission": {"prop1": "debian level prop 1", "prop2": "debian level prop 2"},
+    }
+    first_app_config.somevendor = {
+        "requires": ["vendor level prop"],
+        "permission": {"prop1": "vendor level prop 1"},
+    }
+
+    create_command.finalize_app_config(first_app_config)
+
+    # The target's config attributes have been merged into the app
+    # List properties should be accumulated
+    assert first_app_config.requires == ["debian level prop", "vendor level prop"]
+    # Dictionary properties should overwrite higher-level ones and accumulate
+    assert first_app_config.permission == {
+        "prop1": "vendor level prop 1",
+        "prop2": "debian level prop 2",
+    }
diff --git a/tests/platforms/macOS/app/package/test_ditto.py b/tests/platforms/macOS/app/package/test_ditto.py
index b615c59cf..c5f5d60cc 100644
--- a/tests/platforms/macOS/app/package/test_ditto.py
+++ b/tests/platforms/macOS/app/package/test_ditto.py
@@ -36,7 +36,13 @@ def test_ditto(
 
     # The archive contains the app as the only top level element.
     with ZipFile(archive_path) as archive:
-        assert sorted(archive.namelist()) == [
+        # zip file can include a “__MACOSX” folder for each document that contains information about
+        # the file useful for Finder and will not be in the unzipped set of files
+        archived_files = [
+            fn for fn in archive.namelist() if not fn.startswith("__MACOSX/")
+        ]
+
+        assert sorted(archived_files) == [
             "First App.app/",
             "First App.app/Contents/",
             "First App.app/Contents/Frameworks/",