Skip to content

Commit 56176fd

Browse files
committed
feat: add template dynamic-metadata plugin and dynamic_metadata_needs
Signed-off-by: Henry Schreiner <[email protected]>
1 parent 765af30 commit 56176fd

File tree

10 files changed

+140
-24
lines changed

10 files changed

+140
-24
lines changed

docs/configuration/dynamic.md

Lines changed: 26 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,31 @@ 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+
needs = ["version"]
128+
result = {"dev" = ["subpackage=={project.version}"]}
129+
```
130+
131+
You can specify `needs` to ensure other metadata is computed before this one.
132+
You can use `result` to specify the output. The result must match the type of
133+
the metadata field you are writing to. You can use `project` to access the current
134+
metadata values.
135+
136+
```{versionadded} 0.11.2
137+
138+
```
139+
115140
## `build-system.requires`: Scikit-build-core's `build.requires`
116141

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

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"pathspec >=0.10.1",
4040
"tomli >=1.2.2; python_version<'3.11'",
4141
"typing-extensions >=3.10.0; python_version<'3.9'",
42+
"graphlib_backport >=1; python_version<'3.9'",
4243
]
4344
# Note: cmake and possibly ninja are also required if those are not already
4445
# present (user controllable) - but a system version is fine.
@@ -182,7 +183,7 @@ disallow_untyped_defs = true
182183
disallow_incomplete_defs = true
183184

184185
[[tool.mypy.overrides]]
185-
module = ["numpy", "pathspec", "setuptools_scm", "hatch_fancy_pypi_readme", "virtualenv"]
186+
module = ["numpy", "pathspec", "setuptools_scm", "hatch_fancy_pypi_readme", "virtualenv", "graphlib_backport"]
186187
ignore_missing_imports = true
187188

188189

@@ -301,6 +302,7 @@ known-local-folder = ["pathutils"]
301302
"importlib.resources".msg = "Use scikit_build_core._compat.importlib.resources instead."
302303
"importlib_resources".msg = "Use scikit_build_core._compat.importlib.resources instead."
303304
"pyproject_metadata".msg = "Use scikit_build_core._vendor.pyproject_metadata instead."
305+
"graphlib".msg = "Use scikit_build_core._compat.graphlib instead."
304306

305307

306308
[tool.ruff.lint.per-file-ignores]

src/scikit_build_core/build/metadata.py

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

33
import copy
4+
import inspect
45
import sys
56
from typing import TYPE_CHECKING, Any
67

@@ -13,7 +14,7 @@
1314
extras_build_system,
1415
extras_top_level,
1516
)
16-
from ..settings._load_provider import load_dynamic_metadata
17+
from ..builder._load_provider import load_dynamic_metadata
1718

1819
if TYPE_CHECKING:
1920
from collections.abc import Mapping
@@ -34,7 +35,6 @@ def __dir__() -> list[str]:
3435
errors.ExceptionGroup = ExceptionGroup # type: ignore[misc, assignment]
3536

3637

37-
# If pyproject-metadata eventually supports updates, this can be simplified
3838
def get_standard_metadata(
3939
pyproject_dict: Mapping[str, Any],
4040
settings: ScikitBuildSettings,
@@ -49,7 +49,17 @@ def get_standard_metadata(
4949
if field not in pyproject_dict.get("project", {}).get("dynamic", []):
5050
msg = f"{field} is not in project.dynamic"
5151
raise KeyError(msg)
52-
new_pyproject_dict["project"][field] = provider.dynamic_metadata(field, config)
52+
53+
sig = inspect.signature(provider.dynamic_metadata)
54+
if len(sig.parameters) < 3:
55+
# Backcompat for dynamic_metadata without metadata dict
56+
new_pyproject_dict["project"][field] = provider.dynamic_metadata( # type: ignore[call-arg]
57+
field, config
58+
)
59+
else:
60+
new_pyproject_dict["project"][field] = provider.dynamic_metadata(
61+
field, config, new_pyproject_dict["project"].copy()
62+
)
5363
new_pyproject_dict["project"]["dynamic"].remove(field)
5464

5565
if settings.strict_config:

src/scikit_build_core/settings/_load_provider.py renamed to src/scikit_build_core/builder/_load_provider.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import importlib
44
import sys
55
from pathlib import Path
6-
from typing import TYPE_CHECKING, Any, Protocol, Union
6+
from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable
7+
8+
from .._compat.graphlib import TopologicalSorter
79

810
if TYPE_CHECKING:
911
from collections.abc import Generator, Iterable, Mapping
@@ -15,34 +17,41 @@ def __dir__() -> list[str]:
1517
return __all__
1618

1719

20+
@runtime_checkable
1821
class DynamicMetadataProtocol(Protocol):
1922
def dynamic_metadata(
20-
self, fields: Iterable[str], settings: dict[str, Any]
23+
self, fields: Iterable[str], settings: dict[str, Any], metadata: dict[str, Any]
2124
) -> dict[str, Any]: ...
2225

2326

27+
@runtime_checkable
2428
class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
2529
def get_requires_for_dynamic_metadata(
2630
self, settings: dict[str, Any]
2731
) -> list[str]: ...
2832

2933

34+
@runtime_checkable
3035
class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
3136
def dynamic_wheel(
3237
self, field: str, settings: Mapping[str, Any] | None = None
3338
) -> bool: ...
3439

3540

36-
class DynamicMetadataRequirementsWheelProtocol(
37-
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
38-
): ...
41+
@runtime_checkable
42+
class DynamicMetadataNeeds(DynamicMetadataProtocol, Protocol):
43+
def dynamic_metadata_needs(
44+
self,
45+
field: str,
46+
settings: Mapping[str, object] | None = None,
47+
) -> list[str]: ...
3948

4049

4150
DMProtocols = Union[
4251
DynamicMetadataProtocol,
4352
DynamicMetadataRequirementsProtocol,
4453
DynamicMetadataWheelProtocol,
45-
DynamicMetadataRequirementsWheelProtocol,
54+
DynamicMetadataNeeds,
4655
]
4756

4857

@@ -64,14 +73,31 @@ def load_provider(
6473
sys.path.pop(0)
6574

6675

67-
def load_dynamic_metadata(
76+
def _load_dynamic_metadata(
6877
metadata: Mapping[str, Mapping[str, str]],
69-
) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
78+
) -> Generator[
79+
tuple[str, DMProtocols | None, dict[str, str], frozenset[str]], None, None
80+
]:
7081
for field, orig_config in metadata.items():
7182
if "provider" in orig_config:
7283
config = dict(orig_config)
7384
provider = config.pop("provider")
7485
provider_path = config.pop("provider-path", None)
75-
yield field, load_provider(provider, provider_path), config
86+
loaded_provider = load_provider(provider, provider_path)
87+
needs = frozenset(
88+
loaded_provider.dynamic_metadata_needs(field, config)
89+
if isinstance(loaded_provider, DynamicMetadataNeeds)
90+
else []
91+
)
92+
yield field, loaded_provider, config, needs
7693
else:
77-
yield field, None, dict(orig_config)
94+
yield field, None, dict(orig_config), frozenset()
95+
96+
97+
def load_dynamic_metadata(
98+
metadata: Mapping[str, Mapping[str, str]],
99+
) -> list[tuple[str, DMProtocols | None, dict[str, str]]]:
100+
initial = {f: (p, c, n) for (f, p, c, n) in _load_dynamic_metadata(metadata)}
101+
sorter = TopologicalSorter({f: n for f, (_, _, n) in initial.items()})
102+
order = sorter.static_order()
103+
return [(f, *initial[f][:2]) for f in order]

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: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,31 @@
11
from __future__ import annotations
22

3-
__all__: list[str] = []
3+
__all__: list[str] = ["_DICT_LIST_FIELDS", "_LIST_STR_FIELDS", "_STR_FIELDS"]
4+
5+
6+
# Name is not dynamically settable, so not in this list
7+
_STR_FIELDS = frozenset(
8+
[
9+
"version",
10+
"description",
11+
"requires-python",
12+
"license",
13+
]
14+
)
15+
16+
# Dynamic is not dynamically settable, so not in this list
17+
_LIST_STR_FIELDS = frozenset(
18+
[
19+
"classifiers",
20+
"keywords",
21+
"dependencies",
22+
"license_files",
23+
]
24+
)
25+
26+
_DICT_LIST_FIELDS = frozenset(
27+
[
28+
"urls",
29+
"optional-dependencies",
30+
]
31+
)

src/scikit_build_core/metadata/fancy_pypi_readme.py

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

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

56
from .._compat import tomllib
67

7-
__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"]
8+
__all__ = [
9+
"dynamic_metadata",
10+
"dynamic_requires_needs",
11+
"get_requires_for_dynamic_metadata",
12+
]
813

914

1015
def __dir__() -> list[str]:
@@ -13,7 +18,8 @@ def __dir__() -> list[str]:
1318

1419
def dynamic_metadata(
1520
field: str,
16-
settings: dict[str, list[str] | str] | None = None,
21+
settings: dict[str, list[str] | str],
22+
metadata: dict[str, Any],
1723
) -> str | dict[str, str]:
1824
from hatch_fancy_pypi_readme._builder import build_text
1925
from hatch_fancy_pypi_readme._config import load_and_validate_config
@@ -36,7 +42,9 @@ def dynamic_metadata(
3642
if hasattr(config, "substitutions"):
3743
try:
3844
# We don't have access to the version at this point
39-
text = build_text(config.fragments, config.substitutions, "")
45+
text = build_text(
46+
config.fragments, config.substitutions, version=metadata["version"]
47+
)
4048
except TypeError:
4149
# Version 23.2.0 and before don't have a version field
4250
# pylint: disable-next=no-value-for-parameter
@@ -56,3 +64,10 @@ def get_requires_for_dynamic_metadata(
5664
_settings: dict[str, object] | None = None,
5765
) -> list[str]:
5866
return ["hatch-fancy-pypi-readme>=22.3"]
67+
68+
69+
def dynamic_requires_needs(
70+
_field: str,
71+
_settings: dict[str, object],
72+
) -> list[str]:
73+
return ["version"]

src/scikit_build_core/metadata/regex.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from pathlib import Path
55
from typing import TYPE_CHECKING, Any
66

7+
from . import _STR_FIELDS
8+
79
if TYPE_CHECKING:
810
from collections.abc import Mapping
911

@@ -22,7 +24,7 @@ def dynamic_metadata(
2224
settings: Mapping[str, Any],
2325
) -> str:
2426
# Input validation
25-
if field not in {"version", "description", "requires-python"}:
27+
if field not in _STR_FIELDS:
2628
msg = "Only string fields supported by this plugin"
2729
raise RuntimeError(msg)
2830
if settings.keys() > KEYS:

tests/packages/dynamic_metadata/plugin_project.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ build-backend = "scikit_build_core.build"
44

55
[project]
66
name = "fancy"
7-
dynamic = ["readme", "version"]
7+
dynamic = ["readme", "version", "optional-dependencies"]
88

99
[tool.scikit-build.metadata]
1010
version.provider = "scikit_build_core.metadata.setuptools_scm"
1111
readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
1212

13+
[tool.scikit-build.metadata.optional-dependencies]
14+
provider = "scikit_build_core.metadata.template"
15+
needs = ["version", "name"]
16+
result = {"dev" = ["{name}=={version}"]}
17+
1318
[tool.setuptools_scm]
1419

1520
[tool.hatch.metadata.hooks.fancy-pypi-readme]
@@ -19,4 +24,4 @@ content-type = "text/x-rst"
1924
text = "Fragment #1"
2025

2126
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
22-
text = "Fragment #2"
27+
text = "Fragment #2 -- $HFPR_VERSION"

tests/test_dynamic_metadata.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import TYPE_CHECKING, Any
1111

1212
import pytest
13+
from packaging.requirements import Requirement
1314
from packaging.version import Version
1415

1516
from scikit_build_core._compat import tomllib
@@ -145,14 +146,16 @@ def test_plugin_metadata():
145146

146147
assert str(metadata.version) == "0.1.0"
147148
assert metadata.readme == pyproject_metadata.Readme(
148-
"Fragment #1Fragment #2", None, "text/x-rst"
149+
"Fragment #1Fragment #2 -- 0.1.0", None, "text/x-rst"
149150
)
150151

151152
assert set(GetRequires().dynamic_metadata()) == {
152153
"hatch-fancy-pypi-readme>=22.3",
153154
"setuptools-scm",
154155
}
155156

157+
assert metadata.optional_dependencies == {"dev": [Requirement("fancy==0.1.0")]}
158+
156159

157160
@pytest.mark.usefixtures("package_dynamic_metadata")
158161
def test_faulty_metadata():

0 commit comments

Comments
 (0)