Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions docs/api/scikit_build_core.metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ scikit\_build\_core.metadata.setuptools\_scm module
:members:
:show-inheritance:
:undoc-members:

scikit\_build\_core.metadata.template module
--------------------------------------------

.. automodule:: scikit_build_core.metadata.template
:members:
:show-inheritance:
:undoc-members:
27 changes: 26 additions & 1 deletion docs/configuration/dynamic.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dynamic_version()
project(MyPackage VERSION ${PROJECT_VERSION})
```

## `version`: Regex
## Regex

If you want to pull a string-valued expression (usually version) from an
existing file, you can the integrated `regex` plugin to pull the information.
Expand Down Expand Up @@ -112,6 +112,31 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
# tool.hatch.metadata.hooks.fancy-pypi-readme options here
```

```{versionchanged} 0.11.2

The version number feature now works.
```

## Template

You can access other metadata fields and produce templated outputs.

```toml
[tool.scikit-build.metadata.optional-dependencies]
provider = "scikit_build_core.metadata.template"
needs = ["version"]
result = {"dev" = ["subpackage=={project.version}"]}
```

You can specify `needs` to ensure other metadata is computed before this one.
You can use `result` to specify the output. The result must match the type of
the metadata field you are writing to. You can use `project` to access the
current metadata values.

```{versionadded} 0.11.2

```

## `build-system.requires`: Scikit-build-core's `build.requires`

If you need to inject and manipulate additional `build-system.requires`, you can
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"pathspec >=0.10.1",
"tomli >=1.2.2; python_version<'3.11'",
"typing-extensions >=3.10.0; python_version<'3.9'",
"graphlib_backport >=1; python_version<'3.9'",
]
# Note: cmake and possibly ninja are also required if those are not already
# present (user controllable) - but a system version is fine.
Expand Down Expand Up @@ -182,7 +183,7 @@ disallow_untyped_defs = true
disallow_incomplete_defs = true

[[tool.mypy.overrides]]
module = ["numpy", "pathspec", "setuptools_scm", "hatch_fancy_pypi_readme", "virtualenv"]
module = ["numpy", "pathspec", "setuptools_scm", "hatch_fancy_pypi_readme", "virtualenv", "graphlib"]
ignore_missing_imports = true


Expand Down
16 changes: 13 additions & 3 deletions src/scikit_build_core/build/metadata.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import copy
import inspect
import sys
from typing import TYPE_CHECKING, Any

Expand All @@ -13,7 +14,7 @@
extras_build_system,
extras_top_level,
)
from ..settings._load_provider import load_dynamic_metadata
from ..builder._load_provider import load_dynamic_metadata

if TYPE_CHECKING:
from collections.abc import Mapping
Expand All @@ -34,7 +35,6 @@ def __dir__() -> list[str]:
errors.ExceptionGroup = ExceptionGroup # type: ignore[misc, assignment]


# If pyproject-metadata eventually supports updates, this can be simplified
def get_standard_metadata(
pyproject_dict: Mapping[str, Any],
settings: ScikitBuildSettings,
Expand All @@ -49,7 +49,17 @@ def get_standard_metadata(
if field not in pyproject_dict.get("project", {}).get("dynamic", []):
msg = f"{field} is not in project.dynamic"
raise KeyError(msg)
new_pyproject_dict["project"][field] = provider.dynamic_metadata(field, config)

sig = inspect.signature(provider.dynamic_metadata)
if len(sig.parameters) < 3:
# Backcompat for dynamic_metadata without metadata dict
new_pyproject_dict["project"][field] = provider.dynamic_metadata( # type: ignore[call-arg]
field, config
)
else:
new_pyproject_dict["project"][field] = provider.dynamic_metadata(
field, config, new_pyproject_dict["project"].copy()
)
new_pyproject_dict["project"]["dynamic"].remove(field)

if settings.strict_config:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import importlib
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, Union
from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable

from graphlib import TopologicalSorter

if TYPE_CHECKING:
from collections.abc import Generator, Iterable, Mapping
Expand All @@ -15,34 +17,41 @@
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], metadata: dict[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
): ...
@runtime_checkable
class DynamicMetadataNeeds(DynamicMetadataProtocol, Protocol):
def dynamic_metadata_needs(
self,
field: str,
settings: Mapping[str, object] | None = None,
) -> list[str]: ...


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


Expand All @@ -64,14 +73,31 @@
sys.path.pop(0)


def load_dynamic_metadata(
def _load_dynamic_metadata(
metadata: Mapping[str, Mapping[str, str]],
) -> Generator[tuple[str, DMProtocols | None, dict[str, str]], None, None]:
) -> Generator[
tuple[str, DMProtocols | None, dict[str, str], frozenset[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
loaded_provider = load_provider(provider, provider_path)
needs = frozenset(
loaded_provider.dynamic_metadata_needs(field, config)
if isinstance(loaded_provider, DynamicMetadataNeeds)
else []
)
yield field, loaded_provider, config, needs
else:
yield field, None, dict(orig_config)
yield field, None, dict(orig_config), frozenset()

Check warning on line 94 in src/scikit_build_core/builder/_load_provider.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/builder/_load_provider.py#L94

Added line #L94 was not covered by tests


def load_dynamic_metadata(
metadata: Mapping[str, Mapping[str, str]],
) -> list[tuple[str, DMProtocols | None, dict[str, str]]]:
initial = {f: (p, c, n) for (f, p, c, n) in _load_dynamic_metadata(metadata)}
sorter = TopologicalSorter({f: n for f, (_, _, n) in initial.items()})
order = sorter.static_order()
return [(f, *initial[f][:2]) for f in order]
2 changes: 1 addition & 1 deletion src/scikit_build_core/builder/get_requires.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
get_ninja_programs,
)
from ..resources import resources
from ..settings._load_provider import load_provider
from ..settings.skbuild_read_settings import SettingsReader
from ._load_provider import load_provider

if TYPE_CHECKING:
from collections.abc import Generator, Mapping
Expand Down
65 changes: 64 additions & 1 deletion src/scikit_build_core/metadata/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,66 @@
from __future__ import annotations

__all__: list[str] = []
import typing

if typing.TYPE_CHECKING:
from collections.abc import Callable


__all__: list[str] = ["_process_dynamic_metadata"]


# 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",
]
)

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)

Check warning on line 43 in src/scikit_build_core/metadata/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/__init__.py#L42-L43

Added lines #L42 - L43 were not covered by tests
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]

Check warning on line 49 in src/scikit_build_core/metadata/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/__init__.py#L46-L49

Added lines #L46 - L49 were not covered by tests
if field == "urls":
if not isinstance(result, dict) or not all(

Check warning on line 51 in src/scikit_build_core/metadata/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/__init__.py#L51

Added line #L51 was not covered by tests
isinstance(v, str) for v in result.values()
):
msg = "Field 'urls' must be a dictionary of strings"
raise RuntimeError(msg)
return {k: action(v) for k, v in result.items()} # type: ignore[return-value]

Check warning on line 56 in src/scikit_build_core/metadata/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/__init__.py#L54-L56

Added lines #L54 - L56 were not covered by tests
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)

Check warning on line 62 in src/scikit_build_core/metadata/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/__init__.py#L61-L62

Added lines #L61 - L62 were not covered by tests
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)

Check warning on line 66 in src/scikit_build_core/metadata/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/__init__.py#L65-L66

Added lines #L65 - L66 were not covered by tests
21 changes: 18 additions & 3 deletions src/scikit_build_core/metadata/fancy_pypi_readme.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from .._compat import tomllib

__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"]
__all__ = [
"dynamic_metadata",
"dynamic_requires_needs",
"get_requires_for_dynamic_metadata",
]


def __dir__() -> list[str]:
Expand All @@ -13,7 +18,8 @@

def dynamic_metadata(
field: str,
settings: dict[str, list[str] | str] | None = None,
settings: dict[str, list[str] | str],
metadata: dict[str, Any],
) -> str | dict[str, str]:
from hatch_fancy_pypi_readme._builder import build_text
from hatch_fancy_pypi_readme._config import load_and_validate_config
Expand All @@ -36,7 +42,9 @@
if hasattr(config, "substitutions"):
try:
# We don't have access to the version at this point
text = build_text(config.fragments, config.substitutions, "")
text = build_text(
config.fragments, config.substitutions, version=metadata["version"]
)
except TypeError:
# Version 23.2.0 and before don't have a version field
# pylint: disable-next=no-value-for-parameter
Expand All @@ -56,3 +64,10 @@
_settings: dict[str, object] | None = None,
) -> list[str]:
return ["hatch-fancy-pypi-readme>=22.3"]


def dynamic_requires_needs(
_field: str,
_settings: dict[str, object],
) -> list[str]:
return ["version"]

Check warning on line 73 in src/scikit_build_core/metadata/fancy_pypi_readme.py

View check run for this annotation

Codecov / codecov/patch

src/scikit_build_core/metadata/fancy_pypi_readme.py#L73

Added line #L73 was not covered by tests
20 changes: 13 additions & 7 deletions src/scikit_build_core/metadata/regex.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from __future__ import annotations

import functools
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any

from . import _process_dynamic_metadata

if TYPE_CHECKING:
from collections.abc import Mapping

Expand All @@ -17,14 +20,18 @@ def __dir__() -> list[str]:
KEYS = {"input", "regex", "result", "remove"}


def _process(match: re.Match[str], remove: str, result: str) -> str:
retval = result.format(*match.groups(), **match.groupdict())
if remove:
retval = re.sub(remove, "", retval)
return retval


def dynamic_metadata(
field: str,
settings: Mapping[str, Any],
) -> str:
# Input validation
if field not in {"version", "description", "requires-python"}:
msg = "Only string fields supported by this plugin"
raise RuntimeError(msg)
if settings.keys() > KEYS:
msg = f"Only {KEYS} settings allowed by this plugin"
raise RuntimeError(msg)
Expand Down Expand Up @@ -55,7 +62,6 @@ def dynamic_metadata(
msg = f"Couldn't find {regex!r} in {input_filename}"
raise RuntimeError(msg)

retval = result.format(*match.groups(), **match.groupdict())
if remove:
retval = re.sub(remove, "", retval)
return retval
return _process_dynamic_metadata(
field, functools.partial(_process, match, remove), result
)
Loading
Loading