From 3f79706a3d688632c5d8af207f0d043c5d1c1986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 4 Aug 2026 19:36:55 -0600 Subject: [PATCH 1/4] WIP: Auto-generate `Import-Name` and `Import-Namespace` metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- .github/workflows/test.yml | 1 + backend/src/hatchling/metadata/core.py | 105 ++++++++++++++++-------- backend/src/hatchling/metadata/utils.py | 54 ++++++++++++ tests/backend/metadata/test_core.py | 97 +++++++++++++++++++++- tests/project/test_frontend.py | 15 +--- 5 files changed, 221 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55a40b7c8..702568a70 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - auto-pep-794 pull_request: branches: - master diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index d6dcb6f14..cda0434a1 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -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, @@ -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 @@ -1351,11 +1355,33 @@ 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 " @@ -1363,57 +1389,64 @@ def import_names(self) -> list[str] | None: ) 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//__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]: diff --git a/backend/src/hatchling/metadata/utils.py b/backend/src/hatchling/metadata/utils.py index e6335d660..a4e07a87a 100644 --- a/backend/src/hatchling/metadata/utils.py +++ b/backend/src/hatchling/metadata/utils.py @@ -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 @@ -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 + (`//__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 diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py index abbca4494..fc615f262 100644 --- a/tests/backend/metadata/test_core.py +++ b/tests/backend/metadata/test_core.py @@ -1430,15 +1430,83 @@ def test_entry_not_valid_import_name(self, isolation, entry): _ = metadata.core.import_names def test_correct(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"import-names": ["foo", "_foo"]}}) + metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "foo", "import-names": ["foo", "_foo"]}}) 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"]}}) + metadata = ProjectMetadata( + str(isolation), None, {"project": {"name": "foo", "import-names": ["foo", "_foo ; private"]}} + ) assert metadata.core.import_names == ["_foo ; private", "foo"] + @pytest.mark.parametrize( + ("raw_name", "directory", "import_names"), + [ + ("foo", "foo", ["foo"]), + ("my-package", "my_package", ["my_package"]), + ("MyPackage", "MyPackage", ["MyPackage"]), + ], + ) + def test_auto_detect_flat_layout(self, temp_dir, directory, raw_name, import_names): + temp_dir.joinpath(directory).mkdir() + temp_dir.joinpath(directory, "__init__.py").touch() + + metadata = ProjectMetadata(str(temp_dir), None, {"project": {"name": raw_name}}) + + assert metadata.core.import_names == import_names + assert metadata.core.import_namespaces == [] + + @pytest.mark.parametrize( + ("raw_name", "directory", "import_names"), + [ + ("foo", "foo", ["foo"]), + ("my-package", "my_package", ["my_package"]), + ("MyPackage", "MyPackage", ["MyPackage"]), + ], + ) + def test_auto_detect_src_layout(self, temp_dir, directory, raw_name, import_names): + temp_dir.joinpath("src", directory).mkdir(parents=True) + temp_dir.joinpath("src", directory, "__init__.py").touch() + + metadata = ProjectMetadata(str(temp_dir), None, {"project": {"name": raw_name}}) + + assert metadata.core.import_names == import_names + assert metadata.core.import_namespaces == [] + + @pytest.mark.parametrize( + ("raw_name", "module", "import_names"), + [ + ("foo", "foo", ["foo"]), + ("my-module", "my_module", ["my_module"]), + ("MyModule", "MyModule", ["MyModule"]), + ], + ) + def test_auto_detect_single_module(self, temp_dir, raw_name, module, import_names): + temp_dir.joinpath(f"{module}.py").touch() + + metadata = ProjectMetadata(str(temp_dir), None, {"project": {"name": raw_name}}) + + assert metadata.core.import_names == import_names + assert metadata.core.import_namespaces == [] + + def test_auto_detect_none(self, temp_dir): + metadata = ProjectMetadata(str(temp_dir), None, {"project": {"name": "foo"}}) + + assert metadata.core.import_names is None + assert metadata.core.import_namespaces == [] + + def test_auto_detect_explicit_overrides_no_validation(self, temp_dir): + temp_dir.joinpath("foo").mkdir() + temp_dir.joinpath("foo", "__init__.py").touch() + + metadata = ProjectMetadata( + str(temp_dir), None, {"project": {"name": "foo", "import-names": ["totally_unrelated"]}} + ) + + assert metadata.core.import_names == ["totally_unrelated"] + class TestImportNamespaces: def test_dynamic(self, isolation): @@ -1468,7 +1536,9 @@ def test_entry_not_valid_import_name(self, isolation, entry): _ = metadata.core.import_namespaces def test_correct(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"import-namespaces": ["foo", "foo.bar"]}}) + metadata = ProjectMetadata( + str(isolation), None, {"project": {"name": "foo", "import-namespaces": ["foo", "foo.bar"]}} + ) assert metadata.core.import_namespaces == ["foo", "foo.bar"] @@ -1476,11 +1546,30 @@ def test_private_import_namespace(self, isolation): metadata = ProjectMetadata( str(isolation), None, - {"project": {"import-namespaces": ["foo", "foo.bar", "foo.bar ; private"]}}, + {"project": {"name": "foo", "import-namespaces": ["foo", "foo.bar", "foo.bar ; private"]}}, ) assert metadata.core.import_namespaces == ["foo", "foo.bar", "foo.bar ; private"] + def test_auto_detect_namespace_package(self, temp_dir): + temp_dir.joinpath("ns", "foo").mkdir(parents=True) + temp_dir.joinpath("ns", "foo", "__init__.py").touch() + + metadata = ProjectMetadata(str(temp_dir), None, {"project": {"name": "foo"}}) + + assert metadata.core.import_namespaces == ["ns"] + assert metadata.core.import_names == ["ns.foo"] + + def test_auto_detect_explicit_overrides_no_validation(self, temp_dir): + temp_dir.joinpath("ns", "foo").mkdir(parents=True) + temp_dir.joinpath("ns", "foo", "__init__.py").touch() + + metadata = ProjectMetadata( + str(temp_dir), None, {"project": {"name": "foo", "import-namespaces": ["totally_unrelated"]}} + ) + + assert metadata.core.import_namespaces == ["totally_unrelated"] + def test_import_names_and_import_namespaces_conflict(self, isolation): metadata = ProjectMetadata( str(isolation), diff --git a/tests/project/test_frontend.py b/tests/project/test_frontend.py index 65b0dde52..d79123191 100644 --- a/tests/project/test_frontend.py +++ b/tests/project/test_frontend.py @@ -1,6 +1,5 @@ import json import sys -from typing import Any import pytest @@ -89,15 +88,12 @@ def test_wheel(self, temp_dir, temp_dir_data, platform, global_application, back output = json.loads((output_dir / "output.json").read_text()) metadata_file = work_dir / output["return_val"] / "METADATA" - expected_metadata: dict[str, Any] = { + assert project_metadata_from_core_metadata(metadata_file.read_text()) == { "name": "foo", "version": "9000.42", "description": "text", + "import-names": ["foo"], } - if backend_pkg == "flit-core": - expected_metadata["import-names"] = ["foo"] - - assert project_metadata_from_core_metadata(metadata_file.read_text()) == expected_metadata @pytest.mark.parametrize( ("backend_pkg", "backend_api"), @@ -147,15 +143,12 @@ def test_editable(self, temp_dir, temp_dir_data, platform, global_application, b output = json.loads((output_dir / "output.json").read_text()) metadata_file = work_dir / output["return_val"] / "METADATA" - expected_metadata: dict[str, Any] = { + assert project_metadata_from_core_metadata(metadata_file.read_text()) == { "name": "foo", "version": "9000.42", "description": "text", + "import-names": ["foo"], } - if backend_pkg == "flit-core": - expected_metadata["import-names"] = ["foo"] - - assert project_metadata_from_core_metadata(metadata_file.read_text()) == expected_metadata class TestBuildWheel: From 3682c4060b162c9196fb6569356cf758efaf6dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 4 Aug 2026 19:48:58 -0600 Subject: [PATCH 2/4] Update builder templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- tests/helpers/templates/sdist/standard_default.py | 1 + .../templates/sdist/standard_default_build_script_artifacts.py | 1 + .../sdist/standard_default_build_script_extra_dependencies.py | 1 + .../helpers/templates/sdist/standard_default_support_legacy.py | 1 + .../templates/sdist/standard_default_vcs_git_exclusion_files.py | 1 + .../sdist/standard_default_vcs_mercurial_exclusion_files.py | 1 + tests/helpers/templates/sdist/standard_include.py | 1 + tests/helpers/templates/sdist/standard_include_config_file.py | 1 + tests/helpers/templates/wheel/standard_default_build_script.py | 1 + .../templates/wheel/standard_default_build_script_artifacts.py | 1 + .../standard_default_build_script_artifacts_with_src_layout.py | 1 + .../standard_default_build_script_configured_build_hooks.py | 1 + .../wheel/standard_default_build_script_extra_dependencies.py | 1 + .../wheel/standard_default_build_script_force_include.py | 1 + ...tandard_default_build_script_force_include_no_duplication.py | 1 + .../helpers/templates/wheel/standard_default_extra_metadata.py | 1 + .../templates/wheel/standard_default_license_multiple.py | 1 + .../helpers/templates/wheel/standard_default_license_single.py | 1 + .../templates/wheel/standard_default_namespace_package.py | 2 ++ .../templates/wheel/standard_default_python_constraint.py | 1 + .../standard_default_python_constraint_three_components.py | 1 + tests/helpers/templates/wheel/standard_default_sbom.py | 1 + tests/helpers/templates/wheel/standard_default_shared_data.py | 1 + .../helpers/templates/wheel/standard_default_shared_scripts.py | 1 + tests/helpers/templates/wheel/standard_default_single_module.py | 1 + tests/helpers/templates/wheel/standard_default_symlink.py | 1 + tests/helpers/templates/wheel/standard_editable_exact.py | 1 + .../wheel/standard_editable_exact_extra_dependencies.py | 1 + .../templates/wheel/standard_editable_exact_force_include.py | 1 + tests/helpers/templates/wheel/standard_editable_pth.py | 1 + .../templates/wheel/standard_editable_pth_extra_dependencies.py | 1 + .../templates/wheel/standard_editable_pth_force_include.py | 1 + tests/helpers/templates/wheel/standard_entry_points.py | 1 + tests/helpers/templates/wheel/standard_no_strict_naming.py | 1 + .../templates/wheel/standard_only_packages_artifact_override.py | 1 + tests/helpers/templates/wheel/standard_tests.py | 1 + 36 files changed, 37 insertions(+) diff --git a/tests/helpers/templates/sdist/standard_default.py b/tests/helpers/templates/sdist/standard_default.py index 0127eff6f..f7e82225d 100644 --- a/tests/helpers/templates/sdist/standard_default.py +++ b/tests/helpers/templates/sdist/standard_default.py @@ -16,6 +16,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ) diff --git a/tests/helpers/templates/sdist/standard_default_build_script_artifacts.py b/tests/helpers/templates/sdist/standard_default_build_script_artifacts.py index 4447422be..46fe36958 100644 --- a/tests/helpers/templates/sdist/standard_default_build_script_artifacts.py +++ b/tests/helpers/templates/sdist/standard_default_build_script_artifacts.py @@ -39,6 +39,7 @@ def initialize(self, version, build_data): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/sdist/standard_default_build_script_extra_dependencies.py b/tests/helpers/templates/sdist/standard_default_build_script_extra_dependencies.py index 981d5ca14..86194f90c 100644 --- a/tests/helpers/templates/sdist/standard_default_build_script_extra_dependencies.py +++ b/tests/helpers/templates/sdist/standard_default_build_script_extra_dependencies.py @@ -40,6 +40,7 @@ def initialize(self, version, build_data): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Dist: binary """, diff --git a/tests/helpers/templates/sdist/standard_default_support_legacy.py b/tests/helpers/templates/sdist/standard_default_support_legacy.py index cecaaf8a4..b354a2805 100644 --- a/tests/helpers/templates/sdist/standard_default_support_legacy.py +++ b/tests/helpers/templates/sdist/standard_default_support_legacy.py @@ -16,6 +16,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/sdist/standard_default_vcs_git_exclusion_files.py b/tests/helpers/templates/sdist/standard_default_vcs_git_exclusion_files.py index f1a334317..2dfa70212 100644 --- a/tests/helpers/templates/sdist/standard_default_vcs_git_exclusion_files.py +++ b/tests/helpers/templates/sdist/standard_default_vcs_git_exclusion_files.py @@ -25,6 +25,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/sdist/standard_default_vcs_mercurial_exclusion_files.py b/tests/helpers/templates/sdist/standard_default_vcs_mercurial_exclusion_files.py index 1b7c7359c..101f42ec5 100644 --- a/tests/helpers/templates/sdist/standard_default_vcs_mercurial_exclusion_files.py +++ b/tests/helpers/templates/sdist/standard_default_vcs_mercurial_exclusion_files.py @@ -31,6 +31,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/sdist/standard_include.py b/tests/helpers/templates/sdist/standard_include.py index 128b3151d..2b319c095 100644 --- a/tests/helpers/templates/sdist/standard_include.py +++ b/tests/helpers/templates/sdist/standard_include.py @@ -21,6 +21,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Description-Content-Type: text/markdown diff --git a/tests/helpers/templates/sdist/standard_include_config_file.py b/tests/helpers/templates/sdist/standard_include_config_file.py index 8345d7134..17b1bb06c 100644 --- a/tests/helpers/templates/sdist/standard_include_config_file.py +++ b/tests/helpers/templates/sdist/standard_include_config_file.py @@ -22,6 +22,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Description-Content-Type: text/markdown diff --git a/tests/helpers/templates/wheel/standard_default_build_script.py b/tests/helpers/templates/wheel/standard_default_build_script.py index d7018e88c..40469a14f 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script.py +++ b/tests/helpers/templates/wheel/standard_default_build_script.py @@ -36,6 +36,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_build_script_artifacts.py b/tests/helpers/templates/wheel/standard_default_build_script_artifacts.py index 7d3240213..725406970 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script_artifacts.py +++ b/tests/helpers/templates/wheel/standard_default_build_script_artifacts.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_build_script_artifacts_with_src_layout.py b/tests/helpers/templates/wheel/standard_default_build_script_artifacts_with_src_layout.py index 4c528f2fc..65f4ec948 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script_artifacts_with_src_layout.py +++ b/tests/helpers/templates/wheel/standard_default_build_script_artifacts_with_src_layout.py @@ -38,6 +38,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_build_script_configured_build_hooks.py b/tests/helpers/templates/wheel/standard_default_build_script_configured_build_hooks.py index 5031bfbd0..d69d24750 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script_configured_build_hooks.py +++ b/tests/helpers/templates/wheel/standard_default_build_script_configured_build_hooks.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_build_script_extra_dependencies.py b/tests/helpers/templates/wheel/standard_default_build_script_extra_dependencies.py index f08395864..a2c7247ba 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script_extra_dependencies.py +++ b/tests/helpers/templates/wheel/standard_default_build_script_extra_dependencies.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 Requires-Dist: binary diff --git a/tests/helpers/templates/wheel/standard_default_build_script_force_include.py b/tests/helpers/templates/wheel/standard_default_build_script_force_include.py index 7f762f82e..e04b2046d 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script_force_include.py +++ b/tests/helpers/templates/wheel/standard_default_build_script_force_include.py @@ -38,6 +38,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_build_script_force_include_no_duplication.py b/tests/helpers/templates/wheel/standard_default_build_script_force_include_no_duplication.py index 9de705f4a..0501c2e1c 100644 --- a/tests/helpers/templates/wheel/standard_default_build_script_force_include_no_duplication.py +++ b/tests/helpers/templates/wheel/standard_default_build_script_force_include_no_duplication.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_extra_metadata.py b/tests/helpers/templates/wheel/standard_default_extra_metadata.py index f390a377a..43d38e55d 100644 --- a/tests/helpers/templates/wheel/standard_default_extra_metadata.py +++ b/tests/helpers/templates/wheel/standard_default_extra_metadata.py @@ -38,6 +38,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_license_multiple.py b/tests/helpers/templates/wheel/standard_default_license_multiple.py index 87d972310..48526436e 100644 --- a/tests/helpers/templates/wheel/standard_default_license_multiple.py +++ b/tests/helpers/templates/wheel/standard_default_license_multiple.py @@ -39,6 +39,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSES/Apache-2.0.txt License-File: LICENSES/MIT.txt """, diff --git a/tests/helpers/templates/wheel/standard_default_license_single.py b/tests/helpers/templates/wheel/standard_default_license_single.py index f961027e2..e1677e126 100644 --- a/tests/helpers/templates/wheel/standard_default_license_single.py +++ b/tests/helpers/templates/wheel/standard_default_license_single.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_default_namespace_package.py b/tests/helpers/templates/wheel/standard_default_namespace_package.py index a1335db92..46383a9d5 100644 --- a/tests/helpers/templates/wheel/standard_default_namespace_package.py +++ b/tests/helpers/templates/wheel/standard_default_namespace_package.py @@ -39,6 +39,8 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {namespace_package}.{kwargs["package_name"]} +Import-Namespace: {namespace_package} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_default_python_constraint.py b/tests/helpers/templates/wheel/standard_default_python_constraint.py index 945ef4ad3..0b18c29fd 100644 --- a/tests/helpers/templates/wheel/standard_default_python_constraint.py +++ b/tests/helpers/templates/wheel/standard_default_python_constraint.py @@ -36,6 +36,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_python_constraint_three_components.py b/tests/helpers/templates/wheel/standard_default_python_constraint_three_components.py index 26cb9fa53..8d404715f 100644 --- a/tests/helpers/templates/wheel/standard_default_python_constraint_three_components.py +++ b/tests/helpers/templates/wheel/standard_default_python_constraint_three_components.py @@ -36,6 +36,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: ==3.11.4 """, diff --git a/tests/helpers/templates/wheel/standard_default_sbom.py b/tests/helpers/templates/wheel/standard_default_sbom.py index 62a75c5a3..e6550b5b2 100644 --- a/tests/helpers/templates/wheel/standard_default_sbom.py +++ b/tests/helpers/templates/wheel/standard_default_sbom.py @@ -42,6 +42,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_default_shared_data.py b/tests/helpers/templates/wheel/standard_default_shared_data.py index f74b69247..69d115fac 100644 --- a/tests/helpers/templates/wheel/standard_default_shared_data.py +++ b/tests/helpers/templates/wheel/standard_default_shared_data.py @@ -39,6 +39,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_shared_scripts.py b/tests/helpers/templates/wheel/standard_default_shared_scripts.py index 1e52e6862..bff901d96 100644 --- a/tests/helpers/templates/wheel/standard_default_shared_scripts.py +++ b/tests/helpers/templates/wheel/standard_default_shared_scripts.py @@ -74,6 +74,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_default_single_module.py b/tests/helpers/templates/wheel/standard_default_single_module.py index 7d01d8ae2..fe4f3039a 100644 --- a/tests/helpers/templates/wheel/standard_default_single_module.py +++ b/tests/helpers/templates/wheel/standard_default_single_module.py @@ -34,6 +34,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_default_symlink.py b/tests/helpers/templates/wheel/standard_default_symlink.py index 1e5374bb3..6015e0309 100644 --- a/tests/helpers/templates/wheel/standard_default_symlink.py +++ b/tests/helpers/templates/wheel/standard_default_symlink.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Python: >3 """, diff --git a/tests/helpers/templates/wheel/standard_editable_exact.py b/tests/helpers/templates/wheel/standard_editable_exact.py index 442d92230..e5e5dd392 100644 --- a/tests/helpers/templates/wheel/standard_editable_exact.py +++ b/tests/helpers/templates/wheel/standard_editable_exact.py @@ -44,6 +44,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Dist: editables~=0.3 """, diff --git a/tests/helpers/templates/wheel/standard_editable_exact_extra_dependencies.py b/tests/helpers/templates/wheel/standard_editable_exact_extra_dependencies.py index 6a4158828..f9598bf72 100644 --- a/tests/helpers/templates/wheel/standard_editable_exact_extra_dependencies.py +++ b/tests/helpers/templates/wheel/standard_editable_exact_extra_dependencies.py @@ -44,6 +44,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Dist: binary Requires-Dist: editables~=0.3 diff --git a/tests/helpers/templates/wheel/standard_editable_exact_force_include.py b/tests/helpers/templates/wheel/standard_editable_exact_force_include.py index 72bf7a58f..1fc6bf8fc 100644 --- a/tests/helpers/templates/wheel/standard_editable_exact_force_include.py +++ b/tests/helpers/templates/wheel/standard_editable_exact_force_include.py @@ -45,6 +45,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Dist: editables~=0.3 """, diff --git a/tests/helpers/templates/wheel/standard_editable_pth.py b/tests/helpers/templates/wheel/standard_editable_pth.py index c78915cf6..6b16368e4 100644 --- a/tests/helpers/templates/wheel/standard_editable_pth.py +++ b/tests/helpers/templates/wheel/standard_editable_pth.py @@ -36,6 +36,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_editable_pth_extra_dependencies.py b/tests/helpers/templates/wheel/standard_editable_pth_extra_dependencies.py index cdc9f2ea9..5757d5b45 100644 --- a/tests/helpers/templates/wheel/standard_editable_pth_extra_dependencies.py +++ b/tests/helpers/templates/wheel/standard_editable_pth_extra_dependencies.py @@ -36,6 +36,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt Requires-Dist: binary """, diff --git a/tests/helpers/templates/wheel/standard_editable_pth_force_include.py b/tests/helpers/templates/wheel/standard_editable_pth_force_include.py index 00e0af96a..14b864d96 100644 --- a/tests/helpers/templates/wheel/standard_editable_pth_force_include.py +++ b/tests/helpers/templates/wheel/standard_editable_pth_force_include.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_entry_points.py b/tests/helpers/templates/wheel/standard_entry_points.py index c4b080c1b..7ffc1e463 100644 --- a/tests/helpers/templates/wheel/standard_entry_points.py +++ b/tests/helpers/templates/wheel/standard_entry_points.py @@ -45,6 +45,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_no_strict_naming.py b/tests/helpers/templates/wheel/standard_no_strict_naming.py index eada5e1a8..1f2908d42 100644 --- a/tests/helpers/templates/wheel/standard_no_strict_naming.py +++ b/tests/helpers/templates/wheel/standard_no_strict_naming.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_only_packages_artifact_override.py b/tests/helpers/templates/wheel/standard_only_packages_artifact_override.py index 5c4845f67..7e0a8277d 100644 --- a/tests/helpers/templates/wheel/standard_only_packages_artifact_override.py +++ b/tests/helpers/templates/wheel/standard_only_packages_artifact_override.py @@ -40,6 +40,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), diff --git a/tests/helpers/templates/wheel/standard_tests.py b/tests/helpers/templates/wheel/standard_tests.py index 07a0eb5d7..87e6a0bcc 100644 --- a/tests/helpers/templates/wheel/standard_tests.py +++ b/tests/helpers/templates/wheel/standard_tests.py @@ -37,6 +37,7 @@ def get_files(**kwargs): Metadata-Version: {DEFAULT_METADATA_VERSION} Name: {kwargs["project_name"]} Version: 0.0.1 +Import-Name: {kwargs["package_name"]} License-File: LICENSE.txt """, ), From 7f1fd4519bf19557976a28aba7e4a1fb16d7af69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 4 Aug 2026 20:13:30 -0600 Subject: [PATCH 3/4] Document auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- docs/config/metadata.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/config/metadata.md b/docs/config/metadata.md index c7b415792..3c0abb796 100644 --- a/docs/config/metadata.md +++ b/docs/config/metadata.md @@ -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 (`/__init__.py`), a `src` layout (`src//__init__.py`), a single-file module (`.py`), or an unambiguous namespace package (`//__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. From e522680f7cbdb88a175b0a9eb812f458e391b12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 4 Aug 2026 20:13:43 -0600 Subject: [PATCH 4/4] Add changelog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- docs/history/hatchling.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index 853c104f0..113a0ec1b 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** + +- 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