Skip to content

Commit 18dc302

Browse files
committed
Load plugins from entry points
Enables the loading of plugins via entry points. This should enable libraries to install and advertise their own plugins without requiring as much user configuration. Closes #69
1 parent cbaf81a commit 18dc302

9 files changed

Lines changed: 57 additions & 19 deletions

File tree

docs/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ The following plugins provide XRLint's [inbuilt rules](rule-ref.md):
3232
- `xcube`: implementing the rules for
3333
[xcube datasets](https://xcube.readthedocs.io/en/latest/cubespec.html).
3434
Note, this plugin is fully optional. You must manually configure
35-
it to apply its rules. It may be moved into a separate GitHub repo later.
35+
it to apply its rules. It may be moved into a separate GitHub repo later.
36+
- `acdd`: implements rules for [Attribute Convention for Data Discovery](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3).
3637

docs/start.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ rule configurations:
6666

6767
You can add rules from plugins as well:
6868

69+
!!! note inline end "Built in and auto-loading plugins"
70+
71+
The included plugins (such as `xcube` in the example configs here) and those from external libraries that are findable via [entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html) do not need to be explicitly loaded.
72+
73+
Run `xrlint --print-config <dataset>` to view the loaded plugins and configured rules.
74+
6975
```yaml
7076
- recommended
7177
- plugins:

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ classifiers = [
4040
"Operating System :: MacOS",
4141
]
4242

43+
[project.entry-points."xrlint.rules"]
44+
core = "xrlint.plugins.core"
45+
xcube = "xrlint.plugins.xcube"
46+
acdd = "xrlint.plugins.acdd"
47+
4348
[tool.setuptools.dynamic]
4449
version = {attr = "xrlint.__version__"}
4550
readme = {file = "README.md", content-type = "text/markdown"}

tests/cli/test_main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,9 @@ def test_print_config_option(self):
230230
(
231231
"{\n"
232232
' "plugins": {\n'
233-
' "__core__": "xrlint.plugins.core:export_plugin"\n'
233+
' "acdd": "xrlint.plugins.acdd:export_plugin",\n'
234+
' "__core__": "xrlint.plugins.core:export_plugin",\n'
235+
' "xcube": "xrlint.plugins.xcube:export_plugin"\n'
234236
" },\n"
235237
' "rules": {\n'
236238
' "var-units": 2\n'

tests/test_config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99
import xarray as xr
1010

11-
from xrlint.config import Config, ConfigObject, get_core_config_object
11+
from xrlint.config import Config, ConfigObject, get_entry_point_plugins
1212
from xrlint.constants import CORE_PLUGIN_NAME
1313
from xrlint.plugin import Plugin, new_plugin
1414
from xrlint.processor import ProcessorOp, define_processor
@@ -35,15 +35,15 @@ def test_defaults(self):
3535
self.assertEqual(None, config_obj.rules)
3636

3737
def test_get_plugin(self):
38-
config_obj = get_core_config_object()
38+
config_obj = get_entry_point_plugins()
3939
plugin = config_obj.get_plugin(CORE_PLUGIN_NAME)
4040
self.assertIsInstance(plugin, Plugin)
4141

42-
with pytest.raises(ValueError, match="unknown plugin 'xcube'"):
43-
config_obj.get_plugin("xcube")
42+
with pytest.raises(ValueError, match="unknown plugin 'does-not-exist'"):
43+
config_obj.get_plugin("does-not-exist")
4444

4545
def test_get_rule(self):
46-
config_obj = get_core_config_object()
46+
config_obj = get_entry_point_plugins()
4747
rule = config_obj.get_rule("var-flags")
4848
self.assertIsInstance(rule, Rule)
4949

@@ -195,7 +195,7 @@ def test_from_config_ok(self):
195195

196196
config = Config.from_config(
197197
{"ignores": ["**/*.levels"]},
198-
get_core_config_object(),
198+
get_entry_point_plugins(),
199199
"recommended",
200200
{"rules": {"no-empty-chunks": 2}},
201201
)

tests/test_linter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_new_linter(self):
2828
self.assertEqual(1, len(linter.config.objects))
2929
config_obj = linter.config.objects[0]
3030
self.assertIsInstance(config_obj.plugins, dict)
31-
self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj.plugins.keys()))
31+
self.assertIn(CORE_PLUGIN_NAME, config_obj.plugins)
3232
self.assertEqual(None, config_obj.rules)
3333

3434
def test_new_linter_recommended(self):
@@ -38,7 +38,7 @@ def test_new_linter_recommended(self):
3838
config_obj_0 = linter.config.objects[0]
3939
config_obj_1 = linter.config.objects[1]
4040
self.assertIsInstance(config_obj_0.plugins, dict)
41-
self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj_0.plugins.keys()))
41+
self.assertIn(CORE_PLUGIN_NAME, config_obj_0.plugins)
4242
self.assertIsInstance(config_obj_1.rules, dict)
4343
self.assertIn("coords-for-dims", config_obj_1.rules)
4444

@@ -49,7 +49,7 @@ def test_new_linter_all(self):
4949
config_obj_0 = linter.config.objects[0]
5050
config_obj_1 = linter.config.objects[1]
5151
self.assertIsInstance(config_obj_0.plugins, dict)
52-
self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj_0.plugins.keys()))
52+
self.assertIn(CORE_PLUGIN_NAME, config_obj_0.plugins)
5353
self.assertIsInstance(config_obj_1.rules, dict)
5454
self.assertIn("coords-for-dims", config_obj_1.rules)
5555

xrlint/cli/engine.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
DEFAULT_OUTPUT_FORMAT,
2323
INIT_CONFIG_YAML,
2424
)
25-
from xrlint.config import Config, ConfigLike, ConfigObject, get_core_config_object
25+
from xrlint.config import Config, ConfigLike, ConfigObject, get_entry_point_plugins
2626
from xrlint.formatter import FormatterContext
2727
from xrlint.formatters import export_formatters
2828
from xrlint.linter import Linter
@@ -117,7 +117,7 @@ def init_config(self, *extra_configs: ConfigLike) -> None:
117117
if file_config is None:
118118
click.echo("Warning: no configuration file found.")
119119

120-
core_config_obj = get_core_config_object()
120+
core_config_obj = get_entry_point_plugins()
121121
core_config_obj.plugins.update(plugins)
122122

123123
base_configs = []

xrlint/config.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,37 @@ def get_core_plugin() -> "Plugin":
5151
return export_plugin()
5252

5353

54-
def get_core_config_object() -> "ConfigObject":
55-
"""Create a configuration object that includes the core plugin.
54+
def plugins_from_entry_points() -> dict[str, "Plugin"]:
55+
"""Load plugins from entry points.
56+
57+
Returns:
58+
A dictionary mapping plugin names to plugin instances.
59+
"""
60+
from importlib.metadata import entry_points
61+
62+
plugins = {}
63+
64+
for ep in entry_points(group="xrlint.rules"):
65+
try:
66+
plugin_module = ep.load()
67+
plugin = plugin_module.export_plugin()
68+
plugins[plugin.meta.name] = plugin
69+
except Exception as e:
70+
breakpoint()
71+
raise ValueError(
72+
f"failed to load xrlint plugin from entry point {ep.name!r}: {e}"
73+
) from e
74+
75+
return plugins
76+
77+
78+
def get_entry_point_plugins() -> "ConfigObject":
79+
"""Create a configuration object that includes the plugins loaded from entry points.
5680
5781
Returns:
5882
A new `Config` object
5983
"""
60-
return ConfigObject(plugins={CORE_PLUGIN_NAME: get_core_plugin()})
84+
return ConfigObject(plugins=plugins_from_entry_points())
6185

6286

6387
def split_config_spec(config_spec: str) -> tuple[str, str]:
@@ -379,7 +403,7 @@ def from_config(
379403
new_objects = None
380404
if isinstance(config_like, str):
381405
if CORE_PLUGIN_NAME not in plugins:
382-
plugins.update({CORE_PLUGIN_NAME: get_core_plugin()})
406+
plugins.update(plugins_from_entry_points())
383407
new_objects = cls._get_named_config(config_like, plugins).objects
384408
elif isinstance(config_like, Config):
385409
new_objects = config_like.objects

xrlint/linter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import xarray as xr
1010

11-
from xrlint.config import Config, ConfigLike, get_core_config_object
11+
from xrlint.config import Config, ConfigLike, get_entry_point_plugins
1212
from xrlint.result import Result
1313

1414
from ._linter.validate import new_fatal_message, validate_dataset
@@ -30,7 +30,7 @@ def new_linter(*configs: ConfigLike, **config_props: Any) -> "Linter":
3030
Returns:
3131
A new linter instance
3232
"""
33-
return Linter(get_core_config_object(), *configs, **config_props)
33+
return Linter(get_entry_point_plugins(), *configs, **config_props)
3434

3535

3636
class Linter:

0 commit comments

Comments
 (0)