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/mkruleref.py b/docs/mkruleref.py similarity index 77% rename from mkruleref.py rename to docs/mkruleref.py index 54a6d02..40bc3db 100644 --- a/mkruleref.py +++ b/docs/mkruleref.py @@ -4,6 +4,7 @@ from xrlint.plugin import Plugin from xrlint.rule import RuleConfig +from xrlint.config import plugins_from_entry_points # for icons, see # https://squidfunk.github.io/mkdocs-material/reference/icons-emojis/ @@ -25,26 +26,27 @@ def write_rule_ref_page(): - import xrlint.plugins.core - import xrlint.plugins.xcube - import xrlint.plugins.acdd - - core = xrlint.plugins.core.export_plugin() - xcube = xrlint.plugins.xcube.export_plugin() - acdd = xrlint.plugins.acdd.export_plugin() - with open("docs/rule-ref.md", "w") as stream: + import mkdocs_gen_files + + plugins = plugins_from_entry_points() + + print(f"Generating rule reference for discovered plugins: {list(plugins.keys())}") + + with mkdocs_gen_files.open("rule-ref.md", "w") as stream: stream.write("# Rule Reference\n\n") stream.write( "This page is auto-generated from XRLint's builtin" - " rules (`python -m mkruleref`).\n" + " rules.\n" "New rules will be added by upcoming XRLint releases.\n\n" ) - stream.write("## Core Rules\n\n") - write_plugin_rules(stream, core) - stream.write("## xcube Rules\n\n") - write_plugin_rules(stream, xcube) - stream.write("## ACDD Rules\n\n") - write_plugin_rules(stream, acdd) + for plugin_name in sorted(plugins.keys()): + plugin = plugins[plugin_name] + stream.write(f"## {plugin.meta.name} Rules\n\n") + if plugin.meta.ref: + stream.write(f"- `{plugin.meta.ref.removesuffix(':export_plugin')}`\n") + if plugin.meta.docs_url: + stream.write(f"- [Documentation]({plugin.meta.docs_url})\n\n") + write_plugin_rules(stream, plugin) def write_plugin_rules(stream, plugin: Plugin): @@ -86,5 +88,4 @@ def get_plugin_rule_configs(plugin: Plugin) -> dict[str, dict[str, RuleConfig]]: return config_rules -if __name__ == "__main__": - write_rule_ref_page() +write_rule_ref_page() diff --git a/docs/rule-ref.md b/docs/rule-ref.md deleted file mode 100644 index 1f29c91..0000000 --- a/docs/rule-ref.md +++ /dev/null @@ -1,273 +0,0 @@ -# Rule Reference - -This page is auto-generated from XRLint's builtin rules (`python -m mkruleref`). -New rules will be added by upcoming XRLint releases. - -## Core Rules - -### :material-bug: `access-latency` - -Ensure that the time it takes to open a dataset from its source does a exceed a given `threshold` in seconds. The default threshold is `2.5`. - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-lightbulb: `content-desc` - -A dataset should provide information about where the data came from and what has been done to it. This information is mainly for the benefit of human readers. The rule accepts the following configuration parameters: - -- `globals`: list of names of required global attributes. Defaults to `['title', 'history']`. -- `commons`: list of names of required variable attributes that can also be defined globally. Defaults to `['institution', 'source', 'references', 'comment']`. -- `no_vars`: do not check variables at all. Defaults to `False`. -- `ignored_vars`: list of ignored variables (regex patterns). Defaults to `['crs', 'spatial_ref']`. - -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#description-of-file-contents) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-lightbulb: `conventions` - -Datasets should identify the applicable conventions using the `Conventions` attribute. - The rule has an optional configuration parameter `match` which is a regex pattern that the value of the `Conventions` attribute must match, if any. If not provided, the rule just verifies that the attribute exists and whether it is a character string. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#identification-of-conventions) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-bug: `coords-for-dims` - -Dimensions of data variables should have corresponding coordinates. - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `grid-mappings` - -Grid mappings, if any, shall have valid grid mapping coordinate variables. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#grid-mappings-and-projections) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `lat-coordinate` - -Latitude coordinate should have standard units and standard names. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#latitude-coordinate) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `lon-coordinate` - -Longitude coordinate should have standard units and standard names. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#longitude-coordinate) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-lightbulb: `no-empty-attrs` - -Every dataset element should have metadata that describes it. - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-lightbulb: `no-empty-chunks` - -Empty chunks should not be encoded and written. The rule currently applies to Zarr format only. -[More...](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.to_zarr.html#xarray-dataset-to-zarr) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-circle-off-outline: - -### :material-bug: `time-coordinate` - -Time coordinates should have valid and unambiguous time units encoding. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#time-coordinate) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-lightbulb: `var-desc` - -Check that each data variable provides an identification and description of the content. The rule can be configured by parameter `attrs` which is a list of names of attributes that provides descriptive information. It defaults to `['standard_name', 'long_name']`. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#standard-name) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-lightbulb: `var-flags` - -Validate attributes 'flag_values', 'flag_masks' and 'flag_meanings' that make variables that contain flag values self describing. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#flags) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-lightbulb: `var-missing-data` - -Checks the recommended use of missing data, i.e., coordinate variables should not define missing data, but packed data should. Notifies about the use of valid ranges to indicate missing data, which is currently not supported by xarray. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#units) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-lightbulb: `var-units` - -Every variable should provide a description of its units. -[More...](https://cfconventions.org/cf-conventions/cf-conventions.html#units) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -## xcube Rules - -### :material-bug: `any-spatial-data-var` - -A datacube should have spatial data variables. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `cube-dims-order` - -Order of dimensions in spatio-temporal datacube variables should be [time, ..., y, x]. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-lightbulb: `data-var-colors` - -Spatial data variables should encode xcube color mappings in their metadata. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#encoding-of-colors) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-bug: `dataset-title` - -Datasets should be given a non-empty title. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#metadata) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-lightbulb: `grid-mapping-naming` - -Grid mapping variables should be called 'spatial_ref' or 'crs' for compatibility with rioxarray and other packages. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-bug: `increasing-time` - -Time coordinate labels should be monotonically increasing. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#temporal-reference) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `lat-lon-naming` - -Latitude and longitude coordinates and dimensions should be called 'lat' and 'lon'. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-lightbulb: `ml-dataset-meta` - -Multi-level datasets should provide a '.zlevels' meta-info file, and if so, it should be consistent. Without the meta-info file the multi-level dataset cannot be reliably extended by new time slices as the aggregation method used for each variable must be specified. -[More...](https://xcube.readthedocs.io/en/latest/mldatasets.html#the-xcube-levels-format) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `ml-dataset-time` - -The `time` dimension of multi-level datasets should use a chunk size of 1. This allows for faster image tile generation for visualisation. -[More...](https://xcube.readthedocs.io/en/latest/mldatasets.html#definition) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-bug: `ml-dataset-xy` - -Multi-level dataset levels should provide spatial resolutions decreasing by powers of two. -[More...](https://xcube.readthedocs.io/en/latest/mldatasets.html#definition) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `no-chunked-coords` - -Coordinate variables should not be chunked. Can be used to identify performance issues, where chunked coordinates can cause slow opening if datasets due to the many chunk-fetching requests made to (remote) filesystems with low bandwidth. You can use the `limit` parameter to specify an acceptable number of chunks. Its default is 5. - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert: - -### :material-bug: `single-grid-mapping` - -A single grid mapping shall be used for all spatial data variables of a datacube. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -### :material-bug: `time-naming` - -Time coordinate and dimension should be called 'time'. -[More...](https://xcube.readthedocs.io/en/latest/cubespec.html#temporal-reference) - -Contained in: `all`-:material-lightning-bolt: `recommended`-:material-lightning-bolt: - -## ACDD Rules - -### :material-bug: `1.0-attrs-highly-recommended` - -Global attributes that are highly reccomended by ACDD-1.0. -[More...](https://wiki.esipfed.org/Category:Attribute_Conventions_Dataset_Discovery) - -Contained in: `acdd_1.0`-:material-lightning-bolt: - -### :material-bug: `1.0-attrs-recommended` - -Global attributes that are recommended by ACDD-1.0. -[More...](https://wiki.esipfed.org/Category:Attribute_Conventions_Dataset_Discovery) - -Contained in: `acdd_1.0`-:material-alert: - -### :material-bug: `1.0-attrs-suggested` - -Global attributes that are suggested by ACDD-1.0. -[More...](https://wiki.esipfed.org/Category:Attribute_Conventions_Dataset_Discovery) - -Contained in: `acdd_1.0`-:material-alert: - -### :material-bug: `1.3-attrs-highly-recommended` - -Global attributes that are highly recommended by ACDD-1.3. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-lightning-bolt: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-lightning-bolt: `acdd_1.3_warn`-:material-alert: `recommended`-:material-lightning-bolt: - -### :material-bug: `1.3-attrs-recommended` - -Global attributes that are recommended by ACDD-1.3. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-alert: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-lightning-bolt: `acdd_1.3_warn`-:material-alert: `recommended`-:material-alert: - -### :material-bug: `1.3-attrs-suggested` - -Global attributes that are suggested by ACDD 1.3. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-alert: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-alert: `acdd_1.3_warn`-:material-alert: `recommended`-:material-alert: - -### :material-bug: `1.3-conventions` - -The `Conventions` global attribute should include `ACDD-1.3`. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-lightning-bolt: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-lightning-bolt: `acdd_1.3_warn`-:material-alert: `recommended`-:material-lightning-bolt: - -### :material-bug: `1.3-dates-iso-format` - -ACDD date attributes must be in ISO format. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-alert: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-alert: `acdd_1.3_warn`-:material-alert: `recommended`-:material-alert: - -### :material-bug: `1.3-metadata-link` - -The `metadata` attribute should be a URL. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-alert: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-alert: `acdd_1.3_warn`-:material-alert: `recommended`-:material-alert: - -### :material-bug: `1.3-no-blanks-in-id` - -The `id` attribute should not contain blanks. -[More...](https://wiki.esipfed.org/Attribute_Convention_for_Data_Discovery_1-3) - -Contained in: `acdd_1.3`-:material-alert: `acdd_1.3_strict`-:material-lightning-bolt: `acdd_1.3_strict_reccomended`-:material-alert: `acdd_1.3_warn`-:material-alert: `recommended`-:material-alert: - 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 353a9c6..c824b6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,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"} @@ -87,7 +92,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..6ee4359 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,11 +4,17 @@ from typing import Any from unittest import TestCase +from unittest.mock import Mock, patch 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, + plugins_from_entry_points, +) from xrlint.constants import CORE_PLUGIN_NAME from xrlint.plugin import Plugin, new_plugin from xrlint.processor import ProcessorOp, define_processor @@ -35,21 +41,58 @@ 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) with pytest.raises(ValueError, match="unknown rule 'foo'"): config_obj.get_rule("foo") + def test_plugins_from_entry_points_load_failure(self): + """Test that plugins_from_entry_points raises ValueError when entry point fails to load.""" + # Create a mock entry point that raises an exception when loaded + mock_entry_point = Mock() + mock_entry_point.name = "failing-plugin" + mock_entry_point.load.side_effect = ImportError("Module not found") + + # Mock the entry_points function to return our failing entry point + with patch("importlib.metadata.entry_points") as mock_entry_points: + mock_entry_points.return_value = [mock_entry_point] + + # Verify it raises the expected ValueError with the proper message + with pytest.raises( + ValueError, + match=r"failed to load xrlint plugin from entry point 'failing-plugin'", + ): + plugins_from_entry_points() + + def test_plugins_from_entry_points_export_failure(self): + """Test when the plugin module loads but export_plugin() fails.""" + # Create a mock module that loads successfully but export_plugin() fails + mock_module = Mock() + mock_module.export_plugin.side_effect = AttributeError("missing attribute") + + mock_entry_point = Mock() + mock_entry_point.name = "bad-export-plugin" + mock_entry_point.load.return_value = mock_module + + with patch("importlib.metadata.entry_points") as mock_entry_points: + mock_entry_points.return_value = [mock_entry_point] + + with pytest.raises( + ValueError, + match=r"failed to load xrlint plugin from entry point 'bad-export-plugin'", + ): + plugins_from_entry_points() + def test_get_processor_op(self): class MyProc(ProcessorOp): def preprocess( @@ -195,7 +238,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..c85276a 100644 --- a/xrlint/config.py +++ b/xrlint/config.py @@ -44,20 +44,36 @@ """ -def get_core_plugin() -> "Plugin": - """Get the fully imported, populated core plugin.""" - from xrlint.plugins.core import export_plugin +def plugins_from_entry_points() -> dict[str, "Plugin"]: + """Load plugins from entry points. - return export_plugin() + 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: + raise ValueError( + f"failed to load xrlint plugin from entry point {ep.name!r}: {e}" + ) from e + + return plugins -def get_core_config_object() -> "ConfigObject": - """Create a configuration object that includes the core plugin. +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 +395,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: