Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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:
Expand Down
128 changes: 126 additions & 2 deletions backend/src/hatchling/metadata/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",),
Expand All @@ -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"),
Expand All @@ -46,6 +48,8 @@
"description": ("Summary",),
"urls": ("Project-URL",),
"version": ("Version",),
"import-names": ("Import-Name",),
"import-namespaces": ("Import-Namespace",),
}


Expand All @@ -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,
}


Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:***
Expand Down
90 changes: 90 additions & 0 deletions tests/backend/metadata/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading