diff --git a/README.md b/README.md index e3b89ff..62d2676 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 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 @@ -23,8 +23,8 @@ Every external plugin must specify a "provider", which is a module that provides the API listed in the next section. ```toml -[tool.dynamic-metadata] -.provider = "" +[tool.dynamic-metadata.] +provider = "" ``` There is an optional field: "provider-path", which specifies a local path to @@ -32,8 +32,7 @@ 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 @@ -65,16 +64,16 @@ needs to have a `"value"` named group (`?P`), 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. @@ -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) @@ -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 ``` @@ -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"}: @@ -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`. [actions-badge]: https://github.com/scikit-build/dynamic-metadata/workflows/CI/badge.svg diff --git a/pyproject.toml b/pyproject.toml index 62b0bf9..a2d5a98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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 @@ -151,4 +152,5 @@ messages_control.disable = [ "missing-function-docstring", "import-outside-toplevel", "invalid-name", + "unused-argument", # Ruff ] diff --git a/src/dynamic_metadata/info.py b/src/dynamic_metadata/info.py new file mode 100644 index 0000000..a54c199 --- /dev/null +++ b/src/dynamic_metadata/info.py @@ -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", + ] + ) +) diff --git a/src/dynamic_metadata/loader.py b/src/dynamic_metadata/loader.py index c7cddd0..b43f299 100644 --- a/src/dynamic_metadata/loader.py +++ b/src/dynamic_metadata/loader.py @@ -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, ] @@ -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) diff --git a/src/dynamic_metadata/plugins/__init__.py b/src/dynamic_metadata/plugins/__init__.py new file mode 100644 index 0000000..d654f9c --- /dev/null +++ b/src/dynamic_metadata/plugins/__init__.py @@ -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) diff --git a/src/dynamic_metadata/plugins/fancy_pypi_readme.py b/src/dynamic_metadata/plugins/fancy_pypi_readme.py index cbb9f32..2116175 100644 --- a/src/dynamic_metadata/plugins/fancy_pypi_readme.py +++ b/src/dynamic_metadata/plugins/fancy_pypi_readme.py @@ -1,10 +1,14 @@ from __future__ import annotations from pathlib import Path +from typing import Any, Mapping from .._compat import tomllib -__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"] +__all__ = [ + "dynamic_metadata", + "get_requires_for_dynamic_metadata", +] def __dir__() -> list[str]: @@ -13,7 +17,8 @@ def __dir__() -> list[str]: def dynamic_metadata( field: str, - settings: dict[str, list[str] | str] | None = None, + settings: dict[str, list[str] | str], + project: Mapping[str, Any], ) -> dict[str, str | None]: from hatch_fancy_pypi_readme._builder import build_text from hatch_fancy_pypi_readme._config import load_and_validate_config @@ -35,8 +40,9 @@ def dynamic_metadata( 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, project["version"] + ) except TypeError: # Version 23.2.0 and before don't have a version field # pylint: disable-next=no-value-for-parameter diff --git a/src/dynamic_metadata/plugins/regex.py b/src/dynamic_metadata/plugins/regex.py index 7fcdb60..81f190b 100644 --- a/src/dynamic_metadata/plugins/regex.py +++ b/src/dynamic_metadata/plugins/regex.py @@ -1,9 +1,14 @@ from __future__ import annotations +import functools import re -from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +from . import _process_dynamic_metadata + +if TYPE_CHECKING: + from collections.abc import Mapping __all__ = ["dynamic_metadata"] @@ -12,28 +17,44 @@ def __dir__() -> list[str]: return __all__ +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], + _project: 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() > {"input", "regex"}: - msg = "Only 'input' and 'regex' settings allowed by this plugin" + if settings.keys() > KEYS: + msg = f"Only {KEYS} settings allowed by this plugin" raise RuntimeError(msg) if "input" not in settings: msg = "Must contain the 'input' setting to perform a regex on" raise RuntimeError(msg) - if not all(isinstance(x, str) for x in settings.values()): - msg = "Must set 'input' and/or 'regex' to strings" + if field != "version" and "regex" not in settings: + msg = "Must contain the 'regex' setting if not getting version" raise RuntimeError(msg) + for key in KEYS: + if key in settings and not isinstance(settings[key], str): + msg = f"Setting {key!r} must be a string" + raise RuntimeError(msg) input_filename = settings["input"] regex = settings.get( - "regex", r'(?i)^(__version__|VERSION) *= *([\'"])v?(?P.+?)\2' + "regex", + r'(?i)^(__version__|VERSION)(?: ?\: ?str)? *= *([\'"])v?(?P.+?)\2', ) + result = settings.get("result", "{value}") + assert isinstance(result, str) + remove = settings.get("remove", "") with Path(input_filename).open(encoding="utf-8") as f: match = re.search(regex, f.read(), re.MULTILINE) @@ -42,4 +63,6 @@ def dynamic_metadata( msg = f"Couldn't find {regex!r} in {input_filename}" raise RuntimeError(msg) - return match.group("value") + return _process_dynamic_metadata( + field, functools.partial(_process, match, remove), result + ) diff --git a/src/dynamic_metadata/plugins/setuptools_scm.py b/src/dynamic_metadata/plugins/setuptools_scm.py index 25b158a..09acca1 100644 --- a/src/dynamic_metadata/plugins/setuptools_scm.py +++ b/src/dynamic_metadata/plugins/setuptools_scm.py @@ -9,7 +9,8 @@ def __dir__() -> list[str]: def dynamic_metadata( field: str, - settings: dict[str, object] | None = None, + settings: dict[str, object], + _project: dict[str, object], ) -> str: # this is a classic implementation, waiting for the release of # vcs-versioning and an improved public interface diff --git a/src/dynamic_metadata/plugins/template.py b/src/dynamic_metadata/plugins/template.py new file mode 100644 index 0000000..749d1b5 --- /dev/null +++ b/src/dynamic_metadata/plugins/template.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +from . import _process_dynamic_metadata + +__all__ = ["dynamic_metadata"] + + +def __dir__() -> list[str]: + return __all__ + + +KEYS = {"result"} + + +def dynamic_metadata( + field: str, + settings: Mapping[str, str | list[str] | dict[str, str] | dict[str, list[str]]], + project: Mapping[str, Any], +) -> str | list[str] | dict[str, str] | dict[str, list[str]]: + if settings.keys() > KEYS: + msg = f"Only {KEYS} settings allowed by this plugin" + raise RuntimeError(msg) + + if "result" not in settings: + msg = "Must contain the 'result' setting with a template substitution" + raise RuntimeError(msg) + + result = settings["result"] + + return _process_dynamic_metadata( + field, + lambda r: r.format(project=project), + result, + ) diff --git a/tests/test_package.py b/tests/test_package.py index 15aede8..e51e065 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,7 +1,68 @@ from __future__ import annotations -import dynamic_metadata as m +import dynamic_metadata.loader -def test_version(): - assert m.__version__ +def test_template_basic() -> None: + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + { + "name": "test", + "version": "0.1.0", + "dynamic": ["requires-python"], + }, + { + "requires-python": { + "provider": "dynamic_metadata.plugins.template", + "result": ">={project[version]}", + }, + }, + ) + + assert pyproject["requires-python"] == ">=0.1.0" + + +def test_template_needs() -> None: + # These are intentionally out of order to test the order of processing + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + { + "name": "test", + "version": "0.1.0", + "dynamic": ["requires-python", "license", "readme"], + }, + { + "license": { + "provider": "dynamic_metadata.plugins.template", + "result": "{project[requires-python]}", + }, + "readme": { + "provider": "dynamic_metadata.plugins.template", + "result": {"file": "{project[license]}"}, + }, + "requires-python": { + "provider": "dynamic_metadata.plugins.template", + "result": ">={project[version]}", + }, + }, + ) + + assert pyproject["requires-python"] == ">=0.1.0" + + +def test_regex() -> None: + pyproject = dynamic_metadata.loader.process_dynamic_metadata( + { + "name": "test", + "version": "0.1.0", + "dynamic": ["requires-python"], + }, + { + "requires-python": { + "provider": "dynamic_metadata.plugins.regex", + "input": "pyproject.toml", + "regex": r"name = \"(?P.+)\"", + "result": ">={name}", + }, + }, + ) + + assert pyproject["requires-python"] == ">=dynamic-metadata"