diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index a23c0891f..3b751ad8a 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -32,11 +32,69 @@ import tomli as tomllib +METADATA_2_6_EXTENDABLE_FIELDS = frozenset({ + "authors", + "classifiers", + "dependencies", + "entry-points", + "gui-scripts", + "import-names", + "import-namespaces", + "license-files", + "keywords", + "maintainers", + "optional-dependencies", + "scripts", + "urls", +}) + + def load_toml(path: str) -> dict[str, Any]: with open(path, encoding="utf-8") as f: return tomllib.loads(f.read()) +def _is_extension(before: list, after: list) -> bool: + return after[: len(before)] == before + + +def _check_only_extended(field: str, before: Any, after: Any) -> None: + """ + https://peps.python.org/pep-0808/ + + Entries of a field that is both statically defined and listed in `project.dynamic` + may be appended by metadata hooks but never removed, reordered, or modified. + """ + if isinstance(before, list): + if not isinstance(after, list) or not _is_extension(before, after): + message = ( + f"Entries of metadata field `{field}` were removed, reordered, or modified, " + f"but fields listed in `project.dynamic` may only be extended by appending" + ) + raise ValueError(message) + elif isinstance(before, dict): + if not isinstance(after, dict): + message = ( + f"Metadata field `{field}` was replaced but fields listed in `project.dynamic` may only be extended" + ) + raise ValueError(message) # noqa: TRY004 + + for key, value in before.items(): + if key not in after: + message = ( + f"Entry `{key}` of metadata field `{field}` was removed " + f"but fields listed in `project.dynamic` may only be extended" + ) + raise ValueError(message) + + _check_only_extended(f"{field}.{key}", value, after[key]) + + # cover scripts, gui-scripts and urls entries, and the entries inside an entry-points group + elif before != after: + message = f"Metadata field `{field}` was modified but fields listed in `project.dynamic` may only be extended" + raise ValueError(message) + + class ProjectMetadata(Generic[PluginManagerBound]): def __init__( self, @@ -203,6 +261,11 @@ def core(self) -> CoreMetadata: metadata_hooks = self.hatch.metadata.hooks if metadata_hooks: static_fields = set(self.core_raw_metadata) + extended_fields = { + field: deepcopy(self.core_raw_metadata[field]) + for field in METADATA_2_6_EXTENDABLE_FIELDS + if field in self.core_raw_metadata and field in metadata.dynamic + } if "version" in self.hatch.config: self._version = self._get_version(metadata) self.core_raw_metadata["version"] = self.version @@ -212,6 +275,10 @@ def core(self) -> CoreMetadata: metadata_hook.update(self.core_raw_metadata) metadata.add_known_classifiers(metadata_hook.get_known_classifiers()) + for field, before in extended_fields.items(): + _check_only_extended(field, before, self.core_raw_metadata[field]) + metadata.dynamic.remove(field) + new_fields = set(self.core_raw_metadata) - static_fields for new_field in new_fields: if new_field in metadata.dynamic: @@ -761,13 +828,6 @@ def license_files(self) -> list[str]: if self._license_files is None: if "license-files" in self.config: globs = self.config["license-files"] - if "license-files" in self.dynamic: - message = ( - "Metadata field `license-files` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - if isinstance(globs, dict): globs = globs.get("globs", globs.get("paths", [])) else: @@ -805,16 +865,7 @@ def authors(self) -> list[str]: authors_data: dict[str, list[str]] if self._authors is None: - if "authors" in self.config: - authors = self.config["authors"] - if "authors" in self.dynamic: - message = ( - "Metadata field `authors` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - authors = [] + authors = self.config.get("authors", []) if not isinstance(authors, list): message = "Field `project.authors` must be an array" @@ -873,16 +924,7 @@ def maintainers(self) -> list[str]: maintainers: list[str] if self._maintainers is None: - if "maintainers" in self.config: - maintainers = self.config["maintainers"] - if "maintainers" in self.dynamic: - message = ( - "Metadata field `maintainers` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - maintainers = [] + maintainers = self.config.get("maintainers", []) if not isinstance(maintainers, list): message = "Field `project.maintainers` must be an array" @@ -939,16 +981,7 @@ def keywords(self) -> list[str]: https://peps.python.org/pep-0621/#keywords """ if self._keywords is None: - if "keywords" in self.config: - keywords = self.config["keywords"] - if "keywords" in self.dynamic: - message = ( - "Metadata field `keywords` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - keywords = [] + keywords = self.config.get("keywords", []) if not isinstance(keywords, list): message = "Field `project.keywords` must be an array" @@ -975,16 +1008,7 @@ def classifiers(self) -> list[str]: if self._classifiers is None: import bisect - if "classifiers" in self.config: - classifiers = self.config["classifiers"] - if "classifiers" in self.dynamic: - message = ( - "Metadata field `classifiers` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - classifiers = [] + classifiers = self.config.get("classifiers", []) if not isinstance(classifiers, list): message = "Field `project.classifiers` must be an array" @@ -1039,15 +1063,7 @@ def urls(self) -> dict[str, str]: https://peps.python.org/pep-0621/#urls """ if self._urls is None: - if "urls" in self.config: - urls = self.config["urls"] - if "urls" in self.dynamic: - message = ( - "Metadata field `urls` cannot be both statically defined and listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - urls = {} + urls = self.config.get("urls", {}) if not isinstance(urls, dict): message = "Field `project.urls` must be a table" @@ -1072,16 +1088,7 @@ def scripts(self) -> dict[str, str]: https://peps.python.org/pep-0621/#entry-points """ if self._scripts is None: - if "scripts" in self.config: - scripts = self.config["scripts"] - if "scripts" in self.dynamic: - message = ( - "Metadata field `scripts` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - scripts = {} + scripts = self.config.get("scripts", {}) if not isinstance(scripts, dict): message = "Field `project.scripts` must be a table" @@ -1106,16 +1113,7 @@ def gui_scripts(self) -> dict[str, str]: https://peps.python.org/pep-0621/#entry-points """ if self._gui_scripts is None: - if "gui-scripts" in self.config: - gui_scripts = self.config["gui-scripts"] - if "gui-scripts" in self.dynamic: - message = ( - "Metadata field `gui-scripts` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - gui_scripts = {} + gui_scripts = self.config.get("gui-scripts", {}) if not isinstance(gui_scripts, dict): message = "Field `project.gui-scripts` must be a table" @@ -1140,16 +1138,7 @@ def entry_points(self) -> dict[str, dict[str, str]]: https://peps.python.org/pep-0621/#entry-points """ if self._entry_points is None: - if "entry-points" in self.config: - defined_entry_point_groups = self.config["entry-points"] - if "entry-points" in self.dynamic: - message = ( - "Metadata field `entry-points` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - defined_entry_point_groups = {} + defined_entry_point_groups = self.config.get("entry-points", {}) if not isinstance(defined_entry_point_groups, dict): message = "Field `project.entry-points` must be a table" @@ -1194,16 +1183,7 @@ def dependencies_complex(self) -> dict[str, Requirement]: if self._dependencies_complex is None: from packaging.requirements import InvalidRequirement, Requirement - if "dependencies" in self.config: - dependencies = self.config["dependencies"] - if "dependencies" in self.dynamic: - message = ( - "Metadata field `dependencies` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - dependencies = [] + dependencies = self.config.get("dependencies", []) if not isinstance(dependencies, list): message = "Field `project.dependencies` must be an array" @@ -1254,16 +1234,7 @@ def optional_dependencies_complex(self) -> dict[str, dict[str, Requirement]]: if self._optional_dependencies_complex is None: from packaging.requirements import InvalidRequirement, Requirement - if "optional-dependencies" in self.config: - optional_dependencies = self.config["optional-dependencies"] - if "optional-dependencies" in self.dynamic: - message = ( - "Metadata field `optional-dependencies` cannot be both statically defined and " - "listed in field `project.dynamic`" - ) - raise ValueError(message) - else: - optional_dependencies = {} + optional_dependencies = self.config.get("optional-dependencies", {}) if not isinstance(optional_dependencies, dict): message = "Field `project.optional-dependencies` must be a table" @@ -1372,12 +1343,6 @@ def import_names(self) -> list[str] | None: 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" @@ -1402,16 +1367,7 @@ 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 = [] + import_namespaces = self.config.get("import-namespaces", []) if not isinstance(import_namespaces, list): message = "Field `project.import-namespaces` must be an array" diff --git a/backend/src/hatchling/metadata/spec.py b/backend/src/hatchling/metadata/spec.py index 73d277df2..9498bbd55 100644 --- a/backend/src/hatchling/metadata/spec.py +++ b/backend/src/hatchling/metadata/spec.py @@ -66,6 +66,7 @@ def get_core_metadata_constructors() -> dict[str, Callable]: "2.3": construct_metadata_file_2_3, "2.4": construct_metadata_file_2_4, "2.5": construct_metadata_file_2_5, + "2.6": construct_metadata_file_2_6, } @@ -726,3 +727,13 @@ def construct_metadata_file_2_5(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f"\n{metadata.core.readme}" return metadata_file + + +def construct_metadata_file_2_6(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: + """ + https://peps.python.org/pep-0808/ + + The fields are the same as version 2.5; only the semantics of `Dynamic` differ. + """ + metadata_file = construct_metadata_file_2_5(metadata, extra_dependencies) + return metadata_file.replace("Metadata-Version: 2.5\n", "Metadata-Version: 2.6\n", 1) diff --git a/docs/config/metadata.md b/docs/config/metadata.md index c7b415792..3a4e38e38 100644 --- a/docs/config/metadata.md +++ b/docs/config/metadata.md @@ -240,6 +240,29 @@ 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 names that a project makes importable, independently of the project's distribution name. For more information, see [PEP 794](https://peps.python.org/pep-0794/). + +```toml tab="pyproject.toml" +[project] +... +import-names = [ + "...", +] +import-namespaces = [ + "...", +] +``` + +- `import-names` lists the modules and packages that the project provides. Dotted names such as `pkg.subpkg` are allowed. +- `import-namespaces` lists the [namespace packages](https://packaging.python.org/en/latest/guides/packaging-namespace-packages/) that the project contributes to. +- A name may not appear in both fields. +- Append `; private` to a name to mark it as not intended for public use, for example `"_internal; private"`. Names that start with an underscore are always recorded as private. +- An empty `import-names` array, with `import-namespaces` unset, states that the project provides nothing importable. + +These fields are written to the `Import-Name` and `Import-Namespace` entries of the [core metadata](../plugins/builder/wheel.md#options) when the version is 2.5 or later. Declarations are not validated against the project's layout on disk. + ## Dynamic If any metadata fields are set dynamically, like the [`version`](#version) may be, then they must be listed here. @@ -252,6 +275,8 @@ dynamic = [ ] ``` +Following [PEP 808](https://peps.python.org/pep-0808/), fields that hold arrays or tables of arbitrary entries may also be statically defined, in which case [metadata hooks](../plugins/metadata-hook/reference.md) can only add to them. See the [how-to guide](../how-to/config/dynamic-metadata.md#extend-static-metadata) for details. + ## Metadata options ### Allowing direct references diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index 6d3af09ea..25fba4443 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Added:*** + +- Support [PEP 808](https://peps.python.org/pep-0808/): `authors`, `maintainers`, `classifiers`, `dependencies`, `entry-points`, `scripts`, `gui-scripts`, `keywords`, `license-files`, `optional-dependencies`, `urls`, `import-names` and `import-namespaces` may now be both statically defined and listed in `project.dynamic`. Metadata hooks may then append entries but not remove, reorder or modify the static ones. +- Add support for core metadata version 2.6 ([PEP 808](https://peps.python.org/pep-0808/)), selectable with the `core-metadata-version` option of the `sdist` and `wheel` targets. The default remains 2.5. + ## [1.32.4](https://github.com/pypa/hatch/releases/tag/hatchling-v1.32.4) - 2026-09-20 ## {: #hatchling-v1.32.4 } ***Fixed:*** diff --git a/docs/how-to/config/dynamic-metadata.md b/docs/how-to/config/dynamic-metadata.md index bf26a2208..216545ed6 100644 --- a/docs/how-to/config/dynamic-metadata.md +++ b/docs/how-to/config/dynamic-metadata.md @@ -14,7 +14,7 @@ If you have [project metadata](../../config/metadata.md) that is not appropriate Change the `[project]` section of `pyproject.toml`: 1. Define the [dynamic field](../../config/metadata.md#dynamic) as an array of all the fields you will set dynamically e.g. `dynamic = ["version", "license", "authors", "maintainers"]` -2. If any of those fields have static definitions in `pyproject.toml`, delete those definitions. It is verboten to define a field statically and dynamically. +2. If any of those fields have static definitions in `pyproject.toml`, delete those definitions. Most fields cannot be defined both statically and dynamically; the exceptions are the array and table fields that can be [extended](#extend-static-metadata). Add a section to trigger loading of dynamic metadata plugins: `[tool.hatch.metadata.hooks.custom]`. Use exactly that name, regardless of the name of the class you will use or its `PLUGIN_NAME`. There doesn't need to be anything in the section. @@ -55,3 +55,56 @@ If you want to store the hook in a different location, set the [`path` option](. [tool.hatch.metadata.hooks.custom] path = "some/where.py" ``` + +## Extend static metadata + +Per [PEP 808](https://peps.python.org/pep-0808/), fields that hold arrays or tables of arbitrary entries may be both statically defined and listed in [`dynamic`](../../config/metadata.md#dynamic). The static value is then the starting point and the hook may add entries to it, but it may never remove, reorder, or modify the statically defined entries, or insert entries before them. New entries must be appended after the static ones. Without listing the field in `dynamic`, it is entirely static. + +For example, [the PEP](https://peps.python.org/pep-0808/#practical-example) describes pinning a dependency to the exact version that was present at build time, while still listing the unpinned requirements statically: + +```toml tab="pyproject.toml" +[build-system] +requires = ["hatchling", "torch"] +build-backend = "hatchling.build" + +[project] +dependencies = ["torch", "packaging"] +dynamic = ["dependencies"] + +[tool.hatch.metadata.hooks.custom] +pin-to-build-versions = ["torch=={exact}"] +``` + +```python tab="hatch_build.py" +from importlib.metadata import version + +from hatchling.metadata.plugin.interface import MetadataHookInterface + + +class PinHook(MetadataHookInterface): + def update(self, metadata): + for template in self.config["pin-to-build-versions"]: + name = template.split("==")[0] + metadata["dependencies"].append(template.format(exact=version(name))) +``` + +The built distribution then requires `torch`, `packaging`, and `torch==`. + +The build fails if the hook breaks these rules. + +The following fields can be extended this way: + +| Field | Type | What hooks may add | +| --- | --- | --- | +| [`authors`](../../config/metadata.md#ownership), [`maintainers`](../../config/metadata.md#ownership) | array of tables | New authors or maintainers. Existing ones cannot be modified. | +| [`classifiers`](../../config/metadata.md#classifiers) | array | New classifiers. | +| [`dependencies`](../../config/metadata.md#required) | array | New dependencies, including additional constraints on existing packages. | +| [`entry-points`](../../config/metadata.md#plugins) | table of tables | New entry points, in either new or existing groups. Existing entry points cannot be changed or removed. | +| [`scripts`](../../config/metadata.md#cli), [`gui-scripts`](../../config/metadata.md#gui) | table | New scripts. Existing ones cannot be changed or removed. | +| [`keywords`](../../config/metadata.md#keywords) | array | New keywords. | +| [`license-files`](../../config/metadata.md#license) | array | New files. | +| [`optional-dependencies`](../../config/metadata.md#optional) | table of arrays | New extras, or new items in an existing extra. | +| [`urls`](../../config/metadata.md#urls) | table | New URLs. Existing ones cannot be changed or removed. | +| [`import-names`](../../config/metadata.md#import-names), [`import-namespaces`](../../config/metadata.md#import-names) | array | New import names or namespaces. Existing ones cannot be modified or removed. | + +All other fields, such as `version`, `description`, `readme`, `requires-python` and `license`, cannot be both statically defined and listed in `dynamic`. diff --git a/docs/plugins/builder/sdist.md b/docs/plugins/builder/sdist.md index 13db3f657..9b5950cd7 100644 --- a/docs/plugins/builder/sdist.md +++ b/docs/plugins/builder/sdist.md @@ -16,7 +16,7 @@ The builder plugin name is `sdist`. | Option | Default | Description | | --- | --- | --- | -| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | +| `core-metadata-version` | `"2.5"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | | `strict-naming` | `true` | Whether or not file names should contain the normalized version of the project name | | `support-legacy` | `false` | Whether or not to include a `setup.py` file to support legacy installation mechanisms | diff --git a/docs/plugins/builder/wheel.md b/docs/plugins/builder/wheel.md index 946df8f89..f554cfbf7 100644 --- a/docs/plugins/builder/wheel.md +++ b/docs/plugins/builder/wheel.md @@ -16,7 +16,7 @@ The builder plugin name is `wheel`. | Option | Default | Description | | --- | --- | --- | -| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | +| `core-metadata-version` | `"2.5"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | | `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` | | `shared-scripts` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `scripts` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed in a given Python environment, usually under `Scripts` on Windows or `bin` otherwise, and would normally be available on PATH | | `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` | diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py index 4d2efd611..dc4df4dd0 100644 --- a/tests/backend/metadata/test_core.py +++ b/tests/backend/metadata/test_core.py @@ -1,3 +1,5 @@ +from typing import Any, ClassVar + import pytest from hatchling.metadata.core import BuildMetadata, CoreMetadata, HatchMetadata, ProjectMetadata @@ -635,19 +637,6 @@ def test_text_correct(self, isolation): class TestLicenseFiles: - def test_dynamic(self, isolation): - metadata = ProjectMetadata( - str(isolation), None, {"project": {"license-files": 9000, "dynamic": ["license-files"]}} - ) - - with pytest.raises( - ValueError, - match=( - "Metadata field `license-files` cannot be both statically defined and listed in field `project.dynamic`" - ), - ): - _ = metadata.core.license_files - def test_not_array(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"license-files": 9000}}) @@ -713,15 +702,6 @@ def test_paths_with_licenses(self, temp_dir): class TestAuthors: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": 9000, "dynamic": ["authors"]}}) - - with pytest.raises( - ValueError, - match="Metadata field `authors` cannot be both statically defined and listed in field `project.dynamic`", - ): - _ = metadata.core.authors - def test_not_array(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": "foo"}}) @@ -791,17 +771,6 @@ def test_name_and_email(self, isolation): class TestMaintainers: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": 9000, "dynamic": ["maintainers"]}}) - - with pytest.raises( - ValueError, - match=( - "Metadata field `maintainers` cannot be both statically defined and listed in field `project.dynamic`" - ), - ): - _ = metadata.core.maintainers - def test_not_array(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": "foo"}}) @@ -874,15 +843,6 @@ def test_name_and_email(self, isolation): class TestKeywords: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"keywords": 9000, "dynamic": ["keywords"]}}) - - with pytest.raises( - ValueError, - match="Metadata field `keywords` cannot be both statically defined and listed in field `project.dynamic`", - ): - _ = metadata.core.keywords - def test_not_array(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"keywords": 10}}) @@ -902,17 +862,6 @@ def test_correct(self, isolation): class TestClassifiers: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": 9000, "dynamic": ["classifiers"]}}) - - with pytest.raises( - ValueError, - match=( - "Metadata field `classifiers` cannot be both statically defined and listed in field `project.dynamic`" - ), - ): - _ = metadata.core.classifiers - def test_not_array(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": 10}}) @@ -979,15 +928,6 @@ def test_correct(self, isolation): class TestURLs: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"urls": 9000, "dynamic": ["urls"]}}) - - with pytest.raises( - ValueError, - match="Metadata field `urls` cannot be both statically defined and listed in field `project.dynamic`", - ): - _ = metadata.core.urls - def test_not_table(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"urls": 10}}) @@ -1007,15 +947,6 @@ def test_correct(self, isolation): class TestScripts: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"scripts": 9000, "dynamic": ["scripts"]}}) - - with pytest.raises( - ValueError, - match="Metadata field `scripts` cannot be both statically defined and listed in field `project.dynamic`", - ): - _ = metadata.core.scripts - def test_not_table(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"scripts": 10}}) @@ -1035,17 +966,6 @@ def test_correct(self, isolation): class TestGUIScripts: - def test_dynamic(self, isolation): - metadata = ProjectMetadata(str(isolation), None, {"project": {"gui-scripts": 9000, "dynamic": ["gui-scripts"]}}) - - with pytest.raises( - ValueError, - match=( - "Metadata field `gui-scripts` cannot be both statically defined and listed in field `project.dynamic`" - ), - ): - _ = metadata.core.gui_scripts - def test_not_table(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"gui-scripts": 10}}) @@ -1065,19 +985,6 @@ def test_correct(self, isolation): class TestEntryPoints: - def test_dynamic(self, isolation): - metadata = ProjectMetadata( - str(isolation), None, {"project": {"entry-points": 9000, "dynamic": ["entry-points"]}} - ) - - with pytest.raises( - ValueError, - match=( - "Metadata field `entry-points` cannot be both statically defined and listed in field `project.dynamic`" - ), - ): - _ = metadata.core.entry_points - def test_not_table(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"entry-points": 10}}) @@ -1136,19 +1043,6 @@ def test_correct(self, isolation): class TestDependencies: - def test_dynamic(self, isolation): - metadata = ProjectMetadata( - str(isolation), None, {"project": {"dependencies": 9000, "dynamic": ["dependencies"]}} - ) - - with pytest.raises( - ValueError, - match=( - "Metadata field `dependencies` cannot be both statically defined and listed in field `project.dynamic`" - ), - ): - _ = metadata.core.dependencies - def test_not_array(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"dependencies": 10}}) @@ -1235,20 +1129,6 @@ def test_correct(self, isolation): class TestOptionalDependencies: - def test_dynamic(self, isolation): - metadata = ProjectMetadata( - str(isolation), None, {"project": {"optional-dependencies": 9000, "dynamic": ["optional-dependencies"]}} - ) - - with pytest.raises( - ValueError, - match=( - "Metadata field `optional-dependencies` cannot be both statically defined and " - "listed in field `project.dynamic`" - ), - ): - _ = metadata.core.optional_dependencies - def test_not_table(self, isolation): metadata = ProjectMetadata(str(isolation), None, {"project": {"optional-dependencies": 10}}) @@ -1431,17 +1311,6 @@ 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}}) @@ -1469,17 +1338,6 @@ def test_private_import_name(self, isolation): 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}}) @@ -1913,3 +1771,174 @@ def update(self, metadata): "scripts": {"foo": "bar"}, "dynamic": ["scripts"], } + + +_LIST_REPLACEMENTS = { + "authors": {"name": "X"}, + "maintainers": {"name": "X"}, + "classifiers": "X", + "dependencies": "x>=1", + "keywords": "x", + "license-files": "X", + "import-names": "x", + "import-namespaces": "x", +} + + +class TestExtendedFields: + # Static values with enough entries to be removed, reordered, and modified + _REJECTED: ClassVar[dict[str, Any]] = { + "authors": [{"name": "A"}, {"name": "B"}], + "maintainers": [{"name": "A"}, {"name": "B"}], + "classifiers": ["Programming Language :: Python", "Topic :: Utilities"], + "dependencies": ["foo>=1", "bar>=1"], + "entry-points": {"g": {"a": "p:a", "b": "p:b"}}, + "scripts": {"a": "p:a", "b": "p:b"}, + "gui-scripts": {"a": "p:a", "b": "p:b"}, + "keywords": ["a", "b"], + "license-files": ["A", "B"], + "optional-dependencies": {"dev": ["x", "y"], "docs": ["z"]}, + "urls": {"a": "https://a.org", "b": "https://b.org"}, + "import-names": ["a", "b"], + "import-namespaces": ["n", "m"], + } + + @staticmethod + def _metadata(temp_dir, project, update): + metadata = ProjectMetadata( + str(temp_dir), + PluginManager(), + { + "project": {"name": "foo", "version": "0.0.1", **project}, + "tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}}, + }, + ) + body = "".join(f" {line}\n" for line in update.splitlines()) + (temp_dir / DEFAULT_BUILD_SCRIPT).write_text( + "from hatchling.metadata.plugin.interface import MetadataHookInterface\n\n" + "class CustomHook(MetadataHookInterface):\n" + " def update(self, metadata):\n" + body + ) + return metadata + + def test_extend(self, temp_dir): + (temp_dir / "LICENSE").touch() + (temp_dir / "NOTICE").touch() + + update = "\n".join(( # noqa: FLY002 + "metadata['dynamic'].append({'name': 'Bar', 'email': 'bar@example.org'})", + "metadata['authors'].append({'name': 'Bar', 'email': 'bar@example.org'})", + "metadata['maintainers'].append({'name': 'Baz'})", + "metadata['classifiers'].append('Private :: Do Not Upload')", + "metadata['dependencies'].append('bar>=2')", + "metadata['entry-points']['group1']['new'] = 'pkg:new'", + "metadata['entry-points']['group2'] = {'other': 'pkg:other'}", + "metadata['scripts']['new-script'] = 'pkg:new_script'", + "metadata['gui-scripts']['new-gui'] = 'pkg:new_gui'", + "metadata['keywords'].append('b')", + "metadata['license-files'].append('NOTICE')", + "metadata['optional-dependencies']['pinned'] = ['bar==1']", + "metadata['optional-dependencies']['dev'].append('baz')", + "metadata['urls']['Docs'] = 'https://example.com'", + "metadata['import-names'].append('foo_extra')", + "metadata['import-namespaces'].append('ns_extra')", + )) + extendable = { + "authors": [{"name": "Foo"}], + "maintainers": [{"name": "Qux"}], + "classifiers": ["Programming Language :: Python"], + "dependencies": ["foo>=1"], + "entry-points": {"group1": {"old": "pkg:old"}}, + "scripts": {"old-script": "pkg:old_script"}, + "gui-scripts": {"old-gui": "pkg:old_gui"}, + "keywords": ["a"], + "license-files": ["LICENSE"], + "optional-dependencies": {"dev": ["pytest"]}, + "urls": {"Home": "https://example.org"}, + "import-names": ["foo"], + "import-namespaces": ["ns"], + } + metadata = self._metadata(temp_dir, {"dynamic": list(extendable), **extendable}, update) + + with temp_dir.as_cwd(): + core = metadata.core + assert core.authors_data == {"name": ["Foo"], "email": ["Bar "]} + assert core.maintainers_data == {"name": ["Qux", "Baz"], "email": []} + assert core.classifiers == ["Private :: Do Not Upload", "Programming Language :: Python"] + assert core.dependencies == ["bar>=2", "foo>=1"] + assert core.entry_points == { + "group1": {"new": "pkg:new", "old": "pkg:old"}, + "group2": {"other": "pkg:other"}, + } + assert core.scripts == {"new-script": "pkg:new_script", "old-script": "pkg:old_script"} + assert core.gui_scripts == {"new-gui": "pkg:new_gui", "old-gui": "pkg:old_gui"} + assert core.keywords == ["a", "b"] + assert core.license_files == ["LICENSE", "NOTICE"] + assert core.optional_dependencies == {"dev": ["baz", "pytest"], "pinned": ["bar==1"]} + assert core.urls == {"Home": "https://example.org", "Docs": "https://example.com"} + assert core.import_names == ["foo", "foo_extra"] + assert core.import_namespaces == ["ns", "ns_extra"] + assert core.dynamic == [] + + @pytest.mark.parametrize( + ("update", "message"), + [ + *( + pytest.param(update, "removed, reordered, or modified", id=f"{field}-{case}") + for field, replacement in _LIST_REPLACEMENTS.items() + for case, update in ( + ("remove", f"metadata[{field!r}] = metadata[{field!r}][1:]"), + ("reorder", f"metadata[{field!r}].reverse()"), + ("insert-first", f"metadata[{field!r}].insert(0, {replacement!r})"), + ("modify", f"metadata[{field!r}][0] = {replacement!r}"), + ("replace", f"metadata[{field!r}] = 'foo'"), + ) + ), + *( + pytest.param(update, message, id=f"{field}-{case}") + for field in ("scripts", "gui-scripts", "urls") + for case, update, message in ( + ("remove-key", f"del metadata[{field!r}]['a']", "was removed"), + ("remove-all", f"metadata[{field!r}] = {{}}", "was removed"), + ("modify", f"metadata[{field!r}]['a'] = 'changed'", "was modified"), + ("replace", f"metadata[{field!r}] = 'foo'", "was replaced"), + ) + ), + pytest.param("del metadata['entry-points']['g']", "was removed", id="entry-points-remove-group"), + pytest.param("del metadata['entry-points']['g']['a']", "was removed", id="entry-points-remove-entry"), + pytest.param( + "metadata['entry-points']['g']['a'] = 'changed'", + "was modified", + id="entry-points-modify-entry", + ), + pytest.param("metadata['entry-points']['g'] = 'foo'", "was replaced", id="entry-points-replace-group"), + pytest.param("metadata['entry-points'] = 'foo'", "was replaced", id="entry-points-replace"), + pytest.param("del metadata['optional-dependencies']['dev']", "was removed", id="extras-remove-extra"), + pytest.param( + "metadata['optional-dependencies']['dev'] = ['y']", + "removed, reordered, or modified", + id="extras-remove-item", + ), + pytest.param( + "metadata['optional-dependencies']['dev'].insert(0, 'w')", + "removed, reordered, or modified", + id="extras-insert-first", + ), + pytest.param( + "metadata['optional-dependencies']['dev'].reverse()", + "removed, reordered, or modified", + id="extras-reorder-items", + ), + pytest.param( + "metadata['optional-dependencies']['dev'][0] = 'changed'", + "removed, reordered, or modified", + id="extras-modify-item", + ), + pytest.param("metadata['optional-dependencies'] = 'foo'", "was replaced", id="extras-replace"), + ], + ) + def test_not_only_extended(self, temp_dir, update, message): + metadata = self._metadata(temp_dir, {"dynamic": list(self._REJECTED), **self._REJECTED}, update) + + with temp_dir.as_cwd(), pytest.raises(ValueError, match=message): + _ = metadata.core diff --git a/tests/backend/metadata/test_spec.py b/tests/backend/metadata/test_spec.py index 12d7b1925..423f2001b 100644 --- a/tests/backend/metadata/test_spec.py +++ b/tests/backend/metadata/test_spec.py @@ -2563,3 +2563,40 @@ def test_import_namespaces_private(self, constructor, isolation, helpers): Summary: pytest: simple powerful testing with Python """ ) + + +class TestCoreMetadataV26: + def test_version_header(self, isolation, helpers): + metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0"}}) + + assert get_core_metadata_constructors()["2.6"](metadata) == helpers.dedent( + """ + Metadata-Version: 2.6 + Name: My.App + Version: 0.1.0 + """ + ) + + def test_same_fields_as_v25(self, isolation): + metadata = ProjectMetadata( + str(isolation), + None, + { + "project": { + "name": "My.App", + "version": "0.1.0", + "description": "foo", + "keywords": ["a", "b"], + "authors": [{"name": "Foo", "email": "foo@example.org"}], + "dependencies": ["bar>=1"], + "optional-dependencies": {"dev": ["pytest"]}, + "import-names": ["my_app"], + "dynamic": ["classifiers"], + }, + }, + ) + constructors = get_core_metadata_constructors() + + assert constructors["2.6"](metadata, ("baz",)) == constructors["2.5"](metadata, ("baz",)).replace( + "Metadata-Version: 2.5", "Metadata-Version: 2.6", 1 + )