Skip to content

Overview

Henry Schreiner edited this page Jun 25, 2026 · 1 revision

Here's an overview generated by Claude:


Here's how the dynamic-metadata plugin system is currently structured and how it works.

Big picture

This package is a spec + reference implementation of a plugin protocol that lets a Python build backend compute [project] metadata fields (version, readme, dependencies, …) at build time. There are three audiences, none of whom need a runtime dependency on this package:

  • Users — configure plugins in pyproject.toml.
  • Plugin authors — write a module exposing the hooks.
  • Backend authors — call loader.py to drive the plugins.

User-facing configuration

Each dynamic field gets a table under [tool.dynamic-metadata.<field>]:

[project]
dynamic = ["version", "readme"]

[tool.dynamic-metadata.version]
provider = "dynamic_metadata.plugins.regex"   # importable module
provider-path = "."                            # optional: local dir prepended to sys.path
input = "src/pkg/__init__.py"                  # plugin-specific settings

provider / provider-path are consumed by the loader; everything else in the table is passed to the plugin as settings.

The plugin protocol (loader.py)

A plugin is any module implementing one required hook plus two optional ones. These are formalized as runtime_checkable Protocols (loader.py:27-53):

  • Required: dynamic_metadata(field, settings, project) -> value — computes the field's value.
  • Optional: get_requires_for_dynamic_metadata(settings) -> list[str] — extra build-time requirements (e.g. setuptools-scm).
  • Optional: dynamic_wheel(field, settings) -> bool — reports METADATA 2.2 dynamic status (must be false for version).

load_provider (loader.py:56) imports the module by string, temporarily inserting provider-path onto sys.path if given. load_dynamic_metadata (loader.py:74) iterates the config, validates each field against ALL_FIELDS, and yields (field, provider, settings).

Lazy, data-driven resolution (loader.py:91-153)

The core mechanism is DynamicPyProject, a Mapping that resolves providers lazily on __getitem__:

def __getitem__(self, key):
    if key in self.project:           # already static or already computed
        return self.project[key]
    if key not in self.providers:     # not pending → loop or missing
        dep_type = "missing" if key in self.settings else "circular"
        raise ValueError(...)
    provider = self.providers.pop(key)   # pop BEFORE running
    self.project[key] = provider.dynamic_metadata(key, self.settings[key], self)
    self.project["dynamic"].remove(key)
    return self.project[key]

Key consequences:

  • Cross-field references work. A plugin receives the DynamicPyProject itself as project, so it can ask for another field via project["other-field"], which computes it on demand. This is why template.py can do {project[version]}.
  • Order is data-driven, not declaration order. Resolution follows the dependency graph.
  • Cycles and missing deps are detected for free. Because a provider is pop-ed before it runs, a re-entrant request for the same not-yet-resolved key finds it absent from providers and raises — distinguishing "circular" (still in settings) from "missing".

process_dynamic_metadata(project, metadata) (loader.py:126) is the public entry point backends call: it builds the DynamicPyProject and forces full resolution via dict(dynamic).

Field taxonomy (info.py)

info.py is the single source of truth for which [project] fields may be dynamic and what shape each value has:

  • STR_FIELDS — version, description, requires-python, license
  • LIST_STR_FIELDS — classifiers, keywords, dependencies, license-files
  • DICT_STR_FIELDS — urls, scripts, gui-scripts
  • LIST_DICT_FIELDS — authors, maintainers
  • Special-cased: readme, entry-points, optional-dependencies
  • ALL_FIELDS is the union; loader.py validates against it. name and dynamic are intentionally excluded (not settable or requestable).

Shared value-shaping helper (plugins/__init__.py)

_process_dynamic_metadata(field, action, result) applies a single string-transform action across whatever container shape the field requires, validating the runtime type against info.py (raising if a plugin produces the wrong shape). A plugin author writes the transform once and gets correct behavior for strings, lists, dicts, dict-of-lists, list-of-dicts, entry-points, and optional-dependencies. This is the piece plugin authors are encouraged to reuse or vendor.

Bundled plugins (plugins/)

Plugin What it does Notable mechanics
regex.py Extracts a value from a file via regex (default targets __version__/VERSION). Uses result.format(*groups, **groupdict), optional remove substitution; routes through _process_dynamic_metadata.
template.py str.format substitution using {project[...]}. Demonstrates cross-field references via the live project mapping.
setuptools_scm.py Wraps setuptools-scm for version. Lazy-imports the dep inside the hook; declares it via get_requires_for_dynamic_metadata; rejects inline config.
fancy_pypi_readme.py Wraps hatch-fancy-pypi-readme for readme. Lazy-imports; reads its own config from [tool.hatch...]; pulls project["version"] for substitutions; declares its requirement.

The two external-tool wrappers illustrate the intended pattern: lazy-import the heavy dependency inside the hook and surface it through get_requires_for_dynamic_metadata, so the build only pulls it when actually used.

Schema validation (schema.py + resources/toml_schema.json)

get_schema() loads the bundled JSON schema and is exposed as a validate_pyproject.tool_schema entry point, so [tool.dynamic-metadata] config is validated when users run validate-pyproject.

Compat

_compat/tomllib.py shims tomllib/tomli for <3.11. Target is requires-python >=3.8, and every module starts with from __future__ import annotations (required by the ruff isort config).


The end-to-end flow: backend calls process_dynamic_metadata(project_table, tool_config) → loader imports each provider → builds a lazy DynamicPyProject → forcing it to a dict resolves each provider in dependency order, letting providers pull other fields on demand, validating shapes against info.py, and stripping resolved names out of dynamic.

Clone this wiki locally