Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: first draft #1

Merged
merged 9 commits into from
Mar 1, 2024
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
henryiii marked this conversation as resolved.
Show resolved Hide resolved
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(
henryiii marked this conversation as resolved.
Show resolved Hide resolved
"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

Check warning on line 1 in src/dynamic_metadata/_compat/tomllib.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/tomllib.py#L1

Added line #L1 was not covered by tests

import sys

Check warning on line 3 in src/dynamic_metadata/_compat/tomllib.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/tomllib.py#L3

Added line #L3 was not covered by tests

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

Check warning on line 6 in src/dynamic_metadata/_compat/tomllib.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/tomllib.py#L5-L6

Added lines #L5 - L6 were not covered by tests
else:
from tomllib import load

Check warning on line 8 in src/dynamic_metadata/_compat/tomllib.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/tomllib.py#L8

Added line #L8 was not covered by tests

__all__ = ["load"]

Check warning on line 10 in src/dynamic_metadata/_compat/tomllib.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/tomllib.py#L10

Added line #L10 was not covered by tests


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

Check warning on line 14 in src/dynamic_metadata/_compat/tomllib.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/tomllib.py#L13-L14

Added lines #L13 - L14 were not covered by tests
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

Check warning on line 13 in src/dynamic_metadata/_compat/typing.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/typing.py#L12-L13

Added lines #L12 - L13 were not covered by tests
else:
from typing import TypeAlias
from typing import Protocol

Check warning on line 15 in src/dynamic_metadata/_compat/typing.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/typing.py#L15

Added line #L15 was not covered by tests

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"]

Check warning on line 17 in src/dynamic_metadata/_compat/typing.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/_compat/typing.py#L17

Added line #L17 was not covered by tests


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

Check warning on line 1 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L1

Added line #L1 was not covered by tests

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

Check warning on line 7 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L3-L7

Added lines #L3 - L7 were not covered by tests

from ._compat.typing import Protocol

Check warning on line 9 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L9

Added line #L9 was not covered by tests

__all__ = ["load_provider", "load_dynamic_metadata"]

Check warning on line 11 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L11

Added line #L11 was not covered by tests


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

Check warning on line 15 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L14-L15

Added lines #L14 - L15 were not covered by tests


class DynamicMetadataProtocol(Protocol):
def dynamic_metadata(

Check warning on line 19 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L18-L19

Added lines #L18 - L19 were not covered by tests
self, fields: Iterable[str], settings: dict[str, Any]
) -> dict[str, Any]:
...

Check warning on line 22 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L22

Added line #L22 was not covered by tests


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

Check warning on line 27 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L25-L27

Added lines #L25 - L27 were not covered by tests


class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol):
def dynamic_wheel(

Check warning on line 31 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L30-L31

Added lines #L30 - L31 were not covered by tests
self, field: str, settings: Mapping[str, Any] | None = None
) -> bool:
...

Check warning on line 34 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L34

Added line #L34 was not covered by tests


class DynamicMetadataRequirementsWheelProtocol(

Check warning on line 37 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L37

Added line #L37 was not covered by tests
DynamicMetadataRequirementsProtocol, DynamicMetadataWheelProtocol, Protocol
):
...

Check warning on line 40 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L40

Added line #L40 was not covered by tests


DMProtocols = Union[

Check warning on line 43 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L43

Added line #L43 was not covered by tests
DynamicMetadataProtocol,
DynamicMetadataRequirementsProtocol,
DynamicMetadataWheelProtocol,
DynamicMetadataRequirementsWheelProtocol,
]


def load_provider(

Check warning on line 51 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L51

Added line #L51 was not covered by tests
provider: str,
provider_path: str | None = None,
) -> DMProtocols:
if provider_path is None:
return importlib.import_module(provider)

Check warning on line 56 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L55-L56

Added lines #L55 - L56 were not covered by tests

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

Check warning on line 60 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L58-L60

Added lines #L58 - L60 were not covered by tests

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

Check warning on line 64 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L62-L64

Added lines #L62 - L64 were not covered by tests
finally:
sys.path.pop(0)

Check warning on line 66 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L66

Added line #L66 was not covered by tests


def load_dynamic_metadata(

Check warning on line 69 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L69

Added line #L69 was not covered by tests
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

Check warning on line 77 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L72-L77

Added lines #L72 - L77 were not covered by tests
else:
yield field, None, dict(orig_config)

Check warning on line 79 in src/dynamic_metadata/loader.py

View check run for this annotation

Codecov / codecov/patch

src/dynamic_metadata/loader.py#L79

Added line #L79 was not covered by tests
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
Loading