diff --git a/docs/api/scikit_build_core.metadata.rst b/docs/api/scikit_build_core.metadata.rst index 7073d5ee8..da1713af8 100644 --- a/docs/api/scikit_build_core.metadata.rst +++ b/docs/api/scikit_build_core.metadata.rst @@ -32,3 +32,11 @@ scikit\_build\_core.metadata.setuptools\_scm module :members: :show-inheritance: :undoc-members: + +scikit\_build\_core.metadata.template module +-------------------------------------------- + +.. automodule:: scikit_build_core.metadata.template + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/configuration/dynamic.md b/docs/configuration/dynamic.md index f46d44bd9..1be8bc1d5 100644 --- a/docs/configuration/dynamic.md +++ b/docs/configuration/dynamic.md @@ -52,7 +52,7 @@ dynamic_version() project(MyPackage VERSION ${PROJECT_VERSION}) ``` -## `version`: Regex +## Regex If you want to pull a string-valued expression (usually version) from an existing file, you can the integrated `regex` plugin to pull the information. @@ -112,6 +112,30 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme" # tool.hatch.metadata.hooks.fancy-pypi-readme options here ``` +```{versionchanged} 0.11.2 + +The version number feature now works. +``` + +## Template + +You can access other metadata fields and produce templated outputs. + +```toml +[tool.scikit-build.metadata.optional-dependencies] +provider = "scikit_build_core.metadata.template" +result = {"dev" = ["{project[name]}=={project[version]}"]} +``` + +You can use `project` to access the current metadata values. You can reference +other dynamic metadata fields, and they will be computed before this one. You +can use `result` to specify the output. The result must match the type of the +metadata field you are writing to. + +```{versionadded} 0.11.2 + +``` + ## `build-system.requires`: Scikit-build-core's `build.requires` If you need to inject and manipulate additional `build-system.requires`, you can diff --git a/src/scikit_build_core/build/metadata.py b/src/scikit_build_core/build/metadata.py index a35dcb6f2..ec82601ee 100644 --- a/src/scikit_build_core/build/metadata.py +++ b/src/scikit_build_core/build/metadata.py @@ -13,7 +13,7 @@ extras_build_system, extras_top_level, ) -from ..settings._load_provider import load_dynamic_metadata +from ..builder._load_provider import process_dynamic_metadata if TYPE_CHECKING: from collections.abc import Mapping @@ -34,23 +34,16 @@ def __dir__() -> list[str]: errors.ExceptionGroup = ExceptionGroup # type: ignore[misc, assignment] -# If pyproject-metadata eventually supports updates, this can be simplified def get_standard_metadata( pyproject_dict: Mapping[str, Any], settings: ScikitBuildSettings, ) -> StandardMetadata: - new_pyproject_dict = copy.deepcopy(pyproject_dict) + new_pyproject_dict = copy.deepcopy(dict(pyproject_dict)) # Handle any dynamic metadata - for field, provider, config in load_dynamic_metadata(settings.metadata): - if provider is None: - msg = f"{field} is missing provider" - raise KeyError(msg) - if field not in pyproject_dict.get("project", {}).get("dynamic", []): - msg = f"{field} is not in project.dynamic" - raise KeyError(msg) - new_pyproject_dict["project"][field] = provider.dynamic_metadata(field, config) - new_pyproject_dict["project"]["dynamic"].remove(field) + new_pyproject_dict["project"] = process_dynamic_metadata( + new_pyproject_dict["project"], settings.metadata + ) if settings.strict_config: extra_keys_top = extras_top_level(new_pyproject_dict) diff --git a/src/scikit_build_core/builder/_load_provider.py b/src/scikit_build_core/builder/_load_provider.py new file mode 100644 index 000000000..cc6862f82 --- /dev/null +++ b/src/scikit_build_core/builder/_load_provider.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import sys +from collections.abc import Iterator, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Generator, Iterable + + StrMapping = Mapping[str, Any] +else: + StrMapping = Mapping + +from ..metadata import _ALL_FIELDS + +__all__ = ["load_provider", "process_dynamic_metadata"] + + +def __dir__() -> list[str]: + return __all__ + + +@runtime_checkable +class DynamicMetadataProtocol(Protocol): + def dynamic_metadata( + self, + fields: Iterable[str], + settings: dict[str, Any], + project: Mapping[str, Any], + ) -> dict[str, Any]: ... + + +@runtime_checkable +class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol): + def get_requires_for_dynamic_metadata( + self, settings: dict[str, Any] + ) -> list[str]: ... + + +@runtime_checkable +class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol): + def dynamic_wheel(self, field: str, settings: Mapping[str, Any]) -> bool: ... + + +DMProtocols = Union[ + DynamicMetadataProtocol, + DynamicMetadataRequirementsProtocol, + DynamicMetadataWheelProtocol, +] + + +def load_provider( + provider: str, + provider_path: str | None = None, +) -> DMProtocols: + if provider_path is None: + return importlib.import_module(provider) + + if not Path(provider_path).is_dir(): + msg = "provider-path must be an existing directory" + raise AssertionError(msg) + + try: + sys.path.insert(0, provider_path) + return importlib.import_module(provider) + finally: + sys.path.pop(0) + + +def _load_dynamic_metadata( + metadata: Mapping[str, Mapping[str, str]], +) -> Generator[tuple[str, DMProtocols, dict[str, Any]], None, None]: + for field, orig_config in metadata.items(): + if "provider" not in orig_config: + msg = "Missing provider in dynamic metadata" + raise KeyError(msg) + + if field not in _ALL_FIELDS: + msg = f"{field} is not a valid field" + raise KeyError(msg) + config = dict(orig_config) + provider = config.pop("provider") + provider_path = config.pop("provider-path", None) + loaded_provider = load_provider(provider, provider_path) + yield field, loaded_provider, config + + +@dataclasses.dataclass +class DynamicPyProject(StrMapping): + settings: dict[str, dict[str, Any]] + project: dict[str, Any] + providers: dict[str, DMProtocols] + + def __getitem__(self, key: str) -> Any: + # Try to get the settings from either the static file or dynamic metadata provider + if key in self.project: + return self.project[key] + + # Check if we are in a loop, i.e. something else is already requesting + # this key while trying to get another key + if key not in self.providers: + dep_type = "missing" if key in self.settings else "circular" + msg = f"Encountered a {dep_type} dependency at {key}" + raise ValueError(msg) + + provider = self.providers.pop(key) + sig = inspect.signature(provider.dynamic_metadata) + if len(sig.parameters) < 3: + # Backcompat for dynamic_metadata without metadata dict + self.project[key] = provider.dynamic_metadata( # type: ignore[call-arg] + key, self.settings[key] + ) + else: + self.project[key] = provider.dynamic_metadata(key, self.settings[key], self) + self.project["dynamic"].remove(key) + + return self.project[key] + + def __iter__(self) -> Iterator[str]: + # Iterate over the keys of the static settings + yield from self.project + + # Iterate over the keys of the dynamic metadata providers + yield from self.providers + + def __len__(self) -> int: + return len(self.project) + len(self.providers) + + def __contains__(self, key: object) -> bool: + return key in self.project or key in self.providers + + +def process_dynamic_metadata( + project: Mapping[str, Any], + metadata: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + initial = {f: (p, c) for (f, p, c) in _load_dynamic_metadata(metadata)} + + settings = DynamicPyProject( + settings={f: c for f, (_, c) in initial.items()}, + project=dict(project), + providers={k: v for k, (v, _) in initial.items()}, + ) + + return dict(settings) diff --git a/src/scikit_build_core/builder/get_requires.py b/src/scikit_build_core/builder/get_requires.py index 492aa1ec7..aff5c2c66 100644 --- a/src/scikit_build_core/builder/get_requires.py +++ b/src/scikit_build_core/builder/get_requires.py @@ -19,8 +19,8 @@ get_ninja_programs, ) from ..resources import resources -from ..settings._load_provider import load_provider from ..settings.skbuild_read_settings import SettingsReader +from ._load_provider import load_provider if TYPE_CHECKING: from collections.abc import Generator, Mapping diff --git a/src/scikit_build_core/metadata/__init__.py b/src/scikit_build_core/metadata/__init__.py index bdec2fc8c..4ec51f456 100644 --- a/src/scikit_build_core/metadata/__init__.py +++ b/src/scikit_build_core/metadata/__init__.py @@ -1,3 +1,87 @@ from __future__ import annotations -__all__: list[str] = [] +import typing + +if typing.TYPE_CHECKING: + from collections.abc import Callable + + +__all__: list[str] = ["_ALL_FIELDS", "_process_dynamic_metadata"] + + +# Name is not dynamically settable, so not in this list +_STR_FIELDS = frozenset( + [ + "version", + "description", + "requires-python", + "license", + ] +) + +# Dynamic is not dynamically settable, so not in this list +_LIST_STR_FIELDS = frozenset( + [ + "classifiers", + "keywords", + "dependencies", + "license_files", + ] +) + +_DICT_STR_FIELDS = frozenset( + [ + "urls", + "authors", + "maintainers", + ] +) + +# "dynamic" and "name" can't be set or requested +_ALL_FIELDS = ( + _STR_FIELDS + | _LIST_STR_FIELDS + | _DICT_STR_FIELDS + | frozenset( + [ + "optional-dependencies", + "readme", + ] + ) +) + +T = typing.TypeVar("T", bound="str | list[str] | dict[str, str] | dict[str, list[str]]") + + +def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T: + """ + Helper function for processing the an action on the various possible metadata fields. + """ + + if field in _STR_FIELDS: + if not isinstance(result, str): + msg = f"Field {field!r} must be a string" + raise RuntimeError(msg) + return action(result) # type: ignore[return-value] + if field in _LIST_STR_FIELDS: + if not (isinstance(result, list) and all(isinstance(r, str) for r in result)): + msg = f"Field {field!r} must be a list of strings" + raise RuntimeError(msg) + return [action(r) for r in result] # type: ignore[return-value] + if field in _DICT_STR_FIELDS | {"readme"}: + if not isinstance(result, dict) or not all( + isinstance(v, str) for v in result.values() + ): + msg = f"Field {field!r} must be a dictionary of strings" + raise RuntimeError(msg) + return {k: action(v) for k, v in result.items()} # type: ignore[return-value] + if field == "optional-dependencies": + if not isinstance(result, dict) or not all( + isinstance(v, list) for v in result.values() + ): + msg = "Field 'optional-dependencies' must be a dictionary of lists" + raise RuntimeError(msg) + return {k: [action(r) for r in v] for k, v in result.items()} # type: ignore[return-value] + + msg = f"Unsupported field {field!r} for action" + raise RuntimeError(msg) diff --git a/src/scikit_build_core/metadata/fancy_pypi_readme.py b/src/scikit_build_core/metadata/fancy_pypi_readme.py index 1187f3a2b..f78f5ab08 100644 --- a/src/scikit_build_core/metadata/fancy_pypi_readme.py +++ b/src/scikit_build_core/metadata/fancy_pypi_readme.py @@ -1,10 +1,17 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING, Any from .._compat import tomllib -__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"] +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = [ + "dynamic_metadata", + "get_requires_for_dynamic_metadata", +] def __dir__() -> list[str]: @@ -13,7 +20,8 @@ def __dir__() -> list[str]: def dynamic_metadata( field: str, - settings: dict[str, list[str] | str] | None = None, + settings: dict[str, list[str] | str], + project: Mapping[str, Any], ) -> str | dict[str, str]: from hatch_fancy_pypi_readme._builder import build_text from hatch_fancy_pypi_readme._config import load_and_validate_config @@ -36,7 +44,9 @@ def dynamic_metadata( if hasattr(config, "substitutions"): try: # We don't have access to the version at this point - text = build_text(config.fragments, config.substitutions, "") + text = build_text( + config.fragments, config.substitutions, version=project["version"] + ) except TypeError: # Version 23.2.0 and before don't have a version field # pylint: disable-next=no-value-for-parameter diff --git a/src/scikit_build_core/metadata/regex.py b/src/scikit_build_core/metadata/regex.py index 5f1213a65..787406ab6 100644 --- a/src/scikit_build_core/metadata/regex.py +++ b/src/scikit_build_core/metadata/regex.py @@ -1,9 +1,12 @@ from __future__ import annotations +import functools import re from pathlib import Path from typing import TYPE_CHECKING, Any +from . import _process_dynamic_metadata + if TYPE_CHECKING: from collections.abc import Mapping @@ -17,14 +20,18 @@ def __dir__() -> list[str]: KEYS = {"input", "regex", "result", "remove"} +def _process(match: re.Match[str], remove: str, result: str) -> str: + retval = result.format(*match.groups(), **match.groupdict()) + if remove: + retval = re.sub(remove, "", retval) + return retval + + def dynamic_metadata( field: str, settings: Mapping[str, Any], ) -> str: # Input validation - if field not in {"version", "description", "requires-python"}: - msg = "Only string fields supported by this plugin" - raise RuntimeError(msg) if settings.keys() > KEYS: msg = f"Only {KEYS} settings allowed by this plugin" raise RuntimeError(msg) @@ -55,7 +62,6 @@ def dynamic_metadata( msg = f"Couldn't find {regex!r} in {input_filename}" raise RuntimeError(msg) - retval = result.format(*match.groups(), **match.groupdict()) - if remove: - retval = re.sub(remove, "", retval) - return retval + return _process_dynamic_metadata( + field, functools.partial(_process, match, remove), result + ) diff --git a/src/scikit_build_core/metadata/template.py b/src/scikit_build_core/metadata/template.py new file mode 100644 index 000000000..749d1b569 --- /dev/null +++ b/src/scikit_build_core/metadata/template.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +from . import _process_dynamic_metadata + +__all__ = ["dynamic_metadata"] + + +def __dir__() -> list[str]: + return __all__ + + +KEYS = {"result"} + + +def dynamic_metadata( + field: str, + settings: Mapping[str, str | list[str] | dict[str, str] | dict[str, list[str]]], + project: Mapping[str, Any], +) -> str | list[str] | dict[str, str] | dict[str, list[str]]: + if settings.keys() > KEYS: + msg = f"Only {KEYS} settings allowed by this plugin" + raise RuntimeError(msg) + + if "result" not in settings: + msg = "Must contain the 'result' setting with a template substitution" + raise RuntimeError(msg) + + result = settings["result"] + + return _process_dynamic_metadata( + field, + lambda r: r.format(project=project), + result, + ) diff --git a/src/scikit_build_core/settings/_load_provider.py b/src/scikit_build_core/settings/_load_provider.py deleted file mode 100644 index d992e4218..000000000 --- a/src/scikit_build_core/settings/_load_provider.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import importlib -import sys -from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol, Union - -if TYPE_CHECKING: - from collections.abc import Generator, Iterable, Mapping - -__all__ = ["load_dynamic_metadata", "load_provider"] - - -def __dir__() -> list[str]: - return __all__ - - -class DynamicMetadataProtocol(Protocol): - def dynamic_metadata( - self, fields: Iterable[str], settings: dict[str, Any] - ) -> dict[str, Any]: ... - - -class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol): - def get_requires_for_dynamic_metadata( - self, settings: dict[str, Any] - ) -> list[str]: ... - - -class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol): - def dynamic_wheel( - self, field: str, settings: Mapping[str, Any] | None = None - ) -> bool: ... - - -class DynamicMetadataRequirementsWheelProtocol( - DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol -): ... - - -DMProtocols = Union[ - DynamicMetadataProtocol, - DynamicMetadataRequirementsProtocol, - DynamicMetadataWheelProtocol, - DynamicMetadataRequirementsWheelProtocol, -] - - -def load_provider( - provider: str, - provider_path: str | None = None, -) -> DMProtocols: - if provider_path is None: - return importlib.import_module(provider) - - if not Path(provider_path).is_dir(): - msg = "provider-path must be an existing directory" - raise AssertionError(msg) - - try: - sys.path.insert(0, provider_path) - return importlib.import_module(provider) - finally: - sys.path.pop(0) - - -def load_dynamic_metadata( - metadata: Mapping[str, Mapping[str, str]], -) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]: - for field, orig_config in metadata.items(): - if "provider" in orig_config: - config = dict(orig_config) - provider = config.pop("provider") - provider_path = config.pop("provider-path", None) - yield field, load_provider(provider, provider_path), config - else: - yield field, None, dict(orig_config) diff --git a/tests/packages/dynamic_metadata/plugin_project.toml b/tests/packages/dynamic_metadata/plugin_project.toml index d460feba2..974acc4d6 100644 --- a/tests/packages/dynamic_metadata/plugin_project.toml +++ b/tests/packages/dynamic_metadata/plugin_project.toml @@ -4,12 +4,16 @@ build-backend = "scikit_build_core.build" [project] name = "fancy" -dynamic = ["readme", "version"] +dynamic = ["readme", "version", "optional-dependencies"] [tool.scikit-build.metadata] version.provider = "scikit_build_core.metadata.setuptools_scm" readme.provider = "scikit_build_core.metadata.fancy_pypi_readme" +[tool.scikit-build.metadata.optional-dependencies] +provider = "scikit_build_core.metadata.template" +result = {"dev" = ["{project[name]}=={project[version]}"]} + [tool.setuptools_scm] [tool.hatch.metadata.hooks.fancy-pypi-readme] @@ -19,4 +23,4 @@ content-type = "text/x-rst" text = "Fragment #1" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] -text = "Fragment #2" +text = "Fragment #2 -- $HFPR_VERSION" diff --git a/tests/test_dynamic_metadata.py b/tests/test_dynamic_metadata.py index 359970793..9038edc55 100644 --- a/tests/test_dynamic_metadata.py +++ b/tests/test_dynamic_metadata.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any import pytest +from packaging.requirements import Requirement from packaging.version import Version from scikit_build_core._compat import tomllib @@ -145,7 +146,7 @@ def test_plugin_metadata(): assert str(metadata.version) == "0.1.0" assert metadata.readme == pyproject_metadata.Readme( - "Fragment #1Fragment #2", None, "text/x-rst" + "Fragment #1Fragment #2 -- 0.1.0", None, "text/x-rst" ) assert set(GetRequires().dynamic_metadata()) == { @@ -153,9 +154,14 @@ def test_plugin_metadata(): "setuptools-scm", } + assert metadata.optional_dependencies == {"dev": [Requirement("fancy==0.1.0")]} + @pytest.mark.usefixtures("package_dynamic_metadata") def test_faulty_metadata(): + reason_msg = "install hatch-fancy-pypi-readme to test the dynamic metadata plugins" + pytest.importorskip("hatch_fancy_pypi_readme", reason=reason_msg) + with Path("faulty_project.toml").open("rb") as ft: pyproject = tomllib.load(ft) settings_reader = SettingsReader(pyproject, {}, state="metadata_wheel") @@ -274,8 +280,6 @@ def test_regex(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_regex_errors() -> None: with pytest.raises(RuntimeError): regex.dynamic_metadata("version", {}) - with pytest.raises(RuntimeError, match="Only string fields supported"): - regex.dynamic_metadata("author", {"input": "x", "regex": "x"}) def test_multipart_regex(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: