From f4b610c8c65eee9c60eaad001cde3b9f779b5bc7 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 26 Feb 2025 10:57:14 -0500 Subject: [PATCH 01/11] refactor: entry-point collection changes Signed-off-by: Henry Schreiner --- src/validate_pyproject/plugins/__init__.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index 19ca2c14..d8a0421a 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -70,12 +70,13 @@ def __repr__(self) -> str: _: PluginProtocol = typing.cast(PluginWrapper, None) -def iterate_entry_points(group: str = ENTRYPOINT_GROUP) -> Iterable[EntryPoint]: +def iterate_entry_points(group: str) -> Iterable[EntryPoint]: """Produces a generator yielding an EntryPoint object for each plugin registered via ``setuptools`` `entry point`_ mechanism. This method can be used in conjunction with :obj:`load_from_entry_point` to filter - the plugins before actually loading them. + the plugins before actually loading them. The entry points are not + deduplicated, but they are sorted. """ entries = entry_points() if hasattr(entries, "select"): # pragma: no cover @@ -90,10 +91,7 @@ def iterate_entry_points(group: str = ENTRYPOINT_GROUP) -> Iterable[EntryPoint]: # TODO: Once Python 3.10 becomes the oldest version supported, this fallback and # conditional statement can be removed. entries_ = (plugin for plugin in entries.get(group, [])) - deduplicated = { - e.name: e for e in sorted(entries_, key=lambda e: (e.name, e.value)) - } - return list(deduplicated.values()) + return sorted(entries_, key=lambda e: e.name) def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper: @@ -106,22 +104,23 @@ def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper: def list_from_entry_points( - group: str = ENTRYPOINT_GROUP, filtering: Callable[[EntryPoint], bool] = lambda _: True, ) -> List[PluginWrapper]: """Produces a list of plugin objects for each plugin registered via ``setuptools`` `entry point`_ mechanism. Args: - group: name of the setuptools' entry point group where plugins is being - registered filtering: function returning a boolean deciding if the entry point should be loaded and included (or not) in the final list. A ``True`` return means the plugin should be included. """ - return [ - load_from_entry_point(e) for e in iterate_entry_points(group) if filtering(e) + eps = [ + load_from_entry_point(e) + for e in iterate_entry_points(ENTRYPOINT_GROUP) + if filtering(e) ] + dedup = {e.tool: e for e in sorted(eps, key=lambda e: e.tool)} + return list(dedup.values()) class ErrorLoadingPlugin(RuntimeError): From 50efc03d5ef39898daab5c8be2e46b061e48d6d8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 26 Feb 2025 12:38:15 -0500 Subject: [PATCH 02/11] feat: multi-plugins Signed-off-by: Henry Schreiner --- docs/dev-guide.rst | 34 ++++++++++++- src/validate_pyproject/cli.py | 21 ++++---- src/validate_pyproject/plugins/__init__.py | 59 +++++++++++++++++++--- src/validate_pyproject/pre_compile/cli.py | 8 +-- src/validate_pyproject/repo_review.py | 6 +-- tests/test_plugins.py | 41 +++++++++++++-- 6 files changed, 142 insertions(+), 27 deletions(-) diff --git a/docs/dev-guide.rst b/docs/dev-guide.rst index 8591987a..239e6d34 100644 --- a/docs/dev-guide.rst +++ b/docs/dev-guide.rst @@ -89,7 +89,6 @@ can pass ``plugins=[]`` to the constructor; or, for example in the snippet above, we could have used ``plugins=...`` instead of ``extra_plugins=...`` to ensure only the explicitly given plugins are loaded. - Distributing Plugins -------------------- @@ -122,6 +121,39 @@ Also notice plugins are activated in a specific order, using Python's built-in ``sorted`` function. +Providing multiple schemas +-------------------------- + +A second system is provided for providing multiple schemas in a single plugin. +This is useful when a single plugin is responsible for multiple subtables +under the ``tool`` table, or if you need to provide multiple schemas for a +a single subtable. + +To use this system, the plugin function, which does not take any arguments, +should return a dictionary with two keys: ``tools``, which is a dictionary of +tool names to schemas, and optionally ``schemas``, which is a list of schemas +that are not associated with any specific tool, but are loaded via ref's from +the other tools. + +When using a :pep:`621`-compliant backend, the following can be add to your +``pyproject.toml`` file: + +.. code-block:: toml + + # in pyproject.toml + [project.entry-points."validate_pyproject.validate_pyproject.multi_schema"] + arbitrary = "your_package.your_module:your_plugin" + +An example of the plugin structure needed for this system is shown below: + +.. code-block:: python + + def your_plugin(tool_name: str) -> dict: + return { + "tools": {"my-tool": my_schema}, + "schemas": [my_extra_schema], + } + .. _entry-point: https://setuptools.pypa.io/en/stable/userguide/entry_point.html#entry-points .. _JSON Schema: https://json-schema.org/ .. _Python package: https://packaging.python.org/ diff --git a/src/validate_pyproject/cli.py b/src/validate_pyproject/cli.py index 37a59713..2601d8b4 100644 --- a/src/validate_pyproject/cli.py +++ b/src/validate_pyproject/cli.py @@ -24,13 +24,14 @@ Tuple, Type, TypeVar, + Union, ) from . import __version__ from . import _tomllib as tomllib from .api import Validator from .errors import ValidationError -from .plugins import PluginWrapper +from .plugins import PluginWrapper, StoredPlugin from .plugins import list_from_entry_points as list_plugins_from_entry_points from .remote import RemotePlugin, load_store @@ -124,7 +125,7 @@ class CliParams(NamedTuple): dump_json: bool = False -def __meta__(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]: +def __meta__(plugins: Sequence[Union[PluginWrapper, StoredPlugin]]) -> Dict[str, dict]: """'Hyper parameters' to instruct :mod:`argparse` how to create the CLI""" meta = {k: v.copy() for k, v in META.items()} meta["enable"]["choices"] = {p.tool for p in plugins} @@ -135,9 +136,11 @@ def __meta__(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]: @critical_logging() def parse_args( args: Sequence[str], - plugins: Sequence[PluginWrapper], + plugins: Sequence[Union[PluginWrapper, StoredPlugin]], description: str = "Validate a given TOML file", - get_parser_spec: Callable[[Sequence[PluginWrapper]], Dict[str, dict]] = __meta__, + get_parser_spec: Callable[ + [Sequence[Union[PluginWrapper, StoredPlugin]]], Dict[str, dict] + ] = __meta__, params_class: Type[T] = CliParams, # type: ignore[assignment] ) -> T: """Parse command line parameters @@ -168,10 +171,10 @@ def parse_args( def select_plugins( - plugins: Sequence[PluginWrapper], + plugins: Sequence[Union[PluginWrapper, StoredPlugin]], enabled: Sequence[str] = (), disabled: Sequence[str] = (), -) -> List[PluginWrapper]: +) -> List[Union[StoredPlugin, PluginWrapper]]: available = list(plugins) if enabled: available = [p for p in available if p.tool in enabled] @@ -219,7 +222,7 @@ def run(args: Sequence[str] = ()) -> int: (for example ``["--verbose", "setup.cfg"]``). """ args = args or sys.argv[1:] - plugins: List[PluginWrapper] = list_plugins_from_entry_points() + plugins: List[Union[PluginWrapper, StoredPlugin]] = list_plugins_from_entry_points() params: CliParams = parse_args(args, plugins) setup_logging(params.loglevel) tool_plugins = [RemotePlugin.from_str(t) for t in params.tool] @@ -263,7 +266,7 @@ def _split_lines(self, text: str, width: int) -> List[str]: return list(chain.from_iterable(wrap(x, width) for x in text.splitlines())) -def plugins_help(plugins: Sequence[PluginWrapper]) -> str: +def plugins_help(plugins: Sequence[Union[PluginWrapper, StoredPlugin]]) -> str: return "\n".join(_format_plugin_help(p) for p in plugins) @@ -273,7 +276,7 @@ def _flatten_str(text: str) -> str: return (text[0].lower() + text[1:]).strip() -def _format_plugin_help(plugin: PluginWrapper) -> str: +def _format_plugin_help(plugin: Union[PluginWrapper, StoredPlugin]) -> str: help_text = plugin.help_text help_text = f": {_flatten_str(help_text)}" if help_text else "" return f"* {plugin.tool!r}{help_text}" diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index d8a0421a..498cf788 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -9,13 +9,11 @@ from importlib.metadata import EntryPoint, entry_points from string import Template from textwrap import dedent -from typing import Any, Callable, Iterable, List, Optional, Protocol +from typing import Any, Callable, Generator, Iterable, List, Optional, Protocol, Union from .. import __version__ from ..types import Plugin, Schema -ENTRYPOINT_GROUP = "validate_pyproject.tool_schema" - class PluginProtocol(Protocol): @property @@ -66,6 +64,35 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({self.tool!r}, {self.id})" +class StoredPlugin: + def __init__(self, tool: str, schema: Schema): + self._tool = tool + self._schema = schema + + @property + def id(self) -> str: + return self.schema.get("id", "MISSING ID") + + @property + def tool(self) -> str: + return self._tool + + @property + def schema(self) -> Schema: + return self._schema + + @property + def fragment(self) -> str: + return "" + + @property + def help_text(self) -> str: + return self.schema.get("description", "") + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.tool!r}, )" + + if typing.TYPE_CHECKING: _: PluginProtocol = typing.cast(PluginWrapper, None) @@ -103,9 +130,25 @@ def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper: raise ErrorLoadingPlugin(entry_point=entry_point) from ex +def load_from_multi_entry_point( + entry_point: EntryPoint, +) -> Generator[StoredPlugin, None, None]: + """Carefully load the plugin, raising a meaningful message in case of errors""" + try: + fn = entry_point.load() + output = fn() + except Exception as ex: + raise ErrorLoadingPlugin(entry_point=entry_point) from ex + + for tool, schema in output.get("tools", {}).items(): + yield StoredPlugin(tool, schema) + for schema in output.get("schemas", []): + yield StoredPlugin("", schema) + + def list_from_entry_points( filtering: Callable[[EntryPoint], bool] = lambda _: True, -) -> List[PluginWrapper]: +) -> List[Union[PluginWrapper, StoredPlugin]]: """Produces a list of plugin objects for each plugin registered via ``setuptools`` `entry point`_ mechanism. @@ -114,12 +157,14 @@ def list_from_entry_points( loaded and included (or not) in the final list. A ``True`` return means the plugin should be included. """ - eps = [ + eps: List[Union[PluginWrapper, StoredPlugin]] = [ load_from_entry_point(e) - for e in iterate_entry_points(ENTRYPOINT_GROUP) + for e in iterate_entry_points("validate_pyproject.tool_schema") if filtering(e) ] - dedup = {e.tool: e for e in sorted(eps, key=lambda e: e.tool)} + for e in iterate_entry_points("validate_pyproject.multi_schema"): + eps.extend(load_from_multi_entry_point(e)) + dedup = {(e.tool if e.tool else e.id): e for e in sorted(eps, key=lambda e: e.tool)} return list(dedup.values()) diff --git a/src/validate_pyproject/pre_compile/cli.py b/src/validate_pyproject/pre_compile/cli.py index 5fb157cb..66b5f941 100644 --- a/src/validate_pyproject/pre_compile/cli.py +++ b/src/validate_pyproject/pre_compile/cli.py @@ -7,10 +7,10 @@ from functools import partial, wraps from pathlib import Path from types import MappingProxyType -from typing import Any, Dict, List, Mapping, NamedTuple, Sequence +from typing import Any, Dict, List, Mapping, NamedTuple, Sequence, Union from .. import cli -from ..plugins import PluginProtocol, PluginWrapper +from ..plugins import PluginProtocol, PluginWrapper, StoredPlugin from ..plugins import list_from_entry_points as list_plugins_from_entry_points from ..remote import RemotePlugin, load_store from . import pre_compile @@ -85,7 +85,9 @@ class CliParams(NamedTuple): store: str = "" -def parser_spec(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]: +def parser_spec( + plugins: Sequence[Union[PluginWrapper, StoredPlugin]], +) -> Dict[str, dict]: common = ("version", "enable", "disable", "verbose", "very_verbose") cli_spec = cli.__meta__(plugins) meta = {k: v.copy() for k, v in META.items()} diff --git a/src/validate_pyproject/repo_review.py b/src/validate_pyproject/repo_review.py index 09fc779d..ba8c314d 100644 --- a/src/validate_pyproject/repo_review.py +++ b/src/validate_pyproject/repo_review.py @@ -28,9 +28,9 @@ def repo_review_checks() -> Dict[str, VPP001]: def repo_review_families(pyproject: Dict[str, Any]) -> Dict[str, Dict[str, str]]: has_distutils = "distutils" in pyproject.get("tool", {}) - plugin_names = (ep.name for ep in plugins.iterate_entry_points()) - plugin_list = ( - f"`[tool.{n}]`" for n in plugin_names if n != "distutils" or has_distutils + plugin_names = plugins.list_from_entry_points( + lambda e: e.name != "distutils" or has_distutils ) + plugin_list = (f"`[tool.{n}]`" for n in plugin_names) descr = f"Checks `[build-system]`, `[project]`, {', '.join(plugin_list)}" return {"validate-pyproject": {"name": "Validate-PyProject", "description": descr}} diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 1f07dec5..26d9d1be 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,12 +1,15 @@ # The code in this module is mostly borrowed/adapted from PyScaffold and was originally # published under the MIT license # The original PyScaffold license can be found in 'NOTICE.txt' -from importlib.metadata import EntryPoint # pragma: no cover +import importlib.metadata +import sys +from types import ModuleType +from typing import List import pytest from validate_pyproject import plugins -from validate_pyproject.plugins import ENTRYPOINT_GROUP, ErrorLoadingPlugin +from validate_pyproject.plugins import ErrorLoadingPlugin EXISTING = ( "setuptools", @@ -18,7 +21,9 @@ def test_load_from_entry_point__error(): # This module does not exist, so Python will have some trouble loading it # EntryPoint(name, value, group) entry = "mypkg.SOOOOO___fake___:activate" - fake = EntryPoint("fake", entry, ENTRYPOINT_GROUP) + fake = importlib.metadata.EntryPoint( + "fake", entry, "validate_pyproject.tool_schema" + ) with pytest.raises(ErrorLoadingPlugin): plugins.load_from_entry_point(fake) @@ -28,7 +33,7 @@ def is_entry_point(ep): def test_iterate_entry_points(): - plugin_iter = plugins.iterate_entry_points() + plugin_iter = plugins.iterate_entry_points("validate_pyproject.tool_schema") assert hasattr(plugin_iter, "__iter__") pluging_list = list(plugin_iter) assert all(is_entry_point(e) for e in pluging_list) @@ -68,3 +73,31 @@ def _fn2(_): pw = plugins.PluginWrapper("name", _fn2) assert pw.help_text == "Help for `name`" + + +def fake_multi_iterate_entry_points(name: str) -> List[importlib.metadata.EntryPoint]: + if name == "validate_pyproject.multi_schema": + return [ + importlib.metadata.EntryPoint( + name="_", value="test_module:f", group="validate_pyproject.multi_schema" + ) + ] + return [] + + +def test_multi_plugins(monkeypatch): + s1 = {"id": "example1"} + s2 = {"id": "example2"} + s3 = {"id": "example3"} + sys.modules["test_module"] = ModuleType("test_module") + sys.modules["test_module"].f = lambda: { + "tools": {"example": s1}, + "schemas": [s2, s3], + } # type: ignore[attr-defined] + monkeypatch.setattr( + plugins, "iterate_entry_points", fake_multi_iterate_entry_points + ) + + lst = plugins.list_from_entry_points() + assert len(lst) == 3 + assert all(e.id.startswith("example") for e in lst) From ff44b3713af38c4c585eb0a626971d61ea2f589b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 26 Feb 2025 12:49:35 -0500 Subject: [PATCH 03/11] feat: support fragments Signed-off-by: Henry Schreiner --- docs/dev-guide.rst | 3 +++ src/validate_pyproject/plugins/__init__.py | 9 ++++++--- src/validate_pyproject/repo_review.py | 6 +++--- tests/test_plugins.py | 7 ++++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/dev-guide.rst b/docs/dev-guide.rst index 239e6d34..bccdb3ad 100644 --- a/docs/dev-guide.rst +++ b/docs/dev-guide.rst @@ -154,6 +154,9 @@ An example of the plugin structure needed for this system is shown below: "schemas": [my_extra_schema], } +Fragments for schemas are also supported with this system; use ``#`` to split +the tool name and fragment path in the dictionary key. + .. _entry-point: https://setuptools.pypa.io/en/stable/userguide/entry_point.html#entry-points .. _JSON Schema: https://json-schema.org/ .. _Python package: https://packaging.python.org/ diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index 498cf788..f06f56d4 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -66,7 +66,7 @@ def __repr__(self) -> str: class StoredPlugin: def __init__(self, tool: str, schema: Schema): - self._tool = tool + self._tool, _, self._fragment = tool.partition("#") self._schema = schema @property @@ -83,14 +83,17 @@ def schema(self) -> Schema: @property def fragment(self) -> str: - return "" + return self._fragment @property def help_text(self) -> str: return self.schema.get("description", "") def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.tool!r}, )" + args = [repr(self.tool), self.id] + if self.fragment: + args.append(f"fragment={self.fragment!r}") + return f"{self.__class__.__name__}({', '.join(args)}, )" if typing.TYPE_CHECKING: diff --git a/src/validate_pyproject/repo_review.py b/src/validate_pyproject/repo_review.py index ba8c314d..51c93a6c 100644 --- a/src/validate_pyproject/repo_review.py +++ b/src/validate_pyproject/repo_review.py @@ -28,9 +28,9 @@ def repo_review_checks() -> Dict[str, VPP001]: def repo_review_families(pyproject: Dict[str, Any]) -> Dict[str, Dict[str, str]]: has_distutils = "distutils" in pyproject.get("tool", {}) - plugin_names = plugins.list_from_entry_points( + plugin_list = plugins.list_from_entry_points( lambda e: e.name != "distutils" or has_distutils ) - plugin_list = (f"`[tool.{n}]`" for n in plugin_names) - descr = f"Checks `[build-system]`, `[project]`, {', '.join(plugin_list)}" + plugin_names = (f"`[tool.{n.tool}]`" for n in plugin_list if n.tool) + descr = f"Checks `[build-system]`, `[project]`, {', '.join(plugin_names)}" return {"validate-pyproject": {"name": "Validate-PyProject", "description": descr}} diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 26d9d1be..90981d0b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -91,7 +91,7 @@ def test_multi_plugins(monkeypatch): s3 = {"id": "example3"} sys.modules["test_module"] = ModuleType("test_module") sys.modules["test_module"].f = lambda: { - "tools": {"example": s1}, + "tools": {"example#frag": s1}, "schemas": [s2, s3], } # type: ignore[attr-defined] monkeypatch.setattr( @@ -101,3 +101,8 @@ def test_multi_plugins(monkeypatch): lst = plugins.list_from_entry_points() assert len(lst) == 3 assert all(e.id.startswith("example") for e in lst) + + (fragmented,) = (e for e in lst if e.tool) + assert fragmented.tool == "example" + assert fragmented.fragment == "frag" + assert fragmented.schema == s1 From ec8c6b91cbe9c48d321bc5d3f1dbcbdcce0a8398 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 26 Feb 2025 15:51:44 -0500 Subject: [PATCH 04/11] tests: add some tests for more coverage Signed-off-by: Henry Schreiner --- tests/test_plugins.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 90981d0b..e5c4fff7 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -75,6 +75,22 @@ def _fn2(_): assert pw.help_text == "Help for `name`" +class TestStoredPlugin: + def test_empty_help_text(self): + def _fn1(_): + return {} + + pw = plugins.StoredPlugin("name", {}) + assert pw.help_text == "" + + def _fn2(_): + """Help for `${tool}`""" + return {} + + pw = plugins.StoredPlugin("name", {"description": "Help for me"}) + assert pw.help_text == "Help for me" + + def fake_multi_iterate_entry_points(name: str) -> List[importlib.metadata.EntryPoint]: if name == "validate_pyproject.multi_schema": return [ @@ -106,3 +122,16 @@ def test_multi_plugins(monkeypatch): assert fragmented.tool == "example" assert fragmented.fragment == "frag" assert fragmented.schema == s1 + + +def test_broken_multi_plugin(monkeypatch): + def broken_ep(): + raise RuntimeError("Broken") + + sys.modules["test_module"] = ModuleType("test_module") + sys.modules["test_module"].f = broken_ep + monkeypatch.setattr( + plugins, "iterate_entry_points", fake_multi_iterate_entry_points + ) + with pytest.raises(ErrorLoadingPlugin): + plugins.list_from_entry_points() From 407e378802013263048dcbc3403e250ed558f996 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 26 Feb 2025 16:42:46 -0500 Subject: [PATCH 05/11] refactor: use PluginProtocol instead of Union when approriate Signed-off-by: Henry Schreiner --- src/validate_pyproject/cli.py | 24 +++++++++++------------ src/validate_pyproject/pre_compile/cli.py | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/validate_pyproject/cli.py b/src/validate_pyproject/cli.py index 2601d8b4..a356640c 100644 --- a/src/validate_pyproject/cli.py +++ b/src/validate_pyproject/cli.py @@ -24,14 +24,13 @@ Tuple, Type, TypeVar, - Union, ) from . import __version__ from . import _tomllib as tomllib from .api import Validator from .errors import ValidationError -from .plugins import PluginWrapper, StoredPlugin +from .plugins import PluginProtocol, PluginWrapper from .plugins import list_from_entry_points as list_plugins_from_entry_points from .remote import RemotePlugin, load_store @@ -125,7 +124,7 @@ class CliParams(NamedTuple): dump_json: bool = False -def __meta__(plugins: Sequence[Union[PluginWrapper, StoredPlugin]]) -> Dict[str, dict]: +def __meta__(plugins: Sequence[PluginProtocol]) -> Dict[str, dict]: """'Hyper parameters' to instruct :mod:`argparse` how to create the CLI""" meta = {k: v.copy() for k, v in META.items()} meta["enable"]["choices"] = {p.tool for p in plugins} @@ -136,11 +135,9 @@ def __meta__(plugins: Sequence[Union[PluginWrapper, StoredPlugin]]) -> Dict[str, @critical_logging() def parse_args( args: Sequence[str], - plugins: Sequence[Union[PluginWrapper, StoredPlugin]], + plugins: Sequence[PluginProtocol], description: str = "Validate a given TOML file", - get_parser_spec: Callable[ - [Sequence[Union[PluginWrapper, StoredPlugin]]], Dict[str, dict] - ] = __meta__, + get_parser_spec: Callable[[Sequence[PluginProtocol]], Dict[str, dict]] = __meta__, params_class: Type[T] = CliParams, # type: ignore[assignment] ) -> T: """Parse command line parameters @@ -170,11 +167,14 @@ def parse_args( return params_class(**params) # type: ignore[call-overload, no-any-return] +Plugins = TypeVar("Plugins", bound=PluginProtocol) + + def select_plugins( - plugins: Sequence[Union[PluginWrapper, StoredPlugin]], + plugins: Sequence[Plugins], enabled: Sequence[str] = (), disabled: Sequence[str] = (), -) -> List[Union[StoredPlugin, PluginWrapper]]: +) -> List[Plugins]: available = list(plugins) if enabled: available = [p for p in available if p.tool in enabled] @@ -222,7 +222,7 @@ def run(args: Sequence[str] = ()) -> int: (for example ``["--verbose", "setup.cfg"]``). """ args = args or sys.argv[1:] - plugins: List[Union[PluginWrapper, StoredPlugin]] = list_plugins_from_entry_points() + plugins = list_plugins_from_entry_points() params: CliParams = parse_args(args, plugins) setup_logging(params.loglevel) tool_plugins = [RemotePlugin.from_str(t) for t in params.tool] @@ -266,7 +266,7 @@ def _split_lines(self, text: str, width: int) -> List[str]: return list(chain.from_iterable(wrap(x, width) for x in text.splitlines())) -def plugins_help(plugins: Sequence[Union[PluginWrapper, StoredPlugin]]) -> str: +def plugins_help(plugins: Sequence[PluginProtocol]) -> str: return "\n".join(_format_plugin_help(p) for p in plugins) @@ -276,7 +276,7 @@ def _flatten_str(text: str) -> str: return (text[0].lower() + text[1:]).strip() -def _format_plugin_help(plugin: Union[PluginWrapper, StoredPlugin]) -> str: +def _format_plugin_help(plugin: PluginProtocol) -> str: help_text = plugin.help_text help_text = f": {_flatten_str(help_text)}" if help_text else "" return f"* {plugin.tool!r}{help_text}" diff --git a/src/validate_pyproject/pre_compile/cli.py b/src/validate_pyproject/pre_compile/cli.py index 66b5f941..46e538e4 100644 --- a/src/validate_pyproject/pre_compile/cli.py +++ b/src/validate_pyproject/pre_compile/cli.py @@ -7,10 +7,10 @@ from functools import partial, wraps from pathlib import Path from types import MappingProxyType -from typing import Any, Dict, List, Mapping, NamedTuple, Sequence, Union +from typing import Any, Dict, List, Mapping, NamedTuple, Sequence from .. import cli -from ..plugins import PluginProtocol, PluginWrapper, StoredPlugin +from ..plugins import PluginProtocol, PluginWrapper from ..plugins import list_from_entry_points as list_plugins_from_entry_points from ..remote import RemotePlugin, load_store from . import pre_compile @@ -86,7 +86,7 @@ class CliParams(NamedTuple): def parser_spec( - plugins: Sequence[Union[PluginWrapper, StoredPlugin]], + plugins: Sequence[PluginProtocol], ) -> Dict[str, dict]: common = ("version", "enable", "disable", "verbose", "very_verbose") cli_spec = cli.__meta__(plugins) From a19460edc3cd7a1b0730f594e4e4c756685bf322 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 26 Feb 2025 17:40:21 -0500 Subject: [PATCH 06/11] docs: drop an extra blank line Signed-off-by: Henry Schreiner --- docs/dev-guide.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev-guide.rst b/docs/dev-guide.rst index bccdb3ad..16b6c8ae 100644 --- a/docs/dev-guide.rst +++ b/docs/dev-guide.rst @@ -89,6 +89,7 @@ can pass ``plugins=[]`` to the constructor; or, for example in the snippet above, we could have used ``plugins=...`` instead of ``extra_plugins=...`` to ensure only the explicitly given plugins are loaded. + Distributing Plugins -------------------- From f5f232986cee125404d01d019cc22d9dd56e9198 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 11 Mar 2025 10:27:29 -0400 Subject: [PATCH 07/11] Update src/validate_pyproject/plugins/__init__.py Co-authored-by: Anderson Bravalheri Signed-off-by: Henry Schreiner --- src/validate_pyproject/plugins/__init__.py | 40 ++++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index f06f56d4..ba369f0c 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -7,9 +7,10 @@ import typing from importlib.metadata import EntryPoint, entry_points +from itertools import chain from string import Template from textwrap import dedent -from typing import Any, Callable, Generator, Iterable, List, Optional, Protocol, Union +from typing import Callable, Generator, Iterable, List, Optional, Protocol, Union from .. import __version__ from ..types import Plugin, Schema @@ -101,27 +102,26 @@ def __repr__(self) -> str: def iterate_entry_points(group: str) -> Iterable[EntryPoint]: - """Produces a generator yielding an EntryPoint object for each plugin registered + """Produces an iterable yielding an EntryPoint object for each plugin registered via ``setuptools`` `entry point`_ mechanism. This method can be used in conjunction with :obj:`load_from_entry_point` to filter the plugins before actually loading them. The entry points are not - deduplicated, but they are sorted. + deduplicated or sorted. """ entries = entry_points() if hasattr(entries, "select"): # pragma: no cover # The select method was introduced in importlib_metadata 3.9 (and Python 3.10) # and the previous dict interface was declared deprecated select = typing.cast( - Any, + Callable[..., Iterable[EntryPoint]], getattr(entries, "select"), # noqa: B009 ) # typecheck gymnastics - entries_: Iterable[EntryPoint] = select(group=group) - else: # pragma: no cover - # TODO: Once Python 3.10 becomes the oldest version supported, this fallback and - # conditional statement can be removed. - entries_ = (plugin for plugin in entries.get(group, [])) - return sorted(entries_, key=lambda e: e.name) + return select(group=group) + # pragma: no cover + # TODO: Once Python 3.10 becomes the oldest version supported, this fallback and + # conditional statement can be removed. + return (plugin for plugin in entries.get(group, [])) def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper: @@ -149,6 +149,10 @@ def load_from_multi_entry_point( yield StoredPlugin("", schema) +def _tool_or_id(e: Union[StoredPlugin, PluginWrapper]) -> str: + return e.tool or e.id + + def list_from_entry_points( filtering: Callable[[EntryPoint], bool] = lambda _: True, ) -> List[Union[PluginWrapper, StoredPlugin]]: @@ -160,14 +164,20 @@ def list_from_entry_points( loaded and included (or not) in the final list. A ``True`` return means the plugin should be included. """ - eps: List[Union[PluginWrapper, StoredPlugin]] = [ + tool_eps = ( load_from_entry_point(e) for e in iterate_entry_points("validate_pyproject.tool_schema") if filtering(e) - ] - for e in iterate_entry_points("validate_pyproject.multi_schema"): - eps.extend(load_from_multi_entry_point(e)) - dedup = {(e.tool if e.tool else e.id): e for e in sorted(eps, key=lambda e: e.tool)} + ) + multi_eps = ( + load_from_multi_entry_point(e) + for e in iterate_entry_points("validate_pyproject.multi_schema") + if filtering(e) + ) + eps: Iterable[Union[StoredPlugin, PluginWrapper]] = chain( + tool_eps, chain.from_iterable(multi_eps) + ) + dedup = {_tool_or_id(e): e for e in sorted(eps, key=_tool_or_id)} return list(dedup.values()) From 3f3caf11cb3556e4a2a08262decbffb9277cbeb5 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 11 Mar 2025 11:16:58 -0400 Subject: [PATCH 08/11] fix: make tools required Signed-off-by: Henry Schreiner --- src/validate_pyproject/plugins/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index ba369f0c..33cb2a79 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -143,7 +143,7 @@ def load_from_multi_entry_point( except Exception as ex: raise ErrorLoadingPlugin(entry_point=entry_point) from ex - for tool, schema in output.get("tools", {}).items(): + for tool, schema in output["tools"].items(): yield StoredPlugin(tool, schema) for schema in output.get("schemas", []): yield StoredPlugin("", schema) From 0d6dd3ebe9f2f5d81cad72f057c3da64d9f8752a Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 12 Mar 2025 12:20:11 -0400 Subject: [PATCH 09/11] tests: add test for ordering Signed-off-by: Henry Schreiner --- tests/test_plugins.py | 49 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index e5c4fff7..14494f72 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -9,7 +9,7 @@ import pytest from validate_pyproject import plugins -from validate_pyproject.plugins import ErrorLoadingPlugin +from validate_pyproject.plugins import ErrorLoadingPlugin, PluginWrapper, StoredPlugin EXISTING = ( "setuptools", @@ -124,6 +124,53 @@ def test_multi_plugins(monkeypatch): assert fragmented.schema == s1 +def fake_both_iterate_entry_points(name: str) -> List[importlib.metadata.EntryPoint]: + if name == "validate_pyproject.multi_schema": + return [ + importlib.metadata.EntryPoint( + name="_", value="test_module:f", group="validate_pyproject.multi_schema" + ) + ] + if name == "validate_pyproject.tool_schema": + return [ + importlib.metadata.EntryPoint( + name="example1", + value="test_module:f1", + group="validate_pyproject.tool_schema", + ), + importlib.metadata.EntryPoint( + name="example3", + value="test_module:f3", + group="validate_pyproject.tool_schema", + ), + ] + return [] + + +def test_combined_plugins(monkeypatch): + s1 = {"id": "example1"} + s2 = {"id": "example2"} + sys.modules["test_module"] = ModuleType("test_module") + sys.modules["test_module"].f = lambda: { + "tools": {"example1": s1, "example2": s2}, + } # type: ignore[attr-defined] + sys.modules["test_module"].f1 = lambda _: {"id": "tool1"} # type: ignore[attr-defined] + sys.modules["test_module"].f3 = lambda _: {"id": "tool3"} # type: ignore[attr-defined] + monkeypatch.setattr(plugins, "iterate_entry_points", fake_both_iterate_entry_points) + + lst = plugins.list_from_entry_points() + assert len(lst) == 3 + + assert lst[0].tool == "example1" + assert isinstance(lst[0], StoredPlugin) + + assert lst[1].tool == "example2" + assert isinstance(lst[1], StoredPlugin) + + assert lst[2].tool == "example3" + assert isinstance(lst[2], PluginWrapper) + + def test_broken_multi_plugin(monkeypatch): def broken_ep(): raise RuntimeError("Broken") From ccd55594e33e4965c8e0dd514c699f7372a1685a Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 12 Mar 2025 13:03:54 -0400 Subject: [PATCH 10/11] refactor: sort multiplugins, repeatable interaction Signed-off-by: Henry Schreiner --- src/validate_pyproject/plugins/__init__.py | 8 +++- tests/test_plugins.py | 44 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index 33cb2a79..4e4e6e69 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -107,7 +107,7 @@ def iterate_entry_points(group: str) -> Iterable[EntryPoint]: This method can be used in conjunction with :obj:`load_from_entry_point` to filter the plugins before actually loading them. The entry points are not - deduplicated or sorted. + deduplicated. """ entries = entry_points() if hasattr(entries, "select"): # pragma: no cover @@ -171,7 +171,11 @@ def list_from_entry_points( ) multi_eps = ( load_from_multi_entry_point(e) - for e in iterate_entry_points("validate_pyproject.multi_schema") + for e in sorted( + iterate_entry_points("validate_pyproject.multi_schema"), + key=lambda e: e.name, + reverse=True, + ) if filtering(e) ) eps: Iterable[Union[StoredPlugin, PluginWrapper]] = chain( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 14494f72..89f3c8bd 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,6 +1,7 @@ # The code in this module is mostly borrowed/adapted from PyScaffold and was originally # published under the MIT license # The original PyScaffold license can be found in 'NOTICE.txt' +import functools import importlib.metadata import sys from types import ModuleType @@ -171,6 +172,49 @@ def test_combined_plugins(monkeypatch): assert isinstance(lst[2], PluginWrapper) +def fake_several_entry_points( + name: str, *, reverse: bool +) -> List[importlib.metadata.EntryPoint]: + if name == "validate_pyproject.multi_schema": + items = [ + importlib.metadata.EntryPoint( + name="a", + value="test_module:f1", + group="validate_pyproject.multi_schema", + ), + importlib.metadata.EntryPoint( + name="b", + value="test_module:f2", + group="validate_pyproject.multi_schema", + ), + ] + return items[::-1] if reverse else items + return [] + + +@pytest.mark.parametrize("reverse", [True, False]) +def test_several_multi_plugins(monkeypatch, reverse): + s1 = {"id": "example1"} + s2 = {"id": "example2"} + s3 = {"id": "example3"} + sys.modules["test_module"] = ModuleType("test_module") + sys.modules["test_module"].f1 = lambda: { + "tools": {"example": s1}, + } # type: ignore[attr-defined] + sys.modules["test_module"].f2 = lambda: { + "tools": {"example": s2, "other": s3}, + } # type: ignore[attr-defined] + monkeypatch.setattr( + plugins, + "iterate_entry_points", + functools.partial(fake_several_entry_points, reverse=reverse), + ) + + (plugin1, plugin2) = plugins.list_from_entry_points() + assert plugin1.id == "example1" + assert plugin2.id == "example3" + + def test_broken_multi_plugin(monkeypatch): def broken_ep(): raise RuntimeError("Broken") From fe098a9eaac13c33158602574fb59c70747321dd Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 12 Mar 2025 16:46:04 -0400 Subject: [PATCH 11/11] refactor: sort in one step Signed-off-by: Henry Schreiner --- src/validate_pyproject/plugins/__init__.py | 38 ++++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py index 4e4e6e69..cb1dd8dd 100644 --- a/src/validate_pyproject/plugins/__init__.py +++ b/src/validate_pyproject/plugins/__init__.py @@ -10,7 +10,17 @@ from itertools import chain from string import Template from textwrap import dedent -from typing import Callable, Generator, Iterable, List, Optional, Protocol, Union +from typing import ( + Any, + Callable, + Generator, + Iterable, + List, + NamedTuple, + Optional, + Protocol, + Union, +) from .. import __version__ from ..types import Plugin, Schema @@ -149,8 +159,17 @@ def load_from_multi_entry_point( yield StoredPlugin("", schema) -def _tool_or_id(e: Union[StoredPlugin, PluginWrapper]) -> str: - return e.tool or e.id +class _SortablePlugin(NamedTuple): + priority: int + name: str + plugin: Union[PluginWrapper, StoredPlugin] + + def __lt__(self, other: Any) -> bool: + return (self.plugin.tool or self.plugin.id, self.name, self.priority) < ( + other.plugin.tool or other.plugin.id, + other.name, + other.priority, + ) def list_from_entry_points( @@ -165,24 +184,23 @@ def list_from_entry_points( plugin should be included. """ tool_eps = ( - load_from_entry_point(e) + _SortablePlugin(0, e.name, load_from_entry_point(e)) for e in iterate_entry_points("validate_pyproject.tool_schema") if filtering(e) ) multi_eps = ( - load_from_multi_entry_point(e) + _SortablePlugin(1, e.name, p) for e in sorted( iterate_entry_points("validate_pyproject.multi_schema"), key=lambda e: e.name, reverse=True, ) + for p in load_from_multi_entry_point(e) if filtering(e) ) - eps: Iterable[Union[StoredPlugin, PluginWrapper]] = chain( - tool_eps, chain.from_iterable(multi_eps) - ) - dedup = {_tool_or_id(e): e for e in sorted(eps, key=_tool_or_id)} - return list(dedup.values()) + eps = chain(tool_eps, multi_eps) + dedup = {e.plugin.tool or e.plugin.id: e.plugin for e in sorted(eps, reverse=True)} + return list(dedup.values())[::-1] class ErrorLoadingPlugin(RuntimeError):