From f74f2f8ba0e208d41d899e9c257297b18ee759ec Mon Sep 17 00:00:00 2001 From: fukalous <335426989+fukalous@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:30:42 +0800 Subject: [PATCH 1/2] Normalize project names Assisted-by: ChatGPT --- changes/3011.bugfix.md | 1 + docs/en/reference/configuration.md | 3 +- src/briefcase/commands/convert.py | 2 +- src/briefcase/commands/new.py | 9 +-- src/briefcase/config.py | 36 ++++++++++ .../convert/test_build_app_context.py | 12 ++++ tests/commands/new/test_build_app_context.py | 14 ++-- tests/commands/new/test_input_project_name.py | 17 +++++ tests/config/test_merge_pep621_config.py | 51 +++++++++++++ tests/config/test_parse_config.py | 72 +++++++++++++++++-- tests/test_mainline.py | 2 +- 11 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 changes/3011.bugfix.md create mode 100644 tests/commands/new/test_input_project_name.py diff --git a/changes/3011.bugfix.md b/changes/3011.bugfix.md new file mode 100644 index 0000000000..61508cbabe --- /dev/null +++ b/changes/3011.bugfix.md @@ -0,0 +1 @@ +Briefcase now uses normalized PEP 621 project names, warns about invalid legacy project names, and uses the app name as the default project name in the `new` and `convert` wizards. diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index ccd473cb5b..f9385e2d4c 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -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. If `[project].name` is defined, `project_name` can be omitted. #### `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 b016457811..259dfdb83b 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 db2dd60f58..0ba9e76321 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 8c5e53326a..f710edbd91 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 3550eebd08..92e0f80f3e 100644 --- a/tests/commands/convert/test_build_app_context.py +++ b/tests/commands/convert/test_build_app_context.py @@ -1,3 +1,6 @@ +from unittest import mock + + def test_overrides_are_used(convert_command): (convert_command.base_path / "src/app_name").mkdir(parents=True) (convert_command.base_path / "src/app_name/__main__.py").write_text( @@ -19,7 +22,16 @@ def test_overrides_are_used(convert_command): "leftover": "leftover", } override_input = overrides.copy() + convert_command.input_project_name = mock.MagicMock( + wraps=convert_command.input_project_name + ) + out = convert_command.build_app_context(override_input) + + convert_command.input_project_name.assert_called_once_with( + "app_name", + override_value="project_name", + ) 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 648e3cffc6..80936e325a 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/commands/new/test_input_project_name.py b/tests/commands/new/test_input_project_name.py new file mode 100644 index 0000000000..1b937a7a7e --- /dev/null +++ b/tests/commands/new/test_input_project_name.py @@ -0,0 +1,17 @@ +from unittest import mock + + +def test_app_name_is_default(new_command, monkeypatch): + """The app name is the default project name.""" + mock_text_question = mock.MagicMock(return_value="my-app") + monkeypatch.setattr(new_command.console, "text_question", mock_text_question) + + assert new_command.input_project_name("my-app", None) == "my-app" + + mock_text_question.assert_called_once_with( + intro=mock.ANY, + description="Project Name", + default="my-app", + validator=new_command.validate_app_name, + override_value=None, + ) diff --git a/tests/config/test_merge_pep621_config.py b/tests/config/test_merge_pep621_config.py index 536bd248fd..523b49dd7d 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 32a4887d3c..0f1263eee7 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_no_warning(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 b809f5d129..9f2b92379b 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" From fefb0167258235bd7afeb616aebef2e91b083b00 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 7 Oct 2026 14:57:31 +0800 Subject: [PATCH 2/2] Minor cleanups to docs and tests. --- changes/3011.bugfix.md | 1 - changes/3011.feature.md | 1 + changes/3011.removal.md | 1 + docs/en/reference/configuration.md | 4 ++-- .../commands/convert/test_build_app_context.py | 11 ----------- tests/commands/new/test_input_project_name.py | 17 ----------------- tests/config/test_parse_config.py | 2 +- 7 files changed, 5 insertions(+), 32 deletions(-) delete mode 100644 changes/3011.bugfix.md create mode 100644 changes/3011.feature.md create mode 100644 changes/3011.removal.md delete mode 100644 tests/commands/new/test_input_project_name.py diff --git a/changes/3011.bugfix.md b/changes/3011.bugfix.md deleted file mode 100644 index 61508cbabe..0000000000 --- a/changes/3011.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Briefcase now uses normalized PEP 621 project names, warns about invalid legacy project names, and uses the app name as the default project name in the `new` and `convert` wizards. diff --git a/changes/3011.feature.md b/changes/3011.feature.md new file mode 100644 index 0000000000..bfc7d61145 --- /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 0000000000..da3df8503b --- /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 f9385e2d4c..56d4e880e0 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 app name of the solitary packaged app. If `[project].name` is defined, `project_name` can be omitted. +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` diff --git a/tests/commands/convert/test_build_app_context.py b/tests/commands/convert/test_build_app_context.py index 92e0f80f3e..1c4551aee0 100644 --- a/tests/commands/convert/test_build_app_context.py +++ b/tests/commands/convert/test_build_app_context.py @@ -1,6 +1,3 @@ -from unittest import mock - - def test_overrides_are_used(convert_command): (convert_command.base_path / "src/app_name").mkdir(parents=True) (convert_command.base_path / "src/app_name/__main__.py").write_text( @@ -22,16 +19,8 @@ def test_overrides_are_used(convert_command): "leftover": "leftover", } override_input = overrides.copy() - convert_command.input_project_name = mock.MagicMock( - wraps=convert_command.input_project_name - ) - out = convert_command.build_app_context(override_input) - convert_command.input_project_name.assert_called_once_with( - "app_name", - override_value="project_name", - ) for k, v in overrides.items(): if k == "app_type": assert not out["console_app"] diff --git a/tests/commands/new/test_input_project_name.py b/tests/commands/new/test_input_project_name.py deleted file mode 100644 index 1b937a7a7e..0000000000 --- a/tests/commands/new/test_input_project_name.py +++ /dev/null @@ -1,17 +0,0 @@ -from unittest import mock - - -def test_app_name_is_default(new_command, monkeypatch): - """The app name is the default project name.""" - mock_text_question = mock.MagicMock(return_value="my-app") - monkeypatch.setattr(new_command.console, "text_question", mock_text_question) - - assert new_command.input_project_name("my-app", None) == "my-app" - - mock_text_question.assert_called_once_with( - intro=mock.ANY, - description="Project Name", - default="my-app", - validator=new_command.validate_app_name, - override_value=None, - ) diff --git a/tests/config/test_parse_config.py b/tests/config/test_parse_config.py index 0f1263eee7..f4980943c0 100644 --- a/tests/config/test_parse_config.py +++ b/tests/config/test_parse_config.py @@ -784,7 +784,7 @@ def test_invalid_legacy_project_name_warning(tmp_path): ) -def test_valid_legacy_project_name_no_warning(tmp_path): +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",