Skip to content

Commit 9c8406c

Browse files
authored
feat: multi-plugins with extra schemas (#231)
2 parents c8ff9bd + fe098a9 commit 9c8406c

File tree

6 files changed

+319
-41
lines changed

6 files changed

+319
-41
lines changed

docs/dev-guide.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,42 @@ Also notice plugins are activated in a specific order, using Python's built-in
122122
``sorted`` function.
123123

124124

125+
Providing multiple schemas
126+
--------------------------
127+
128+
A second system is provided for providing multiple schemas in a single plugin.
129+
This is useful when a single plugin is responsible for multiple subtables
130+
under the ``tool`` table, or if you need to provide multiple schemas for a
131+
a single subtable.
132+
133+
To use this system, the plugin function, which does not take any arguments,
134+
should return a dictionary with two keys: ``tools``, which is a dictionary of
135+
tool names to schemas, and optionally ``schemas``, which is a list of schemas
136+
that are not associated with any specific tool, but are loaded via ref's from
137+
the other tools.
138+
139+
When using a :pep:`621`-compliant backend, the following can be add to your
140+
``pyproject.toml`` file:
141+
142+
.. code-block:: toml
143+
144+
# in pyproject.toml
145+
[project.entry-points."validate_pyproject.validate_pyproject.multi_schema"]
146+
arbitrary = "your_package.your_module:your_plugin"
147+
148+
An example of the plugin structure needed for this system is shown below:
149+
150+
.. code-block:: python
151+
152+
def your_plugin(tool_name: str) -> dict:
153+
return {
154+
"tools": {"my-tool": my_schema},
155+
"schemas": [my_extra_schema],
156+
}
157+
158+
Fragments for schemas are also supported with this system; use ``#`` to split
159+
the tool name and fragment path in the dictionary key.
160+
125161
.. _entry-point: https://setuptools.pypa.io/en/stable/userguide/entry_point.html#entry-points
126162
.. _JSON Schema: https://json-schema.org/
127163
.. _Python package: https://packaging.python.org/

src/validate_pyproject/cli.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from . import _tomllib as tomllib
3131
from .api import Validator
3232
from .errors import ValidationError
33-
from .plugins import PluginWrapper
33+
from .plugins import PluginProtocol, PluginWrapper
3434
from .plugins import list_from_entry_points as list_plugins_from_entry_points
3535
from .remote import RemotePlugin, load_store
3636

@@ -124,7 +124,7 @@ class CliParams(NamedTuple):
124124
dump_json: bool = False
125125

126126

127-
def __meta__(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]:
127+
def __meta__(plugins: Sequence[PluginProtocol]) -> Dict[str, dict]:
128128
"""'Hyper parameters' to instruct :mod:`argparse` how to create the CLI"""
129129
meta = {k: v.copy() for k, v in META.items()}
130130
meta["enable"]["choices"] = {p.tool for p in plugins}
@@ -135,9 +135,9 @@ def __meta__(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]:
135135
@critical_logging()
136136
def parse_args(
137137
args: Sequence[str],
138-
plugins: Sequence[PluginWrapper],
138+
plugins: Sequence[PluginProtocol],
139139
description: str = "Validate a given TOML file",
140-
get_parser_spec: Callable[[Sequence[PluginWrapper]], Dict[str, dict]] = __meta__,
140+
get_parser_spec: Callable[[Sequence[PluginProtocol]], Dict[str, dict]] = __meta__,
141141
params_class: Type[T] = CliParams, # type: ignore[assignment]
142142
) -> T:
143143
"""Parse command line parameters
@@ -167,11 +167,14 @@ def parse_args(
167167
return params_class(**params) # type: ignore[call-overload, no-any-return]
168168

169169

170+
Plugins = TypeVar("Plugins", bound=PluginProtocol)
171+
172+
170173
def select_plugins(
171-
plugins: Sequence[PluginWrapper],
174+
plugins: Sequence[Plugins],
172175
enabled: Sequence[str] = (),
173176
disabled: Sequence[str] = (),
174-
) -> List[PluginWrapper]:
177+
) -> List[Plugins]:
175178
available = list(plugins)
176179
if enabled:
177180
available = [p for p in available if p.tool in enabled]
@@ -219,7 +222,7 @@ def run(args: Sequence[str] = ()) -> int:
219222
(for example ``["--verbose", "setup.cfg"]``).
220223
"""
221224
args = args or sys.argv[1:]
222-
plugins: List[PluginWrapper] = list_plugins_from_entry_points()
225+
plugins = list_plugins_from_entry_points()
223226
params: CliParams = parse_args(args, plugins)
224227
setup_logging(params.loglevel)
225228
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]:
263266
return list(chain.from_iterable(wrap(x, width) for x in text.splitlines()))
264267

265268

266-
def plugins_help(plugins: Sequence[PluginWrapper]) -> str:
269+
def plugins_help(plugins: Sequence[PluginProtocol]) -> str:
267270
return "\n".join(_format_plugin_help(p) for p in plugins)
268271

269272

@@ -273,7 +276,7 @@ def _flatten_str(text: str) -> str:
273276
return (text[0].lower() + text[1:]).strip()
274277

275278

276-
def _format_plugin_help(plugin: PluginWrapper) -> str:
279+
def _format_plugin_help(plugin: PluginProtocol) -> str:
277280
help_text = plugin.help_text
278281
help_text = f": {_flatten_str(help_text)}" if help_text else ""
279282
return f"* {plugin.tool!r}{help_text}"

src/validate_pyproject/plugins/__init__.py

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,24 @@
77

88
import typing
99
from importlib.metadata import EntryPoint, entry_points
10+
from itertools import chain
1011
from string import Template
1112
from textwrap import dedent
12-
from typing import Any, Callable, Iterable, List, Optional, Protocol
13+
from typing import (
14+
Any,
15+
Callable,
16+
Generator,
17+
Iterable,
18+
List,
19+
NamedTuple,
20+
Optional,
21+
Protocol,
22+
Union,
23+
)
1324

1425
from .. import __version__
1526
from ..types import Plugin, Schema
1627

17-
ENTRYPOINT_GROUP = "validate_pyproject.tool_schema"
18-
1928

2029
class PluginProtocol(Protocol):
2130
@property
@@ -66,34 +75,63 @@ def __repr__(self) -> str:
6675
return f"{self.__class__.__name__}({self.tool!r}, {self.id})"
6776

6877

78+
class StoredPlugin:
79+
def __init__(self, tool: str, schema: Schema):
80+
self._tool, _, self._fragment = tool.partition("#")
81+
self._schema = schema
82+
83+
@property
84+
def id(self) -> str:
85+
return self.schema.get("id", "MISSING ID")
86+
87+
@property
88+
def tool(self) -> str:
89+
return self._tool
90+
91+
@property
92+
def schema(self) -> Schema:
93+
return self._schema
94+
95+
@property
96+
def fragment(self) -> str:
97+
return self._fragment
98+
99+
@property
100+
def help_text(self) -> str:
101+
return self.schema.get("description", "")
102+
103+
def __repr__(self) -> str:
104+
args = [repr(self.tool), self.id]
105+
if self.fragment:
106+
args.append(f"fragment={self.fragment!r}")
107+
return f"{self.__class__.__name__}({', '.join(args)}, <schema: {self.id}>)"
108+
109+
69110
if typing.TYPE_CHECKING:
70111
_: PluginProtocol = typing.cast(PluginWrapper, None)
71112

72113

73-
def iterate_entry_points(group: str = ENTRYPOINT_GROUP) -> Iterable[EntryPoint]:
74-
"""Produces a generator yielding an EntryPoint object for each plugin registered
114+
def iterate_entry_points(group: str) -> Iterable[EntryPoint]:
115+
"""Produces an iterable yielding an EntryPoint object for each plugin registered
75116
via ``setuptools`` `entry point`_ mechanism.
76117
77118
This method can be used in conjunction with :obj:`load_from_entry_point` to filter
78-
the plugins before actually loading them.
119+
the plugins before actually loading them. The entry points are not
120+
deduplicated.
79121
"""
80122
entries = entry_points()
81123
if hasattr(entries, "select"): # pragma: no cover
82124
# The select method was introduced in importlib_metadata 3.9 (and Python 3.10)
83125
# and the previous dict interface was declared deprecated
84126
select = typing.cast(
85-
Any,
127+
Callable[..., Iterable[EntryPoint]],
86128
getattr(entries, "select"), # noqa: B009
87129
) # typecheck gymnastics
88-
entries_: Iterable[EntryPoint] = select(group=group)
89-
else: # pragma: no cover
90-
# TODO: Once Python 3.10 becomes the oldest version supported, this fallback and
91-
# conditional statement can be removed.
92-
entries_ = (plugin for plugin in entries.get(group, []))
93-
deduplicated = {
94-
e.name: e for e in sorted(entries_, key=lambda e: (e.name, e.value))
95-
}
96-
return list(deduplicated.values())
130+
return select(group=group)
131+
# pragma: no cover
132+
# TODO: Once Python 3.10 becomes the oldest version supported, this fallback and
133+
# conditional statement can be removed.
134+
return (plugin for plugin in entries.get(group, []))
97135

98136

99137
def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper:
@@ -105,23 +143,64 @@ def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper:
105143
raise ErrorLoadingPlugin(entry_point=entry_point) from ex
106144

107145

146+
def load_from_multi_entry_point(
147+
entry_point: EntryPoint,
148+
) -> Generator[StoredPlugin, None, None]:
149+
"""Carefully load the plugin, raising a meaningful message in case of errors"""
150+
try:
151+
fn = entry_point.load()
152+
output = fn()
153+
except Exception as ex:
154+
raise ErrorLoadingPlugin(entry_point=entry_point) from ex
155+
156+
for tool, schema in output["tools"].items():
157+
yield StoredPlugin(tool, schema)
158+
for schema in output.get("schemas", []):
159+
yield StoredPlugin("", schema)
160+
161+
162+
class _SortablePlugin(NamedTuple):
163+
priority: int
164+
name: str
165+
plugin: Union[PluginWrapper, StoredPlugin]
166+
167+
def __lt__(self, other: Any) -> bool:
168+
return (self.plugin.tool or self.plugin.id, self.name, self.priority) < (
169+
other.plugin.tool or other.plugin.id,
170+
other.name,
171+
other.priority,
172+
)
173+
174+
108175
def list_from_entry_points(
109-
group: str = ENTRYPOINT_GROUP,
110176
filtering: Callable[[EntryPoint], bool] = lambda _: True,
111-
) -> List[PluginWrapper]:
177+
) -> List[Union[PluginWrapper, StoredPlugin]]:
112178
"""Produces a list of plugin objects for each plugin registered
113179
via ``setuptools`` `entry point`_ mechanism.
114180
115181
Args:
116-
group: name of the setuptools' entry point group where plugins is being
117-
registered
118182
filtering: function returning a boolean deciding if the entry point should be
119183
loaded and included (or not) in the final list. A ``True`` return means the
120184
plugin should be included.
121185
"""
122-
return [
123-
load_from_entry_point(e) for e in iterate_entry_points(group) if filtering(e)
124-
]
186+
tool_eps = (
187+
_SortablePlugin(0, e.name, load_from_entry_point(e))
188+
for e in iterate_entry_points("validate_pyproject.tool_schema")
189+
if filtering(e)
190+
)
191+
multi_eps = (
192+
_SortablePlugin(1, e.name, p)
193+
for e in sorted(
194+
iterate_entry_points("validate_pyproject.multi_schema"),
195+
key=lambda e: e.name,
196+
reverse=True,
197+
)
198+
for p in load_from_multi_entry_point(e)
199+
if filtering(e)
200+
)
201+
eps = chain(tool_eps, multi_eps)
202+
dedup = {e.plugin.tool or e.plugin.id: e.plugin for e in sorted(eps, reverse=True)}
203+
return list(dedup.values())[::-1]
125204

126205

127206
class ErrorLoadingPlugin(RuntimeError):

src/validate_pyproject/pre_compile/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ class CliParams(NamedTuple):
8585
store: str = ""
8686

8787

88-
def parser_spec(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]:
88+
def parser_spec(
89+
plugins: Sequence[PluginProtocol],
90+
) -> Dict[str, dict]:
8991
common = ("version", "enable", "disable", "verbose", "very_verbose")
9092
cli_spec = cli.__meta__(plugins)
9193
meta = {k: v.copy() for k, v in META.items()}

src/validate_pyproject/repo_review.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ def repo_review_checks() -> Dict[str, VPP001]:
2828

2929
def repo_review_families(pyproject: Dict[str, Any]) -> Dict[str, Dict[str, str]]:
3030
has_distutils = "distutils" in pyproject.get("tool", {})
31-
plugin_names = (ep.name for ep in plugins.iterate_entry_points())
32-
plugin_list = (
33-
f"`[tool.{n}]`" for n in plugin_names if n != "distutils" or has_distutils
31+
plugin_list = plugins.list_from_entry_points(
32+
lambda e: e.name != "distutils" or has_distutils
3433
)
35-
descr = f"Checks `[build-system]`, `[project]`, {', '.join(plugin_list)}"
34+
plugin_names = (f"`[tool.{n.tool}]`" for n in plugin_list if n.tool)
35+
descr = f"Checks `[build-system]`, `[project]`, {', '.join(plugin_names)}"
3636
return {"validate-pyproject": {"name": "Validate-PyProject", "description": descr}}

0 commit comments

Comments
 (0)