diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 063a3d027..44dea4686 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -389,6 +389,8 @@ def __init__( self._optional_dependencies_complex: dict[str, dict[str, Requirement]] | None = None self._optional_dependencies: dict[str, list[str]] | None = None self._dynamic: list[str] | None = None + self._import_names: list[str] | None = None + self._import_namespaces: list[str] | None = None # Indicates that the version has been successfully set dynamically self._version_set: bool = False @@ -1337,6 +1339,75 @@ def optional_dependencies(self) -> dict[str, list[str]]: return self._optional_dependencies + @property + def import_names(self) -> list[str] | None: + """ + https://peps.python.org/pep-0794/ + """ + if self._import_names is None: + if "import-names" not in self.config: + return None + + import_names = self.config["import-names"] + if "import-names" in self.dynamic: + message = ( + "Metadata field `import-names` cannot be both statically defined and " + "listed in field `project.dynamic`" + ) + raise ValueError(message) + + if not isinstance(import_names, list): + message = "Field `project.import-names` must be an array" + 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): + message = f"Import name #{i} of field `project.import-names` must be a valid import name" + raise TypeError(message) + + self._import_names = sorted(import_names) + + if set(self._import_names) & set(self.import_namespaces): + message = "Fields `project.import-names` and `project.import-namespaces` cannot contain the same name" + raise ValueError(message) + + return self._import_names + + @property + def import_namespaces(self) -> list[str]: + """ + https://packaging.python.org/en/latest/specifications/pyproject-toml/#import-namespaces + """ + if self._import_namespaces is None: + if "import-namespaces" in self.config: + import_namespaces = self.config["import-namespaces"] + if "import-namespaces" in self.dynamic: + message = ( + "Metadata field `import-namespaces` cannot be both statically defined and " + "listed in field `project.dynamic`" + ) + raise ValueError(message) + else: + import_namespaces = [] + + if not isinstance(import_namespaces, list): + message = "Field `project.import-namespaces` must be an array" + 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): + message = f"Import namespace #{i} of field `project.import-namespaces` must be a valid import name" + raise TypeError(message) + + self._import_namespaces = sorted(import_namespaces) + + import_names = self.import_names + if import_names is not None and set(import_names) & set(self._import_namespaces): + message = "Fields `project.import-names` and `project.import-namespaces` cannot contain the same name" + raise ValueError(message) + + return self._import_namespaces + @property def dynamic(self) -> list[str]: """ @@ -1369,6 +1440,10 @@ 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 72c163d98..c2067bd1f 100644 --- a/backend/src/hatchling/metadata/spec.py +++ b/backend/src/hatchling/metadata/spec.py @@ -7,8 +7,8 @@ from hatchling.metadata.core import ProjectMetadata -DEFAULT_METADATA_VERSION = "2.4" -LATEST_METADATA_VERSION = "2.4" +DEFAULT_METADATA_VERSION = "2.5" +LATEST_METADATA_VERSION = "2.5" CORE_METADATA_PROJECT_FIELDS = { "Author": ("authors",), "Author-email": ("authors",), @@ -29,6 +29,8 @@ "Summary": ("description",), "Project-URL": ("urls",), "Version": ("version",), + "Import-Name": ("import-names",), + "Import-Namespace": ("import-namespaces",), } PROJECT_CORE_METADATA_FIELDS = { "authors": ("Author", "Author-email"), @@ -46,6 +48,8 @@ "description": ("Summary",), "urls": ("Project-URL",), "version": ("Version",), + "import-names": ("Import-Name",), + "import-namespaces": ("Import-Namespace",), } @@ -59,6 +63,7 @@ def get_core_metadata_constructors() -> dict[str, Callable]: "2.2": construct_metadata_file_2_2, "2.3": construct_metadata_file_2_3, "2.4": construct_metadata_file_2_4, + "2.5": construct_metadata_file_2_5, } @@ -196,6 +201,16 @@ def project_metadata_from_core_metadata(core_metadata: str) -> dict[str, Any]: if optional_dependencies: metadata["optional-dependencies"] = optional_dependencies + if (import_names := message.get_all("Import-Name")) is not None: + metadata["import-names"] = import_names + + if (import_namespaces := message.get_all("Import-Namespace")) is not None: + metadata["import-namespaces"] = import_namespaces + + if set(metadata.get("import-names", [])) & set(metadata.get("import-namespaces", [])): + error_message = "Import-Name and Import-Namespace fields cannot contain the same name" + raise ValueError(error_message) + return metadata @@ -598,3 +613,112 @@ def construct_metadata_file_2_4(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f"\n{metadata.core.readme}" return metadata_file + + +def construct_metadata_file_2_5(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: + """ + https://peps.python.org/pep-0794/ + """ + metadata_file = "Metadata-Version: 2.5\n" + metadata_file += f"Name: {metadata.core.raw_name}\n" + metadata_file += f"Version: {metadata.version}\n" + + if metadata.core.import_names is not None: + if not metadata.core.import_names and not metadata.core.import_namespaces: + # Projects MAY set `import-names` an empty array and not set `import-namespaces` + # at all in a `pyproject.toml` file (e.g. `import-names = []`). To match this, + # projects MAY have an empty `Import-Name` field in their metadata. This represents + # a project with NO import names, public or private (i.e. there are no Python modules + # of any kind in the distribution file). + 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 + 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 + metadata_file += f"Import-Namespace: {_name}\n" + + if metadata.core.dynamic: + # Ordered set + for field in { + core_metadata_field: None + for project_field in metadata.core.dynamic + for core_metadata_field in PROJECT_CORE_METADATA_FIELDS.get(project_field, ()) + }: + metadata_file += f"Dynamic: {field}\n" + + if metadata.core.description: + metadata_file += f"Summary: {metadata.core.description}\n" + + if metadata.core.urls: + for label, url in metadata.core.urls.items(): + metadata_file += f"Project-URL: {label}, {url}\n" + + authors_data = metadata.core.authors_data + if authors_data["name"]: + metadata_file += f"Author: {', '.join(authors_data['name'])}\n" + if authors_data["email"]: + metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n" + + maintainers_data = metadata.core.maintainers_data + if maintainers_data["name"]: + metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n" + if maintainers_data["email"]: + metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n" + + if metadata.core.license: + license_start = "License: " + indent = " " * (len(license_start) - 1) + metadata_file += license_start + + for i, line in enumerate(metadata.core.license.splitlines()): + if i == 0: + metadata_file += f"{line}\n" + else: + metadata_file += f"{indent}{line}\n" + + if metadata.core.license_expression: + metadata_file += f"License-Expression: {metadata.core.license_expression}\n" + + if metadata.core.license_files: + for license_file in metadata.core.license_files: + metadata_file += f"License-File: {license_file}\n" + + if metadata.core.keywords: + metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n" + + if metadata.core.classifiers: + for classifier in metadata.core.classifiers: + metadata_file += f"Classifier: {classifier}\n" + + if metadata.core.requires_python: + metadata_file += f"Requires-Python: {metadata.core.requires_python}\n" + + if metadata.core.dependencies: + for dependency in metadata.core.dependencies: + metadata_file += f"Requires-Dist: {dependency}\n" + + if extra_dependencies: + for dependency in extra_dependencies: + metadata_file += f"Requires-Dist: {dependency}\n" + + if metadata.core.optional_dependencies: + for option, dependencies in metadata.core.optional_dependencies.items(): + metadata_file += f"Provides-Extra: {option}\n" + for dependency in dependencies: + if ";" in dependency: + dep_name, dep_env_marker = dependency.split(";", maxsplit=1) + metadata_file += f"Requires-Dist: {dep_name}; ({dep_env_marker.strip()}) and extra == {option!r}\n" + elif "@ " in dependency: + metadata_file += f"Requires-Dist: {dependency} ; extra == {option!r}\n" + else: + metadata_file += f"Requires-Dist: {dependency}; extra == {option!r}\n" + + if metadata.core.readme: + metadata_file += f"Description-Content-Type: {metadata.core.readme_content_type}\n" + metadata_file += f"\n{metadata.core.readme}" + + return metadata_file diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index d9f47a0fa..9aedcd1eb 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 +***Added:*** + +- Support PEP 794 (core metadata `Import-Name` and `Import-Namespace` fields and version 2.5) + ## [1.29.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.29.0) - 2026-02-21 ## {: #hatchling-v1.29.0 } ***Fixed:*** diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py index 23440b725..b9794787c 100644 --- a/tests/backend/metadata/test_core.py +++ b/tests/backend/metadata/test_core.py @@ -1402,6 +1402,96 @@ def test_correct(self, isolation): ) +class TestImportNames: + def test_dynamic(self, isolation): + metadata = ProjectMetadata( + str(isolation), None, {"project": {"import-names": 9000, "dynamic": ["import-names"]}} + ) + + with pytest.raises( + ValueError, + match="Metadata field `import-names` cannot be both statically defined and listed in field `project.dynamic`", + ): + _ = metadata.core.import_names + + def test_not_array(self, isolation): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-names": 10}}) + + 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"]) + def test_entry_not_valid_import_name(self, isolation, entry): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-names": [entry]}}) + + with pytest.raises( + TypeError, match="Import name #1 of field `project.import-names` must be a valid import name" + ): + _ = metadata.core.import_names + + def test_correct(self, isolation): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-names": ["foo", "_foo"]}}) + + assert metadata.core.import_names == ["_foo", "foo"] + + +class TestImportNamespaces: + def test_dynamic(self, isolation): + metadata = ProjectMetadata( + str(isolation), None, {"project": {"import-namespaces": 9000, "dynamic": ["import-namespaces"]}} + ) + + with pytest.raises( + ValueError, + match="Metadata field `import-namespaces` cannot be both statically defined and listed in field `project.dynamic`", + ): + _ = metadata.core.import_namespaces + + def test_not_array(self, isolation): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-namespaces": 10}}) + + 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"]) + def test_entry_not_valid_import_name(self, isolation, entry): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-namespaces": [entry]}}) + + with pytest.raises( + TypeError, match="Import namespace #1 of field `project.import-namespaces` must be a valid import name" + ): + _ = metadata.core.import_namespaces + + def test_correct(self, isolation): + metadata = ProjectMetadata(str(isolation), None, {"project": {"import-namespaces": ["foo", "foo.bar"]}}) + + assert metadata.core.import_namespaces == ["foo", "foo.bar"] + + def test_import_names_and_import_namespaces_conflict(self, isolation): + metadata = ProjectMetadata( + str(isolation), + None, + {"project": {"import-names": ["pytest", "foo"], "import-namespaces": ["bar", "pytest"]}}, + ) + + with pytest.raises( + ValueError, + match="Fields `project.import-names` and `project.import-namespaces` cannot contain the same name", + ): + _ = metadata.core.import_names + + metadata2 = ProjectMetadata( + str(isolation), + None, + {"project": {"import-names": ["pytest", "foo"], "import-namespaces": ["bar", "pytest"]}}, + ) + with pytest.raises( + ValueError, + match="Fields `project.import-names` and `project.import-namespaces` cannot contain the same name", + ): + _ = metadata2.core.import_namespaces + + class TestHook: def test_unknown(self, isolation): metadata = ProjectMetadata( diff --git a/tests/backend/metadata/test_spec.py b/tests/backend/metadata/test_spec.py index 311832092..2612da0b0 100644 --- a/tests/backend/metadata/test_spec.py +++ b/tests/backend/metadata/test_spec.py @@ -229,6 +229,32 @@ def test_dependencies(self): }, } + def test_import_names_and_import_namespaces(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Import-Name: _pytest +Import-Name: py +Import-Name: pytest +""" + assert project_metadata_from_core_metadata(core_metadata) == { + "name": "My.App", + "version": "0.1.0", + "import-names": ["_pytest", "py", "pytest"], + } + + def test_import_names_and_import_namespaces_conflict(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Import-Name: pytest +Import-Namespace: pytest +""" + with pytest.raises(ValueError, match="^Import-Name and Import-Namespace fields cannot contain the same name$"): + project_metadata_from_core_metadata(core_metadata) + @pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["1.2"]]) class TestCoreMetadataV12: @@ -2370,3 +2396,130 @@ def test_all(self, constructor, temp_dir, helpers): test content """ ) + + +@pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["2.5"]]) +class TestCoreMetadataV25: + def test_import_names(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "scikit-learn", + "version": "0.1.0", + "import-names": ["sklearn"], + "description": "A set of python modules for machine learning and data mining", + }, + }, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.5 + Name: scikit-learn + Version: 0.1.0 + Import-Name: sklearn + Summary: A set of python modules for machine learning and data mining + """ + ) + + def test_import_names_private(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "pytest", + "version": "0.1.0", + "import-names": ["pytest", "_pytest"], + "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 + Summary: pytest: simple powerful testing with Python + """ + ) + + def test_explicit_no_import_names(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "my-package", + "version": "0.1.0", + "import-names": [], + "description": "My package without any import names", + }, + }, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.5 + Name: my-package + Version: 0.1.0 + Import-Name + Summary: My package without any import names + """ + ) + + def test_import_namespaces(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "azure-mgmt-search", + "version": "0.1.0", + "import-names": ["azure.mgmt.search"], + "import-namespaces": ["azure", "azure.mgmt"], + "description": "Microsoft Azure Search Management Client Library for Python", + }, + }, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.5 + Name: azure-mgmt-search + Version: 0.1.0 + Import-Name: azure.mgmt.search + Import-Namespace: azure + Import-Namespace: azure.mgmt + Summary: Microsoft Azure Search Management Client Library for Python + """ + ) + + def test_import_namespaces_private(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "pytest", + "version": "0.1.0", + "import-namespaces": ["_pytest.assertion"], + "description": "pytest: simple powerful testing with Python", + }, + }, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.5 + Name: pytest + Version: 0.1.0 + Import-Namespace: _pytest.assertion; private + Summary: pytest: simple powerful testing with Python + """ + )