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
9 changes: 3 additions & 6 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from hatchling.metadata.utils import (
format_dependency,
is_valid_import_name,
is_valid_project_name,
normalize_project_name,
normalize_requirement,
Expand Down Expand Up @@ -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)

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

Expand Down Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions backend/src/hatchling/metadata/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions backend/src/hatchling/metadata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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

**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
Expand Down
18 changes: 16 additions & 2 deletions tests/backend/metadata/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}})

Expand All @@ -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):
Expand All @@ -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]}})

Expand All @@ -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),
Expand Down
29 changes: 29 additions & 0 deletions tests/backend/metadata/test_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading