From 4a63fc58fe4dd2de49b9bb466206e7eead5aebbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 4 Aug 2026 21:28:04 -0600 Subject: [PATCH 1/2] fix: Allow the `; private` annotation on `import-names`/`import-namespaces` entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- backend/src/hatchling/metadata/core.py | 9 +++----- backend/src/hatchling/metadata/spec.py | 8 +++++-- backend/src/hatchling/metadata/utils.py | 21 ++++++++++++++++++ docs/history/hatchling.md | 4 ++++ tests/backend/metadata/test_core.py | 18 +++++++++++++-- tests/backend/metadata/test_spec.py | 29 +++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 10 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 44dea4686..cde4da547 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -8,6 +8,7 @@ from hatchling.metadata.utils import ( format_dependency, + is_valid_import_name, is_valid_project_name, normalize_project_name, normalize_requirement, @@ -1361,7 +1362,7 @@ def import_names(self) -> list[str] | None: raise TypeError(message) for i, import_name in enumerate(import_names, 1): - if not isinstance(import_name, str) or not self.__import_name_is_valid(import_name): + if not isinstance(import_name, str) or not is_valid_import_name(import_name): message = f"Import name #{i} of field `project.import-names` must be a valid import name" raise TypeError(message) @@ -1395,7 +1396,7 @@ def import_namespaces(self) -> list[str]: raise TypeError(message) for i, import_namespace in enumerate(import_namespaces, 1): - if not isinstance(import_namespace, str) or not self.__import_name_is_valid(import_namespace): + if not isinstance(import_namespace, str) or not is_valid_import_name(import_namespace): message = f"Import namespace #{i} of field `project.import-namespaces` must be a valid import name" raise TypeError(message) @@ -1440,10 +1441,6 @@ def validate_fields(self) -> None: def __classifier_is_private(classifier: str) -> bool: return classifier.lower().startswith("private ::") - @staticmethod - def __import_name_is_valid(import_name: str) -> bool: - return all(module.isidentifier() for module in import_name.split(".")) - class HatchMetadata(Generic[PluginManagerBound]): def __init__(self, root: str, config: dict[str, dict[str, Any]], plugin_manager: PluginManagerBound) -> None: diff --git a/backend/src/hatchling/metadata/spec.py b/backend/src/hatchling/metadata/spec.py index c2067bd1f..7fc6cab48 100644 --- a/backend/src/hatchling/metadata/spec.py +++ b/backend/src/hatchling/metadata/spec.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any +from hatchling.metadata.utils import split_import_name_annotation + if TYPE_CHECKING: from collections.abc import Callable @@ -633,12 +635,14 @@ def construct_metadata_file_2_5(metadata: ProjectMetadata, extra_dependencies: t metadata_file += "Import-Name\n" for import_name in metadata.core.import_names: - _name = f"{import_name}; private" if import_name.startswith("_") else import_name + name, private = split_import_name_annotation(import_name) + _name = f"{name}; private" if private or name.startswith("_") else name metadata_file += f"Import-Name: {_name}\n" if metadata.core.import_namespaces: for import_namespace in metadata.core.import_namespaces: - _name = f"{import_namespace}; private" if import_namespace.startswith("_") else import_namespace + name, private = split_import_name_annotation(import_namespace) + _name = f"{name}; private" if private or name.startswith("_") else name metadata_file += f"Import-Namespace: {_name}\n" if metadata.core.dynamic: diff --git a/backend/src/hatchling/metadata/utils.py b/backend/src/hatchling/metadata/utils.py index 270d1730e..e6335d660 100644 --- a/backend/src/hatchling/metadata/utils.py +++ b/backend/src/hatchling/metadata/utils.py @@ -21,6 +21,27 @@ def normalize_project_name(project_name: str) -> str: return re.sub(r"[-_.]+", "-", project_name).lower() +def split_import_name_annotation(import_name: str) -> tuple[str, bool]: + # https://packaging.python.org/en/latest/specifications/pyproject-toml/#import-names + # https://packaging.python.org/en/latest/specifications/pyproject-toml/#import-namespaces + # + # An import name MAY be followed by `; private`, with any amount of whitespace surrounding + # the semicolon. Returns the bare name and whether it was annotated private. + if ";" not in import_name: + return import_name, False + + name, annotation = import_name.split(";", 1) + return name.strip(), annotation.strip() == "private" + + +def is_valid_import_name(import_name: str) -> bool: + name, annotated_private = split_import_name_annotation(import_name) + if ";" in import_name and not annotated_private: + return False + + return all(module.isidentifier() for module in name.split(".")) + + def normalize_requirement(requirement: Requirement) -> None: # Changes to this function affect reproducibility between versions from packaging.specifiers import SpecifierSet diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index 853c104f0..ad92683b1 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +**Fixed** + +- Allow the `; private` annotation on `import-names`/`import-namespaces` entries, as permitted by the specification, and avoid duplicating it in generated metadata for entries that already declare it + **Changed*** - Bump default core metadata version to 2.5 diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py index b9794787c..abbca4494 100644 --- a/tests/backend/metadata/test_core.py +++ b/tests/backend/metadata/test_core.py @@ -1420,7 +1420,7 @@ def test_not_array(self, isolation): with pytest.raises(TypeError, match="Field `project.import-names` must be an array"): _ = metadata.core.import_names - @pytest.mark.parametrize("entry", [5, "1_foo", "foo.1_bar"]) + @pytest.mark.parametrize("entry", [5, "1_foo", "foo.1_bar", "foo ; not-valid"]) def test_entry_not_valid_import_name(self, isolation, entry): metadata = ProjectMetadata(str(isolation), None, {"project": {"import-names": [entry]}}) @@ -1434,6 +1434,11 @@ def test_correct(self, isolation): assert metadata.core.import_names == ["_foo", "foo"] + def test_private_import_name(self, isolation): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-names": ["foo", "_foo ; private"]}}) + + assert metadata.core.import_names == ["_foo ; private", "foo"] + class TestImportNamespaces: def test_dynamic(self, isolation): @@ -1453,7 +1458,7 @@ def test_not_array(self, isolation): with pytest.raises(TypeError, match="Field `project.import-namespaces` must be an array"): _ = metadata.core.import_namespaces - @pytest.mark.parametrize("entry", [5, "1_foo", "foo.1_bar"]) + @pytest.mark.parametrize("entry", [5, "1_foo", "foo.1_bar", "foo.bar ; not-valid"]) def test_entry_not_valid_import_name(self, isolation, entry): metadata = ProjectMetadata(str(isolation), None, {"project": {"import-namespaces": [entry]}}) @@ -1467,6 +1472,15 @@ def test_correct(self, isolation): assert metadata.core.import_namespaces == ["foo", "foo.bar"] + def test_private_import_namespace(self, isolation): + metadata = ProjectMetadata( + str(isolation), + None, + {"project": {"import-namespaces": ["foo", "foo.bar", "foo.bar ; private"]}}, + ) + + assert metadata.core.import_namespaces == ["foo", "foo.bar", "foo.bar ; private"] + def test_import_names_and_import_namespaces_conflict(self, isolation): metadata = ProjectMetadata( str(isolation), diff --git a/tests/backend/metadata/test_spec.py b/tests/backend/metadata/test_spec.py index 2612da0b0..66b094730 100644 --- a/tests/backend/metadata/test_spec.py +++ b/tests/backend/metadata/test_spec.py @@ -2449,6 +2449,35 @@ def test_import_names_private(self, constructor, isolation, helpers): """ ) + def test_import_names_explicit_private_annotation(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "pytest", + "version": "0.1.0", + # `_pytest ; private` is already annotated and underscore-prefixed, so it must not + # be annotated a second time; `pytest; private` is annotated despite not being + # underscore-prefixed, and must be preserved (in canonical `; private` form) rather + # than dropped. + "import-names": ["_pytest ; private", "pytest; private"], + "description": "pytest: simple powerful testing with Python", + }, + }, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.5 + Name: pytest + Version: 0.1.0 + Import-Name: _pytest; private + Import-Name: pytest; private + Summary: pytest: simple powerful testing with Python + """ + ) + def test_explicit_no_import_names(self, constructor, isolation, helpers): metadata = ProjectMetadata( str(isolation), From e94369bd3155faf77e144a2ee2ffc2ba28a2d33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= <16805946+edgarrmondragon@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:42:04 -0600 Subject: [PATCH 2/2] Revert changelog --- docs/history/hatchling.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index ad92683b1..853c104f0 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -8,10 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased -**Fixed** - -- Allow the `; private` annotation on `import-names`/`import-namespaces` entries, as permitted by the specification, and avoid duplicating it in generated metadata for entries that already declare it - **Changed*** - Bump default core metadata version to 2.5