diff --git a/AGENTS.md b/AGENTS.md index f98053a..56a16cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,9 @@ encouraged to reuse or vendor. ### Bundled plugins — `plugins/` +- `ast.py` — read the literal value of a module-level global (`name`) from a + Python file via `ast.literal_eval`, without importing it; values keep their + Python shape, so list/table fields can be filled directly. - `regex.py` — extract a value from a file via regex (default targets `__version__`/`VERSION`). - `template.py` — `str.format` substitution using `{project[...]}`, diff --git a/README.md b/README.md index 912cb32..2b1b3a4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The documentation is split by audience: - **[For users](https://dynamic-metadata.readthedocs.io/en/latest/users.html)** — configure plugins in `pyproject.toml`. - **[Bundled plugins](https://dynamic-metadata.readthedocs.io/en/latest/plugins.html)** - — the plugins shipped with this package (`regex`, `template`, `static`, + — the plugins shipped with this package (`ast`, `regex`, `template`, `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. diff --git a/docs/plugins.md b/docs/plugins.md index 129f568..dcf8446 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,8 +1,8 @@ # Bundled plugins -This package ships six plugins. `regex`, `template`, 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 +This package ships seven plugins. `ast`, `regex`, `template`, 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. @@ -48,6 +48,43 @@ The search runs in `re.MULTILINE` mode. When the target `field` is not a string field, `result` is applied across the container shape the field requires (each string in a list, each value in a table, and so on). +## `ast` + +`dynamic_metadata.plugins.ast` reads the literal value assigned to a +module-level global in a Python file. The file is parsed with {mod}`ast`, never +imported, so it works without the package (or its dependencies) being importable +in the build environment. + +```toml +[project] +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.ast" +field = "version" +input = "src/my_package/__init__.py" +name = "__version__" +``` + +Settings (all values must be strings): + +| Setting | Required | Description | +| ------- | -------- | -------------------------- | +| `field` | yes | The metadata field to set. | +| `input` | yes | The Python file to parse. | +| `name` | yes | The global to read. | + +Only assignments at module scope are considered (including annotated ones like +`__version__: str = "1.2.3"`); if the name is assigned more than once, the last +assignment wins, as it would when executing the file. The value must be a +literal accepted by {func}`ast.literal_eval` — a call like `get_version()` is an +error. + +Unlike `regex`, which always extracts a string, the value keeps its Python +shape, so a list or table field can be filled directly — for example +`field = "keywords"` from `KEYWORDS = ["science", "build"]`. Tuples are +converted to lists. The shape must match what the field requires. + ## `template` `dynamic_metadata.plugins.template` fills a `str.format` template from fields diff --git a/pyproject.toml b/pyproject.toml index 4756ea8..5d5d1ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ Changelog = "https://github.com/scikit-build/dynamic-metadata/releases" dynamic-metadata = "dynamic_metadata.schema:get_schema" [project.entry-points."dynamic_metadata.provider"] +"dynamic_metadata.ast" = "dynamic_metadata.plugins.ast" "dynamic_metadata.regex" = "dynamic_metadata.plugins.regex" "dynamic_metadata.template" = "dynamic_metadata.plugins.template" "dynamic_metadata.substitute" = "dynamic_metadata.plugins.substitute" diff --git a/src/dynamic_metadata/plugins/__init__.py b/src/dynamic_metadata/plugins/__init__.py index 4e0dc23..3422097 100644 --- a/src/dynamic_metadata/plugins/__init__.py +++ b/src/dynamic_metadata/plugins/__init__.py @@ -30,6 +30,14 @@ def _require_field(settings: Mapping[str, typing.Any], allowed: set[str]) -> str return field +def _require_str_settings(settings: Mapping[str, typing.Any], keys: set[str]) -> None: + """Reject any of ``keys`` present in ``settings`` with a non-string value.""" + for key in keys: + if key in settings and not isinstance(settings[key], str): + msg = f"Setting {key!r} must be a string" + raise RuntimeError(msg) + + def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T: """ Helper function for processing an action on the various possible metadata fields. diff --git a/src/dynamic_metadata/plugins/ast.py b/src/dynamic_metadata/plugins/ast.py new file mode 100644 index 0000000..bc3e4ff --- /dev/null +++ b/src/dynamic_metadata/plugins/ast.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import ast +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from . import _process_dynamic_metadata, _require_field, _require_str_settings + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = ["dynamic_metadata"] + + +def __dir__() -> list[str]: + return __all__ + + +KEYS = {"field", "input", "name"} + + +def _tuples_to_lists(value: Any) -> Any: + if isinstance(value, (list, tuple)): + return [_tuples_to_lists(v) for v in value] + if isinstance(value, dict): + return {k: _tuples_to_lists(v) for k, v in value.items()} + return value + + +def dynamic_metadata( + settings: Mapping[str, Any], + _project: Mapping[str, Any], +) -> dict[str, Any]: + # Input validation + field = _require_field(settings, KEYS) + if "input" not in settings: + msg = "Must contain the 'input' setting naming a Python file to parse" + raise RuntimeError(msg) + if "name" not in settings: + msg = "Must contain the 'name' setting naming the global to read" + raise RuntimeError(msg) + _require_str_settings(settings, KEYS) + + input_filename = settings["input"] + name = settings["name"] + + tree = ast.parse( + Path(input_filename).read_text(encoding="utf-8"), filename=input_filename + ) + + # Last matching module-level assignment wins, like execution would give + value_node = None + for node in tree.body: + if isinstance(node, ast.Assign): + targets, value = node.targets, node.value + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets, value = [node.target], node.value + else: + continue + if any(isinstance(t, ast.Name) and t.id == name for t in targets): + value_node = value + + if value_node is None: + msg = f"Couldn't find a global assignment to {name} in {input_filename}" + raise RuntimeError(msg) + + try: + result = _tuples_to_lists(ast.literal_eval(value_node)) + except ValueError as err: + msg = f"Assignment at {input_filename}:{value_node.lineno} is not a literal constant" + raise RuntimeError(msg) from err + + return {field: _process_dynamic_metadata(field, lambda s: s, result)} diff --git a/src/dynamic_metadata/plugins/regex.py b/src/dynamic_metadata/plugins/regex.py index 650dc6f..ba4b0ce 100644 --- a/src/dynamic_metadata/plugins/regex.py +++ b/src/dynamic_metadata/plugins/regex.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from . import _process_dynamic_metadata, _require_field +from . import _process_dynamic_metadata, _require_field, _require_str_settings if TYPE_CHECKING: from collections.abc import Mapping @@ -39,10 +39,7 @@ def dynamic_metadata( if field != "version" and "regex" not in settings: msg = "Must contain the 'regex' setting if not getting version" raise RuntimeError(msg) - for key in KEYS: - if key in settings and not isinstance(settings[key], str): - msg = f"Setting {key!r} must be a string" - raise RuntimeError(msg) + _require_str_settings(settings, KEYS) input_filename = settings["input"] regex = settings.get( diff --git a/tests/test_package.py b/tests/test_package.py index 370ed7a..b4de397 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -319,6 +319,118 @@ def test_regex_rejects_unknown_setting() -> None: ) +def test_ast_version(tmp_path: Path) -> None: + (tmp_path / "version.py").write_text('__version__ = "1.2.3"\n') + + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.ast", + "field": "version", + "input": str(tmp_path / "version.py"), + "name": "__version__", + }, + ], + "wheel", + ) + + assert pyproject["version"] == "1.2.3" + + +def test_ast_last_assignment_wins(tmp_path: Path) -> None: + (tmp_path / "version.py").write_text( + '__version__: str = "0.1"\n__version__ = "0.2"\n' + ) + + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.ast", + "field": "version", + "input": str(tmp_path / "version.py"), + "name": "__version__", + }, + ], + "wheel", + ) + + assert pyproject["version"] == "0.2" + + +def test_ast_list_field_from_tuple(tmp_path: Path) -> None: + (tmp_path / "meta.py").write_text('KEYWORDS = ("science", "build")\n') + + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "version": "0.1.0", "dynamic": ["keywords"]}, + [ + { + "provider": "dynamic_metadata.ast", + "field": "keywords", + "input": str(tmp_path / "meta.py"), + "name": "KEYWORDS", + }, + ], + "wheel", + ) + + assert pyproject["keywords"] == ["science", "build"] + + +def test_ast_missing_name_raises(tmp_path: Path) -> None: + (tmp_path / "version.py").write_text('other = "1.0"\n') + + with pytest.raises(RuntimeError, match="Couldn't find a global assignment"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.ast", + "field": "version", + "input": str(tmp_path / "version.py"), + "name": "__version__", + }, + ], + "wheel", + ) + + +def test_ast_non_literal_raises(tmp_path: Path) -> None: + (tmp_path / "version.py").write_text("__version__ = get_version()\n") + + with pytest.raises(RuntimeError, match="not a literal constant"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.ast", + "field": "version", + "input": str(tmp_path / "version.py"), + "name": "__version__", + }, + ], + "wheel", + ) + + +def test_ast_requires_name(tmp_path: Path) -> None: + (tmp_path / "version.py").write_text('__version__ = "1.2.3"\n') + + with pytest.raises(RuntimeError, match="'name' setting"): + dynamic_metadata.loader.process_dynamic_metadata( + {"name": "test", "dynamic": ["version"]}, + [ + { + "provider": "dynamic_metadata.ast", + "field": "version", + "input": str(tmp_path / "version.py"), + }, + ], + "wheel", + ) + + def test_build_state_hook_drives_result(tmp_path: Path) -> None: # A provider with the optional build_state hook is told the build state # before dynamic_metadata, and can drive its result from it: recompute for