Skip to content
Closed
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ 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.
- `env.py` β€” fill a string field from an environment variable (`variable`), with
an optional `default`; an unset variable and no default is a hard error.
Restricted to `STR_FIELDS` (the value is one string); implements
`dynamic_wheel` marking the field Dynamic (env values differ per build),
except `version`, which is never dynamic.
- `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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `env`, `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)**
Expand Down
38 changes: 36 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Bundled plugins

This package ships eight plugins. `ast`, `regex`, `template`, `from_file`, and
`substitute` are generic β€” they read their target from a `field` setting.
This package ships nine plugins. `ast`, `regex`, `template`, `from_file`, `env`,
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
Expand Down Expand Up @@ -159,6 +159,40 @@ Fields whose values aren't flat text β€” `readme` (use
[`readme_fragment`](#readme_fragment)), `entry-points`, `authors`, and
`maintainers` β€” are rejected.

## `env`

`dynamic_metadata.plugins.env` fills a field from an environment variable. This
suits CI-driven values such as a build number stamped into `version`.

```toml
[project]
dynamic = ["version"]

[[tool.dynamic-metadata]]
provider = "dynamic_metadata.env"
field = "version"
variable = "PACKAGE_VERSION"
default = "0.0.0"
```

Settings (all values must be strings):

| Setting | Required | Description |
| ---------- | -------- | --------------------------------------------------------------------------- |
| `field` | yes | The metadata field to set. Must be a string field. |
| `variable` | yes | The environment variable to read. |
| `default` | no | Used when the variable is unset. Without it, an unset variable is an error. |

Only string fields (`version`, `description`, `requires-python`, `license`) can
be filled, since the value is a single string. If the variable is unset and no
`default` is given, the build fails loudly rather than skipping the field.

The plugin implements `dynamic_wheel`, reporting the field as dynamic (METADATA
2.2) β€” an environment variable can hold different values when the SDist and the
wheel are built, so an SDist's `PKG-INFO` marks the field `Dynamic` and
installers must not trust its value. The one exception is `version`, which may
never differ between the SDist and a wheel and so is never marked dynamic.

## `static`

`dynamic_metadata.plugins.static` sets fields directly from its own settings β€”
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.env" = "dynamic_metadata.plugins.env"
"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"
Expand Down
66 changes: 66 additions & 0 deletions src/dynamic_metadata/plugins/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any

from ..info import STR_FIELDS
from . import _require_field, _require_str_settings

if TYPE_CHECKING:
from collections.abc import Mapping

__all__ = ["dynamic_metadata", "dynamic_wheel"]


def __dir__() -> list[str]:
return __all__


KEYS = {"field", "variable", "default"}


def _field(settings: Mapping[str, Any]) -> str:
"""Validate the settings and return the target field name.

The value read from the environment is a single string, so only scalar
string fields (see :data:`STR_FIELDS`) can be filled.
"""
field = _require_field(settings, KEYS)
if "variable" not in settings:
msg = "Must contain the 'variable' setting naming the environment variable"
raise RuntimeError(msg)
_require_str_settings(settings, KEYS)
if field not in STR_FIELDS:
msg = (
f"Field {field!r} cannot be filled from an environment variable; "
"only string fields are supported"
)
raise RuntimeError(msg)
return field


def dynamic_metadata(
settings: Mapping[str, Any],
_project: Mapping[str, Any],
) -> dict[str, Any]:
field = _field(settings)
variable = settings["variable"]
value = os.environ.get(variable)
if value is None:
if "default" not in settings:
msg = (
f"Environment variable {variable!r} is not set and no 'default' "
"was given"
)
raise RuntimeError(msg)
value = settings["default"]
assert isinstance(value, str)
return {field: value}


def dynamic_wheel(settings: Mapping[str, Any]) -> dict[str, bool]:
# An environment variable may hold different values when the SDist and the
# wheel are built, so the field is marked Dynamic (METADATA 2.2). 'version'
# may never differ between the two, so it is never dynamic.
field = _field(settings)
return {field: field != "version"}
121 changes: 121 additions & 0 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,127 @@ def test_from_file_rejects_bad_settings(settings: dict[str, Any], match: str) ->
)


def test_env_string_field(monkeypatch: pytest.MonkeyPatch) -> None:
# A string field takes the environment variable's value verbatim.
monkeypatch.setenv("PACKAGE_VERSION", "1.2.3")
pyproject = dynamic_metadata.loader.process_dynamic_metadata(
{"name": "test", "dynamic": ["version"]},
[
{
"provider": "dynamic_metadata.env",
"field": "version",
"variable": "PACKAGE_VERSION",
},
],
"wheel",
)

assert pyproject["version"] == "1.2.3"
assert "version" not in pyproject["dynamic"]


def test_env_default_used_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
# An unset variable falls back to 'default'.
monkeypatch.delenv("PACKAGE_VERSION", raising=False)
pyproject = dynamic_metadata.loader.process_dynamic_metadata(
{"name": "test", "dynamic": ["version"]},
[
{
"provider": "dynamic_metadata.env",
"field": "version",
"variable": "PACKAGE_VERSION",
"default": "0.0.0",
},
],
"wheel",
)

assert pyproject["version"] == "0.0.0"


def test_env_unset_without_default(monkeypatch: pytest.MonkeyPatch) -> None:
# An unset variable with no default fails loudly rather than skipping.
monkeypatch.delenv("PACKAGE_VERSION", raising=False)
with pytest.raises(RuntimeError, match="is not set and no 'default'"):
dynamic_metadata.loader.process_dynamic_metadata(
{"name": "test", "dynamic": ["version"]},
[
{
"provider": "dynamic_metadata.env",
"field": "version",
"variable": "PACKAGE_VERSION",
},
],
"wheel",
)


@pytest.mark.parametrize(
("settings", "match"),
[
pytest.param(
{"field": "keywords", "variable": "X"},
"only string fields",
id="list-field",
),
pytest.param(
{"field": "readme", "variable": "X"},
"only string fields",
id="readme-field",
),
pytest.param(
{"field": "version"},
"'variable' setting",
id="missing-variable",
),
pytest.param(
{"field": "version", "variable": 3},
"must be a string",
id="non-string-variable",
),
pytest.param(
{"field": "version", "variable": "X", "typo": "oops"},
"settings allowed",
id="unknown-setting",
),
],
)
def test_env_rejects_bad_settings(settings: dict[str, Any], match: str) -> None:
with pytest.raises(RuntimeError, match=match):
dynamic_metadata.loader.process_dynamic_metadata(
{"name": "test", "dynamic": ["version", "keywords", "readme"]},
[{"provider": "dynamic_metadata.env", **settings}],
"wheel",
)


def test_env_dynamic_wheel(monkeypatch: pytest.MonkeyPatch) -> None:
# An env-derived field can differ per build, so it is marked Dynamic β€” but
# version may never differ between the SDist and a wheel.
monkeypatch.setenv("DESC", "hello")
fields = dynamic_metadata.loader.dynamic_wheel_fields(
[
{
"provider": "dynamic_metadata.env",
"field": "description",
"variable": "DESC",
}
]
)
assert fields == {"description"}

fields = dynamic_metadata.loader.dynamic_wheel_fields(
[
{
"provider": "dynamic_metadata.env",
"field": "version",
"variable": "PACKAGE_VERSION",
}
]
)
assert fields == set()


def test_list_providers_includes_bundled() -> None:
providers = dynamic_metadata.discovery.list_providers()
assert "dynamic_metadata.regex" in providers
Expand Down
Loading