Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- master
- auto-pep-794
pull_request:
branches:
- master
Expand Down
105 changes: 69 additions & 36 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from typing import TYPE_CHECKING, Any, Generic, cast

from hatchling.metadata.utils import (
detect_import_names,
detect_import_namespaces,
format_dependency,
import_name_candidates,
is_valid_import_name,
is_valid_project_name,
normalize_project_name,
Expand Down Expand Up @@ -391,6 +394,7 @@ def __init__(
self._optional_dependencies: dict[str, list[str]] | None = None
self._dynamic: list[str] | None = None
self._import_names: list[str] | None = None
self._import_names_complete: bool = False
self._import_namespaces: list[str] | None = None

# Indicates that the version has been successfully set dynamically
Expand Down Expand Up @@ -1351,69 +1355,98 @@ 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
if not self._import_names_complete:
self.__resolve_import_metadata()

import_names = self.config["import-names"]
return self._import_names

@property
def import_namespaces(self) -> list[str]:
"""
https://packaging.python.org/en/latest/specifications/pyproject-toml/#import-namespaces
"""
if not self._import_names_complete:
self.__resolve_import_metadata()

return cast("list[str]", self._import_namespaces)

def __resolve_import_metadata(self) -> None:
# If not explicitly declared, `import-names`/`import-namespaces` are auto-detected
# from the project's package layout (PEP 794), mirroring `flit-core`. Explicit
# declarations always win and are never validated against the detected layout.
#
# Explicit configuration for both fields is validated first, before any auto-detection
# is attempted, since auto-detection requires `raw_name`/`name` (and therefore
# `project.name`), which explicit configuration should not otherwise require.
import_names_explicit = "import-names" in self.config
import_names: list[str] | None = None
if import_names_explicit:
import_names_config = 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):
if not isinstance(import_names_config, list):
message = "Field `project.import-names` must be an array"
raise TypeError(message)

for i, import_name in enumerate(import_names, 1):
for i, import_name in enumerate(import_names_config, 1):
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)

self._import_names = sorted(import_names)
import_names = sorted(import_names_config)

if set(self._import_names) & set(self.import_namespaces):
message = "Fields `project.import-names` and `project.import-namespaces` cannot contain the same name"
import_namespaces_explicit = "import-namespaces" in self.config
import_namespaces: list[str] = []
if import_namespaces_explicit:
import_namespaces_config = 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)

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):
if not isinstance(import_namespaces_config, list):
message = "Field `project.import-namespaces` must be an array"
raise TypeError(message)

for i, import_namespace in enumerate(import_namespaces, 1):
for i, import_namespace in enumerate(import_namespaces_config, 1):
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)

self._import_namespaces = sorted(import_namespaces)
import_namespaces = sorted(import_namespaces_config)

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)
if not import_names_explicit or not import_namespaces_explicit:
candidates = import_name_candidates(self.raw_name, self.name)

# Namespace-package detection only applies as a fallback when no flat/src/single-module
# layout was found, otherwise e.g. a `src/<name>/__init__.py` layout would be misdetected
# as a namespace package named `src`.
detected_names = detect_import_names(self.root, candidates)
detected_namespace_names: list[str] = []
detected_namespaces: list[str] = []
if not detected_names:
detected_namespace_names, detected_namespaces = detect_import_namespaces(self.root, candidates)

if not import_names_explicit:
import_names = detected_names or detected_namespace_names or None

if not import_namespaces_explicit:
import_namespaces = sorted(detected_namespaces)

if import_names is not None and set(import_names) & set(import_namespaces):
message = "Fields `project.import-names` and `project.import-namespaces` cannot contain the same name"
raise ValueError(message)

return self._import_namespaces
self._import_names = import_names
self._import_namespaces = import_namespaces
self._import_names_complete = True

@property
def dynamic(self) -> list[str]:
Expand Down
54 changes: 54 additions & 0 deletions backend/src/hatchling/metadata/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from __future__ import annotations

import os
import re
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Iterable

from packaging.requirements import Requirement

from hatchling.metadata.core import ProjectMetadata
Expand Down Expand Up @@ -42,6 +45,57 @@ def is_valid_import_name(import_name: str) -> bool:
return all(module.isidentifier() for module in name.split("."))


def _escape_import_name_candidate(name: str) -> str:
# Deliberately mirrors `BuilderInterface.normalize_file_name_component`
# (hatchling/builders/plugin/interface.py), which escapes project names into candidate
# on-disk directory/file names using the wheel filename escaping rule
# (https://peps.python.org/pep-0427/#escaping-and-unicode). Reused here as-is, not because
# import names are governed by PEP 427, but so that auto-detection searches for the exact
# same candidate names the wheel builder's own package-detection heuristic would look for.
# Cannot import the original directly without introducing a metadata -> builders cycle;
# keep in sync if that changes.
return re.sub(r"[^\w\d.]+", "_", name, flags=re.UNICODE)


def import_name_candidates(raw_name: str, name: str) -> tuple[str, ...]:
return (_escape_import_name_candidate(raw_name), _escape_import_name_candidate(name))


def detect_import_names(root: str, candidates: Iterable[str]) -> list[str]:
"""
Best-effort detection of the import name a project ships, based on common layouts
(flat, src/, single-module). Returns an empty list if nothing matches; never raises.
"""
for project_name in candidates:
if os.path.isfile(os.path.join(root, project_name, "__init__.py")):
return [project_name]

if os.path.isfile(os.path.join(root, "src", project_name, "__init__.py")):
return [project_name]

if os.path.isfile(os.path.join(root, f"{project_name}.py")):
return [project_name]

return []


def detect_import_namespaces(root: str, candidates: Iterable[str]) -> tuple[list[str], list[str]]:
"""
Best-effort detection of a single, unambiguous namespace-package layout
(`<namespace>/<project_name>/__init__.py`). Returns a `(import_names, import_namespaces)`
pair, both possibly empty; never raises.
"""
from glob import glob

for project_name in candidates:
matches = glob(os.path.join(root, "*", project_name, "__init__.py"))
if len(matches) == 1:
namespace = os.path.relpath(matches[0], root).split(os.sep)[0]
return [f"{namespace}.{project_name}"], [namespace]

return [], []


def normalize_requirement(requirement: Requirement) -> None:
# Changes to this function affect reproducibility between versions
from packaging.specifiers import SpecifierSet
Expand Down
21 changes: 21 additions & 0 deletions docs/config/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,27 @@ plugin-name1 = "pkg.subpkg1"
plugin-name2 = "pkg.subpkg2:func"
```

## Import names

The [`import-names`](https://packaging.python.org/en/latest/specifications/pyproject-toml/#import-names) and [`import-namespaces`](https://packaging.python.org/en/latest/specifications/pyproject-toml/#import-namespaces) fields record the top-level module(s) that a project makes importable, independently of the project's distribution name on PyPI.

By default, these are auto-detected from the project's package layout, using the same heuristics as the [default file selection](../plugins/builder/wheel.md#default-file-selection) for the [wheel](../plugins/builder/wheel.md) target: a flat layout (`<name>/__init__.py`), a `src` layout (`src/<name>/__init__.py`), a single-file module (`<name>.py`), or an unambiguous namespace package (`<namespace>/<name>/__init__.py`). If none of these match, no import name is recorded.

To declare these explicitly instead, for example when a project's import name doesn't match its project name:

```toml tab="pyproject.toml"
[project]
...
import-names = [
"...",
]
import-namespaces = [
"...",
]
```

Explicit declarations always take precedence over auto-detection and are not validated against the project's on-disk layout.

## Dynamic

If any metadata fields are set dynamically, like the [`version`](#version) may be, then they must be listed here.
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

**Added**

- Auto-detect `import-names`/`import-namespaces` project metadata from the project's package layout when not explicitly declared

**Changed***

- Bump default core metadata version to 2.5
Expand Down
Loading
Loading