Skip to content

Commit 01083fb

Browse files
feat: needs (#30)
* feat: needs Signed-off-by: Henry Schreiner <[email protected]> * feat: auto needs Signed-off-by: Henry Schreiner <[email protected]> * tests: add tests for regex and template Signed-off-by: Henry Schreiner <[email protected]> * style: pre-commit fixes --------- 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 c306480 commit 01083fb

File tree

10 files changed

+346
-76
lines changed

10 files changed

+346
-76
lines changed

README.md

Lines changed: 15 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,24 @@ https://github.com/scikit-build/scikit-build-core/issues/230.
1515

1616
> [!WARNING]
1717
>
18-
> This plugin is still a WiP!
18+
> This is still a WiP! The design may still change.
1919
2020
## For users
2121

2222
Every external plugin must specify a "provider", which is a module that provides
2323
the API listed in the next section.
2424

2525
```toml
26-
[tool.dynamic-metadata]
27-
<field-name>.provider = "<module>"
26+
[tool.dynamic-metadata.<field-name>]
27+
provider = "<module>"
2828
```
2929

3030
There is an optional field: "provider-path", which specifies a local path to
3131
load a plugin from, allowing plugins to reside inside your own project.
3232

3333
All other fields are passed on to the plugin, allowing plugins to specify custom
3434
configuration per field. Plugins can, if desired, use their own `tool.*`
35-
sections as well; plugins only supporting one metadata field are more likely to
36-
do this.
35+
sections as well.
3736

3837
### Example: regex
3938

@@ -65,16 +64,16 @@ needs to have a `"value"` named group (`?P<value>`), which it will set.
6564

6665
**You do not need to depend on dynamic-metadata to write a plugin.** This
6766
library provides testing and static typing helpers that are not needed at
68-
runtime.
67+
runtime, along with a reference implementation that you can either use as an
68+
example, or use directly if you are fine to require the dependency.
6969

7070
Like PEP 517's hooks, `dynamic-metadata` defines a set of hooks that you can
7171
implement; one required hook and two optional hooks. The required hook is:
7272

7373
```python
7474
def dynamic_metadata(
75-
field: str,
76-
settings: dict[str, object] | None = None,
77-
) -> str | dict[str, str | None]: ... # return the value of the metadata
75+
field: str, settings: Mapping[str, Any], project: Mapping[str, Any]
76+
) -> str | dict[str, Any]: ... # return the value of the metadata
7877
```
7978

8079
The backend will call this hook in the same directory as PEP 517's hooks.
@@ -85,7 +84,8 @@ A plugin can return METADATA 2.2 dynamic status:
8584

8685
```python
8786
def dynamic_wheel(
88-
field: str, settings: Mapping[str, Any] | None = None
87+
field: str,
88+
settings: Mapping[str, Any],
8989
) -> (
9090
bool
9191
): ... # Return true if metadata can change from SDist to wheel (METADATA 2.2 feature)
@@ -99,7 +99,7 @@ A plugin can also decide at runtime if it needs extra dependencies:
9999

100100
```python
101101
def get_requires_for_dynamic_metadata(
102-
settings: Mapping[str, Any] | None = None,
102+
settings: Mapping[str, Any],
103103
) -> list[str]: ... # return list of packages to require
104104
```
105105

@@ -114,6 +114,7 @@ Here is the regex plugin example implementation:
114114
def dynamic_metadata(
115115
field: str,
116116
settings: Mapping[str, Any],
117+
_project: Mapping[str, Any],
117118
) -> str:
118119
# Input validation
119120
if field not in {"version", "description", "requires-python"}:
@@ -148,36 +149,9 @@ library provides some helper functions you can use if you want, but you can
148149
implement them yourself following the standard provided or vendor the helper
149150
file (which will be tested and supported).
150151

151-
You should collect the contents of `tool.dynamic-metadata` and load each,
152-
something like this:
153-
154-
```python
155-
def load_provider(
156-
provider: str,
157-
provider_path: str | None = None,
158-
) -> DynamicMetadataProtocol:
159-
if provider_path is None:
160-
return importlib.import_module(provider)
161-
162-
if not Path(provider_path).is_dir():
163-
msg = "provider-path must be an existing directory"
164-
raise AssertionError(msg)
165-
166-
try:
167-
sys.path.insert(0, provider_path)
168-
return importlib.import_module(provider)
169-
finally:
170-
sys.path.pop(0)
171-
172-
173-
for dynamic_metadata in settings.metadata.values():
174-
if "provider" in dynamic_metadata:
175-
config = dynamic_metadata.copy()
176-
provider = config.pop("provider")
177-
provider_path = config.pop("provider-path", None)
178-
module = load_provider(provider, provider_path)
179-
# Run hooks from module
180-
```
152+
You should collect the contents of `tool.dynamic-metadata` and load each one.
153+
You should respect requests for metadata from other plugins, as well; to see how
154+
to do that, refer to `src/dynamic-metadata/loader.py`.
181155

182156
<!-- prettier-ignore-start -->
183157
[actions-badge]: https://github.com/scikit-build/dynamic-metadata/workflows/CI/badge.svg

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
"Programming Language :: Python :: 3.10",
2626
"Programming Language :: Python :: 3.11",
2727
"Programming Language :: Python :: 3.12",
28+
"Programming Language :: Python :: 3.13",
2829
"Topic :: Scientific/Engineering",
2930
"Typing :: Typed",
3031
]
@@ -99,7 +100,7 @@ disallow_untyped_defs = true
99100
disallow_incomplete_defs = true
100101

101102
[[tool.mypy.overrides]]
102-
module = "setuptools_scm"
103+
module = ["setuptools_scm"]
103104
ignore_missing_imports = true
104105

105106

@@ -151,4 +152,5 @@ messages_control.disable = [
151152
"missing-function-docstring",
152153
"import-outside-toplevel",
153154
"invalid-name",
155+
"unused-argument", # Ruff
154156
]

src/dynamic_metadata/info.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
__all__ = ["ALL_FIELDS", "DICT_STR_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+
27+
DICT_STR_FIELDS = frozenset(
28+
[
29+
"urls",
30+
"authors",
31+
"maintainers",
32+
]
33+
)
34+
35+
36+
# "dynamic" and "name" can't be set or requested
37+
ALL_FIELDS = (
38+
STR_FIELDS
39+
| LIST_STR_FIELDS
40+
| DICT_STR_FIELDS
41+
| frozenset(
42+
[
43+
"optional-dependencies",
44+
"readme",
45+
]
46+
)
47+
)

src/dynamic_metadata/loader.py

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,55 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import importlib
45
import sys
5-
from collections.abc import Generator, Iterable, Mapping
6+
from collections.abc import Iterator, Mapping
67
from pathlib import Path
7-
from typing import Any, Protocol, Union
8+
from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable
89

9-
__all__ = ["load_dynamic_metadata", "load_provider"]
10+
from .info import ALL_FIELDS
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Generator, Iterable
14+
15+
StrMapping = Mapping[str, Any]
16+
else:
17+
StrMapping = Mapping
18+
19+
20+
__all__ = ["load_dynamic_metadata", "load_provider", "process_dynamic_metadata"]
1021

1122

1223
def __dir__() -> list[str]:
1324
return __all__
1425

1526

27+
@runtime_checkable
1628
class DynamicMetadataProtocol(Protocol):
1729
def dynamic_metadata(
18-
self, fields: Iterable[str], settings: dict[str, Any]
30+
self,
31+
fields: Iterable[str],
32+
settings: dict[str, Any],
33+
project: Mapping[str, Any],
1934
) -> dict[str, Any]: ...
2035

2136

37+
@runtime_checkable
2238
class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
2339
def get_requires_for_dynamic_metadata(
2440
self, settings: dict[str, Any]
2541
) -> list[str]: ...
2642

2743

44+
@runtime_checkable
2845
class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
29-
def dynamic_wheel(
30-
self, field: str, settings: Mapping[str, Any] | None = None
31-
) -> bool: ...
32-
33-
34-
class DynamicMetadataRequirementsWheelProtocol(
35-
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
36-
): ...
46+
def dynamic_wheel(self, field: str, settings: Mapping[str, Any]) -> bool: ...
3747

3848

3949
DMProtocols = Union[
4050
DynamicMetadataProtocol,
4151
DynamicMetadataRequirementsProtocol,
4252
DynamicMetadataWheelProtocol,
43-
DynamicMetadataRequirementsWheelProtocol,
4453
]
4554

4655

@@ -63,13 +72,79 @@ def load_provider(
6372

6473

6574
def load_dynamic_metadata(
66-
metadata: Mapping[str, Mapping[str, str]],
75+
metadata: Mapping[str, Mapping[str, Any]],
6776
) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
6877
for field, orig_config in metadata.items():
6978
if "provider" in orig_config:
79+
if field not in ALL_FIELDS:
80+
msg = f"{field} is not a valid field"
81+
raise KeyError(msg)
7082
config = dict(orig_config)
7183
provider = config.pop("provider")
7284
provider_path = config.pop("provider-path", None)
73-
yield field, load_provider(provider, provider_path), config
85+
loaded_provider = load_provider(provider, provider_path)
86+
yield field, loaded_provider, config
7487
else:
7588
yield field, None, dict(orig_config)
89+
90+
91+
@dataclasses.dataclass
92+
class DynamicPyProject(StrMapping):
93+
settings: dict[str, dict[str, Any]]
94+
project: dict[str, Any]
95+
providers: dict[str, DMProtocols]
96+
97+
def __getitem__(self, key: str) -> Any:
98+
# Try to get the settings from either the static file or dynamic metadata provider
99+
if key in self.project:
100+
return self.project[key]
101+
102+
# Check if we are in a loop, i.e. something else is already requesting
103+
# this key while trying to get another key
104+
if key not in self.providers:
105+
dep_type = "missing" if key in self.settings else "circular"
106+
msg = f"Encountered a {dep_type} dependency at {key}"
107+
raise ValueError(msg)
108+
109+
provider = self.providers.pop(key)
110+
self.project[key] = provider.dynamic_metadata(key, self.settings[key], self)
111+
self.project["dynamic"].remove(key)
112+
113+
return self.project[key]
114+
115+
def __iter__(self) -> Iterator[str]:
116+
# Iterate over the keys of the static settings
117+
yield from [*self.project.keys(), *self.providers.keys()]
118+
119+
def __len__(self) -> int:
120+
return len(self.project) + len(self.providers)
121+
122+
def __contains__(self, key: object) -> bool:
123+
return key in self.project or key in self.providers
124+
125+
126+
def process_dynamic_metadata(
127+
project: Mapping[str, Any],
128+
metadata: Mapping[str, Mapping[str, Any]],
129+
) -> dict[str, Any]:
130+
"""Process dynamic metadata.
131+
132+
This function loads the dynamic metadata providers and calls them to
133+
generate the dynamic metadata. It takes the original project table and
134+
returns a new project table. Empty providers are not supported; you
135+
need to implement this yourself for now if you support that.
136+
"""
137+
138+
initial = {f: (p, s) for (f, p, s) in load_dynamic_metadata(metadata)}
139+
for f, (p, _) in initial.items():
140+
if p is None:
141+
msg = f"{f} does not have a provider"
142+
raise KeyError(msg)
143+
144+
settings = DynamicPyProject(
145+
settings={f: s for f, (p, s) in initial.items() if p is not None},
146+
project=dict(project),
147+
providers={k: p for k, (p, _) in initial.items() if p is not None},
148+
)
149+
150+
return dict(settings)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
from collections.abc import Callable
5+
6+
from ..info import DICT_STR_FIELDS, LIST_STR_FIELDS, STR_FIELDS
7+
8+
T = typing.TypeVar("T", bound="str | list[str] | dict[str, str] | dict[str, list[str]]")
9+
10+
11+
def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T:
12+
"""
13+
Helper function for processing the an action on the various possible metadata fields.
14+
"""
15+
16+
if field in STR_FIELDS:
17+
if not isinstance(result, str):
18+
msg = f"Field {field!r} must be a string"
19+
raise RuntimeError(msg)
20+
return action(result) # type: ignore[return-value]
21+
if field in LIST_STR_FIELDS:
22+
if not (isinstance(result, list) and all(isinstance(r, str) for r in result)):
23+
msg = f"Field {field!r} must be a list of strings"
24+
raise RuntimeError(msg)
25+
return [action(r) for r in result] # type: ignore[return-value]
26+
if field in DICT_STR_FIELDS | {"readme"}:
27+
if not isinstance(result, dict) or not all(
28+
isinstance(v, str) for v in result.values()
29+
):
30+
msg = f"Field {field!r} must be a dictionary of strings"
31+
raise RuntimeError(msg)
32+
return {k: action(v) for k, v in result.items()} # type: ignore[return-value]
33+
if field == "optional-dependencies":
34+
if not isinstance(result, dict) or not all(
35+
isinstance(v, list) for v in result.values()
36+
):
37+
msg = "Field 'optional-dependencies' must be a dictionary of lists"
38+
raise RuntimeError(msg)
39+
return {k: [action(r) for r in v] for k, v in result.items()} # type: ignore[return-value]
40+
41+
msg = f"Unsupported field {field!r} for action"
42+
raise RuntimeError(msg)

0 commit comments

Comments
 (0)