Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[...]}`,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 40 additions & 3 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/dynamic_metadata/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions src/dynamic_metadata/plugins/ast.py
Original file line number Diff line number Diff line change
@@ -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)}
7 changes: 2 additions & 5 deletions src/dynamic_metadata/plugins/regex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
112 changes: 112 additions & 0 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading