From c1d4560b1c23082292d8fb90ef392c81069013e7 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 3 Jul 2026 19:25:31 -0400 Subject: [PATCH] feat: add from_data plugin for filling fields from TOML/JSON data files Reads a value at a dotted key path from a .toml or .json file (format inferred from the extension); like ast, the value keeps its shape so list/table fields fill directly, and a table field can name one key after a dot in 'field'. Also the first bundled exemplar of the optional build_state hook: an opt-in 'states' list gates the entry to specific build states. Gated out, the entry contributes nothing and the field stays unresolved in project.dynamic; with 'states' set, dynamic_wheel marks the field Dynamic (METADATA 2.2) since it may differ between SDist and wheel builds. 'version' combined with 'states' is a hard error, as gating would leave it unresolvable in other states. Assisted-by: ClaudeCode:claude-opus-4-8 --- AGENTS.md | 6 + README.md | 3 +- docs/plugin_authors.md | 8 +- docs/plugins.md | 91 +++++- pyproject.toml | 1 + src/dynamic_metadata/plugins/from_data.py | 151 ++++++++++ tests/test_package.py | 336 ++++++++++++++++++++++ 7 files changed, 588 insertions(+), 8 deletions(-) create mode 100644 src/dynamic_metadata/plugins/from_data.py diff --git a/AGENTS.md b/AGENTS.md index 78ddb3e..c915938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 57cec70..4517d41 100644 --- a/README.md +++ b/README.md @@ -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)** diff --git a/docs/plugin_authors.md b/docs/plugin_authors.md index c035e72..c9bb382 100644 --- a/docs/plugin_authors.md +++ b/docs/plugin_authors.md @@ -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 diff --git a/docs/plugins.md b/docs/plugins.md index c755d73..36ea47a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 @@ -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 — diff --git a/pyproject.toml b/pyproject.toml index 52b13d3..fce169c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/dynamic_metadata/plugins/from_data.py b/src/dynamic_metadata/plugins/from_data.py new file mode 100644 index 0000000..39c1c5f --- /dev/null +++ b/src/dynamic_metadata/plugins/from_data.py @@ -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 ` 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 ` 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 {} diff --git a/tests/test_package.py b/tests/test_package.py index 25cea19..a7fbf79 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1936,6 +1936,342 @@ def test_from_file_rejects_bad_settings(settings: dict[str, Any], match: str) -> ) +def test_from_data_toml_scalar(tmp_path: Path) -> None: + # A version out of a pyproject.toml-shaped file via a dotted key. + (tmp_path / "data.toml").write_text('[project]\nversion = "1.2.3"\n') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "version", + "path": str(tmp_path / "data.toml"), + "key": "project.version", + }, + ], + "wheel", + ) + + assert pyproject["version"] == "1.2.3" + assert pyproject["dynamic"] == [] + + +def test_from_data_json_scalar(tmp_path: Path) -> None: + (tmp_path / "data.json").write_text('{"package": {"version": "4.5.6"}}') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "version", + "path": str(tmp_path / "data.json"), + "key": "package.version", + }, + ], + "wheel", + ) + + assert pyproject["version"] == "4.5.6" + + +def test_from_data_list_field(tmp_path: Path) -> None: + # A list value keeps its shape and fills a list field directly. + (tmp_path / "data.toml").write_text('[tool]\nkeywords = ["science", "build"]\n') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["keywords"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "keywords", + "path": str(tmp_path / "data.toml"), + "key": "tool.keywords", + }, + ], + "wheel", + ) + + assert pyproject["keywords"] == ["science", "build"] + + +def test_from_data_table_field_whole(tmp_path: Path) -> None: + # A table value fills the whole field directly, like the ast plugin. + (tmp_path / "data.json").write_text('{"urls": {"Home": "h", "Docs": "d"}}') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["urls"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "urls", + "path": str(tmp_path / "data.json"), + "key": "urls", + }, + ], + "wheel", + ) + + assert pyproject["urls"] == {"Home": "h", "Docs": "d"} + + +def test_from_data_dotted_field_optional_dependencies(tmp_path: Path) -> None: + # A dotted field names one extra; the value is a list of requirements. + (tmp_path / "data.toml").write_text( + '[groups]\ntest = ["pytest>=7", "pytest-cov"]\n' + ) + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["optional-dependencies"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "optional-dependencies.test", + "path": str(tmp_path / "data.toml"), + "key": "groups.test", + }, + ], + "wheel", + ) + + assert pyproject["optional-dependencies"] == {"test": ["pytest>=7", "pytest-cov"]} + + +def test_from_data_dotted_field_url_key(tmp_path: Path) -> None: + # A dotted key on a dict-of-strings field takes a single string value. + (tmp_path / "data.json").write_text('{"meta": {"home": "https://example.com"}}') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["urls"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "urls.Homepage", + "path": str(tmp_path / "data.json"), + "key": "meta.home", + }, + ], + "wheel", + ) + + assert pyproject["urls"] == {"Homepage": "https://example.com"} + + +def test_from_data_missing_key_segment(tmp_path: Path) -> None: + (tmp_path / "data.toml").write_text('[project]\nname = "x"\n') + with pytest.raises(RuntimeError, match="Key segment 'version' not found"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "version", + "path": str(tmp_path / "data.toml"), + "key": "project.version", + }, + ], + "wheel", + ) + + +def test_from_data_key_descends_into_scalar(tmp_path: Path) -> None: + # Traversing past a non-table value names the failing segment. + (tmp_path / "data.json").write_text('{"project": {"version": "1.0"}}') + with pytest.raises(RuntimeError, match="Cannot read key segment 'extra'"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "version", + "path": str(tmp_path / "data.json"), + "key": "project.version.extra", + }, + ], + "wheel", + ) + + +def test_from_data_unknown_extension(tmp_path: Path) -> None: + (tmp_path / "data.yaml").write_text("version: 1.0\n") + with pytest.raises(RuntimeError, match="Unsupported data file extension"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "version", + "path": str(tmp_path / "data.yaml"), + "key": "version", + }, + ], + "wheel", + ) + + +def test_from_data_shape_mismatch(tmp_path: Path) -> None: + # A string value cannot fill a list field. + (tmp_path / "data.toml").write_text('[project]\nversion = "1.0"\n') + with pytest.raises(RuntimeError, match="must be a list of strings"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["keywords"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "keywords", + "path": str(tmp_path / "data.toml"), + "key": "project.version", + }, + ], + "wheel", + ) + + +def _from_data_states_entry(path: str) -> dict[str, Any]: + return { + "provider": "dynamic_metadata.from_data", + "field": "description", + "path": path, + "key": "meta.summary", + "states": ["wheel", "sdist"], + } + + +def test_from_data_states_active(tmp_path: Path) -> None: + # In a listed state the entry resolves the field normally. + (tmp_path / "data.toml").write_text('[meta]\nsummary = "hello"\n') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["description"]}, + [_from_data_states_entry(str(tmp_path / "data.toml"))], + "wheel", + ) + + assert pyproject["description"] == "hello" + assert pyproject["dynamic"] == [] + + +def test_from_data_states_gated_out(tmp_path: Path) -> None: + # Outside the listed states the entry contributes nothing and the field is + # left unresolved in project.dynamic. + (tmp_path / "data.toml").write_text('[meta]\nsummary = "hello"\n') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["description"]}, + [_from_data_states_entry(str(tmp_path / "data.toml"))], + "metadata_wheel", + ) + + assert "description" not in pyproject + assert pyproject["dynamic"] == ["description"] + + +def test_from_data_no_states_unconditional(tmp_path: Path) -> None: + # Without states the field resolves regardless of build state. + (tmp_path / "data.toml").write_text('[meta]\nsummary = "hello"\n') + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["description"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "description", + "path": str(tmp_path / "data.toml"), + "key": "meta.summary", + } + ], + "metadata_wheel", + ) + + assert pyproject["description"] == "hello" + + +def test_from_data_dynamic_wheel_with_states() -> None: + # A states-gated field is Dynamic in the SDist (it may differ per build state). + fields = dynamic_metadata.loader.dynamic_wheel_fields( + [ + { + "provider": "dynamic_metadata.from_data", + "field": "optional-dependencies.test", + "path": "data.toml", + "key": "groups.test", + "states": ["wheel"], + } + ] + ) + assert fields == {"optional-dependencies"} + + +def test_from_data_dynamic_wheel_without_states() -> None: + # Without states the file content is identical at both build times. + fields = dynamic_metadata.loader.dynamic_wheel_fields( + [ + { + "provider": "dynamic_metadata.from_data", + "field": "description", + "path": "data.toml", + "key": "meta.summary", + } + ] + ) + assert fields == set() + + +def test_from_data_version_with_states_rejected() -> None: + # Gating version by state would leave it unresolvable in other states. + with pytest.raises(RuntimeError, match="'version' cannot be gated by 'states'"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.from_data", + "field": "version", + "path": "data.toml", + "key": "project.version", + "states": ["wheel"], + } + ], + "wheel", + ) + + +@pytest.mark.parametrize( + ("settings", "match"), + [ + pytest.param( + {"field": "version", "key": "v"}, "'path' setting", id="missing-path" + ), + pytest.param( + {"field": "version", "path": "d.toml"}, "'key' setting", id="missing-key" + ), + pytest.param( + {"field": "version", "path": 3, "key": "v"}, + "must be a string", + id="non-string-path", + ), + pytest.param( + {"field": "dependencies.x", "path": "d.toml", "key": "v"}, + "does not take a dotted key", + id="dotted-list-field", + ), + pytest.param( + {"field": "version", "path": "d.toml", "key": "v", "typo": "x"}, + "settings allowed", + id="unknown-setting", + ), + pytest.param( + {"field": "version", "path": "d.toml", "key": "v", "states": "wheel"}, + "must be a list of strings", + id="states-not-list", + ), + pytest.param( + {"field": "version", "path": "d.toml", "key": "v", "states": ["bdist"]}, + "Unknown build state", + id="states-unknown", + ), + ], +) +def test_from_data_rejects_bad_settings(settings: dict[str, Any], match: str) -> None: + with pytest.raises(RuntimeError, match=match): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["version"]}, + [{"provider": "dynamic_metadata.from_data", **settings}], + "wheel", + ) + + def test_list_providers_includes_bundled() -> None: providers = dynamic_metadata.discovery.list_providers() assert "dynamic_metadata.regex" in providers