Skip to content

Commit b94da3a

Browse files
henryiiijcfraryamanjeendgarpre-commit-ci[bot]
authored
feat: first draft (#1)
* feat: first draft Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> * feat: add schema Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> * Apply suggestions from code review Co-authored-by: Jean-Christophe Fillion-Robin <jchris.fillionr@kitware.com> * fix: some changes from impl in scikit-build-core Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> * style: ignore a few pylint codes Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> * small-changes to draft (#8) * Draft changes (#9) * small-changes to draft * chore: WiP warning, readme * chore: admonition-syntax for warning (#10) * style: pre-commit fixes --------- Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> Co-authored-by: Jean-Christophe Fillion-Robin <jchris.fillionr@kitware.com> Co-authored-by: Aryaman Jeendgar <52452970+aryamanjeendgar@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 64ce19a commit b94da3a

File tree

13 files changed

+485
-17
lines changed

13 files changed

+485
-17
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ repos:
5757
files: src|tests
5858
args: []
5959
additional_dependencies:
60+
- hatch-fancy-pypi-readme
61+
- importlib_resources
6062
- pytest
63+
- tomli
6164

6265
- repo: https://github.com/codespell-project/codespell
6366
rev: "v2.2.6"

README.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,171 @@
1313
This repo is to support
1414
https://github.com/scikit-build/scikit-build-core/issues/230.
1515

16+
> [!WARNING]
17+
>
18+
> This plugin is still a WiP!
19+
20+
## For users
21+
22+
Every external plugin must specify a "provider", which is a module that provides
23+
the API listed in the next section.
24+
25+
```toml
26+
[tool.dynamic-metadata]
27+
<field-name>.provider = "<module>"
28+
```
29+
30+
There is an optional field: "provider-path", which specifies a local path to
31+
load a plugin from, allowing plugins to reside inside your own project.
32+
33+
All other fields are passed on to the plugin, allowing plugins to specify custom
34+
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.
37+
38+
### Example: regex
39+
40+
An example regex plugin is provided in this package. It is used like this:
41+
42+
```toml
43+
[build-system]
44+
requires = ["...", "dynamic-metadata"]
45+
build-backend = "..."
46+
47+
[project]
48+
dynamic = ["version"]
49+
50+
[tool.dynamic-metadata.version]
51+
provider = "dynamic_metadata.plugins.regex"
52+
input = "src/my_package/__init__.py"
53+
regex = '(?i)^(__version__|VERSION) *= *([\'"])v?(?P<value>.+?)\2'
54+
```
55+
56+
In this case, since the plugin lives inside `dynamic-metadata`, you have to
57+
include that in your requirements. Make sure the version is marked dynamic in
58+
your project table. And then you specify `version.provider`. The other options
59+
are defined by the plugin; this one takes a required `input` file and an
60+
optional `regex` (which defaults to the expression you see above). The regex
61+
optional `regex` (which defaults to the expression you see above). The regex
62+
needs to have a `"value"` named group (`?P<value>`), which it will set.
63+
64+
## For plugin authors
65+
66+
**You do not need to depend on dynamic-metadata to write a plugin.** This
67+
library provides testing and static typing helpers that are not needed at
68+
runtime.
69+
70+
Like PEP 517's hooks, `dynamic-metadata` defines a set of hooks that you can
71+
implement; one required hook and two optional hooks. The required hook is:
72+
73+
```python
74+
def dynamic_metadata(
75+
field: str,
76+
settings: dict[str, object] | None = None,
77+
) -> str | dict[str, str | None]:
78+
... # return the value of the metadata
79+
```
80+
81+
The backend will call this hook in the same directory as PEP 517's hooks.
82+
83+
There are two optional hooks.
84+
85+
A plugin can return METADATA 2.2 dynamic status:
86+
87+
```python
88+
def dynamic_wheel(field: str, settings: Mapping[str, Any] | None = None) -> bool:
89+
... # Return true if metadata can change from SDist to wheel (METADATA 2.2 feature)
90+
```
91+
92+
If this hook is not implemented, it will default to "false". Note that "version"
93+
must always return "false". This hook is called after the main hook, so you do
94+
not need to validate the input here.
95+
96+
A plugin can also decide at runtime if it needs extra dependencies:
97+
98+
```python
99+
def get_requires_for_dynamic_metadata(
100+
settings: Mapping[str, Any] | None = None,
101+
) -> list[str]:
102+
... # return list of packages to require
103+
```
104+
105+
This is mostly used to provide wrappers for existing non-compatible plugins and
106+
for plugins that require a CLI tool that has an optional compiled component.
107+
108+
### Example: regex
109+
110+
Here is the regex plugin example implementation:
111+
112+
```python
113+
def dynamic_metadata(
114+
field: str,
115+
settings: Mapping[str, Any],
116+
) -> str:
117+
# Input validation
118+
if field not in {"version", "description", "requires-python"}:
119+
raise RuntimeError("Only string feilds supported by this plugin")
120+
if settings > {"input", "regex"}:
121+
raise RuntimeError("Only 'input' and 'regex' settings allowed by this plugin")
122+
if "input" not in settings:
123+
raise RuntimeError("Must contain the 'input' setting to perform a regex on")
124+
if not all(isinstance(x, str) for x in settings.values()):
125+
raise RuntimeError("Must set 'input' and/or 'regex' to strings")
126+
127+
input = settings["input"]
128+
# If not explicitly specified in the `tool.dynamic-metadata.<field-name>` table,
129+
# the default regex provided below is used.
130+
regex = settings.get(
131+
"regex", r'(?i)^(__version__|VERSION) *= *([\'"])v?(?P<value>.+?)\2'
132+
)
133+
134+
with Path(input).open(encoding="utf-8") as f:
135+
match = re.search(regex, f.read())
136+
137+
if not match:
138+
raise RuntimeError(f"Couldn't find {regex!r} in {file}")
139+
140+
return match.groups("value")
141+
```
142+
143+
## For backend authors
144+
145+
**You do not need to depend on dynamic-metadata to support plugins.** This
146+
library provides some helper functions you can use if you want, but you can
147+
implement them yourself following the standard provided or vendor the helper
148+
file (which will be tested and supported).
149+
150+
You should collect the contents of `tool.dynamic-metadata` and load each,
151+
something like this:
152+
153+
```python
154+
def load_provider(
155+
provider: str,
156+
provider_path: str | None = None,
157+
) -> DynamicMetadataProtocol:
158+
if provider_path is None:
159+
return importlib.import_module(provider)
160+
161+
if not Path(provider_path).is_dir():
162+
msg = "provider-path must be an existing directory"
163+
raise AssertionError(msg)
164+
165+
try:
166+
sys.path.insert(0, provider_path)
167+
return importlib.import_module(provider)
168+
finally:
169+
sys.path.pop(0)
170+
171+
172+
for dynamic_metadata in settings.metadata.values():
173+
if "provider" in dynamic_metadata:
174+
config = dynamic_metadata.copy()
175+
provider = config.pop("provider")
176+
provider_path = config.pop("provider-path", None)
177+
module = load_provider(provider, provider_path)
178+
# Run hooks from module
179+
```
180+
16181
<!-- prettier-ignore-start -->
17182
[actions-badge]: https://github.com/scikit-build/dynamic-metadata/workflows/CI/badge.svg
18183
[actions-link]: https://github.com/scikit-build/dynamic-metadata/actions

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def pylint(session: nox.Session) -> None:
2727
"""
2828
# This needs to be installed into the package environment, and is slower
2929
# than a pre-commit check
30-
session.install(".", "pylint")
30+
session.install(".", "pylint", "setuptools_scm", "hatch-fancy-pypi-readme")
3131
session.run("pylint", "src", *session.posargs)
3232

3333

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ envs.default.dependencies = [
6363
"pytest-cov",
6464
]
6565

66+
[project.entry-points."validate_pyproject.tool_schema"]
67+
dynamic-metadata = "dynamic_metadata.schema:get_schema"
6668

6769
[tool.pytest.ini_options]
6870
minversion = "6.0"
@@ -102,6 +104,10 @@ module = "dynamic_metadata.*"
102104
disallow_untyped_defs = true
103105
disallow_incomplete_defs = true
104106

107+
[[tool.mypy.overrides]]
108+
module = "setuptools_scm"
109+
ignore_missing_imports = true
110+
105111

106112
[tool.ruff]
107113
select = [
@@ -159,4 +165,8 @@ messages_control.disable = [
159165
"line-too-long",
160166
"missing-module-docstring",
161167
"wrong-import-position",
168+
"missing-class-docstring",
169+
"missing-function-docstring",
170+
"import-outside-toplevel",
171+
"invalid-name",
162172
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
if sys.version_info < (3, 11):
6+
from tomli import load
7+
else:
8+
from tomllib import load
9+
10+
__all__ = ["load"]
11+
12+
13+
def __dir__() -> list[str]:
14+
return __all__

src/dynamic_metadata/_compat/typing.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,12 @@
88

99
import sys
1010

11-
if sys.version_info < (3, 10):
12-
from typing_extensions import TypeAlias
11+
if sys.version_info < (3, 8):
12+
from typing_extensions import Protocol
1313
else:
14-
from typing import TypeAlias
14+
from typing import Protocol
1515

16-
if sys.version_info < (3, 11):
17-
from typing_extensions import Self, assert_never
18-
else:
19-
from typing import Self, assert_never
20-
21-
__all__ = ["TypeAlias", "Self", "assert_never"]
16+
__all__ = ["Protocol"]
2217

2318

2419
def __dir__() -> list[str]:

src/dynamic_metadata/loader.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import sys
5+
from collections.abc import Generator, Iterable, Mapping
6+
from pathlib import Path
7+
from typing import Any, Union
8+
9+
from ._compat.typing import Protocol
10+
11+
__all__ = ["load_provider", "load_dynamic_metadata"]
12+
13+
14+
def __dir__() -> list[str]:
15+
return __all__
16+
17+
18+
class DynamicMetadataProtocol(Protocol):
19+
def dynamic_metadata(
20+
self, fields: Iterable[str], settings: dict[str, Any]
21+
) -> dict[str, Any]:
22+
...
23+
24+
25+
class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
26+
def get_requires_for_dynamic_metadata(self, settings: dict[str, Any]) -> list[str]:
27+
...
28+
29+
30+
class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
31+
def dynamic_wheel(
32+
self, field: str, settings: Mapping[str, Any] | None = None
33+
) -> bool:
34+
...
35+
36+
37+
class DynamicMetadataRequirementsWheelProtocol(
38+
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
39+
):
40+
...
41+
42+
43+
DMProtocols = Union[
44+
DynamicMetadataProtocol,
45+
DynamicMetadataRequirementsProtocol,
46+
DynamicMetadataWheelProtocol,
47+
DynamicMetadataRequirementsWheelProtocol,
48+
]
49+
50+
51+
def load_provider(
52+
provider: str,
53+
provider_path: str | None = None,
54+
) -> DMProtocols:
55+
if provider_path is None:
56+
return importlib.import_module(provider)
57+
58+
if not Path(provider_path).is_dir():
59+
msg = "provider-path must be an existing directory"
60+
raise AssertionError(msg)
61+
62+
try:
63+
sys.path.insert(0, provider_path)
64+
return importlib.import_module(provider)
65+
finally:
66+
sys.path.pop(0)
67+
68+
69+
def load_dynamic_metadata(
70+
metadata: Mapping[str, Mapping[str, str]]
71+
) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
72+
for field, orig_config in metadata.items():
73+
if "provider" in orig_config:
74+
config = dict(orig_config)
75+
provider = config.pop("provider")
76+
provider_path = config.pop("provider-path", None)
77+
yield field, load_provider(provider, provider_path), config
78+
else:
79+
yield field, None, dict(orig_config)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from .._compat import tomllib
6+
7+
__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"]
8+
9+
10+
def __dir__() -> list[str]:
11+
return __all__
12+
13+
14+
def dynamic_metadata(
15+
field: str,
16+
settings: dict[str, list[str] | str] | None = None,
17+
) -> dict[str, str | None]:
18+
from hatch_fancy_pypi_readme._builder import build_text
19+
from hatch_fancy_pypi_readme._config import load_and_validate_config
20+
21+
if field != "readme":
22+
msg = "Only the 'readme' field is supported"
23+
raise ValueError(msg)
24+
25+
if settings:
26+
msg = "No inline configuration is supported"
27+
raise ValueError(msg)
28+
29+
with Path("pyproject.toml").open("rb") as f:
30+
pyproject_dict = tomllib.load(f)
31+
32+
config = load_and_validate_config(
33+
pyproject_dict["tool"]["hatch"]["metadata"]["hooks"]["fancy-pypi-readme"]
34+
)
35+
36+
if hasattr(config, "substitutions"):
37+
try:
38+
# We don't have access to the version at this point
39+
text = build_text(config.fragments, config.substitutions, "")
40+
except TypeError:
41+
# Version 23.2.0 and before don't have a version field
42+
# pylint: disable-next=no-value-for-parameter
43+
text = build_text(config.fragments, config.substitutions)
44+
else:
45+
# Version 22.3 does not have fragment support
46+
# pylint: disable-next=no-value-for-parameter
47+
text = build_text(config.fragments) # type: ignore[call-arg]
48+
49+
return {
50+
"content-type": config.content_type,
51+
"text": text,
52+
}
53+
54+
55+
def get_requires_for_dynamic_metadata(
56+
_settings: dict[str, object] | None = None,
57+
) -> list[str]:
58+
return ["hatch-fancy-pypi-readme>=22.3"]

0 commit comments

Comments
 (0)