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 .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ repos:
files: src|tests
args: []
additional_dependencies:
- hatch-fancy-pypi-readme
- importlib_resources
- pytest
- tomli

- repo: https://github.com/codespell-project/codespell
rev: "v2.2.5"
Expand Down
165 changes: 165 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,171 @@
This repo is to support
https://github.com/scikit-build/scikit-build-core/issues/230.

> [!WARNING]
>
> This plugin is still a WiP!

## 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>"
```

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.

### Example: regex

An example regex plugin is provided in this package. It is used like this:

```toml
[build-system]
requires = ["...", "dynamic-metadata"]
build-backend = "..."

[project]
dynamic = ["version"]

[tool.dynamic-metadata.version]
provider = "dynamic_metadata.plugins.regex"
input = "src/my_package/__init__.py"
regex = '(?i)^(__version__|VERSION) *= *([\'"])v?(?P<value>.+?)\2'
```

In this case, since the plugin lives inside `dynamic-metadata`, you have to
include that in your requirements. Make sure the version is marked dynamic in
your project table. And then you specify `version.provider`. The other options
are defined by the plugin; this one takes a required `input` file and an
optional `regex` (which defaults to the expression you see above). The regex
optional `regex` (which defaults to the expression you see above). The regex
needs to have a `"value"` named group (`?P<value>`), which it will set.

## For plugin authors

**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.

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
```

The backend will call this hook in the same directory as PEP 517's hooks.

There are two optional hooks.

A plugin can return METADATA 2.2 dynamic status:

```python
def dynamic_wheel(field: str, settings: Mapping[str, Any] | None = None) -> bool:
... # Return true if metadata can change from SDist to wheel (METADATA 2.2 feature)
```

If this hook is not implemented, it will default to "false". Note that "version"
must always return "false". This hook is called after the main hook, so you do
not need to validate the input here.

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,
) -> list[str]:
... # return list of packages to require
```

This is mostly used to provide wrappers for existing non-compatible plugins and
for plugins that require a CLI tool that has an optional compiled component.

### Example: regex

Here is the regex plugin example implementation:

```python
def dynamic_metadata(
field: str,
settings: Mapping[str, Any],
) -> str:
# Input validation
if field not in {"version", "description", "requires-python"}:
raise RuntimeError("Only string feilds supported by this plugin")
if settings > {"input", "regex"}:
raise RuntimeError("Only 'input' and 'regex' settings allowed by this plugin")
if "input" not in settings:
raise RuntimeError("Must contain the 'input' setting to perform a regex on")
if not all(isinstance(x, str) for x in settings.values()):
raise RuntimeError("Must set 'input' and/or 'regex' to strings")

input = settings["input"]
# If not explicitly specified in the `tool.dynamic-metadata.<field-name>` table,
# the default regex provided below is used.
regex = settings.get(
"regex", r'(?i)^(__version__|VERSION) *= *([\'"])v?(?P<value>.+?)\2'
)

with Path(input).open(encoding="utf-8") as f:
match = re.search(regex, f.read())

if not match:
raise RuntimeError(f"Couldn't find {regex!r} in {file}")

return match.groups("value")
```

## For backend authors

**You do not need to depend on dynamic-metadata to support plugins.** This
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
```

<!-- prettier-ignore-start -->
[actions-badge]: https://github.com/scikit-build/dynamic-metadata/workflows/CI/badge.svg
[actions-link]: https://github.com/scikit-build/dynamic-metadata/actions
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def pylint(session: nox.Session) -> None:
"""
# This needs to be installed into the package environment, and is slower
# than a pre-commit check
session.install(".", "pylint")
session.install(".", "pylint", "setuptools_scm", "hatch-fancy-pypi-readme")
session.run("pylint", "src", *session.posargs)


Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ envs.default.dependencies = [
"pytest-cov",
]

[project.entry-points."validate_pyproject.tool_schema"]
dynamic-metadata = "dynamic_metadata.schema:get_schema"

[tool.pytest.ini_options]
minversion = "6.0"
Expand Down Expand Up @@ -102,6 +104,10 @@ module = "dynamic_metadata.*"
disallow_untyped_defs = true
disallow_incomplete_defs = true

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


[tool.ruff]
select = [
Expand Down Expand Up @@ -159,4 +165,8 @@ messages_control.disable = [
"line-too-long",
"missing-module-docstring",
"wrong-import-position",
"missing-class-docstring",
"missing-function-docstring",
"import-outside-toplevel",
"invalid-name",
]
14 changes: 14 additions & 0 deletions src/dynamic_metadata/_compat/tomllib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import sys

if sys.version_info < (3, 11):
from tomli import load
else:
from tomllib import load

__all__ = ["load"]


def __dir__() -> list[str]:
return __all__
13 changes: 4 additions & 9 deletions src/dynamic_metadata/_compat/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,12 @@

import sys

if sys.version_info < (3, 10):
from typing_extensions import TypeAlias
if sys.version_info < (3, 8):
from typing_extensions import Protocol
else:
from typing import TypeAlias
from typing import Protocol

if sys.version_info < (3, 11):
from typing_extensions import Self, assert_never
else:
from typing import Self, assert_never

__all__ = ["TypeAlias", "Self", "assert_never"]
__all__ = ["Protocol"]


def __dir__() -> list[str]:
Expand Down
79 changes: 79 additions & 0 deletions src/dynamic_metadata/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from __future__ import annotations

import importlib
import sys
from collections.abc import Generator, Iterable, Mapping
from pathlib import Path
from typing import Any, Union

from ._compat.typing import Protocol

__all__ = ["load_provider", "load_dynamic_metadata"]


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


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


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


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


class DynamicMetadataRequirementsWheelProtocol(
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
):
...


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


def load_provider(
provider: str,
provider_path: str | None = None,
) -> DMProtocols:
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)


def load_dynamic_metadata(
metadata: Mapping[str, Mapping[str, str]]
) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
for field, orig_config in metadata.items():
if "provider" in orig_config:
config = dict(orig_config)
provider = config.pop("provider")
provider_path = config.pop("provider-path", None)
yield field, load_provider(provider, provider_path), config
else:
yield field, None, dict(orig_config)
58 changes: 58 additions & 0 deletions src/dynamic_metadata/plugins/fancy_pypi_readme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

from pathlib import Path

from .._compat import tomllib

__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"]


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


def dynamic_metadata(
field: str,
settings: dict[str, list[str] | str] | None = None,
) -> dict[str, str | None]:
from hatch_fancy_pypi_readme._builder import build_text
from hatch_fancy_pypi_readme._config import load_and_validate_config

if field != "readme":
msg = "Only the 'readme' field is supported"
raise ValueError(msg)

if settings:
msg = "No inline configuration is supported"
raise ValueError(msg)

with Path("pyproject.toml").open("rb") as f:
pyproject_dict = tomllib.load(f)

config = load_and_validate_config(
pyproject_dict["tool"]["hatch"]["metadata"]["hooks"]["fancy-pypi-readme"]
)

if hasattr(config, "substitutions"):
try:
# We don't have access to the version at this point
text = build_text(config.fragments, config.substitutions, "")
except TypeError:
# Version 23.2.0 and before don't have a version field
# pylint: disable-next=no-value-for-parameter
text = build_text(config.fragments, config.substitutions)
else:
# Version 22.3 does not have fragment support
# pylint: disable-next=no-value-for-parameter
text = build_text(config.fragments) # type: ignore[call-arg]

return {
"content-type": config.content_type,
"text": text,
}


def get_requires_for_dynamic_metadata(
_settings: dict[str, object] | None = None,
) -> list[str]:
return ["hatch-fancy-pypi-readme>=22.3"]
Loading