From d746f2b572e2e2724336bcd87b7fc5fb462a955d Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 19 Apr 2025 00:20:11 -0400 Subject: [PATCH 1/4] feat: needs Signed-off-by: Henry Schreiner --- README.md | 7 +-- pyproject.toml | 5 +- src/dynamic_metadata/info.py | 47 +++++++++++++++ src/dynamic_metadata/loader.py | 58 +++++++++++++++---- src/dynamic_metadata/plugins/__init__.py | 42 ++++++++++++++ .../plugins/fancy_pypi_readme.py | 22 +++++-- src/dynamic_metadata/plugins/regex.py | 45 ++++++++++---- .../plugins/setuptools_scm.py | 3 +- src/dynamic_metadata/plugins/template.py | 46 +++++++++++++++ 9 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 src/dynamic_metadata/info.py create mode 100644 src/dynamic_metadata/plugins/__init__.py create mode 100644 src/dynamic_metadata/plugins/template.py diff --git a/README.md b/README.md index e3b89ff..58a566a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 62b0bf9..1b6b426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,12 +25,14 @@ 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", ] dynamic = ["version"] dependencies = [ "typing_extensions >=4.6; python_version<'3.11'", + "graphlib_backport >=1; python_version<'3.9'", ] [project.urls] @@ -99,7 +101,7 @@ disallow_untyped_defs = true disallow_incomplete_defs = true [[tool.mypy.overrides]] -module = "setuptools_scm" +module = ["setuptools_scm", "graphlib"] ignore_missing_imports = true @@ -151,4 +153,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..cf8b285 100644 --- a/src/dynamic_metadata/loader.py +++ b/src/dynamic_metadata/loader.py @@ -4,7 +4,11 @@ import sys from collections.abc import Generator, Iterable, Mapping from pathlib import Path -from typing import Any, Protocol, Union +from typing import Any, Protocol, Union, runtime_checkable + +from graphlib import TopologicalSorter + +from .info import ALL_FIELDS __all__ = ["load_dynamic_metadata", "load_provider"] @@ -13,34 +17,41 @@ 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], 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, ] @@ -62,14 +73,41 @@ def load_provider( 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: + 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) + needs = frozenset( + loaded_provider.dynamic_metadata_needs(field, config) + if isinstance(loaded_provider, DynamicMetadataNeeds) + else [] + ) + if needs > ALL_FIELDS: + msg = f"Invalid dyanmic_metada_needs: {needs - ALL_FIELDS}" + raise KeyError(msg) + yield field, loaded_provider, config, needs else: - yield field, None, dict(orig_config) + yield field, None, dict(orig_config), frozenset() + + +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)} + + dynamic_fields = initial.keys() + sorter = TopologicalSorter( + {f: n & dynamic_fields for f, (_, _, n) in initial.items()} + ) + order = sorter.static_order() + return [(f, *initial[f][:2]) for f in order] diff --git a/src/dynamic_metadata/plugins/__init__.py b/src/dynamic_metadata/plugins/__init__.py new file mode 100644 index 0000000..bf93315 --- /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: + 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..93a7b71 100644 --- a/src/dynamic_metadata/plugins/fancy_pypi_readme.py +++ b/src/dynamic_metadata/plugins/fancy_pypi_readme.py @@ -1,10 +1,15 @@ 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", + "dynamic_requires_needs", + "get_requires_for_dynamic_metadata", +] def __dir__() -> list[str]: @@ -13,7 +18,8 @@ def __dir__() -> list[str]: def dynamic_metadata( field: str, - settings: dict[str, list[str] | str] | None = None, + settings: dict[str, list[str] | str], + metadata: 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 +41,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, metadata["version"] + ) except TypeError: # Version 23.2.0 and before don't have a version field # pylint: disable-next=no-value-for-parameter @@ -56,3 +63,10 @@ def get_requires_for_dynamic_metadata( _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"] diff --git a/src/dynamic_metadata/plugins/regex.py b/src/dynamic_metadata/plugins/regex.py index 7fcdb60..84e3d6c 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], + _metadata: 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..2a72b26 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], + _metadata: 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..8ac5b45 --- /dev/null +++ b/src/dynamic_metadata/plugins/template.py @@ -0,0 +1,46 @@ +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", "dynamic_metadata_needs"] + + +def __dir__() -> list[str]: + return __all__ + + +KEYS = {"needs", "result"} + + +def dynamic_metadata( + field: str, + settings: Mapping[str, str | list[str] | dict[str, str] | dict[str, list[str]]], + metadata: 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(**metadata), + result, + ) + + +def dynamic_metadata_needs( + field: str, # noqa: ARG001 + settings: Mapping[str, Any], +) -> list[str]: + return settings.get("needs", []) From 5b2eaaad473f9d533f5b1f9bb1d2664558e55c51 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 24 Apr 2025 11:25:06 -0400 Subject: [PATCH 2/4] feat: auto needs Signed-off-by: Henry Schreiner --- pyproject.toml | 3 +- src/dynamic_metadata/loader.py | 117 ++++++++++++++++++++++----------- 2 files changed, 80 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b6b426..a2d5a98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ classifiers = [ dynamic = ["version"] dependencies = [ "typing_extensions >=4.6; python_version<'3.11'", - "graphlib_backport >=1; python_version<'3.9'", ] [project.urls] @@ -101,7 +100,7 @@ disallow_untyped_defs = true disallow_incomplete_defs = true [[tool.mypy.overrides]] -module = ["setuptools_scm", "graphlib"] +module = ["setuptools_scm"] ignore_missing_imports = true diff --git a/src/dynamic_metadata/loader.py b/src/dynamic_metadata/loader.py index cf8b285..95112c9 100644 --- a/src/dynamic_metadata/loader.py +++ b/src/dynamic_metadata/loader.py @@ -1,16 +1,23 @@ 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, runtime_checkable - -from graphlib import TopologicalSorter +from typing import TYPE_CHECKING, Any, Protocol, Union, runtime_checkable from .info import ALL_FIELDS -__all__ = ["load_dynamic_metadata", "load_provider"] +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]: @@ -38,20 +45,10 @@ def dynamic_wheel( ) -> bool: ... -@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, - DynamicMetadataNeeds, ] @@ -73,11 +70,9 @@ def load_provider( 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], frozenset[str]], None, None -]: +) -> 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: @@ -87,27 +82,73 @@ def _load_dynamic_metadata( provider = config.pop("provider") provider_path = config.pop("provider-path", None) loaded_provider = load_provider(provider, provider_path) - needs = frozenset( - loaded_provider.dynamic_metadata_needs(field, config) - if isinstance(loaded_provider, DynamicMetadataNeeds) - else [] - ) - if needs > ALL_FIELDS: - msg = f"Invalid dyanmic_metada_needs: {needs - ALL_FIELDS}" - raise KeyError(msg) - yield field, loaded_provider, config, needs + yield field, loaded_provider, config else: - yield field, None, dict(orig_config), frozenset() + yield field, None, dict(orig_config) -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)} +@dataclasses.dataclass +class DynamicPyProject(StrMapping): + settings: dict[str, dict[str, Any]] + project: dict[str, Any] + providers: dict[str, DMProtocols] - dynamic_fields = initial.keys() - sorter = TopologicalSorter( - {f: n & dynamic_fields for f, (_, _, n) in initial.items()} + 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.project + ) + 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 + + # Iterate over the keys of the dynamic metadata providers + yield from self.providers + + 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, str]], +) -> 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}, ) - order = sorter.static_order() - return [(f, *initial[f][:2]) for f in order] + + return dict(settings) From 0c7a678224e661f31f78614e2cd4e93bf5927853 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 24 Apr 2025 12:40:36 -0400 Subject: [PATCH 3/4] tests: add tests for regex and template Signed-off-by: Henry Schreiner --- README.md | 49 ++++---------- src/dynamic_metadata/loader.py | 22 +++--- src/dynamic_metadata/plugins/__init__.py | 2 +- .../plugins/fancy_pypi_readme.py | 12 +--- src/dynamic_metadata/plugins/regex.py | 2 +- .../plugins/setuptools_scm.py | 2 +- src/dynamic_metadata/plugins/template.py | 15 ++--- tests/test_package.py | 67 ++++++++++++++++++- 8 files changed, 94 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 58a566a..bbb00c0 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 @@ -64,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. @@ -84,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) @@ -98,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 ``` @@ -113,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"}: @@ -147,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/src/dynamic_metadata/loader.py b/src/dynamic_metadata/loader.py index 95112c9..b43f299 100644 --- a/src/dynamic_metadata/loader.py +++ b/src/dynamic_metadata/loader.py @@ -27,7 +27,10 @@ def __dir__() -> list[str]: @runtime_checkable class DynamicMetadataProtocol(Protocol): def dynamic_metadata( - self, fields: Iterable[str], settings: dict[str, Any], metadata: dict[str, Any] + self, + fields: Iterable[str], + settings: dict[str, Any], + project: Mapping[str, Any], ) -> dict[str, Any]: ... @@ -40,9 +43,7 @@ def get_requires_for_dynamic_metadata( @runtime_checkable class DynamicMetadataWheelProtocol(DynamicMetadataProtocol, Protocol): - def dynamic_wheel( - self, field: str, settings: Mapping[str, Any] | None = None - ) -> bool: ... + def dynamic_wheel(self, field: str, settings: Mapping[str, Any]) -> bool: ... DMProtocols = Union[ @@ -71,7 +72,7 @@ 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: @@ -106,19 +107,14 @@ def __getitem__(self, key: str) -> Any: raise ValueError(msg) provider = self.providers.pop(key) - self.project[key] = provider.dynamic_metadata( - key, self.settings[key], self.project - ) + 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 - - # Iterate over the keys of the dynamic metadata providers - yield from self.providers + yield from [*self.project.keys(), *self.providers.keys()] def __len__(self) -> int: return len(self.project) + len(self.providers) @@ -129,7 +125,7 @@ def __contains__(self, key: object) -> bool: def process_dynamic_metadata( project: Mapping[str, Any], - metadata: Mapping[str, Mapping[str, str]], + metadata: Mapping[str, Mapping[str, Any]], ) -> dict[str, Any]: """Process dynamic metadata. diff --git a/src/dynamic_metadata/plugins/__init__.py b/src/dynamic_metadata/plugins/__init__.py index bf93315..d654f9c 100644 --- a/src/dynamic_metadata/plugins/__init__.py +++ b/src/dynamic_metadata/plugins/__init__.py @@ -23,7 +23,7 @@ def _process_dynamic_metadata(field: str, action: Callable[[str], str], 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: + if field in DICT_STR_FIELDS | {"readme"}: if not isinstance(result, dict) or not all( isinstance(v, str) for v in result.values() ): diff --git a/src/dynamic_metadata/plugins/fancy_pypi_readme.py b/src/dynamic_metadata/plugins/fancy_pypi_readme.py index 93a7b71..2116175 100644 --- a/src/dynamic_metadata/plugins/fancy_pypi_readme.py +++ b/src/dynamic_metadata/plugins/fancy_pypi_readme.py @@ -7,7 +7,6 @@ __all__ = [ "dynamic_metadata", - "dynamic_requires_needs", "get_requires_for_dynamic_metadata", ] @@ -19,7 +18,7 @@ def __dir__() -> list[str]: def dynamic_metadata( field: str, settings: dict[str, list[str] | str], - metadata: Mapping[str, Any], + 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 @@ -42,7 +41,7 @@ def dynamic_metadata( if hasattr(config, "substitutions"): try: text = build_text( - config.fragments, config.substitutions, metadata["version"] + config.fragments, config.substitutions, project["version"] ) except TypeError: # Version 23.2.0 and before don't have a version field @@ -63,10 +62,3 @@ def get_requires_for_dynamic_metadata( _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"] diff --git a/src/dynamic_metadata/plugins/regex.py b/src/dynamic_metadata/plugins/regex.py index 84e3d6c..81f190b 100644 --- a/src/dynamic_metadata/plugins/regex.py +++ b/src/dynamic_metadata/plugins/regex.py @@ -30,7 +30,7 @@ def _process(match: re.Match[str], remove: str, result: str) -> str: def dynamic_metadata( field: str, settings: Mapping[str, Any], - _metadata: Mapping[str, Any], + _project: Mapping[str, Any], ) -> str: # Input validation if settings.keys() > KEYS: diff --git a/src/dynamic_metadata/plugins/setuptools_scm.py b/src/dynamic_metadata/plugins/setuptools_scm.py index 2a72b26..09acca1 100644 --- a/src/dynamic_metadata/plugins/setuptools_scm.py +++ b/src/dynamic_metadata/plugins/setuptools_scm.py @@ -10,7 +10,7 @@ def __dir__() -> list[str]: def dynamic_metadata( field: str, settings: dict[str, object], - _metadata: 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 index 8ac5b45..749d1b5 100644 --- a/src/dynamic_metadata/plugins/template.py +++ b/src/dynamic_metadata/plugins/template.py @@ -7,20 +7,20 @@ from . import _process_dynamic_metadata -__all__ = ["dynamic_metadata", "dynamic_metadata_needs"] +__all__ = ["dynamic_metadata"] def __dir__() -> list[str]: return __all__ -KEYS = {"needs", "result"} +KEYS = {"result"} def dynamic_metadata( field: str, settings: Mapping[str, str | list[str] | dict[str, str] | dict[str, list[str]]], - metadata: Mapping[str, Any], + 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" @@ -34,13 +34,6 @@ def dynamic_metadata( return _process_dynamic_metadata( field, - lambda r: r.format(**metadata), + lambda r: r.format(project=project), result, ) - - -def dynamic_metadata_needs( - field: str, # noqa: ARG001 - settings: Mapping[str, Any], -) -> list[str]: - return settings.get("needs", []) 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" From c0f47011a18ebfd5a2072696857bb13ac80ad208 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 24 Apr 2025 17:09:52 +0000 Subject: [PATCH 4/4] style: pre-commit fixes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bbb00c0..62d2676 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ 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, along with a reference implementation that you can either use as -an example, or use directly if you are fine to require the dependency. +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: @@ -150,8 +150,8 @@ 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 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`. +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