diff --git a/changes/3011.feature.md b/changes/3011.feature.md new file mode 100644 index 000000000..bfc7d6114 --- /dev/null +++ b/changes/3011.feature.md @@ -0,0 +1 @@ +Briefcase will now honor the PEP 621 project name as the Briefcase project name. diff --git a/changes/3011.removal.md b/changes/3011.removal.md new file mode 100644 index 000000000..da3df8503 --- /dev/null +++ b/changes/3011.removal.md @@ -0,0 +1 @@ +The `project_name` for a project must now be a valid PEP 621 project name. If your existing project name is not a valid PEP 621 project name, a warning will be raised; this will become an error in future. diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index ccd473cb5..56d4e880e 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -6,7 +6,7 @@ If you have an application called "My App", with source code in the `src/myapp` ```toml [tool.briefcase] -project_name = "My Project" +project_name = "my-project" bundle = "com.example" version = "0.1" @@ -79,7 +79,7 @@ Many platforms will also require a [`license_files`][] definition. #### `project_name` -The project is the collection of all applications that are described by the briefcase configuration. For projects with a single app, this may be the same as the formal name of the solitary packaged app. +The project is the collection of all applications that are described by the briefcase configuration. For projects with a single app, this may be the same as the app name of the solitary packaged app. It must be a [valid PEP 621 project name](https://packaging.python.org/en/latest/specifications/name-normalization/#name-format); it will be automatically normalized by Briefcase. #### `version` @@ -514,6 +514,7 @@ Many of the keys that exist in Briefcase's configuration have analogous settings The following `[project]` metadata keys will be used by Briefcase if they are available: +- `name` is normalized and maps to the Briefcase [`project_name`][] setting. - `version` maps to the same key in Briefcase. - `license` and `license-files` map to the same key in Briefcase. Legacy formats for these keys will be [coerced into PEP 639 format][license-definitions]. - `authors` The `email` and `name` keys of the first value in the `authors` setting map to [`author`][] and [`author_email`][]. diff --git a/src/briefcase/commands/convert.py b/src/briefcase/commands/convert.py index b01645781..259dfdb83 100644 --- a/src/briefcase/commands/convert.py +++ b/src/briefcase/commands/convert.py @@ -559,7 +559,7 @@ def build_app_context(self, project_overrides): module_name, override_value=project_overrides.pop("test_source_dir", None) ) project_name = self.input_project_name( - formal_name, override_value=project_overrides.pop("project_name", None) + app_name, override_value=project_overrides.pop("project_name", None) ) description = self.input_description( override_value=project_overrides.pop("description", None) diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py index db2dd60f5..0ba9e7632 100644 --- a/src/briefcase/commands/new.py +++ b/src/briefcase/commands/new.py @@ -270,17 +270,18 @@ def make_project_url(self, bundle, app_name): """ return f"https://{self.make_domain(bundle)}/{app_name}" - def input_project_name(self, formal_name, override_value): + def input_project_name(self, app_name, override_value): return self.console.text_question( intro=( "Briefcase can manage projects that contain multiple applications, so " "we need a Project name.\n" "\n" "If you're only planning to have one application in this project, you " - "can use the formal name as the project name." + "can use the app name as the project name." ), description="Project Name", - default=formal_name, + default=app_name, + validator=self.validate_app_name, override_value=override_value, ) @@ -359,7 +360,7 @@ def build_app_context(self, project_overrides: dict[str, str]) -> dict[str, str] ) project_name = self.input_project_name( - formal_name, project_overrides.pop("project_name", None) + app_name, project_overrides.pop("project_name", None) ) description = self.console.text_question( diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 8c5e53326..f710edbd9 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -15,6 +15,7 @@ from build import BuildBackendException from build.util import project_wheel_metadata from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression +from packaging.utils import InvalidName, canonicalize_name from packaging.version import InvalidVersion, Version from briefcase.debuggers.base import BaseDebugger @@ -1289,6 +1290,16 @@ def resolve_dynamic_pep621_config(base_path, dynamic, console): def merge_pep621_config(global_config, pep621_config): """Merge a PEP621 configuration into a Briefcase configuration.""" + if "name" in pep621_config: + try: + project_name = canonicalize_name(pep621_config["name"], validate=True) + except (InvalidName, TypeError): + raise BriefcaseConfigError( + f"The PEP 621 project name {pep621_config['name']!r} is invalid." + ) from None + + global_config.setdefault("project_name", project_name) + if requires_python := pep621_config.get("requires-python"): global_config["requires_python"] = requires_python @@ -1344,6 +1355,29 @@ def maybe_update(field, *project_fields): pass +def warn_invalid_legacy_project_name(global_config, console): + """Warn if a legacy Briefcase project name is not valid PEP 621 metadata.""" + try: + project_name = global_config["project_name"] + except KeyError: + return + + try: + canonicalize_name(project_name, validate=True) + except (InvalidName, TypeError): + console.warning_banner( + "Invalid project name", + f""" + {project_name!r} is not a valid PEP 621 project name and cannot be + normalized. Briefcase currently accepts invalid `project_name` values + in the `[tool.briefcase]` configuration, but this may become an error + in a future version. + + Update `project_name` to a valid PEP 621 project name. + """, + ) + + def parse_config(config_file: Path, platform, output_format, console): """Parse the briefcase section of the pyproject.toml configuration file. @@ -1397,6 +1431,8 @@ def parse_config(config_file: Path, platform, output_format, console): except KeyError: pass + warn_invalid_legacy_project_name(global_config, console) + # For consistent results, sort the platforms and formats all_platforms = sorted(get_platforms().keys()) all_formats = sorted(get_output_formats(platform).keys()) diff --git a/tests/commands/convert/test_build_app_context.py b/tests/commands/convert/test_build_app_context.py index 3550eebd0..1c4551aee 100644 --- a/tests/commands/convert/test_build_app_context.py +++ b/tests/commands/convert/test_build_app_context.py @@ -20,6 +20,7 @@ def test_overrides_are_used(convert_command): } override_input = overrides.copy() out = convert_command.build_app_context(override_input) + for k, v in overrides.items(): if k == "app_type": assert not out["console_app"] diff --git a/tests/commands/new/test_build_app_context.py b/tests/commands/new/test_build_app_context.py index 648e3cffc..80936e325 100644 --- a/tests/commands/new/test_build_app_context.py +++ b/tests/commands/new/test_build_app_context.py @@ -11,7 +11,7 @@ def test_question_sequence(new_command): "My Application", # formal name "", # app name - accept the default "org.beeware", # bundle ID - "My Project", # project name + "my-project", # project name "Cool stuff", # description "Grace Hopper", # author "grace@navy.mil", # author email @@ -35,7 +35,7 @@ def test_question_sequence(new_command): "module_name": "myapplication", "source_dir": "src/myapplication", "test_source_dir": "tests", - "project_name": "My Project", + "project_name": "my-project", "url": "https://navy.mil/myapplication", } @@ -51,7 +51,7 @@ def test_question_sequence_with_overrides(new_command): "formal_name": "My Override App", "app_name": "myoverrideapp", "bundle": "net.example", - "project_name": "My Override Project", + "project_name": "my-override-project", "description": "My override description", "author": "override, author", "author_email": "author@override.tld", @@ -72,7 +72,7 @@ def test_question_sequence_with_overrides(new_command): "module_name": "myoverrideapp", "source_dir": "src/myoverrideapp", "test_source_dir": "tests", - "project_name": "My Override Project", + "project_name": "my-override-project", "url": "https://override.example.com", } @@ -90,7 +90,7 @@ def test_question_sequence_with_bad_license_override(new_command): "formal_name": "My Override App", "app_name": "myoverrideapp", "bundle": "net.example", - "project_name": "My Override Project", + "project_name": "my-override-project", "description": "My override description", "author": "override, author", "author_email": "author@override.tld", @@ -111,7 +111,7 @@ def test_question_sequence_with_bad_license_override(new_command): "module_name": "myoverrideapp", "source_dir": "src/myoverrideapp", "test_source_dir": "tests", - "project_name": "My Override Project", + "project_name": "my-override-project", "url": "https://override.example.com", } @@ -135,7 +135,7 @@ def test_question_sequence_with_no_user_input(new_command): "module_name": "helloworld", "source_dir": "src/helloworld", "test_source_dir": "tests", - "project_name": "Hello World", + "project_name": "helloworld", "url": "https://example.com/helloworld", } diff --git a/tests/config/test_merge_pep621_config.py b/tests/config/test_merge_pep621_config.py index 536bd248f..523b49dd7 100644 --- a/tests/config/test_merge_pep621_config.py +++ b/tests/config/test_merge_pep621_config.py @@ -1,4 +1,7 @@ +import pytest + from briefcase.config import merge_pep621_config +from briefcase.exceptions import BriefcaseConfigError def test_empty(): @@ -10,6 +13,54 @@ def test_empty(): assert briefcase_config == {"key": "value"} +def test_project_name_is_normalized(): + """The PEP 621 project name is normalized for use by Briefcase.""" + briefcase_config = {"key": "value"} + + merge_pep621_config( + briefcase_config, + {"name": "Some.Project_Name"}, + ) + + assert briefcase_config == { + "key": "value", + "project_name": "some-project-name", + } + + +@pytest.mark.parametrize( + ("project_name", "representation"), + [ + ("not a valid name!", "'not a valid name!'"), + (42, "42"), + ], +) +def test_invalid_project_name(project_name, representation): + """An invalid PEP 621 project name raises a clean configuration error.""" + briefcase_config = {"key": "value"} + + with pytest.raises( + BriefcaseConfigError, + match=rf"The PEP 621 project name {representation} is invalid\.", + ): + merge_pep621_config( + briefcase_config, + {"name": project_name}, + ) + + +def test_legacy_project_name_takes_priority(): + """A legacy project name takes priority over PEP 621 project metadata.""" + briefcase_config = {"project_name": "Legacy_Name"} + + merge_pep621_config( + briefcase_config, + {"name": "PEP.621.Name"}, + ) + + assert briefcase_config == {"project_name": "Legacy_Name"} + + def test_base_keys(): "If the PEP621 config provides keys, they are added" briefcase_config = {"key": "value"} diff --git a/tests/config/test_parse_config.py b/tests/config/test_parse_config.py index 32a4887d3..f4980943c 100644 --- a/tests/config/test_parse_config.py +++ b/tests/config/test_parse_config.py @@ -8,7 +8,7 @@ from briefcase.config import parse_config from briefcase.console import Console from briefcase.exceptions import BriefcaseConfigError -from tests.utils import create_file +from tests.utils import PartialMatchString, create_file def test_invalid_toml(tmp_path): @@ -690,7 +690,7 @@ def test_pep_621_merge(tmp_path): tmp_path / "pyproject.toml", """ [project] - name = "awesome" + name = "Awesome.App" version = "1.2.3" authors = [{name = "Kim Park", email = "kim@example.com"}] dependencies = ["numpy"] @@ -704,7 +704,6 @@ def test_pep_621_merge(tmp_path): test = ["pytest"] [tool.briefcase] - project_name = "Awesome app" bundle = "com.example" license = "MIT" @@ -726,16 +725,17 @@ def test_pep_621_merge(tmp_path): """, ) + console = Mock() _, apps = parse_config( config_file, platform="macOS", output_format="app", - console=Mock(), + console=console, ) awesome = apps["awesome"] assert awesome == { - "project_name": "Awesome app", + "project_name": "awesome-app", "bundle": "com.example", "version": "1.2.3", "license": "MIT", @@ -752,6 +752,62 @@ def test_pep_621_merge(tmp_path): "formal_name": "Awesome Application", "long_description": "The application is very awesome", } + console.warning_banner.assert_not_called() + + +def test_invalid_legacy_project_name_warning(tmp_path): + """An invalid legacy project name is accepted with a warning.""" + config_file = create_file( + tmp_path / "pyproject.toml", + """ + [tool.briefcase] + project_name = "Awesome app!" + license = "MIT" + + [tool.briefcase.app.awesome] + """, + ) + + console = Mock() + global_config, apps = parse_config( + config_file, + platform="macOS", + output_format="app", + console=console, + ) + + assert global_config["project_name"] == "Awesome app!" + assert apps["awesome"]["project_name"] == "Awesome app!" + console.warning_banner.assert_called_once_with( + "Invalid project name", + PartialMatchString("'Awesome app!' is not a valid PEP 621 project name"), + ) + + +def test_valid_legacy_project_name(tmp_path): + """A valid legacy project name is accepted without a warning.""" + config_file = create_file( + tmp_path / "pyproject.toml", + """ + [tool.briefcase] + project_name = "awesome-app" + license = "MIT" + + [tool.briefcase.app.awesome] + """, + ) + + console = Mock() + global_config, apps = parse_config( + config_file, + platform="macOS", + output_format="app", + console=console, + ) + + assert global_config["project_name"] == "awesome-app" + assert apps["awesome"]["project_name"] == "awesome-app" + console.warning_banner.assert_not_called() def test_long_description_warning(tmp_path): @@ -1368,7 +1424,7 @@ def test_license_text_non_spdx(tmp_path): license = "You can use it while standing on one foot" [tool.briefcase] - project_name = "Awesome app" + project_name = "awesome-app" bundle = "com.example" [tool.briefcase.app.my_app] @@ -1412,7 +1468,7 @@ def test_license_text_non_spdx_multiline(tmp_path): license = "You can use it\\nwhile standing on one foot" [tool.briefcase] - project_name = "Awesome app" + project_name = "awesome-app" bundle = "com.example" [tool.briefcase.app.my_app] @@ -1510,6 +1566,7 @@ def test_pep621_empty_dynamic(monkeypatch, tmp_path): assert awesome == { "app_name": "awesome", "bundle": "com.example", + "project_name": "awesome", "version": "1.2.3", "license": "EUPL-1.2", "license_files": [], @@ -1584,6 +1641,7 @@ def test_pep621_dynamic(monkeypatch, tmp_path): "formal_name": "Awesome Application", "license": "GPL-3.0", "license_files": [], + "project_name": "awesome", "requires": ["toga>=0.5.3"], "url": "https://example.com/", "version": "1.2.3", diff --git a/tests/test_mainline.py b/tests/test_mainline.py index b809f5d12..9f2b92379 100644 --- a/tests/test_mainline.py +++ b/tests/test_mainline.py @@ -24,7 +24,7 @@ def pyproject_toml(monkeypatch, tmp_path): requires = ["briefcase"] [tool.briefcase] -project_name = "Hello World" +project_name = "hello-world" bundle = "com.example" version = "0.0.1" license = "MIT"