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
56 changes: 15 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,24 @@ https://github.com/scikit-build/scikit-build-core/issues/230.

> [!WARNING]
>
> This plugin is still a WiP!
> This is still a WiP! The design may still change.

## For users

Every external plugin must specify a "provider", which is a module that provides
the API listed in the next section.

```toml
[tool.dynamic-metadata]
<field-name>.provider = "<module>"
[tool.dynamic-metadata.<field-name>]
provider = "<module>"
```

There is an optional field: "provider-path", which specifies a local path to
load a plugin from, allowing plugins to reside inside your own project.

All other fields are passed on to the plugin, allowing plugins to specify custom
configuration per field. Plugins can, if desired, use their own `tool.*`
sections as well; plugins only supporting one metadata field are more likely to
do this.
sections as well.

### Example: regex

Expand Down Expand Up @@ -65,16 +64,16 @@ needs to have a `"value"` named group (`?P<value>`), which it will set.

**You do not need to depend on dynamic-metadata to write a plugin.** This
library provides testing and static typing helpers that are not needed at
runtime.
runtime, along with a reference implementation that you can either use as an
example, or use directly if you are fine to require the dependency.

Like PEP 517's hooks, `dynamic-metadata` defines a set of hooks that you can
implement; one required hook and two optional hooks. The required hook is:

```python
def dynamic_metadata(
field: str,
settings: dict[str, object] | None = None,
) -> str | dict[str, str | None]: ... # return the value of the metadata
field: str, settings: Mapping[str, Any], project: Mapping[str, Any]
) -> str | dict[str, Any]: ... # return the value of the metadata
```

The backend will call this hook in the same directory as PEP 517's hooks.
Expand All @@ -85,7 +84,8 @@ A plugin can return METADATA 2.2 dynamic status:

```python
def dynamic_wheel(
field: str, settings: Mapping[str, Any] | None = None
field: str,
settings: Mapping[str, Any],
) -> (
bool
): ... # Return true if metadata can change from SDist to wheel (METADATA 2.2 feature)
Expand All @@ -99,7 +99,7 @@ A plugin can also decide at runtime if it needs extra dependencies:

```python
def get_requires_for_dynamic_metadata(
settings: Mapping[str, Any] | None = None,
settings: Mapping[str, Any],
) -> list[str]: ... # return list of packages to require
```

Expand All @@ -114,6 +114,7 @@ Here is the regex plugin example implementation:
def dynamic_metadata(
field: str,
settings: Mapping[str, Any],
_project: Mapping[str, Any],
) -> str:
# Input validation
if field not in {"version", "description", "requires-python"}:
Expand Down Expand Up @@ -148,36 +149,9 @@ library provides some helper functions you can use if you want, but you can
implement them yourself following the standard provided or vendor the helper
file (which will be tested and supported).

You should collect the contents of `tool.dynamic-metadata` and load each,
something like this:

```python
def load_provider(
provider: str,
provider_path: str | None = None,
) -> DynamicMetadataProtocol:
if provider_path is None:
return importlib.import_module(provider)

if not Path(provider_path).is_dir():
msg = "provider-path must be an existing directory"
raise AssertionError(msg)

try:
sys.path.insert(0, provider_path)
return importlib.import_module(provider)
finally:
sys.path.pop(0)


for dynamic_metadata in settings.metadata.values():
if "provider" in dynamic_metadata:
config = dynamic_metadata.copy()
provider = config.pop("provider")
provider_path = config.pop("provider-path", None)
module = load_provider(provider, provider_path)
# Run hooks from module
```
You should collect the contents of `tool.dynamic-metadata` and load each one.
You should respect requests for metadata from other plugins, as well; to see how
to do that, refer to `src/dynamic-metadata/loader.py`.

<!-- prettier-ignore-start -->
[actions-badge]: https://github.com/scikit-build/dynamic-metadata/workflows/CI/badge.svg
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Typing :: Typed",
]
Expand Down Expand Up @@ -99,7 +100,7 @@ disallow_untyped_defs = true
disallow_incomplete_defs = true

[[tool.mypy.overrides]]
module = "setuptools_scm"
module = ["setuptools_scm"]
ignore_missing_imports = true


Expand Down Expand Up @@ -151,4 +152,5 @@ messages_control.disable = [
"missing-function-docstring",
"import-outside-toplevel",
"invalid-name",
"unused-argument", # Ruff
]
47 changes: 47 additions & 0 deletions src/dynamic_metadata/info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

__all__ = ["ALL_FIELDS", "DICT_STR_FIELDS", "LIST_STR_FIELDS", "STR_FIELDS"]


# Name is not dynamically settable, so not in this list
STR_FIELDS = frozenset(
[
"version",
"description",
"requires-python",
"license",
]
)

# Dynamic is not dynamically settable, so not in this list
LIST_STR_FIELDS = frozenset(
[
"classifiers",
"keywords",
"dependencies",
"license_files",
]
)


DICT_STR_FIELDS = frozenset(
[
"urls",
"authors",
"maintainers",
]
)


# "dynamic" and "name" can't be set or requested
ALL_FIELDS = (
STR_FIELDS
| LIST_STR_FIELDS
| DICT_STR_FIELDS
| frozenset(
[
"optional-dependencies",
"readme",
]
)
)
105 changes: 90 additions & 15 deletions src/dynamic_metadata/loader.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,55 @@
from __future__ import annotations

import dataclasses
import importlib
import sys
from collections.abc import Generator, Iterable, Mapping
from collections.abc import Iterator, Mapping
from pathlib import Path
from typing import Any, Protocol, Union
from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable

__all__ = ["load_dynamic_metadata", "load_provider"]
from .info import ALL_FIELDS

if TYPE_CHECKING:
from collections.abc import Generator, Iterable

StrMapping = Mapping[str, Any]
else:
StrMapping = Mapping


__all__ = ["load_dynamic_metadata", "load_provider", "process_dynamic_metadata"]


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


@runtime_checkable
class DynamicMetadataProtocol(Protocol):
def dynamic_metadata(
self, fields: Iterable[str], settings: dict[str, Any]
self,
fields: Iterable[str],
settings: dict[str, Any],
project: Mapping[str, Any],
) -> dict[str, Any]: ...


@runtime_checkable
class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol):
def get_requires_for_dynamic_metadata(
self, settings: dict[str, Any]
) -> list[str]: ...


@runtime_checkable
class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
def dynamic_wheel(
self, field: str, settings: Mapping[str, Any] | None = None
) -> bool: ...


class DynamicMetadataRequirementsWheelProtocol(
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
): ...
def dynamic_wheel(self, field: str, settings: Mapping[str, Any]) -> bool: ...


DMProtocols = Union[
DynamicMetadataProtocol,
DynamicMetadataRequirementsProtocol,
DynamicMetadataWheelProtocol,
DynamicMetadataRequirementsWheelProtocol,
]


Expand All @@ -63,13 +72,79 @@ def load_provider(


def load_dynamic_metadata(
metadata: Mapping[str, Mapping[str, str]],
metadata: Mapping[str, Mapping[str, Any]],
) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
for field, orig_config in metadata.items():
if "provider" in orig_config:
if field not in ALL_FIELDS:
msg = f"{field} is not a valid field"
raise KeyError(msg)
config = dict(orig_config)
provider = config.pop("provider")
provider_path = config.pop("provider-path", None)
yield field, load_provider(provider, provider_path), config
loaded_provider = load_provider(provider, provider_path)
yield field, loaded_provider, config
else:
yield field, None, dict(orig_config)


@dataclasses.dataclass
class DynamicPyProject(StrMapping):
settings: dict[str, dict[str, Any]]
project: dict[str, Any]
providers: dict[str, DMProtocols]

def __getitem__(self, key: str) -> Any:
# Try to get the settings from either the static file or dynamic metadata provider
if key in self.project:
return self.project[key]

# Check if we are in a loop, i.e. something else is already requesting
# this key while trying to get another key
if key not in self.providers:
dep_type = "missing" if key in self.settings else "circular"
msg = f"Encountered a {dep_type} dependency at {key}"
raise ValueError(msg)

provider = self.providers.pop(key)
self.project[key] = provider.dynamic_metadata(key, self.settings[key], self)
self.project["dynamic"].remove(key)

return self.project[key]

def __iter__(self) -> Iterator[str]:
# Iterate over the keys of the static settings
yield from [*self.project.keys(), *self.providers.keys()]

def __len__(self) -> int:
return len(self.project) + len(self.providers)

def __contains__(self, key: object) -> bool:
return key in self.project or key in self.providers


def process_dynamic_metadata(
project: Mapping[str, Any],
metadata: Mapping[str, Mapping[str, Any]],
) -> dict[str, Any]:
"""Process dynamic metadata.

This function loads the dynamic metadata providers and calls them to
generate the dynamic metadata. It takes the original project table and
returns a new project table. Empty providers are not supported; you
need to implement this yourself for now if you support that.
"""

initial = {f: (p, s) for (f, p, s) in load_dynamic_metadata(metadata)}
for f, (p, _) in initial.items():
if p is None:
msg = f"{f} does not have a provider"
raise KeyError(msg)

settings = DynamicPyProject(
settings={f: s for f, (p, s) in initial.items() if p is not None},
project=dict(project),
providers={k: p for k, (p, _) in initial.items() if p is not None},
)

return dict(settings)
42 changes: 42 additions & 0 deletions src/dynamic_metadata/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import typing
from collections.abc import Callable

from ..info import DICT_STR_FIELDS, LIST_STR_FIELDS, STR_FIELDS

T = typing.TypeVar("T", bound="str | list[str] | dict[str, str] | dict[str, list[str]]")


def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T:
"""
Helper function for processing the an action on the various possible metadata fields.
"""

if field in STR_FIELDS:
if not isinstance(result, str):
msg = f"Field {field!r} must be a string"
raise RuntimeError(msg)
return action(result) # type: ignore[return-value]
if field in LIST_STR_FIELDS:
if not (isinstance(result, list) and all(isinstance(r, str) for r in result)):
msg = f"Field {field!r} must be a list of strings"
raise RuntimeError(msg)
return [action(r) for r in result] # type: ignore[return-value]
if field in DICT_STR_FIELDS | {"readme"}:
if not isinstance(result, dict) or not all(
isinstance(v, str) for v in result.values()
):
msg = f"Field {field!r} must be a dictionary of strings"
raise RuntimeError(msg)
return {k: action(v) for k, v in result.items()} # type: ignore[return-value]
if field == "optional-dependencies":
if not isinstance(result, dict) or not all(
isinstance(v, list) for v in result.values()
):
msg = "Field 'optional-dependencies' must be a dictionary of lists"
raise RuntimeError(msg)
return {k: [action(r) for r in v] for k, v in result.items()} # type: ignore[return-value]

msg = f"Unsupported field {field!r} for action"
raise RuntimeError(msg)
Loading
Loading