Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,15 @@ The rule naming conventions for XRLint are based ESLint:
in a dedicated module under `tests`, i.e., `tests/rules/test_<rule>`.
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"
```
7 changes: 1 addition & 6 deletions docs/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

13 changes: 11 additions & 2 deletions docs/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dataset>` 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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -85,7 +90,8 @@ doc = [
"mkdocs-autorefs",
"mkdocs-material",
"mkdocstrings",
"mkdocstrings-python"
"mkdocstrings-python",
"mkdocs-gen-files",
]

[project.urls]
Expand Down
4 changes: 3 additions & 1 deletion tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 6 additions & 6 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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}},
)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)

Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions xrlint/cli/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down
32 changes: 28 additions & 4 deletions xrlint/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions xrlint/linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down