From 18dc302a4d5465a055d150820292fd3bbbf8e769 Mon Sep 17 00:00:00 2001 From: Alex Kerney Date: Mon, 29 Dec 2025 10:57:19 -0500 Subject: [PATCH 1/2] 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 --- docs/index.md | 3 ++- docs/start.md | 6 ++++++ pyproject.toml | 5 +++++ tests/cli/test_main.py | 4 +++- tests/test_config.py | 12 ++++++------ tests/test_linter.py | 6 +++--- xrlint/cli/engine.py | 4 ++-- xrlint/config.py | 32 ++++++++++++++++++++++++++++---- xrlint/linter.py | 4 ++-- 9 files changed, 57 insertions(+), 19 deletions(-) diff --git a/docs/index.md b/docs/index.md index 389cb8b..4cc3cd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,5 +32,6 @@ The following plugins provide XRLint's [inbuilt rules](rule-ref.md): - `xcube`: implementing the rules for [xcube datasets](https://xcube.readthedocs.io/en/latest/cubespec.html). Note, this plugin is fully optional. You must manually configure - it to apply its rules. It may be moved into a separate GitHub repo later. + it to apply its rules. It may be moved into a separate GitHub repo later. +- `acdd`: implements rules for [Attribute Convention for Data Discovery](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3). diff --git a/docs/start.md b/docs/start.md index 3156ed4..6f3b2b0 100644 --- a/docs/start.md +++ b/docs/start.md @@ -66,6 +66,12 @@ rule configurations: You can add rules from plugins as well: +!!! note inline end "Built in and auto-loading plugins" + + 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. + + Run `xrlint --print-config ` to view the loaded plugins and configured rules. + ```yaml - recommended - plugins: diff --git a/pyproject.toml b/pyproject.toml index daca338..1b412cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,11 @@ classifiers = [ "Operating System :: MacOS", ] +[project.entry-points."xrlint.rules"] +core = "xrlint.plugins.core" +xcube = "xrlint.plugins.xcube" +acdd = "xrlint.plugins.acdd" + [tool.setuptools.dynamic] version = {attr = "xrlint.__version__"} readme = {file = "README.md", content-type = "text/markdown"} diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 7db324e..814de68 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -230,7 +230,9 @@ def test_print_config_option(self): ( "{\n" ' "plugins": {\n' - ' "__core__": "xrlint.plugins.core:export_plugin"\n' + ' "acdd": "xrlint.plugins.acdd:export_plugin",\n' + ' "__core__": "xrlint.plugins.core:export_plugin",\n' + ' "xcube": "xrlint.plugins.xcube:export_plugin"\n' " },\n" ' "rules": {\n' ' "var-units": 2\n' diff --git a/tests/test_config.py b/tests/test_config.py index 2213947..361ac50 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ import pytest import xarray as xr -from xrlint.config import Config, ConfigObject, get_core_config_object +from xrlint.config import Config, ConfigObject, get_entry_point_plugins from xrlint.constants import CORE_PLUGIN_NAME from xrlint.plugin import Plugin, new_plugin from xrlint.processor import ProcessorOp, define_processor @@ -35,15 +35,15 @@ def test_defaults(self): self.assertEqual(None, config_obj.rules) def test_get_plugin(self): - config_obj = get_core_config_object() + config_obj = get_entry_point_plugins() plugin = config_obj.get_plugin(CORE_PLUGIN_NAME) self.assertIsInstance(plugin, Plugin) - with pytest.raises(ValueError, match="unknown plugin 'xcube'"): - config_obj.get_plugin("xcube") + with pytest.raises(ValueError, match="unknown plugin 'does-not-exist'"): + config_obj.get_plugin("does-not-exist") def test_get_rule(self): - config_obj = get_core_config_object() + config_obj = get_entry_point_plugins() rule = config_obj.get_rule("var-flags") self.assertIsInstance(rule, Rule) @@ -195,7 +195,7 @@ def test_from_config_ok(self): config = Config.from_config( {"ignores": ["**/*.levels"]}, - get_core_config_object(), + get_entry_point_plugins(), "recommended", {"rules": {"no-empty-chunks": 2}}, ) diff --git a/tests/test_linter.py b/tests/test_linter.py index c8d650c..b63d3db 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -28,7 +28,7 @@ def test_new_linter(self): self.assertEqual(1, len(linter.config.objects)) config_obj = linter.config.objects[0] self.assertIsInstance(config_obj.plugins, dict) - self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj.plugins.keys())) + self.assertIn(CORE_PLUGIN_NAME, config_obj.plugins) self.assertEqual(None, config_obj.rules) def test_new_linter_recommended(self): @@ -38,7 +38,7 @@ def test_new_linter_recommended(self): config_obj_0 = linter.config.objects[0] config_obj_1 = linter.config.objects[1] self.assertIsInstance(config_obj_0.plugins, dict) - self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj_0.plugins.keys())) + self.assertIn(CORE_PLUGIN_NAME, config_obj_0.plugins) self.assertIsInstance(config_obj_1.rules, dict) self.assertIn("coords-for-dims", config_obj_1.rules) @@ -49,7 +49,7 @@ def test_new_linter_all(self): config_obj_0 = linter.config.objects[0] config_obj_1 = linter.config.objects[1] self.assertIsInstance(config_obj_0.plugins, dict) - self.assertEqual({CORE_PLUGIN_NAME}, set(config_obj_0.plugins.keys())) + self.assertIn(CORE_PLUGIN_NAME, config_obj_0.plugins) self.assertIsInstance(config_obj_1.rules, dict) self.assertIn("coords-for-dims", config_obj_1.rules) diff --git a/xrlint/cli/engine.py b/xrlint/cli/engine.py index 43c6b36..63da2a8 100644 --- a/xrlint/cli/engine.py +++ b/xrlint/cli/engine.py @@ -22,7 +22,7 @@ DEFAULT_OUTPUT_FORMAT, INIT_CONFIG_YAML, ) -from xrlint.config import Config, ConfigLike, ConfigObject, get_core_config_object +from xrlint.config import Config, ConfigLike, ConfigObject, get_entry_point_plugins from xrlint.formatter import FormatterContext from xrlint.formatters import export_formatters from xrlint.linter import Linter @@ -117,7 +117,7 @@ def init_config(self, *extra_configs: ConfigLike) -> None: if file_config is None: click.echo("Warning: no configuration file found.") - core_config_obj = get_core_config_object() + core_config_obj = get_entry_point_plugins() core_config_obj.plugins.update(plugins) base_configs = [] diff --git a/xrlint/config.py b/xrlint/config.py index 014fd7c..0a5361c 100644 --- a/xrlint/config.py +++ b/xrlint/config.py @@ -51,13 +51,37 @@ def get_core_plugin() -> "Plugin": return export_plugin() -def get_core_config_object() -> "ConfigObject": - """Create a configuration object that includes the core plugin. +def plugins_from_entry_points() -> dict[str, "Plugin"]: + """Load plugins from entry points. + + Returns: + A dictionary mapping plugin names to plugin instances. + """ + from importlib.metadata import entry_points + + plugins = {} + + for ep in entry_points(group="xrlint.rules"): + try: + plugin_module = ep.load() + plugin = plugin_module.export_plugin() + plugins[plugin.meta.name] = plugin + except Exception as e: + breakpoint() + raise ValueError( + f"failed to load xrlint plugin from entry point {ep.name!r}: {e}" + ) from e + + return plugins + + +def get_entry_point_plugins() -> "ConfigObject": + """Create a configuration object that includes the plugins loaded from entry points. Returns: A new `Config` object """ - return ConfigObject(plugins={CORE_PLUGIN_NAME: get_core_plugin()}) + return ConfigObject(plugins=plugins_from_entry_points()) def split_config_spec(config_spec: str) -> tuple[str, str]: @@ -379,7 +403,7 @@ def from_config( new_objects = None if isinstance(config_like, str): if CORE_PLUGIN_NAME not in plugins: - plugins.update({CORE_PLUGIN_NAME: get_core_plugin()}) + plugins.update(plugins_from_entry_points()) new_objects = cls._get_named_config(config_like, plugins).objects elif isinstance(config_like, Config): new_objects = config_like.objects diff --git a/xrlint/linter.py b/xrlint/linter.py index b7422b7..7850c52 100644 --- a/xrlint/linter.py +++ b/xrlint/linter.py @@ -8,7 +8,7 @@ import xarray as xr -from xrlint.config import Config, ConfigLike, get_core_config_object +from xrlint.config import Config, ConfigLike, get_entry_point_plugins from xrlint.result import Result from ._linter.validate import new_fatal_message, validate_dataset @@ -30,7 +30,7 @@ def new_linter(*configs: ConfigLike, **config_props: Any) -> "Linter": Returns: A new linter instance """ - return Linter(get_core_config_object(), *configs, **config_props) + return Linter(get_entry_point_plugins(), *configs, **config_props) class Linter: From f1d015756f9a7e8eaca85309ee02ecbc998693fa Mon Sep 17 00:00:00 2001 From: Alex Kerney Date: Mon, 29 Dec 2025 11:51:40 -0500 Subject: [PATCH 2/2] Automatically update rule docs --- CONTRIBUTING.md | 12 ++++++++++++ docs/about.md | 7 +------ docs/start.md | 11 +++++++---- mkdocs.yml | 7 +++++++ pyproject.toml | 3 ++- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6000be7..cceac9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,3 +85,15 @@ The rule naming conventions for XRLint are based ESLint: in a dedicated module under `tests`, i.e., `tests/rules/test_`. Consider using `xrlint.testing.RuleTester` which can save a lot of time and is used for almost all in-built rules. + +## Contributing an XRLint Plugin + +New plugins should be added to the `xrlint.rules` entry point table, which will cause them to be automatically loaded by XRLint, and to be included in the rule documentation. + +```toml +# pyproject.toml +[project.entry-points."xrlint.rules"] +core = "xrlint.plugins.core" +xcube = "xrlint.plugins.xcube" +acdd = "xrlint.plugins.acdd" +``` \ No newline at end of file diff --git a/docs/about.md b/docs/about.md index ce83e5e..c18c9e4 100644 --- a/docs/about.md +++ b/docs/about.md @@ -72,12 +72,7 @@ mkdocs serve mkdocs gh-deploy ``` -The rule reference page is generated by a script called `mkruleref.py`. -After changing or adding a rule, make sure you recreate the page: - -```bash -python -m mkruleref -``` +The rule reference page is generated by a script called `docs/mkruleref.py` which is called by mkdocs during build. ## License diff --git a/docs/start.md b/docs/start.md index 6f3b2b0..021500b 100644 --- a/docs/start.md +++ b/docs/start.md @@ -64,14 +64,16 @@ rule configurations: grid-mappings: error ``` -You can add rules from plugins as well: - !!! note inline end "Built in and auto-loading plugins" 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. Run `xrlint --print-config ` to view the loaded plugins and configured rules. + Custom plugins, or those that are not loadable via entry points will need to be explcitly loaded via the plugins object. + +You can add rules from plugins as well: + ```yaml - recommended - plugins: @@ -83,8 +85,9 @@ And customize its rules, if desired: ```yaml - recommended -- plugins: - xcube: xrlint.plugins.xcube +# Explicit loading of included plugins is unneeded, see note +# - plugins: +# xcube: xrlint.plugins.xcube - xcube/recommended - rules: xcube/grid-mapping-naming: off diff --git a/mkdocs.yml b/mkdocs.yml index f930310..ec13320 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,8 +48,15 @@ extra: - icon: fontawesome/brands/python link: https://pypi.org/project/xrlint/ +watch: + - xrlint + plugins: - search + # Build rule reference page + - gen-files: + scripts: + - docs/mkruleref.py - autorefs - mkdocstrings: handlers: diff --git a/pyproject.toml b/pyproject.toml index 1b412cb..a645c71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,8 @@ doc = [ "mkdocs-autorefs", "mkdocs-material", "mkdocstrings", - "mkdocstrings-python" + "mkdocstrings-python", + "mkdocs-gen-files", ] [project.urls]