Skip to content

Commit 000dd94

Browse files
committed
feat: needs
Signed-off-by: Henry Schreiner <[email protected]>
1 parent ad52da6 commit 000dd94

File tree

9 files changed

+244
-31
lines changed

9 files changed

+244
-31
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,16 @@ 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

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ 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
]
3132
dynamic = ["version"]
3233
dependencies = [
3334
"typing_extensions >=4.6; python_version<'3.11'",
35+
"graphlib_backport >=1; python_version<'3.9'",
3436
]
3537

3638
[project.optional-dependencies]
@@ -106,7 +108,7 @@ disallow_untyped_defs = true
106108
disallow_incomplete_defs = true
107109

108110
[[tool.mypy.overrides]]
109-
module = "setuptools_scm"
111+
module = ["setuptools_scm", "graphlib"]
110112
ignore_missing_imports = true
111113

112114

@@ -161,4 +163,5 @@ messages_control.disable = [
161163
"missing-function-docstring",
162164
"import-outside-toplevel",
163165
"invalid-name",
166+
"unused-argument", # Ruff
164167
]

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: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
import sys
55
from collections.abc import Generator, Iterable, Mapping
66
from pathlib import Path
7-
from typing import Any, Protocol, Union
7+
from typing import Any, Protocol, Union, runtime_checkable
8+
9+
from graphlib import TopologicalSorter
10+
11+
from .info import ALL_FIELDS
812

913
__all__ = ["load_dynamic_metadata", "load_provider"]
1014

@@ -13,34 +17,41 @@ def __dir__() -> list[str]:
1317
return __all__
1418

1519

20+
@runtime_checkable
1621
class DynamicMetadataProtocol(Protocol):
1722
def dynamic_metadata(
18-
self, fields: Iterable[str], settings: dict[str, Any]
23+
self, fields: Iterable[str], settings: dict[str, Any], metadata: dict[str, Any]
1924
) -> dict[str, Any]: ...
2025

2126

27+
@runtime_checkable
2228
class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
2329
def get_requires_for_dynamic_metadata(
2430
self, settings: dict[str, Any]
2531
) -> list[str]: ...
2632

2733

34+
@runtime_checkable
2835
class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
2936
def dynamic_wheel(
3037
self, field: str, settings: Mapping[str, Any] | None = None
3138
) -> bool: ...
3239

3340

34-
class DynamicMetadataRequirementsWheelProtocol(
35-
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
36-
): ...
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]: ...
3748

3849

3950
DMProtocols = Union[
4051
DynamicMetadataProtocol,
4152
DynamicMetadataRequirementsProtocol,
4253
DynamicMetadataWheelProtocol,
43-
DynamicMetadataRequirementsWheelProtocol,
54+
DynamicMetadataNeeds,
4455
]
4556

4657

@@ -62,14 +73,41 @@ def load_provider(
6273
sys.path.pop(0)
6374

6475

65-
def load_dynamic_metadata(
76+
def _load_dynamic_metadata(
6677
metadata: Mapping[str, Mapping[str, str]],
67-
) -> 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+
]:
6881
for field, orig_config in metadata.items():
6982
if "provider" in orig_config:
83+
if field not in ALL_FIELDS:
84+
msg = f"{field} is not a valid field"
85+
raise KeyError(msg)
7086
config = dict(orig_config)
7187
provider = config.pop("provider")
7288
provider_path = config.pop("provider-path", None)
73-
yield field, load_provider(provider, provider_path), config
89+
loaded_provider = load_provider(provider, provider_path)
90+
needs = frozenset(
91+
loaded_provider.dynamic_metadata_needs(field, config)
92+
if isinstance(loaded_provider, DynamicMetadataNeeds)
93+
else []
94+
)
95+
if needs > ALL_FIELDS:
96+
msg = f"Invalid dyanmic_metada_needs: {needs - ALL_FIELDS}"
97+
raise KeyError(msg)
98+
yield field, loaded_provider, config, needs
7499
else:
75-
yield field, None, dict(orig_config)
100+
yield field, None, dict(orig_config), frozenset()
101+
102+
103+
def load_dynamic_metadata(
104+
metadata: Mapping[str, Mapping[str, str]],
105+
) -> list[tuple[str, DMProtocols | None, dict[str, str]]]:
106+
initial = {f: (p, c, n) for (f, p, c, n) in _load_dynamic_metadata(metadata)}
107+
108+
dynamic_fields = initial.keys()
109+
sorter = TopologicalSorter(
110+
{f: n & dynamic_fields for f, (_, _, n) in initial.items()}
111+
)
112+
order = sorter.static_order()
113+
return [(f, *initial[f][:2]) for f in order]
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:
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)

src/dynamic_metadata/plugins/fancy_pypi_readme.py

Lines changed: 18 additions & 4 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, Mapping
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: Mapping[str, Any],
1723
) -> dict[str, str | None]:
1824
from hatch_fancy_pypi_readme._builder import build_text
1925
from hatch_fancy_pypi_readme._config import load_and_validate_config
@@ -35,8 +41,9 @@ def dynamic_metadata(
3541

3642
if hasattr(config, "substitutions"):
3743
try:
38-
# We don't have access to the version at this point
39-
text = build_text(config.fragments, config.substitutions, "")
44+
text = build_text(
45+
config.fragments, config.substitutions, metadata["version"]
46+
)
4047
except TypeError:
4148
# Version 23.2.0 and before don't have a version field
4249
# pylint: disable-next=no-value-for-parameter
@@ -56,3 +63,10 @@ def get_requires_for_dynamic_metadata(
5663
_settings: dict[str, object] | None = None,
5764
) -> list[str]:
5865
return ["hatch-fancy-pypi-readme>=22.3"]
66+
67+
68+
def dynamic_requires_needs(
69+
_field: str,
70+
_settings: dict[str, object],
71+
) -> list[str]:
72+
return ["version"]
Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from __future__ import annotations
22

3+
import functools
34
import re
4-
from collections.abc import Mapping
55
from pathlib import Path
6-
from typing import Any
6+
from typing import TYPE_CHECKING, Any
7+
8+
from . import _process_dynamic_metadata
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Mapping
712

813
__all__ = ["dynamic_metadata"]
914

@@ -12,28 +17,44 @@ def __dir__() -> list[str]:
1217
return __all__
1318

1419

20+
KEYS = {"input", "regex", "result", "remove"}
21+
22+
23+
def _process(match: re.Match[str], remove: str, result: str) -> str:
24+
retval = result.format(*match.groups(), **match.groupdict())
25+
if remove:
26+
retval = re.sub(remove, "", retval)
27+
return retval
28+
29+
1530
def dynamic_metadata(
1631
field: str,
1732
settings: Mapping[str, Any],
33+
_metadata: Mapping[str, Any],
1834
) -> str:
1935
# Input validation
20-
if field not in {"version", "description", "requires-python"}:
21-
msg = "Only string fields supported by this plugin"
22-
raise RuntimeError(msg)
23-
if settings.keys() > {"input", "regex"}:
24-
msg = "Only 'input' and 'regex' settings allowed by this plugin"
36+
if settings.keys() > KEYS:
37+
msg = f"Only {KEYS} settings allowed by this plugin"
2538
raise RuntimeError(msg)
2639
if "input" not in settings:
2740
msg = "Must contain the 'input' setting to perform a regex on"
2841
raise RuntimeError(msg)
29-
if not all(isinstance(x, str) for x in settings.values()):
30-
msg = "Must set 'input' and/or 'regex' to strings"
42+
if field != "version" and "regex" not in settings:
43+
msg = "Must contain the 'regex' setting if not getting version"
3144
raise RuntimeError(msg)
45+
for key in KEYS:
46+
if key in settings and not isinstance(settings[key], str):
47+
msg = f"Setting {key!r} must be a string"
48+
raise RuntimeError(msg)
3249

3350
input_filename = settings["input"]
3451
regex = settings.get(
35-
"regex", r'(?i)^(__version__|VERSION) *= *([\'"])v?(?P<value>.+?)\2'
52+
"regex",
53+
r'(?i)^(__version__|VERSION)(?: ?\: ?str)? *= *([\'"])v?(?P<value>.+?)\2',
3654
)
55+
result = settings.get("result", "{value}")
56+
assert isinstance(result, str)
57+
remove = settings.get("remove", "")
3758

3859
with Path(input_filename).open(encoding="utf-8") as f:
3960
match = re.search(regex, f.read(), re.MULTILINE)
@@ -42,4 +63,6 @@ def dynamic_metadata(
4263
msg = f"Couldn't find {regex!r} in {input_filename}"
4364
raise RuntimeError(msg)
4465

45-
return match.group("value")
66+
return _process_dynamic_metadata(
67+
field, functools.partial(_process, match, remove), result
68+
)

src/dynamic_metadata/plugins/setuptools_scm.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ def __dir__() -> list[str]:
99

1010
def dynamic_metadata(
1111
field: str,
12-
settings: dict[str, object] | None = None,
12+
settings: dict[str, object],
13+
_metadata: dict[str, object],
1314
) -> str:
1415
# this is a classic implementation, waiting for the release of
1516
# vcs-versioning and an improved public interface

0 commit comments

Comments
 (0)