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/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..021500b 100644 --- a/docs/start.md +++ b/docs/start.md @@ -64,6 +64,14 @@ rule configurations: grid-mappings: error ``` +!!! 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 @@ -77,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 daca338..a645c71 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"} @@ -85,7 +90,8 @@ doc = [ "mkdocs-autorefs", "mkdocs-material", "mkdocstrings", - "mkdocstrings-python" + "mkdocstrings-python", + "mkdocs-gen-files", ] [project.urls] 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: