Skip to content

Commit 827141b

Browse files
committed
feat: multi-plugins
Signed-off-by: Henry Schreiner <[email protected]>
1 parent 1634322 commit 827141b

File tree

8 files changed

+76
-33
lines changed

8 files changed

+76
-33
lines changed

docs/dev-guide.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ specify which ``tool`` subtable it would be checking:
7777
7878
7979
available_plugins = [
80-
*plugins.list_from_entry_points(),
80+
*plugins.list_plugins_from_entry_points(),
8181
plugins.PluginWrapper("your-tool", your_plugin),
8282
]
8383
validator = api.Validator(available_plugins)

src/validate_pyproject/api.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,9 @@ def __init__(
202202
self._extra_validations = tuple(extra_validations)
203203

204204
if plugins is ALL_PLUGINS:
205-
from .plugins import list_from_entry_points
205+
from .plugins import list_plugins_from_entry_points
206206

207-
plugins = list_from_entry_points()
207+
plugins = list_plugins_from_entry_points()
208208

209209
self._plugins = (*plugins, *extra_plugins)
210210

src/validate_pyproject/cli.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@
3030
from . import _tomllib as tomllib
3131
from .api import Validator
3232
from .errors import ValidationError
33-
from .plugins import PluginWrapper
34-
from .plugins import list_from_entry_points as list_plugins_from_entry_points
33+
from .plugins import PluginWrapper, list_plugins_from_entry_points
3534
from .remote import RemotePlugin, load_store
3635

3736
_logger = logging.getLogger(__package__)

src/validate_pyproject/plugins/__init__.py

+29-15
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@
2929
else:
3030
Protocol = object
3131

32-
ENTRYPOINT_GROUP = "validate_pyproject.tool_schema"
33-
3432

3533
class PluginProtocol(Protocol):
3634
@property
@@ -56,7 +54,7 @@ def fragment(self) -> str:
5654

5755
class PluginWrapper:
5856
def __init__(self, tool: str, load_fn: "Plugin"):
59-
self._tool = tool
57+
self._tool, _, self._fragment = tool.partition("#")
6058
self._load_fn = load_fn
6159

6260
@property
@@ -73,7 +71,7 @@ def schema(self) -> "Schema":
7371

7472
@property
7573
def fragment(self) -> str:
76-
return ""
74+
return self._fragment
7775

7876
@property
7977
def help_text(self) -> str:
@@ -90,12 +88,13 @@ def __repr__(self) -> str:
9088
_: PluginProtocol = typing.cast(PluginWrapper, None)
9189

9290

93-
def iterate_entry_points(group: str = ENTRYPOINT_GROUP) -> Iterable[EntryPoint]:
91+
def iterate_entry_points(group: str) -> Iterable[EntryPoint]:
9492
"""Produces a generator yielding an EntryPoint object for each plugin registered
9593
via ``setuptools`` `entry point`_ mechanism.
9694
97-
This method can be used in conjunction with :obj:`load_from_entry_point` to filter
98-
the plugins before actually loading them.
95+
This method can be used in conjunction with :obj:`load_from_entry_point` to
96+
filter the plugins before actually loading them. The entry points are not
97+
deduplicated, but they are sorted.
9998
"""
10099
entries = entry_points()
101100
if hasattr(entries, "select"): # pragma: no cover
@@ -110,8 +109,7 @@ def iterate_entry_points(group: str = ENTRYPOINT_GROUP) -> Iterable[EntryPoint]:
110109
# TODO: Once Python 3.10 becomes the oldest version supported, this fallback and
111110
# conditional statement can be removed.
112111
entries_ = (plugin for plugin in entries.get(group, []))
113-
deduplicated = {e.name: e for e in sorted(entries_, key=lambda e: e.name)}
114-
return list(deduplicated.values())
112+
return sorted(entries_, key=lambda e: e.name)
115113

116114

117115
def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper:
@@ -123,23 +121,39 @@ def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper:
123121
raise ErrorLoadingPlugin(entry_point=entry_point) from ex
124122

125123

126-
def list_from_entry_points(
127-
group: str = ENTRYPOINT_GROUP,
124+
def load_multi_entry_point(entry_point: EntryPoint) -> List[PluginWrapper]:
125+
"""Carefully load the plugin, raising a meaningful message in case of errors"""
126+
try:
127+
dict_plugins = entry_point.load()
128+
return [PluginWrapper(k, v) for k, v in dict_plugins().items()]
129+
except Exception as ex:
130+
raise ErrorLoadingPlugin(entry_point=entry_point) from ex
131+
132+
133+
def list_plugins_from_entry_points(
128134
filtering: Callable[[EntryPoint], bool] = lambda _: True,
129135
) -> List[PluginWrapper]:
130136
"""Produces a list of plugin objects for each plugin registered
131137
via ``setuptools`` `entry point`_ mechanism.
132138
133139
Args:
134-
group: name of the setuptools' entry point group where plugins is being
135-
registered
136140
filtering: function returning a boolean deciding if the entry point should be
137141
loaded and included (or not) in the final list. A ``True`` return means the
138142
plugin should be included.
139143
"""
140-
return [
141-
load_from_entry_point(e) for e in iterate_entry_points(group) if filtering(e)
144+
eps = [
145+
load_from_entry_point(e)
146+
for e in iterate_entry_points("validate_pyproject.tool_schema")
147+
if filtering(e)
148+
]
149+
eps += [
150+
ep
151+
for e in iterate_entry_points("validate_pyproject.multi_schema")
152+
for ep in load_multi_entry_point(e)
153+
if filtering(e)
142154
]
155+
dedup = {e.tool: e for e in sorted(eps, key=lambda e: e.tool)}
156+
return list(dedup.values())
143157

144158

145159
class ErrorLoadingPlugin(RuntimeError):

src/validate_pyproject/pre_compile/cli.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
from typing import Any, Dict, List, Mapping, NamedTuple, Sequence
1111

1212
from .. import cli
13-
from ..plugins import PluginWrapper
14-
from ..plugins import list_from_entry_points as list_plugins_from_entry_points
13+
from ..plugins import PluginWrapper, list_plugins_from_entry_points
1514
from ..remote import RemotePlugin, load_store
1615
from . import pre_compile
1716

tests/test_api.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_load_plugin():
3232

3333
class TestRegistry:
3434
def test_with_plugins(self):
35-
plg = plugins.list_from_entry_points()
35+
plg = plugins.list_plugins_from_entry_points()
3636
registry = api.SchemaRegistry(plg)
3737
main_schema = registry[registry.main]
3838
project = main_schema["properties"]["project"]
@@ -112,7 +112,7 @@ def test_invalid(self):
112112
# ---
113113

114114
def plugin(self, tool):
115-
plg = plugins.list_from_entry_points(filtering=lambda e: e.name == tool)
115+
plg = plugins.list_plugins_from_entry_points(filtering=lambda e: e.name == tool)
116116
return plg[0]
117117

118118
TOOLS = ("distutils", "setuptools")

tests/test_cli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_custom_plugins(self, capsys):
3636

3737

3838
def parse_args(args):
39-
plg = plugins.list_from_entry_points()
39+
plg = plugins.list_plugins_from_entry_points()
4040
return cli.parse_args(args, plg)
4141

4242

tests/test_plugins.py

+39-8
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
# The original PyScaffold license can be found in 'NOTICE.txt'
44

55
import sys
6+
from types import ModuleType
7+
from typing import Any, List
68

79
import pytest
810

911
from validate_pyproject import plugins
10-
from validate_pyproject.plugins import ENTRYPOINT_GROUP, ErrorLoadingPlugin
12+
from validate_pyproject.plugins import ErrorLoadingPlugin
1113

1214
EXISTING = (
1315
"setuptools",
@@ -17,16 +19,16 @@
1719

1820
if sys.version_info[:2] >= (3, 8):
1921
# TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
20-
from importlib.metadata import EntryPoint # pragma: no cover
22+
from importlib import metadata # pragma: no cover
2123
else:
22-
from importlib_metadata import EntryPoint # pragma: no cover
24+
import importlib_metadata as metadata # pragma: no cover
2325

2426

2527
def test_load_from_entry_point__error():
2628
# This module does not exist, so Python will have some trouble loading it
27-
# EntryPoint(name, value, group)
29+
# metadata.EntryPoint(name, value, group)
2830
entry = "mypkg.SOOOOO___fake___:activate"
29-
fake = EntryPoint("fake", entry, ENTRYPOINT_GROUP)
31+
fake = metadata.EntryPoint("fake", entry, "validate_pyproject.tool_schema")
3032
with pytest.raises(ErrorLoadingPlugin):
3133
plugins.load_from_entry_point(fake)
3234

@@ -36,7 +38,7 @@ def is_entry_point(ep):
3638

3739

3840
def test_iterate_entry_points():
39-
plugin_iter = plugins.iterate_entry_points()
41+
plugin_iter = plugins.iterate_entry_points("validate_pyproject.tool_schema")
4042
assert hasattr(plugin_iter, "__iter__")
4143
pluging_list = list(plugin_iter)
4244
assert all(is_entry_point(e) for e in pluging_list)
@@ -47,14 +49,14 @@ def test_iterate_entry_points():
4749

4850
def test_list_from_entry_points():
4951
# Should return a list with all the plugins registered in the entrypoints
50-
pluging_list = plugins.list_from_entry_points()
52+
pluging_list = plugins.list_plugins_from_entry_points()
5153
orig_len = len(pluging_list)
5254
plugin_names = " ".join(e.tool for e in pluging_list)
5355
for example in EXISTING:
5456
assert example in plugin_names
5557

5658
# a filtering function can be passed to avoid loading plugins that are not needed
57-
pluging_list = plugins.list_from_entry_points(
59+
pluging_list = plugins.list_plugins_from_entry_points(
5860
filtering=lambda e: e.name != "setuptools"
5961
)
6062
plugin_names = " ".join(e.tool for e in pluging_list)
@@ -76,3 +78,32 @@ def _fn2(_):
7678

7779
pw = plugins.PluginWrapper("name", _fn2)
7880
assert pw.help_text == "Help for `name`"
81+
82+
83+
def loader(name: str) -> Any:
84+
return {"example": "thing"}
85+
86+
87+
def dynamic_ep():
88+
return {"some#fragment": loader}
89+
90+
91+
class Select(list):
92+
def select(self, group: str) -> List[str]:
93+
return list(self) if group == "validate_pyproject.multi_schema" else []
94+
95+
96+
def test_process_checks(monkeypatch: pytest.MonkeyPatch) -> None:
97+
ep = metadata.EntryPoint(
98+
name="_",
99+
group="validate_pyproject.multi_schema",
100+
value="test_module:dynamic_ep",
101+
)
102+
sys.modules["test_module"] = ModuleType("test_module")
103+
sys.modules["test_module"].dynamic_ep = dynamic_ep # type: ignore[attr-defined]
104+
sys.modules["test_module"].loader = loader # type: ignore[attr-defined]
105+
monkeypatch.setattr(plugins, "entry_points", lambda: Select([ep]))
106+
eps = plugins.list_plugins_from_entry_points()
107+
(ep,) = eps
108+
assert ep.tool == "some"
109+
assert ep.fragment == "fragment"

0 commit comments

Comments
 (0)