Skip to content

Commit a8d15c5

Browse files
feat: add template dynamic-metadata plugin and support requesting other fields (#1047)
Implementation of scikit-build/dynamic-metadata#21. Mentioned in https://discuss.python.org/t/partially-dynamic-project-metadata-proposal-pre-pep/88608. ~~TODO: support specifying a non-dynamic field. It should not be an error if it exists already!~~ --------- Signed-off-by: Henry Schreiner <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a7b883a commit a8d15c5

File tree

12 files changed

+351
-107
lines changed

12 files changed

+351
-107
lines changed

docs/api/scikit_build_core.metadata.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,11 @@ scikit\_build\_core.metadata.setuptools\_scm module
3232
:members:
3333
:show-inheritance:
3434
:undoc-members:
35+
36+
scikit\_build\_core.metadata.template module
37+
--------------------------------------------
38+
39+
.. automodule:: scikit_build_core.metadata.template
40+
:members:
41+
:show-inheritance:
42+
:undoc-members:

docs/configuration/dynamic.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ dynamic_version()
5252
project(MyPackage VERSION ${PROJECT_VERSION})
5353
```
5454

55-
## `version`: Regex
55+
## Regex
5656

5757
If you want to pull a string-valued expression (usually version) from an
5858
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"
112112
# tool.hatch.metadata.hooks.fancy-pypi-readme options here
113113
```
114114

115+
```{versionchanged} 0.11.2
116+
117+
The version number feature now works.
118+
```
119+
120+
## Template
121+
122+
You can access other metadata fields and produce templated outputs.
123+
124+
```toml
125+
[tool.scikit-build.metadata.optional-dependencies]
126+
provider = "scikit_build_core.metadata.template"
127+
result = {"dev" = ["{project[name]}=={project[version]}"]}
128+
```
129+
130+
You can use `project` to access the current metadata values. You can reference
131+
other dynamic metadata fields, and they will be computed before this one. You
132+
can use `result` to specify the output. The result must match the type of the
133+
metadata field you are writing to.
134+
135+
```{versionadded} 0.11.2
136+
137+
```
138+
115139
## `build-system.requires`: Scikit-build-core's `build.requires`
116140

117141
If you need to inject and manipulate additional `build-system.requires`, you can

src/scikit_build_core/build/metadata.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
extras_build_system,
1414
extras_top_level,
1515
)
16-
from ..settings._load_provider import load_dynamic_metadata
16+
from ..builder._load_provider import process_dynamic_metadata
1717

1818
if TYPE_CHECKING:
1919
from collections.abc import Mapping
@@ -34,23 +34,16 @@ def __dir__() -> list[str]:
3434
errors.ExceptionGroup = ExceptionGroup # type: ignore[misc, assignment]
3535

3636

37-
# If pyproject-metadata eventually supports updates, this can be simplified
3837
def get_standard_metadata(
3938
pyproject_dict: Mapping[str, Any],
4039
settings: ScikitBuildSettings,
4140
) -> StandardMetadata:
42-
new_pyproject_dict = copy.deepcopy(pyproject_dict)
41+
new_pyproject_dict = copy.deepcopy(dict(pyproject_dict))
4342

4443
# Handle any dynamic metadata
45-
for field, provider, config in load_dynamic_metadata(settings.metadata):
46-
if provider is None:
47-
msg = f"{field} is missing provider"
48-
raise KeyError(msg)
49-
if field not in pyproject_dict.get("project", {}).get("dynamic", []):
50-
msg = f"{field} is not in project.dynamic"
51-
raise KeyError(msg)
52-
new_pyproject_dict["project"][field] = provider.dynamic_metadata(field, config)
53-
new_pyproject_dict["project"]["dynamic"].remove(field)
44+
new_pyproject_dict["project"] = process_dynamic_metadata(
45+
new_pyproject_dict["project"], settings.metadata
46+
)
5447

5548
if settings.strict_config:
5649
extra_keys_top = extras_top_level(new_pyproject_dict)
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import importlib
5+
import inspect
6+
import sys
7+
from collections.abc import Iterator, Mapping
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Generator, Iterable
13+
14+
StrMapping = Mapping[str, Any]
15+
else:
16+
StrMapping = Mapping
17+
18+
from ..metadata import _ALL_FIELDS
19+
20+
__all__ = ["load_provider", "process_dynamic_metadata"]
21+
22+
23+
def __dir__() -> list[str]:
24+
return __all__
25+
26+
27+
@runtime_checkable
28+
class DynamicMetadataProtocol(Protocol):
29+
def dynamic_metadata(
30+
self,
31+
fields: Iterable[str],
32+
settings: dict[str, Any],
33+
project: Mapping[str, Any],
34+
) -> dict[str, Any]: ...
35+
36+
37+
@runtime_checkable
38+
class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
39+
def get_requires_for_dynamic_metadata(
40+
self, settings: dict[str, Any]
41+
) -> list[str]: ...
42+
43+
44+
@runtime_checkable
45+
class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
46+
def dynamic_wheel(self, field: str, settings: Mapping[str, Any]) -> bool: ...
47+
48+
49+
DMProtocols = Union[
50+
DynamicMetadataProtocol,
51+
DynamicMetadataRequirementsProtocol,
52+
DynamicMetadataWheelProtocol,
53+
]
54+
55+
56+
def load_provider(
57+
provider: str,
58+
provider_path: str | None = None,
59+
) -> DMProtocols:
60+
if provider_path is None:
61+
return importlib.import_module(provider)
62+
63+
if not Path(provider_path).is_dir():
64+
msg = "provider-path must be an existing directory"
65+
raise AssertionError(msg)
66+
67+
try:
68+
sys.path.insert(0, provider_path)
69+
return importlib.import_module(provider)
70+
finally:
71+
sys.path.pop(0)
72+
73+
74+
def _load_dynamic_metadata(
75+
metadata: Mapping[str, Mapping[str, str]],
76+
) -> Generator[tuple[str, DMProtocols, dict[str, Any]], None, None]:
77+
for field, orig_config in metadata.items():
78+
if "provider" not in orig_config:
79+
msg = "Missing provider in dynamic metadata"
80+
raise KeyError(msg)
81+
82+
if field not in _ALL_FIELDS:
83+
msg = f"{field} is not a valid field"
84+
raise KeyError(msg)
85+
config = dict(orig_config)
86+
provider = config.pop("provider")
87+
provider_path = config.pop("provider-path", None)
88+
loaded_provider = load_provider(provider, provider_path)
89+
yield field, loaded_provider, config
90+
91+
92+
@dataclasses.dataclass
93+
class DynamicPyProject(StrMapping):
94+
settings: dict[str, dict[str, Any]]
95+
project: dict[str, Any]
96+
providers: dict[str, DMProtocols]
97+
98+
def __getitem__(self, key: str) -> Any:
99+
# Try to get the settings from either the static file or dynamic metadata provider
100+
if key in self.project:
101+
return self.project[key]
102+
103+
# Check if we are in a loop, i.e. something else is already requesting
104+
# this key while trying to get another key
105+
if key not in self.providers:
106+
dep_type = "missing" if key in self.settings else "circular"
107+
msg = f"Encountered a {dep_type} dependency at {key}"
108+
raise ValueError(msg)
109+
110+
provider = self.providers.pop(key)
111+
sig = inspect.signature(provider.dynamic_metadata)
112+
if len(sig.parameters) < 3:
113+
# Backcompat for dynamic_metadata without metadata dict
114+
self.project[key] = provider.dynamic_metadata( # type: ignore[call-arg]
115+
key, self.settings[key]
116+
)
117+
else:
118+
self.project[key] = provider.dynamic_metadata(key, self.settings[key], self)
119+
self.project["dynamic"].remove(key)
120+
121+
return self.project[key]
122+
123+
def __iter__(self) -> Iterator[str]:
124+
# Iterate over the keys of the static settings
125+
yield from self.project
126+
127+
# Iterate over the keys of the dynamic metadata providers
128+
yield from self.providers
129+
130+
def __len__(self) -> int:
131+
return len(self.project) + len(self.providers)
132+
133+
def __contains__(self, key: object) -> bool:
134+
return key in self.project or key in self.providers
135+
136+
137+
def process_dynamic_metadata(
138+
project: Mapping[str, Any],
139+
metadata: Mapping[str, Mapping[str, Any]],
140+
) -> dict[str, Any]:
141+
initial = {f: (p, c) for (f, p, c) in _load_dynamic_metadata(metadata)}
142+
143+
settings = DynamicPyProject(
144+
settings={f: c for f, (_, c) in initial.items()},
145+
project=dict(project),
146+
providers={k: v for k, (v, _) in initial.items()},
147+
)
148+
149+
return dict(settings)

src/scikit_build_core/builder/get_requires.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
get_ninja_programs,
2020
)
2121
from ..resources import resources
22-
from ..settings._load_provider import load_provider
2322
from ..settings.skbuild_read_settings import SettingsReader
23+
from ._load_provider import load_provider
2424

2525
if TYPE_CHECKING:
2626
from collections.abc import Generator, Mapping
Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,87 @@
11
from __future__ import annotations
22

3-
__all__: list[str] = []
3+
import typing
4+
5+
if typing.TYPE_CHECKING:
6+
from collections.abc import Callable
7+
8+
9+
__all__: list[str] = ["_ALL_FIELDS", "_process_dynamic_metadata"]
10+
11+
12+
# Name is not dynamically settable, so not in this list
13+
_STR_FIELDS = frozenset(
14+
[
15+
"version",
16+
"description",
17+
"requires-python",
18+
"license",
19+
]
20+
)
21+
22+
# Dynamic is not dynamically settable, so not in this list
23+
_LIST_STR_FIELDS = frozenset(
24+
[
25+
"classifiers",
26+
"keywords",
27+
"dependencies",
28+
"license_files",
29+
]
30+
)
31+
32+
_DICT_STR_FIELDS = frozenset(
33+
[
34+
"urls",
35+
"authors",
36+
"maintainers",
37+
]
38+
)
39+
40+
# "dynamic" and "name" can't be set or requested
41+
_ALL_FIELDS = (
42+
_STR_FIELDS
43+
| _LIST_STR_FIELDS
44+
| _DICT_STR_FIELDS
45+
| frozenset(
46+
[
47+
"optional-dependencies",
48+
"readme",
49+
]
50+
)
51+
)
52+
53+
T = typing.TypeVar("T", bound="str | list[str] | dict[str, str] | dict[str, list[str]]")
54+
55+
56+
def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T:
57+
"""
58+
Helper function for processing the an action on the various possible metadata fields.
59+
"""
60+
61+
if field in _STR_FIELDS:
62+
if not isinstance(result, str):
63+
msg = f"Field {field!r} must be a string"
64+
raise RuntimeError(msg)
65+
return action(result) # type: ignore[return-value]
66+
if field in _LIST_STR_FIELDS:
67+
if not (isinstance(result, list) and all(isinstance(r, str) for r in result)):
68+
msg = f"Field {field!r} must be a list of strings"
69+
raise RuntimeError(msg)
70+
return [action(r) for r in result] # type: ignore[return-value]
71+
if field in _DICT_STR_FIELDS | {"readme"}:
72+
if not isinstance(result, dict) or not all(
73+
isinstance(v, str) for v in result.values()
74+
):
75+
msg = f"Field {field!r} must be a dictionary of strings"
76+
raise RuntimeError(msg)
77+
return {k: action(v) for k, v in result.items()} # type: ignore[return-value]
78+
if field == "optional-dependencies":
79+
if not isinstance(result, dict) or not all(
80+
isinstance(v, list) for v in result.values()
81+
):
82+
msg = "Field 'optional-dependencies' must be a dictionary of lists"
83+
raise RuntimeError(msg)
84+
return {k: [action(r) for r in v] for k, v in result.items()} # type: ignore[return-value]
85+
86+
msg = f"Unsupported field {field!r} for action"
87+
raise RuntimeError(msg)

src/scikit_build_core/metadata/fancy_pypi_readme.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4+
from typing import TYPE_CHECKING, Any
45

56
from .._compat import tomllib
67

7-
__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"]
8+
if TYPE_CHECKING:
9+
from collections.abc import Mapping
10+
11+
__all__ = [
12+
"dynamic_metadata",
13+
"get_requires_for_dynamic_metadata",
14+
]
815

916

1017
def __dir__() -> list[str]:
@@ -13,7 +20,8 @@ def __dir__() -> list[str]:
1320

1421
def dynamic_metadata(
1522
field: str,
16-
settings: dict[str, list[str] | str] | None = None,
23+
settings: dict[str, list[str] | str],
24+
project: Mapping[str, Any],
1725
) -> str | dict[str, str]:
1826
from hatch_fancy_pypi_readme._builder import build_text
1927
from hatch_fancy_pypi_readme._config import load_and_validate_config
@@ -36,7 +44,9 @@ def dynamic_metadata(
3644
if hasattr(config, "substitutions"):
3745
try:
3846
# We don't have access to the version at this point
39-
text = build_text(config.fragments, config.substitutions, "")
47+
text = build_text(
48+
config.fragments, config.substitutions, version=project["version"]
49+
)
4050
except TypeError:
4151
# Version 23.2.0 and before don't have a version field
4252
# pylint: disable-next=no-value-for-parameter

0 commit comments

Comments
 (0)