Skip to content
Closed
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ encouraged to reuse or vendor.
field, requirements.txt-style lines for a list field; a table field names its
key after a dot (`optional-dependencies.test`), one entry per key. pip option
lines (`-r`, ...) are a hard error — combine files with one entry per file.
- `from_data.py` — fill a field from a `.toml`/`.json` file at a dotted `key`
path (format from the extension); values keep their shape like `ast`. A class
provider and the reference `build_state` exemplar: the opt-in `states` list
gates the entry to those build states (gated out → returns `{}`, field stays
in `dynamic`); with `states` set, `dynamic_wheel` marks the field Dynamic;
`version` + `states` is a hard error.
- `pin_installed.py` — pin `dependencies` to build-environment versions via
templates like `"torch==x.x.*"` (`x` = installed release component, `x+N`,
trailing `*`); implements `dynamic_wheel` (dependencies are Dynamic in the
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ The documentation is split by audience:
— configure plugins in `pyproject.toml`.
- **[Bundled plugins](https://dynamic-metadata.readthedocs.io/en/latest/plugins.html)**
— the plugins shipped with this package (`ast`, `regex`, `template`,
`from_file`, `static`, `readme_fragment`, `substitute`, `pin_installed`).
`from_file`, `from_data`, `static`, `readme_fragment`, `substitute`,
`pin_installed`).
- **[For plugin authors](https://dynamic-metadata.readthedocs.io/en/latest/plugin_authors.html)**
— implement the hooks; no runtime dependency on this package required.
- **[For backend authors](https://dynamic-metadata.readthedocs.io/en/latest/backend_authors.html)**
Expand Down
8 changes: 7 additions & 1 deletion docs/plugin_authors.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ the `prepare_metadata_for_build_*` phases). This hook is called once, before
`dynamic_metadata`. A plugin may use it — for example to reuse a value already
computed in an SDist's `PKG-INFO` instead of recomputing it for the wheel — by
stashing it (typically on `self` in a class provider) for `dynamic_metadata` to
read; a plugin that does not care simply omits this hook.
read; a plugin that does not care simply omits this hook. The bundled
[`from_data`](plugins.md#from_data) plugin is a worked example: it stashes the
state and, when its opt-in `states` setting excludes the current state, returns
nothing so the field is left for another entry to resolve. A plugin whose value
can differ between the SDist and the wheel because of the build state should
also implement [`dynamic_wheel`](#metadata-2-2-dynamic-status) to mark that
field `Dynamic`.

### METADATA 2.2 dynamic status

Expand Down
91 changes: 85 additions & 6 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Bundled plugins

This package ships eight plugins. `ast`, `regex`, `template`, `from_file`, and
`substitute` are generic — they read their target from a `field` setting.
`static` writes values straight from its settings; `readme_fragment` and
`pin_installed` are single-purpose and always write `readme` and `dependencies`
respectively. Because they live inside `dynamic-metadata`, you must add
`dynamic-metadata` to your `[build-system].requires` to use them.
This package ships nine plugins. `ast`, `regex`, `template`, `from_file`,
`from_data`, and `substitute` are generic — they read their target from a
`field` setting. `static` writes values straight from its settings;
`readme_fragment` and `pin_installed` are single-purpose and always write
`readme` and `dependencies` respectively. Because they live inside
`dynamic-metadata`, you must add `dynamic-metadata` to your
`[build-system].requires` to use them.

Each registers a provider name of `dynamic_metadata.` plus the heading below, so
the `regex` plugin is `provider = "dynamic_metadata.regex"`. The examples use
Expand Down Expand Up @@ -159,6 +160,84 @@ Fields whose values aren't flat text — `readme` (use
[`readme_fragment`](#readme_fragment)), `entry-points`, `authors`, and
`maintainers` — are rejected.

## `from_data`

`dynamic_metadata.plugins.from_data` reads a value out of a structured data file
— a `.toml` or `.json` document — at a dotted `key` path. The format is inferred
from the file extension; any other extension is an error. Like [`ast`](#ast),
the value keeps its shape, so a list or table field can be filled directly.

```toml
[project]
dynamic = ["version"]

[[tool.dynamic-metadata]]
provider = "dynamic_metadata.from_data"
field = "version"
path = "Cargo.toml"
key = "package.version"
```

Settings:

| Setting | Required | Description |
| -------- | -------- | --------------------------------------------------------------------------------------------------------- |
| `field` | yes | The metadata field to set; a table field may name the key after a dot (`optional-dependencies.test`). |
| `path` | yes | The data file to read; `.toml` or `.json`, chosen by extension. |
| `key` | yes | A dotted path into the parsed document (`project.version`). Key segments cannot themselves contain a dot. |
| `states` | no | A flat list of build states this entry applies to (opt-in; see below). |

`key` walks the parsed table one segment at a time; a missing segment raises an
error naming the segment that failed. Because segments are split on `.`, a key
that itself contains a dot cannot be addressed. The extracted value's shape must
match the target `field` (a string for `version`, a list for `keywords`, a table
for `urls`), as with `ast`. A table field can also be filled one key at a time
with a dotted `field` like [`from_file`](#from_file):
`optional-dependencies.test` expects a list value, and a
`urls`/`scripts`/`gui-scripts` key expects a string.

### Gating on the build state

`from_data` is the reference example of the optional `build_state` hook. Set
`states` to a list of
[build states](plugin_authors.md#receiving-the-build-state) (`sdist`, `wheel`,
`editable`, `metadata_wheel`, `metadata_editable`) to apply the entry only in
those states:

```toml
[project]
dynamic = ["dependencies"]

# Read the locked, exact dependencies only when building a wheel; the SDist keeps
# whatever another entry (or [project]) provides.
[[tool.dynamic-metadata]]
provider = "dynamic_metadata.from_data"
field = "optional-dependencies.locked"
path = "locked.json"
key = "dependencies.locked"
states = ["wheel", "editable"]
```

The semantics:

- When `states` is **absent**, the plugin is unconditional — identical to not
having the feature. Nothing changes for anyone not using it.
- When `states` is **set** and the current build state is **not** listed,
`dynamic_metadata` returns nothing. The field is **not** resolved, so it stays
in `project.dynamic`. Pair a gated entry with another entry that provides the
field in the remaining states, or only gate a field the backend does not
require in those states — otherwise the backend sees an unresolved dynamic
field and errors.
- Because a gated field can differ between the SDist build and the wheel build,
`dynamic_wheel` reports it as `Dynamic` (METADATA 2.2) when `states` is set;
without `states` the file content is the same at both times, so nothing is
marked dynamic.
- `version` may not be combined with `states`: a version must resolve
identically in every build state, and gating it would leave it unresolvable
elsewhere. This is a hard error.

Unknown state names (and a non-list `states`) are rejected when the entry loads.

## `static`

`dynamic_metadata.plugins.static` sets fields directly from its own settings —
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ dynamic-metadata = "dynamic_metadata.schema:get_schema"
"dynamic_metadata.template" = "dynamic_metadata.plugins.template"
"dynamic_metadata.substitute" = "dynamic_metadata.plugins.substitute"
"dynamic_metadata.from_file" = "dynamic_metadata.plugins.from_file"
"dynamic_metadata.from_data" = "dynamic_metadata.plugins.from_data:Provider"
"dynamic_metadata.static" = "dynamic_metadata.plugins.static"
"dynamic_metadata.readme_fragment" = "dynamic_metadata.plugins.readme_fragment"
"dynamic_metadata.pin_installed" = "dynamic_metadata.plugins.pin_installed"
Expand Down
151 changes: 151 additions & 0 deletions src/dynamic_metadata/plugins/from_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Fill a field from a structured data file (TOML or JSON).

The value at a dotted ``key`` path is read from a ``.toml`` or ``.json`` file and,
like the :mod:`ast <dynamic_metadata.plugins.ast>` plugin, keeps its shape — so a
list or table field can be filled directly. A table field may instead name a
single key after a dot in ``field`` (``optional-dependencies.test``), matching
the :mod:`from_file <dynamic_metadata.plugins.from_file>` convention.

This is the reference example of the optional ``build_state`` hook. An opt-in
``states`` setting gates the entry to a set of build states: when the current
build state is not listed, ``dynamic_metadata`` contributes nothing and the field
is left in ``project.dynamic`` for another entry (or the backend) to resolve.
Without ``states`` the plugin is unconditional, so nothing changes for anyone not
using the feature.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .._compat import tomllib
from ..info import DICT_STR_FIELDS
from ..protocols import BUILD_STATES
from . import _process_dynamic_metadata, _require_field, _require_str_settings

if TYPE_CHECKING:
from collections.abc import Mapping

from ..protocols import BuildState

__all__ = ["Provider"]


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


KEYS = {"field", "path", "key", "states"}


def _settings(settings: Mapping[str, Any]) -> tuple[str, str, str, list[str] | None]:
"""Validate the settings and return ``(field, path, key, states)``.

``states`` is ``None`` when the setting is absent (unconditional behavior).
Runs identically from ``dynamic_metadata`` and the stateless ``dynamic_wheel``.
"""
field = _require_field(settings, KEYS)
for required in ("path", "key"):
if required not in settings:
msg = f"Must contain the {required!r} setting"
raise RuntimeError(msg)
_require_str_settings(settings, {"path", "key"})

base = field.partition(".")[0]
if (
base != field
and base not in DICT_STR_FIELDS
and base != "optional-dependencies"
):
msg = f"Field {base!r} does not take a dotted key"
raise RuntimeError(msg)

states = settings.get("states")
if states is not None:
if not isinstance(states, list) or not all(isinstance(s, str) for s in states):
msg = "Setting 'states' must be a list of strings"
raise RuntimeError(msg)
unknown = sorted(set(states) - BUILD_STATES)
if unknown:
msg = f"Unknown build state(s) {unknown}; valid states are {sorted(BUILD_STATES)}"
raise RuntimeError(msg)
if base == "version":
msg = (
"'version' cannot be gated by 'states': a version must resolve "
"identically in every build state"
)
raise RuntimeError(msg)

return field, settings["path"], settings["key"], states


def _extract(path: str, key: str) -> Any:
"""Read ``path`` (format from its extension) and follow the dotted ``key``.

Key segments that themselves contain a dot are not supported. A missing
segment raises, naming the segment that failed.
"""
suffix = Path(path).suffix.lower()
if suffix == ".toml":
with Path(path).open("rb") as f:
data: Any = tomllib.load(f)
elif suffix == ".json":
with Path(path).open(encoding="utf-8") as f:
data = json.load(f)
else:
msg = f"Unsupported data file extension {suffix!r} for {path!r}; use '.toml' or '.json'"
raise RuntimeError(msg)

current = data
seen: list[str] = []
for segment in key.split("."):
if not isinstance(current, dict):
location = ".".join(seen) or "the document root"
msg = f"Cannot read key segment {segment!r}: {location} is not a table in {path}"
raise RuntimeError(msg)
if segment not in current:
location = ".".join(seen) or "the document root"
msg = f"Key segment {segment!r} not found in {location} of {path}"
raise RuntimeError(msg)
current = current[segment]
seen.append(segment)
return current


class Provider:
"""Class provider: the ``build_state`` hook stashes the state on ``self``."""

def __init__(self) -> None:
self._build_state: BuildState | None = None

def build_state(self, build_state: BuildState) -> None:
self._build_state = build_state

def dynamic_metadata(
self,
settings: Mapping[str, Any],
_project: Mapping[str, Any],
) -> dict[str, Any]:
field, path, key, states = _settings(settings)
# Gated out for this build state: contribute nothing (the field stays in
# project.dynamic unless another entry provides it).
if states is not None and self._build_state not in states:
return {}

value = _extract(path, key)

base, _, subkey = field.partition(".")
if subkey:
# A table field naming one key: validate the value's shape by pushing
# {key: value} through the field's shape check.
return {base: _process_dynamic_metadata(base, lambda s: s, {subkey: value})}
return {field: _process_dynamic_metadata(field, lambda s: s, value)}

def dynamic_wheel(self, settings: Mapping[str, Any]) -> dict[str, bool]:
# A states-gated field can differ between the SDist build and the wheel
# build, so it is Dynamic (METADATA 2.2); without states the file content
# is identical at both times, so nothing is dynamic. Stateless by design.
field, _, _, states = _settings(settings)
return {field.partition(".")[0]: True} if states is not None else {}
Loading
Loading