-
Notifications
You must be signed in to change notification settings - Fork 1
Overview
Here's an overview generated by Claude:
Here's how the dynamic-metadata plugin system is currently structured and how it works.
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.pyto drive the plugins.
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 settingsprovider / provider-path are consumed by the loader; everything else in the table is passed to the plugin as settings.
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 forversion).
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).
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
DynamicPyProjectitself asproject, so it can ask for another field viaproject["other-field"], which computes it on demand. This is whytemplate.pycan 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 fromprovidersand raises — distinguishing "circular" (still insettings) 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).
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_FIELDSis the union;loader.pyvalidates against it.nameanddynamicare intentionally excluded (not settable or requestable).
_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.
| 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.
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/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.