Repository: beeware/briefcase

Issue:
Adopt PEP621 conventions for package metadata
### What is the problem or limitation you are having?

Briefcase uses a `[tool.briefcase]` section to specify project metadata. Many of the configuration items that Briefcase specifies have analogs in the [PEP621](https://peps.python.org/pep-0621/) (currently maintained by the [PyPA specification](https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata)).

### Describe the solution you'd like

Briefcase should honour PEP621 configuration keys where possible. This should be treated as another layer of cascading - so - a `description` specified in the PEP621 `[project]` section would be overridden by a value in the `[tool.briefcase]` section, which would be overridden by the value in the `[tool.briefcase.app.myapp]` section, and so on.

The briefcase new project template should generated PEP621 configuration items by default, rather than values in the `[tool.briefcase]` section.

This may require some adaptation for some keys - for example PEP621 allows `authors` to be a list, and LICENSE to be a dictionary. In these cases, Briefcase should be modified to accept PEP621-compatible keys.

### Describe alternatives you've considered

Do nothing; Briefcase could continue to operate completely independently of PEP621.

### Additional context

_No response_

Discussion:

Patch:
diff --git a/changes/1203.feature.rst b/changes/1203.feature.rst
new file mode 100644
index 00000000..8a4bd521
--- /dev/null
+++ b/changes/1203.feature.rst
@@ -0,0 +1,1 @@
+Briefcase will now honor PEP-621 project fields where they map to Briefcase configuration items.
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 596bb9b7..a04a6779 100644
--- a/docs/reference/configuration.rst
+++ b/docs/reference/configuration.rst
@@ -497,3 +497,30 @@ look for ``resource/round-icon-42.png``, ``resource/square-icon-42.png``,
 -------
 
 A URL for help related to the document format.
+
+PEP621 compatibility
+====================
+
+Many of the keys that exist in Briefcase's configuration have analogous settings
+in `PEP621 project metadata
+<https://packaging.python.org/en/latest/specifications/declaring-project-metadata>`__.
+If your ``pyproject.toml`` defines a ``[project]`` section, Briefcase will honor
+those settings as a top level definition. Any ``[tool.briefcase]`` definitions
+will override those in the ``[project]`` section.
+
+The following PEP621 project metadata keys will be used by Briefcase if they are
+available:
+
+* ``version`` maps to the same key in Briefcase.
+* ``authors`` The ``email`` and ``name`` keys of the first value in the
+  ``authors`` setting map to ``author`` and ``author_email``.
+* ``dependencies`` maps to the Briefcase ``requires`` setting. This is a
+  cumulative setting; any packages defined in the ``requires`` setting at the
+  ``[tool.briefcase]`` level will be appended to the packages defined with
+  ``dependencies`` at the ``[project]`` level.
+* ``description`` maps to the same key in Briefcase.
+* ``test`` in an ``[project.optional-dependencies]`` section maps to
+  ``test_requires``., As with ``dependencies``/``requires``, this is a
+  cumulative setting.
+* ``text`` in a ``[project.license]`` section will be mapped to ``license``.
+* ``homepage`` in a ``[project.urls]`` section will be mapped to ``url``.
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 8adfb446..a68a408e 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -491,6 +491,60 @@ def merge_config(config, data):
     config.update(data)
 
 
+def merge_pep621_config(global_config, pep621_config):
+    """Merge a PEP621 configuration into a Briefcase configuration."""
+
+    def maybe_update(field, *project_fields):
+        # If there's an existing key in the Briefcase config, it takes priority.
+        if field in global_config:
+            return
+
+        # Traverse the fields in the pep621 config; if the config exists, set it
+        # in the Briefcase config.
+        datum = pep621_config
+        try:
+            for key in project_fields:
+                datum = datum[key]
+        except KeyError:
+            pass
+        else:
+            global_config[field] = datum
+
+    # Keys that map direclty
+    maybe_update("description", "description")
+    maybe_update("license", "license", "text")
+    maybe_update("url", "urls", "Homepage")
+    maybe_update("version", "version")
+
+    # Use the details of the first author as the Briefcase author.
+    if "author" not in global_config:
+        try:
+            global_config["author"] = pep621_config["authors"][0]["name"]
+        except (KeyError, IndexError):
+            pass
+    if "author_email" not in global_config:
+        try:
+            global_config["author_email"] = pep621_config["authors"][0]["email"]
+        except (KeyError, IndexError):
+            pass
+
+    # Briefcase requires is cumulative over PEP621 dependencies
+    try:
+        pep621_dependencies = pep621_config["dependencies"]
+        requires = global_config.get("requires", [])
+        global_config["requires"] = pep621_dependencies + requires
+    except KeyError:
+        pass
+
+    # Briefcase test_requires is cumulative over PEP621 optional test dependencies
+    try:
+        pep621_test_dependencies = pep621_config["optional-dependencies"]["test"]
+        test_requires = global_config.get("test_requires", [])
+        global_config["test_requires"] = pep621_test_dependencies + test_requires
+    except KeyError:
+        pass
+
+
 def parse_config(config_file, platform, output_format):
     """Parse the briefcase section of the pyproject.toml configuration file.
 
@@ -521,13 +575,20 @@ def parse_config(config_file, platform, output_format):
     """
     try:
         pyproject = tomllib.load(config_file)
-
-        global_config = pyproject["tool"]["briefcase"]
     except tomllib.TOMLDecodeError as e:
         raise BriefcaseConfigError(f"Invalid pyproject.toml: {e}") from e
+
+    try:
+        global_config = pyproject["tool"]["briefcase"]
     except KeyError as e:
         raise BriefcaseConfigError("No tool.briefcase section in pyproject.toml") from e
 
+    # Merge the PEP621 configuration (if it exists)
+    try:
+        merge_pep621_config(global_config, pyproject["project"])
+    except KeyError:
+        pass
+
     # For consistent results, sort the platforms and formats
     all_platforms = sorted(get_platforms().keys())
     all_formats = sorted(get_output_formats(platform).keys())


Test patch:
diff --git a/tests/config/test_merge_pep621_config.py b/tests/config/test_merge_pep621_config.py
new file mode 100644
index 00000000..3bf42455
--- /dev/null
+++ b/tests/config/test_merge_pep621_config.py
@@ -0,0 +1,336 @@
+from briefcase.config import merge_pep621_config
+
+
+def test_empty():
+    "Merging a PEP621 config with no interesting keys causes no changes"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(briefcase_config, {"other": "thingy"})
+
+    assert briefcase_config == {"key": "value"}
+
+
+def test_base_keys():
+    "If the PEP621 config provides keys, they are added"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "description": "It's cool",
+            "version": "1.2.3",
+            "urls": {"Homepage": "https://example.com"},
+            "license": {"text": "BSD License"},
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "description": "It's cool",
+        "version": "1.2.3",
+        "license": "BSD License",
+        "url": "https://example.com",
+    }
+
+
+def test_base_keys_override():
+    "If the PEP621 config provides keys, they are added"
+    briefcase_config = {
+        "key": "value",
+        "description": "Good code I promise",
+        "version": "2.3.4",
+        "url": "https://beeware.org",
+        "license": "BSD License",
+    }
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "description": "It's cool",
+            "version": "1.2.3",
+            "urls": {"Homepage": "https://example.com"},
+            "license": {"text": "GPL3"},
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "description": "Good code I promise",
+        "version": "2.3.4",
+        "license": "BSD License",
+        "url": "https://beeware.org",
+    }
+
+
+def test_missing_subkeys():
+    "If a subkey is missing, the value is ignored"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "urls": {"Sponsorship": "https://example.com"},
+            "license": {"file": "license.txt"},
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+    }
+
+
+def test_empty_authors():
+    "If the author list is empty, no author is recorded"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {"authors": []},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+    }
+
+
+def test_single_author():
+    "If there is a single author, their details are recorded"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "authors": [
+                {
+                    "name": "Jane Developer",
+                    "email": "jane@example.com",
+                }
+            ]
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "author": "Jane Developer",
+        "author_email": "jane@example.com",
+    }
+
+
+def test_multiple_authors():
+    "If there are multiple authors, the first author is recorded"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "authors": [
+                {
+                    "name": "Jane Developer",
+                    "email": "jane@example.com",
+                },
+                {
+                    "name": "John Assistant",
+                    "email": "john@somewhere.com",
+                },
+            ]
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "author": "Jane Developer",
+        "author_email": "jane@example.com",
+    }
+
+
+def test_mising_author_name():
+    "If the author is missing name, only the author email is captured"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "authors": [
+                {
+                    "email": "jane@example.com",
+                }
+            ]
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "author_email": "jane@example.com",
+    }
+
+
+def test_missing_author_email():
+    "If the author email is missing, the name is still recorded"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "authors": [
+                {
+                    "name": "Jane Developer",
+                }
+            ]
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "author": "Jane Developer",
+    }
+
+
+def test_existing_author_name():
+    "If the author is defined in the global config, the PEP621 value is ignored"
+    briefcase_config = {"key": "value", "author": "Grace Hopper"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "authors": [
+                {
+                    "name": "Jane Developer",
+                    "email": "jane@example.com",
+                }
+            ]
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "author": "Grace Hopper",
+        "author_email": "jane@example.com",
+    }
+
+
+def test_existing_author_email():
+    "If the author email is missing, the name is still recorded"
+    briefcase_config = {"key": "value", "author_email": "grace@hopper.org"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {
+            "authors": [
+                {
+                    "name": "Jane Developer",
+                    "email": "jane@example.com",
+                }
+            ]
+        },
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "author": "Jane Developer",
+        "author_email": "grace@hopper.org",
+    }
+
+
+def test_no_dependencies():
+    "If there are no global dependencies, requires are used as is."
+    briefcase_config = {"key": "value", "requires": ["first", "second"]}
+
+    merge_pep621_config(
+        briefcase_config,
+        {},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "requires": ["first", "second"],
+    }
+
+
+def test_dependencies_without_requires():
+    "If the global config doesn't specify requirements, dependencies are used as is"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {"dependencies": ["dep1", "dep2"]},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "requires": ["dep1", "dep2"],
+    }
+
+
+def test_dependencies_with_requires():
+    "If the global config specify requirements, requires augments dependencies"
+    briefcase_config = {"key": "value", "requires": ["first", "second"]}
+
+    merge_pep621_config(
+        briefcase_config,
+        {"dependencies": ["dep1", "dep2"]},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "requires": ["dep1", "dep2", "first", "second"],
+    }
+
+
+def test_no_test_dependencies():
+    "If the global config doesn't specify test dependencies, test_requires are used as is"
+    briefcase_config = {"key": "value", "test_requires": ["first", "second"]}
+
+    merge_pep621_config(
+        briefcase_config,
+        {},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "test_requires": ["first", "second"],
+    }
+
+
+def test_optional_non_test_dependencies():
+    "If the global config has non-test optional requirements, requires is used as-is"
+    briefcase_config = {"key": "value", "requires": ["first", "second"]}
+
+    merge_pep621_config(
+        briefcase_config,
+        {"optional-dependencies": {"other": ["dep1", "dep2"]}},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "requires": ["first", "second"],
+    }
+
+
+def test_test_dependencies_without_requires():
+    "If the global config doesn't specify test requirements, test dependencies are used as is"
+    briefcase_config = {"key": "value"}
+
+    merge_pep621_config(
+        briefcase_config,
+        {"optional-dependencies": {"test": ["dep1", "dep2"]}},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "test_requires": ["dep1", "dep2"],
+    }
+
+
+def test_test_dependencies_with_requires():
+    "If the global config specify requirements, requires augments dependencies"
+    briefcase_config = {"key": "value", "test_requires": ["first", "second"]}
+
+    merge_pep621_config(
+        briefcase_config,
+        {"optional-dependencies": {"test": ["dep1", "dep2"]}},
+    )
+
+    assert briefcase_config == {
+        "key": "value",
+        "test_requires": ["dep1", "dep2", "first", "second"],
+    }
diff --git a/tests/config/test_parse_config.py b/tests/config/test_parse_config.py
index 0f3abfeb..ba24a26c 100644
--- a/tests/config/test_parse_config.py
+++ b/tests/config/test_parse_config.py
@@ -534,3 +534,68 @@ def test_document_types():
             "value": 0,
         },
     }
+
+
+def test_pep621_defaults():
+    config_file = BytesIO(
+        b"""
+        [project]
+        name = "awesome"
+        version = "1.2.3"
+        authors = [{name = "Kim Park", email = "kim@example.com"}]
+        dependencies = ["numpy"]
+        description = "awesome project"
+
+        [project.urls]
+        Homepage = "https://example.com/awesome"
+
+        [project.optional-dependencies]
+        test = ["pytest"]
+
+        [project.license]
+        text = "You can use it while standing on one foot"
+
+        [tool.briefcase]
+        project_name = "Awesome app"
+        bundle = "com.example"
+
+        [tool.briefcase.app.awesome]
+        formal_name = "Awesome Application"
+        long_description = "The application is very awesome"
+        sources = [
+            "src",
+        ]
+        test_sources = [
+            "tests",
+        ]
+
+        [tool.briefcase.app.awesome.macOS]
+        requires = [
+            "toga-cocoa~=0.3.1",
+            "std-nslog~=1.0.0"
+        ]
+        """
+    )
+
+    global_options, apps = parse_config(
+        config_file, platform="macOS", output_format="app"
+    )
+
+    awesome = apps["awesome"]
+    assert awesome == {
+        "project_name": "Awesome app",
+        "bundle": "com.example",
+        "version": "1.2.3",
+        "license": "You can use it while standing on one foot",
+        "author": "Kim Park",
+        "author_email": "kim@example.com",
+        "url": "https://example.com/awesome",
+        "description": "awesome project",
+        "requires": ["numpy", "toga-cocoa~=0.3.1", "std-nslog~=1.0.0"],
+        "test_requires": ["pytest"],
+        "app_name": "awesome",
+        "sources": ["src"],
+        "test_sources": ["tests"],
+        "formal_name": "Awesome Application",
+        "long_description": "The application is very awesome",
+    }