For backend authors

A backend’s job is small: collect the ordered [[tool.dynamic-metadata]] entries, load each entry’s provider, and call its hooks at the right point in the PEP 517 build. Because the entries are an explicit ordered list, there is no dependency graph to solve.

The easiest way is a build-time dependency on this package, calling the reference loader in dynamic_metadata.loader — what this page shows. You do not have to depend on us, though: you can vendor the loader or reimplement it from a precise description of its behaviour — see Reimplementing the loader.

Where plugins plug into a build

Plugins touch two PEP 517 responsibilities: declaring extra build requirements, and producing the final [project] metadata. Wire them in like this:

PEP 517 hook

What the backend does

build_state

get_requires_for_build_wheel

add each provider’s get_requires_for_dynamic_metadata

get_requires_for_build_sdist

same

get_requires_for_build_editable

same

prepare_metadata_for_build_wheel

run process_dynamic_metadata, write METADATA

"metadata_wheel"

prepare_metadata_for_build_editable

run process_dynamic_metadata, write METADATA

"metadata_editable"

build_wheel

run process_dynamic_metadata, write METADATA

"wheel"

build_editable

run process_dynamic_metadata, write METADATA

"editable"

build_sdist

run process_dynamic_metadata, write PKG-INFO

"sdist"

build_state is the string you pass into the loader so a provider can tell which build it is taking part in (see Telling a provider the build state). It must be one of the five values above (dynamic_metadata.protocols.BUILD_STATES).

Run all hooks from the same directory PEP 517 uses (the project root), since plugins resolve relative paths like input = "src/pkg/__init__.py" against the current directory.

The short version

resolve() does everything below in one call, including the dynamic bookkeeping every backend otherwise repeats:

import tomllib  # or tomli on <3.11

from dynamic_metadata.errors import DynamicMetadataError
from dynamic_metadata.loader import (
    entries_from_pyproject,
    get_requires_for_dynamic_metadata,
    resolve,
)

with open("pyproject.toml", "rb") as f:
    pyproject = tomllib.load(f)


def get_requires_for_build_wheel(config_settings=None):
    return [..., *get_requires_for_dynamic_metadata(entries_from_pyproject(pyproject))]


def build_sdist(sdist_directory, config_settings=None):
    try:
        resolved = resolve(pyproject, "sdist", backend_fields={"version"})
    except DynamicMetadataError as exc:
        raise MyBackendError(str(exc)) from exc
    project = resolved.project  # the new [project] table
    dynamic_lines = [f"Dynamic: {h}" for h in resolved.dynamic_headers]  # PKG-INFO

resolve returns [project] unchanged when there are no entries, and raises if entries exist without a [project] table. backend_fields names the fields your backend fills in itself (here version, say from a build file): with strict=True (the default) any other field still in dynamic afterwards — declared but produced by no provider — is an error. For an SDist it also asks the providers which fields are wheel-dynamic, removes those from dynamic so the PKG-INFO is valid, and hands them back as dynamic_fields (and as core-metadata header names in dynamic_headers) for the Dynamic: header. See METADATA 2.2 dynamic status.

The except clause works because every loader error shares one base — see Errors below.

The rest of this page covers the pieces resolve is built from, for a backend that needs finer control.

Reading the configuration

Parse pyproject.toml and take tool.dynamic-metadata as an ordered list of tables. Each table has a required provider; every other key is plugin-specific and passed through verbatim as that plugin’s settings. entries_from_pyproject() reads and validates it, returning [] if absent:

from dynamic_metadata.loader import entries_from_pyproject

project = pyproject.get("project", {})
entries = entries_from_pyproject(pyproject)

A field is only eligible if it appears in project["dynamic"]; the loader enforces this for you.

Collecting build requirements

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.

from dynamic_metadata.loader import get_requires_for_dynamic_metadata

requires += get_requires_for_dynamic_metadata(entries)

Requirements are collected in entry order; a provider without the optional hook contributes nothing.

A provider that is found but fails to import — typically one that imports at module level a package it would have declared as its own requirement — is skipped rather than raising, because its hook cannot be asked; the import error surfaces when the metadata is resolved. An unknown provider name or a missing local module always raises. Note that this hook can only run when dynamic-metadata is already in [build-system].requires, since your backend cannot import the loader otherwise; a backend that supports dynamic-metadata without depending on it should append its own dynamic-metadata >= X requirement when entries are present but the import fails.

Resolving the metadata

process_dynamic_metadata is the core call. It applies the entries in order, giving each provider a read-only snapshot of the project as resolved so far, merges each returned fragment into [project], and removes each resolved field from dynamic.

from dynamic_metadata.loader import process_dynamic_metadata

project = process_dynamic_metadata(project, entries, build_state="wheel")

After it returns, anything left in project["dynamic"] was declared but never produced — surface that as an error if your backend requires every dynamic field to be filled. The exact ordering, validation, and merge rules are documented in Reimplementing the loader; you only need them if you are replacing this call.

METADATA 2.2 dynamic status

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:

from dynamic_metadata.loader import dynamic_wheel_fields
from dynamic_metadata.info import METADATA_HEADERS

fields = dynamic_wheel_fields(entries)
headers = sorted({h for field in fields for h in METADATA_HEADERS[field]})

Only an SDist build needs this. Remove these fields from project["dynamic"] before writing PKG-INFO (a field cannot be both given and listed in dynamic there) and write each of their core-metadata headers as a Dynamic: line; METADATA_HEADERS in dynamic_metadata.info maps a field to its headers (optional-dependenciesProvides-Extra, Requires-Dist).

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

If a provider implements build_state, the loader calls it once with the build-state string before dynamic_metadata. A provider uses it to adapt — for example, reading a value back out of an SDist’s PKG-INFO during a wheel build instead of recomputing it. Providers that do not care omit the hook; you just have to pass the right build_state value (from the table above) into process_dynamic_metadata.

Errors

Every error the loader raises derives from DynamicMetadataError (see dynamic_metadata.errors), so one except translates them all; str(exc) is the message. An exception a provider raises from inside a hook is not wrapped, and propagates as-is.

from dynamic_metadata.errors import DynamicMetadataError

try:
    project = process_dynamic_metadata(project, entries, build_state="wheel")
except DynamicMetadataError as exc:
    raise MyBackendError(str(exc)) from exc

Testing your integration

The bundled dynamic_metadata.testing provider implements all four hooks, driven by its settings, so an integration test needs no plugin file:

[[tool.dynamic-metadata]]
provider = "dynamic_metadata.testing"
fields = { description = "built as {build_state}", dependencies = ["dep"] }
requires = ["test-plugin-requirement"]
dynamic-wheel = ["dependencies"]

See the plugin’s documentation.

See also

The API reference documents the protocols, the dynamic_metadata.info field taxonomy the loader validates against, and the plugin helpers. If you would rather not depend on this package, see Reimplementing the loader.