Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ merged per field by `_merge_metadata` — lists append, tables add keys (PEP 808
add-only), and a single-value field is replaced if a later entry targets it (a
`produced` set distinguishes a prior entry's result from a static value, which
for a scalar is the rejected static+dynamic case). Each resolved field is
removed from `dynamic`.
removed from `dynamic`. The loader also provides the two backend collection
loops for the optional hooks: `get_requires_for_dynamic_metadata(entries)`
(concatenates in entry order) and `dynamic_wheel_fields(entries)` (the METADATA
2.2 set for SDist `PKG-INFO`; a field is dynamic if _any_ provider says so,
unknown fields are rejected, `version` may never be dynamic). Both load
providers fresh, so `dynamic_wheel` is stateless by design.

### Shared value-shaping helper — `plugins/__init__.py`

Expand Down
50 changes: 20 additions & 30 deletions docs/backend_authors.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,18 @@ enforces this for you.

## Collecting build requirements

In your `get_requires_for_build_*` hooks, load each provider and union in
anything it asks for. This is how a provider that wraps an external tool gets
its dependency installed without the user listing it.
In your `get_requires_for_build_*` hooks, add anything the providers ask for.
This is how a provider that wraps an external tool gets its dependency installed
without the user listing it.

```python
from dynamic_metadata.loader import load_dynamic_metadata
from dynamic_metadata.protocols import DynamicMetadataRequirementsProtocol
from dynamic_metadata.loader import get_requires_for_dynamic_metadata


def collect_requires(entries):
requires = []
for provider, settings in load_dynamic_metadata(entries):
if isinstance(provider, DynamicMetadataRequirementsProtocol):
requires += provider.get_requires_for_dynamic_metadata(settings)
return requires
requires += get_requires_for_dynamic_metadata(entries)
```

`load_dynamic_metadata` is a thin generator that loads each entry's provider and
hands back the leftover keys as `settings` (it consumes only `provider`).
Requirements are collected in entry order; a provider without the optional hook
contributes nothing.

## Resolving the metadata

Expand All @@ -99,27 +92,24 @@ you only need them if you are replacing this call.

## METADATA 2.2 dynamic status

After resolving metadata you write a `METADATA` file. METADATA 2.2 lets a field
be marked `Dynamic`, meaning its value may legitimately differ between the SDist
and the wheel built from it. Ask each provider which of its fields are dynamic
in that sense via the optional `dynamic_wheel` hook:
When building an SDist you write a `PKG-INFO` file. METADATA 2.2 lets a field in
it be marked `Dynamic`, meaning its value may legitimately differ between the
SDist and a wheel built from it. `dynamic_wheel_fields` asks each provider via
the optional `dynamic_wheel` hook and returns the set of field names to mark:

```python
from dynamic_metadata.loader import load_dynamic_metadata
from dynamic_metadata.protocols import DynamicMetadataWheelProtocol

from dynamic_metadata.loader import dynamic_wheel_fields

def dynamic_wheel_fields(entries):
fields = {}
for provider, settings in load_dynamic_metadata(entries):
if isinstance(provider, DynamicMetadataWheelProtocol):
fields.update(provider.dynamic_wheel(settings))
return {field for field, is_dynamic in fields.items() if is_dynamic}
fields = dynamic_wheel_fields(entries)
```

A field a provider does not mention defaults to **not** dynamic, and `version`
must never be dynamic. Call this hook after `dynamic_metadata`, so a provider
can rely on its inputs already being validated.
A field no provider mentions is **not** dynamic, and `version` may never be. A
field is dynamic if _any_ provider reports it so: contributions to a field
merge, so one dynamic part makes the merged value dynamic (PEP 643 permits
marking a field `Dynamic` even when a value is also given). Call it after
`process_dynamic_metadata`, so a provider may assume its settings were already
validated by the main hook — but note providers are loaded fresh, so
`dynamic_wheel` cannot rely on state stashed during `dynamic_metadata`.

## Telling a provider the build state

Expand Down
29 changes: 25 additions & 4 deletions docs/backend_authors_reimplement.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,40 @@ detected by their presence — a plain `hasattr` check:
| `get_requires_for_dynamic_metadata` | `get_requires_for_dynamic_metadata` | during `get_requires_for_build_*` |
| `dynamic_wheel(settings)` | `dynamic_wheel` | after metadata, for METADATA 2.2 |

The requirements and METADATA 2.2 passes are the loops shown on the
[backend authors](backend_authors.md#collecting-build-requirements) page, with
each `isinstance(provider, SomeProtocol)` replaced by `hasattr(provider, ...)`:
The requirements and METADATA 2.2 passes are simple collection loops
(`get_requires_for_dynamic_metadata` and `dynamic_wheel_fields` in
{mod}`dynamic_metadata.loader`, with each `isinstance(provider, SomeProtocol)`
replaced by `hasattr(provider, ...)`):

```python
def collect_requires(entries):
def get_requires_for_dynamic_metadata(entries):
requires = []
for provider, settings in load_dynamic_metadata(entries):
if hasattr(provider, "get_requires_for_dynamic_metadata"):
requires += provider.get_requires_for_dynamic_metadata(settings)
return requires


def dynamic_wheel_fields(entries):
fields = set()
for provider, settings in load_dynamic_metadata(entries):
if not hasattr(provider, "dynamic_wheel"):
continue
for field, is_dynamic in provider.dynamic_wheel(settings).items():
if field not in ALL_FIELDS:
raise KeyError(f"{field!r} is not a settable field")
if field == "version" and is_dynamic:
raise ValueError("'version' may never be dynamic")
if is_dynamic:
fields.add(field)
return fields
```

`dynamic_wheel_fields` must validate reported names against the field taxonomy,
reject a dynamic `version`, and treat a field as dynamic if _any_ provider says
so — contributions to a field merge, so one dynamic part makes the merged value
dynamic.

## The field taxonomy

You need to know which `[project]` fields a provider may set and what _shape_
Expand Down
5 changes: 3 additions & 2 deletions docs/plugin_authors.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ def dynamic_wheel(
It returns a map from each field this plugin sets to whether that field's value
can change between the SDist and the wheel (the METADATA 2.2 feature). A field
not present defaults to "false", and `"version"` must always be "false". This
hook is called after the main hook, so you do not need to validate the input
here.
hook runs after the main hook in the build, so you do not need to validate the
input here — but it may be called on a fresh instance, so do not rely on state
stashed by `dynamic_metadata` or `build_state`.

### Extra build requirements

Expand Down
49 changes: 49 additions & 0 deletions src/dynamic_metadata/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
BuildState,
DynamicMetadataBuildStateProtocol,
DynamicMetadataProtocol,
DynamicMetadataRequirementsProtocol,
DynamicMetadataWheelProtocol,
)

if TYPE_CHECKING:
Expand All @@ -39,6 +41,8 @@


__all__ = [
"dynamic_wheel_fields",
"get_requires_for_dynamic_metadata",
"load_dynamic_metadata",
"load_provider",
"process_dynamic_metadata",
Expand Down Expand Up @@ -309,3 +313,48 @@ def process_dynamic_metadata(
result["dynamic"].remove(field)

return result


def get_requires_for_dynamic_metadata(
entries: Sequence[Mapping[str, Any]],
) -> list[str]:
"""Collect every provider's extra build requirements, in entry order.

Call this from each PEP 517 ``get_requires_for_build_*`` hook. A provider
without the optional ``get_requires_for_dynamic_metadata`` hook contributes
nothing.
"""
requires = []
for provider, settings in load_dynamic_metadata(entries):
if isinstance(provider, DynamicMetadataRequirementsProtocol):
requires += provider.get_requires_for_dynamic_metadata(settings)
return requires


def dynamic_wheel_fields(entries: Sequence[Mapping[str, Any]]) -> set[str]:
"""Collect the fields to mark ``Dynamic`` in SDist metadata (METADATA 2.2).

Asks each provider's optional ``dynamic_wheel`` hook which fields may
legitimately differ between the SDist and a wheel built from it. A field is
dynamic if *any* provider reports it ``True``: contributions to a field
merge, so one dynamic part makes the merged value dynamic (PEP 643 permits
marking a field ``Dynamic`` even when its value is also given). A field no
provider mentions is not dynamic, and ``version`` may never be.

Providers are loaded fresh here, so ``dynamic_wheel`` cannot rely on state
from a ``dynamic_metadata`` call.
"""
fields: set[str] = set()
for provider, settings in load_dynamic_metadata(entries):
if not isinstance(provider, DynamicMetadataWheelProtocol):
continue
for field, is_dynamic in provider.dynamic_wheel(settings).items():
if field not in ALL_FIELDS:
msg = f"{field!r} is not a settable dynamic-metadata field"
raise KeyError(msg)
if field == "version" and is_dynamic:
msg = "'version' may never differ between the SDist and a wheel"
raise ValueError(msg)
if is_dynamic:
fields.add(field)
return fields
133 changes: 104 additions & 29 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,48 +363,123 @@ def test_build_state_rejects_unknown_value() -> None:
)


def test_load_dynamic_metadata_aggregates_requires_and_wheel(
tmp_path: Path,
) -> None:
# The backend-author pattern (see docs/backend_authors.md): iterate
# load_dynamic_metadata and use the runtime-checkable protocols to collect
# each provider's extra build requirements and METADATA 2.2 dynamic status.
# A provider implementing neither hook is simply skipped.
def test_get_requires_for_dynamic_metadata(tmp_path: Path) -> None:
# Requirements are collected in entry order; a provider without the hook is
# simply skipped.
plugin_dir = tmp_path / "plugins"
plugin_dir.mkdir()
(plugin_dir / "full_prov.py").write_text(
(plugin_dir / "req_prov.py").write_text(
"class Provider:\n"
" def get_requires_for_dynamic_metadata(self, settings):\n"
" return ['some-dep>=1']\n"
" def dynamic_metadata(self, settings, project):\n"
" return {'version': '1.2.3'}\n"
)
(plugin_dir / "bare_prov.py").write_text(
"def dynamic_metadata(settings, project):\n return {'description': 'hi'}\n"
)
(plugin_dir / "req_prov2.py").write_text(
"def get_requires_for_dynamic_metadata(settings):\n"
" return ['other-dep']\n"
"def dynamic_metadata(settings, project):\n"
" return {'description': 'hi'}\n"
)

requires = dynamic_metadata.loader.get_requires_for_dynamic_metadata(
[
{"provider": {"path": str(plugin_dir), "module": "req_prov:Provider"}},
{"provider": {"path": str(plugin_dir), "module": "bare_prov"}},
{"provider": {"path": str(plugin_dir), "module": "req_prov2"}},
]
)

assert requires == ["some-dep>=1", "other-dep"]


def test_dynamic_wheel_fields(tmp_path: Path) -> None:
# Only fields reported True are dynamic; unmentioned fields (and providers
# without the hook) default to not dynamic.
plugin_dir = tmp_path / "plugins"
plugin_dir.mkdir()
(plugin_dir / "wheel_prov.py").write_text(
"class Provider:\n"
" def dynamic_wheel(self, settings):\n"
" return {'version': False, 'dependencies': True}\n"
" def dynamic_metadata(self, settings, project):\n"
" return {'version': '1.2.3'}\n"
" return {'version': '1.2.3', 'dependencies': ['numpy']}\n"
)
(plugin_dir / "bare_prov.py").write_text(
"def dynamic_metadata(settings, project):\n return {'description': 'hi'}\n"
)

entries = [
{"provider": {"path": str(plugin_dir), "module": "full_prov:Provider"}},
{"provider": {"path": str(plugin_dir), "module": "bare_prov"}},
]

requires = []
dynamic_wheel = {}
for provider, settings in dynamic_metadata.loader.load_dynamic_metadata(entries):
if isinstance(
provider,
dynamic_metadata.protocols.DynamicMetadataRequirementsProtocol,
):
requires += provider.get_requires_for_dynamic_metadata(settings)
if isinstance(
provider, dynamic_metadata.protocols.DynamicMetadataWheelProtocol
):
dynamic_wheel.update(provider.dynamic_wheel(settings))

assert requires == ["some-dep>=1"]
assert dynamic_wheel == {"version": False, "dependencies": True}
fields = dynamic_metadata.loader.dynamic_wheel_fields(
[
{"provider": {"path": str(plugin_dir), "module": "wheel_prov:Provider"}},
{"provider": {"path": str(plugin_dir), "module": "bare_prov"}},
]
)

assert fields == {"dependencies"}


def test_dynamic_wheel_fields_any_true_wins(tmp_path: Path) -> None:
# Contributions to a field merge, so it is dynamic if *any* provider says
# so; a later False does not retract an earlier True.
plugin_dir = tmp_path / "plugins"
plugin_dir.mkdir()
(plugin_dir / "wheel_true.py").write_text(
"def dynamic_wheel(settings):\n"
" return {'dependencies': True}\n"
"def dynamic_metadata(settings, project):\n"
" return {'dependencies': ['a']}\n"
)
(plugin_dir / "wheel_false.py").write_text(
"def dynamic_wheel(settings):\n"
" return {'dependencies': False}\n"
"def dynamic_metadata(settings, project):\n"
" return {'dependencies': ['b']}\n"
)

fields = dynamic_metadata.loader.dynamic_wheel_fields(
[
{"provider": {"path": str(plugin_dir), "module": "wheel_true"}},
{"provider": {"path": str(plugin_dir), "module": "wheel_false"}},
]
)

assert fields == {"dependencies"}


def test_dynamic_wheel_fields_rejects_unknown_field(tmp_path: Path) -> None:
plugin_dir = tmp_path / "plugins"
plugin_dir.mkdir()
(plugin_dir / "wheel_bad.py").write_text(
"def dynamic_wheel(settings):\n"
" return {'not-a-field': True}\n"
"def dynamic_metadata(settings, project):\n"
" return {}\n"
)

with pytest.raises(KeyError, match="settable"):
dynamic_metadata.loader.dynamic_wheel_fields(
[{"provider": {"path": str(plugin_dir), "module": "wheel_bad"}}]
)


def test_dynamic_wheel_fields_rejects_dynamic_version(tmp_path: Path) -> None:
plugin_dir = tmp_path / "plugins"
plugin_dir.mkdir()
(plugin_dir / "wheel_ver.py").write_text(
"def dynamic_wheel(settings):\n"
" return {'version': True}\n"
"def dynamic_metadata(settings, project):\n"
" return {'version': '1.0'}\n"
)

with pytest.raises(ValueError, match="'version' may never"):
dynamic_metadata.loader.dynamic_wheel_fields(
[{"provider": {"path": str(plugin_dir), "module": "wheel_ver"}}]
)


def test_load_dynamic_metadata_requires_provider_key() -> None:
Expand Down
Loading